20 Commits

Author SHA1 Message Date
Salad Dais
6f87ec8725 Split up dependencies so core can be used without GUI deps 2025-11-24 01:44:08 +00:00
Salad Dais
fb885d8eec Add example addon for debugging avatar load times 2025-10-29 02:13:25 +00:00
Salad Dais
78281ed12b Make send off-circuit work correctly again 2025-10-29 02:08:13 +00:00
Salad Dais
4087eaa3c6 Don't trigger resends on off-circuit messages 2025-08-26 21:53:32 +00:00
Salad Dais
32428941d7 Fix up inventory handlers a little 2025-08-18 21:28:26 +00:00
Salad Dais
0cc3397402 Improve inventory handling 2025-07-15 01:53:24 +00:00
Salad Dais
0c2dfd3213 Pass EQ messages off to session message handler as well 2025-07-14 07:43:56 +00:00
Salad Dais
e119181e3f Handle RemoveInventoryObjects message 2025-07-14 07:43:36 +00:00
Salad Dais
64c7265578 Beautify JSON responses 2025-07-14 03:56:27 +00:00
Salad Dais
eb652152f5 Update some flags 2025-07-14 03:56:16 +00:00
Salad Dais
cd03dd4fdd Fix duplication not handling update messages properly 2025-07-07 23:54:26 +00:00
Salad Dais
056e142347 Add API for duplicating inventory folders / items 2025-07-07 22:52:38 +00:00
Salad Dais
927a353dec Use windows-2022 for CI, windows-2019 is retired 2025-07-06 05:47:16 +00:00
Salad Dais
bc68eeb7d2 Add shape creator example addon 2025-07-06 05:27:23 +00:00
Salad Dais
de79f42aa6 Start handling AvatarAppearance messages 2025-07-05 03:59:14 +00:00
Salad Dais
e138ae88a1 Start adding tests for inventory manager 2025-06-30 22:19:24 +00:00
Salad Dais
e20a4a01ad Add tools for mirroring animations 2025-06-28 04:23:18 +00:00
Salad Dais
a2b49fdc44 Allow updating skeleton definitions with attributes from mesh 2025-06-21 08:45:30 +00:00
Salad Dais
988a82179e Update templates 2025-06-18 20:44:11 +00:00
Salad Dais
4eb97b5958 Improve anim tracker addon 2025-06-18 20:43:49 +00:00
32 changed files with 883 additions and 76 deletions

View File

@@ -18,7 +18,7 @@ env:
jobs:
build:
runs-on: windows-2019
runs-on: windows-2022
permissions:
contents: write
strategy:
@@ -40,7 +40,7 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -e .
pip install -e .[gui]
pip install cx_freeze
- name: Bundle with cx_Freeze

View File

@@ -33,7 +33,7 @@ jobs:
pip install -r requirements.txt
pip install -r requirements-test.txt
sudo apt-get install libopenjp2-7
pip install -e .
pip install -e .[gui]
- name: Run Flake8
run: |
flake8 .

View File

