11 Commits

Author SHA1 Message Date
Salad Dais
1085dbc8ab v0.14.1 2023-12-22 04:38:30 +00:00
Salad Dais
fb9740003e Fix a couple AIS cases 2023-12-22 04:38:30 +00:00
Salad Dais
087f16fbc5 Simplify Inventory/AssetType legacy conversion 2023-12-22 03:57:36 +00:00
Salad Dais
fa96e80590 Simplify AIS<->InventoryData conversion 2023-12-22 02:40:53 +00:00
Salad Dais
539d38fb4a Fix legacy serialization for categories 2023-12-21 22:09:48 +00:00
Salad Dais
caaf0b0e13 Add tests for legacy category parsing 2023-12-21 20:12:41 +00:00
Salad Dais
16958e516d More enumification in inventory code 2023-12-21 19:18:58 +00:00
Salad Dais
74e4e0c4ec Start supporting enums in inventory schema 2023-12-21 14:55:14 +00:00
Salad Dais
3efeb46500 Add notes about inventory compatibility issues 2023-12-21 06:41:47 +00:00
Salad Dais
0f2e933be1 Make legacy input schema round-trip correctly 2023-12-20 22:26:03 +00:00
Salad Dais
a7f40b0d15 Properly handle inventory metadata field 2023-12-20 03:23:03 +00:00
11 changed files with 367 additions and 172 deletions

View File

@@ -17,7 +17,7 @@ from hippolyzer.lib.base import llsd
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.inventory import InventoryModel, InventoryObject
from hippolyzer.lib.base.message.message import Message, Block
from hippolyzer.lib.base.templates import XferFilePath
from hippolyzer.lib.base.templates import XferFilePath, AssetType
from hippolyzer.lib.proxy import addon_ctx
from hippolyzer.lib.proxy.webapp_cap_addon import WebAppCapAddon
@@ -64,7 +64,7 @@ async def get_task_inventory():
InventoryObject(
name="Contents",
parent_id=UUID.ZERO,
type="category",
type=AssetType.CATEGORY,
obj_id=obj_id
).to_llsd()
],

View File

@@ -132,6 +132,13 @@ def proxify(obj: Union[Callable[[], _T], weakref.ReferenceType, _T]) -> _T:
return obj
class BiDiDict(Generic[_T]):
"""Dictionary for bidirectional lookups"""
def __init__(self, values: Dict[_T, _T]):
self.forward = {**values}
self.backward = {value: key for (key, value) in values.items()}
def bytes_unescape(val: bytes) -> bytes:
# Only in CPython. bytes -> bytes with escape decoding.
# https://stackoverflow.com/a/23151714

View File

