12 Commits

Author SHA1 Message Date
Salad Dais
848a6745c0 v0.11.3 2022-07-28 03:55:22 +00:00
Salad Dais
0cbbedd27b Make assignments on BaseAddon class objects work as expected
The descriptors were being silently clobbered for a while now, and
I never noticed. Oops!
2022-07-28 03:39:53 +00:00
Salad Dais
e951a5b5c3 Make datetime objects (de)serialize in binary LLSD more accurately
Fixes some precision issues with LLBase's LLSD serialization stuff
where the microseconds component was dropped. May still get some
off-by-one serialization differences due to rounding.
2022-07-27 22:42:58 +00:00
Salad Dais
68bf3ba4a2 More comments in mesh module 2022-07-27 22:21:42 +00:00
Salad Dais
5b4f8f03dc Use same compression ratio for LLSD as indra 2022-07-27 22:16:31 +00:00
Salad Dais
d7c2215cbc Remove special Firestorm section from readme
The new Firestorm release added proxy configuration back in.
2022-07-27 02:50:06 +00:00
Salad Dais
629e59d3f9 Add option to upload mesh deformer directly 2022-07-26 04:13:15 +00:00
Salad Dais
8f68bc219e Split up deformer helper a little 2022-07-26 03:44:32 +00:00
Salad Dais
ba296377de Save mesh deformers as files rather than uploading directly 2022-07-26 02:12:54 +00:00
Salad Dais
e34927a996 Improve AssetUploader API, make uploader example addon use it 2022-07-26 00:11:37 +00:00
Salad Dais
3c6a917550 Add command to deformer_helper addon that uploads mesh deformers
Sometimes these are preferable to deformer anims.
2022-07-25 23:11:15 +00:00
Salad Dais
dbae2acf27 Add basic AssetUploader class
Should make it less anoying to upload procedurally generated mesh
outside of local mesh mode
2022-07-25 22:08:28 +00:00
13 changed files with 518 additions and 113 deletions

View File

@@ -83,27 +83,9 @@ SOCKS 5 works correctly on these platforms, so you can just configure it through
the `no_proxy` env var appropriately. For ex. `no_proxy="asset-cdn.glb.agni.lindenlab.com" ./firestorm`.
* Log in!
##### Firestorm
The proxy selection dialog in the most recent Firestorm release is non-functional, as
https://bitbucket.org/lindenlab/viewer/commits/454c7f4543688126b2fa5c0560710f5a1733702e was not pulled in.
As a workaround, you can go to `Debug -> Show Debug Settings` and enter the following values:
| Name | Value |
|---------------------|-----------|
| HttpProxyType | Web |
| BrowserProxyAddress | 127.0.0.1 |
| BrowserProxyEnabled | TRUE |
| BrowserProxyPort | 9062 |
| Socks5ProxyEnabled | TRUE |
| Socks5ProxyHost | 127.0.0.1 |
| Socks5ProxyPort | 9061 |
Or, if you're on Linux, you can also use [LinHippoAutoProxy](https://github.com/SaladDais/LinHippoAutoProxy).
Connections from the in-viewer browser will likely _not_ be run through Hippolyzer when using either of
these workarounds.
Or, if you're on Linux, you can instead use [LinHippoAutoProxy](https://github.com/SaladDais/LinHippoAutoProxy)
to launch your viewer, which will configure everything for you. Note that connections from the in-viewer browser will
likely _not_ be run through Hippolyzer when using LinHippoAutoProxy.
### Filtering

View File

