├── tests ├── __init__.py ├── proxy │ ├── __init__.py │ ├── integration │ │ └── __init__.py │ ├── test_commands.py │ └── test_httpflows.py ├── voice │ └── __init__.py ├── base │ ├── test_resources │ │ ├── plywood.j2c │ │ └── testslm.slm │ ├── test_vfs.py │ ├── __init__.py │ ├── test_settings.py │ ├── test_skeleton.py │ ├── test_jp2.py │ ├── test_message_dot_xml.py │ ├── test_events.py │ ├── test_capsclient.py │ ├── test_llsd_serializer.py │ ├── test_mesh.py │ ├── test_udp_deserializer.py │ └── test_udp_serializer.py └── client │ ├── test_rlv.py │ ├── __init__.py │ ├── test_inventory_manager.py │ └── test_material_manager.py ├── hippolyzer ├── __init__.py ├── apps │ ├── __init__.py │ ├── addon_dialog.ui │ └── filter_dialog.ui └── lib │ ├── __init__.py │ ├── client │ ├── __init__.py │ ├── rlv.py │ └── namecache.py │ ├── proxy │ ├── __init__.py │ ├── parcel_manager.py │ ├── transport.py │ ├── addon_ctx.py │ ├── ca_utils.py │ ├── asset_uploader.py │ ├── settings.py │ ├── webapp_cap_addon.py │ ├── http_asset_repo.py │ ├── caps_client.py │ ├── namecache.py │ ├── viewer_settings.py │ ├── test_utils.py │ ├── caps.py │ ├── commands.py │ └── task_scheduler.py │ ├── voice │ └── __init__.py │ └── base │ ├── data │ ├── static_data.db2 │ ├── static_index.db2 │ └── LICENSE-artwork.txt │ ├── __init__.py │ ├── message │ ├── __init__.py │ ├── data │ │ └── __init__.py │ ├── message_dot_xml.py │ ├── llsd_msg_serializer.py │ ├── msgtypes.py │ └── template_dict.py │ ├── network │ ├── __init__.py │ └── transport.py │ ├── jp2_utils.py │ ├── test_utils.py │ ├── multiprocessing_utils.py │ ├── settings.py │ ├── vfs.py │ ├── events.py │ ├── namevalue.py │ └── ui_helpers.py ├── .github └── workflows │ ├── .gitkeep │ ├── pypi_publish.yml │ ├── pytest.yml │ └── bundle_windows.yml ├── requirements-test.txt ├── static ├── screenshot.png └── repl_screenshot.png ├── setup.py ├── .gitignore ├── .coveragerc ├── codecov.yml ├── setup.cfg ├── addon_examples ├── counter.py ├── packet_stats.py ├── simulate_packet_loss.py ├── anim_mangler.py ├── spongecase.py ├── hide_lookat.py ├── custom_meta_filter.py ├── caps_example.py ├── greetings.py ├── mock_proxy_cap.py ├── profiler.py ├── repl.py ├── mesh_mangler.py ├── payday.py ├── create_shape.py ├── shield.py ├── rlv_at_home.py ├── leap_example.py ├── task_example.py ├── backwards.py ├── tail_anim.py ├── uploader.py ├── transfer_example.py ├── blueish_object_list.ui ├── addon_state_management.py ├── objectupdate_blame.py ├── appearance_delay_tracker.py ├── find_packet_bugs.py ├── bezoscape.py └── puppetry_example.py ├── voice_examples └── hello_voice.py ├── requirements.txt ├── pyproject.toml ├── client_examples └── hello_client.py └── setup_cxfreeze.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hippolyzer/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/proxy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/voice/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hippolyzer/apps/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hippolyzer/lib/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/workflows/.gitkeep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /hippolyzer/lib/client/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hippolyzer/lib/proxy/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hippolyzer/lib/voice/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/proxy/integration/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | aioresponses 2 | pytest 3 | pytest-cov 4 | flake8 5 | -------------------------------------------------------------------------------- /static/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaladDais/Hippolyzer/HEAD/static/screenshot.png -------------------------------------------------------------------------------- /static/repl_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaladDais/Hippolyzer/HEAD/static/repl_screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from setuptools import setup 4 | 5 | if __name__ == "__main__": 6 | setup() 7 | -------------------------------------------------------------------------------- /tests/base/test_resources/plywood.j2c: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaladDais/Hippolyzer/HEAD/tests/base/test_resources/plywood.j2c -------------------------------------------------------------------------------- /tests/base/test_resources/testslm.slm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaladDais/Hippolyzer/HEAD/tests/base/test_resources/testslm.slm -------------------------------------------------------------------------------- /hippolyzer/lib/base/data/static_data.db2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaladDais/Hippolyzer/HEAD/hippolyzer/lib/base/data/static_data.db2 -------------------------------------------------------------------------------- /hippolyzer/lib/base/data/static_index.db2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SaladDais/Hippolyzer/HEAD/hippolyzer/lib/base/data/static_index.db2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | #use glob syntax 2 | syntax: glob 3 | 4 | __pycache__ 5 | *.pyc 6 | build/* 7 | *.egg-info 8 | dist/* 9 | .doctrees 10 | docs/html/* 11 | .coverage 12 | .eggs 13 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | concurrency = multiprocessing 4 | [report] 5 | exclude_lines = 6 | pragma: no cover 7 | if TYPE_CHECKING: 8 | if typing.TYPE_CHECKING: 9 | def __repr__ 10 | raise AssertionError 11 | assert False 12 | ^\s*pass\b 13 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | precision: 1 3 | round: down 4 | range: "50...80" 5 | status: 6 | project: 7 | default: 8 | # Do not fail commits if the code coverage drops. 9 | target: 0% 10 | threshold: 100% 11 | base: auto 12 | patch: 13 | default: 14 | only_pulls: true 15 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | license_files = 3 | LICENSE.txt 4 | NOTICE.md 5 | 6 | [bdist_wheel] 7 | universal = 1 8 | 9 | [flake8] 10 | max-line-length = 160 11 | exclude = build/*, .eggs/* 12 | ignore = F405, F403, E501, F841, E722, W503, E741, E731 13 | 14 | [options.extras_require] 15 | test = 16 | pytest 17 | aioresponses 18 | pytest-cov 19 | flake8 20 | -------------------------------------------------------------------------------- /tests/base/test_vfs.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from hippolyzer.lib.base.datatypes import UUID 4 | from hippolyzer.lib.base.vfs import STATIC_VFS 5 | 6 | 7 | class StaticVFSTest(unittest.TestCase): 8 | def test_basic(self): 9 | # Load the block for the fly anim 10 | block = STATIC_VFS[UUID("aec4610c-757f-bc4e-c092-c6e9caf18daf")] 11 | anim_data = STATIC_VFS.read_block(block) 12 | self.assertEqual(len(anim_data), 1414) 13 | -------------------------------------------------------------------------------- /addon_examples/counter.py: -------------------------------------------------------------------------------- 1 | from hippolyzer.lib.base.message.message import Message 2 | from hippolyzer.lib.proxy.region import ProxiedRegion 3 | from hippolyzer.lib.proxy.sessions import Session 4 | 5 | 6 | def handle_lludp_message(session: Session, region: ProxiedRegion, message: Message): 7 | # addon_ctx will persist across addon reloads, use for storing data that 8 | # needs to survive across calls to this function 9 | ctx = session.addon_ctx[__name__] 10 | if message.name == "ChatFromViewer": 11 | chat = message["ChatData"]["Message"] 12 | if chat == "COUNT": 13 | ctx["chat_counter"] = ctx.get("chat_counter", 0) + 1 14 | message["ChatData"]["Message"] = str(ctx["chat_counter"]) 15 | -------------------------------------------------------------------------------- /hippolyzer/lib/proxy/parcel_manager.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | 3 | from hippolyzer.lib.base.helpers import proxify 4 | from hippolyzer.lib.base.message.message import Message 5 | from hippolyzer.lib.client.parcel_manager import ParcelManager 6 | if TYPE_CHECKING: 7 | from hippolyzer.lib.proxy.region import ProxiedRegion 8 | 9 | 10 | class ProxyParcelManager(ParcelManager): 11 | def __init__(self, region: "ProxiedRegion"): 12 | super().__init__(proxify(region)) 13 | # Handle ParcelProperties messages that we didn't specifically ask for 14 | self._region.message_handler.subscribe("ParcelProperties", self._handle_parcel_properties) 15 | 16 | def _handle_parcel_properties(self, msg: Message): 17 | self._process_parcel_properties(msg) 18 | return None 19 | -------------------------------------------------------------------------------- /hippolyzer/lib/proxy/transport.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import struct 3 | 4 | from hippolyzer.lib.base.network.transport import SocketUDPTransport, UDPPacket 5 | 6 | 7 | class SOCKS5UDPTransport(SocketUDPTransport): 8 | HEADER_STRUCT = struct.Struct("!HBB4sH") 9 | 10 | @classmethod 11 | def serialize(cls, packet: UDPPacket, force_socks_header: bool = False) -> bytes: 12 | # Decide whether we need a header based on packet direction 13 | if packet.outgoing and not force_socks_header: 14 | return packet.data 15 | header = cls.HEADER_STRUCT.pack( 16 | 0, 0, 1, socket.inet_aton(packet.far_addr[0]), packet.far_addr[1]) 17 | return header + packet.data 18 | 19 | def send_packet(self, packet: UDPPacket) -> None: 20 | self.transport.sendto(self.serialize(packet), packet.dst_addr) 21 | -------------------------------------------------------------------------------- /addon_examples/packet_stats.py: -------------------------------------------------------------------------------- 1 | import collections 2 | 3 | from hippolyzer.lib.base.message.message import Message 4 | from hippolyzer.lib.proxy.addon_utils import BaseAddon, GlobalProperty 5 | from hippolyzer.lib.proxy.commands import handle_command 6 | from hippolyzer.lib.proxy.region import ProxiedRegion 7 | from hippolyzer.lib.proxy.sessions import Session 8 | 9 | 10 | class PacketStatsAddon(BaseAddon): 11 | packet_stats: collections.Counter = GlobalProperty(collections.Counter) 12 | 13 | def handle_lludp_message(self, session: Session, region: ProxiedRegion, message: Message): 14 | self.packet_stats[message.name] += 1 15 | 16 | @handle_command() 17 | async def print_packet_stats(self, _session: Session, _region: ProxiedRegion): 18 | print(self.packet_stats.most_common(10)) 19 | 20 | 21 | addons = [PacketStatsAddon()] 22 | -------------------------------------------------------------------------------- /tests/base/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2009, Linden Research, Inc. 3 | See NOTICE.md for previous contributors 4 | Copyright 2021, Salad Dais 5 | All Rights Reserved. 6 | 7 | This program is free software; you can redistribute it and/or 8 | modify it under the terms of the GNU Lesser General Public 9 | License as published by the Free Software Foundation; either 10 | version 3 of the License, or (at your option) any later version. 11 | 12 | This program is distributed in the hope that it will be useful, 13 | but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 15 | Lesser General Public License for more details. 16 | 17 | You should have received a copy of the GNU Lesser General Public License 18 | along with this program; if not, write to the Free Software Foundation, 19 | Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 20 | """ 21 | -------------------------------------------------------------------------------- /addon_examples/simulate_packet_loss.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from hippolyzer.lib.proxy.addon_utils import BaseAddon 4 | from hippolyzer.lib.base.message.message import Message 5 | from hippolyzer.lib.proxy.region import ProxiedRegion 6 | from hippolyzer.lib.proxy.sessions import Session 7 | 8 | 9 | class SimulatePacketLossAddon(BaseAddon): 10 | def handle_lludp_message(self, session: Session, region: ProxiedRegion, message: Message): 11 | # Messing with these may kill your circuit 12 | if message.name in {"PacketAck", "StartPingCheck", "CompletePingCheck", "UseCircuitCode", 13 | "CompleteAgentMovement", "AgentMovementComplete"}: 14 | return 15 | # Simulate 30% packet loss 16 | if random.random() > 0.7: 17 | # Do nothing, drop this packet on the floor 18 | return True 19 | return 20 | 21 | 22 | addons = [SimulatePacketLossAddon()] 23 | -------------------------------------------------------------------------------- /hippolyzer/lib/base/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | Copyright 2009, Linden Research, Inc. 4 | See NOTICE.md for previous contributors 5 | Copyright 2021, Salad Dais 6 | All Rights Reserved. 7 | 8 | This program is free software; you can redistribute it and/or 9 | modify it under the terms of the GNU Lesser General Public 10 | License as published by the Free Software Foundation; either 11 | version 3 of the License, or (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 16 | Lesser General Public License for more details. 17 | 18 | You should have received a copy of the GNU Lesser General Public License 19 | along with this program; if not, write to the Free Software Foundation, 20 | Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 21 | """ 22 | -------------------------------------------------------------------------------- /hippolyzer/lib/base/message/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | Copyright 2009, Linden Research, Inc. 4 | See NOTICE.md for previous contributors 5 | Copyright 2021, Salad Dais 6 | All Rights Reserved. 7 | 8 | This program is free software; you can redistribute it and/or 9 | modify it under the terms of the GNU Lesser General Public 10 | License as published by the Free Software Foundation; either 11 | version 3 of the License, or (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 16 | Lesser General Public License for more details. 17 | 18 | You should have received a copy of the GNU Lesser General Public License 19 | along with this program; if not, write to the Free Software Foundation, 20 | Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 21 | """ 22 | -------------------------------------------------------------------------------- /hippolyzer/lib/base/network/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | Copyright 2009, Linden Research, Inc. 4 | See NOTICE.md for previous contributors 5 | Copyright 2021, Salad Dais 6 | All Rights Reserved. 7 | 8 | This program is free software; you can redistribute it and/or 9 | modify it under the terms of the GNU Lesser General Public 10 | License as published by the Free Software Foundation; either 11 | version 3 of the License, or (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 16 | Lesser General Public License for more details. 17 | 18 | You should have received a copy of the GNU Lesser General Public License 19 | along with this program; if not, write to the Free Software Foundation, 20 | Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 21 | """ 22 | -------------------------------------------------------------------------------- /hippolyzer/lib/proxy/addon_ctx.py: -------------------------------------------------------------------------------- 1 | """ 2 | Global context for use within Addon callbacks 3 | """ 4 | from __future__ import annotations 5 | 6 | import contextlib 7 | from contextvars import ContextVar, Token 8 | from typing import * 9 | 10 | if TYPE_CHECKING: 11 | from hippolyzer.lib.proxy.region import ProxiedRegion 12 | from hippolyzer.lib.proxy.sessions import Session 13 | 14 | # By using ContextVar, coroutines retain the context as it was when 15 | # they were created. 16 | session: ContextVar[Optional[Session]] = ContextVar("session", default=None) 17 | region: ContextVar[Optional[ProxiedRegion]] = ContextVar("region", default=None) 18 | 19 | 20 | @contextlib.contextmanager 21 | def push(new_session: Optional[Session] = None, new_region: Optional[ProxiedRegion] = None): 22 | session_token: Token = session.set(new_session) 23 | region_token: Token = region.set(new_region) 24 | try: 25 | yield 26 | finally: 27 | session.reset(session_token) 28 | region.reset(region_token) 29 | -------------------------------------------------------------------------------- /addon_examples/anim_mangler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example anim mangler addon, to be used with local anim addon. 3 | 4 | You can edit this live to apply various transforms to local anims, 5 | as well as any uploaded anims. Any changes will be reflected in currently 6 | playing local anims. 7 | 8 | This example modifies any position keys of an animation's mHipRight joint. 9 | """ 10 | from hippolyzer.lib.base.llanim import Animation 11 | from hippolyzer.lib.proxy.addons import AddonManager 12 | 13 | import local_anim 14 | AddonManager.hot_reload(local_anim, require_addons_loaded=True) 15 | 16 | 17 | def offset_right_hip(anim: Animation): 18 | hip_joint = anim.joints.get("mHipRight") 19 | if hip_joint: 20 | for pos_frame in hip_joint.pos_keyframes: 21 | pos_frame.pos.Z *= 2.5 22 | pos_frame.pos.X *= 5.0 23 | return anim 24 | 25 | 26 | class ExampleAnimManglerAddon(local_anim.BaseAnimManglerAddon): 27 | ANIM_MANGLERS = [ 28 | offset_right_hip, 29 | ] 30 | 31 | 32 | addons = [ExampleAnimManglerAddon()] 33 | -------------------------------------------------------------------------------- /hippolyzer/lib/proxy/ca_utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import shutil 3 | 4 | from hippolyzer.lib.proxy.viewer_settings import iter_viewer_config_dirs, has_settings_file 5 | 6 | 7 | class InvalidConfigDir(Exception): 8 | pass 9 | 10 | 11 | def setup_ca(config_path, mitmproxy_master): 12 | p = Path(config_path) 13 | if not p.exists(): 14 | raise InvalidConfigDir("Config path does not exist!") 15 | if not has_settings_file(p): 16 | raise InvalidConfigDir("Path is not a second life config dir!") 17 | 18 | mitmproxy_conf_dir = Path(mitmproxy_master.options.confdir) 19 | mitmproxy_ca_path = (mitmproxy_conf_dir.expanduser() / "mitmproxy-ca-cert.pem") 20 | 21 | shutil.copy(mitmproxy_ca_path, p / "user_settings" / "CA.pem") 22 | 23 | 24 | def setup_ca_everywhere(mitmproxy_master): 25 | valid_paths = set() 26 | paths = iter_viewer_config_dirs() 27 | for path in paths: 28 | try: 29 | setup_ca(path, mitmproxy_master) 30 | valid_paths.add(path) 31 | except InvalidConfigDir: 32 | pass 33 | return valid_paths 34 | -------------------------------------------------------------------------------- /addon_examples/spongecase.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | 3 | from hippolyzer.lib.base.message.message import Message 4 | from hippolyzer.lib.proxy.region import ProxiedRegion 5 | from hippolyzer.lib.proxy.sessions import Session 6 | 7 | 8 | def _to_spongecase(val): 9 | # give alternating casing for each character 10 | spongecased = itertools.zip_longest(val[::2].upper(), val[1::2].lower(), fillvalue="") 11 | # join them back together 12 | return "".join(itertools.chain(*spongecased)) 13 | 14 | 15 | def handle_lludp_message(session: Session, _region: ProxiedRegion, message: Message): 16 | ctx = session.addon_ctx[__name__] 17 | ctx.setdefault("spongecase", False) 18 | if message.name == "ChatFromViewer": 19 | chat = message["ChatData"]["Message"] 20 | if chat == "spongeon": 21 | ctx["spongecase"] = True 22 | elif chat == "spongeoff": 23 | ctx["spongecase"] = False 24 | 25 | if ctx["spongecase"]: 26 | if not chat or message["ChatData"]["Channel"] != 0: 27 | return 28 | message["ChatData"]["Message"] = _to_spongecase(chat) 29 | -------------------------------------------------------------------------------- /addon_examples/hide_lookat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Drop outgoing packets that might leak what you're looking at, similar to Firestorm 3 | """ 4 | 5 | from hippolyzer.lib.base.templates import ViewerEffectType 6 | from hippolyzer.lib.base.message.message import Message 7 | from hippolyzer.lib.base.network.transport import Direction 8 | from hippolyzer.lib.proxy.region import ProxiedRegion 9 | from hippolyzer.lib.proxy.sessions import Session 10 | 11 | 12 | BLOCKED_EFFECTS = ( 13 | ViewerEffectType.EFFECT_LOOKAT, 14 | ViewerEffectType.EFFECT_BEAM, 15 | ViewerEffectType.EFFECT_POINTAT, 16 | ViewerEffectType.EFFECT_EDIT, 17 | ) 18 | 19 | 20 | def handle_lludp_message(_session: Session, region: ProxiedRegion, msg: Message): 21 | if msg.name == "ViewerEffect" and msg.direction == Direction.OUT: 22 | new_blocks = [b for b in msg["Effect"] if b["Type"] not in BLOCKED_EFFECTS] 23 | if new_blocks: 24 | msg["Effect"] = new_blocks 25 | else: 26 | # drop `ViewerEffect` entirely if left with no blocks 27 | region.circuit.drop_message(msg) 28 | # Short-circuit any other addons processing this message 29 | return True 30 | -------------------------------------------------------------------------------- /addon_examples/custom_meta_filter.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of custom meta tags, useful for complex expressions that wouldn't work 3 | well in the message log filter language. 4 | 5 | Tags messages where someone said "hello", and record who they said hello to. 6 | 7 | If you said "hello Someone", that message would be shown in the log pane when 8 | filtering with `Meta.Greeted == "Someone"` or just `Meta.Greeted` to match any 9 | message with a greeting. 10 | """ 11 | 12 | from hippolyzer.lib.proxy.addon_utils import BaseAddon 13 | from hippolyzer.lib.base.message.message import Message 14 | from hippolyzer.lib.proxy.region import ProxiedRegion 15 | from hippolyzer.lib.proxy.sessions import Session 16 | 17 | 18 | class CustomMetaExampleAddon(BaseAddon): 19 | def handle_lludp_message(self, session: Session, region: ProxiedRegion, message: Message): 20 | if not message.name.startswith("ChatFrom"): 21 | return 22 | 23 | chat = message["ChatData"]["Message"] 24 | if not chat: 25 | return 26 | 27 | if chat.lower().startswith("hello "): 28 | message.meta["Greeted"] = chat.split(" ", 1)[1] 29 | 30 | 31 | addons = [CustomMetaExampleAddon()] 32 | -------------------------------------------------------------------------------- /voice_examples/hello_voice.py: -------------------------------------------------------------------------------- 1 | """ 2 | Connect to a voice session at 0, 0, 0 for 20 seconds, then exit. 3 | """ 4 | 5 | import asyncio 6 | from contextlib import aclosing 7 | import os 8 | 9 | from hippolyzer.lib.base.datatypes import Vector3 10 | from hippolyzer.lib.voice.client import VoiceClient 11 | 12 | 13 | VOICE_PATH = os.environ["SLVOICE_PATH"] 14 | 15 | 16 | async def amain(): 17 | client = await VoiceClient.simple_init(VOICE_PATH) 18 | async with aclosing(client): 19 | print("Capture Devices:", client.capture_devices) 20 | print("Render Devices:", client.render_devices) 21 | await client.set_mic_muted(True) 22 | await client.set_mic_volume(60) 23 | print(await client.login(os.environ["SLVOICE_USERNAME"], os.environ["SLVOICE_PASSWORD"])) 24 | 25 | await client.join_session(os.environ["SLVOICE_URI"], int(os.environ["SLVOICE_HANDLE"])) 26 | 27 | await client.set_region_3d_pos(Vector3(0, 0, 0)) 28 | print(client.region_pos) 29 | 30 | # leave running for 20 seconds, then exit 31 | await asyncio.sleep(20.0) 32 | print("Bye!") 33 | 34 | 35 | if __name__ == "__main__": 36 | asyncio.run(amain()) 37 | -------------------------------------------------------------------------------- /hippolyzer/lib/base/message/data/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | Copyright 2009, Linden Research, Inc. 4 | See NOTICE.md for previous contributors 5 | Copyright 2021, Salad Dais 6 | All Rights Reserved. 7 | 8 | This program is free software; you can redistribute it and/or 9 | modify it under the terms of the GNU Lesser General Public 10 | License as published by the Free Software Foundation; either 11 | version 3 of the License, or (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 16 | Lesser General Public License for more details. 17 | 18 | You should have received a copy of the GNU Lesser General Public License 19 | along with this program; if not, write to the Free Software Foundation, 20 | Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 21 | """ 22 | 23 | from hippolyzer.lib.base.helpers import get_resource_filename 24 | 25 | msg_tmpl = open(get_resource_filename("lib/base/message/data/message_template.msg")) 26 | with open(get_resource_filename("lib/base/message/data/message.xml"), "rb") as _f: 27 | msg_details = _f.read() 28 | -------------------------------------------------------------------------------- /hippolyzer/lib/base/data/LICENSE-artwork.txt: -------------------------------------------------------------------------------- 1 | COPYRIGHT AND PERMISSION NOTICE 2 | 3 | Second Life(TM) Viewer Artwork. Copyright (C) 2008 Linden Research, Inc. 4 | 5 | Linden Research, Inc. ("Linden Lab") licenses the Second Life viewer 6 | artwork and other works in the files distributed with this Notice under 7 | the Creative Commons Attribution-Share Alike 3.0 License, available at 8 | http://creativecommons.org/licenses/by-sa/3.0/legalcode. For the license 9 | summary, see http://creativecommons.org/licenses/by-sa/3.0/. 10 | 11 | Notwithstanding the foregoing, all of Linden Lab's trademarks, including 12 | but not limited to the Second Life brand name and Second Life Eye-in-Hand 13 | logo, are subject to our trademark policy at 14 | http://secondlife.com/corporate/brand/trademark/. 15 | 16 | If you distribute any copies or adaptations of the Second Life viewer 17 | artwork or any other works in these files, you must include this Notice 18 | and clearly identify any changes made to the original works. Include 19 | this Notice and information where copyright notices are usually included, 20 | for example, after your own copyright notice acknowledging your use of 21 | the Second Life viewer artwork, in a text file distributed with your 22 | program, in your application's About window, or on a credits page for 23 | your work. 24 | -------------------------------------------------------------------------------- /tests/base/test_settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright 2009, Linden Research, Inc. 3 | See NOTICE.md for previous contributors 4 | Copyright 2021, Salad Dais 5 | All Rights Reserved. 6 | 7 | This program is free software; you can redistribute it and/or 8 | modify it under the terms of the GNU Lesser General Public 9 | License as published by the Free Software Foundation; either 10 | version 3 of the License, or (at your option) any later version. 11 | 12 | This program is distributed in the hope that it will be useful, 13 | but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 15 | Lesser General Public License for more details. 16 | 17 | You should have received a copy of the GNU Lesser General Public License 18 | along with this program; if not, write to the Free Software Foundation, 19 | Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 20 | """ 21 | import unittest 22 | 23 | from hippolyzer.lib.base.settings import Settings 24 | 25 | 26 | class TestSettings(unittest.TestCase): 27 | def test_base_settings(self): 28 | settings = Settings() 29 | self.assertEqual(settings.ENABLE_DEFERRED_PACKET_PARSING, True) 30 | settings.ENABLE_DEFERRED_PACKET_PARSING = False 31 | self.assertEqual(settings.ENABLE_DEFERRED_PACKET_PARSING, False) 32 | -------------------------------------------------------------------------------- /addon_examples/caps_example.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of how to make simple Caps requests 3 | """ 4 | import aiohttp 5 | 6 | from hippolyzer.lib.proxy.addon_utils import BaseAddon, show_message 7 | from hippolyzer.lib.proxy.commands import handle_command 8 | from hippolyzer.lib.proxy.region import ProxiedRegion 9 | from hippolyzer.lib.proxy.sessions import Session 10 | 11 | 12 | class CapsExampleAddon(BaseAddon): 13 | @handle_command() 14 | async def test_caps(self, _session: Session, region: ProxiedRegion): 15 | caps_client = region.caps_client 16 | # We can pass in a ClientSession if we want to do keep-alive across requests 17 | async with aiohttp.ClientSession() as aio_sess: 18 | async with caps_client.get("SimulatorFeatures", session=aio_sess) as resp: 19 | await resp.read_llsd() 20 | # Or we can have one created for us just for this request 21 | async with caps_client.get("SimulatorFeatures") as resp: 22 | show_message(await resp.read_llsd()) 23 | 24 | # POSTing LLSD works 25 | req = caps_client.post("AgentPreferences", llsd={ 26 | "hover_height": 0.5, 27 | }) 28 | # Request object can be built, then awaited 29 | async with req as resp: 30 | show_message(await resp.read_llsd()) 31 | 32 | 33 | addons = [CapsExampleAddon()] 34 | -------------------------------------------------------------------------------- /addon_examples/greetings.py: -------------------------------------------------------------------------------- 1 | from hippolyzer.lib.base.datatypes import Vector3 2 | from hippolyzer.lib.proxy.addon_utils import send_chat, BaseAddon, show_message 3 | from hippolyzer.lib.proxy.commands import handle_command 4 | from hippolyzer.lib.proxy.region import ProxiedRegion 5 | from hippolyzer.lib.proxy.sessions import Session 6 | 7 | 8 | class GreetingAddon(BaseAddon): 9 | @handle_command() 10 | async def greetings(self, session: Session, region: ProxiedRegion): 11 | """Greet everyone around you""" 12 | our_avatar = region.objects.lookup_avatar(session.agent_id) 13 | if not our_avatar: 14 | show_message("Don't have an agent object?") 15 | 16 | # Look this up in the session object store since we may be next 17 | # to a region border. 18 | other_avatars = [o for o in session.objects.all_avatars if o.FullID != our_avatar.FullID] 19 | 20 | if not other_avatars: 21 | show_message("No other avatars?") 22 | 23 | for other_avatar in other_avatars: 24 | dist = Vector3.dist(our_avatar.GlobalPosition, other_avatar.GlobalPosition) 25 | if dist >= 19.0: 26 | continue 27 | if other_avatar.PreferredName is None: 28 | continue 29 | send_chat(f"Greetings, {other_avatar.PreferredName}!") 30 | 31 | 32 | addons = [GreetingAddon()] 33 | -------------------------------------------------------------------------------- /tests/base/test_skeleton.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | import numpy as np 4 | 5 | from hippolyzer.lib.base.mesh_skeleton import load_avatar_skeleton 6 | 7 | 8 | class TestSkeleton(unittest.TestCase): 9 | @classmethod 10 | def setUpClass(cls) -> None: 11 | cls.skeleton = load_avatar_skeleton() 12 | 13 | def test_get_joint(self): 14 | node = self.skeleton["mNeck"] 15 | self.assertEqual("mNeck", node.name) 16 | self.assertEqual(self.skeleton, node.skeleton()) 17 | 18 | def test_get_joint_index(self): 19 | self.assertEqual(7, self.skeleton["mNeck"].index) 20 | self.assertEqual(113, self.skeleton["mKneeLeft"].index) 21 | 22 | def test_get_joint_parent(self): 23 | self.assertEqual("mChest", self.skeleton["mNeck"].parent.name) 24 | 25 | def test_get_joint_matrix(self): 26 | expected_mat = np.array([ 27 | [1., 0., 0., -0.01], 28 | [0., 1., 0., 0.], 29 | [0., 0., 1., 0.251], 30 | [0., 0., 0., 1.] 31 | ]) 32 | np.testing.assert_equal(expected_mat, self.skeleton["mNeck"].matrix) 33 | 34 | def test_get_inverse_joint(self): 35 | self.assertEqual("R_CLAVICLE", self.skeleton["L_CLAVICLE"].inverse.name) 36 | self.assertEqual(None, self.skeleton["mChest"].inverse) 37 | self.assertEqual("mHandMiddle1Right", self.skeleton["mHandMiddle1Left"].inverse.name) 38 | self.assertEqual("RIGHT_HANDLE", self.skeleton["LEFT_HANDLE"].inverse.name) 39 | -------------------------------------------------------------------------------- /hippolyzer/lib/base/jp2_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | from io import BytesIO 4 | 5 | import defusedxml.ElementTree 6 | from glymur import jp2box, Jp2k 7 | 8 | # Replace glymur's ElementTree with a safe one 9 | jp2box.ET = defusedxml.ElementTree 10 | 11 | 12 | class BufferedJp2k(Jp2k): 13 | """ 14 | For manipulating JP2K from within a binary buffer. 15 | 16 | For many operations glymur expects to be able to re-read from a file 17 | based on filename, so this is the least brittle approach. 18 | """ 19 | 20 | def __init__(self, contents: bytes): 21 | stream = BytesIO(contents) 22 | self.temp_file = tempfile.NamedTemporaryFile(delete=False) 23 | stream.seek(0) 24 | self.temp_file.write(stream.read()) 25 | # Windows NT can't handle two FHs open on a tempfile at once 26 | self.temp_file.close() 27 | 28 | super().__init__(self.temp_file.name) 29 | 30 | def __del__(self): 31 | if self.temp_file is not None: 32 | os.remove(self.temp_file.name) 33 | self.temp_file = None 34 | 35 | def _populate_cparams(self, img_array): 36 | if self._cratios is None: 37 | self._cratios = (1920.0, 480.0, 120.0, 30.0, 10.0) 38 | if self._irreversible is None: 39 | self.irreversible = True 40 | return super()._populate_cparams(img_array) 41 | 42 | def __bytes__(self): 43 | with open(self.temp_file.name, "rb") as f: 44 | return f.read() 45 | -------------------------------------------------------------------------------- /tests/base/test_jp2.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import unittest 3 | 4 | import glymur 5 | from glymur.codestream import CMEsegment 6 | 7 | from hippolyzer.lib.base.jp2_utils import BufferedJp2k 8 | 9 | BASE_PATH = os.path.dirname(os.path.abspath(__file__)) 10 | 11 | 12 | @unittest.skipIf(glymur.jp2k.opj2.OPENJP2 is None, "OpenJPEG library missing") 13 | class TestJP2Utils(unittest.TestCase): 14 | @classmethod 15 | def setUpClass(cls) -> None: 16 | with open(os.path.join(BASE_PATH, "test_resources", "plywood.j2c"), "rb") as f: 17 | cls.j2c_bytes = f.read() 18 | 19 | def test_load_j2c(self): 20 | j = BufferedJp2k(contents=self.j2c_bytes) 21 | j.parse() 22 | # Last segment in the header is the comment section 23 | com: CMEsegment = j.codestream.segment[-1] 24 | self.assertEqual("CME", com.marker_id) 25 | # In this case the comment is the encoder version 26 | self.assertEqual(b'Kakadu-3.0.3', com.ccme) 27 | 28 | def test_read_j2c_data(self): 29 | j = BufferedJp2k(self.j2c_bytes) 30 | pixels = j[::] 31 | self.assertEqual((512, 512, 3), pixels.shape) 32 | 33 | def test_save_j2c_data(self): 34 | j = BufferedJp2k(self.j2c_bytes) 35 | pixels = j[::] 36 | j[::] = pixels 37 | new_j2c_bytes = bytes(j) 38 | self.assertNotEqual(self.j2c_bytes, new_j2c_bytes) 39 | # Glymur will have replaced the CME section with its own 40 | self.assertIn(b"Created by OpenJPEG", new_j2c_bytes) 41 | -------------------------------------------------------------------------------- /tests/client/test_rlv.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from hippolyzer.lib.base.message.message import Message, Block 4 | from hippolyzer.lib.base.templates import ChatType 5 | from hippolyzer.lib.client.rlv import RLVParser, RLVCommand 6 | 7 | 8 | class TestRLV(unittest.TestCase): 9 | def test_is_rlv_command(self): 10 | msg = Message( 11 | "ChatFromSimulator", 12 | Block("ChatData", Message="@foobar", ChatType=ChatType.OWNER) 13 | ) 14 | self.assertTrue(RLVParser.is_rlv_message(msg)) 15 | msg["ChatData"]["ChatType"] = ChatType.NORMAL 16 | self.assertFalse(RLVParser.is_rlv_message(msg)) 17 | 18 | def test_rlv_parse_single_command(self): 19 | cmd = RLVParser.parse_chat("@foo:bar;baz=quux")[0] 20 | self.assertEqual("foo", cmd.behaviour) 21 | self.assertListEqual(["bar", "baz"], cmd.options) 22 | self.assertEqual("quux", cmd.param) 23 | 24 | def test_rlv_parse_multiple_commands(self): 25 | cmds = RLVParser.parse_chat("@foo:bar;baz=quux,bazzy") 26 | self.assertEqual("foo", cmds[0].behaviour) 27 | self.assertListEqual(["bar", "baz"], cmds[0].options) 28 | self.assertEqual("quux", cmds[0].param) 29 | self.assertEqual("bazzy", cmds[1].behaviour) 30 | 31 | def test_rlv_format_commands(self): 32 | chat = RLVParser.format_chat([ 33 | RLVCommand("foo", "quux", ["bar", "baz"]), 34 | RLVCommand("bazzy", "", []) 35 | ]) 36 | self.assertEqual("@foo:bar;baz=quux,bazzy", chat) 37 | -------------------------------------------------------------------------------- /hippolyzer/lib/base/test_utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Any, Optional, List, Tuple 3 | 4 | from hippolyzer.lib.base.message.circuit import Circuit, ConnectionHolder 5 | from hippolyzer.lib.base.message.message import Message 6 | from hippolyzer.lib.base.message.message_handler import MessageHandler 7 | from hippolyzer.lib.base.network.transport import AbstractUDPTransport, ADDR_TUPLE, UDPPacket 8 | 9 | 10 | class MockTransport(AbstractUDPTransport): 11 | def sendto(self, data: Any, addr: Optional[ADDR_TUPLE] = ...) -> None: 12 | pass 13 | 14 | def abort(self) -> None: 15 | pass 16 | 17 | def close(self) -> None: 18 | pass 19 | 20 | def __init__(self): 21 | super().__init__() 22 | self.packets: List[Tuple[bytes, Tuple[str, int]]] = [] 23 | 24 | def send_packet(self, packet: UDPPacket) -> None: 25 | self.packets.append((packet.data, packet.dst_addr)) 26 | 27 | 28 | class MockHandlingCircuit(Circuit): 29 | def __init__(self, handler: MessageHandler[Message, str]): 30 | super().__init__(("127.0.0.1", 1), ("127.0.0.1", 2), None) 31 | self.handler = handler 32 | 33 | def _send_prepared_message(self, message: Message, transport=None): 34 | loop = asyncio.get_event_loop_policy().get_event_loop() 35 | loop.call_soon(self.handler.handle, message) 36 | 37 | 38 | class MockConnectionHolder(ConnectionHolder): 39 | def __init__(self, circuit, message_handler): 40 | self.circuit = circuit 41 | self.message_handler = message_handler 42 | 43 | 44 | async def soon(awaitable) -> Message: 45 | return await asyncio.wait_for(awaitable, timeout=1.0) 46 | -------------------------------------------------------------------------------- /hippolyzer/lib/proxy/asset_uploader.py: -------------------------------------------------------------------------------- 1 | from hippolyzer.lib.base.datatypes import UUID 2 | from hippolyzer.lib.base.inventory import InventoryItem 3 | from hippolyzer.lib.base.message.message import Message, Block 4 | from hippolyzer.lib.base.network.transport import Direction 5 | from hippolyzer.lib.client.asset_uploader import AssetUploader 6 | 7 | 8 | class ProxyAssetUploader(AssetUploader): 9 | async def _handle_upload_complete(self, resp_payload: dict): 10 | # Check if this a failure response first, raising if it is 11 | await super()._handle_upload_complete(resp_payload) 12 | 13 | # Fetch enough data from AIS to tell the viewer about the new inventory item 14 | session = self._region.session() 15 | item_id = resp_payload["new_inventory_item"] 16 | ais_req_data = { 17 | "items": [ 18 | { 19 | "owner_id": session.agent_id, 20 | "item_id": item_id, 21 | } 22 | ] 23 | } 24 | async with self._region.caps_client.post('FetchInventory2', llsd=ais_req_data) as resp: 25 | ais_item = InventoryItem.from_llsd((await resp.read_llsd())["items"][0], flavor="ais") 26 | 27 | # Got it, ship it off to the viewer 28 | message = Message( 29 | "UpdateCreateInventoryItem", 30 | Block( 31 | "AgentData", 32 | AgentID=session.agent_id, 33 | SimApproved=1, 34 | TransactionID=UUID.random(), 35 | ), 36 | ais_item.to_inventory_data(), 37 | direction=Direction.IN 38 | ) 39 | self._region.circuit.send(message) 40 | -------------------------------------------------------------------------------- /.github/workflows/pypi_publish.yml: -------------------------------------------------------------------------------- 1 | name: PyPi Release 2 | 3 | # https://help.github.com/en/actions/reference/events-that-trigger-workflows 4 | on: 5 | # Only trigger on release creation 6 | release: 7 | types: 8 | - created 9 | workflow_dispatch: 10 | 11 | 12 | # based on https://github.com/pypa/gh-action-pypi-publish 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Get history and tags for SCM versioning to work 21 | run: | 22 | git fetch --prune --unshallow 23 | git fetch --depth=1 origin +refs/tags/*:refs/tags/* 24 | - uses: actions/setup-python@v2 25 | with: 26 | python-version: "3.12" 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip setuptools wheel build 31 | if [ -f requirements.txt ]; then pip install -r requirements.txt; fi 32 | - name: Build 33 | run: >- 34 | python -m build 35 | # We do this, since failures on test.pypi aren't that bad 36 | - name: Publish to Test PyPI 37 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 38 | uses: pypa/gh-action-pypi-publish@release/v1 39 | with: 40 | user: __token__ 41 | password: ${{ secrets.TEST_PYPI_API_TOKEN }} 42 | repository_url: https://test.pypi.org/legacy/ 43 | attestations: false 44 | 45 | - name: Publish to PyPI 46 | if: startsWith(github.event.ref, 'refs/tags') || github.event_name == 'release' 47 | uses: pypa/gh-action-pypi-publish@release/v1 48 | with: 49 | user: __token__ 50 | password: ${{ secrets.PYPI_API_TOKEN }} 51 | attestations: false 52 | -------------------------------------------------------------------------------- /tests/base/test_message_dot_xml.py: -------------------------------------------------------------------------------- 1 | 2 | """ 3 | Copyright 2009, Linden Research, Inc. 4 | See NOTICE.md for previous contributors 5 | Copyright 2021, Salad Dais 6 | All Rights Reserved. 7 | 8 | This program is free software; you can redistribute it and/or 9 | modify it under the terms of the GNU Lesser General Public 10 | License as published by the Free Software Foundation; either 11 | version 3 of the License, or (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 16 | Lesser General Public License for more details. 17 | 18 | You should have received a copy of the GNU Lesser General Public License 19 | along with this program; if not, write to the Free Software Foundation, 20 | Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 21 | """ 22 | import unittest 23 | 24 | from hippolyzer.lib.base.message.message_dot_xml import MessageDotXML 25 | 26 | 27 | class TestMessageDotXML(unittest.TestCase): 28 | 29 | def setUp(self): 30 | self.message_xml = MessageDotXML() 31 | 32 | def test_constructor(self): 33 | self.assertTrue(self.message_xml.serverDefaults) 34 | self.assertTrue(self.message_xml.messages) 35 | self.assertTrue(self.message_xml.capBans) 36 | self.assertTrue(self.message_xml.maxQueuedEvents) 37 | self.assertTrue(self.message_xml.messageBans) 38 | 39 | def test_validate_udp_msg_false(self): 40 | self.assertEqual(self.message_xml.validate_udp_msg('ParcelProperties'), False) 41 | 42 | def test_validate_udp_msg_true(self): 43 | self.assertEqual(self.message_xml.validate_udp_msg('CloseCircuit'), True) 44 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohappyeyeballs==2.6.1 2 | aiohttp==3.11.18 3 | aioquic==1.2.0 4 | aiosignal==1.3.2 5 | appdirs==1.4.4 6 | argon2-cffi==23.1.0 7 | argon2-cffi-bindings==21.2.0 8 | Arpeggio==2.0.2 9 | asgiref==3.8.1 10 | attrs==25.3.0 11 | blinker==1.9.0 12 | Brotli==1.1.0 13 | certifi==2025.4.26 14 | cffi==1.17.1 15 | click==8.2.0 16 | cryptography==44.0.3 17 | dataclasses-json==0.6.7 18 | defusedxml==0.7.1 19 | Flask==3.1.0 20 | frozenlist==1.6.0 21 | gltflib==1.0.13 22 | Glymur==0.9.6 23 | h11==0.14.0 24 | h2==4.1.0 25 | hpack==4.1.0 26 | hyperframe==6.1.0 27 | idna==2.10 28 | itsdangerous==2.2.0 29 | jedi==0.19.2 30 | Jinja2==3.1.6 31 | kaitaistruct==0.10 32 | lazy-object-proxy==1.11.0 33 | ldap3==2.9.1 34 | llsd==1.0.0 35 | lxml==5.4.0 36 | MarkupSafe==3.0.2 37 | marshmallow==3.26.1 38 | mitmproxy==11.1.3 39 | mitmproxy_linux==0.11.5 40 | mitmproxy_rs==0.11.5 41 | msgpack==1.1.0 42 | multidict==6.4.4 43 | mypy_extensions==1.1.0 44 | numpy==1.26.4 45 | outleap==0.7.1 46 | packaging==25.0 47 | parso==0.8.4 48 | passlib==1.7.4 49 | prompt_toolkit==3.0.51 50 | propcache==0.3.1 51 | ptpython==3.0.30 52 | publicsuffix2==2.20191221 53 | pyasn1==0.6.1 54 | pyasn1_modules==0.4.2 55 | pycollada==0.9 56 | pycparser==2.22 57 | Pygments==2.19.1 58 | pylsqpack==0.3.22 59 | pyOpenSSL==25.0.0 60 | pyparsing==3.2.1 61 | pyperclip==1.9.0 62 | PySide6_Essentials==6.9.0 63 | python-dateutil==2.9.0.post0 64 | qasync==0.27.1 65 | recordclass==0.23.1 66 | ruamel.yaml==0.18.10 67 | service-identity==24.2.0 68 | setuptools==80.7.1 69 | shiboken6==6.9.0 70 | six==1.17.0 71 | sortedcontainers==2.4.0 72 | tornado==6.4.2 73 | transformations==2025.1.1 74 | typing-inspect==0.9.0 75 | typing_extensions==4.13.2 76 | urwid==2.6.16 77 | wcwidth==0.2.13 78 | Werkzeug==3.1.3 79 | wsproto==1.2.0 80 | yarl==1.20.0 81 | zstandard==0.23.0 82 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: Run Python Tests 2 | 3 | on: 4 | push: 5 | paths-ignore: 6 | - '*.md' 7 | pull_request: 8 | paths-ignore: 9 | - '*.md' 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ["3.12", "3.13"] 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Get history and tags for SCM versioning to work 22 | run: | 23 | git fetch --prune --unshallow 24 | git fetch --depth=1 origin +refs/tags/*:refs/tags/* 25 | - name: Set up Python ${{ matrix.python-version }} 26 | uses: actions/setup-python@v2 27 | with: 28 | python-version: ${{ matrix.python-version }} 29 | 30 | - name: Install dependencies 31 | run: | 32 | python -m pip install --upgrade pip wheel 33 | pip install -r requirements.txt 34 | pip install -r requirements-test.txt 35 | sudo apt-get install libopenjp2-7 36 | pip install -e .[gui] 37 | - name: Run Flake8 38 | run: | 39 | flake8 . 40 | - name: Test with pytest 41 | # Tests are intentionally covered to detect broken tests. 42 | run: | 43 | pytest --cov=./hippolyzer --cov=./tests --cov-report=xml 44 | 45 | # Keep this in a workflow without any other secrets in it. 46 | - name: Upload coverage to Codecov 47 | uses: codecov/codecov-action@v1 48 | with: 49 | token: ${{ secrets.CODECOV_TOKEN }} 50 | files: ./coverage.xml 51 | directory: ./coverage/reports/ 52 | flags: unittests 53 | env_vars: OS,PYTHON 54 | name: codecov-umbrella 55 | fail_ci_if_error: false 56 | path_to_write_report: ./coverage/codecov_report.txt 57 | verbose: false 58 | -------------------------------------------------------------------------------- /hippolyzer/lib/proxy/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import * 3 | 4 | from hippolyzer.lib.base.settings import Settings, SettingDescriptor 5 | 6 | _T = TypeVar("_T") 7 | 8 | 9 | class EnvSettingDescriptor(SettingDescriptor): 10 | """A setting that prefers to pull its value from the environment""" 11 | __slots__ = ("_env_name", "_env_callable") 12 | 13 | def __init__(self, default: Union[Callable[[], _T], _T], env_name: str, spec: Callable[[str], _T]): 14 | super().__init__(default) 15 | self._env_name = env_name 16 | self._env_callable = spec 17 | 18 | def __get__(self, obj, owner=None) -> _T: 19 | val = os.getenv(self._env_name) 20 | if val is not None: 21 | return self._env_callable(val) 22 | return super().__get__(obj, owner) 23 | 24 | 25 | class ProxySettings(Settings): 26 | SOCKS_PROXY_PORT: int = EnvSettingDescriptor(9061, "HIPPO_UDP_PORT", int) 27 | HTTP_PROXY_PORT: int = EnvSettingDescriptor(9062, "HIPPO_HTTP_PORT", int) 28 | LEAP_PORT: int = EnvSettingDescriptor(9063, "HIPPO_LEAP_PORT", int) 29 | PROXY_BIND_ADDR: str = EnvSettingDescriptor("127.0.0.1", "HIPPO_BIND_HOST", str) 30 | REMOTELY_ACCESSIBLE: bool = SettingDescriptor(False) 31 | USE_VIEWER_OBJECT_CACHE: bool = SettingDescriptor(False) 32 | # Whether having the proxy do automatic internal requests objects is allowed at all 33 | ALLOW_AUTO_REQUEST_OBJECTS: bool = SettingDescriptor(True) 34 | # Whether the viewer should request any directly referenced objects it didn't know about. 35 | AUTOMATICALLY_REQUEST_MISSING_OBJECTS: bool = SettingDescriptor(False) 36 | ADDON_SCRIPTS: List[str] = SettingDescriptor(list) 37 | FILTERS: Dict[str, str] = SettingDescriptor(dict) 38 | SSL_INSECURE: bool = SettingDescriptor(False) 39 | -------------------------------------------------------------------------------- /hippolyzer/lib/proxy/webapp_cap_addon.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | from mitmproxy.addons import asgiapp 4 | 5 | from hippolyzer.lib.proxy.addon_utils import BaseAddon 6 | from hippolyzer.lib.proxy.http_flow import HippoHTTPFlow 7 | from hippolyzer.lib.proxy.region import ProxiedRegion 8 | from hippolyzer.lib.proxy.sessions import Session, SessionManager 9 | 10 | 11 | async def serve(app, flow: HippoHTTPFlow): 12 | """Serve a request based on a Hippolyzer HTTP flow using a provided app""" 13 | await asgiapp.serve(app, flow.flow) 14 | # Send the modified flow object back to mitmproxy 15 | flow.resume() 16 | 17 | 18 | class WebAppCapAddon(BaseAddon, abc.ABC): 19 | """ 20 | Addon that provides a cap via an ASGI webapp 21 | 22 | Handles all registration of the cap URL and routing of the request. 23 | """ 24 | CAP_NAME: str 25 | APP: any 26 | 27 | def handle_region_registered(self, session: Session, region: ProxiedRegion): 28 | # Register a fake URL for our cap. This will add the cap URL to the Seed 29 | # response that gets sent back to the client if that cap name was requested. 30 | region.register_proxy_cap(self.CAP_NAME) 31 | 32 | def handle_session_init(self, session: Session): 33 | for region in session.regions: 34 | region.register_proxy_cap(self.CAP_NAME) 35 | 36 | def handle_http_request(self, session_manager: SessionManager, flow: HippoHTTPFlow): 37 | if flow.cap_data.cap_name != self.CAP_NAME: 38 | return 39 | # This request may take a while to generate a response for, take it out of the normal 40 | # HTTP handling flow and handle it in a async task. 41 | # TODO: Make all HTTP handling hooks async so this isn't necessary 42 | self._schedule_task(serve(self.APP, flow.take())) 43 | -------------------------------------------------------------------------------- /tests/client/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import Mapping, Optional 2 | 3 | import multidict 4 | 5 | from hippolyzer.lib.base.datatypes import UUID 6 | from hippolyzer.lib.base.message.message import Message 7 | from hippolyzer.lib.base.message.message_handler import MessageHandler 8 | from hippolyzer.lib.base.network.caps_client import CapsClient 9 | from hippolyzer.lib.base.test_utils import MockHandlingCircuit 10 | from hippolyzer.lib.client.hippo_client import ClientSettings 11 | from hippolyzer.lib.client.object_manager import ClientWorldObjectManager 12 | from hippolyzer.lib.client.state import BaseClientRegion, BaseClientSession, BaseClientSessionManager 13 | 14 | 15 | class MockClientRegion(BaseClientRegion): 16 | def __init__(self, caps_urls: Optional[dict] = None): 17 | super().__init__() 18 | self.handle = None 19 | self.circuit_addr = ("127.0.0.1", 1) 20 | self.message_handler: MessageHandler[Message, str] = MessageHandler(take_by_default=False) 21 | self.circuit = MockHandlingCircuit(self.message_handler) 22 | self._name = "Test" 23 | self.cap_urls = multidict.MultiDict() 24 | if caps_urls: 25 | self.cap_urls.update(caps_urls) 26 | self.caps_client = CapsClient(self.cap_urls) 27 | 28 | def session(self): 29 | return MockClientSession(UUID.ZERO, UUID.ZERO, UUID.ZERO, 0, None) 30 | 31 | def update_caps(self, caps: Mapping[str, str]) -> None: 32 | pass 33 | 34 | 35 | class MockClientSession(BaseClientSession): 36 | def __init__(self, id, secure_session_id, agent_id, circuit_code, 37 | session_manager: Optional[BaseClientSessionManager]): 38 | super().__init__(id, secure_session_id, agent_id, circuit_code, session_manager) 39 | self.objects = ClientWorldObjectManager(self, ClientSettings(), None) 40 | -------------------------------------------------------------------------------- /addon_examples/mock_proxy_cap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example of proxy-provided caps 3 | 4 | Useful for mocking out a cap that isn't actually implemented by the server 5 | while developing the viewer-side pieces of it. 6 | 7 | Implements a cap that accepts an `obj_id` UUID query parameter and returns 8 | the name of the object. 9 | """ 10 | import asyncio 11 | import asgiref.wsgi 12 | 13 | from flask import Flask, Response, request 14 | 15 | from hippolyzer.lib.base.datatypes import UUID 16 | from hippolyzer.lib.proxy import addon_ctx 17 | from hippolyzer.lib.proxy.webapp_cap_addon import WebAppCapAddon 18 | 19 | app = Flask("GetObjectNameCapApp") 20 | 21 | 22 | @app.route('/') 23 | async def get_object_name(): 24 | # Should always have the current region, the cap handler is bound to one. 25 | # Just need to pull it from the `addon_ctx` module's global. 26 | obj_mgr = addon_ctx.region.get().objects 27 | obj_id = UUID(request.args['obj_id']) 28 | obj = obj_mgr.lookup_fullid(obj_id) 29 | if not obj: 30 | return Response(f"Couldn't find {obj_id!r}", status=404, mimetype="text/plain") 31 | 32 | try: 33 | await asyncio.wait_for(obj_mgr.request_object_properties(obj)[0], 1.0) 34 | except asyncio.TimeoutError: 35 | return Response(f"Timed out requesting {obj_id!r}'s properties", status=500, mimetype="text/plain") 36 | 37 | return Response(obj.Name, mimetype="text/plain") 38 | 39 | 40 | class MockProxyCapExampleAddon(WebAppCapAddon): 41 | # A cap URL with this name will be tied to each region when 42 | # the sim is first connected to. The URL will be returned to the 43 | # viewer in the Seed if the viewer requests it by name. 44 | CAP_NAME = "GetObjectNameExample" 45 | # Any asgi app should be fine. 46 | APP = asgiref.wsgi.WsgiToAsgi(app) 47 | 48 | 49 | addons = [MockProxyCapExampleAddon()] 50 | -------------------------------------------------------------------------------- /addon_examples/profiler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Debug performance issues in the proxy 3 | /524 start_profiling 4 | /524 stop_profiling 5 | """ 6 | 7 | import cProfile 8 | from typing import * 9 | 10 | from hippolyzer.lib.proxy.addons import AddonManager 11 | from hippolyzer.lib.proxy.addon_utils import BaseAddon 12 | from hippolyzer.lib.proxy.commands import handle_command 13 | from hippolyzer.lib.proxy.region import ProxiedRegion 14 | from hippolyzer.lib.proxy.sessions import Session, SessionManager 15 | 16 | 17 | class ProfilingAddon(BaseAddon): 18 | def __init__(self): 19 | # We don't want this to surive module reloads so it can be an 20 | # instance attribute rather than on session_manager.addon_ctx 21 | self.profile: Optional[cProfile.Profile] = None 22 | 23 | def handle_unload(self, session_manager: SessionManager): 24 | if self.profile is not None: 25 | self.profile.disable() 26 | self.profile = None 27 | 28 | @handle_command() 29 | async def start_profiling(self, _session: Session, _region: ProxiedRegion): 30 | """Start a cProfile session""" 31 | if self.profile is not None: 32 | self.profile.disable() 33 | self.profile = cProfile.Profile() 34 | self.profile.enable() 35 | print("Started profiling") 36 | 37 | @handle_command() 38 | async def stop_profiling(self, _session: Session, _region: ProxiedRegion): 39 | """Stop profiling and save to file""" 40 | if self.profile is None: 41 | return 42 | self.profile.disable() 43 | profile = self.profile 44 | self.profile = None 45 | 46 | print("Finished profiling") 47 | profile_path = await AddonManager.UI.save_file(caption="Save Profile") 48 | if profile_path: 49 | profile.dump_stats(profile_path) 50 | 51 | 52 | addons = [ProfilingAddon()] 53 | -------------------------------------------------------------------------------- /addon_examples/repl.py: -------------------------------------------------------------------------------- 1 | from hippolyzer.lib.proxy.addons import AddonManager 2 | from hippolyzer.lib.proxy.addon_utils import BaseAddon 3 | from hippolyzer.lib.base.message.message import Message 4 | from hippolyzer.lib.proxy.region import ProxiedRegion 5 | from hippolyzer.lib.proxy.sessions import Session 6 | 7 | 8 | class REPLExampleAddon(BaseAddon): 9 | def handle_lludp_message(self, session: Session, region: ProxiedRegion, message: Message): 10 | if message.name == "ChatFromViewer": 11 | chat_msg = message["ChatData"]["Message"] 12 | if not chat_msg: 13 | return 14 | # Intercept chat messages containing "hippolyzer_test" as an example 15 | if "hippolyzer_test" in chat_msg: 16 | if AddonManager.have_active_repl(): 17 | # Already intercepting, don't touch it 18 | return 19 | # Take ownership of the message so it won't be sent by the 20 | # usual machinery. 21 | _new_msg = message.take() 22 | # repl will have access to `_new_msg` and can send it with 23 | # `region.circuit.send_message()` after it's modified. 24 | AddonManager.spawn_repl() 25 | return True 26 | if "hippolyzer_async_test" in chat_msg: 27 | if AddonManager.have_active_repl(): 28 | # Already intercepting, don't touch it 29 | return 30 | 31 | async def _coro(): 32 | foo = 4 33 | # spawn_repl() can be `await`ed, changing foo 34 | # in the repl will change what's printed on exit. 35 | await AddonManager.spawn_repl() 36 | print("foo is", foo) 37 | 38 | self._schedule_task(_coro()) 39 | 40 | 41 | addons = [REPLExampleAddon()] 42 | -------------------------------------------------------------------------------- /addon_examples/mesh_mangler.py: -------------------------------------------------------------------------------- 1 | """ 2 | Example mesh mangler addon, to be used with local mesh addon. 3 | 4 | You can edit this live to apply various transforms to local mesh. 5 | If there are no live local mesh instances, the transforms will be 6 | applied to the mesh before upload. 7 | 8 | I personally use manglers to strip bounding box materials you need 9 | to add to give a mesh an arbitrary center of rotation / scaling. 10 | """ 11 | from hippolyzer.lib.base.helpers import reorient_coord 12 | from hippolyzer.lib.base.mesh import MeshAsset 13 | from hippolyzer.lib.proxy.addons import AddonManager 14 | 15 | import local_mesh 16 | AddonManager.hot_reload(local_mesh, require_addons_loaded=True) 17 | 18 | 19 | def _reorient_coord_list(coord_list, orientation, min_val: int | float = 0): 20 | return [reorient_coord(x, orientation, min_val) for x in coord_list] 21 | 22 | 23 | def reorient_mesh(orientation): 24 | # Returns a callable that will change `mesh` to match `orientation` 25 | # X=1, Y=2, Z=3 26 | def _reorienter(mesh: MeshAsset): 27 | for material in mesh.iter_lod_materials(): 28 | if "Position" not in material: 29 | # Must be a NoGeometry LOD 30 | continue 31 | # We don't need to use positions_(to/from)_domain here since we're just naively 32 | # flipping the axes around. 33 | material["Position"] = _reorient_coord_list(material["Position"], orientation) 34 | # Are you even supposed to do this to the normals? 35 | material["Normal"] = _reorient_coord_list(material["Normal"], orientation, min_val=-1) 36 | return mesh 37 | return _reorienter 38 | 39 | 40 | class ExampleMeshManglerAddon(local_mesh.BaseMeshManglerAddon): 41 | MESH_MANGLERS = [ 42 | # Negate the X and Y axes on any mesh we upload or create temp 43 | reorient_mesh((-1, -2, 3)), 44 | ] 45 | 46 | 47 | addons = [ExampleMeshManglerAddon()] 48 | -------------------------------------------------------------------------------- /addon_examples/payday.py: -------------------------------------------------------------------------------- 1 | """ 2 | Do the money dance whenever someone in the sim pays you directly 3 | """ 4 | 5 | from hippolyzer.lib.base.datatypes import UUID 6 | from hippolyzer.lib.base.message.message import Block, Message 7 | from hippolyzer.lib.base.templates import MoneyTransactionType, ChatType 8 | from hippolyzer.lib.proxy.addon_utils import send_chat, BaseAddon 9 | from hippolyzer.lib.proxy.region import ProxiedRegion 10 | from hippolyzer.lib.proxy.sessions import Session 11 | 12 | 13 | class PaydayAddon(BaseAddon): 14 | def handle_lludp_message(self, session: Session, region: ProxiedRegion, message: Message): 15 | if message.name != "MoneyBalanceReply": 16 | return 17 | transaction_block = message["TransactionInfo"][0] 18 | # Check for direct user -> user transfer 19 | if transaction_block["TransactionType"] != MoneyTransactionType.GIFT: 20 | return 21 | 22 | # Check transfer was to us, not from us 23 | if transaction_block["DestID"] != session.agent_id: 24 | return 25 | sender = transaction_block["SourceID"] 26 | if sender == session.agent_id: 27 | return 28 | 29 | # Check if they're likely to be in the sim 30 | sender_obj = region.objects.lookup_avatar(sender) 31 | if not sender_obj: 32 | return 33 | 34 | amount = transaction_block['Amount'] 35 | send_chat( 36 | f"Thanks for the L${amount} secondlife:///app/agent/{sender}/completename !", 37 | chat_type=ChatType.SHOUT, 38 | ) 39 | # Do the traditional money dance. 40 | session.main_region.circuit.send(Message( 41 | "AgentAnimation", 42 | Block("AgentData", AgentID=session.agent_id, SessionID=session.id), 43 | Block("AnimationList", AnimID=UUID("928cae18-e31d-76fd-9cc9-2f55160ff818"), StartAnim=True), 44 | )) 45 | 46 | 47 | addons = [PaydayAddon()] 48 | -------------------------------------------------------------------------------- /hippolyzer/lib/client/rlv.py: -------------------------------------------------------------------------------- 1 | from typing import NamedTuple, List, Sequence 2 | 3 | from hippolyzer.lib.base.message.message import Message 4 | from hippolyzer.lib.base.templates import ChatType 5 | 6 | 7 | class RLVCommand(NamedTuple): 8 | behaviour: str 9 | param: str 10 | options: List[str] 11 | 12 | 13 | class RLVParser: 14 | @staticmethod 15 | def is_rlv_message(msg: Message) -> bool: 16 | chat: str = msg["ChatData"]["Message"] 17 | chat_type: int = msg["ChatData"]["ChatType"] 18 | return chat and chat.startswith("@") and chat_type == ChatType.OWNER 19 | 20 | @staticmethod 21 | def parse_chat(chat: str) -> List[RLVCommand]: 22 | assert chat.startswith("@") 23 | chat = chat.lstrip("@") 24 | commands = [] 25 | for command_str in chat.split(","): 26 | if not command_str: 27 | continue 28 | # RLV-style command, `(:;)?(=)?` 29 | # Roughly (?[^:=]+)(:(?