18 Commits

Author SHA1 Message Date
Salad Dais
ce130c4831 Use a newer cx_Freeze 2025-05-26 18:50:37 +00:00
Salad Dais
b6ac988601 Always fetch tags so SCM versioning works 2025-05-19 23:22:05 +00:00
Salad Dais
c8dbbef8fc Let's use newer Python versions 2025-05-19 23:14:40 +00:00
Salad Dais
a974f167d1 Update requirements and package dirs 2025-05-19 23:05:34 +00:00
Salad Dais
2d3b3daf10 Start switching to pyproject.toml 2025-05-19 22:49:05 +00:00
Salad Dais
1d54c70164 Update uses of recordclass and utcfromtimestamp() 2025-05-16 22:47:17 +00:00
Salad Dais
6dafe32f6a Update version to v0.15.6
I forgot I have to manually do it in this repo.
2025-04-18 04:33:00 +00:00
Salad Dais
3149d3610f Pin cx_freeze version 2025-04-18 04:30:11 +00:00
Salad Dais
f8f3bcfc36 Make PyPi stop whining about attestations 2025-04-18 04:26:42 +00:00
Salad Dais
8548cce4e5 Use new upload-artifact action 2025-04-18 04:19:52 +00:00
Salad Dais
ad2aca1803 Upgrade mitmproxy 2025-04-18 01:44:23 +00:00
Salad Dais
8cf500ce44 Me more verbose if we can't parse legacy schema 2025-04-18 01:43:10 +00:00
Salad Dais
ceda7f370e Update message template to upstream 2024-12-11 22:59:27 +00:00
Salad Dais
0692a10253 Add support for JankStringyBytes in LLSD 2024-12-11 22:58:56 +00:00
Salad Dais
c1c2a96295 Fix some event handling quirks 2024-12-11 22:56:50 +00:00
Salad Dais
b4be9fa757 Better handle resent reliable messages 2024-10-29 07:31:59 +00:00
Salad Dais
a8967f0b7d Handle unknown messages better 2024-10-29 07:31:35 +00:00
Salad Dais
10af5cc250 Handle more JankStringyBytes ops 2024-10-29 07:15:24 +00:00
27 changed files with 6590 additions and 6389 deletions

View File

