11 Commits

Author SHA1 Message Date
Salad Dais
220a02543e v0.11.1 2022-07-20 20:38:17 +00:00
Salad Dais
8ac47c2397 Fix use of dynamically imported globals in REPL 2022-07-20 20:30:41 +00:00
Salad Dais
d384978322 UpdateType -> ObjectUpdateType 2022-07-20 20:26:50 +00:00
Salad Dais
f02a479834 Add get_task_inventory_cap.py addon example
An example of mocking out actually useful behavior for the viewer.
Better (faster!) task inventory fetching API.
2022-07-20 09:20:27 +00:00
Salad Dais
b5e8b36173 Add more enum and flag defs to templates.py 2022-07-20 06:35:04 +00:00
Salad Dais
08a39f4df7 Make object update handling more robust 2022-07-20 06:35:04 +00:00
Salad Dais
61ec51beec Add demo autoattacher addon example 2022-07-19 23:48:40 +00:00
Salad Dais
9adbdcdcc8 Add a couple more flag definitions to templates.py 2022-07-19 09:49:43 +00:00
Salad Dais
e7b05f72ca Dequantize TimeDilation message var 2022-07-19 05:57:19 +00:00
Salad Dais
75f2f363a4 Handle TE glow field quantization 2022-07-18 22:29:37 +00:00
Salad Dais
cc1bb9ac1d Give MediaFlags and BasicMaterials sensible default values 2022-07-18 22:08:06 +00:00
13 changed files with 621 additions and 50 deletions

View File