@@ -4,8 +4,13 @@ Helper for making deformer anims. This could have a GUI I guess.
import dataclasses
from typing import *
import numpy as np
import transformations
from hippolyzer.lib.base.datatypes import Vector3, Quaternion, UUID
from hippolyzer.lib.base.llanim import Joint, Animation, PosKeyframe, RotKeyframe
from hippolyzer.lib.base.mesh import MeshAsset, SegmentHeaderDict, SkinSegmentDict, LLMeshSerializer
from hippolyzer.lib.base.serialization import BufferWriter
from hippolyzer.lib.proxy.addon_utils import show_message, BaseAddon, SessionProperty
from hippolyzer.lib.proxy.addons import AddonManager
from hippolyzer.lib.proxy.commands import handle_command, Parameter
@@ -45,6 +50,58 @@ def build_deformer(joints: Dict[str, DeformerJoint]) -> bytes:
return anim.to_bytes()
def build_mesh_deformer(joints: Dict[str, DeformerJoint]) -> bytes:
skin_seg = SkinSegmentDict(
joint_names=[],
bind_shape_matrix=identity_mat4(),
inverse_bind_matrix=[],
alt_inverse_bind_matrix=[],
pelvis_offset=0.0,
lock_scale_if_joint_position=False
)
for joint_name, joint in joints.items():
# We can only represent joint translations, ignore this joint if it doesn't have any.
if not joint.pos:
continue
skin_seg['joint_names'].append(joint_name)
# Inverse bind matrix isn't actually used, so we can just give it a placeholder value of the
# identity mat4. This might break things in weird ways because the matrix isn't actually sensible.
skin_seg['inverse_bind_matrix'].append(identity_mat4())
# Create a flattened mat4 that only has a translation component of our joint pos
# The viewer ignores any other component of these matrices so no point putting shear
# or perspective or whatever :)
joint_mat4 = pos_to_mat4(joint.pos)
# Ask the viewer to override this joint's usual parent-relative position with our matrix
skin_seg['alt_inverse_bind_matrix'].append(joint_mat4)
# Make a dummy mesh and shove our skin segment onto it. None of the tris are rigged, so the
# viewer will freak out and refuse to display the tri, only the joint translations will be used.
# Supposedly a mesh with a `skin` segment but no weights on the material should just result in an
# effectively unrigged material, but that's not the case. Oh well.
mesh = MeshAsset.make_triangle()
mesh.header['skin'] = SegmentHeaderDict(offset=0, size=0)
mesh.segments['skin'] = skin_seg
writer = BufferWriter("!")
writer.write(LLMeshSerializer(), mesh)
return writer.copy_buffer()
def identity_mat4() -> List[float]:
"""
Return an "Identity" mat4
Effectively represents a transform of no rot, no translation, no shear, no perspective
and scaling by 1.0 on every axis.
"""
return list(np.identity(4).flatten('F'))
def pos_to_mat4(pos: Vector3) -> List[float]:
"""Convert a position Vector3 to a Translation Mat4"""
return list(transformations.compose_matrix(translate=tuple(pos)).flatten('F'))
class DeformerAddon(BaseAddon):
deform_joints: Dict[str, DeformerJoint] = SessionProperty(dict)
@@ -118,5 +175,41 @@ class DeformerAddon(BaseAddon):
self._reapply_deformer(session, region)
return True
@handle_command()
async def save_deformer_as_mesh(self, _session: Session, _region: ProxiedRegion):
"""
Export the deformer as a crafted rigged mesh rather than an animation
Mesh deformers have the advantage that they don't cause your joints to "stick"
like animations do when using animations with pos keyframes.
"""
filename = await AddonManager.UI.save_file(filter_str="LL Mesh (*.llmesh)")
if not filename:
return
with open(filename, "wb") as f:
f.write(build_mesh_deformer(self.deform_joints))
@handle_command()
async def upload_deformer_as_mesh(self, _session: Session, region: ProxiedRegion):
"""Same as save_deformer_as_mesh, but uploads the mesh directly to SL."""
mesh_bytes = build_mesh_deformer(self.deform_joints)
try:
# Send off mesh to calculate upload cost
upload_token = await region.asset_uploader.initiate_mesh_upload("deformer", mesh_bytes)
except Exception as e:
show_message(e)
raise
if not await AddonManager.UI.confirm("Upload", f"Spend {upload_token.linden_cost}L on upload?"):
return
# Do the actual upload
try:
await region.asset_uploader.complete_upload(upload_token)
except Exception as e:
show_message(e)
raise
addons = [DeformerAddon()]

View File