@@ -23,11 +23,14 @@ jobs:
contents: write
strategy:
matrix:
python-version: ["3.11"]
python-version: ["3.12"]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Get history and tags for SCM versioning to work
run: |
git fetch --prune --unshallow
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
@@ -51,7 +54,7 @@ jobs:
mv ./dist/*.zip hippolyzer-windows-${{ env.target_tag }}.zip
- name: Upload the artifact
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: hippolyzer-windows-${{ env.sha }}
path: ./hippolyzer-windows-${{ env.target_tag }}.zip

View File

@@ -16,10 +16,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Get history and tags for SCM versioning to work
run: |
git fetch --prune --unshallow
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- uses: actions/setup-python@v2
with:
python-version: "3.10"
python-version: "3.12"
- name: Install dependencies
run: |
@@ -36,6 +40,7 @@ jobs:
user: __token__
password: ${{ secrets.TEST_PYPI_API_TOKEN }}
repository_url: https://test.pypi.org/legacy/
attestations: false
- name: Publish to PyPI
if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release'
@@ -43,3 +48,4 @@ jobs:
with:
user: __token__
password: ${{ secrets.PYPI_API_TOKEN }}
attestations: false

View File

@@ -14,11 +14,14 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.12"]
python-version: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Get history and tags for SCM versioning to work
run: |
git fetch --prune --unshallow
git fetch --depth=1 origin +refs/tags/*:refs/tags/*
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:

View File

@@ -27,7 +27,7 @@ with low-level SL details. See the [Local Animation addon example](https://githu
### From Source
* Python 3.10 or above is **required**. If you're unable to upgrade your system Python package due to
* Python 3.12 or above is **required**. If you're unable to upgrade your system Python package due to
being on a stable distro, you can use [pyenv](https://github.com/pyenv/pyenv) to create
a self-contained Python install with the appropriate version.
* [Create a clean Python 3 virtualenv](https://packaging.python.org/guides/installing-using-pip-and-virtual-environments/#creating-a-virtual-environment)

View File

@@ -322,12 +322,12 @@ class JankStringyBytes(bytes):
def __add__(self, other):
if isinstance(other, bytes):
return bytes(self) + other
return JankStringyBytes(bytes(self) + other)
return str(self) + other
def __radd__(self, other):
if isinstance(other, bytes):
return other + bytes(self)
return JankStringyBytes(other + bytes(self))
return other + str(self)
def lower(self):
@@ -336,6 +336,20 @@ class JankStringyBytes(bytes):
def upper(self):
return str(self).upper()
def startswith(self, __prefix, __start=None, __end=None):
if __start or __end:
raise RuntimeError("Can't handle __start or __end")
if isinstance(__prefix, str):
return str(self).startswith(__prefix)
return self.startswith(__prefix)
def endswith(self, __prefix, __start=None, __end=None):
if __start or __end:
raise RuntimeError("Can't handle __start or __end")
if isinstance(__prefix, str):
return str(self).endswith(__prefix)
return self.endswith(__prefix)
class RawBytes(bytes):
__slots__ = ()

View File

@@ -41,7 +41,8 @@ class Event:
return self
def _handler_key(self, handler):
@staticmethod
def _handler_key(handler):
return handler[:3]
def unsubscribe(self, handler, *args, **kwargs):
@@ -55,21 +56,23 @@ class Event:
raise ValueError(f"Handler {handler!r} is not subscribed to this event.")
return self
def _create_async_wrapper(self, handler, args, inner_args, kwargs):
# Note that unsubscription may be delayed due to asyncio scheduling :)
async def _run_handler_wrapper():
unsubscribe = await handler(args, *inner_args, **kwargs)
if unsubscribe:
_ = self.unsubscribe(handler, *inner_args, **kwargs)
return _run_handler_wrapper
def notify(self, args):
for handler in self.subscribers[:]:
handler, inner_args, kwargs, one_shot, predicate = handler
for subscriber in self.subscribers[:]:
handler, inner_args, kwargs, one_shot, predicate = subscriber
if predicate and not predicate(args):
continue
if one_shot:
self.unsubscribe(handler, *inner_args, **kwargs)
if asyncio.iscoroutinefunction(handler):
# Note that unsubscription may be delayed due to asyncio scheduling :)
async def _run_handler_wrapper():
unsubscribe = await handler(args, *inner_args, **kwargs)
if unsubscribe:
_ = self.unsubscribe(handler, *inner_args, **kwargs)
create_logged_task(_run_handler_wrapper(), self.name, LOG)
create_logged_task(self._create_async_wrapper(handler, args, inner_args, kwargs)(), self.name, LOG)
else:
try:
if handler(args, *inner_args, **kwargs) and not one_shot:

View File

@@ -171,7 +171,7 @@ def get_mtime(path):
def fut_logger(name: str, logger: logging.Logger, fut: asyncio.Future, *args) -> None:
"""Callback suitable for exception logging in `Future.add_done_callback()`"""
if fut.exception():
if not fut.cancelled() and fut.exception():
if isinstance(fut.exception(), asyncio.CancelledError):
# Don't really care if the task was just cancelled
return

View File

@@ -46,7 +46,7 @@ class SchemaFieldSerializer(abc.ABC, Generic[_T]):
class SchemaDate(SchemaFieldSerializer[dt.datetime]):
@classmethod
def deserialize(cls, val: str) -> dt.datetime:
return dt.datetime.utcfromtimestamp(int(val))
return dt.datetime.fromtimestamp(int(val), dt.timezone.utc)
@classmethod
def serialize(cls, val: dt.datetime) -> str:
@@ -54,7 +54,7 @@ class SchemaDate(SchemaFieldSerializer[dt.datetime]):
@classmethod
def from_llsd(cls, val: Any, flavor: str) -> dt.datetime:
return dt.datetime.utcfromtimestamp(val)
return dt.datetime.fromtimestamp(val, dt.timezone.utc)
@classmethod
def to_llsd(cls, val: dt.datetime, flavor: str):
@@ -190,32 +190,36 @@ class SchemaBase(abc.ABC):
def from_llsd(cls, inv_dict: Dict, flavor: str = "legacy"):
fields = cls._get_fields_dict(llsd_flavor=flavor)
obj_dict = {}
for key, val in inv_dict.items():
if key in fields:
field: dataclasses.Field = fields[key]
key = field.name
spec = field.metadata.get("spec")
# Not a real key, an internal var on our dataclass
if not spec:
LOG.warning(f"Internal key {key!r}")
continue
try:
for key, val in inv_dict.items():
if key in fields:
field: dataclasses.Field = fields[key]
key = field.name
spec = field.metadata.get("spec")
# Not a real key, an internal var on our dataclass
if not spec:
LOG.warning(f"Internal key {key!r}")
continue
spec_cls = spec
if not inspect.isclass(spec_cls):
spec_cls = spec_cls.__class__
spec_cls = spec
if not inspect.isclass(spec_cls):
spec_cls = spec_cls.__class__
# some kind of nested structure like sale_info
if issubclass(spec_cls, SchemaBase):
obj_dict[key] = spec.from_llsd(val, flavor)
elif issubclass(spec_cls, SchemaFieldSerializer):
obj_dict[key] = spec.from_llsd(val, flavor)
# some kind of nested structure like sale_info
if issubclass(spec_cls, SchemaBase):
obj_dict[key] = spec.from_llsd(val, flavor)
elif issubclass(spec_cls, SchemaFieldSerializer):
obj_dict[key] = spec.from_llsd(val, flavor)
else:
raise ValueError(f"Unsupported spec for {key!r}, {spec!r}")
else:
raise ValueError(f"Unsupported spec for {key!r}, {spec!r}")
else:
if flavor != "ais":
# AIS has a number of different fields that are irrelevant depending on
# what exactly sent the payload
LOG.warning(f"Unknown key {key!r}")
if flavor != "ais":
# AIS has a number of different fields that are irrelevant depending on
# what exactly sent the payload
LOG.warning(f"Unknown key {key!r}")
except:
LOG.error(f"Failed to parse inventory schema: {inv_dict!r}")
raise
return cls._obj_from_dict(obj_dict)
def to_bytes(self) -> bytes:

View File

@@ -16,10 +16,12 @@ from hippolyzer.lib.base.datatypes import *
class HippoLLSDBaseFormatter(base_llsd.base.LLSDBaseFormatter):
UUID: callable
ARRAY: callable
BINARY: callable
def __init__(self):
super().__init__()
self.type_map[UUID] = self.UUID
self.type_map[JankStringyBytes] = self.BINARY
self.type_map[Vector2] = self.TUPLECOORD
self.type_map[Vector3] = self.TUPLECOORD
self.type_map[Vector4] = self.TUPLECOORD
@@ -101,7 +103,7 @@ def _format_binary_recurse(something) -> bytes:
raise LLSDSerializationError(str(exc), something)
elif isinstance(something, uuid.UUID):
return b'u' + something.bytes
elif isinstance(something, binary):
elif isinstance(something, (binary, JankStringyBytes)):
return b'b' + struct.pack('!i', len(something)) + something
elif is_string(something):
if is_unicode(something):

File diff suppressed because it is too large Load Diff

View File

@@ -188,7 +188,7 @@ class MsgBlockList(List["Block"]):
class Message:
__slots__ = ("name", "send_flags", "packet_id", "acks", "body_boundaries", "queued",
"offset", "raw_extra", "raw_body", "deserializer", "_blocks", "finalized",
"direction", "meta", "synthetic", "dropped", "sender")
"direction", "meta", "synthetic", "dropped", "sender", "unknown_message")
def __init__(self, name, *args, packet_id=None, flags=0, acks=None, direction=None):
# TODO: Do this on a timer or something.
@@ -200,6 +200,7 @@ class Message:
self.acks = acks if acks is not None else tuple()
self.body_boundaries = (-1, -1)
self.unknown_message = False
self.offset = 0
self.raw_extra = b""
self.direction: Direction = direction if direction is not None else Direction.OUT
@@ -288,7 +289,7 @@ class Message:
def ensure_parsed(self):
# This is a little magic, think about whether we want this.
if self.raw_body and self.deserializer():
if self.raw_body and self.deserializer and self.deserializer():
self.deserializer().parse_message_body(self)
def to_dict(self, extended=False):

View File

@@ -126,8 +126,14 @@ class UDPMessageDeserializer:
frequency, num = _parse_msg_num(reader)
current_template = self.template_dict.get_template_by_pair(frequency, num)
if current_template is None:
raise exc.MessageTemplateNotFound("deserializing data", f"{frequency}:{num}")
msg.name = current_template.name
if self.settings.ALLOW_UNKNOWN_MESSAGES:
LOG.warning(f"Unknown message type {frequency}:{num}")
msg.unknown_message = True
msg.name = "UnknownMessage:%d" % num
else:
raise exc.MessageTemplateNotFound("deserializing data", f"{frequency}:{num}")
else:
msg.name = current_template.name
# extra field, see note regarding msg.offset
msg.raw_extra = reader.read_bytes(msg.offset)
@@ -143,6 +149,12 @@ class UDPMessageDeserializer:
# Already parsed if we don't have a raw body
if not raw_body:
return
if msg.unknown_message:
# We can't parse this, we don't know anything about it
msg.deserializer = None
return
msg.raw_body = None
msg.deserializer = None

View File

@@ -45,7 +45,7 @@ class UDPMessageSerializer:
def serialize(self, msg: Message):
current_template = self.template_dict.get_template_by_name(msg.name)
if current_template is None:
if current_template is None and msg.raw_body is None:
raise exc.MessageSerializationError("message name", "invalid message name")
# Header and trailers are all big-endian

View File

@@ -1728,7 +1728,6 @@ class QuantizedNumPyArray(Adapter):
def subfield_serializer(msg_name, block_name, var_name):
def f(orig_cls):
global SUBFIELD_SERIALIZERS
SUBFIELD_SERIALIZERS[(msg_name, block_name, var_name)] = orig_cls
return orig_cls
return f
@@ -1940,7 +1939,6 @@ class IntFlagSubfieldSerializer(AdapterInstanceSubfieldSerializer):
def http_serializer(msg_name):
def f(orig_cls):
global HTTP_SERIALIZERS
HTTP_SERIALIZERS[msg_name] = orig_cls
return orig_cls
return f

View File

@@ -55,6 +55,7 @@ class SettingDescriptor(Generic[_T]):
class Settings:
ENABLE_DEFERRED_PACKET_PARSING: bool = SettingDescriptor(True)
ALLOW_UNKNOWN_MESSAGES: bool = SettingDescriptor(True)
def __init__(self):
self._settings: Dict[str, Any] = {}

View File

@@ -1822,9 +1822,20 @@ class ChatSourceType(IntEnum):
UNKNOWN = 3
@dataclasses.dataclass
class ThrottleData:
resend: float = se.dataclass_field(se.F32)
land: float = se.dataclass_field(se.F32)
wind: float = se.dataclass_field(se.F32)
cloud: float = se.dataclass_field(se.F32)
task: float = se.dataclass_field(se.F32)
texture: float = se.dataclass_field(se.F32)
asset: float = se.dataclass_field(se.F32)
@se.subfield_serializer("AgentThrottle", "Throttle", "Throttles")
class AgentThrottlesSerializer(se.SimpleSubfieldSerializer):
TEMPLATE = se.Collection(None, se.F32)
TEMPLATE = se.Dataclass(ThrottleData)
@se.subfield_serializer("ObjectUpdate", "ObjectData", "NameValue")

View File

@@ -5,6 +5,7 @@ Body parts and linden clothing layers
from __future__ import annotations
import dataclasses
import enum
import logging
from io import StringIO
from typing import *
@@ -21,6 +22,60 @@ LOG = logging.getLogger(__name__)
_T = TypeVar("_T")
WEARABLE_VERSION = "LLWearable version 22"
DEFAULT_WEARABLE_TEX = UUID("c228d1cf-4b5d-4ba8-84f4-899a0796aa97")
class AvatarTEIndex(enum.IntEnum):
"""From llavatarappearancedefines.h"""
HEAD_BODYPAINT = 0
UPPER_SHIRT = enum.auto()
LOWER_PANTS = enum.auto()
EYES_IRIS = enum.auto()
HAIR = enum.auto()
UPPER_BODYPAINT = enum.auto()
LOWER_BODYPAINT = enum.auto()
LOWER_SHOES = enum.auto()
HEAD_BAKED = enum.auto()
UPPER_BAKED = enum.auto()
LOWER_BAKED = enum.auto()
EYES_BAKED = enum.auto()
LOWER_SOCKS = enum.auto()
UPPER_JACKET = enum.auto()
LOWER_JACKET = enum.auto()
UPPER_GLOVES = enum.auto()
UPPER_UNDERSHIRT = enum.auto()
LOWER_UNDERPANTS = enum.auto()
SKIRT = enum.auto()
SKIRT_BAKED = enum.auto()
HAIR_BAKED = enum.auto()
LOWER_ALPHA = enum.auto()
UPPER_ALPHA = enum.auto()
HEAD_ALPHA = enum.auto()
EYES_ALPHA = enum.auto()
HAIR_ALPHA = enum.auto()
HEAD_TATTOO = enum.auto()
UPPER_TATTOO = enum.auto()
LOWER_TATTOO = enum.auto()
HEAD_UNIVERSAL_TATTOO = enum.auto()
UPPER_UNIVERSAL_TATTOO = enum.auto()
LOWER_UNIVERSAL_TATTOO = enum.auto()
SKIRT_TATTOO = enum.auto()
HAIR_TATTOO = enum.auto()
EYES_TATTOO = enum.auto()
LEFT_ARM_TATTOO = enum.auto()
LEFT_LEG_TATTOO = enum.auto()
AUX1_TATTOO = enum.auto()
AUX2_TATTOO = enum.auto()
AUX3_TATTOO = enum.auto()
LEFTARM_BAKED = enum.auto()
LEFTLEG_BAKED = enum.auto()
AUX1_BAKED = enum.auto()
AUX2_BAKED = enum.auto()
AUX3_BAKED = enum.auto()
@property
def is_baked(self) -> bool:
return self.name.endswith("_BAKED")
@dataclasses.dataclass

View File

@@ -23,7 +23,7 @@ from hippolyzer.lib.base.message.udpdeserializer import UDPMessageDeserializer
from hippolyzer.lib.base.network.caps_client import CapsClient, CAPS_DICT
from hippolyzer.lib.base.network.transport import ADDR_TUPLE, Direction, SocketUDPTransport, AbstractUDPTransport
from hippolyzer.lib.base.settings import Settings, SettingDescriptor
from hippolyzer.lib.base.templates import RegionHandshakeReplyFlags, ChatType
from hippolyzer.lib.base.templates import RegionHandshakeReplyFlags, ChatType, ThrottleData
from hippolyzer.lib.base.transfer_manager import TransferManager
from hippolyzer.lib.base.xfer_manager import XferManager
from hippolyzer.lib.client.asset_uploader import AssetUploader
@@ -108,8 +108,9 @@ class HippoClientProtocol(asyncio.DatagramProtocol):
if should_handle:
self.session.message_handler.handle(message)
except:
LOG.exception("Failed in region message handler")
region.message_handler.handle(message)
LOG.exception("Failed in session message handler")
if should_handle:
region.message_handler.handle(message)
class HippoClientRegion(BaseClientRegion):
@@ -189,7 +190,7 @@ class HippoClientRegion(BaseClientRegion):
"RegionInfo",
Flags=(
RegionHandshakeReplyFlags.SUPPORTS_SELF_APPEARANCE
| RegionHandshakeReplyFlags.VOCACHE_IS_EMPTY
| RegionHandshakeReplyFlags.VOCACHE_CULLING_ENABLED
)
)
)
@@ -207,7 +208,15 @@ class HippoClientRegion(BaseClientRegion):
"Throttle",
GenCounter=0,
# Reasonable defaults, I guess
Throttles_=[207360.0, 165376.0, 33075.19921875, 33075.19921875, 682700.75, 682700.75, 269312.0],
Throttles_=ThrottleData(
resend=207360.0,
land=165376.0,
wind=33075.19921875,
cloud=33075.19921875,
task=682700.75,
texture=682700.75,
asset=269312.0
),
)
)
)
@@ -276,21 +285,25 @@ class HippoClientRegion(BaseClientRegion):
ack: Optional[int] = None
while True:
payload = {"ack": ack, "done": False}
async with self.caps_client.post("EventQueueGet", llsd=payload) as resp:
if resp.status != 200:
await asyncio.sleep(0.1)
continue
polled = await resp.read_llsd()
for event in polled["events"]:
if self._llsd_serializer.can_handle(event["message"]):
msg = self._llsd_serializer.deserialize(event)
else:
msg = Message.from_eq_event(event)
msg.sender = self.circuit_addr
msg.direction = Direction.IN
self.session().message_handler.handle(msg)
self.message_handler.handle(msg)
ack = polled["id"]
try:
async with self.caps_client.post("EventQueueGet", llsd=payload) as resp:
if resp.status != 200:
await asyncio.sleep(0.1)
continue
polled = await resp.read_llsd()
for event in polled["events"]:
if self._llsd_serializer.can_handle(event["message"]):
msg = self._llsd_serializer.deserialize(event)
else:
msg = Message.from_eq_event(event)
msg.sender = self.circuit_addr
msg.direction = Direction.IN
self.session().message_handler.handle(msg)
self.message_handler.handle(msg)
ack = polled["id"]
await asyncio.sleep(0.001)
except aiohttp.client_exceptions.ServerDisconnectedError:
# This is expected to happen during long-polling, just pick up again where we left off.
await asyncio.sleep(0.001)
async def _handle_ping_check(self, message: Message):

View File

@@ -116,18 +116,6 @@ class IPCInterceptionAddon:
self.to_proxy_queue: multiprocessing.Queue = flow_context.to_proxy_queue
self.shutdown_signal: multiprocessing.Event = flow_context.shutdown_signal
def add_log(self, entry: mitmproxy.log.LogEntry):
if entry.level == "debug":
logging.debug(entry.msg)
elif entry.level in ("alert", "info"):
# TODO: All mitmproxy infos are basically debugs, should
# probably give these dedicated loggers
logging.debug(entry.msg)
elif entry.level == "warn":
logging.warning(entry.msg)
elif entry.level == "error":
logging.error(entry.msg)
def running(self):
# register to pump the events or something here
create_logged_task(self._pump_callbacks(), "Pump HTTP proxy callbacks")

69
pyproject.toml Normal file
View File

@@ -0,0 +1,69 @@
[build-system]
requires = ["setuptools>=64", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "hippolyzer"
dynamic = ["version"]
description = "Analysis tools for SL-compatible virtual worlds"
readme = "README.md"
license = "LGPL-3.0-only"
requires-python = ">=3.12"
authors = [
{ name = "Salad Dais", email = "83434023+SaladDais@users.noreply.github.com" },
]
classifiers = [
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Software Development :: Testing",
"Topic :: System :: Networking :: Monitoring",
]
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",
"Werkzeug<4.0",
]
[tool.setuptools.packages.find]
where = ["."]
include = ["hippolyzer*"]
namespaces = false
[project.scripts]
hippolyzer-cli = "hippolyzer.apps.proxy:main"
hippolyzer-gui = "hippolyzer.apps.proxy_gui:gui_main"
[project.urls]
Homepage = "https://github.com/SaladDais/Hippolyzer/"
[tool.black]
line-length = 160
[tool.pytest.ini_options]
minversion = "6.0"
[tool.isort]
profile = "black"
[tool.setuptools_scm]

View File

@@ -1,77 +1,81 @@
aiohttp==3.9.2
aioquic==0.9.25
aiosignal==1.3.1
aiohappyeyeballs==2.6.1
aiohttp==3.11.18
aioquic==1.2.0
aiosignal==1.3.2
appdirs==1.4.4
argon2-cffi==23.1.0
argon2-cffi-bindings==21.2.0
Arpeggio==2.0.2
asgiref==3.7.2
attrs==23.2.0
blinker==1.7.0
asgiref==3.8.1
attrs==25.3.0
blinker==1.9.0
Brotli==1.1.0
certifi==2023.11.17
cffi==1.16.0
click==8.1.7
cryptography==41.0.7
dataclasses-json==0.6.3
certifi==2025.4.26
cffi==1.17.1
click==8.2.0
cryptography==44.0.3
dataclasses-json==0.6.7
defusedxml==0.7.1
Flask==2.3.3
frozenlist==1.4.1
Flask==3.1.0
frozenlist==1.6.0
gltflib==1.0.13
Glymur==0.9.6
h11==0.14.0
h2==4.1.0
hpack==4.0.0
hyperframe==6.0.1
hpack==4.1.0
hyperframe==6.1.0
idna==2.10
itsdangerous==2.1.2
jedi==0.19.1
Jinja2==3.1.3
itsdangerous==2.2.0
jedi==0.19.2
Jinja2==3.1.6
kaitaistruct==0.10
lazy-object-proxy==1.10.0
lazy-object-proxy==1.11.0
ldap3==2.9.1
llsd==1.0.0
lxml==5.1.0
MarkupSafe==2.1.3
marshmallow==3.20.1
mitmproxy==10.2.1
mitmproxy_rs==0.5.1
msgpack==1.0.7
multidict==6.0.4
mypy-extensions==1.0.0
numpy==1.26.3
outleap==0.6.1
packaging==23.2
parso==0.8.3
lxml==5.4.0
MarkupSafe==3.0.2
marshmallow==3.26.1
mitmproxy==11.1.3
mitmproxy_linux==0.11.5
mitmproxy_rs==0.11.5
msgpack==1.1.0
multidict==6.4.4
mypy_extensions==1.1.0
numpy==1.26.4
outleap==0.7.1
packaging==25.0
parso==0.8.4
passlib==1.7.4
prompt-toolkit==3.0.43
protobuf==4.25.1
ptpython==3.0.25
prompt_toolkit==3.0.51
propcache==0.3.1
ptpython==3.0.30
publicsuffix2==2.20191221
pyasn1==0.5.1
pyasn1-modules==0.3.0
pycollada==0.8
pycparser==2.21
Pygments==2.17.2
pylsqpack==0.3.18
pyOpenSSL==23.3.0
pyparsing==3.1.1
pyperclip==1.8.2
PySide6-Essentials==6.6.1
python-dateutil==2.8.2
pyasn1==0.6.1
pyasn1_modules==0.4.2
pycollada==0.9
pycparser==2.22
Pygments==2.19.1
pylsqpack==0.3.22
pyOpenSSL==25.0.0
pyparsing==3.2.1
pyperclip==1.9.0
PySide6_Essentials==6.9.0
python-dateutil==2.9.0.post0
qasync==0.27.1
recordclass==0.18.2
ruamel.yaml==0.18.5
ruamel.yaml.clib==0.2.8
service-identity==23.1.0
shiboken6==6.6.1
six==1.16.0
recordclass==0.23.1
ruamel.yaml==0.18.10
service-identity==24.2.0
setuptools==80.7.1
shiboken6==6.9.0
six==1.17.0
sortedcontainers==2.4.0
tornado==6.4
transformations==2024.6.1
tornado==6.4.2
transformations==2025.1.1
typing-inspect==0.9.0
typing_extensions==4.9.0
urwid-mitmproxy==2.1.2.1
typing_extensions==4.13.2
urwid==2.6.16
wcwidth==0.2.13
Werkzeug==2.3.8
Werkzeug==3.1.3
wsproto==1.2.0
yarl==1.9.4
zstandard==0.22.0
yarl==1.20.0
zstandard==0.23.0

View File

@@ -10,3 +10,10 @@ universal = 1
max-line-length = 160
exclude = build/*, .eggs/*
ignore = F405, F403, E501, F841, E722, W503, E741, E731
[options.extras_require]
test =
pytest
aioresponses
pytest-cov
flake8

118
setup.py
View File

@@ -1,116 +1,6 @@
"""
Copyright 2008, Linden Research, Inc.
See NOTICE.md for previous contributors
Copyright 2021, Salad Dais
All Rights Reserved.
#!/usr/bin/env python3
This program is free software; you can redistribute it and/or
modify it under the terms of the GNU Lesser General Public
License as published by the Free Software Foundation; either
version 3 of the License, or (at your option) any later version.
from setuptools import setup
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with this program; if not, write to the Free Software Foundation,
Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
"""
from os import path
from setuptools import setup, find_packages
here = path.abspath(path.dirname(__file__))
version = '0.15.2'
with open(path.join(here, 'README.md')) as readme_fh:
readme = readme_fh.read()
setup(
name='hippolyzer',
version=version,
description="Analysis tools for SL-compatible virtual worlds",
long_description=readme,
long_description_content_type="text/markdown",
classifiers=[
"License :: OSI Approved :: GNU Lesser General Public License v3 or later (LGPLv3+)",
"Operating System :: MacOS",
"Operating System :: POSIX",
"Operating System :: Microsoft :: Windows",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: System :: Networking :: Monitoring",
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Software Development :: Testing",
],
author='Salad Dais',
author_email='83434023+SaladDais@users.noreply.github.com',
url='https://github.com/SaladDais/Hippolyzer/',
license='LGPLv3',
packages=find_packages(include=["hippolyzer", "hippolyzer.*"]),
package_data={
'hippolyzer': [
'apps/message_builder.ui',
'apps/proxy_mainwindow.ui',
'apps/filter_dialog.ui',
'apps/addon_dialog.ui',
'lib/base/message/data/message_template.msg',
'lib/base/message/data/message.xml',
'lib/base/network/data/ca-bundle.crt',
'lib/base/data/static_data.db2',
'lib/base/data/static_index.db2',
'lib/base/data/avatar_lad.xml',
'lib/base/data/male_collada_joints.xml',
'lib/base/data/avatar_skeleton.xml',
'lib/base/data/LICENSE-artwork.txt',
],
},
entry_points={
'console_scripts': {
'hippolyzer-gui = hippolyzer.apps.proxy_gui:gui_main',
'hippolyzer-cli = hippolyzer.apps.proxy:main',
}
},
zip_safe=False,
python_requires='>=3.10',
install_requires=[
'llsd<1.1.0',
'defusedxml',
'aiohttp<4.0.0',
# Newer recordclasses break!
'recordclass>0.15,<0.18.3',
'lazy-object-proxy',
# requests breaks with newer idna
'idna<3,>=2.5',
# Needed for mesh format conversion tooling
'pycollada',
'transformations',
'gltflib',
# JP2 codec
'Glymur<0.9.7',
'numpy<2.0',
# Proxy-specific stuff
'outleap<1.0',
'arpeggio',
# 11.x will be a major change.
'mitmproxy>=10.0.0,<11',
'Werkzeug<3.0',
# For REPLs
'ptpython<4.0',
# These could be in extras_require if you don't want a GUI.
'pyside6-essentials',
'qasync',
],
tests_require=[
"pytest",
"aioresponses",
],
)
if __name__ == "__main__":
setup()

View File

@@ -49,3 +49,15 @@ class TestEvents(unittest.IsolatedAsyncioTestCase):
await called.wait()
mock.assert_called_with("foo")
self.assertNotIn(_mock_wrapper, [x[0] for x in self.event.subscribers])
async def test_multiple_subscribers(self):
called = asyncio.Event()
called2 = asyncio.Event()
self.event.subscribe(lambda *args: called.set())
self.event.subscribe(lambda *args: called2.set())
self.event.notify(None)
self.assertTrue(called.is_set())
self.assertTrue(called2.is_set())

View File

@@ -181,6 +181,8 @@ class TestMessageHandlers(unittest.IsolatedAsyncioTestCase):
self.message_handler.handle(msg)
async def test_subscription(self):
called = asyncio.Event()
called2 = asyncio.Event()
with self.message_handler.subscribe_async(
message_names=("Foo",),
predicate=lambda m: m["Bar"]["Baz"] == 1,
@@ -192,6 +194,10 @@ class TestMessageHandlers(unittest.IsolatedAsyncioTestCase):
msg3 = Message("Foo", Block("Bar", Baz=1, Biz=3))
self._fake_received_message(msg1)
self._fake_received_message(msg2)
self.message_handler.subscribe("Foo", lambda *args: called.set())
self.message_handler.subscribe("Foo", lambda *args: called2.set())
self._fake_received_message(msg3)
received = []
while True:
@@ -199,14 +205,15 @@ class TestMessageHandlers(unittest.IsolatedAsyncioTestCase):
received.append(await asyncio.wait_for(get_msg(), 0.001))
except asyncio.exceptions.TimeoutError:
break
self.assertEqual(len(foo_handlers), 1)
self.assertEqual(len(foo_handlers), 3)
self.assertListEqual(received, [msg1, msg3])
# The message should have been take()n, making a copy
self.assertIsNot(msg1, received[0])
# take() was called, so this should have been marked queued
self.assertTrue(msg1.queued)
# Leaving the block should have unsubscribed automatically
self.assertEqual(len(foo_handlers), 0)
self.assertEqual(len(foo_handlers), 2)
self.assertTrue(called.is_set())
async def test_subscription_no_take(self):
with self.message_handler.subscribe_async(("Foo",), take=False) as get_msg:

View File

@@ -50,6 +50,8 @@ OBJECT_UPDATE = binascii.unhexlify(''.join(OBJECT_UPDATE.split()))
COARSE_LOCATION_UPDATE = b'\x00\x00\x00\x00E\x00\xff\x06\x00\xff\xff\xff\xff\x00'
UNKNOWN_PACKET = b'\x00\x00\x00\x00E\x00\xff\xf0\x00\xff\xff\xff\xff\x00'
class TestPacketDecode(unittest.TestCase):
@@ -110,3 +112,12 @@ class TestPacketDecode(unittest.TestCase):
parsed = deserializer.deserialize(message)
logging.debug("Parsed blocks: %r " % (list(parsed.blocks.keys()),))
self.assertEqual(message, serializer.serialize(parsed))
def test_unknown_packet_roundtrips(self):
message = UNKNOWN_PACKET
deserializer = UDPMessageDeserializer(settings=self.settings)
serializer = UDPMessageSerializer()
parsed = deserializer.deserialize(message)
logging.debug("Parsed blocks: %r " % (list(parsed.blocks.keys()),))
self.assertEqual("UnknownMessage:240", parsed.name)
self.assertEqual(message, serializer.serialize(parsed))

View File

@@ -21,6 +21,9 @@ from hippolyzer.lib.proxy.sessions import Session
from hippolyzer.lib.proxy.test_utils import BaseProxyTest
UNKNOWN_PACKET = b'\x00\x00\x00\x00E\x00\xff\xf0\x00\xff\xff\xff\xff\x00'
class MockAddon(BaseAddon):
def __init__(self):
self.events = []
@@ -242,6 +245,21 @@ class LLUDPIntegrationTests(BaseProxyTest):
self.assertEqual(entry.name, "UndoLand")
self.assertEqual(entry.message.dropped, True)
async def test_logging_unknown_message(self):
message_logger = SimpleMessageLogger()
self.session_manager.message_logger = message_logger
self._setup_default_circuit()
self.protocol.datagram_received(UNKNOWN_PACKET, self.region_addr)
await self._wait_drained()
entries = message_logger.entries
self.assertEqual(len(entries), 1)
entry: LLUDPMessageLogEntry = entries[0] # type: ignore
# Freezing shouldn't affect this
entry.freeze()
self.assertEqual(entry.name, "UnknownMessage:240")
self.assertEqual(entry.message.dropped, False)
self.assertEqual(entry.message.unknown_message, True)
async def test_session_message_handler(self):
self._setup_default_circuit()
obj_update = self._make_objectupdate_compressed(1234)