diff --git a/addon_examples/puppetry_example.py b/addon_examples/puppetry_example.py new file mode 100644 index 0000000..0adade2 --- /dev/null +++ b/addon_examples/puppetry_example.py @@ -0,0 +1,111 @@ +""" +Control a puppetry-enabled viewer and make your neck spin like crazy + +It currently requires a custom rebased Firestorm with puppetry applied on top, +and patches applied on top to make startup LEAP scripts be treated as puppetry modules. +Basically, you probably don't want to use this yet. But hey, Puppetry is still only +on the beta grid anyway. +""" +import asyncio +import enum +import logging +import math +from typing import * + +import outleap + +from hippolyzer.lib.base.datatypes import Quaternion +from hippolyzer.lib.proxy.addon_utils import BaseAddon, SessionProperty +from hippolyzer.lib.proxy.sessions import Session + +LOG = logging.getLogger(__name__) + + +class BodyPartMask(enum.IntFlag): + """Which joints to send the viewer as part of "move" puppetry command""" + HEAD = 1 << 0 + FACE = 1 << 1 + LHAND = 1 << 2 + RHAND = 1 << 3 + FINGERS = 1 << 4 + + +def register_puppetry_command(func: Callable[[dict], Awaitable[None]]): + """Register a method as handling inbound puppetry commands from the viewer""" + func._puppetry_command = True + return func + + +class PuppetryExampleAddon(BaseAddon): + server_skeleton: Dict[str, Dict[str, Any]] = SessionProperty(dict) + camera_num: int = SessionProperty(0) + parts_active: BodyPartMask = SessionProperty(lambda: BodyPartMask(0x1F)) + puppetry_api: Optional[outleap.LLPuppetryAPI] = SessionProperty(None) + leap_client: Optional[outleap.LEAPClient] = SessionProperty(None) + + def handle_session_init(self, session: Session): + if not session.leap_client: + return + self.puppetry_api = outleap.LLPuppetryAPI(session.leap_client) + self.leap_client = session.leap_client + self._schedule_task(self._serve()) + self._schedule_task(self._exorcist(session)) + + @register_puppetry_command + async def enable_parts(self, args: dict): + if (new_mask := args.get("parts_mask")) is not None: + self.parts_active = BodyPartMask(new_mask) + + @register_puppetry_command + async def set_camera(self, args: dict): + if (camera_num := args.get("camera_num")) is not None: + self.camera_num = BodyPartMask(camera_num) + + @register_puppetry_command + async def stop(self, _args: dict): + LOG.info("Viewer asked us to stop puppetry") + + @register_puppetry_command + async def log(self, _args: dict): + # Intentionally ignored, we don't care about things the viewer + # asked us to log + pass + + @register_puppetry_command + async def set_skeleton(self, args: dict): + # Don't really care about what the viewer thinks the view of the skeleton is. + # Just log store it. + self.server_skeleton = args + + async def _serve(self): + """Handle inbound puppetry commands from viewer in a loop""" + async with self.leap_client.listen_scoped("puppetry.controller") as listener: + while True: + msg = await listener.get() + cmd = msg["command"] + handler = getattr(self, cmd, None) + if handler is None or not hasattr(handler, "_puppetry_command"): + LOG.warning(f"Unknown puppetry command {cmd!r}: {msg!r}") + continue + await handler(msg.get("args", {})) + + async def _exorcist(self, session): + """Do the Linda Blair thing with your neck""" + spin_rad = 0.0 + while True: + await asyncio.sleep(0.05) + if not session.main_region: + continue + # Wrap spin_rad around if necessary + while spin_rad > math.pi: + spin_rad -= math.pi * 2 + + # LEAP wants rot as a quaternion with just the imaginary parts. + neck_rot = Quaternion.from_euler(0, 0, spin_rad).data(3) + self.puppetry_api.move({ + "mNeck": {"no_constraint": True, "local_rot": neck_rot}, + }) + spin_rad += math.pi / 25 + + +addons = [PuppetryExampleAddon()] diff --git a/hippolyzer/lib/base/templates.py b/hippolyzer/lib/base/templates.py index edc0567..6fcb8bd 100644 --- a/hippolyzer/lib/base/templates.py +++ b/hippolyzer/lib/base/templates.py @@ -5,13 +5,14 @@ Serialization templates for structures used in LLUDP and HTTP bodies. import abc import collections import dataclasses +import enum import math import zlib from typing import * import hippolyzer.lib.base.serialization as se from hippolyzer.lib.base import llsd -from hippolyzer.lib.base.datatypes import UUID, IntEnum, IntFlag, Vector3 +from hippolyzer.lib.base.datatypes import UUID, IntEnum, IntFlag, Vector3, Quaternion from hippolyzer.lib.base.namevalue import NameValuesSerializer @@ -2055,3 +2056,69 @@ class RetrieveNavMeshSrcSerializer(se.BaseHTTPSerializer): # 15 bit window size, gzip wrapped deser["navmesh_data"] = zlib.decompress(deser["navmesh_data"], wbits=15 | 32) return deser + + +# Beta puppetry stuff, subject to change! + + +class PuppetryEventMask(enum.IntFlag): + POSITION = 1 << 0 + POSITION_IN_PARENT_FRAME = 1 << 1 + ROTATION = 1 << 2 + ROTATION_IN_PARENT_FRAME = 1 << 3 + SCALE = 1 << 4 + DISABLE_CONSTRAINT = 1 << 7 + + +class PuppetryOption(se.OptionalFlagged): + def __init__(self, flag_val, spec): + super().__init__("mask", se.IntFlag(PuppetryEventMask, se.U8), flag_val, spec) + + +# Range to use for puppetry's quantized floats when converting to<->from U16 +LL_PELVIS_OFFSET_RANGE = (-5.0, 5.0) + + +@dataclasses.dataclass +class PuppetryJointData: + # Where does this number come from? `avatar_skeleton.xml`? + joint_id: int = se.dataclass_field(se.S16) + # Determines which fields will follow + mask: PuppetryEventMask = se.dataclass_field(se.IntFlag(PuppetryEventMask, se.U8)) + rotation: Optional[Quaternion] = se.dataclass_field( + # These are very odd scales for a quantized quaternion, but that's what they are. + PuppetryOption(PuppetryEventMask.ROTATION, se.PackedQuat(se.Vector3U16(*LL_PELVIS_OFFSET_RANGE))), + ) + position: Optional[Vector3] = se.dataclass_field( + PuppetryOption(PuppetryEventMask.POSITION, se.Vector3U16(*LL_PELVIS_OFFSET_RANGE)), + ) + scale: Optional[Vector3] = se.dataclass_field( + PuppetryOption(PuppetryEventMask.SCALE, se.Vector3U16(*LL_PELVIS_OFFSET_RANGE)), + ) + + +@dataclasses.dataclass +class PuppetryEventData: + time: int = se.dataclass_field(se.S32) + # Must be set manually due to below issue + num_joints: int = se.dataclass_field(se.U16) + # This field is packed in the least helpful way possible. The length field + # is in between the collection count and the collection data, but the length + # field essentially only tells you how many bytes until the end of the buffer + # proper, which you already know from msgsystem. Why is this here? + joints: List[PuppetryJointData] = se.dataclass_field(se.TypedByteArray( + se.U32, + # Just treat contents as a greedy collection, tries to keep reading until EOF + se.Collection(None, se.Dataclass(PuppetryJointData)), + )) + + +@se.subfield_serializer("AgentAnimation", "PhysicalAvatarEventList", "TypeData") +@se.subfield_serializer("AvatarAnimation", "PhysicalAvatarEventList", "TypeData") +class PuppetryEventDataSerializer(se.SimpleSubfieldSerializer): + # You can have multiple joint events packed in right after the other, implicitly. + # They may _or may not_ be split into separate PhysicalAvatarEventList blocks? + # This doesn't seem to be handled specifically in the decoder, is this a + # serialization bug in the viewer? + TEMPLATE = se.Collection(None, se.Dataclass(PuppetryEventData)) + EMPTY_IS_NONE = True