@@ -2,21 +2,15 @@
Example of how to upload assets, assumes assets are already encoded
in the appropriate format.
/524 upload <asset type>
/524 upload_asset <asset type>
"""
import pprint
from pathlib import Path
from typing import *
import aiohttp
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.message.message import Block, Message
from hippolyzer.lib.base.templates import AssetType
from hippolyzer.lib.proxy.addons import AddonManager
from hippolyzer.lib.proxy.addon_utils import ais_item_to_inventory_data, show_message, BaseAddon
from hippolyzer.lib.proxy.addon_utils import show_message, BaseAddon
from hippolyzer.lib.proxy.commands import handle_command, Parameter
from hippolyzer.lib.base.network.transport import Direction
from hippolyzer.lib.proxy.region import ProxiedRegion
from hippolyzer.lib.proxy.sessions import Session
@@ -29,7 +23,6 @@ class UploaderAddon(BaseAddon):
async def upload_asset(self, _session: Session, region: ProxiedRegion,
asset_type: AssetType, flags: Optional[int] = None):
"""Upload a raw asset with optional flags"""
inv_type = asset_type.inventory_type
file = await AddonManager.UI.open_file()
if not file:
return
@@ -42,67 +35,29 @@ class UploaderAddon(BaseAddon):
with open(file, "rb") as f:
file_body = f.read()
params = {
"asset_type": asset_type.human_name,
"description": "(No Description)",
"everyone_mask": 0,
"group_mask": 0,
"folder_id": UUID(), # Puts it in the default folder, I guess. Undocumented.
"inventory_type": inv_type.human_name,
"name": name,
"next_owner_mask": 581632,
}
if flags is not None:
params['flags'] = flags
try:
if asset_type == AssetType.MESH:
# Kicking off a mesh upload works a little differently internally
upload_token = await region.asset_uploader.initiate_mesh_upload(
name, file_body, flags=flags
)
else:
upload_token = await region.asset_uploader.initiate_asset_upload(
name, asset_type, file_body, flags=flags,
)
except Exception as e:
show_message(e)
raise
caps = region.caps_client
async with aiohttp.ClientSession() as sess:
async with caps.post('NewFileAgentInventory', llsd=params, session=sess) as resp:
parsed = await resp.read_llsd()
if "uploader" not in parsed:
show_message(f"Upload error!: {parsed!r}")
return
print("Got upload URL, uploading...")
if not await AddonManager.UI.confirm("Upload", f"Spend {upload_token.linden_cost}L on upload?"):
return
async with caps.post(parsed["uploader"], data=file_body, session=sess) as resp:
upload_parsed = await resp.read_llsd()
if "new_inventory_item" not in upload_parsed:
show_message(f"Got weird upload resp: {pprint.pformat(upload_parsed)}")
return
await self._force_inv_update(region, upload_parsed['new_inventory_item'])
@handle_command(item_id=UUID)
async def force_inv_update(self, _session: Session, region: ProxiedRegion, item_id: UUID):
"""Force an inventory update for a given item id"""
await self._force_inv_update(region, item_id)
async def _force_inv_update(self, region: ProxiedRegion, item_id: UUID):
session = region.session()
ais_req_data = {
"items": [
{
"owner_id": session.agent_id,
"item_id": item_id,
}
]
}
async with region.caps_client.post('FetchInventory2', llsd=ais_req_data) as resp:
ais_item = (await resp.read_llsd())["items"][0]
message = Message(
"UpdateCreateInventoryItem",
Block(
"AgentData",
AgentID=session.agent_id,
SimApproved=1,
TransactionID=UUID.random(),
),
ais_item_to_inventory_data(ais_item),
direction=Direction.IN
)
region.circuit.send(message)
# Do the actual upload
try:
await region.asset_uploader.complete_upload(upload_token)
except Exception as e:
show_message(e)
raise
addons = [UploaderAddon()]

View File

@@ -1,3 +1,4 @@
import datetime
import typing
import zlib
@@ -52,16 +53,93 @@ def format_notation(val: typing.Any):
def format_binary(val: typing.Any, with_header=True):
val = llbase.llsd.format_binary(val)
if not with_header:
return val.split(b"\n", 1)[1]
val = _format_binary_recurse(val)
if with_header:
return b'<?llsd/binary?>\n' + val
return val
# This is copied almost wholesale from https://bitbucket.org/lindenlab/llbase/src/master/llbase/llsd.py
# With a few minor changes to make serialization round-trip correctly. It's evil.
def _format_binary_recurse(something) -> bytes:
"""Binary formatter workhorse."""
def _format_list(something):
array_builder = []
array_builder.append(b'[' + struct.pack('!i', len(something)))
for item in something:
array_builder.append(_format_binary_recurse(item))
array_builder.append(b']')
return b''.join(array_builder)
if something is None:
return b'!'
elif isinstance(something, LLSD):
return _format_binary_recurse(something.thing)
elif isinstance(something, bool):
if something:
return b'1'
else:
return b'0'
elif is_integer(something):
try:
return b'i' + struct.pack('!i', something)
except (OverflowError, struct.error) as exc:
raise LLSDSerializationError(str(exc), something)
elif isinstance(something, float):
try:
return b'r' + struct.pack('!d', something)
except SystemError as exc:
raise LLSDSerializationError(str(exc), something)
elif isinstance(something, uuid.UUID):
return b'u' + something.bytes
elif isinstance(something, binary):
return b'b' + struct.pack('!i', len(something)) + something
elif is_string(something):
if is_unicode(something):
something = something.encode("utf8")
return b's' + struct.pack('!i', len(something)) + something
elif isinstance(something, uri):
return b'l' + struct.pack('!i', len(something)) + something.encode("utf8")
elif isinstance(something, datetime.datetime):
return b'd' + struct.pack('<d', something.timestamp())
elif isinstance(something, datetime.date):
seconds_since_epoch = calendar.timegm(something.timetuple())
return b'd' + struct.pack('<d', seconds_since_epoch)
elif isinstance(something, (list, tuple)):
return _format_list(something)
elif isinstance(something, dict):
map_builder = []
map_builder.append(b'{' + struct.pack('!i', len(something)))
for key, value in something.items():
if isinstance(key, str):
key = key.encode("utf8")
map_builder.append(b'k' + struct.pack('!i', len(key)) + key)
map_builder.append(_format_binary_recurse(value))
map_builder.append(b'}')
return b''.join(map_builder)
else:
try:
return _format_list(list(something))
except TypeError:
raise LLSDSerializationError(
"Cannot serialize unknown type: %s (%s)" %
(type(something), something))
class HippoLLSDBinaryParser(llbase.llsd.LLSDBinaryParser):
def __init__(self):
super().__init__()
self._dispatch[ord('u')] = lambda: UUID(bytes=self._getc(16))
self._dispatch[ord('d')] = self._parse_date
def _parse_date(self):
seconds = struct.unpack("<d", self._getc(8))[0]
try:
return datetime.datetime.fromtimestamp(seconds, tz=datetime.timezone.utc)
except OverflowError as exc:
# A garbage seconds value can cause utcfromtimestamp() to raise
# OverflowError: timestamp out of range for platform time_t
self._error(exc, -8)
def _parse_string(self):
# LLSD's C++ API lets you stuff binary in a string field even though it's only
@@ -89,7 +167,7 @@ def parse_notation(data: bytes):
def zip_llsd(val: typing.Any):
return zlib.compress(format_binary(val, with_header=False))
return zlib.compress(format_binary(val, with_header=False), level=zlib.Z_BEST_COMPRESSION)
def unzip_llsd(data: bytes):

View File

@@ -26,6 +26,50 @@ class MeshAsset:
segments: MeshSegmentDict = dataclasses.field(default_factory=dict)
raw_segments: Dict[str, bytes] = dataclasses.field(default_factory=dict)
@classmethod
def make_triangle(cls) -> MeshAsset:
"""Make an asset representing an un-rigged single-sided mesh triangle"""
inst = cls()
inst.header = {
"version": 1,
"high_lod": {"offset": 0, "size": 0},
"physics_mesh": {"offset": 0, "size": 0},
"physics_convex": {"offset": 0, "size": 0},
}
base_lod: LODSegmentDict = {
'Normal': [
Vector3(-0.0, -0.0, -1.0),
Vector3(-0.0, -0.0, -1.0),
Vector3(-0.0, -0.0, -1.0)
],
'PositionDomain': {'Max': [0.5, 0.5, 0.0], 'Min': [-0.5, -0.5, 0.0]},
'Position': [
Vector3(0.0, 0.0, 0.0),
Vector3(1.0, 0.0, 0.0),
Vector3(0.5, 1.0, 0.0)
],
'TexCoord0Domain': {'Max': [1.0, 1.0], 'Min': [0.0, 0.0]},
'TexCoord0': [
Vector2(0.0, 0.0),
Vector2(1.0, 0.0),
Vector2(0.5, 1.0)
],
'TriangleList': [[0, 1, 2]],
}
inst.segments['physics_mesh'] = [deepcopy(base_lod)]
inst.segments['high_lod'] = [deepcopy(base_lod)]
convex_segment: PhysicsConvexSegmentDict = {
'BoundingVerts': [
Vector3(-0.0, 1.0, -1.0),
Vector3(-1.0, -1.0, -1.0),
Vector3(1.0, -1.0, -1.0)
],
'Max': [0.5, 0.5, 0.0],
'Min': [-0.5, -0.5, 0.0]
}
inst.segments['physics_convex'] = convex_segment
return inst
def iter_lods(self) -> Generator[List[LODSegmentDict], None, None]:
for lod_name, lod_val in self.segments.items():
if lod_name.endswith("_lod"):
@@ -135,20 +179,26 @@ class VertexWeight(recordclass.datatuple): # type: ignore
class SkinSegmentDict(TypedDict, total=False):
"""Rigging information"""
joint_names: List[str]
# model -> world transform matrix for model
# model -> world transform mat4 for model
bind_shape_matrix: List[float]
# world -> joint local transform matrices
# world -> joint local transform mat4s
inverse_bind_matrix: List[List[float]]
# offset matrices for joints, translation-only.
# Not sure what these are relative to, base joint or model <0,0,0>.
# Transform mat4s for the joint nodes themselves.
# The matrices may have scale or other components, but only the
# translation component will be used by the viewer.
# All translations are relative to the joint's parent.
alt_inverse_bind_matrix: List[List[float]]
lock_scale_if_joint_position: bool
pelvis_offset: float
class PhysicsConvexSegmentDict(DomainDict, total=False):
"""Data for convex hull collisions, populated by the client"""
# Min / Max domain vals are inline, unlike for LODs
"""
Data for convex hull collisions, populated by the client
Min / Max pos domain vals are inline, unlike for LODs, so this inherits from DomainDict
"""
# Indices into the Positions list
HullList: List[int]
# -1.0 - 1.0, dequantized from binary field of U16s
Positions: List[Vector3]
@@ -158,13 +208,13 @@ class PhysicsConvexSegmentDict(DomainDict, total=False):
class PhysicsHavokSegmentDict(TypedDict, total=False):
"""Cached data for Havok collisions, populated by sim and not used by client."""
HullMassProps: MassPropsDict
MOPP: MOPPDict
MeshDecompMassProps: MassPropsDict
HullMassProps: HavokMassPropsDict
MOPP: HavokMOPPDict
MeshDecompMassProps: HavokMassPropsDict
WeldingData: bytes
class MassPropsDict(TypedDict, total=False):
class HavokMassPropsDict(TypedDict, total=False):
# Vec, center of mass
CoM: List[float]
# 9 floats, Mat3?
@@ -173,7 +223,7 @@ class MassPropsDict(TypedDict, total=False):
volume: float
class MOPPDict(TypedDict, total=False):
class HavokMOPPDict(TypedDict, total=False):
"""Memory Optimized Partial Polytope"""
BuildType: int
MoppData: bytes

View File

@@ -1997,6 +1997,35 @@ class ModifyLandAction(IntEnum):
REVERT = 5
@se.flag_field_serializer("RevokePermissions", "Data", "ObjectPermissions")
@se.flag_field_serializer("ScriptQuestion", "Data", "Questions")
@se.flag_field_serializer("ScriptAnswerYes", "Data", "Questions")
class ScriptPermissions(IntFlag):
# "1" itself seems to be unused?
TAKE_MONEY = 1 << 1
TAKE_CONTROLS = 1 << 2
# Doesn't seem to be used?
REMAP_CONTROLS = 1 << 3
TRIGGER_ANIMATIONS = 1 << 4
ATTACH = 1 << 5
# Doesn't seem to be used?
RELEASE_OWNERSHIP = 1 << 6
CHANGE_LINKS = 1 << 7
# Object joints don't exist anymore
CHANGE_JOINTS = 1 << 8
# Change its own permissions? Doesn't seem to be used.
CHANGE_PERMISSIONS = 1 << 9
TRACK_CAMERA = 1 << 10
CONTROL_CAMERA = 1 << 11
TELEPORT = 1 << 12
JOIN_EXPERIENCE = 1 << 13
MANAGE_ESTATE_ACCESS = 1 << 14
ANIMATION_OVERRIDE = 1 << 15
RETURN_OBJECTS = 1 << 16
FORCE_SIT = 1 << 17
CHANGE_ENVIRONMENT = 1 << 18
@se.http_serializer("RenderMaterials")
class RenderMaterialsSerializer(se.BaseHTTPSerializer):
@classmethod

View File

@@ -0,0 +1,127 @@
from typing import NamedTuple, Union, Optional
import hippolyzer.lib.base.serialization as se
from hippolyzer.lib.base import llsd
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.mesh import MeshAsset, LLMeshSerializer
from hippolyzer.lib.base.templates import AssetType
from hippolyzer.lib.client.state import BaseClientRegion
class UploadError(Exception):
pass
class UploadToken(NamedTuple):
linden_cost: int
uploader_url: str
payload: bytes
class AssetUploader:
def __init__(self, region: BaseClientRegion):
self._region = region
async def initiate_asset_upload(self, name: str, asset_type: AssetType,
body: bytes, flags: Optional[int] = None) -> UploadToken:
payload = {
"asset_type": asset_type.human_name,
"description": "(No Description)",
"everyone_mask": 0,
"group_mask": 0,
"folder_id": UUID.ZERO, # Puts it in the default folder, I guess. Undocumented.
"inventory_type": asset_type.inventory_type.human_name,
"name": name,
"next_owner_mask": 581632,
}
if flags is not None:
payload['flags'] = flags
resp_payload = await self._make_newfileagentinventory_req(payload)
return UploadToken(resp_payload["upload_price"], resp_payload["uploader"], body)
async def _make_newfileagentinventory_req(self, payload: dict):
async with self._region.caps_client.post("NewFileAgentInventory", llsd=payload) as resp:
resp.raise_for_status()
resp_payload = await resp.read_llsd()
# Need to sniff the resp payload for this because SL sends a 200 status code on error
if "error" in resp_payload:
raise UploadError(resp_payload)
return resp_payload
async def complete_upload(self, token: UploadToken) -> dict:
async with self._region.caps_client.post(token.uploader_url, data=token.payload) as resp:
resp.raise_for_status()
resp_payload = await resp.read_llsd()
# The actual upload endpoints return 200 on error, have to sniff the payload to figure
# out if it actually failed...
if "error" in resp_payload:
raise UploadError(resp_payload)
await self._handle_upload_complete(resp_payload)
return resp_payload
async def _handle_upload_complete(self, resp_payload: dict):
"""
Generic hook called when any asset upload completes.
Could trigger an AIS fetch to send the viewer details about the item we just created,
assuming we were in proxy context.
"""
pass
# The mesh upload flow is a little special, so it gets its own methods
async def initiate_mesh_upload(self, name: str, mesh: Union[bytes, MeshAsset],
flags: Optional[int] = None) -> UploadToken:
"""
Very basic LL-serialized mesh uploader
Currently only handles a single mesh with a single face and no associated textures.
"""
if isinstance(mesh, MeshAsset):
writer = se.BufferWriter("!")
writer.write(LLMeshSerializer(), mesh)
mesh = writer.copy_buffer()
asset_resources = self._build_asset_resources(name, mesh)
payload = {
'asset_resources': asset_resources,
'asset_type': 'mesh',
'description': '(No Description)',
'everyone_mask': 0,
'folder_id': UUID.ZERO,
'group_mask': 0,
'inventory_type': 'object',
'name': name,
'next_owner_mask': 581632,
'texture_folder_id': UUID.ZERO
}
if flags is not None:
payload['flags'] = flags
resp_payload = await self._make_newfileagentinventory_req(payload)
upload_body = llsd.format_xml(asset_resources)
return UploadToken(resp_payload["upload_price"], resp_payload["uploader"], upload_body)
def _build_asset_resources(self, name: str, mesh: bytes) -> dict:
return {
'instance_list': [
{
'face_list': [
{
'diffuse_color': [1.0, 1.0, 1.0, 1.0],
'fullbright': False
}
],
'material': 3,
'mesh': 0,
'mesh_name': name,
'physics_shape_type': 2,
'position': [0.0, 0.0, 0.0],
'rotation': [0.7071067690849304, 0.0, 0.0, 0.7071067690849304],
'scale': [1.0, 1.0, 1.0]
}
],
'mesh_list': [mesh],
'metric': 'MUT_Unspecified',
'texture_list': []
}

View File

@@ -1,3 +1,7 @@
from __future__ import annotations
from typing import *
import abc
import copy
import dataclasses
@@ -5,7 +9,6 @@ import multiprocessing
import pickle
import secrets
import warnings
from typing import *
from hippolyzer.lib.base.datatypes import UUID, Vector3
from hippolyzer.lib.base.message.message import Block, Message
@@ -14,10 +17,11 @@ from hippolyzer.lib.proxy import addon_ctx
from hippolyzer.lib.proxy.addons import AddonManager
from hippolyzer.lib.proxy.http_flow import HippoHTTPFlow
from hippolyzer.lib.base.network.transport import UDPPacket, Direction
from hippolyzer.lib.proxy.region import ProxiedRegion
from hippolyzer.lib.proxy.sessions import SessionManager, Session
from hippolyzer.lib.proxy.task_scheduler import TaskLifeScope
from hippolyzer.lib.base.templates import ChatSourceType, ChatType
if TYPE_CHECKING:
from hippolyzer.lib.proxy.sessions import SessionManager, Session
from hippolyzer.lib.proxy.region import ProxiedRegion
class AssetAliasTracker:
@@ -139,7 +143,34 @@ def ais_folder_to_inventory_data(ais_folder: dict):
)
class BaseAddon(abc.ABC):
class MetaBaseAddon(abc.ABCMeta):
"""
Metaclass for BaseAddon that prevents class member assignments from clobbering descriptors
Without this things like:
class Foo(BaseAddon):
bar: int = GlobalProperty(0)
Foo.bar = 2
Won't work as you expect!
"""
def __setattr__(self, key: str, value):
# TODO: Keep track of AddonProperties in __new__ or something?
try:
existing = object.__getattribute__(self, key)
except AttributeError:
# If the attribute doesn't exist then it's fine to use the base setattr.
super().__setattr__(key, value)
return
if existing and isinstance(existing, BaseAddonProperty):
existing.__set__(self, value)
return
super().__setattr__(key, value)
class BaseAddon(metaclass=MetaBaseAddon):
def _schedule_task(self, coro: Coroutine, session=None,
region_scoped=False, session_scoped=True, addon_scoped=True):
session = session or addon_ctx.session.get(None) or None
@@ -208,7 +239,7 @@ class BaseAddon(abc.ABC):
_T = TypeVar("_T")
_U = TypeVar("_U", Session, SessionManager)
_U = TypeVar("_U", "Session", "SessionManager")
class BaseAddonProperty(abc.ABC, Generic[_T, _U]):
@@ -257,7 +288,7 @@ class BaseAddonProperty(abc.ABC, Generic[_T, _U]):
self._get_context_obj().addon_ctx[self.name] = value
class SessionProperty(BaseAddonProperty[_T, Session]):
class SessionProperty(BaseAddonProperty[_T, "Session"]):
"""
Property tied to the current session context
@@ -267,7 +298,7 @@ class SessionProperty(BaseAddonProperty[_T, Session]):
return addon_ctx.session.get()
class GlobalProperty(BaseAddonProperty[_T, SessionManager]):
class GlobalProperty(BaseAddonProperty[_T, "SessionManager"]):
"""
Property tied to the global SessionManager context

View File

@@ -0,0 +1,39 @@
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.message.message import Message, Block
from hippolyzer.lib.base.network.transport import Direction
from hippolyzer.lib.client.asset_uploader import AssetUploader
from hippolyzer.lib.proxy.addon_utils import ais_item_to_inventory_data
class ProxyAssetUploader(AssetUploader):
async def _handle_upload_complete(self, resp_payload: dict):
# Check if this a failure response first, raising if it is
await super()._handle_upload_complete(resp_payload)
# Fetch enough data from AIS to tell the viewer about the new inventory item
session = self._region.session()
item_id = resp_payload["new_inventory_item"]
ais_req_data = {
"items": [
{
"owner_id": session.agent_id,
"item_id": item_id,
}
]
}
async with self._region.caps_client.post('FetchInventory2', llsd=ais_req_data) as resp:
ais_item = (await resp.read_llsd())["items"][0]
# Got it, ship it off to the viewer
message = Message(
"UpdateCreateInventoryItem",
Block(
"AgentData",
AgentID=session.agent_id,
SimApproved=1,
TransactionID=UUID.random(),
),
ais_item_to_inventory_data(ais_item),
direction=Direction.IN
)
self._region.circuit.send(message)

View File

@@ -22,6 +22,7 @@ from hippolyzer.lib.proxy.caps import CapType
from hippolyzer.lib.proxy.object_manager import ProxyObjectManager
from hippolyzer.lib.base.transfer_manager import TransferManager
from hippolyzer.lib.base.xfer_manager import XferManager
from hippolyzer.lib.proxy.asset_uploader import ProxyAssetUploader
if TYPE_CHECKING:
from hippolyzer.lib.proxy.sessions import Session
@@ -66,6 +67,7 @@ class ProxiedRegion(BaseClientRegion):
self.objects: ProxyObjectManager = ProxyObjectManager(self, may_use_vo_cache=True)
self.xfer_manager = XferManager(proxify(self), self.session().secure_session_id)
self.transfer_manager = TransferManager(proxify(self), session.agent_id, session.id)
self.asset_uploader = ProxyAssetUploader(proxify(self))
self._recalc_caps()
@property

View File

@@ -25,7 +25,7 @@ from setuptools import setup, find_packages
here = path.abspath(path.dirname(__file__))
version = '0.11.2'
version = '0.11.3'
with open(path.join(here, 'README.md')) as readme_fh:
readme = readme_fh.read()

View File

@@ -62,3 +62,8 @@ class TestMesh(unittest.TestCase):
mat_list = list(mesh.iter_lod_materials())
self.assertEqual(4, len(mat_list))
self.assertIsInstance(mat_list[0], dict)
def test_make_default_triangle(self):
tri = MeshAsset.make_triangle()
self.assertEqual(0.5, tri.segments['high_lod'][0]['Position'][2].X)
self.assertEqual(1, tri.header['version'])

View File

@@ -33,10 +33,11 @@ class MockAddon(BaseAddon):
PARENT_ADDON_SOURCE = """
from hippolyzer.lib.proxy.addon_utils import BaseAddon
from hippolyzer.lib.proxy.addon_utils import BaseAddon, GlobalProperty
class ParentAddon(BaseAddon):
baz = None
quux: int = GlobalProperty(0)
@classmethod
def foo(cls):
@@ -136,3 +137,16 @@ class AddonIntegrationTests(BaseProxyTest):
AddonManager.unload_addon_from_path(str(self.parent_path), reload=True)
await asyncio.sleep(0.001)
self.assertNotIn('hippolyzer.user_addon_parent_addon', sys.modules)
async def test_global_property_access_and_set(self):
with open(self.parent_path, "w") as f:
f.write(PARENT_ADDON_SOURCE)
AddonManager.load_addon_from_path(str(self.parent_path), reload=True)
# Wait for the init hooks to run
await asyncio.sleep(0.001)
self.assertFalse("quux" in self.session_manager.addon_ctx)
parent_addon_mod = AddonManager.FRESH_ADDON_MODULES['hippolyzer.user_addon_parent_addon']
self.assertEqual(0, parent_addon_mod.ParentAddon.quux)
self.assertEqual(0, self.session_manager.addon_ctx["quux"])
parent_addon_mod.ParentAddon.quux = 1
self.assertEqual(1, self.session_manager.addon_ctx["quux"])