@@ -35,7 +35,9 @@ with low-level SL details. See the [Local Animation addon example](https://githu
* Activate the virtualenv by running the appropriate activation script
* * Under Linux this would be something like `source <virtualenv_dir>/bin/activate`
* * Under Windows it's `<virtualenv_dir>\Scripts\activate.bat`
* Run `pip install hippolyzer`, or run `pip install -e .` in a cloned repo to install an editable version
* Run `pip install hippolyzer[gui]` for a full install, or run `pip install -e .[gui]` in a cloned repo to install an editable version
* * If you only want the core library without proxy or GUI support, use `pip install hippolyzer` or `pip install -e .`
* * If you only want proxy/CLI support without the GUI, use `pip install hippolyzer[proxy]` or `pip install -e .[proxy]`
### Binary Windows Builds

View File

@@ -74,7 +74,7 @@ class AnimTrackerAddon(BaseAddon):
# We don't care about other messages, we're just interested in distinguishing cases where the viewer
# specifically requested something vs something being done by the server on its own.
return
av = region.objects.lookup_avatar(session.agent_id)
av = session.objects.lookup_avatar(session.agent_id)
if not av or not av.Object:
print("Somehow didn't know about our own av object?")
return

View File

@@ -0,0 +1,94 @@
"""
Try and diagnose very slow avatar appearance loads when the avatars first come on the scene
I guess use LEAP or something to detect when things _actually_ declouded.
"""
from typing import *
import dataclasses
import datetime as dt
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.message.message import Message
from hippolyzer.lib.base.objects import Object
from hippolyzer.lib.base.templates import PCode
from hippolyzer.lib.proxy.addon_utils import BaseAddon, GlobalProperty
from hippolyzer.lib.proxy.http_flow import HippoHTTPFlow
from hippolyzer.lib.proxy.region import ProxiedRegion
from hippolyzer.lib.proxy.sessions import Session, SessionManager
@dataclasses.dataclass
class AvatarBakeRequest:
requested: dt.datetime
received: Optional[dt.datetime] = None
@dataclasses.dataclass
class AvatarAppearanceRecord:
object_received: dt.datetime
"""When we learned about the agent as an object"""
appearance_received: Optional[dt.datetime] = None
"""When AvatarAppearance was first received"""
bake_requests: Dict[str, AvatarBakeRequest] = dataclasses.field(default_factory=dict)
"""Layer name -> request / response details"""
class AppearanceDelayTrackerAddon(BaseAddon):
# Should be able to access this in the REPL
# Normally we'd use a session property, but we may not have a proper session context for some requests
av_appearance_data: Dict[UUID, AvatarAppearanceRecord] = GlobalProperty(dict)
def handle_object_updated(self, session: Session, region: ProxiedRegion,
obj: Object, updated_props: Set[str], msg: Optional[Message]):
if obj.PCode == PCode.AVATAR and obj.FullID not in self.av_appearance_data:
self.av_appearance_data[obj.FullID] = AvatarAppearanceRecord(object_received=dt.datetime.now())
def handle_lludp_message(self, session: Session, region: ProxiedRegion, message: Message):
if message.name != "AvatarAppearance":
return
agent_id = message["Sender"]["ID"]
appearance_data = self.av_appearance_data.get(agent_id)
if not appearance_data:
print(f"Got appearance for {agent_id} without knowing about object?")
return
if appearance_data.appearance_received:
return
appearance_data.appearance_received = dt.datetime.now()
def handle_http_request(self, session_manager: SessionManager, flow: HippoHTTPFlow):
if not flow.cap_data:
return
if flow.cap_data.cap_name != "AppearanceService":
return
agent_id = UUID(flow.request.url.split('/')[-3])
slot_name = flow.request.url.split('/')[-2]
appearance_data = self.av_appearance_data.get(agent_id)
if not appearance_data:
print(f"Got AppearanceService req for {agent_id} without knowing about object?")
return
if slot_name in appearance_data.bake_requests:
# We already requested this slot before
return
appearance_data.bake_requests[slot_name] = AvatarBakeRequest(requested=dt.datetime.now())
def handle_http_response(self, session_manager: SessionManager, flow: HippoHTTPFlow):
if not flow.cap_data:
return
if flow.cap_data.cap_name != "AppearanceService":
return
agent_id = UUID(flow.request.url.split('/')[-3])
slot_name = flow.request.url.split('/')[-2]
appearance_data = self.av_appearance_data.get(agent_id)
if not appearance_data:
return
slot_details = appearance_data.bake_requests.get(slot_name)
if not slot_details:
return
slot_details.received = dt.datetime.now()
addons = [AppearanceDelayTrackerAddon()]

View File

@@ -0,0 +1,44 @@
"""
Demonstrates item creation as well as bodypart / clothing upload
"""
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.templates import WearableType, Permissions
from hippolyzer.lib.base.wearables import Wearable, VISUAL_PARAMS
from hippolyzer.lib.proxy.addon_utils import BaseAddon
from hippolyzer.lib.proxy.commands import handle_command
from hippolyzer.lib.proxy.region import ProxiedRegion
from hippolyzer.lib.proxy.sessions import Session
class ShapeCreatorAddon(BaseAddon):
@handle_command()
async def create_shape(self, session: Session, region: ProxiedRegion):
"""Make a shape with pre-set parameters and place it in the body parts folder"""
wearable = Wearable.make_default(WearableType.SHAPE)
# Max out the jaw jut param
jaw_param = VISUAL_PARAMS.by_name("Jaw Jut")
wearable.parameters[jaw_param.id] = jaw_param.value_max
wearable.name = "Cool Shape"
# A unique transaction ID is needed to tie the item creation to the following asset upload.
transaction_id = UUID.random()
item = await session.inventory.create_item(
UUID.ZERO, # This will place it in the default folder for the type
name=wearable.name,
type=wearable.wearable_type.asset_type,
inv_type=wearable.wearable_type.asset_type.inventory_type,
wearable_type=wearable.wearable_type,
next_mask=Permissions.MOVE | Permissions.MODIFY | Permissions.COPY | Permissions.TRANSFER,
transaction_id=transaction_id,
)
print(f"Created {item!r}")
await region.xfer_manager.upload_asset(
wearable.wearable_type.asset_type,
wearable.to_str(),
transaction_id=transaction_id,
)
addons = [ShapeCreatorAddon()]

View File

@@ -8,7 +8,7 @@ applied to the mesh before upload.
I personally use manglers to strip bounding box materials you need
to add to give a mesh an arbitrary center of rotation / scaling.
"""
from hippolyzer.lib.base.helpers import reorient_coord
from hippolyzer.lib.base.mesh import MeshAsset
from hippolyzer.lib.proxy.addons import AddonManager
@@ -16,23 +16,8 @@ import local_mesh
AddonManager.hot_reload(local_mesh, require_addons_loaded=True)
def _reorient_coord(coord, orientation, normals=False):
coords = []
for axis in orientation:
axis_idx = abs(axis) - 1
if normals:
# Normals have a static domain from -1.0 to 1.0, just negate.
new_coord = coord[axis_idx] if axis >= 0 else -coord[axis_idx]
else:
new_coord = coord[axis_idx] if axis >= 0 else 1.0 - coord[axis_idx]
coords.append(new_coord)
if coord.__class__ in (list, tuple):
return coord.__class__(coords)
return coord.__class__(*coords)
def _reorient_coord_list(coord_list, orientation, normals=False):
return [_reorient_coord(x, orientation, normals) for x in coord_list]
def _reorient_coord_list(coord_list, orientation, min_val: int | float = 0):
return [reorient_coord(x, orientation, min_val) for x in coord_list]
def reorient_mesh(orientation):
@@ -47,7 +32,7 @@ def reorient_mesh(orientation):
# flipping the axes around.
material["Position"] = _reorient_coord_list(material["Position"], orientation)
# Are you even supposed to do this to the normals?
material["Normal"] = _reorient_coord_list(material["Normal"], orientation, normals=True)
material["Normal"] = _reorient_coord_list(material["Normal"], orientation, min_val=-1)
return mesh
return _reorienter

View File

@@ -719,7 +719,9 @@ class MessageBuilderWindow(QtWidgets.QMainWindow):
transport = None
off_circuit = self.checkOffCircuit.isChecked()
if off_circuit:
transport = SocketUDPTransport(socket.socket(socket.AF_INET, socket.SOCK_DGRAM))
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("0.0.0.0", 0))
transport = SocketUDPTransport(sock)
region.circuit.send(msg, transport=transport)
if off_circuit:
transport.close()

View File

@@ -3,10 +3,12 @@ Assorted utilities to make creating animations from scratch easier
"""
import copy
from typing import List, Union
from typing import List, Union, Mapping
from hippolyzer.lib.base.datatypes import Vector3, Quaternion
from hippolyzer.lib.base.llanim import PosKeyframe, RotKeyframe
from hippolyzer.lib.base.llanim import PosKeyframe, RotKeyframe, JOINTS_DICT, Joint
from hippolyzer.lib.base.mesh_skeleton import AVATAR_SKELETON
from hippolyzer.lib.base.multidict import OrderedMultiDict
def smooth_step(t: float):
@@ -89,3 +91,35 @@ def smooth_rot(start: Quaternion, end: Quaternion, inter_frames: int, time: floa
smooth_t = smooth_step(t)
frames.append(RotKeyframe(time=time + (t * duration), rot=rot_interp(start, end, smooth_t)))
return frames + [RotKeyframe(time=time + duration, rot=end)]
def mirror_joints(joints_dict: Mapping[str, Joint]) -> JOINTS_DICT:
"""Mirror a joints dict so left / right are swapped, including transformations"""
new_joints: JOINTS_DICT = OrderedMultiDict()
for joint_name, joint in joints_dict.items():
inverse_joint_node = AVATAR_SKELETON[joint_name].inverse
if not inverse_joint_node:
new_joints[joint_name] = joint
continue
# Okay, this is one we have to actually mirror
new_joint = Joint(joint.priority, [], [])
for rot_keyframe in joint.rot_keyframes:
new_joint.rot_keyframes.append(RotKeyframe(
time=rot_keyframe.time,
# Just need to mirror on yaw and roll
rot=Quaternion.from_euler(*(rot_keyframe.rot.to_euler() * Vector3(-1, 1, -1)))
))
for pos_keyframe in joint.pos_keyframes:
new_joint.pos_keyframes.append(PosKeyframe(
time=pos_keyframe.time,
# Y is left / right so just negate it.
pos=pos_keyframe.pos * Vector3(1, -1, 1)
))
new_joints[inverse_joint_node.name] = new_joint
return new_joints

View File

@@ -429,8 +429,8 @@ class GLTFBuilder:
# Add each joint to the child list of their respective parent
for joint_name, joint_ctx in built_joints.items():
if parent := AVATAR_SKELETON[joint_name].parent:
built_joints[parent().name].node.children.append(self.model.nodes.index(joint_ctx.node))
if parent_name := AVATAR_SKELETON[joint_name].parent_name:
built_joints[parent_name].node.children.append(self.model.nodes.index(joint_ctx.node))
return built_joints
def _fix_blender_joint(self, joint_matrix: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:

View File

@@ -195,3 +195,21 @@ def create_logged_task(
task = asyncio.create_task(coro, name=name)
add_future_logger(task, name, logger)
return task
def reorient_coord(coord, new_orientation, min_val: int | float = 0):
"""
Reorient a coordinate instance such that its components are negated and transposed appropriately.
For ex:
reorient_coord((1,2,3), (3,-2,-1)) == (3,-2,-1)
"""
min_val = abs(min_val)
coords = []
for axis in new_orientation:
axis_idx = abs(axis) - 1
new_coord = coord[axis_idx] if axis >= 0 else min_val - coord[axis_idx]
coords.append(new_coord)
if coord.__class__ in (list, tuple):
return coord.__class__(coords)
return coord.__class__(*coords)

View File

@@ -485,6 +485,25 @@ class InventoryContainerBase(InventoryNodeBase):
if x.parent_id == self.node_id
)
@property
def descendents(self) -> List[InventoryNodeBase]:
new_children: List[InventoryNodeBase] = [self]
descendents = []
while new_children:
to_check = new_children[:]
new_children.clear()
for obj in to_check:
if isinstance(obj, InventoryContainerBase):
for child in obj.children:
if child in descendents:
continue
new_children.append(child)
descendents.append(child)
else:
if obj not in descendents:
descendents.append(obj)
return descendents
def __getitem__(self, item: Union[int, str]) -> InventoryNodeBase:
if isinstance(item, int):
return self.children[item]
@@ -607,6 +626,9 @@ class InventoryItem(InventoryNodeBase):
name: Optional[str] = schema_field(SchemaMultilineStr, default=None)
desc: Optional[str] = schema_field(SchemaMultilineStr, default=None)
metadata: Optional[Dict[str, Any]] = schema_field(SchemaLLSD, default=None, include_none=True)
"""Specifically for script metadata, generally just experience info"""
thumbnail: Optional[Dict[str, Any]] = schema_field(SchemaLLSD, default=None, include_none=False)
"""Generally just a dict with the thumbnail UUID in it"""
creation_date: Optional[dt.datetime] = schema_field(SchemaDate, llsd_name="created_at", default=None)
__hash__ = InventoryNodeBase.__hash__

View File

@@ -15,6 +15,8 @@ CONSTRAINT_DATACLASS = se.ForwardSerializable(lambda: se.Dataclass(Constraint))
POSKEYFRAME_DATACLASS = se.ForwardSerializable(lambda: se.Dataclass(PosKeyframe))
ROTKEYFRAME_DATACLASS = se.ForwardSerializable(lambda: se.Dataclass(RotKeyframe))
JOINTS_DICT = OrderedMultiDict[str, "Joint"]
@dataclasses.dataclass
class Animation:
@@ -29,7 +31,7 @@ class Animation:
ease_in_duration: float = se.dataclass_field(se.F32)
ease_out_duration: float = se.dataclass_field(se.F32)
hand_pose: HandPose = se.dataclass_field(lambda: se.IntEnum(HandPose, se.U32), default=0)
joints: OrderedMultiDict[str, Joint] = se.dataclass_field(se.MultiDictAdapter(
joints: JOINTS_DICT = se.dataclass_field(se.MultiDictAdapter(
se.Collection(se.U32, se.Tuple(se.CStr(), JOINT_DATACLASS)),
))
constraints: List[Constraint] = se.dataclass_field(

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import copy
import dataclasses
import re
import weakref
from typing import *
@@ -9,22 +11,23 @@ from lxml import etree
from hippolyzer.lib.base.datatypes import Vector3, RAD_TO_DEG
from hippolyzer.lib.base.helpers import get_resource_filename
from hippolyzer.lib.base.mesh import MeshAsset, SkinSegmentDict, llsd_to_mat4
MAYBE_JOINT_REF = Optional[Callable[[], "JointNode"]]
MAYBE_JOINT_REF = Optional[str]
SKELETON_REF = Optional[Callable[[], "Skeleton"]]
@dataclasses.dataclass
class JointNode:
name: str
parent: MAYBE_JOINT_REF
parent_name: MAYBE_JOINT_REF
skeleton: SKELETON_REF
translation: Vector3
pivot: Vector3 # pivot point for the joint, generally the same as translation
rotation: Vector3 # Euler rotation in degrees
scale: Vector3
type: str # bone or collision_volume
support: str
def __hash__(self):
return hash((self.name, self.type))
@@ -37,6 +40,12 @@ class JointNode:
translate=tuple(self.translation),
)
@property
def parent(self) -> Optional[JointNode]:
if self.parent_name:
return self.skeleton()[self.parent_name]
return None
@property
def index(self) -> int:
bone_idx = 0
@@ -51,57 +60,109 @@ class JointNode:
@property
def ancestors(self) -> Sequence[JointNode]:
joint_node = self
ancestors = []
while joint_node.parent:
joint_node = joint_node.parent()
skeleton = self.skeleton()
ancestors: List[JointNode] = []
while joint_node.parent_name:
joint_node = skeleton.joint_dict.get(joint_node.parent_name)
ancestors.append(joint_node)
return ancestors
@property
def children(self) -> Sequence[JointNode]:
children = []
children: List[JointNode] = []
for node in self.skeleton().joint_dict.values():
if node.parent and node.parent() == self:
if node.parent_name and node.parent_name == self.name:
children.append(node)
return children
@property
def inverse(self) -> Optional[JointNode]:
l_re = re.compile(r"(.*?(?:_|\b))L((?:_|\b).*)")
r_re = re.compile(r"(.*?(?:_|\b))R((?:_|\b).*)")
inverse_name = None
if "Left" in self.name:
inverse_name = self.name.replace("Left", "Right")
elif "LEFT" in self.name:
inverse_name = self.name.replace("LEFT", "RIGHT")
elif l_re.match(self.name):
inverse_name = re.sub(l_re, r"\1R\2", self.name)
elif "Right" in self.name:
inverse_name = self.name.replace("Right", "Left")
elif "RIGHT" in self.name:
inverse_name = self.name.replace("RIGHT", "LEFT")
elif r_re.match(self.name):
inverse_name = re.sub(r_re, r"\1L\2", self.name)
if inverse_name:
return self.skeleton().joint_dict.get(inverse_name)
return None
@property
def descendents(self) -> Set[JointNode]:
descendents = set()
ancestors = {self}
last_ancestors = set()
descendents: Set[JointNode] = set()
ancestors: Set[str] = {self.name}
last_ancestors: Set[str] = set()
while last_ancestors != ancestors:
last_ancestors = ancestors
last_ancestors = ancestors.copy()
for node in self.skeleton().joint_dict.values():
if node.parent and node.parent() in ancestors:
ancestors.add(node)
if node.parent_name and node.parent_name in ancestors:
ancestors.add(node.name)
descendents.add(node)
return descendents
class Skeleton:
def __init__(self, root_node: etree.ElementBase):
def __init__(self, root_node: Optional[etree.ElementBase] = None):
self.joint_dict: Dict[str, JointNode] = {}
self._parse_node_children(root_node, None)
if root_node is not None:
self._parse_node_children(root_node, None)
def __getitem__(self, item: str) -> JointNode:
return self.joint_dict[item]
def _parse_node_children(self, node: etree.ElementBase, parent: MAYBE_JOINT_REF):
def clone(self) -> Self:
val = copy.deepcopy(self)
skel_ref = weakref.ref(val)
for joint in val.joint_dict.values():
joint.skeleton = skel_ref
return val
def _parse_node_children(self, node: etree.ElementBase, parent_name: MAYBE_JOINT_REF):
name = node.get('name')
joint = JointNode(
name=name,
parent=parent,
parent_name=parent_name,
skeleton=weakref.ref(self),
translation=_get_vec_attr(node, "pos", Vector3()),
pivot=_get_vec_attr(node, "pivot", Vector3()),
rotation=_get_vec_attr(node, "rot", Vector3()),
scale=_get_vec_attr(node, "scale", Vector3(1, 1, 1)),
support=node.get('support', 'base'),
type=node.tag,
)
self.joint_dict[name] = joint
for child in node.iterchildren():
self._parse_node_children(child, weakref.ref(joint))
self._parse_node_children(child, joint.name)
def merge_mesh_skeleton(self, mesh: MeshAsset) -> None:
"""Update this skeleton with a skeleton definition from a mesh asset"""
skin_seg: Optional[SkinSegmentDict] = mesh.segments.get('skin')
if not skin_seg:
return
for joint_name, matrix in zip(skin_seg['joint_names'], skin_seg.get('alt_inverse_bind_matrix', [])):
# We're only meant to use the translation component from the alt inverse bind matrix.
joint_decomp = transformations.decompose_matrix(llsd_to_mat4(matrix))
joint_node = self.joint_dict.get(joint_name)
if not joint_node:
continue
joint_node.translation = Vector3(*joint_decomp[3])
if pelvis_offset := skin_seg.get('pelvis_offset'):
# TODO: Should we even do this?
pelvis_node = self["mPelvis"]
pelvis_node.translation += Vector3(0, 0, pelvis_offset)
def _get_vec_attr(node, attr_name: str, default: Vector3) -> Vector3:

View File

@@ -84,7 +84,7 @@ class Circuit:
def send(self, message: Message, transport=None) -> UDPPacket:
if self.prepare_message(message):
# If the message originates from us then we're responsible for resends.
if message.reliable and message.synthetic:
if message.reliable and message.synthetic and not transport:
self.unacked_reliable[(message.direction, message.packet_id)] = ReliableResendInfo(
last_resent=dt.datetime.now(),
message=message,

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

@@ -42,7 +42,7 @@ class Object(recordclass.RecordClass, use_weakref=True): # type: ignore
CRC: Optional[int] = None
PCode: Optional[tmpls.PCode] = None
Material: Optional[tmpls.MCode] = None
ClickAction: Optional[int] = None
ClickAction: Optional[tmpls.ClickAction] = None
Scale: Optional[Vector3] = None
ParentID: Optional[int] = None
# Actually contains a weakref proxy
@@ -243,6 +243,7 @@ def normalize_object_update(block: Block, handle: int):
"NameValue": block.deserialize_var("NameValue", make_copy=False),
"TextureAnim": block.deserialize_var("TextureAnim", make_copy=False),
"ExtraParams": block.deserialize_var("ExtraParams", make_copy=False) or {},
"ClickAction": block.deserialize_var("ClickAction", make_copy=False),
"PSBlock": block.deserialize_var("PSBlock", make_copy=False).value,
"UpdateFlags": block.deserialize_var("UpdateFlags", make_copy=False),
"State": block.deserialize_var("State", make_copy=False),
@@ -435,8 +436,8 @@ class FastObjectUpdateCompressedDataDeserializer:
"PCode": pcode,
"State": state,
"CRC": crc,
"Material": material,
"ClickAction": click_action,
"Material": tmpls.MCode(material),
"ClickAction": tmpls.ClickAction(click_action),
"Scale": scale,
"Position": pos,
"Rotation": rot,

View File

@@ -57,6 +57,7 @@ _ASSET_TYPE_BIDI: BiDiDict[str] = BiDiDict({
@se.enum_field_serializer("AssetUploadComplete", "AssetBlock", "Type")
@se.enum_field_serializer("UpdateCreateInventoryItem", "InventoryData", "Type")
@se.enum_field_serializer("CreateInventoryItem", "InventoryBlock", "Type")
@se.enum_field_serializer("LinkInventoryItem", "InventoryBlock", "Type")
@se.enum_field_serializer("RezObject", "InventoryData", "Type")
@se.enum_field_serializer("RezScript", "InventoryBlock", "Type")
@se.enum_field_serializer("UpdateTaskInventory", "InventoryData", "Type")
@@ -143,6 +144,7 @@ _INV_TYPE_BIDI: BiDiDict[str] = BiDiDict({
@se.enum_field_serializer("UpdateCreateInventoryItem", "InventoryData", "InvType")
@se.enum_field_serializer("CreateInventoryItem", "InventoryBlock", "InvType")
@se.enum_field_serializer("LinkInventoryItem", "InventoryBlock", "InvType")
@se.enum_field_serializer("RezObject", "InventoryData", "InvType")
@se.enum_field_serializer("RezScript", "InventoryBlock", "InvType")
@se.enum_field_serializer("UpdateTaskInventory", "InventoryData", "InvType")
@@ -980,6 +982,7 @@ class MCode(IntEnum):
# What's in the high nybble, anything?
STONE = 0
METAL = 1
GLASS = 2
WOOD = 3
FLESH = 4
PLASTIC = 5
@@ -1688,6 +1691,24 @@ class SoundFlags(IntFlag):
STOP = 1 << 5
@se.enum_field_serializer("ObjectClickAction", "ObjectData", "ClickAction")
@se.enum_field_serializer("ObjectUpdate", "ObjectData", "ClickAction")
class ClickAction(IntEnum):
# "NONE" is also used as an alias for "TOUCH"
TOUCH = 0
SIT = 1
BUY = 2
PAY = 3
OPEN = 4
PLAY = 5
OPEN_MEDIA = 6
ZOOM = 7
DISABLED = 8
IGNORE = 9
# I've seen this in practice, not clear what it is.
UNKNOWN = 255
class CompressedFlags(IntFlag):
SCRATCHPAD = 1
TREE = 1 << 1
@@ -1722,7 +1743,7 @@ class ObjectUpdateCompressedDataSerializer(se.SimpleSubfieldSerializer):
"State": ObjectStateAdapter(se.U8),
"CRC": se.U32,
"Material": se.IntEnum(MCode, se.U8),
"ClickAction": se.U8,
"ClickAction": se.IntEnum(ClickAction, se.U8),
"Scale": se.Vector3,
"Position": se.Vector3,
"Rotation": se.PackedQuat(se.Vector3),
@@ -1963,6 +1984,7 @@ class RegionFlags(IntFlag):
ALLOW_VOICE = 1 << 28
BLOCK_PARCEL_SEARCH = 1 << 29
DENY_AGEUNVERIFIED = 1 << 30
DENY_BOTS = 1 << 31
@se.flag_field_serializer("RegionHandshakeReply", "RegionInfo", "Flags")

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

@@ -330,7 +330,7 @@ class HippoClientSession(BaseClientSession):
super().__init__(id, secure_session_id, agent_id, circuit_code, session_manager, login_data=login_data)
self.http_session = session_manager.http_session
self.objects = ClientWorldObjectManager(proxify(self), session_manager.settings, None)
self.inventory_manager = InventoryManager(proxify(self))
self.inventory = InventoryManager(proxify(self))
self.transport: Optional[SocketUDPTransport] = None
self.protocol: Optional[HippoClientProtocol] = None
self.message_handler.take_by_default = False
@@ -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

@@ -6,16 +6,18 @@ import gzip
import itertools
import logging
from pathlib import Path
from typing import Union, List, Tuple, Set, Sequence
from typing import Union, List, Tuple, Set, Sequence, Dict, TYPE_CHECKING
from hippolyzer.lib.base import llsd
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.inventory import InventoryModel, InventoryCategory, InventoryItem, InventoryNodeBase
from hippolyzer.lib.base.message.message import Message, Block
from hippolyzer.lib.base.templates import AssetType, FolderType, InventoryType, Permissions
from hippolyzer.lib.client.state import BaseClientSession
from hippolyzer.lib.base.templates import WearableType
if TYPE_CHECKING:
from hippolyzer.lib.client.state import BaseClientSession
LOG = logging.getLogger(__name__)
@@ -38,6 +40,7 @@ class InventoryManager:
self._session.message_handler.subscribe("BulkUpdateInventory", self._handle_bulk_update_inventory)
self._session.message_handler.subscribe("UpdateCreateInventoryItem", self._handle_update_create_inventory_item)
self._session.message_handler.subscribe("RemoveInventoryItem", self._handle_remove_inventory_item)
self._session.message_handler.subscribe("RemoveInventoryObjects", self._handle_remove_inventory_objects)
self._session.message_handler.subscribe("RemoveInventoryFolder", self._handle_remove_inventory_folder)
self._session.message_handler.subscribe("MoveInventoryItem", self._handle_move_inventory_item)
self._session.message_handler.subscribe("MoveInventoryFolder", self._handle_move_inventory_folder)
@@ -117,6 +120,7 @@ class InventoryManager:
# Line-delimited LLSD notation!
for line in f.readlines():
# TODO: Parsing of invcache is dominated by `parse_notation()`. It's stupidly inefficient.
# TODO: sniff out binary LLSD invcaches
node_llsd = llsd.parse_notation(line)
if first_line:
# First line is the file header
@@ -137,7 +141,7 @@ class InventoryManager:
def _handle_bulk_update_inventory(self, msg: Message):
any_cats = False
for folder_block in msg["FolderData"]:
for folder_block in msg.get_blocks("FolderData", ()):
if folder_block["FolderID"] == UUID.ZERO:
continue
any_cats = True
@@ -147,7 +151,7 @@ class InventoryManager:
# and hasn't just moved.
update_fields={"parent_id", "name", "pref_type"},
)
for item_block in msg["ItemData"]:
for item_block in msg.get_blocks("ItemData", ()):
if item_block["ItemID"] == UUID.ZERO:
continue
self.model.upsert(InventoryItem.from_inventory_data(item_block))
@@ -178,6 +182,17 @@ class InventoryManager:
if node:
self.model.unlink(node)
def _handle_remove_inventory_objects(self, msg: Message):
self._validate_recipient(msg["AgentData"]["AgentID"])
for item_block in msg.get_blocks("ItemData", []):
node = self.model.get(item_block["ItemID"])
if node:
self.model.unlink(node)
for folder_block in msg.get_blocks("FolderData", []):
node = self.model.get(folder_block["FolderID"])
if node:
self.model.unlink(node)
def _handle_move_inventory_item(self, msg: Message):
for inventory_block in msg["InventoryData"]:
node = self.model.get(inventory_block["ItemID"])
@@ -341,6 +356,58 @@ class InventoryManager:
await self._session.main_region.circuit.send_reliable(msg)
node.parent_id = new_parent
async def copy(self, node: InventoryNodeBase, destination: UUID | InventoryCategory, contents: bool = True)\
-> InventoryItem | InventoryCategory:
destination = _get_node_id(destination)
if isinstance(node, InventoryItem):
with self._session.main_region.message_handler.subscribe_async(
("BulkUpdateInventory",),
# Not ideal, but there doesn't seem to be an easy way to determine the transaction ID,
# and using the callback ID seems a bit crap.
predicate=lambda x: x["ItemData"]["Name"] == node.name,
take=False,
) as get_msg:
await self._session.main_region.circuit.send_reliable(Message(
'CopyInventoryItem',
Block('AgentData', AgentID=self._session.agent_id, SessionID=self._session.id),
Block(
'InventoryData',
CallbackID=0,
OldAgentID=self._session.agent_id,
OldItemID=node.item_id,
NewFolderID=destination,
NewName=b''
)
))
msg = await asyncio.wait_for(get_msg(), 5.0)
# BulkInventoryUpdate message may not have already been handled internally, do it manually.
self._handle_bulk_update_inventory(msg)
# Now pull the item out of the inventory
new_item = self.model.get(msg["ItemData"]["ItemID"])
assert new_item is not None
return new_item # type: ignore
elif isinstance(node, InventoryCategory):
# Keep a list of the original descendents in case we're copy a folder within itself
to_copy = list(node.descendents)
# There's not really any way to "copy" a category, we just create a new one with the same properties.
new_cat = await self.create_folder(destination, node.name, node.pref_type)
if contents:
cat_lookup: Dict[UUID, UUID] = {node.node_id: new_cat.node_id}
# Recreate the category hierarchy first, keeping note of the new category IDs.
for node in to_copy:
if isinstance(node, InventoryCategory):
new_parent = cat_lookup[node.parent_id]
cat_lookup[node.node_id] = (await self.copy(node, new_parent, contents=False)).node_id
# Items have to be explicitly copied individually
for node in to_copy:
if isinstance(node, InventoryItem):
new_parent = cat_lookup[node.parent_id]
await self.copy(node, new_parent, contents=False)
return new_cat
else:
raise ValueError(f"Unknown node type: {node!r}")
async def update(self, node: InventoryNodeBase, data: dict) -> None:
path = f"/category/{node.node_id}"
if isinstance(node, InventoryItem):

View File

@@ -15,7 +15,7 @@ from typing import *
from hippolyzer.lib.base.datatypes import UUID, Vector3
from hippolyzer.lib.base.helpers import proxify
from hippolyzer.lib.base.inventory import InventoryItem, InventoryModel
from hippolyzer.lib.base.inventory import InventoryItem, InventoryModel, InventoryObject
from hippolyzer.lib.base.message.message import Block, Message
from hippolyzer.lib.base.message.message_handler import MessageHandler
from hippolyzer.lib.base.message.msgtypes import PacketFlags
@@ -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:
@@ -227,7 +229,18 @@ class ClientObjectManager:
async def request_object_inv_via_cap(self, obj: Object) -> List[InventoryItem]:
async with self._region.caps_client.get("RequestTaskInventory", params={"task_id": obj.FullID}) as resp:
resp.raise_for_status()
return [InventoryItem.from_llsd(x) for x in (await resp.read_llsd())["contents"]]
all_items = [InventoryItem.from_llsd(x) for x in (await resp.read_llsd())["contents"]]
# Synthesize the Contents directory so the items can have a parent
parent = InventoryObject(
obj_id=obj.FullID,
name="Contents",
)
model = InventoryModel()
model.add(parent)
for item in all_items:
model.add(item)
return all_items
async def request_object_inv_via_xfer(self, obj: Object) -> List[InventoryItem]:
session = self._region.session()
@@ -299,6 +312,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)
@@ -366,6 +381,10 @@ class ClientWorldObjectManager:
futs.extend(region_mgr.request_object_properties(region_objs))
return futs
async def request_object_inv(self, obj: Object) -> List[InventoryItem]:
region_mgr = self._get_region_manager(obj.RegionHandle)
return await region_mgr.request_object_inv(obj)
async def load_ancestors(self, obj: Object, wait_time: float = 1.0):
"""
Ensure that the entire chain of parents above this object is loaded
@@ -663,17 +682,43 @@ 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}")
return
obj.Animations.clear()
for block in message["AnimationList"]:
for block in message.blocks.get("AnimationList", []):
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 +998,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

@@ -18,6 +18,7 @@ from hippolyzer.lib.base.network.caps_client import CapsClient
from hippolyzer.lib.base.network.transport import ADDR_TUPLE
from hippolyzer.lib.base.objects import handle_to_global_pos
from hippolyzer.lib.base.xfer_manager import XferManager
from hippolyzer.lib.client.inventory_manager import InventoryManager
from hippolyzer.lib.client.object_manager import ClientObjectManager, ClientWorldObjectManager
@@ -91,6 +92,7 @@ class BaseClientSession(abc.ABC):
region_by_handle: Callable[[int], Optional[BaseClientRegion]]
region_by_circuit_addr: Callable[[ADDR_TUPLE], Optional[BaseClientRegion]]
objects: ClientWorldObjectManager
inventory: InventoryManager
login_data: Dict[str, Any]
REGION_CLS = Type[BaseClientRegion]

View File

@@ -341,10 +341,15 @@ class MITMProxyEventManager:
msg.sender = region.circuit_addr
msg.direction = Direction.IN
try:
session.message_handler.handle(msg)
except:
LOG.exception("Failed while handling EQ message for session")
try:
region.message_handler.handle(msg)
except:
LOG.exception("Failed while handling EQ message")
LOG.exception("Failed while handling EQ message for region")
handle_event = AddonManager.handle_eq_event(session, region, event)
if handle_event is True:
@@ -360,7 +365,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

@@ -158,6 +158,12 @@ class ProxyInventoryManager(InventoryManager):
await super().move(node, new_parent)
await self._session.main_region.circuit.send_reliable(self._craft_update_message(node))
async def copy(self, node: InventoryNodeBase, destination: UUID | InventoryCategory, contents: bool = True)\
-> InventoryCategory | InventoryItem:
ret_node = await super().copy(node, destination, contents)
await self._session.main_region.circuit.send_reliable(self._craft_update_message(node))
return ret_node
def _craft_removal_message(self, node: InventoryNodeBase) -> Message:
is_folder = True
if isinstance(node, InventoryItem):

View File

@@ -7,6 +7,7 @@ import copy
import fnmatch
import gzip
import io
import json
import logging
import pickle
import re
@@ -507,6 +508,8 @@ class HTTPMessageLogEntry(AbstractMessageLogEntry):
raise
elif any(content_type.startswith(x) for x in ("application/xml", "text/xml")):
beautified = self._format_xml(message.content)
elif "json" in content_type:
beautified = json.dumps(json.loads(message.content), indent=2)
except:
LOG.exception("Failed to beautify message")

View File

@@ -34,6 +34,7 @@ if TYPE_CHECKING:
class Session(BaseClientSession):
regions: MutableSequence[ProxiedRegion]
inventory: ProxyInventoryManager
region_by_handle: Callable[[int], Optional[ProxiedRegion]]
region_by_circuit_addr: Callable[[ADDR_TUPLE], Optional[ProxiedRegion]]
main_region: Optional[ProxiedRegion]

View File

@@ -26,24 +26,31 @@ classifiers = [
]
dependencies = [
"aiohttp<4.0.0",
"arpeggio",
"defusedxml",
"gltflib",
"Glymur<0.9.7",
"idna<3,>=2.5",
"lazy-object-proxy",
"llsd<1.1.0",
"mitmproxy>=11.0.0,<12",
"numpy<2.0",
"outleap<1.0",
"ptpython<4.0",
"pycollada",
"pyside6-essentials",
"qasync",
"recordclass>=0.23.1,<0.24",
"transformations",
]
[project.optional-dependencies]
proxy = [
"arpeggio",
"mitmproxy>=11.0.0,<12",
"outleap<1.0",
"ptpython<4.0",
"Werkzeug<4.0",
]
gui = [
"hippolyzer[proxy]",
"pyside6-essentials",
"qasync",
]
[tool.setuptools.packages.find]
where = ["."]

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)

View File

@@ -20,7 +20,7 @@ class TestSkeleton(unittest.TestCase):
self.assertEqual(113, self.skeleton["mKneeLeft"].index)
def test_get_joint_parent(self):
self.assertEqual("mChest", self.skeleton["mNeck"].parent().name)
self.assertEqual("mChest", self.skeleton["mNeck"].parent.name)
def test_get_joint_matrix(self):
expected_mat = np.array([
@@ -30,3 +30,9 @@ class TestSkeleton(unittest.TestCase):
[0., 0., 0., 1.]
])
np.testing.assert_equal(expected_mat, self.skeleton["mNeck"].matrix)
def test_get_inverse_joint(self):
self.assertEqual("R_CLAVICLE", self.skeleton["L_CLAVICLE"].inverse.name)
self.assertEqual(None, self.skeleton["mChest"].inverse)
self.assertEqual("mHandMiddle1Right", self.skeleton["mHandMiddle1Left"].inverse.name)
self.assertEqual("RIGHT_HANDLE", self.skeleton["LEFT_HANDLE"].inverse.name)

View File

@@ -159,7 +159,7 @@ class TestHippoClient(unittest.IsolatedAsyncioTestCase):
async def test_inventory_manager(self):
await self._log_client_in(self.client)
self.assertEqual(self.client.session.inventory_manager.model.root.node_id, UUID(int=4))
self.assertEqual(self.client.session.inventory.model.root.node_id, UUID(int=4))
async def test_resend_suppression(self):
"""Make sure the client only handles the first seen copy of a reliable message"""

View File

@@ -0,0 +1,61 @@
import unittest
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.client.inventory_manager import InventoryManager
from tests.client import MockClientRegion
CREATE_FOLDER_PAYLOAD = {
'_base_uri': 'slcap://InventoryAPIv3',
'_created_categories': [
UUID(int=2),
],
'_created_items': [],
'_embedded': {
'categories': {
f'{UUID(int=2)}': {
'_embedded': {'categories': {}, 'items': {}, 'links': {}},
'_links': {
'parent': {'href': f'/category/{UUID(int=1)}'},
'self': {'href': f'/category/{UUID(int=2)}'}
},
'agent_id': f'{UUID(int=9)}',
'category_id': f'{UUID(int=2)}',
'name': 'New Folder',
'parent_id': f'{UUID(int=1)}',
'type_default': -1,
'version': 1
}
},
'items': {}, 'links': {}
},
'_links': {
'categories': {'href': f'/category/{UUID(int=1)}/categories'},
'category': {'href': f'/category/{UUID(int=1)}', 'name': 'self'},
'children': {'href': f'/category/{UUID(int=1)}/children'},
'items': {'href': f'/category/{UUID(int=1)}/items'},
'links': {'href': f'/category/{UUID(int=1)}/links'},
'parent': {'href': '/category/00000000-0000-0000-0000-000000000000'},
'self': {'href': f'/category/{UUID(int=1)}/children'}
},
'_updated_category_versions': {str(UUID(int=1)): 27},
'agent_id': UUID(int=9),
'category_id': UUID(int=1),
'name': 'My Inventory',
'parent_id': UUID.ZERO,
'type_default': 8,
'version': 27,
}
class TestParcelOverlay(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.region = MockClientRegion()
self.session = self.region.session()
self.inv_manager = InventoryManager(self.session)
self.model = self.inv_manager.model
self.handler = self.region.message_handler
def test_create_folder_response(self):
self.inv_manager.process_aisv3_response(CREATE_FOLDER_PAYLOAD)
self.assertIsNotNone(self.model.get_category(UUID(int=1)))
self.assertIsNotNone(self.model.get_category(UUID(int=2)))