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.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

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.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"))

View File

@@ -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"]

View File

@@ -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

View File

@@ -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"]

View File

@@ -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)