From de79f42aa6b39553cd10860e1d12b55322add41e Mon Sep 17 00:00:00 2001 From: Salad Dais Date: Sat, 5 Jul 2025 03:59:14 +0000 Subject: [PATCH] Start handling AvatarAppearance messages --- hippolyzer/lib/base/message/message.py | 2 +- hippolyzer/lib/base/wearables.py | 38 ++- hippolyzer/lib/client/hippo_client.py | 2 +- hippolyzer/lib/client/object_manager.py | 34 ++- hippolyzer/lib/proxy/http_event_manager.py | 2 +- tests/base/test_legacy_schema.py | 279 +++++++++++++++++++++ 6 files changed, 352 insertions(+), 5 deletions(-) diff --git a/hippolyzer/lib/base/message/message.py b/hippolyzer/lib/base/message/message.py index b1fc5d8..90e69c1 100644 --- a/hippolyzer/lib/base/message/message.py +++ b/hippolyzer/lib/base/message/message.py @@ -267,7 +267,7 @@ class Message: block.message_name = self.name block.finalize() - def get_block(self, block_name: str, default=None, /) -> Optional[Block]: + def get_blocks(self, block_name: str, default=None, /) -> Optional[MsgBlockList]: return self.blocks.get(block_name, default) @property diff --git a/hippolyzer/lib/base/wearables.py b/hippolyzer/lib/base/wearables.py index 7129494..ebb1041 100644 --- a/hippolyzer/lib/base/wearables.py +++ b/hippolyzer/lib/base/wearables.py @@ -16,6 +16,8 @@ from hippolyzer.lib.base.datatypes import UUID from hippolyzer.lib.base.helpers import get_resource_filename from hippolyzer.lib.base.inventory import InventorySaleInfo, InventoryPermissions from hippolyzer.lib.base.legacy_schema import SchemaBase, parse_schema_line, SchemaParsingError +import hippolyzer.lib.base.serialization as se +from hippolyzer.lib.base.message.message import Message from hippolyzer.lib.base.templates import WearableType LOG = logging.getLogger(__name__) @@ -78,6 +80,13 @@ class AvatarTEIndex(enum.IntEnum): return self.name.endswith("_BAKED") +class VisualParamGroup(enum.IntEnum): + TWEAKABLE = 0 + ANIMATABLE = 1 + TWEAKABLE_NO_TRANSMIT = 2 + TRANSMIT_NOT_TWEAKABLE = 3 + + @dataclasses.dataclass class VisualParam: id: int @@ -85,26 +94,47 @@ class VisualParam: value_min: float value_max: float value_default: float + group: VisualParamGroup # These might be `None` if the param isn't meant to be directly edited edit_group: Optional[str] wearable: Optional[str] + def dequantize_val(self, val: int) -> float: + """Dequantize U8 values from AvatarAppearance messages""" + spec = se.QuantizedFloat(se.U8, self.value_min, self.value_max, False) + return spec.decode(val, None) + class VisualParams(List[VisualParam]): def __init__(self, lad_path): super().__init__() with open(lad_path, "rb") as f: doc = parse_etree(f) + + temp_params = [] for param in doc.findall(".//param"): - self.append(VisualParam( + temp_params.append(VisualParam( id=int(param.attrib["id"]), name=param.attrib["name"], + group=VisualParamGroup(int(param.get("group", "0"))), edit_group=param.get("edit_group"), wearable=param.get("wearable"), value_min=float(param.attrib["value_min"]), value_max=float(param.attrib["value_max"]), value_default=float(param.attrib.get("value_default", 0.0)) )) + # Some functionality relies on the list being sorted by ID, though there may be holes. + temp_params.sort(key=lambda x: x.id) + # Remove dupes, only using the last value present (matching indra behavior) + # This is necessary to remove the duplicate eye pop entry... + self.extend({x.id: x for x in temp_params}.values()) + + @property + def appearance_params(self) -> Iterator[VisualParam]: + for param in self: + if param.group not in (VisualParamGroup.TWEAKABLE, VisualParamGroup.TRANSMIT_NOT_TWEAKABLE): + continue + yield param def by_name(self, name: str) -> VisualParam: return [x for x in self if x.name == name][0] @@ -118,6 +148,12 @@ class VisualParams(List[VisualParam]): def by_id(self, vparam_id: int) -> VisualParam: return [x for x in self if x.id == vparam_id][0] + def parse_appearance_message(self, message: Message) -> Dict[int, float]: + params = {} + for param, value_block in zip(self.appearance_params, message["VisualParam"]): + params[param.id] = param.dequantize_val(value_block["ParamValue"]) + return params + VISUAL_PARAMS = VisualParams(get_resource_filename("lib/base/data/avatar_lad.xml")) diff --git a/hippolyzer/lib/client/hippo_client.py b/hippolyzer/lib/client/hippo_client.py index f9df5eb..761ebba 100644 --- a/hippolyzer/lib/client/hippo_client.py +++ b/hippolyzer/lib/client/hippo_client.py @@ -378,7 +378,7 @@ class HippoClientSession(BaseClientSession): sim_seed = msg["EventData"]["seed-capability"] # We teleported or cross region, opening comms to new sim elif msg.name in ("TeleportFinish", "CrossedRegion"): - sim_block = msg.get_block("RegionData", msg.get_block("Info"))[0] + sim_block = msg.get_blocks("RegionData", msg.get_blocks("Info"))[0] sim_addr = (sim_block["SimIP"], sim_block["SimPort"]) sim_handle = sim_block["RegionHandle"] sim_seed = sim_block["SeedCapability"] diff --git a/hippolyzer/lib/client/object_manager.py b/hippolyzer/lib/client/object_manager.py index 4d7e2b4..ce6a332 100644 --- a/hippolyzer/lib/client/object_manager.py +++ b/hippolyzer/lib/client/object_manager.py @@ -27,6 +27,7 @@ from hippolyzer.lib.base.objects import ( Object, handle_to_global_pos, ) from hippolyzer.lib.base.settings import Settings +from hippolyzer.lib.base.wearables import VISUAL_PARAMS from hippolyzer.lib.client.namecache import NameCache, NameCacheEntry from hippolyzer.lib.base.templates import PCode, ObjectStateSerializer, XferFilePath from hippolyzer.lib.base import llsd @@ -47,6 +48,7 @@ class ObjectUpdateType(enum.IntEnum): COSTS = enum.auto() KILL = enum.auto() ANIMATIONS = enum.auto() + APPEARANCE = enum.auto() class ClientObjectManager: @@ -299,6 +301,8 @@ class ClientWorldObjectManager: self._handle_animation_message) message_handler.subscribe("ObjectAnimation", self._handle_animation_message) + message_handler.subscribe("AvatarAppearance", + self._handle_avatar_appearance_message) def lookup_fullid(self, full_id: UUID) -> Optional[Object]: return self._fullid_lookup.get(full_id, None) @@ -663,7 +667,8 @@ class ClientWorldObjectManager: elif message.name == "ObjectAnimation": obj = self.lookup_fullid(sender_id) if not obj: - LOG.warning(f"Received AvatarAnimation for avatar with no object {sender_id}") + # This is only a debug message in the viewer, but let's be louder. + LOG.warning(f"Received ObjectAnimation for animesh with no object {sender_id}") return else: LOG.error(f"Unknown animation message type: {message.name}") @@ -674,6 +679,31 @@ class ClientWorldObjectManager: obj.Animations.append(block["AnimID"]) self._run_object_update_hooks(obj, {"Animations"}, ObjectUpdateType.ANIMATIONS, message) + def _handle_avatar_appearance_message(self, message: Message): + sender_id: UUID = message["Sender"]["ID"] + if message["Sender"]["IsTrial"]: + return + av = self.lookup_avatar(sender_id) + if not av: + LOG.warning(f"Received AvatarAppearance with no avatar {sender_id}") + return + + version = message["AppearanceData"]["CofVersion"] + if version < av.COFVersion: + LOG.warning(f"Ignoring stale appearance for {sender_id}, {version} < {av.COFVersion}") + return + + if not message.get_blocks("VisualParam"): + LOG.warning(f"No visual params in AvatarAppearance for {sender_id}") + return + + av.COFVersion = version + av.Appearance = VISUAL_PARAMS.parse_appearance_message(message) + + av_obj = av.Object + if av_obj: + self._run_object_update_hooks(av_obj, set(), ObjectUpdateType.APPEARANCE, message) + def _process_get_object_cost_response(self, parsed: dict): if "error" in parsed: return @@ -953,6 +983,8 @@ class Avatar: self.Object: Optional["Object"] = obj self.RegionHandle: int = region_handle self.CoarseLocation = coarse_location + self.Appearance: Dict[int, float] = {} + self.COFVersion: int = -1 self.Valid = True self.GuessedZ: Optional[float] = None self._resolved_name = resolved_name diff --git a/hippolyzer/lib/proxy/http_event_manager.py b/hippolyzer/lib/proxy/http_event_manager.py index b557ccb..e41cc8b 100644 --- a/hippolyzer/lib/proxy/http_event_manager.py +++ b/hippolyzer/lib/proxy/http_event_manager.py @@ -360,7 +360,7 @@ class MITMProxyEventManager: sim_seed = event["body"]["seed-capability"] # We teleported or cross region, opening comms to new sim elif msg and msg.name in ("TeleportFinish", "CrossedRegion"): - sim_block = msg.get_block("RegionData", msg.get_block("Info"))[0] + sim_block = msg.get_blocks("RegionData", msg.get_blocks("Info"))[0] sim_addr = (sim_block["SimIP"], sim_block["SimPort"]) sim_handle = sim_block["RegionHandle"] sim_seed = sim_block["SeedCapability"] diff --git a/tests/base/test_legacy_schema.py b/tests/base/test_legacy_schema.py index 40d61c5..48fe3c2 100644 --- a/tests/base/test_legacy_schema.py +++ b/tests/base/test_legacy_schema.py @@ -4,6 +4,7 @@ import unittest from hippolyzer.lib.base.datatypes import * from hippolyzer.lib.base.inventory import InventoryModel, SaleType, InventoryItem +from hippolyzer.lib.base.message.message import Block, Message from hippolyzer.lib.base.wearables import Wearable, VISUAL_PARAMS SIMPLE_INV = """\tinv_object\t0 @@ -323,6 +324,270 @@ parameters 82 textures 0 """ +# TODO: Move appearance-related stuff elsewhere. + +GIRL_NEXT_DOOR_APPEARANCE_MSG = Message( + 'AvatarAppearance', + Block('Sender', ID=UUID(int=1), IsTrial=0), + # We don't care about the value of this. + Block('ObjectData', TextureEntry=b""), + Block('VisualParam', ParamValue=9), + Block('VisualParam', ParamValue=30), + Block('VisualParam', ParamValue=71), + Block('VisualParam', ParamValue=32), + Block('VisualParam', ParamValue=51), + Block('VisualParam', ParamValue=132), + Block('VisualParam', ParamValue=10), + Block('VisualParam', ParamValue=76), + Block('VisualParam', ParamValue=84), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=43), + Block('VisualParam', ParamValue=83), + Block('VisualParam', ParamValue=113), + Block('VisualParam', ParamValue=68), + Block('VisualParam', ParamValue=73), + Block('VisualParam', ParamValue=43), + Block('VisualParam', ParamValue=35), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=7), + Block('VisualParam', ParamValue=132), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=76), + Block('VisualParam', ParamValue=91), + Block('VisualParam', ParamValue=129), + Block('VisualParam', ParamValue=106), + Block('VisualParam', ParamValue=76), + Block('VisualParam', ParamValue=58), + Block('VisualParam', ParamValue=99), + Block('VisualParam', ParamValue=73), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=203), + Block('VisualParam', ParamValue=48), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=150), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=114), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=76), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=40), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=140), + Block('VisualParam', ParamValue=86), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=99), + Block('VisualParam', ParamValue=84), + Block('VisualParam', ParamValue=53), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=66), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=100), + Block('VisualParam', ParamValue=216), + Block('VisualParam', ParamValue=214), + Block('VisualParam', ParamValue=204), + Block('VisualParam', ParamValue=204), + Block('VisualParam', ParamValue=204), + Block('VisualParam', ParamValue=51), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=89), + Block('VisualParam', ParamValue=109), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=61), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=115), + Block('VisualParam', ParamValue=76), + Block('VisualParam', ParamValue=91), + Block('VisualParam', ParamValue=158), + Block('VisualParam', ParamValue=102), + Block('VisualParam', ParamValue=109), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=193), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=132), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=68), + Block('VisualParam', ParamValue=35), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=97), + Block('VisualParam', ParamValue=92), + Block('VisualParam', ParamValue=79), + Block('VisualParam', ParamValue=107), + Block('VisualParam', ParamValue=160), + Block('VisualParam', ParamValue=112), + Block('VisualParam', ParamValue=63), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=159), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=73), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=102), + Block('VisualParam', ParamValue=158), + Block('VisualParam', ParamValue=145), + Block('VisualParam', ParamValue=153), + Block('VisualParam', ParamValue=163), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=122), + Block('VisualParam', ParamValue=43), + Block('VisualParam', ParamValue=94), + Block('VisualParam', ParamValue=135), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=214), + Block('VisualParam', ParamValue=204), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=56), + Block('VisualParam', ParamValue=30), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=204), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=112), + Block('VisualParam', ParamValue=127), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=25), + Block('VisualParam', ParamValue=100), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=84), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=51), + Block('VisualParam', ParamValue=94), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=255), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=25), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=25), + Block('VisualParam', ParamValue=23), + Block('VisualParam', ParamValue=51), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=25), + Block('VisualParam', ParamValue=23), + Block('VisualParam', ParamValue=51), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=25), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=25), + Block('VisualParam', ParamValue=23), + Block('VisualParam', ParamValue=51), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=25), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=25), + Block('VisualParam', ParamValue=23), + Block('VisualParam', ParamValue=51), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=25), + Block('VisualParam', ParamValue=23), + Block('VisualParam', ParamValue=51), + Block('VisualParam', ParamValue=0), + Block('VisualParam', ParamValue=25), + Block('VisualParam', ParamValue=23), + Block('VisualParam', ParamValue=51), + Block('VisualParam', ParamValue=1), + Block('VisualParam', ParamValue=127), + Block('AppearanceData', AppearanceVersion=1, CofVersion=100, Flags=0), + Block('AppearanceHover', HoverHeight=Vector3(0.0, 0.0, 0.0)) +) + class TestWearable(unittest.TestCase): def test_parse(self): @@ -338,3 +603,17 @@ class TestWearable(unittest.TestCase): def test_visual_params(self): param = VISUAL_PARAMS.by_name("Eyelid_Inner_Corner_Up") self.assertEqual(param.value_max, 1.2) + + def test_message_equivalent(self): + wearable = Wearable.from_str(GIRL_NEXT_DOOR_SHAPE) + parsed = VISUAL_PARAMS.parse_appearance_message(GIRL_NEXT_DOOR_APPEARANCE_MSG) + + for i, (param_id, param_val) in enumerate(parsed.items()): + param = VISUAL_PARAMS.by_id(param_id) + if param.wearable != "shape": + continue + # A parameter may legitimately be missing from the shape depending on its age, + # just assume it's the default value. + expected_val = wearable.parameters.get(param_id, param.value_default) + # This seems like quite a large delta. Maybe we should be using different quantization here. + self.assertAlmostEqual(expected_val, param_val, delta=0.015)