@@ -3,11 +3,19 @@ Parse the horrible legacy inventory-related format.
It's typically only used for object contents now.
"""
# TODO: Maybe handle CRC calculation? Does anything care about that?
# I don't think anything in the viewer actually looks at the result
# of the CRC check for UDP stuff.
from __future__ import annotations
import abc
import dataclasses
import datetime as dt
import inspect
import logging
import secrets
import struct
import typing
import weakref
@@ -29,6 +37,8 @@ from hippolyzer.lib.base.legacy_schema import (
SchemaUUID,
schema_field,
)
from hippolyzer.lib.base.message.message import Block
from hippolyzer.lib.base.templates import SaleType, InventoryType, LookupIntEnum, AssetType, FolderType
MAGIC_ID = UUID("3c115e51-04f4-523c-9fa6-98aff1034730")
LOG = logging.getLogger(__name__)
@@ -38,12 +48,38 @@ _T = TypeVar("_T")
class SchemaFlagField(SchemaHexInt):
"""Like a hex int, but must be serialized as bytes in LLSD due to being a U32"""
@classmethod
def from_llsd(cls, val: Any) -> int:
return struct.unpack("!I", val)[0]
def from_llsd(cls, val: Any, flavor: str) -> int:
if flavor == "legacy":
return struct.unpack("!I", val)[0]
return val
@classmethod
def to_llsd(cls, val: int) -> Any:
return struct.pack("!I", val)
def to_llsd(cls, val: int, flavor: str) -> Any:
if flavor == "legacy":
return struct.pack("!I", val)
return val
class SchemaEnumField(SchemaStr, Generic[_T]):
def __init__(self, enum_cls: Type[LookupIntEnum]):
super().__init__()
self._enum_cls = enum_cls
def deserialize(self, val: str) -> _T:
return self._enum_cls.from_lookup_name(val)
def serialize(self, val: _T) -> str:
return self._enum_cls(val).to_lookup_name()
def from_llsd(self, val: Union[str, int], flavor: str) -> _T:
if flavor == "legacy":
return self.deserialize(val)
return self._enum_cls(val)
def to_llsd(self, val: _T, flavor: str) -> Union[int, str]:
if flavor == "legacy":
return self.serialize(val)
return int(val)
def _yield_schema_tokens(reader: StringIO):
@@ -99,10 +135,14 @@ class InventoryBase(SchemaBase):
if not spec:
LOG.warning(f"Internal key {key!r}")
continue
spec_cls = spec
if not inspect.isclass(spec_cls):
spec_cls = spec_cls.__class__
# some kind of nested structure like sale_info
if issubclass(spec, SchemaBase):
if issubclass(spec_cls, SchemaBase):
obj_dict[key] = spec.from_reader(reader)
elif issubclass(spec, SchemaFieldSerializer):
elif issubclass(spec_cls, SchemaFieldSerializer):
obj_dict[key] = spec.deserialize(val)
else:
raise ValueError(f"Unsupported spec for {key!r}, {spec!r}")
@@ -111,9 +151,21 @@ class InventoryBase(SchemaBase):
return cls._obj_from_dict(obj_dict)
def to_writer(self, writer: StringIO):
writer.write(f"\t{self.SCHEMA_NAME}\t0\n")
writer.write(f"\t{self.SCHEMA_NAME}")
if self.SCHEMA_NAME == "permissions":
writer.write(" 0\n")
else:
writer.write("\t0\n")
writer.write("\t{\n")
for field_name, field in self._get_fields_dict().items():
# Make sure the ID field always comes first, if there is one.
fields_dict = {}
if hasattr(self, "ID_ATTR"):
fields_dict = {getattr(self, "ID_ATTR"): None}
# update()ing will put all fields that aren't yet in the dict after the ID attr.
fields_dict.update(self._get_fields_dict())
for field_name, field in fields_dict.items():
spec = field.metadata.get("spec")
# Not meant to be serialized
if not spec:
@@ -122,13 +174,16 @@ class InventoryBase(SchemaBase):
continue
val = getattr(self, field_name)
if val is None:
if val is None and not field.metadata.get("include_none"):
continue
spec_cls = spec
if not inspect.isclass(spec_cls):
spec_cls = spec_cls.__class__
# Some kind of nested structure like sale_info
if isinstance(val, SchemaBase):
val.to_writer(writer)
elif issubclass(spec, SchemaFieldSerializer):
elif issubclass(spec_cls, SchemaFieldSerializer):
writer.write(f"\t\t{field_name}\t{spec.serialize(val)}\n")
else:
raise ValueError(f"Bad inventory spec {spec!r}")
@@ -166,12 +221,12 @@ class InventoryModel(InventoryBase):
return model
@classmethod
def from_llsd(cls, llsd_val: List[Dict]) -> InventoryModel:
def from_llsd(cls, llsd_val: List[Dict], flavor: str = "legacy") -> InventoryModel:
model = cls()
for obj_dict in llsd_val:
for inv_type in INVENTORY_TYPES:
if inv_type.ID_ATTR in obj_dict:
if (obj := inv_type.from_llsd(obj_dict)) is not None:
if (obj := inv_type.from_llsd(obj_dict, flavor)) is not None:
model.add(obj)
break
LOG.warning(f"Unknown object type {obj_dict!r}")
@@ -203,8 +258,8 @@ class InventoryModel(InventoryBase):
for node in self.ordered_nodes:
node.to_writer(writer)
def to_llsd(self):
return list(node.to_llsd() for node in self.ordered_nodes)
def to_llsd(self, flavor: str = "legacy"):
return list(node.to_llsd(flavor) for node in self.ordered_nodes)
def add(self, node: InventoryNodeBase):
if node.node_id in self.nodes:
@@ -280,24 +335,31 @@ class InventoryPermissions(InventoryBase):
group_id: UUID = schema_field(SchemaUUID)
# Nothing actually cares about this, but it could be there.
# It's kind of redundant since it just means owner_id == NULL_KEY && group_id != NULL_KEY.
is_owner_group: int = schema_field(SchemaInt, default=0, llsd_only=True)
is_owner_group: Optional[int] = schema_field(SchemaInt, default=None, llsd_only=True)
@dataclasses.dataclass
class InventorySaleInfo(InventoryBase):
SCHEMA_NAME: ClassVar[str] = "sale_info"
sale_type: str = schema_field(SchemaStr)
sale_type: SaleType = schema_field(SchemaEnumField(SaleType))
sale_price: int = schema_field(SchemaInt)
@dataclasses.dataclass
class InventoryNodeBase(InventoryBase):
ID_ATTR: ClassVar[str]
class _HasName(abc.ABC):
"""
Only exists so that we can assert that all subclasses should have this without forcing
a particular serialization order, as would happen if this was present on InventoryNodeBase.
"""
name: str
@dataclasses.dataclass
class InventoryNodeBase(InventoryBase, _HasName):
ID_ATTR: ClassVar[str]
parent_id: Optional[UUID] = schema_field(SchemaUUID)
model: Optional[InventoryModel] = dataclasses.field(
default=None, init=False, hash=False, compare=False, repr=False
)
@@ -338,8 +400,8 @@ class InventoryNodeBase(InventoryBase):
@dataclasses.dataclass
class InventoryContainerBase(InventoryNodeBase):
type: str = schema_field(SchemaStr)
name: str = schema_field(SchemaMultilineStr)
# TODO: Not a string in AIS
type: AssetType = schema_field(SchemaEnumField(AssetType))
@property
def children(self) -> Sequence[InventoryNodeBase]:
@@ -368,8 +430,8 @@ class InventoryContainerBase(InventoryNodeBase):
name=name,
cat_id=UUID.random(),
parent_id=self.node_id,
type="category",
pref_type="-1",
type=AssetType.CATEGORY,
pref_type=FolderType.NONE,
owner_id=getattr(self, 'owner_id', UUID.ZERO),
version=1,
)
@@ -386,7 +448,8 @@ class InventoryObject(InventoryContainerBase):
ID_ATTR: ClassVar[str] = "obj_id"
obj_id: UUID = schema_field(SchemaUUID)
metadata: Optional[Dict[str, Any]] = schema_field(SchemaLLSD, default=None)
name: str = schema_field(SchemaMultilineStr)
metadata: Optional[Dict[str, Any]] = schema_field(SchemaLLSD, default=None, include_none=True)
__hash__ = InventoryNodeBase.__hash__
@@ -398,10 +461,32 @@ class InventoryCategory(InventoryContainerBase):
VERSION_NONE: ClassVar[int] = -1
cat_id: UUID = schema_field(SchemaUUID)
pref_type: str = schema_field(SchemaStr, llsd_name="preferred_type")
owner_id: UUID = schema_field(SchemaUUID)
version: int = schema_field(SchemaInt)
metadata: Optional[Dict[str, Any]] = schema_field(SchemaLLSD, default=None)
# TODO: not a string in AIS
pref_type: FolderType = schema_field(SchemaEnumField(FolderType), llsd_name="preferred_type")
name: str = schema_field(SchemaMultilineStr)
owner_id: Optional[UUID] = schema_field(SchemaUUID, default=None)
version: int = schema_field(SchemaInt, default=VERSION_NONE, llsd_only=True)
metadata: Optional[Dict[str, Any]] = schema_field(SchemaLLSD, default=None, include_none=False)
def to_folder_data(self) -> Block:
return Block(
"FolderData",
FolderID=self.cat_id,
ParentID=self.parent_id,
CallbackID=0,
Type=self.pref_type,
Name=self.name,
)
@classmethod
def from_folder_data(cls, block: Block):
return cls(
cat_id=block["FolderID"],
parent_id=block["ParentID"],
pref_type=block["Type"],
name=block["Name"],
type=AssetType.CATEGORY,
)
__hash__ = InventoryNodeBase.__hash__
@@ -412,17 +497,17 @@ class InventoryItem(InventoryNodeBase):
ID_ATTR: ClassVar[str] = "item_id"
item_id: UUID = schema_field(SchemaUUID)
type: str = schema_field(SchemaStr)
inv_type: str = schema_field(SchemaStr)
flags: int = schema_field(SchemaFlagField)
name: str = schema_field(SchemaMultilineStr)
desc: str = schema_field(SchemaMultilineStr)
creation_date: dt.datetime = schema_field(SchemaDate, llsd_name="created_at")
permissions: InventoryPermissions = schema_field(InventoryPermissions)
sale_info: InventorySaleInfo = schema_field(InventorySaleInfo)
asset_id: Optional[UUID] = schema_field(SchemaUUID, default=None)
shadow_id: Optional[UUID] = schema_field(SchemaUUID, default=None)
metadata: Optional[Dict[str, Any]] = schema_field(SchemaLLSD, default=None)
type: Optional[AssetType] = schema_field(SchemaEnumField(AssetType), default=None)
inv_type: Optional[InventoryType] = schema_field(SchemaEnumField(InventoryType), default=None)
flags: Optional[int] = schema_field(SchemaFlagField, default=None)
sale_info: Optional[InventorySaleInfo] = schema_field(InventorySaleInfo, default=None)
name: Optional[str] = schema_field(SchemaMultilineStr, default=None)
desc: Optional[str] = schema_field(SchemaMultilineStr, default=None)
metadata: Optional[Dict[str, Any]] = schema_field(SchemaLLSD, default=None, include_none=True)
creation_date: Optional[dt.datetime] = schema_field(SchemaDate, llsd_name="created_at", default=None)
__hash__ = InventoryNodeBase.__hash__
@@ -432,5 +517,61 @@ class InventoryItem(InventoryNodeBase):
return self.asset_id
return self.shadow_id ^ MAGIC_ID
def to_inventory_data(self) -> Block:
return Block(
"InventoryData",
ItemID=self.item_id,
FolderID=self.parent_id,
CallbackID=0,
CreatorID=self.permissions.creator_id,
OwnerID=self.permissions.owner_id,
GroupID=self.permissions.group_id,
BaseMask=self.permissions.base_mask,
OwnerMask=self.permissions.owner_mask,
GroupMask=self.permissions.group_mask,
EveryoneMask=self.permissions.everyone_mask,
NextOwnerMask=self.permissions.next_owner_mask,
GroupOwned=self.permissions.owner_id == UUID.ZERO and self.permissions.group_id != UUID.ZERO,
AssetID=self.true_asset_id,
Type=self.type,
InvType=self.inv_type,
Flags=self.flags,
SaleType=self.sale_info.sale_type,
SalePrice=self.sale_info.sale_price,
Name=self.name,
Description=self.desc,
CreationDate=SchemaDate.to_llsd(self.creation_date, "legacy"),
# Meaningless here
CRC=secrets.randbits(32),
)
@classmethod
def from_inventory_data(cls, block: Block):
return cls(
item_id=block["ItemID"],
parent_id=block["ParentID"],
permissions=InventoryPermissions(
creator_id=block["CreatorID"],
owner_id=block["OwnerID"],
group_id=block["GroupID"],
base_mask=block["BaseMask"],
owner_mask=block["OwnerMask"],
group_mask=block["GroupMask"],
everyone_mask=block["EveryoneMask"],
next_owner_mask=block["NextOwnerMask"],
),
asset_id=block["AssetID"],
type=AssetType(block["Type"]),
inv_type=InventoryType(block["InvType"]),
flags=block["Flags"],
sale_info=InventorySaleInfo(
sale_type=SaleType(block["SaleType"]),
sale_price=block["SalePrice"],
),
name=block["Name"],
desc=block["Description"],
creation_date=block["CreationDate"],
)
INVENTORY_TYPES: Tuple[Type[InventoryNodeBase], ...] = (InventoryCategory, InventoryObject, InventoryItem)

View File

@@ -9,6 +9,7 @@ import abc
import calendar
import dataclasses
import datetime as dt
import inspect
import logging
import re
from io import StringIO
@@ -34,11 +35,11 @@ class SchemaFieldSerializer(abc.ABC, Generic[_T]):
pass
@classmethod
def from_llsd(cls, val: Any) -> _T:
def from_llsd(cls, val: Any, flavor: str) -> _T:
return val
@classmethod
def to_llsd(cls, val: _T) -> Any:
def to_llsd(cls, val: _T, flavor: str) -> Any:
return val
@@ -52,11 +53,11 @@ class SchemaDate(SchemaFieldSerializer[dt.datetime]):
return str(calendar.timegm(val.utctimetuple()))
@classmethod
def from_llsd(cls, val: Any) -> dt.datetime:
def from_llsd(cls, val: Any, flavor: str) -> dt.datetime:
return dt.datetime.utcfromtimestamp(val)
@classmethod
def to_llsd(cls, val: dt.datetime):
def to_llsd(cls, val: dt.datetime, flavor: str):
return calendar.timegm(val.utctimetuple())
@@ -116,19 +117,24 @@ class SchemaLLSD(SchemaFieldSerializer[_T]):
"""Arbitrary LLSD embedded in a field"""
@classmethod
def deserialize(cls, val: str) -> _T:
return llsd.parse_notation(val.encode("utf8"))
return llsd.parse_xml(val.partition("|")[0].encode("utf8"))
@classmethod
def serialize(cls, val: _T) -> str:
return llsd.format_notation(val).decode("utf8")
# Don't include the XML header
return llsd.format_xml(val).split(b">", 1)[1].decode("utf8") + "\n|"
def schema_field(spec: Type[Union[SchemaBase, SchemaFieldSerializer]], *, default=dataclasses.MISSING, init=True,
repr=True, hash=None, compare=True, llsd_name=None, llsd_only=False) -> dataclasses.Field: # noqa
_SCHEMA_SPEC = Union[Type[Union["SchemaBase", SchemaFieldSerializer]], SchemaFieldSerializer]
def schema_field(spec: _SCHEMA_SPEC, *, default=dataclasses.MISSING, init=True,
repr=True, hash=None, compare=True, llsd_name=None, llsd_only=False,
include_none=False) -> dataclasses.Field: # noqa
"""Describe a field in the inventory schema and the shape of its value"""
return dataclasses.field( # noqa
metadata={"spec": spec, "llsd_name": llsd_name, "llsd_only": llsd_only}, default=default,
init=init, repr=repr, hash=hash, compare=compare,
metadata={"spec": spec, "llsd_name": llsd_name, "llsd_only": llsd_only, "include_none": include_none},
default=default, init=init, repr=repr, hash=hash, compare=compare,
)
@@ -174,7 +180,7 @@ class SchemaBase(abc.ABC):
return cls.from_str(data.decode("utf8"))
@classmethod
def from_llsd(cls, inv_dict: Dict):
def from_llsd(cls, inv_dict: Dict, flavor: str = "legacy"):
fields = cls._get_fields_dict(llsd=True)
obj_dict = {}
for key, val in inv_dict.items():
@@ -186,11 +192,16 @@ class SchemaBase(abc.ABC):
if not spec:
LOG.warning(f"Internal key {key!r}")
continue
spec_cls = spec
if not inspect.isclass(spec_cls):
spec_cls = spec_cls.__class__
# some kind of nested structure like sale_info
if issubclass(spec, SchemaBase):
obj_dict[key] = spec.from_llsd(val)
elif issubclass(spec, SchemaFieldSerializer):
obj_dict[key] = spec.from_llsd(val)
if issubclass(spec_cls, SchemaBase):
obj_dict[key] = spec.from_llsd(val, flavor)
elif issubclass(spec_cls, SchemaFieldSerializer):
obj_dict[key] = spec.from_llsd(val, flavor)
else:
raise ValueError(f"Unsupported spec for {key!r}, {spec!r}")
else:
@@ -206,7 +217,7 @@ class SchemaBase(abc.ABC):
writer.seek(0)
return writer.read()
def to_llsd(self):
def to_llsd(self, flavor: str = "legacy"):
obj_dict = {}
for field_name, field in self._get_fields_dict(llsd=True).items():
spec = field.metadata.get("spec")
@@ -218,11 +229,15 @@ class SchemaBase(abc.ABC):
if val is None:
continue
spec_cls = spec
if not inspect.isclass(spec_cls):
spec_cls = spec_cls.__class__
# Some kind of nested structure like sale_info
if isinstance(val, SchemaBase):
val = val.to_llsd()
elif issubclass(spec, SchemaFieldSerializer):
val = spec.to_llsd(val)
val = val.to_llsd(flavor)
elif issubclass(spec_cls, SchemaFieldSerializer):
val = spec.to_llsd(val, flavor)
else:
raise ValueError(f"Bad inventory spec {spec!r}")
obj_dict[field_name] = val

View File

@@ -1580,8 +1580,15 @@ def bitfield_field(bits: int, *, adapter: Optional[Adapter] = None, default=0, i
class BitfieldDataclass(DataclassAdapter):
def __init__(self, data_cls: Type,
PRIM_SPEC: ClassVar[Optional[SerializablePrimitive]] = None
def __init__(self, data_cls: Optional[Type] = None,
prim_spec: Optional[SerializablePrimitive] = None, shift: bool = True):
if not dataclasses.is_dataclass(data_cls):
raise ValueError(f"{data_cls!r} is not a dataclass")
if prim_spec is None:
prim_spec = getattr(data_cls, 'PRIM_SPEC', None)
super().__init__(data_cls, prim_spec)
self._shift = shift
self._bitfield_spec = self._build_bitfield(data_cls)

View File

@@ -15,9 +15,38 @@ from typing import *
import hippolyzer.lib.base.serialization as se
from hippolyzer.lib.base import llsd
from hippolyzer.lib.base.datatypes import UUID, IntEnum, IntFlag, Vector3, Quaternion
from hippolyzer.lib.base.helpers import BiDiDict
from hippolyzer.lib.base.namevalue import NameValuesSerializer
class LookupIntEnum(IntEnum):
"""
Used for enums that have legacy string names, may be used in the legacy schema
Generally this is the string returned by `LLWhateverType::lookup()` in indra
"""
@abc.abstractmethod
def to_lookup_name(self) -> str:
raise NotImplementedError()
@classmethod
def from_lookup_name(cls, legacy_name: str):
raise NotImplementedError()
_ASSET_TYPE_BIDI: BiDiDict[str] = BiDiDict({
"animation": "animatn",
"callingcard": "callcard",
"texture_tga": "txtr_tga",
"image_tga": "img_tga",
"sound_wav": "snd_wav",
"lsl_text": "lsltext",
"lsl_bytecode": "lslbyte",
"folder_link": "link_f",
"unknown": "invalid",
})
@se.enum_field_serializer("RequestXfer", "XferID", "VFileType")
@se.enum_field_serializer("AssetUploadRequest", "AssetBlock", "Type")
@se.enum_field_serializer("AssetUploadComplete", "AssetBlock", "Type")
@@ -26,7 +55,7 @@ from hippolyzer.lib.base.namevalue import NameValuesSerializer
@se.enum_field_serializer("RezObject", "InventoryData", "Type")
@se.enum_field_serializer("RezScript", "InventoryBlock", "Type")
@se.enum_field_serializer("UpdateTaskInventory", "InventoryData", "Type")
class AssetType(IntEnum):
class AssetType(LookupIntEnum):
TEXTURE = 0
SOUND = 1
CALLINGCARD = 2
@@ -47,7 +76,7 @@ class AssetType(IntEnum):
GESTURE = 21
SIMSTATE = 22
LINK = 24
LINK_FOLDER = 25
FOLDER_LINK = 25
MARKETPLACE_FOLDER = 26
WIDGET = 40
PERSON = 45
@@ -62,16 +91,14 @@ class AssetType(IntEnum):
UNKNOWN = 255
NONE = -1
@property
def human_name(self):
def to_lookup_name(self) -> str:
lower = self.name.lower()
return {
"animation": "animatn",
"callingcard": "callcard",
"texture_tga": "txtr_tga",
"image_tga": "img_tga",
"sound_wav": "snd_wav",
}.get(lower, lower)
return _ASSET_TYPE_BIDI.forward.get(lower, lower)
@classmethod
def from_lookup_name(cls, legacy_name: str):
reg_name = _ASSET_TYPE_BIDI.backward.get(legacy_name, legacy_name).upper()
return cls[reg_name]
@property
def inventory_type(self):
@@ -99,12 +126,19 @@ class AssetType(IntEnum):
}.get(self, AssetType.NONE)
_INV_TYPE_BIDI: BiDiDict[str] = BiDiDict({
"callingcard": "callcard",
"attachment": "attach",
"none": "-1",
})
@se.enum_field_serializer("UpdateCreateInventoryItem", "InventoryData", "InvType")
@se.enum_field_serializer("CreateInventoryItem", "InventoryBlock", "InvType")
@se.enum_field_serializer("RezObject", "InventoryData", "InvType")
@se.enum_field_serializer("RezScript", "InventoryBlock", "InvType")
@se.enum_field_serializer("UpdateTaskInventory", "InventoryData", "InvType")
class InventoryType(IntEnum):
class InventoryType(LookupIntEnum):
TEXTURE = 0
SOUND = 1
CALLINGCARD = 2
@@ -133,16 +167,37 @@ class InventoryType(IntEnum):
UNKNOWN = 255
NONE = -1
@property
def human_name(self):
def to_lookup_name(self) -> str:
lower = self.name.lower()
return {
"callingcard": "callcard",
"none": "-1",
}.get(lower, lower)
return _INV_TYPE_BIDI.forward.get(lower, lower)
@classmethod
def from_lookup_name(cls, legacy_name: str):
reg_name = _INV_TYPE_BIDI.backward.get(legacy_name, legacy_name).upper()
return cls[reg_name]
class FolderType(IntEnum):
_FOLDER_TYPE_BIDI: BiDiDict[str] = BiDiDict({
"callingcard": "callcard",
"lsl_text": "lsltext",
"animation": "animatn",
"snapshot_category": "snapshot",
"lost_and_found": "lstndfnd",
"ensemble_start": "ensemble",
"ensemble_end": "ensemble",
"current_outfit": "current",
"my_outfits": "my_otfts",
"basic_root": "basic_rt",
"marketplace_listings": "merchant",
"marketplace_stock": "stock",
"marketplace_version": "version",
"my_suitcase": "suitcase",
"root_inventory": "root_inv",
"none": "-1",
})
class FolderType(LookupIntEnum):
TEXTURE = 0
SOUND = 1
CALLINGCARD = 2
@@ -161,6 +216,7 @@ class FolderType(IntEnum):
ANIMATION = 20
GESTURE = 21
FAVORITE = 23
# The "ensemble" values aren't used, no idea what they were for.
ENSEMBLE_START = 26
ENSEMBLE_END = 45
# This range is reserved for special clothing folder types.
@@ -177,7 +233,7 @@ class FolderType(IntEnum):
# Note: We actually *never* create folders with that type. This is used for icon override only.
MARKETPLACE_VERSION = 55
SETTINGS = 56
# Firestorm folders, may not actually exist
# Firestorm folders, may not actually exist in legacy schema
FIRESTORM = 57
PHOENIX = 58
RLV = 59
@@ -185,6 +241,15 @@ class FolderType(IntEnum):
MY_SUITCASE = 100
NONE = -1
def to_lookup_name(self) -> str:
lower = self.name.lower()
return _FOLDER_TYPE_BIDI.forward.get(lower, lower)
@classmethod
def from_lookup_name(cls, legacy_name: str):
reg_name = _FOLDER_TYPE_BIDI.backward.get(legacy_name, legacy_name).upper()
return cls[reg_name]
@se.enum_field_serializer("AgentIsNowWearing", "WearableData", "WearableType")
@se.enum_field_serializer("AgentWearablesUpdate", "WearableData", "WearableType")
@@ -244,6 +309,9 @@ class Permissions(IntFlag):
RESERVED = 1 << 31
_SALE_TYPE_LEGACY_NAMES = ("not", "orig", "copy", "cntn")
@se.enum_field_serializer("ObjectSaleInfo", "ObjectData", "SaleType")
@se.enum_field_serializer("ObjectProperties", "ObjectData", "SaleType")
@se.enum_field_serializer("ObjectPropertiesFamily", "ObjectData", "SaleType")
@@ -252,12 +320,19 @@ class Permissions(IntFlag):
@se.enum_field_serializer("RezObject", "InventoryData", "SaleType")
@se.enum_field_serializer("UpdateTaskInventory", "InventoryData", "SaleType")
@se.enum_field_serializer("UpdateCreateInventoryItem", "InventoryData", "SaleType")
class SaleInfo(IntEnum):
class SaleType(LookupIntEnum):
NOT = 0
ORIGINAL = 1
COPY = 2
CONTENTS = 3
@classmethod
def from_lookup_name(cls, legacy_name: str):
return cls(_SALE_TYPE_LEGACY_NAMES.index(legacy_name))
def to_lookup_name(self) -> str:
return _SALE_TYPE_LEGACY_NAMES[int(self.value)]
@se.flag_field_serializer("ParcelInfoReply", "Data", "Flags")
class ParcelInfoFlags(IntFlag):

View File

@@ -30,12 +30,12 @@ class AssetUploader:
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,
"asset_type": asset_type.to_lookup_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,
"inventory_type": asset_type.inventory_type.to_lookup_name(),
"name": name,
"next_owner_mask": 581632,
}

View File

@@ -2,14 +2,13 @@ from __future__ import annotations
import gzip
import logging
import secrets
from pathlib import Path
from typing import Union, List, Tuple, Set
from hippolyzer.lib.base import llsd
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.inventory import InventoryModel, InventoryCategory, InventoryItem
from hippolyzer.lib.base.message.message import Block
from hippolyzer.lib.base.templates import AssetType, FolderType
from hippolyzer.lib.client.state import BaseClientSession
@@ -33,8 +32,8 @@ class InventoryManager:
# Don't use the version from the skeleton, this flags the inventory as needing
# completion from the inventory cache. This matches indra's behavior.
version=InventoryCategory.VERSION_NONE,
type="category",
pref_type=skel_cat.get("type_default", "-1"),
type=AssetType.CATEGORY,
pref_type=FolderType(skel_cat.get("type_default", FolderType.NONE)),
owner_id=self._session.agent_id,
))
@@ -112,81 +111,3 @@ class InventoryManager:
else:
LOG.warning(f"Unknown node type in inv cache: {node_llsd!r}")
return categories, items
# Thankfully we have 9 billion different ways to represent inventory data.
def ais_item_to_inventory_data(ais_item: dict) -> Block:
return Block(
"InventoryData",
ItemID=ais_item["item_id"],
FolderID=ais_item["parent_id"],
CallbackID=0,
CreatorID=ais_item["permissions"]["creator_id"],
OwnerID=ais_item["permissions"]["owner_id"],
GroupID=ais_item["permissions"]["group_id"],
BaseMask=ais_item["permissions"]["base_mask"],
OwnerMask=ais_item["permissions"]["owner_mask"],
GroupMask=ais_item["permissions"]["group_mask"],
EveryoneMask=ais_item["permissions"]["everyone_mask"],
NextOwnerMask=ais_item["permissions"]["next_owner_mask"],
GroupOwned=0,
AssetID=ais_item["asset_id"],
Type=ais_item["type"],
InvType=ais_item["inv_type"],
Flags=ais_item["flags"],
SaleType=ais_item["sale_info"]["sale_type"],
SalePrice=ais_item["sale_info"]["sale_price"],
Name=ais_item["name"],
Description=ais_item["desc"],
CreationDate=ais_item["created_at"],
# Meaningless here
CRC=secrets.randbits(32),
)
def inventory_data_to_ais_item(inventory_data: Block) -> dict:
return dict(
item_id=inventory_data["ItemID"],
parent_id=inventory_data["ParentID"],
permissions=dict(
creator_id=inventory_data["CreatorID"],
owner_id=inventory_data["OwnerID"],
group_id=inventory_data["GroupID"],
base_mask=inventory_data["BaseMask"],
owner_mask=inventory_data["OwnerMask"],
group_mask=inventory_data["GroupMask"],
everyone_mask=inventory_data["EveryoneMask"],
next_owner_mask=inventory_data["NextOwnerMask"],
),
asset_id=inventory_data["AssetID"],
type=inventory_data["Type"],
inv_type=inventory_data["InvType"],
flags=inventory_data["Flags"],
sale_info=dict(
sale_type=inventory_data["SaleType"],
sale_price=inventory_data["SalePrice"],
),
name=inventory_data["Name"],
description=inventory_data["Description"],
creation_at=inventory_data["CreationDate"],
)
def ais_folder_to_inventory_data(ais_folder: dict) -> Block:
return Block(
"FolderData",
FolderID=ais_folder["cat_id"],
ParentID=ais_folder["parent_id"],
CallbackID=0,
Type=ais_folder["preferred_type"],
Name=ais_folder["name"],
)
def inventory_data_to_ais_folder(inventory_data: Block) -> dict:
return dict(
cat_id=inventory_data["FolderID"],
parent_id=inventory_data["ParentID"],
preferred_type=inventory_data["Type"],
name=inventory_data["Name"],
)

View File

@@ -1,8 +1,8 @@
from hippolyzer.lib.base.datatypes import UUID
from hippolyzer.lib.base.inventory import InventoryItem
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.client.inventory_manager import ais_item_to_inventory_data
class ProxyAssetUploader(AssetUploader):
@@ -22,7 +22,7 @@ class ProxyAssetUploader(AssetUploader):
]
}
async with self._region.caps_client.post('FetchInventory2', llsd=ais_req_data) as resp:
ais_item = (await resp.read_llsd())["items"][0]
ais_item = InventoryItem.from_llsd((await resp.read_llsd())["items"][0], flavor="ais")
# Got it, ship it off to the viewer
message = Message(
@@ -33,7 +33,7 @@ class ProxyAssetUploader(AssetUploader):
SimApproved=1,
TransactionID=UUID.random(),
),
ais_item_to_inventory_data(ais_item),
ais_item.to_inventory_data(),
direction=Direction.IN
)
self._region.circuit.send(message)

View File

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

View File

@@ -2,7 +2,7 @@ import copy
import unittest
from hippolyzer.lib.base.datatypes import *
from hippolyzer.lib.base.inventory import InventoryModel
from hippolyzer.lib.base.inventory import InventoryModel, SaleType
from hippolyzer.lib.base.wearables import Wearable, VISUAL_PARAMS
SIMPLE_INV = """\tinv_object\t0
@@ -11,6 +11,8 @@ SIMPLE_INV = """\tinv_object\t0
\t\tparent_id\t00000000-0000-0000-0000-000000000000
\t\ttype\tcategory
\t\tname\tContents|
\t\tmetadata\t<llsd><undef /></llsd>
|
\t}
\tinv_item\t0
\t{
@@ -39,10 +41,22 @@ SIMPLE_INV = """\tinv_object\t0
\t}
\t\tname\tNew Script|
\t\tdesc\t2020-04-20 04:20:39 lsl2 script|
\t\tmetadata\t<llsd><map><key>experience</key><uuid>a2e76fcd-9360-4f6d-a924-000000000003</uuid></map></llsd>
|
\t\tcreation_date\t1587367239
\t}
"""
INV_CATEGORY = """\tinv_category\t0
\t{
\t\tcat_id\tf4d91477-def1-487a-b4f3-6fa201c17376
\t\tparent_id\t00000000-0000-0000-0000-000000000000
\t\ttype\tlsltext
\t\tpref_type\tlsltext
\t\tname\tScripts|
\t}
"""
class TestLegacyInv(unittest.TestCase):
def setUp(self) -> None:
@@ -52,15 +66,27 @@ class TestLegacyInv(unittest.TestCase):
self.assertTrue(UUID('f4d91477-def1-487a-b4f3-6fa201c17376') in self.model.nodes)
self.assertIsNotNone(self.model.root)
def test_parse_category(self):
model = InventoryModel.from_str(INV_CATEGORY)
self.assertEqual(UUID('f4d91477-def1-487a-b4f3-6fa201c17376'), model.root.node_id)
def test_serialize(self):
self.model = InventoryModel.from_str(SIMPLE_INV)
new_model = InventoryModel.from_str(self.model.to_str())
self.assertEqual(self.model, new_model)
def test_serialize_category(self):
model = InventoryModel.from_str(INV_CATEGORY)
new_model = InventoryModel.from_str(model.to_str())
self.assertEqual(model, new_model)
def test_category_legacy_serialization(self):
self.assertEqual(INV_CATEGORY, InventoryModel.from_str(INV_CATEGORY).to_str())
def test_item_access(self):
item = self.model.nodes[UUID('dd163122-946b-44df-99f6-a6030e2b9597')]
self.assertEqual(item.name, "New Script")
self.assertEqual(item.sale_info.sale_type, "not")
self.assertEqual(item.sale_info.sale_type, SaleType.NOT)
self.assertDictEqual(item.metadata, {"experience": UUID("a2e76fcd-9360-4f6d-a924-000000000003")})
self.assertEqual(item.model, self.model)
def test_access_children(self):
@@ -112,6 +138,7 @@ class TestLegacyInv(unittest.TestCase):
'inv_type': 'script',
'item_id': UUID('dd163122-946b-44df-99f6-a6030e2b9597'),
'name': 'New Script',
'metadata': {"experience": UUID("a2e76fcd-9360-4f6d-a924-000000000003")},
'parent_id': UUID('f4d91477-def1-487a-b4f3-6fa201c17376'),
'permissions': {
'base_mask': 2147483647,
@@ -123,7 +150,6 @@ class TestLegacyInv(unittest.TestCase):
'next_owner_mask': 581632,
'owner_id': UUID('a2e76fcd-9360-4f6d-a924-000000000003'),
'owner_mask': 2147483647,
'is_owner_group': 0,
},
'sale_info': {
'sale_price': 10,
@@ -140,6 +166,9 @@ class TestLegacyInv(unittest.TestCase):
new_model.root.name = "foo"
self.assertNotEqual(self.model, new_model)
def test_legacy_serialization(self):
self.assertEqual(SIMPLE_INV, self.model.to_str())
def test_difference_added(self):
new_model = InventoryModel.from_llsd(self.model.to_llsd())
diff = self.model.get_differences(new_model)