@@ -340,6 +340,15 @@ It can be launched at any time by typing `/524 spawn_repl` in chat.
![Screenshot of REPL](https://github.com/SaladDais/Hippolyzer/blob/master/static/repl_screenshot.png?raw=true)
The REPL is fully async aware and allows awaiting events without blocking:
```python
>>> from hippolyzer.lib.client.object_manager import ObjectUpdateType
>>> evt = await session.objects.events.wait_for((ObjectUpdateType.OBJECT_UPDATE,), timeout=2.0)
>>> evt.updated
{'Position'}
```
## Potential Changes
* AISv3 wrapper?

View File

@@ -0,0 +1,158 @@
"""
Detect receipt of a marketplace order for a demo, and auto-attach the most appropriate object
"""
import asyncio
import re
from typing import List, Tuple, Dict, Optional, Sequence
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.message.message import Message, Block
from hippolyzer.lib.base.templates import InventoryType, Permissions, FolderType
from hippolyzer.lib.proxy.addon_utils import BaseAddon, show_message
from hippolyzer.lib.proxy.region import ProxiedRegion
from hippolyzer.lib.proxy.sessions import Session
MARKETPLACE_TRANSACTION_ID = UUID('ffffffff-ffff-ffff-ffff-ffffffffffff')
class DemoAutoAttacher(BaseAddon):
def handle_eq_event(self, session: Session, region: ProxiedRegion, event: dict):
if event["message"] != "BulkUpdateInventory":
return
# Check that this update even possibly came from the marketplace
if event["body"]["AgentData"][0]["TransactionID"] != MARKETPLACE_TRANSACTION_ID:
return
# Make sure that the transaction targeted our real received items folder
folders = event["body"]["FolderData"]
received_folder = folders[0]
if received_folder["Name"] != "Received Items":
return
skel = session.login_data['inventory-skeleton']
actual_received = [x for x in skel if x['type_default'] == FolderType.INBOX]
assert actual_received
if UUID(actual_received[0]['folder_id']) != received_folder["FolderID"]:
show_message(f"Strange received folder ID spoofing? {folders!r}")
return
if not re.match(r".*\bdemo\b.*", folders[1]["Name"], flags=re.I):
return
# Alright, so we have a demo... thing from the marketplace. What now?
items = event["body"]["ItemData"]
object_items = [x for x in items if x["InvType"] == InventoryType.OBJECT]
if not object_items:
return
self._schedule_task(self._attach_best_object(session, region, object_items))
async def _attach_best_object(self, session: Session, region: ProxiedRegion, object_items: List[Dict]):
own_body_type = await self._guess_own_body(session, region)
show_message(f"Trying to find demo for {own_body_type}")
guess_patterns = self.BODY_CLOTHING_PATTERNS.get(own_body_type)
to_attach = []
if own_body_type and guess_patterns:
matching_items = self._get_matching_items(object_items, guess_patterns)
if matching_items:
# Only take the first one
to_attach.append(matching_items[0])
if not to_attach:
# Don't know what body's being used or couldn't figure out what item
# would work best with our body. Just attach the first object in the folder.
to_attach.append(object_items[0])
# Also attach whatever HUDs, maybe we need them.
for hud in self._get_matching_items(object_items, ("hud",)):
if hud not in to_attach:
to_attach.append(hud)
region.circuit.send(Message(
'RezMultipleAttachmentsFromInv',
Block('AgentData', AgentID=session.agent_id, SessionID=session.id),
Block('HeaderData', CompoundMsgID=UUID.random(), TotalObjects=len(to_attach), FirstDetachAll=0),
*[Block(
'ObjectData',
ItemID=o["ItemID"],
OwnerID=session.agent_id,
# 128 = "add", uses whatever attachmentpt was defined on the object
AttachmentPt=128,
ItemFlags_=(),
GroupMask_=(),
EveryoneMask_=(),
NextOwnerMask_=(Permissions.COPY | Permissions.MOVE),
Name=o["Name"],
Description=o["Description"],
) for o in to_attach]
))
def _get_matching_items(self, items: List[dict], patterns: Sequence[str]):
# Loop over patterns to search for our body type, in order of preference
matched = []
for guess_pattern in patterns:
# Check each item for that pattern
for item in items:
if re.match(rf".*\b{guess_pattern}\b.*", item["Name"], re.I):
matched.append(item)
return matched
# We scan the agent's attached objects to guess what kind of body they use
BODY_PREFIXES = {
"-Belleza- Jake ": "jake",
"-Belleza- Freya ": "freya",
"-Belleza- Isis ": "isis",
"-Belleza- Venus ": "venus",
"[Signature] Gianni Body": "gianni",
"[Signature] Geralt Body": "geralt",
"Maitreya Mesh Body - Lara": "maitreya",
"Slink Physique Hourglass Petite": "hg_petite",
"Slink Physique Mesh Body Hourglass": "hourglass",
"Slink Physique Original Petite": "phys_petite",
"Slink Physique Mesh Body Original": "physique",
"[BODY] Legacy (f)": "legacy_f",
"[BODY] Legacy (m)": "legacy_m",
"[Signature] Alice Body": "sig_alice",
"Slink Physique MALE Mesh Body": "slink_male",
"AESTHETIC - [Mesh Body]": "aesthetic",
}
# Different bodies' clothes have different naming conventions according to different merchants.
# These are common naming patterns we use to choose objects to attach, in order of preference.
BODY_CLOTHING_PATTERNS: Dict[str, Tuple[str, ...]] = {
"jake": ("jake", "belleza"),
"freya": ("freya", "belleza"),
"isis": ("isis", "belleza"),
"venus": ("venus", "belleza"),
"gianni": ("gianni", "signature", "sig"),
"geralt": ("geralt", "signature", "sig"),
"hg_petite": ("hourglass petite", "hg petite", "hourglass", "hg", "slink"),
"hourglass": ("hourglass", "hg", "slink"),
"phys_petite": ("physique petite", "phys petite", "physique", "phys", "slink"),
"physique": ("physique", "phys", "slink"),
"legacy_f": ("legacy",),
"legacy_m": ("legacy",),
"sig_alice": ("alice", "signature"),
"slink_male": ("physique", "slink"),
"aesthetic": ("aesthetic",),
}
async def _guess_own_body(self, session: Session, region: ProxiedRegion) -> Optional[str]:
agent_obj = region.objects.lookup_fullid(session.agent_id)
if not agent_obj:
return None
# We probably won't know the names for all of our attachments, request them.
# Could be obviated by looking at the COF, not worth it for this.
try:
await asyncio.wait(region.objects.request_object_properties(agent_obj.Children), timeout=0.5)
except asyncio.TimeoutError:
# We expect that we just won't ever receive some property requests, that's fine
pass
for prefix, body_type in self.BODY_PREFIXES.items():
for obj in agent_obj.Children:
if not obj.Name:
continue
if obj.Name.startswith(prefix):
return body_type
return None
addons = [DemoAutoAttacher()]

View File

@@ -0,0 +1,100 @@
"""
Loading task inventory doesn't actually need to be slow.
By using a cap instead of the slow xfer path and sending the LLSD inventory
model we get 15x speedups even when mocking things behind the scenes by using
a hacked up version of xfer. See turbo_object_inventory.py
"""
import asyncio
import asgiref.wsgi
from typing import *
from flask import Flask, Response, request
from hippolyzer.lib.base import llsd
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.inventory import InventoryModel
from hippolyzer.lib.base.message.message import Message, Block
from hippolyzer.lib.base.templates import XferFilePath
from hippolyzer.lib.proxy import addon_ctx
from hippolyzer.lib.proxy.webapp_cap_addon import WebAppCapAddon
app = Flask("GetTaskInventoryCapApp")
@app.route('/', methods=["POST"])
async def get_task_inventory():
# Should always have the current region, the cap handler is bound to one.
# Just need to pull it from the `addon_ctx` module's global.
region = addon_ctx.region.get()
session = addon_ctx.session.get()
obj_id = UUID(request.args["task_id"])
obj = region.objects.lookup_fullid(obj_id)
if not obj:
return Response(f"Couldn't find {obj_id}", status=404, mimetype="text/plain")
request_msg = Message(
'RequestTaskInventory',
Block('AgentData', AgentID=session.agent_id, SessionID=session.id),
Block('InventoryData', LocalID=obj.LocalID),
)
# Keep around a dict of chunks we saw previously in case we have to restart
# an Xfer due to missing chunks. We don't expect chunks to change across Xfers
# so this can be used to recover from dropped SendXferPackets in subsequent attempts
existing_chunks: Dict[int, bytes] = {}
for _ in range(3):
# Any previous requests will have triggered a delete of the inventory file
# by marking it complete on the server-side. Re-send our RequestTaskInventory
# To make sure there's a fresh copy.
region.circuit.send(request_msg.take())
inv_message = await region.message_handler.wait_for(
('ReplyTaskInventory',),
predicate=lambda x: x["InventoryData"]["TaskID"] == obj.FullID,
timeout=5.0,
)
# No task inventory, send the reply as-is
file_name = inv_message["InventoryData"]["Filename"]
if not file_name:
return Response("", status=204)
if inv_message["InventoryData"]["Serial"] == int(request.args.get("last_serial", None)):
# Nothing has changed since the version of the inventory they say they have, say so.
return Response("", status=304)
xfer = region.xfer_manager.request(
file_name=file_name,
file_path=XferFilePath.CACHE,
turbo=True,
)
xfer.chunks.update(existing_chunks)
try:
await xfer
except asyncio.TimeoutError:
# We likely failed the request due to missing chunks, store
# the chunks that we _did_ get for the next attempt.
existing_chunks.update(xfer.chunks)
continue
inv_model = InventoryModel.from_str(xfer.reassemble_chunks().decode("utf8"))
return Response(
llsd.format_notation({
"inventory": inv_model.to_llsd(),
"inv_serial": inv_message["InventoryData"]["Serial"],
}),
headers={"Content-Type": "application/llsd+notation"},
)
raise asyncio.TimeoutError("Failed to get inventory after 3 tries")
class GetTaskInventoryCapExampleAddon(WebAppCapAddon):
# A cap URL with this name will be tied to each region when
# the sim is first connected to. The URL will be returned to the
# viewer in the Seed if the viewer requests it by name.
CAP_NAME = "GetTaskInventoryExample"
# Any asgi app should be fine.
APP = asgiref.wsgi.WsgiToAsgi(app)
addons = [GetTaskInventoryCapExampleAddon()]

View File

@@ -14,8 +14,9 @@ from PySide6.QtGui import QImage
from hippolyzer.lib.base.datatypes import UUID, Vector3, Quaternion
from hippolyzer.lib.base.helpers import to_chunks
from hippolyzer.lib.base.message.message import Block, Message
from hippolyzer.lib.base.templates import ObjectUpdateFlags, PCode, MCode, MultipleObjectUpdateFlags, TextureEntryCollection
from hippolyzer.lib.client.object_manager import ObjectEvent, UpdateType
from hippolyzer.lib.base.templates import ObjectUpdateFlags, PCode, MCode, MultipleObjectUpdateFlags, \
TextureEntryCollection, JUST_CREATED_FLAGS
from hippolyzer.lib.client.object_manager import ObjectEvent, ObjectUpdateType
from hippolyzer.lib.proxy.addon_utils import BaseAddon
from hippolyzer.lib.proxy.addons import AddonManager
from hippolyzer.lib.proxy.commands import handle_command
@@ -24,7 +25,6 @@ from hippolyzer.lib.proxy.region import ProxiedRegion
from hippolyzer.lib.proxy.sessions import Session
JUST_CREATED_FLAGS = (ObjectUpdateFlags.CREATE_SELECTED | ObjectUpdateFlags.OBJECT_YOU_OWNER)
PRIM_SCALE = 0.2
@@ -72,7 +72,7 @@ class PixelArtistAddon(BaseAddon):
# Watch for any newly created prims, this is basically what the viewer does to find
# prims that it just created with the build tool.
with session.objects.events.subscribe_async(
(UpdateType.OBJECT_UPDATE,),
(ObjectUpdateType.OBJECT_UPDATE,),
predicate=lambda e: e.object.UpdateFlags & JUST_CREATED_FLAGS and "LocalID" in e.updated
) as get_events:
# Create a pool of prims to use for building the pixel art

View File

@@ -113,7 +113,8 @@ class MessageHandler(Generic[_T, _K]):
async def _canceller():
await asyncio.sleep(timeout)
fut.set_exception(asyncio.exceptions.TimeoutError("Timed out waiting for packet"))
if not fut.done():
fut.set_exception(asyncio.exceptions.TimeoutError("Timed out waiting for packet"))
for n in notifiers:
n.unsubscribe(_handler)
@@ -126,7 +127,8 @@ class MessageHandler(Generic[_T, _K]):
# Whatever was awaiting this future now owns this message
if take:
message = message.take()
fut.set_result(message)
if not fut.done():
fut.set_result(message)
# Make sure to unregister this handler for all message types
for n in notifiers:
n.unsubscribe(_handler)

View File

@@ -146,6 +146,50 @@ class InventoryType(IntEnum):
}.get(lower, lower)
class FolderType(IntEnum):
TEXTURE = 0
SOUND = 1
CALLINGCARD = 2
LANDMARK = 3
CLOTHING = 5
OBJECT = 6
NOTECARD = 7
# We'd really like to change this to 9 since AT_CATEGORY is 8,
# but "My Inventory" has been type 8 for a long time.
ROOT_INVENTORY = 8
LSL_TEXT = 10
BODYPART = 13
TRASH = 14
SNAPSHOT_CATEGORY = 15
LOST_AND_FOUND = 16
ANIMATION = 20
GESTURE = 21
FAVORITE = 23
ENSEMBLE_START = 26
ENSEMBLE_END = 45
# This range is reserved for special clothing folder types.
CURRENT_OUTFIT = 46
OUTFIT = 47
MY_OUTFITS = 48
MESH = 49
# "received items" for MP
INBOX = 50
OUTBOX = 51
BASIC_ROOT = 52
MARKETPLACE_LISTINGS = 53
MARKETPLACE_STOCK = 54
# Note: We actually *never* create folders with that type. This is used for icon override only.
MARKETPLACE_VERSION = 55
SETTINGS = 56
# Firestorm folders, may not actually exist
FIRESTORM = 57
PHOENIX = 58
RLV = 59
# Opensim folders
MY_SUITCASE = 100
NONE = -1
@se.enum_field_serializer("AgentIsNowWearing", "WearableData", "WearableType")
@se.enum_field_serializer("AgentWearablesUpdate", "WearableData", "WearableType")
@se.enum_field_serializer("CreateInventoryItem", "InventoryBlock", "WearableType")
@@ -179,6 +223,7 @@ def _register_permissions_flags(message_name, block_name):
@se.flag_field_serializer("ObjectPermissions", "ObjectData", "Mask")
@_register_permissions_flags("ObjectProperties", "ObjectData")
@_register_permissions_flags("ObjectPropertiesFamily", "ObjectData")
@_register_permissions_flags("UpdateCreateInventoryItem", "InventoryData")
@_register_permissions_flags("UpdateTaskInventory", "InventoryData")
@_register_permissions_flags("CreateInventoryItem", "InventoryBlock")
@@ -203,11 +248,74 @@ class Permissions(IntFlag):
RESERVED = 1 << 31
@se.enum_field_serializer("ObjectSaleInfo", "ObjectData", "SaleType")
@se.enum_field_serializer("ObjectProperties", "ObjectData", "SaleType")
@se.enum_field_serializer("ObjectPropertiesFamily", "ObjectData", "SaleType")
@se.enum_field_serializer("ObjectBuy", "ObjectData", "SaleType")
@se.enum_field_serializer("RezScript", "InventoryBlock", "SaleType")
@se.enum_field_serializer("RezObject", "InventoryData", "SaleType")
@se.enum_field_serializer("UpdateTaskInventory", "InventoryData", "SaleType")
@se.enum_field_serializer("UpdateCreateInventoryItem", "InventoryData", "SaleType")
class SaleInfo(IntEnum):
NOT = 0
ORIGINAL = 1
COPY = 2
CONTENTS = 3
@se.flag_field_serializer("ParcelInfoReply", "Data", "Flags")
class ParcelInfoFlags(IntFlag):
MATURE = 1 << 0
# You should never see adult without mature
ADULT = 1 << 1
GROUP_OWNED = 1 << 2
@se.flag_field_serializer("MapItemRequest", "AgentData", "Flags")
@se.flag_field_serializer("MapNameRequest", "AgentData", "Flags")
@se.flag_field_serializer("MapBlockRequest", "AgentData", "Flags")
@se.flag_field_serializer("MapItemReply", "AgentData", "Flags")
@se.flag_field_serializer("MapNameReply", "AgentData", "Flags")
@se.flag_field_serializer("MapBlockReply", "AgentData", "Flags")
class MapImageFlags(IntFlag):
# No clue, honestly. I guess there's potentially different image types you could request.
LAYER = 1 << 1
@se.enum_field_serializer("MapBlockReply", "Data", "Access")
class SimAccess(IntEnum):
# Treated as 'unknown', usually ends up being SIM_ACCESS_PG
MIN = 0
PG = 13
MATURE = 21
ADULT = 42
DOWN = 254
@se.enum_field_serializer("MapItemRequest", "RequestData", "ItemType")
@se.enum_field_serializer("MapItemReply", "RequestData", "ItemType")
class MapItemType(IntEnum):
# Treated as 'unknown', usually ends up being SIM_ACCESS_PG
TELEHUB = 0x01
PG_EVENT = 0x02
MATURE_EVENT = 0x03
# No longer supported, 2009-03-02 KLW
DEPRECATED_POPULAR = 0x04
DEPRECATED_AGENT_COUNT = 0x05
AGENT_LOCATIONS = 0x06
LAND_FOR_SALE = 0x07
CLASSIFIED = 0x08
ADULT_EVENT = 0x09
LAND_FOR_SALE_ADULT = 0x0a
@se.flag_field_serializer("RezObject", "RezData", "ItemFlags")
@se.flag_field_serializer("RezMultipleAttachmentsFromInv", "ObjectData", "ItemFlags")
@se.flag_field_serializer("RezObject", "InventoryData", "Flags")
@se.flag_field_serializer("RezScript", "InventoryBlock", "Flags")
@se.flag_field_serializer("UpdateCreateInventoryItem", "InventoryData", "Flags")
@se.flag_field_serializer("UpdateTaskInventory", "InventoryData", "Flags")
@se.flag_field_serializer("ChangeInventoryItemFlags", "InventoryData", "Flags")
class InventoryItemFlags(IntFlag):
# The asset has only one reference in the system. If the
# inventory item is deleted, or the assetid updated, then we
@@ -234,7 +342,8 @@ class InventoryItemFlags(IntFlag):
OBJECT_HAS_MULTIPLE_ITEMS = 0x200000
@property
def attachment_point(self):
def subtype(self):
"""Subtype of the given item type, could be an attachment point or setting type, etc."""
return self & 0xFF
@@ -763,6 +872,7 @@ class MCode(IntEnum):
@se.flag_field_serializer("ObjectUpdateCompressed", "ObjectData", "UpdateFlags")
@se.flag_field_serializer("ObjectUpdateCached", "ObjectData", "UpdateFlags")
@se.flag_field_serializer("ObjectAdd", "ObjectData", "AddFlags")
@se.flag_field_serializer("ObjectDuplicate", "SharedData", "DuplicateFlags")
class ObjectUpdateFlags(IntFlag):
USE_PHYSICS = 1 << 0
CREATE_SELECTED = 1 << 1
@@ -798,6 +908,9 @@ class ObjectUpdateFlags(IntFlag):
ZLIB_COMPRESSED_REPRECATED = 1 << 31
JUST_CREATED_FLAGS = (ObjectUpdateFlags.CREATE_SELECTED | ObjectUpdateFlags.OBJECT_YOU_OWNER)
class AttachmentStateAdapter(se.Adapter):
# Encoded attachment point ID for attached objects
# nibbles are swapped around because old attachment nums only used to live
@@ -842,6 +955,15 @@ class ObjectStateSerializer(se.AdapterSubfieldSerializer):
ORIG_INLINE = True
@se.subfield_serializer("ObjectUpdate", "RegionData", "TimeDilation")
@se.subfield_serializer("ObjectUpdateCompressed", "RegionData", "TimeDilation")
@se.subfield_serializer("ObjectUpdateCached", "RegionData", "TimeDilation")
@se.subfield_serializer("ImprovedTerseObjectUpdate", "RegionData", "TimeDilation")
class TimeDilationSerializer(se.AdapterSubfieldSerializer):
ADAPTER = se.QuantizedFloat(se.U16, 0.0, 1.0, False)
ORIG_INLINE = True
@se.subfield_serializer("ImprovedTerseObjectUpdate", "ObjectData", "Data")
class ImprovedTerseObjectUpdateDataSerializer(se.SimpleSubfieldSerializer):
TEMPLATE = se.Template({
@@ -867,9 +989,9 @@ class ShineLevel(IntEnum):
@dataclasses.dataclass(unsafe_hash=True)
class BasicMaterials:
# Meaning is technically implementation-dependent, these are in LL data files
Bump: int = se.bitfield_field(bits=5)
FullBright: bool = se.bitfield_field(bits=1, adapter=se.BoolAdapter())
Shiny: int = se.bitfield_field(bits=2, adapter=se.IntEnum(ShineLevel))
Bump: int = se.bitfield_field(bits=5, default=0)
FullBright: bool = se.bitfield_field(bits=1, adapter=se.BoolAdapter(), default=False)
Shiny: int = se.bitfield_field(bits=2, adapter=se.IntEnum(ShineLevel), default=0)
BUMP_SHINY_FULLBRIGHT = se.BitfieldDataclass(BasicMaterials, se.U8)
@@ -885,10 +1007,10 @@ class TexGen(IntEnum):
@dataclasses.dataclass(unsafe_hash=True)
class MediaFlags:
WebPage: bool = se.bitfield_field(bits=1, adapter=se.BoolAdapter())
TexGen: "TexGen" = se.bitfield_field(bits=2, adapter=se.IntEnum(TexGen))
WebPage: bool = se.bitfield_field(bits=1, adapter=se.BoolAdapter(), default=False)
TexGen: "TexGen" = se.bitfield_field(bits=2, adapter=se.IntEnum(TexGen), default=TexGen.DEFAULT)
# Probably unused but show it just in case
_Unused: int = se.bitfield_field(bits=5)
_Unused: int = se.bitfield_field(bits=5, default=0)
# Not shifted so enum definitions can match indra
@@ -1073,9 +1195,9 @@ class TextureEntry:
OffsetsT: float = 0.0
# In radians
Rotation: float = 0.0
MediaFlags: Optional[MediaFlags] = None
BasicMaterials: Optional[BasicMaterials] = None
Glow: int = 0
MediaFlags: "MediaFlags" = dataclasses.field(default_factory=MediaFlags)
BasicMaterials: "BasicMaterials" = dataclasses.field(default_factory=BasicMaterials)
Glow: float = 0.0
Materials: UUID = UUID.ZERO
def st_to_uv(self, st_coord: Vector3) -> Vector3:
@@ -1110,14 +1232,10 @@ class TextureEntryCollection:
OffsetsT: Dict[_TE_FIELD_KEY, float] = _te_field(TE_S16_COORD, default=0.0)
Rotation: Dict[_TE_FIELD_KEY, float] = _te_field(PackedTERotation(), default=0.0)
BasicMaterials: Dict[_TE_FIELD_KEY, "BasicMaterials"] = _te_field(
BUMP_SHINY_FULLBRIGHT, default_factory=lambda: BasicMaterials(Bump=0, FullBright=False, Shiny=0),
BUMP_SHINY_FULLBRIGHT, default_factory=BasicMaterials,
)
MediaFlags: Dict[_TE_FIELD_KEY, "MediaFlags"] = _te_field(
MEDIA_FLAGS,
default_factory=lambda: MediaFlags(WebPage=False, TexGen=TexGen.DEFAULT, _Unused=0),
)
# TODO: dequantize
Glow: Dict[_TE_FIELD_KEY, int] = _te_field(se.U8, default=0)
MediaFlags: Dict[_TE_FIELD_KEY, "MediaFlags"] = _te_field(MEDIA_FLAGS, default_factory=MediaFlags)
Glow: Dict[_TE_FIELD_KEY, float] = _te_field(se.QuantizedFloat(se.U8, 0.0, 1.0), default=0.0)
Materials: Dict[_TE_FIELD_KEY, UUID] = _te_field(se.UUID, optional=True, default=UUID.ZERO)
def unwrap(self):
@@ -1660,6 +1778,7 @@ class DeRezObjectDestination(IntEnum):
@se.flag_field_serializer("SimStats", "RegionInfo", "RegionFlagsExtended")
@se.flag_field_serializer("RegionInfo", "RegionInfo", "RegionFlags")
@se.flag_field_serializer("RegionInfo", "RegionInfo3", "RegionFlagsExtended")
@se.flag_field_serializer("MapBlockReply", "Data", "RegionFlags")
class RegionFlags(IntFlag):
ALLOW_DAMAGE = 1 << 0
ALLOW_LANDMARK = 1 << 1
@@ -1728,6 +1847,131 @@ class TeleportFlags(IntFlag):
WITHIN_REGION = 1 << 17
@se.flag_field_serializer("AvatarPropertiesReply", "PropertiesData", "Flags")
class AvatarPropertiesFlags(IntFlag):
ALLOW_PUBLISH = 1 << 0 # whether profile is externally visible or not
MATURE_PUBLISH = 1 << 1 # profile is "mature"
IDENTIFIED = 1 << 2 # whether avatar has provided payment info
TRANSACTED = 1 << 3 # whether avatar has actively used payment info
ONLINE = 1 << 4 # the online status of this avatar, if known.
AGEVERIFIED = 1 << 5 # whether avatar has been age-verified
@se.flag_field_serializer("AvatarGroupsReply", "GroupData", "GroupPowers")
@se.flag_field_serializer("AvatarGroupDataUpdate", "GroupData", "GroupPowers")
@se.flag_field_serializer("AvatarDataUpdate", "AgentDataData", "GroupPowers")
class GroupPowerFlags(IntFlag):
MEMBER_INVITE = 1 << 1 # Invite member
MEMBER_EJECT = 1 << 2 # Eject member from group
MEMBER_OPTIONS = 1 << 3 # Toggle "Open enrollment" and change "Signup Fee"
MEMBER_VISIBLE_IN_DIR = 1 << 47
# Roles
ROLE_CREATE = 1 << 4 # Create new roles
ROLE_DELETE = 1 << 5 # Delete roles
ROLE_PROPERTIES = 1 << 6 # Change Role Names, Titles, and Descriptions (Of roles the user is in, only, or any role in group?)
ROLE_ASSIGN_MEMBER_LIMITED = 1 << 7 # Assign Member to a Role that the assigner is in
ROLE_ASSIGN_MEMBER = 1 << 8 # Assign Member to Role
ROLE_REMOVE_MEMBER = 1 << 9 # Remove Member from Role
ROLE_CHANGE_ACTIONS = 1 << 10 # Change actions a role can perform
# Group Identity
GROUP_CHANGE_IDENTITY = 1 << 11 # Charter, insignia, 'Show In Group List', 'Publish on the web', 'Mature', all 'Show Member In Group Profile' checkboxes
# Parcel Management
LAND_DEED = 1 << 12 # Deed Land and Buy Land for Group
LAND_RELEASE = 1 << 13 # Release Land (to Gov. Linden)
LAND_SET_SALE_INFO = 1 << 14 # Set for sale info (Toggle "For Sale", Set Price, Set Target, Toggle "Sell objects with the land")
LAND_DIVIDE_JOIN = 1 << 15 # Divide and Join Parcels
# Parcel Identity
LAND_FIND_PLACES = 1 << 17 # Toggle "Show in Find Places" and Set Category.
LAND_CHANGE_IDENTITY = 1 << 18 # Change Parcel Identity: Parcel Name, Parcel Description, Snapshot, 'Publish on the web', and 'Mature' checkbox
LAND_SET_LANDING_POINT = 1 << 19 # Set Landing Point
# Parcel Settings
LAND_CHANGE_MEDIA = 1 << 20 # Change Media Settings
LAND_EDIT = 1 << 21 # Toggle Edit Land
LAND_OPTIONS = 1 << 22 # Toggle Set Home Point, Fly, Outside Scripts, Create/Edit Objects, Landmark, and Damage checkboxes
# Parcel Powers
LAND_ALLOW_EDIT_LAND = 1 << 23 # Bypass Edit Land Restriction
LAND_ALLOW_FLY = 1 << 24 # Bypass Fly Restriction
LAND_ALLOW_CREATE = 1 << 25 # Bypass Create/Edit Objects Restriction
LAND_ALLOW_LANDMARK = 1 << 26 # Bypass Landmark Restriction
LAND_ALLOW_SET_HOME = 1 << 28 # Bypass Set Home Point Restriction
LAND_ALLOW_HOLD_EVENT = 1 << 41 # Allowed to hold events on group-owned land
LAND_ALLOW_ENVIRONMENT = 1 << 46 # Allowed to change the environment
# Parcel Access
LAND_MANAGE_ALLOWED = 1 << 29 # Manage Allowed List
LAND_MANAGE_BANNED = 1 << 30 # Manage Banned List
LAND_MANAGE_PASSES = 1 << 31 # Change Sell Pass Settings
LAND_ADMIN = 1 << 32 # Eject and Freeze Users on the land
# Parcel Content
LAND_RETURN_GROUP_SET = 1 << 33 # Return objects on parcel that are set to group
LAND_RETURN_NON_GROUP = 1 << 34 # Return objects on parcel that are not set to group
LAND_RETURN_GROUP_OWNED = 1 << 48 # Return objects on parcel that are owned by the group
LAND_GARDENING = 1 << 35 # Parcel Gardening - plant and move linden trees
# Object Management
OBJECT_DEED = 1 << 36 # Deed Object
OBJECT_MANIPULATE = 1 << 38 # Manipulate Group Owned Objects (Move, Copy, Mod)
OBJECT_SET_SALE = 1 << 39 # Set Group Owned Object for Sale
# Accounting
ACCOUNTING_ACCOUNTABLE = 1 << 40 # Pay Group Liabilities and Receive Group Dividends
# Notices
NOTICES_SEND = 1 << 42 # Send Notices
NOTICES_RECEIVE = 1 << 43 # Receive Notices and View Notice History
# Proposals
# TODO: _DEPRECATED suffix as part of vote removal - DEV-24856:
PROPOSAL_START = 1 << 44 # Start Proposal
# TODO: _DEPRECATED suffix as part of vote removal - DEV-24856:
PROPOSAL_VOTE = 1 << 45 # Vote on Proposal
# Group chat moderation related
SESSION_JOIN = 1 << 16 # can join session
SESSION_VOICE = 1 << 27 # can hear/talk
SESSION_MODERATOR = 1 << 37 # can mute people's session
EXPERIENCE_ADMIN = 1 << 49 # has admin rights to any experiences owned by this group
EXPERIENCE_CREATOR = 1 << 50 # can sign scripts for experiences owned by this group
# Group Banning
GROUP_BAN_ACCESS = 1 << 51 # Allows access to ban / un-ban agents from a group.
@se.flag_field_serializer("RequestObjectPropertiesFamily", "ObjectData", "RequestFlags")
@se.flag_field_serializer("ObjectPropertiesFamily", "ObjectData", "RequestFlags")
class ObjectPropertiesFamilyRequestFlags(IntFlag):
BUG_REPORT = 1 << 0
COMPLAINT_REPORT = 1 << 1
OBJECT_PAY = 1 << 2
@se.enum_field_serializer("RequestImage", "RequestImage", "Type")
class RequestImageType(IntEnum):
NORMAL = 0
AVATAR_BAKE = 1
@se.enum_field_serializer("ImageData", "ImageID", "Codec")
class ImageCodec(IntEnum):
INVALID = 0
RGB = 1
J2C = 2
BMP = 3
TGA = 4
JPEG = 5
DXT = 6
PNG = 7
@se.http_serializer("RenderMaterials")
class RenderMaterialsSerializer(se.BaseHTTPSerializer):
@classmethod

View File

@@ -34,7 +34,7 @@ LOG = logging.getLogger(__name__)
OBJECT_OR_LOCAL = Union[Object, int]
class UpdateType(enum.IntEnum):
class ObjectUpdateType(enum.IntEnum):
OBJECT_UPDATE = enum.auto()
PROPERTIES = enum.auto()
FAMILY = enum.auto()
@@ -124,7 +124,7 @@ class ClientObjectManager:
for local_id in local_ids:
if local_id in unselected_ids:
# Need to wait until we get our reply
fut = self.state.register_future(local_id, UpdateType.PROPERTIES)
fut = self.state.register_future(local_id, ObjectUpdateType.PROPERTIES)
else:
# This was selected so we should already have up to date info
fut = asyncio.Future()
@@ -159,7 +159,7 @@ class ClientObjectManager:
futures = []
for local_id in local_ids:
futures.append(self.state.register_future(local_id, UpdateType.OBJECT_UPDATE))
futures.append(self.state.register_future(local_id, ObjectUpdateType.OBJECT_UPDATE))
return futures
@@ -168,15 +168,15 @@ class ObjectEvent:
object: Object
updated: Set[str]
update_type: UpdateType
update_type: ObjectUpdateType
def __init__(self, obj: Object, updated: Set[str], update_type: UpdateType):
def __init__(self, obj: Object, updated: Set[str], update_type: ObjectUpdateType):
self.object = obj
self.updated = updated
self.update_type = update_type
@property
def name(self) -> UpdateType:
def name(self) -> ObjectUpdateType:
return self.update_type
@@ -186,7 +186,7 @@ class ClientWorldObjectManager:
self._session: BaseClientSession = session
self._settings = settings
self.name_cache = name_cache or NameCache()
self.events: MessageHandler[ObjectEvent, UpdateType] = MessageHandler(take_by_default=False)
self.events: MessageHandler[ObjectEvent, ObjectUpdateType] = MessageHandler(take_by_default=False)
self._fullid_lookup: Dict[UUID, Object] = {}
self._avatars: Dict[UUID, Avatar] = {}
self._avatar_objects: Dict[UUID, Object] = {}
@@ -295,7 +295,7 @@ class ClientWorldObjectManager:
self._rebuild_avatar_objects()
self._region_managers.clear()
def _update_existing_object(self, obj: Object, new_properties: dict, update_type: UpdateType):
def _update_existing_object(self, obj: Object, new_properties: dict, update_type: ObjectUpdateType):
old_parent_id = obj.ParentID
new_parent_id = new_properties.get("ParentID", obj.ParentID)
old_local_id = obj.LocalID
@@ -354,7 +354,7 @@ class ClientWorldObjectManager:
if obj.PCode == PCode.AVATAR:
self._avatar_objects[obj.FullID] = obj
self._rebuild_avatar_objects()
self._run_object_update_hooks(obj, set(obj.to_dict().keys()), UpdateType.OBJECT_UPDATE)
self._run_object_update_hooks(obj, set(obj.to_dict().keys()), ObjectUpdateType.OBJECT_UPDATE)
def _kill_object_by_local_id(self, region_state: RegionObjectsState, local_id: int):
obj = region_state.lookup_localid(local_id)
@@ -406,7 +406,7 @@ class ClientWorldObjectManager:
# our view of the world then we want to move it to this region.
obj = self.lookup_fullid(object_data["FullID"])
if obj:
self._update_existing_object(obj, object_data, UpdateType.OBJECT_UPDATE)
self._update_existing_object(obj, object_data, ObjectUpdateType.OBJECT_UPDATE)
else:
if region_state is None:
continue
@@ -430,7 +430,7 @@ class ClientWorldObjectManager:
# Need the Object as context because decoding state requires PCode.
state_deserializer = ObjectStateSerializer.deserialize
object_data["State"] = state_deserializer(ctx_obj=obj, val=object_data["State"])
self._update_existing_object(obj, object_data, UpdateType.OBJECT_UPDATE)
self._update_existing_object(obj, object_data, ObjectUpdateType.OBJECT_UPDATE)
else:
if region_state:
region_state.missing_locals.add(object_data["LocalID"])
@@ -458,7 +458,7 @@ class ClientWorldObjectManager:
self._update_existing_object(obj, {
"UpdateFlags": update_flags,
"RegionHandle": handle,
}, UpdateType.OBJECT_UPDATE)
}, ObjectUpdateType.OBJECT_UPDATE)
continue
cached_obj_data = self._lookup_cache_entry(handle, block["ID"], block["CRC"])
@@ -497,7 +497,7 @@ class ClientWorldObjectManager:
LOG.warning(f"Got ObjectUpdateCompressed for unknown region {handle}: {object_data!r}")
obj = self.lookup_fullid(object_data["FullID"])
if obj:
self._update_existing_object(obj, object_data, UpdateType.OBJECT_UPDATE)
self._update_existing_object(obj, object_data, ObjectUpdateType.OBJECT_UPDATE)
else:
if region_state is None:
continue
@@ -514,7 +514,7 @@ class ClientWorldObjectManager:
obj = self.lookup_fullid(block["ObjectID"])
if obj:
seen_locals.append(obj.LocalID)
self._update_existing_object(obj, object_properties, UpdateType.PROPERTIES)
self._update_existing_object(obj, object_properties, ObjectUpdateType.PROPERTIES)
else:
LOG.debug(f"Received {packet.name} for unknown {block['ObjectID']}")
packet.meta["ObjectUpdateIDs"] = tuple(seen_locals)
@@ -561,9 +561,9 @@ class ClientWorldObjectManager:
LOG.debug(f"Received ObjectCost for unknown {object_id}")
continue
obj.ObjectCosts.update(object_costs)
self._run_object_update_hooks(obj, {"ObjectCosts"}, UpdateType.COSTS)
self._run_object_update_hooks(obj, {"ObjectCosts"}, ObjectUpdateType.COSTS)
def _run_object_update_hooks(self, obj: Object, updated_props: Set[str], update_type: UpdateType):
def _run_object_update_hooks(self, obj: Object, updated_props: Set[str], update_type: ObjectUpdateType):
region_state = self._get_region_state(obj.RegionHandle)
region_state.resolve_futures(obj, update_type)
if obj.PCode == PCode.AVATAR and "NameValue" in updated_props:
@@ -572,7 +572,7 @@ class ClientWorldObjectManager:
self.events.handle(ObjectEvent(obj, updated_props, update_type))
def _run_kill_object_hooks(self, obj: Object):
self.events.handle(ObjectEvent(obj, set(), UpdateType.KILL))
self.events.handle(ObjectEvent(obj, set(), ObjectUpdateType.KILL))
def _rebuild_avatar_objects(self):
# Get all avatars known through coarse locations and which region the location was in
@@ -779,7 +779,7 @@ class RegionObjectsState:
del self._orphans[parent_id]
return removed
def register_future(self, local_id: int, future_type: UpdateType) -> asyncio.Future[Object]:
def register_future(self, local_id: int, future_type: ObjectUpdateType) -> asyncio.Future[Object]:
fut = asyncio.Future()
fut_key = (local_id, future_type)
local_futs = self._object_futures.get(fut_key, [])
@@ -788,7 +788,7 @@ class RegionObjectsState:
fut.add_done_callback(local_futs.remove)
return fut
def resolve_futures(self, obj: Object, update_type: UpdateType):
def resolve_futures(self, obj: Object, update_type: ObjectUpdateType):
futures = self._object_futures.get((obj.LocalID, update_type), [])
for fut in futures[:]:
fut.set_result(obj)

