Start handling AvatarAppearance messages

This commit is contained in:
Salad Dais
2025-07-05 03:59:14 +00:00
parent e138ae88a1
commit de79f42aa6
6 changed files with 352 additions and 5 deletions

View File

@@ -267,7 +267,7 @@ class Message:
block.message_name = self.name block.message_name = self.name
block.finalize() 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) return self.blocks.get(block_name, default)
@property @property

View File

@@ -16,6 +16,8 @@ from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.helpers import get_resource_filename from hippolyzer.lib.base.helpers import get_resource_filename
from hippolyzer.lib.base.inventory import InventorySaleInfo, InventoryPermissions from hippolyzer.lib.base.inventory import InventorySaleInfo, InventoryPermissions
from hippolyzer.lib.base.legacy_schema import SchemaBase, parse_schema_line, SchemaParsingError 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 from hippolyzer.lib.base.templates import WearableType
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@@ -78,6 +80,13 @@ class AvatarTEIndex(enum.IntEnum):
return self.name.endswith("_BAKED") return self.name.endswith("_BAKED")
class VisualParamGroup(enum.IntEnum):
TWEAKABLE = 0
ANIMATABLE = 1
TWEAKABLE_NO_TRANSMIT = 2
TRANSMIT_NOT_TWEAKABLE = 3
@dataclasses.dataclass @dataclasses.dataclass
class VisualParam: class VisualParam:
id: int id: int
@@ -85,26 +94,47 @@ class VisualParam:
value_min: float value_min: float
value_max: float value_max: float
value_default: float value_default: float
group: VisualParamGroup
# These might be `None` if the param isn't meant to be directly edited # These might be `None` if the param isn't meant to be directly edited
edit_group: Optional[str] edit_group: Optional[str]
wearable: 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]): class VisualParams(List[VisualParam]):
def __init__(self, lad_path): def __init__(self, lad_path):
super().__init__() super().__init__()
with open(lad_path, "rb") as f: with open(lad_path, "rb") as f:
doc = parse_etree(f) doc = parse_etree(f)
temp_params = []
for param in doc.findall(".//param"): for param in doc.findall(".//param"):
self.append(VisualParam( temp_params.append(VisualParam(
id=int(param.attrib["id"]), id=int(param.attrib["id"]),
name=param.attrib["name"], name=param.attrib["name"],
group=VisualParamGroup(int(param.get("group", "0"))),
edit_group=param.get("edit_group"), edit_group=param.get("edit_group"),
wearable=param.get("wearable"), wearable=param.get("wearable"),
value_min=float(param.attrib["value_min"]), value_min=float(param.attrib["value_min"]),
value_max=float(param.attrib["value_max"]), value_max=float(param.attrib["value_max"]),
value_default=float(param.attrib.get("value_default", 0.0)) 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: def by_name(self, name: str) -> VisualParam:
return [x for x in self if x.name == name][0] 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: def by_id(self, vparam_id: int) -> VisualParam:
return [x for x in self if x.id == vparam_id][0] 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")) VISUAL_PARAMS = VisualParams(get_resource_filename("lib/base/data/avatar_lad.xml"))

View File

@@ -378,7 +378,7 @@ class HippoClientSession(BaseClientSession):
sim_seed = msg["EventData"]["seed-capability"] sim_seed = msg["EventData"]["seed-capability"]
# We teleported or cross region, opening comms to new sim # We teleported or cross region, opening comms to new sim
elif msg.name in ("TeleportFinish", "CrossedRegion"): 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_addr = (sim_block["SimIP"], sim_block["SimPort"])
sim_handle = sim_block["RegionHandle"] sim_handle = sim_block["RegionHandle"]
sim_seed = sim_block["SeedCapability"] sim_seed = sim_block["SeedCapability"]

View File

@@ -27,6 +27,7 @@ from hippolyzer.lib.base.objects import (
Object, handle_to_global_pos, Object, handle_to_global_pos,
) )
from hippolyzer.lib.base.settings import Settings 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.client.namecache import NameCache, NameCacheEntry
from hippolyzer.lib.base.templates import PCode, ObjectStateSerializer, XferFilePath from hippolyzer.lib.base.templates import PCode, ObjectStateSerializer, XferFilePath
from hippolyzer.lib.base import llsd from hippolyzer.lib.base import llsd
@@ -47,6 +48,7 @@ class ObjectUpdateType(enum.IntEnum):
COSTS = enum.auto() COSTS = enum.auto()
KILL = enum.auto() KILL = enum.auto()
ANIMATIONS = enum.auto() ANIMATIONS = enum.auto()
APPEARANCE = enum.auto()
class ClientObjectManager: class ClientObjectManager:
@@ -299,6 +301,8 @@ class ClientWorldObjectManager:
self._handle_animation_message) self._handle_animation_message)
message_handler.subscribe("ObjectAnimation", message_handler.subscribe("ObjectAnimation",
self._handle_animation_message) self._handle_animation_message)
message_handler.subscribe("AvatarAppearance",
self._handle_avatar_appearance_message)
def lookup_fullid(self, full_id: UUID) -> Optional[Object]: def lookup_fullid(self, full_id: UUID) -> Optional[Object]:
return self._fullid_lookup.get(full_id, None) return self._fullid_lookup.get(full_id, None)
@@ -663,7 +667,8 @@ class ClientWorldObjectManager:
elif message.name == "ObjectAnimation": elif message.name == "ObjectAnimation":
obj = self.lookup_fullid(sender_id) obj = self.lookup_fullid(sender_id)
if not obj: 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 return
else: else:
LOG.error(f"Unknown animation message type: {message.name}") LOG.error(f"Unknown animation message type: {message.name}")
@@ -674,6 +679,31 @@ class ClientWorldObjectManager:
obj.Animations.append(block["AnimID"]) obj.Animations.append(block["AnimID"])
self._run_object_update_hooks(obj, {"Animations"}, ObjectUpdateType.ANIMATIONS, message) 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): def _process_get_object_cost_response(self, parsed: dict):
if "error" in parsed: if "error" in parsed:
return return
@@ -953,6 +983,8 @@ class Avatar:
self.Object: Optional["Object"] = obj self.Object: Optional["Object"] = obj
self.RegionHandle: int = region_handle self.RegionHandle: int = region_handle
self.CoarseLocation = coarse_location self.CoarseLocation = coarse_location
self.Appearance: Dict[int, float] = {}
self.COFVersion: int = -1
self.Valid = True self.Valid = True
self.GuessedZ: Optional[float] = None self.GuessedZ: Optional[float] = None
self._resolved_name = resolved_name self._resolved_name = resolved_name

