Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
848a6745c0 | ||
|
|
0cbbedd27b | ||
|
|
e951a5b5c3 | ||
|
|
68bf3ba4a2 | ||
|
|
5b4f8f03dc | ||
|
|
d7c2215cbc | ||
|
|
629e59d3f9 | ||
|
|
8f68bc219e | ||
|
|
ba296377de | ||
|
|
e34927a996 | ||
|
|
3c6a917550 | ||
|
|
dbae2acf27 |
24
README.md
24
README.md
@@ -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
|
||||
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
127
hippolyzer/lib/client/asset_uploader.py
Normal file
127
hippolyzer/lib/client/asset_uploader.py
Normal 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': []
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
39
hippolyzer/lib/proxy/asset_uploader.py
Normal file
39
hippolyzer/lib/proxy/asset_uploader.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
2
setup.py
2
setup.py
@@ -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()
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user