13 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
18 changed files with 250 additions and 21 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

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

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

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

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

@@ -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")
@@ -1982,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

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

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
@@ -229,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()
@@ -370,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

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:

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

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