View File

@@ -61,6 +61,7 @@ class BaseInteractionManager:
# Used to initialize a REPL environment with commonly desired helpers
REPL_INITIALIZER = r"""
from hippolyzer.lib.base.datatypes import *
from hippolyzer.lib.base.templates import *
from hippolyzer.lib.base.message.message import Block, Message, Direction
from hippolyzer.lib.proxy.addon_utils import send_chat, show_message
"""
@@ -141,8 +142,15 @@ class AddonManager:
if _locals is None:
_locals = stack.frame.f_locals
_globals = dict(_globals)
exec(REPL_INITIALIZER, _globals, None)
init_globals = {}
exec(REPL_INITIALIZER, init_globals, None)
# We're modifying the globals of the caller, be careful of things we imported
# for the REPL initializer clobber things that already exist in the caller's globals.
# Making our own mutable copy of the globals dict, mutating that and then passing it
# to embed() is not an option due to https://github.com/prompt-toolkit/ptpython/issues/279
for global_name, global_val in init_globals.items():
if global_name not in _globals:
_globals[global_name] = global_val
async def _wrapper():
coro: Coroutine = ptpython.repl.embed( # noqa: the type signature lies

View File

@@ -11,7 +11,7 @@ from hippolyzer.lib.base.templates import PCode
from hippolyzer.lib.client.namecache import NameCache
from hippolyzer.lib.client.object_manager import (
ClientObjectManager,
UpdateType, ClientWorldObjectManager,
ObjectUpdateType, ClientWorldObjectManager,
)
from hippolyzer.lib.base.objects import Object
@@ -133,7 +133,7 @@ class ProxyWorldObjectManager(ClientWorldObjectManager):
region_mgr.queued_cache_misses |= missing_locals
region_mgr.request_missed_cached_objects_soon()
def _run_object_update_hooks(self, obj: Object, updated_props: Set[str], update_type: UpdateType):
def _run_object_update_hooks(self, obj: Object, updated_props: Set[str], update_type: ObjectUpdateType):
super()._run_object_update_hooks(obj, updated_props, update_type)
region = self._session.region_by_handle(obj.RegionHandle)
if self._settings.ALLOW_AUTO_REQUEST_OBJECTS:

View File

@@ -129,12 +129,16 @@ class ProxiedRegion(BaseClientRegion):
def register_proxy_cap(self, name: str):
"""Register a cap to be completely handled by the proxy"""
if name in self.caps:
# If we have an existing cap then we should just use that.
cap_data = self.caps[name]
if cap_data[1] == CapType.PROXY_ONLY:
return cap_data[0]
cap_url = f"http://{uuid.uuid4()!s}.caps.hippo-proxy.localhost"
self.register_cap(name, cap_url, CapType.PROXY_ONLY)
return cap_url
def register_cap(self, name: str, cap_url: str, cap_type: CapType = CapType.NORMAL):
"""Register a Cap that only has meaning the first time it's used"""
self.caps.add(name, (cap_type, cap_url))
self._recalc_caps()

View File

@@ -27,7 +27,10 @@ class WebAppCapAddon(BaseAddon, abc.ABC):
def handle_region_registered(self, session: Session, region: ProxiedRegion):
# Register a fake URL for our cap. This will add the cap URL to the Seed
# response that gets sent back to the client if that cap name was requested.
if self.CAP_NAME not in region.cap_urls:
region.register_proxy_cap(self.CAP_NAME)
def handle_session_init(self, session: Session):
for region in session.regions:
region.register_proxy_cap(self.CAP_NAME)
def handle_http_request(self, session_manager: SessionManager, flow: HippoHTTPFlow):

View File

@@ -25,7 +25,7 @@ from setuptools import setup, find_packages
here = path.abspath(path.dirname(__file__))
version = '0.11.0'
version = '0.11.1'
with open(path.join(here, 'README.md')) as readme_fh:
readme = readme_fh.read()

View File

@@ -10,7 +10,8 @@ from hippolyzer.lib.base.message.message import Block, Message as Message
from hippolyzer.lib.base.message.udpdeserializer import UDPMessageDeserializer
from hippolyzer.lib.base.message.udpserializer import UDPMessageSerializer
from hippolyzer.lib.base.objects import Object, normalize_object_update_compressed_data
from hippolyzer.lib.base.templates import ExtraParamType, PCode
from hippolyzer.lib.base.templates import ExtraParamType, PCode, JUST_CREATED_FLAGS
from hippolyzer.lib.client.object_manager import ObjectUpdateType
from hippolyzer.lib.proxy.addons import AddonManager
from hippolyzer.lib.proxy.addon_utils import BaseAddon
from hippolyzer.lib.proxy.region import ProxiedRegion
@@ -663,3 +664,45 @@ class SessionObjectManagerTests(ObjectManagerTestMixin, unittest.IsolatedAsyncio
self._create_object(local_id=av.LocalID, full_id=av.FullID,
pcode=PCode.AVATAR, parent_id=seat_id, pos=(1, 2, 9))
self.assertEqual(set(), self.region_object_manager.queued_cache_misses)
async def test_handle_object_update_event(self):
with self.session.objects.events.subscribe_async(
message_names=(ObjectUpdateType.OBJECT_UPDATE,),
predicate=lambda e: e.object.UpdateFlags & JUST_CREATED_FLAGS and "LocalID" in e.updated,
) as get_events:
self._create_object(local_id=999)
evt = await asyncio.wait_for(get_events(), 1.0)
self.assertEqual(999, evt.object.LocalID)
async def test_handle_object_update_predicate(self):
with self.session.objects.events.subscribe_async(
message_names=(ObjectUpdateType.OBJECT_UPDATE,),
) as get_events:
self._create_object(local_id=999)
evt = await asyncio.wait_for(get_events(), 1.0)
self.assertEqual(999, evt.object.LocalID)
async def test_handle_object_update_events_two_subscribers(self):
with self.session.objects.events.subscribe_async(
message_names=(ObjectUpdateType.OBJECT_UPDATE,),
) as get_events:
with self.session.objects.events.subscribe_async(
message_names=(ObjectUpdateType.OBJECT_UPDATE,),
) as get_events2:
self._create_object(local_id=999)
evt = await asyncio.wait_for(get_events(), 1.0)
evt2 = await asyncio.wait_for(get_events2(), 1.0)
self.assertEqual(999, evt.object.LocalID)
self.assertEqual(evt, evt2)
async def test_handle_object_update_events_two_subscribers_timeout(self):
with self.session.objects.events.subscribe_async(
message_names=(ObjectUpdateType.OBJECT_UPDATE,),
) as get_events:
with self.session.objects.events.subscribe_async(
message_names=(ObjectUpdateType.OBJECT_UPDATE,),
) as get_events2:
self._create_object(local_id=999)
evt = asyncio.wait_for(get_events(), 0.01)
evt2 = asyncio.wait_for(get_events2(), 0.01)
await asyncio.gather(evt, evt2)