View File

@@ -360,7 +360,7 @@ class MITMProxyEventManager:
sim_seed = event["body"]["seed-capability"] sim_seed = event["body"]["seed-capability"]
# We teleported or cross region, opening comms to new sim # We teleported or cross region, opening comms to new sim
elif msg and msg.name in ("TeleportFinish", "CrossedRegion"): 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_addr = (sim_block["SimIP"], sim_block["SimPort"])
sim_handle = sim_block["RegionHandle"] sim_handle = sim_block["RegionHandle"]
sim_seed = sim_block["SeedCapability"] sim_seed = sim_block["SeedCapability"]

View File

@@ -4,6 +4,7 @@ import unittest
from hippolyzer.lib.base.datatypes import * from hippolyzer.lib.base.datatypes import *
from hippolyzer.lib.base.inventory import InventoryModel, SaleType, InventoryItem 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 from hippolyzer.lib.base.wearables import Wearable, VISUAL_PARAMS
SIMPLE_INV = """\tinv_object\t0 SIMPLE_INV = """\tinv_object\t0
@@ -323,6 +324,270 @@ parameters 82
textures 0 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): class TestWearable(unittest.TestCase):
def test_parse(self): def test_parse(self):
@@ -338,3 +603,17 @@ class TestWearable(unittest.TestCase):
def test_visual_params(self): def test_visual_params(self):
param = VISUAL_PARAMS.by_name("Eyelid_Inner_Corner_Up") param = VISUAL_PARAMS.by_name("Eyelid_Inner_Corner_Up")
self.assertEqual(param.value_max, 1.2) 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)