Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f87ec8725 | ||
|
|
fb885d8eec | ||
|
|
78281ed12b | ||
|
|
4087eaa3c6 | ||
|
|
32428941d7 | ||
|
|
0cc3397402 | ||
|
|
0c2dfd3213 | ||
|
|
e119181e3f | ||
|
|
64c7265578 | ||
|
|
eb652152f5 | ||
|
|
cd03dd4fdd | ||
|
|
056e142347 | ||
|
|
927a353dec | ||
|
|
bc68eeb7d2 | ||
|
|
de79f42aa6 | ||
|
|
e138ae88a1 | ||
|
|
e20a4a01ad | ||
|
|
a2b49fdc44 | ||
|
|
988a82179e | ||
|
|
4eb97b5958 |
4
.github/workflows/bundle_windows.yml
vendored
4
.github/workflows/bundle_windows.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/pytest.yml
vendored
2
.github/workflows/pytest.yml
vendored
@@ -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 .
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
94
addon_examples/appearance_delay_tracker.py
Normal file
94
addon_examples/appearance_delay_tracker.py
Normal 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()]
|
||||
44
addon_examples/create_shape.py
Normal file
44
addon_examples/create_shape.py
Normal 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()]
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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__
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 = ["."]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"""
|
||||
|
||||
61
tests/client/test_inventory_manager.py
Normal file
61
tests/client/test_inventory_manager.py
Normal 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)))
|
||||
Reference in New Issue
Block a user