1057 lines
45 KiB
Python
1057 lines
45 KiB
Python
"""
|
|
Manager for a client's view of objects in the region and world.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import collections
|
|
import enum
|
|
import itertools
|
|
import logging
|
|
import math
|
|
import weakref
|
|
from typing import *
|
|
|
|
from hippolyzer.lib.base.datatypes import UUID, Vector3
|
|
from hippolyzer.lib.base.helpers import proxify
|
|
from hippolyzer.lib.base.inventory import InventoryItem, InventoryModel, InventoryObject
|
|
from hippolyzer.lib.base.message.message import Block, Message
|
|
from hippolyzer.lib.base.message.message_handler import MessageHandler
|
|
from hippolyzer.lib.base.message.msgtypes import PacketFlags
|
|
from hippolyzer.lib.base.objects import (
|
|
normalize_object_update,
|
|
normalize_terse_object_update,
|
|
normalize_object_update_compressed_data,
|
|
normalize_object_update_compressed,
|
|
Object, handle_to_global_pos,
|
|
)
|
|
from hippolyzer.lib.base.settings import Settings
|
|
from hippolyzer.lib.base.wearables import VISUAL_PARAMS
|
|
from hippolyzer.lib.client.namecache import NameCache, NameCacheEntry
|
|
from hippolyzer.lib.base.templates import PCode, ObjectStateSerializer, XferFilePath
|
|
from hippolyzer.lib.base import llsd
|
|
|
|
if TYPE_CHECKING:
|
|
from hippolyzer.lib.client.state import BaseClientRegion, BaseClientSession
|
|
|
|
|
|
LOG = logging.getLogger(__name__)
|
|
OBJECT_OR_LOCAL = Union[Object, int]
|
|
MATERIAL_MAP_TYPE = Dict[UUID, dict]
|
|
|
|
|
|
class ObjectUpdateType(enum.IntEnum):
|
|
UPDATE = enum.auto()
|
|
PROPERTIES = enum.auto()
|
|
FAMILY = enum.auto()
|
|
COSTS = enum.auto()
|
|
KILL = enum.auto()
|
|
ANIMATIONS = enum.auto()
|
|
APPEARANCE = enum.auto()
|
|
|
|
|
|
class ClientObjectManager:
|
|
"""
|
|
Object manager for a specific region
|
|
"""
|
|
|
|
__slots__ = ("_region", "_world_objects", "state", "__weakref__", "_requesting_all_mats_lock")
|
|
|
|
def __init__(self, region: BaseClientRegion):
|
|
self._region: BaseClientRegion = proxify(region)
|
|
self._world_objects: ClientWorldObjectManager = proxify(region.session().objects)
|
|
self.state: RegionObjectsState = RegionObjectsState()
|
|
self._requesting_all_mats_lock = asyncio.Lock()
|
|
|
|
def __len__(self):
|
|
return len(self.state.localid_lookup)
|
|
|
|
@property
|
|
def all_objects(self) -> Iterable[Object]:
|
|
return self.state.localid_lookup.values()
|
|
|
|
@property
|
|
def missing_locals(self) -> Set[int]:
|
|
return self.state.missing_locals
|
|
|
|
def clear(self):
|
|
self.state.clear()
|
|
if self._region.handle is not None:
|
|
# We're tracked by the world object manager, tell it to untrack
|
|
# any objects that we owned
|
|
self._world_objects.untrack_region_objects(self._region.handle)
|
|
|
|
def lookup_localid(self, localid: int) -> Optional[Object]:
|
|
return self.state.lookup_localid(localid)
|
|
|
|
def lookup_fullid(self, fullid: UUID) -> Optional[Object]:
|
|
obj = self._world_objects.lookup_fullid(fullid)
|
|
if obj is None or obj.RegionHandle != self._region.handle:
|
|
return None
|
|
return obj
|
|
|
|
@property
|
|
def all_avatars(self) -> Iterable[Avatar]:
|
|
return tuple(a for a in self._world_objects.all_avatars
|
|
if a.RegionHandle == self._region.handle)
|
|
|
|
def lookup_avatar(self, fullid: UUID) -> Optional[Avatar]:
|
|
for avatar in self.all_avatars:
|
|
if avatar.FullID == fullid:
|
|
return avatar
|
|
return None
|
|
|
|
# noinspection PyUnusedLocal
|
|
def _is_localid_selected(self, local_id: int):
|
|
return False
|
|
|
|
def request_object_properties(self, objects: Union[OBJECT_OR_LOCAL, Sequence[OBJECT_OR_LOCAL]]) \
|
|
-> List[asyncio.Future[Object]]:
|
|
if isinstance(objects, (Object, int)):
|
|
objects = (objects,)
|
|
if not objects:
|
|
return []
|
|
|
|
local_ids = tuple((o.LocalID if isinstance(o, Object) else o) for o in objects)
|
|
|
|
# Don't mess with already selected objects
|
|
unselected_ids = tuple(local for local in local_ids if not self._is_localid_selected(local))
|
|
ids_to_req = unselected_ids
|
|
|
|
session = self._region.session()
|
|
while ids_to_req:
|
|
blocks = [
|
|
Block("AgentData", AgentID=session.agent_id, SessionID=session.id),
|
|
*[Block("ObjectData", ObjectLocalID=x) for x in ids_to_req[:255]],
|
|
]
|
|
# Selecting causes ObjectProperties to be sent
|
|
self._region.circuit.send(Message("ObjectSelect", blocks, flags=PacketFlags.RELIABLE))
|
|
self._region.circuit.send(Message("ObjectDeselect", blocks, flags=PacketFlags.RELIABLE))
|
|
ids_to_req = ids_to_req[255:]
|
|
|
|
futures = []
|
|
for local_id in local_ids:
|
|
if local_id in unselected_ids:
|
|
# Need to wait until we get our reply
|
|
fut = self.state.register_future(local_id, ObjectUpdateType.PROPERTIES)
|
|
else:
|
|
# This was selected so we should already have up-to-date info
|
|
fut = asyncio.Future()
|
|
fut.set_result(self.lookup_localid(local_id))
|
|
futures.append(fut)
|
|
return futures
|
|
|
|
def request_missing_objects(self) -> List[asyncio.Future[Object]]:
|
|
return self.request_objects(self.state.missing_locals)
|
|
|
|
def request_objects(self, local_ids: Union[int, Iterable[int]]) -> List[asyncio.Future[Object]]:
|
|
"""
|
|
Request object local IDs, returning a list of awaitable handles for the objects
|
|
|
|
Some may never be resolved, so use `asyncio.wait()` or `asyncio.wait_for()`.
|
|
"""
|
|
if isinstance(local_ids, int):
|
|
local_ids = (local_ids,)
|
|
elif isinstance(local_ids, set):
|
|
local_ids = tuple(local_ids)
|
|
|
|
session = self._region.session()
|
|
|
|
ids_to_req = local_ids
|
|
while ids_to_req:
|
|
self._region.circuit.send(Message(
|
|
"RequestMultipleObjects",
|
|
Block("AgentData", AgentID=session.agent_id, SessionID=session.id),
|
|
*[Block("ObjectData", CacheMissType=0, ID=x) for x in ids_to_req[:255]],
|
|
flags=PacketFlags.RELIABLE,
|
|
))
|
|
ids_to_req = ids_to_req[255:]
|
|
|
|
futures = []
|
|
for local_id in local_ids:
|
|
futures.append(self.state.register_future(local_id, ObjectUpdateType.UPDATE))
|
|
return futures
|
|
|
|
async def request_all_materials(self) -> MATERIAL_MAP_TYPE:
|
|
"""
|
|
Request all materials within the sim
|
|
|
|
Sigh, yes, this is best practice per indra :(
|
|
"""
|
|
if self._requesting_all_mats_lock.locked():
|
|
# We're already requesting all materials, wait until the lock is free
|
|
# and just return what was returned.
|
|
async with self._requesting_all_mats_lock:
|
|
return self.state.materials
|
|
|
|
async with self._requesting_all_mats_lock:
|
|
async with self._region.caps_client.get("RenderMaterials") as resp:
|
|
resp.raise_for_status()
|
|
# Clear out all previous materials, this is a complete response.
|
|
self.state.materials.clear()
|
|
self._process_materials_response(await resp.read())
|
|
return self.state.materials
|
|
|
|
async def request_materials(self, material_ids: Sequence[UUID]) -> MATERIAL_MAP_TYPE:
|
|
if self._requesting_all_mats_lock.locked():
|
|
# Just wait for the in-flight request for all materials to complete
|
|
# if we have one in flight.
|
|
async with self._requesting_all_mats_lock:
|
|
# Wait for the lock to be released
|
|
pass
|
|
|
|
not_found = set(x for x in material_ids if (x not in self.state.materials))
|
|
if not_found:
|
|
# Request any materials we don't already have, if there were any
|
|
data = {"Zipped": llsd.zip_llsd([x.bytes for x in material_ids])}
|
|
async with self._region.caps_client.post("RenderMaterials", data=data) as resp:
|
|
resp.raise_for_status()
|
|
self._process_materials_response(await resp.read())
|
|
|
|
# build up a dict of just the requested mats
|
|
mats = {}
|
|
for mat_id in material_ids:
|
|
mats[mat_id] = self.state.materials[mat_id]
|
|
return mats
|
|
|
|
def _process_materials_response(self, response: bytes):
|
|
entries = llsd.unzip_llsd(llsd.parse_xml(response)["Zipped"])
|
|
for entry in entries:
|
|
self.state.materials[UUID(bytes=entry["ID"])] = entry["Material"]
|
|
|
|
async def request_object_inv(self, obj: Object) -> List[InventoryItem]:
|
|
if "RequestTaskInventory" in self._region.cap_urls:
|
|
return await self.request_object_inv_via_cap(obj)
|
|
else:
|
|
return await self.request_object_inv_via_xfer(obj)
|
|
|
|
async def request_object_inv_via_cap(self, obj: Object) -> List[InventoryItem]:
|
|
async with self._region.caps_client.get("RequestTaskInventory", params={"task_id": obj.FullID}) as resp:
|
|
resp.raise_for_status()
|
|
all_items = [InventoryItem.from_llsd(x) for x in (await resp.read_llsd())["contents"]]
|
|
# Synthesize the Contents directory so the items can have a parent
|
|
parent = InventoryObject(
|
|
obj_id=obj.FullID,
|
|
name="Contents",
|
|
)
|
|
model = InventoryModel()
|
|
model.add(parent)
|
|
for item in all_items:
|
|
model.add(item)
|
|
|
|
return all_items
|
|
|
|
async def request_object_inv_via_xfer(self, obj: Object) -> List[InventoryItem]:
|
|
session = self._region.session()
|
|
with self._region.message_handler.subscribe_async(
|
|
('ReplyTaskInventory',), predicate=lambda x: x["InventoryData"]["TaskID"] == obj.FullID
|
|
) as get_msg:
|
|
await self._region.circuit.send_reliable(Message(
|
|
'RequestTaskInventory',
|
|
# If no session is passed in we'll use the active session when the coro was created
|
|
Block('AgentData', AgentID=session.agent_id, SessionID=session.id),
|
|
Block('InventoryData', LocalID=obj.LocalID),
|
|
))
|
|
|
|
inv_message = await asyncio.wait_for(get_msg(), timeout=5.0)
|
|
|
|
# Xfer doesn't need to be immediately awaited, multiple signals can be waited on.
|
|
xfer = await self._region.xfer_manager.request(
|
|
file_name=inv_message["InventoryData"]["Filename"], file_path=XferFilePath.CACHE)
|
|
|
|
inv_model = InventoryModel.from_bytes(xfer.reassemble_chunks())
|
|
return list(inv_model.all_items)
|
|
|
|
|
|
class ObjectEvent:
|
|
__slots__ = ("object", "updated", "update_type")
|
|
|
|
object: Object
|
|
updated: Set[str]
|
|
update_type: ObjectUpdateType
|
|
|
|
def __init__(self, obj: Object, updated: Set[str], update_type: ObjectUpdateType):
|
|
self.object = obj
|
|
self.updated = updated
|
|
self.update_type = update_type
|
|
|
|
@property
|
|
def name(self) -> ObjectUpdateType:
|
|
return self.update_type
|
|
|
|
|
|
class ClientWorldObjectManager:
|
|
"""Manages Objects for a session's whole world"""
|
|
def __init__(self, session: BaseClientSession, settings: Settings, name_cache: Optional[NameCache]):
|
|
self._session: BaseClientSession = session
|
|
self._settings = settings
|
|
self.name_cache = name_cache or NameCache()
|
|
self.events: MessageHandler[ObjectEvent, ObjectUpdateType] = MessageHandler(take_by_default=False)
|
|
self._fullid_lookup: Dict[UUID, Object] = {}
|
|
self._avatars: Dict[UUID, Avatar] = {}
|
|
self._avatar_objects: Dict[UUID, Object] = {}
|
|
self._region_managers: Dict[int, ClientObjectManager] = {}
|
|
message_handler = self._session.message_handler
|
|
message_handler.subscribe("ObjectUpdate", self._handle_object_update)
|
|
message_handler.subscribe("ImprovedTerseObjectUpdate",
|
|
self._handle_terse_object_update)
|
|
message_handler.subscribe("ObjectUpdateCompressed",
|
|
self._handle_object_update_compressed)
|
|
message_handler.subscribe("ObjectUpdateCached",
|
|
self._handle_object_update_cached)
|
|
message_handler.subscribe("CoarseLocationUpdate",
|
|
self._handle_coarse_location_update)
|
|
message_handler.subscribe("KillObject",
|
|
self._handle_kill_object)
|
|
message_handler.subscribe("ObjectProperties",
|
|
self._handle_object_properties_generic)
|
|
message_handler.subscribe("ObjectPropertiesFamily",
|
|
self._handle_object_properties_generic)
|
|
message_handler.subscribe("AvatarAnimation",
|
|
self._handle_animation_message)
|
|
message_handler.subscribe("ObjectAnimation",
|
|
self._handle_animation_message)
|
|
message_handler.subscribe("AvatarAppearance",
|
|
self._handle_avatar_appearance_message)
|
|
|
|
def lookup_fullid(self, full_id: UUID) -> Optional[Object]:
|
|
return self._fullid_lookup.get(full_id, None)
|
|
|
|
@property
|
|
def all_objects(self) -> Iterable[Object]:
|
|
return self._fullid_lookup.values()
|
|
|
|
def lookup_avatar(self, full_id: UUID) -> Optional[Avatar]:
|
|
return {a.FullID: a for a in self.all_avatars}.get(full_id, None)
|
|
|
|
@property
|
|
def all_avatars(self) -> Iterable[Avatar]:
|
|
return list(self._avatars.values())
|
|
|
|
def __len__(self):
|
|
return len(self._fullid_lookup)
|
|
|
|
def _get_region_state(self, handle: int) -> Optional[RegionObjectsState]:
|
|
val = self._get_region_manager(handle)
|
|
if val is None:
|
|
return None
|
|
return val.state
|
|
|
|
def track_region_objects(self, handle: int):
|
|
"""Start tracking objects for a region"""
|
|
if self._get_region_manager(handle) is None:
|
|
self._region_managers[handle] = proxify(self._session.region_by_handle(handle).objects)
|
|
|
|
def untrack_region_objects(self, handle: int):
|
|
"""Handle signal that a region object manager was just cleared"""
|
|
# Make sure they're gone from our lookup table
|
|
for obj in list(self._fullid_lookup.values()):
|
|
if obj.RegionHandle == handle:
|
|
del self._fullid_lookup[obj.FullID]
|
|
if handle in self._region_managers:
|
|
del self._region_managers[handle]
|
|
self._rebuild_avatar_objects()
|
|
|
|
def _get_region_manager(self, handle: int) -> Optional[ClientObjectManager]:
|
|
return self._region_managers.get(handle)
|
|
|
|
def request_missing_objects(self) -> List[asyncio.Future[Object]]:
|
|
futs = []
|
|
for region in self._session.regions:
|
|
futs.extend(region.objects.request_missing_objects())
|
|
return futs
|
|
|
|
def request_object_properties(self, objects: Union[Object, Sequence[Object]]) \
|
|
-> List[asyncio.Future[Object]]:
|
|
# Doesn't accept local ID unlike ObjectManager because they're ambiguous here.
|
|
if isinstance(objects, Object):
|
|
objects = (objects,)
|
|
if not objects:
|
|
return []
|
|
|
|
# Has to be sent to the region they belong to, so split the objects out by region handle.
|
|
objs_by_region = collections.defaultdict(list)
|
|
for obj in objects:
|
|
objs_by_region[obj.RegionHandle].append(obj)
|
|
|
|
futs = []
|
|
for region_handle, region_objs in objs_by_region.items():
|
|
region_mgr = self._get_region_manager(region_handle)
|
|
futs.extend(region_mgr.request_object_properties(region_objs))
|
|
return futs
|
|
|
|
async def request_object_inv(self, obj: Object) -> List[InventoryItem]:
|
|
region_mgr = self._get_region_manager(obj.RegionHandle)
|
|
return await region_mgr.request_object_inv(obj)
|
|
|
|
async def load_ancestors(self, obj: Object, wait_time: float = 1.0):
|
|
"""
|
|
Ensure that the entire chain of parents above this object is loaded
|
|
|
|
Use this to make sure the object you're dealing with isn't orphaned and
|
|
its RegionPosition can be determined.
|
|
"""
|
|
region_mgr = self._get_region_manager(obj.RegionHandle)
|
|
while obj.ParentID:
|
|
if obj.Parent is None:
|
|
await asyncio.wait_for(region_mgr.request_objects(obj.ParentID)[0], wait_time)
|
|
obj = obj.Parent
|
|
|
|
def clear(self):
|
|
for handle in tuple(self._region_managers.keys()):
|
|
self.untrack_region_objects(handle)
|
|
self._avatars.clear()
|
|
if self._fullid_lookup:
|
|
LOG.warning(f"Had {len(self._fullid_lookup)} objects not tied to a region manager!")
|
|
self._fullid_lookup.clear()
|
|
self._rebuild_avatar_objects()
|
|
self._region_managers.clear()
|
|
|
|
def _update_existing_object(self, obj: Object, new_properties: dict, update_type: ObjectUpdateType,
|
|
msg: Optional[Message]):
|
|
old_parent_id = obj.ParentID
|
|
new_parent_id = new_properties.get("ParentID", obj.ParentID)
|
|
old_local_id = obj.LocalID
|
|
new_local_id = new_properties.get("LocalID", obj.LocalID)
|
|
old_region_handle = obj.RegionHandle
|
|
new_region_handle = new_properties.get("RegionHandle", obj.RegionHandle)
|
|
old_region_state = self._get_region_state(old_region_handle)
|
|
new_region_state = self._get_region_state(new_region_handle)
|
|
|
|
actually_updated_props = set()
|
|
|
|
if old_region_handle != new_region_handle:
|
|
# The object just changed regions, we have to remove it from the old one.
|
|
# Our LocalID will most likely change because, well, our locale changed.
|
|
old_region_state.untrack_object(obj)
|
|
elif old_local_id != new_local_id:
|
|
# Our LocalID changed, and we deal with linkages to other prims by
|
|
# LocalID association. Break any links since our LocalID is changing.
|
|
# Could happen if we didn't mark an attachment prim dead and the parent agent
|
|
# came back into the sim. Attachment FullIDs do not change across TPs,
|
|
# LocalIDs do. This at least lets us partially recover from the bad state.
|
|
new_localid = new_properties["LocalID"]
|
|
LOG.warning(f"Got an update with new LocalID for {obj.FullID}, {obj.LocalID} != {new_localid}. "
|
|
f"May have mishandled a KillObject for a prim that left and re-entered region.")
|
|
old_region_state.untrack_object(obj)
|
|
obj.LocalID = new_localid
|
|
old_region_state.track_object(obj)
|
|
actually_updated_props |= {"LocalID"}
|
|
|
|
actually_updated_props |= obj.update_properties(new_properties)
|
|
|
|
if new_region_handle != old_region_handle:
|
|
# Region just changed to this region, we should have untracked it before
|
|
# so mark it tracked on this region. This should implicitly pick up any
|
|
# orphans and handle parent ID changes.
|
|
if new_region_state is not None:
|
|
new_region_state.track_object(obj)
|
|
else:
|
|
# This will leave a regionless object in the global lookup dict, same as indra.
|
|
LOG.warning(f"Tried to move object {obj!r} to unknown region {new_region_handle}")
|
|
|
|
if obj.PCode == PCode.AVATAR:
|
|
# `Avatar` instances are handled separately. Update all Avatar objects,
|
|
# so we can deal with the RegionHandle change.
|
|
self._rebuild_avatar_objects()
|
|
elif new_parent_id != old_parent_id:
|
|
# Parent ID changed, but we're in the same region
|
|
new_region_state.handle_object_reparented(obj, old_parent_id=old_parent_id)
|
|
|
|
if actually_updated_props and new_region_state is not None:
|
|
self._run_object_update_hooks(obj, actually_updated_props, update_type, msg)
|
|
|
|
def _track_new_object(self, region: RegionObjectsState, obj: Object, msg: Message):
|
|
region.track_object(obj)
|
|
self._fullid_lookup[obj.FullID] = obj
|
|
if obj.PCode == PCode.AVATAR:
|
|
self._avatar_objects[obj.FullID] = obj
|
|
self._rebuild_avatar_objects()
|
|
self._run_object_update_hooks(obj, set(obj.to_dict().keys()), ObjectUpdateType.UPDATE, msg)
|
|
|
|
def _kill_object_by_local_id(self, region_state: RegionObjectsState, local_id: int):
|
|
obj = region_state.lookup_localid(local_id)
|
|
region_state.missing_locals -= {local_id}
|
|
child_ids: Sequence[int]
|
|
|
|
if obj:
|
|
self._run_kill_object_hooks(obj)
|
|
child_ids = obj.ChildIDs
|
|
else:
|
|
LOG.debug(f"Tried to kill unknown object {local_id}")
|
|
# Kill any pending futures it might have had since untrack_object()
|
|
# won't be called.
|
|
region_state.cancel_futures(local_id)
|
|
# If it had any orphans, they need to die.
|
|
child_ids = region_state.collect_orphans(local_id)
|
|
|
|
# KillObject implicitly kills descendents
|
|
# This may mutate child_ids, use the reversed iterator so we don't
|
|
# invalidate the iterator during removal.
|
|
for child_id in reversed(child_ids):
|
|
# indra special-cases avatar PCodes and doesn't mark them dead
|
|
# due to cascading kill. Is this correct? Do avatars require
|
|
# explicit kill? Does this imply ParentID = 0 or do we need
|
|
# an explicit follow-up update?
|
|
child_obj = region_state.lookup_localid(child_id)
|
|
if child_obj and child_obj.PCode == PCode.AVATAR:
|
|
continue
|
|
self._kill_object_by_local_id(region_state, child_id)
|
|
|
|
# Have to do this last, since untracking will clear child IDs
|
|
if obj:
|
|
region_state.untrack_object(obj)
|
|
self._fullid_lookup.pop(obj.FullID, None)
|
|
if obj.PCode == PCode.AVATAR:
|
|
self._avatar_objects.pop(obj.FullID, None)
|
|
self._rebuild_avatar_objects()
|
|
|
|
def _handle_object_update(self, msg: Message):
|
|
seen_locals = []
|
|
handle = msg["RegionData"]["RegionHandle"]
|
|
region_state = self._get_region_state(handle)
|
|
for block in msg['ObjectData']:
|
|
object_data = normalize_object_update(block, handle)
|
|
seen_locals.append(object_data["LocalID"])
|
|
if region_state is None:
|
|
LOG.warning(f"Got ObjectUpdate for unknown region {handle}: {object_data!r}")
|
|
# Do a lookup by FullID, if an object with this FullID already exists anywhere in
|
|
# our view of the world then we want to move it to this region.
|
|
obj = self.lookup_fullid(object_data["FullID"])
|
|
if obj:
|
|
self._update_existing_object(obj, object_data, ObjectUpdateType.UPDATE, msg)
|
|
else:
|
|
if region_state is None:
|
|
continue
|
|
self._track_new_object(region_state, Object(**object_data), msg)
|
|
msg.meta["ObjectUpdateIDs"] = tuple(seen_locals)
|
|
|
|
def _handle_terse_object_update(self, msg: Message):
|
|
seen_locals = []
|
|
handle = msg["RegionData"]["RegionHandle"]
|
|
region_state = self._get_region_state(handle)
|
|
for block in msg['ObjectData']:
|
|
object_data = normalize_terse_object_update(block, handle)
|
|
|
|
if region_state is None:
|
|
LOG.warning(f"Got ImprovedTerseObjectUpdate for unknown region {handle}: {object_data!r}")
|
|
continue
|
|
|
|
obj = region_state.lookup_localid(object_data["LocalID"])
|
|
# Can only update existing object with this message
|
|
if obj:
|
|
# Need the Object as context because decoding state requires PCode.
|
|
state_deserializer = ObjectStateSerializer.deserialize
|
|
object_data["State"] = state_deserializer(ctx_obj=obj, val=object_data["State"])
|
|
self._update_existing_object(obj, object_data, ObjectUpdateType.UPDATE, msg)
|
|
else:
|
|
if region_state:
|
|
region_state.missing_locals.add(object_data["LocalID"])
|
|
LOG.debug(f"Received terse update for unknown object {object_data['LocalID']}")
|
|
seen_locals.append(object_data["LocalID"])
|
|
|
|
msg.meta["ObjectUpdateIDs"] = tuple(seen_locals)
|
|
|
|
def _handle_object_update_cached(self, msg: Message):
|
|
seen_locals = []
|
|
missing_locals = set()
|
|
handle = msg["RegionData"]["RegionHandle"]
|
|
region_state = self._get_region_state(handle)
|
|
for block in msg['ObjectData']:
|
|
seen_locals.append(block["ID"])
|
|
update_flags = block.deserialize_var("UpdateFlags", make_copy=False)
|
|
|
|
if region_state is None:
|
|
LOG.warning(f"Got ObjectUpdateCached for unknown region {handle}: {block!r}")
|
|
continue
|
|
|
|
# Check if we already know about the object
|
|
obj = region_state.lookup_localid(block["ID"])
|
|
if obj is not None and obj.CRC == block["CRC"]:
|
|
self._update_existing_object(obj, {
|
|
"UpdateFlags": update_flags,
|
|
"RegionHandle": handle,
|
|
}, ObjectUpdateType.UPDATE, msg)
|
|
continue
|
|
|
|
cached_obj_data = self._lookup_cache_entry(handle, block["ID"], block["CRC"])
|
|
if cached_obj_data is not None:
|
|
cached_obj = normalize_object_update_compressed_data(cached_obj_data)
|
|
cached_obj["UpdateFlags"] = update_flags
|
|
cached_obj["RegionHandle"] = handle
|
|
self._track_new_object(region_state, Object(**cached_obj), msg)
|
|
continue
|
|
|
|
# Don't know about it and wasn't cached.
|
|
missing_locals.add(block["ID"])
|
|
if region_state:
|
|
region_state.missing_locals.update(missing_locals)
|
|
if missing_locals:
|
|
self._handle_object_update_cached_misses(handle, missing_locals)
|
|
msg.meta["ObjectUpdateIDs"] = tuple(seen_locals)
|
|
|
|
def _handle_object_update_cached_misses(self, region_handle: int, missing_locals: Set[int]):
|
|
"""Handle an ObjectUpdateCached that referenced some un-cached local IDs"""
|
|
region_mgr = self._get_region_manager(region_handle)
|
|
region_mgr.request_objects(missing_locals)
|
|
|
|
# noinspection PyUnusedLocal
|
|
def _lookup_cache_entry(self, region_handle: int, local_id: int, crc: int) -> Optional[bytes]:
|
|
return None
|
|
|
|
def _handle_object_update_compressed(self, msg: Message):
|
|
seen_locals = []
|
|
handle = msg["RegionData"]["RegionHandle"]
|
|
region_state = self._get_region_state(handle)
|
|
for block in msg['ObjectData']:
|
|
object_data = normalize_object_update_compressed(block, handle)
|
|
seen_locals.append(object_data["LocalID"])
|
|
if region_state is None:
|
|
LOG.warning(f"Got ObjectUpdateCompressed for unknown region {handle}: {object_data!r}")
|
|
obj = self.lookup_fullid(object_data["FullID"])
|
|
if obj:
|
|
self._update_existing_object(obj, object_data, ObjectUpdateType.UPDATE, msg)
|
|
else:
|
|
if region_state is None:
|
|
continue
|
|
self._track_new_object(region_state, Object(**object_data), msg)
|
|
msg.meta["ObjectUpdateIDs"] = tuple(seen_locals)
|
|
|
|
def _handle_object_properties_generic(self, packet: Message):
|
|
seen_locals = []
|
|
for block in packet["ObjectData"]:
|
|
object_properties = dict(block.items())
|
|
if packet.name == "ObjectProperties":
|
|
object_properties["TextureID"] = block.deserialize_var("TextureID")
|
|
|
|
obj = self.lookup_fullid(block["ObjectID"])
|
|
if obj:
|
|
seen_locals.append(obj.LocalID)
|
|
self._update_existing_object(obj, object_properties, ObjectUpdateType.PROPERTIES, packet)
|
|
else:
|
|
LOG.debug(f"Received {packet.name} for unknown {block['ObjectID']}")
|
|
packet.meta["ObjectUpdateIDs"] = tuple(seen_locals)
|
|
|
|
def _handle_kill_object(self, message: Message):
|
|
seen_locals = []
|
|
|
|
# Have to look up region based on sender, handle not sent in this message
|
|
region = self._session.region_by_circuit_addr(message.sender)
|
|
region_state = region.objects.state
|
|
for block in message["ObjectData"]:
|
|
self._kill_object_by_local_id(region_state, block["ID"])
|
|
seen_locals.append(block["ID"])
|
|
message.meta["ObjectUpdateIDs"] = tuple(seen_locals)
|
|
|
|
def _handle_coarse_location_update(self, message: Message):
|
|
# Have to look up region based on sender, handle not sent in this message
|
|
region = self._session.region_by_circuit_addr(message.sender)
|
|
region_state = region.objects.state
|
|
region_state.coarse_locations.clear()
|
|
|
|
coarse_locations: Dict[UUID, Vector3] = {}
|
|
for agent_block, location_block in zip(message["AgentData"], message["Location"]):
|
|
x, y, z = location_block["X"], location_block["Y"], location_block["Z"]
|
|
coarse_locations[agent_block["AgentID"]] = Vector3(
|
|
X=x,
|
|
Y=y,
|
|
# The z-axis is multiplied by 4 to obtain true Z location
|
|
# The z-axis is also limited to 1020m in height
|
|
# If z == 255 then the true Z is unknown.
|
|
# http://wiki.secondlife.com/wiki/CoarseLocationUpdate
|
|
Z=z * 4 if z != 255 else math.inf,
|
|
)
|
|
|
|
region_state.coarse_locations.update(coarse_locations)
|
|
self._rebuild_avatar_objects()
|
|
|
|
def _handle_animation_message(self, message: Message):
|
|
sender_id = message["Sender"]["ID"]
|
|
if message.name == "AvatarAnimation":
|
|
avatar = self._avatars.get(sender_id)
|
|
if not avatar:
|
|
LOG.warning(f"Received AvatarAnimation for unknown avatar {sender_id}")
|
|
return
|
|
|
|
if not avatar.Object:
|
|
LOG.warning(f"Received AvatarAnimation for avatar with no object {sender_id}")
|
|
return
|
|
|
|
obj = avatar.Object
|
|
elif message.name == "ObjectAnimation":
|
|
obj = self.lookup_fullid(sender_id)
|
|
if not obj:
|
|
# This is only a debug message in the viewer, but let's be louder.
|
|
LOG.warning(f"Received ObjectAnimation for animesh with no object {sender_id}")
|
|
return
|
|
else:
|
|
LOG.error(f"Unknown animation message type: {message.name}")
|
|
return
|
|
|
|
obj.Animations.clear()
|
|
for block in message.blocks.get("AnimationList", []):
|
|
obj.Animations.append(block["AnimID"])
|
|
self._run_object_update_hooks(obj, {"Animations"}, ObjectUpdateType.ANIMATIONS, message)
|
|
|
|
def _handle_avatar_appearance_message(self, message: Message):
|
|
sender_id: UUID = message["Sender"]["ID"]
|
|
if message["Sender"]["IsTrial"]:
|
|
return
|
|
av = self.lookup_avatar(sender_id)
|
|
if not av:
|
|
LOG.warning(f"Received AvatarAppearance with no avatar {sender_id}")
|
|
return
|
|
|
|
version = message["AppearanceData"]["CofVersion"]
|
|
if version < av.COFVersion:
|
|
LOG.warning(f"Ignoring stale appearance for {sender_id}, {version} < {av.COFVersion}")
|
|
return
|
|
|
|
if not message.get_blocks("VisualParam"):
|
|
LOG.warning(f"No visual params in AvatarAppearance for {sender_id}")
|
|
return
|
|
|
|
av.COFVersion = version
|
|
av.Appearance = VISUAL_PARAMS.parse_appearance_message(message)
|
|
|
|
av_obj = av.Object
|
|
if av_obj:
|
|
self._run_object_update_hooks(av_obj, set(), ObjectUpdateType.APPEARANCE, message)
|
|
|
|
def _process_get_object_cost_response(self, parsed: dict):
|
|
if "error" in parsed:
|
|
return
|
|
for object_id, object_costs in parsed.items():
|
|
obj = self.lookup_fullid(UUID(object_id))
|
|
if not obj:
|
|
LOG.debug(f"Received ObjectCost for unknown {object_id}")
|
|
continue
|
|
obj.ObjectCosts.update(object_costs)
|
|
self._run_object_update_hooks(obj, {"ObjectCosts"}, ObjectUpdateType.COSTS, None)
|
|
|
|
def _run_object_update_hooks(self, obj: Object, updated_props: Set[str], update_type: ObjectUpdateType,
|
|
msg: Optional[Message]):
|
|
region_state = self._get_region_state(obj.RegionHandle)
|
|
if region_state:
|
|
region_state.resolve_futures(obj, update_type)
|
|
else:
|
|
LOG.warning(f"{obj} not tied to a region state")
|
|
|
|
if obj.PCode == PCode.AVATAR and "NameValue" in updated_props:
|
|
if obj.NameValue:
|
|
self.name_cache.update(obj.FullID, obj.NameValue.to_dict())
|
|
self.events.handle(ObjectEvent(obj, updated_props, update_type))
|
|
|
|
def _run_kill_object_hooks(self, obj: Object):
|
|
self.events.handle(ObjectEvent(obj, set(), ObjectUpdateType.KILL))
|
|
|
|
def _rebuild_avatar_objects(self):
|
|
# Get all avatars known through coarse locations and which region the location was in
|
|
coarse_locations: Dict[UUID, Tuple[int, Vector3]] = {}
|
|
for region_handle, region in self._region_managers.items():
|
|
for av_key, location in region.state.coarse_locations.items():
|
|
coarse_locations[av_key] = (region_handle, location)
|
|
|
|
# Merge together avatars known through coarse locations or objects, with details for both
|
|
current_av_details: Dict[UUID, Tuple[Optional[Tuple[int, Vector3]], Optional[Object]]] = {}
|
|
for av_key in set(coarse_locations.keys()) | set(self._avatar_objects.keys()):
|
|
details = (coarse_locations.get(av_key), self._avatar_objects.get(av_key))
|
|
current_av_details[av_key] = details
|
|
|
|
# Look for changes in avatars we're already tracking
|
|
for existing_key in tuple(self._avatars.keys()):
|
|
av = self._avatars[existing_key]
|
|
if existing_key in current_av_details:
|
|
# This avatar this exists, update it.
|
|
coarse_pair, av_obj = current_av_details[existing_key]
|
|
av.Object = av_obj
|
|
if coarse_pair:
|
|
coarse_handle, coarse_location = coarse_pair
|
|
av.CoarseLocation = coarse_location
|
|
av.RegionHandle = coarse_handle
|
|
# If we have a real value for Z then throw away any stale guesses
|
|
if av.CoarseLocation.Z != math.inf:
|
|
av.GuessedZ = None
|
|
if av_obj:
|
|
av.Object = av_obj
|
|
av.RegionHandle = av_obj.RegionHandle
|
|
else:
|
|
# Avatar isn't in coarse locations or objects, it's gone.
|
|
self._avatars.pop(existing_key, None)
|
|
av.Object = None
|
|
av.CoarseLocation = None
|
|
av.Valid = False
|
|
|
|
# Check for any new avatars
|
|
for av_key, (coarse_pair, av_obj) in current_av_details.items():
|
|
if av_key in self._avatars:
|
|
# Already handled in the update step above
|
|
continue
|
|
region_handle = None
|
|
coarse_location = None
|
|
if coarse_pair:
|
|
region_handle, coarse_location = coarse_pair
|
|
if av_obj:
|
|
# Prefer the region handle from the Object if we have one
|
|
region_handle = av_obj.RegionHandle
|
|
assert region_handle is not None
|
|
self._avatars[av_key] = Avatar(
|
|
full_id=av_key,
|
|
region_handle=region_handle,
|
|
resolved_name=self.name_cache.lookup(av_key, create_if_none=True),
|
|
coarse_location=coarse_location,
|
|
obj=av_obj,
|
|
)
|
|
|
|
|
|
class RegionObjectsState:
|
|
"""
|
|
Internal class for tracking Object state within a specific region
|
|
|
|
Should only be directly used by the world and region ObjectManagers.
|
|
"""
|
|
|
|
__slots__ = (
|
|
"handle", "missing_locals", "_orphans", "localid_lookup", "coarse_locations",
|
|
"_object_futures", "materials"
|
|
)
|
|
|
|
def __init__(self):
|
|
self.missing_locals = set()
|
|
self.localid_lookup: Dict[int, Object] = {}
|
|
self.coarse_locations: Dict[UUID, Vector3] = {}
|
|
self.materials: MATERIAL_MAP_TYPE = {}
|
|
self._object_futures: Dict[Tuple[int, int], List[asyncio.Future]] = {}
|
|
self._orphans: Dict[int, List[int]] = collections.defaultdict(list)
|
|
|
|
def clear(self):
|
|
"""Called by the owning ObjectManager when it knows the region is going away"""
|
|
for fut in tuple(itertools.chain(*self._object_futures.values())):
|
|
fut.cancel()
|
|
self._object_futures.clear()
|
|
self._orphans.clear()
|
|
self.coarse_locations.clear()
|
|
self.missing_locals.clear()
|
|
self.localid_lookup.clear()
|
|
self.materials.clear()
|
|
|
|
def lookup_localid(self, localid: int) -> Optional[Object]:
|
|
return self.localid_lookup.get(localid)
|
|
|
|
def track_object(self, obj: Object):
|
|
"""Assign ownership of Object to this region"""
|
|
obj_same_localid = self.localid_lookup.get(obj.LocalID)
|
|
if obj_same_localid:
|
|
LOG.error(f"Clobbering existing object with LocalID {obj.LocalID}! "
|
|
f"{obj.to_dict()} clobbered {obj_same_localid.to_dict()}")
|
|
self.localid_lookup[obj.LocalID] = obj
|
|
# If it was missing, it's not missing anymore.
|
|
self.missing_locals -= {obj.LocalID}
|
|
|
|
self._parent_object(obj)
|
|
|
|
# Adopt any of our orphaned child objects.
|
|
for orphan_local in self.collect_orphans(obj.LocalID):
|
|
child_obj = self.localid_lookup.get(orphan_local)
|
|
# Shouldn't be any dead children in the orphanage
|
|
assert child_obj is not None
|
|
self._parent_object(child_obj)
|
|
|
|
def untrack_object(self, obj: Object):
|
|
"""
|
|
Take ownership of an Object from this region
|
|
|
|
Can happen due to the object being killed, or due to it moving to another region
|
|
"""
|
|
former_child_ids = obj.ChildIDs[:]
|
|
for child_id in former_child_ids:
|
|
child_obj = self.localid_lookup.get(child_id)
|
|
assert child_obj is not None
|
|
self._unparent_object(child_obj, child_obj.ParentID)
|
|
|
|
# Place any remaining unkilled children in the orphanage
|
|
for child_id in former_child_ids:
|
|
self._track_orphan(child_id, obj.LocalID)
|
|
|
|
assert not obj.ChildIDs
|
|
|
|
# Make sure the parent knows we went away
|
|
self._unparent_object(obj, obj.ParentID)
|
|
# Object doesn't belong to this region anymore and won't receive
|
|
# any updates, cancel any pending futures
|
|
self.cancel_futures(obj.LocalID)
|
|
|
|
del self.localid_lookup[obj.LocalID]
|
|
|
|
def _parent_object(self, obj: Object, insert_at_head=False):
|
|
"""Create any links to ancestor Objects for obj"""
|
|
if obj.ParentID:
|
|
parent = self.localid_lookup.get(obj.ParentID)
|
|
if parent is not None:
|
|
assert obj.LocalID not in parent.ChildIDs
|
|
# Link order is never explicitly passed to clients, so we have to do
|
|
# some nasty guesswork based on order of received initial ObjectUpdates
|
|
# Note that this is broken in the viewer as well, and there doesn't seem
|
|
# to be a foolproof way to get this.
|
|
idx = 0 if insert_at_head else len(parent.ChildIDs)
|
|
parent.ChildIDs.insert(idx, obj.LocalID)
|
|
parent.Children.insert(idx, obj)
|
|
obj.Parent = weakref.proxy(parent)
|
|
else:
|
|
# We have a parent, but we don't have an Object for it yet
|
|
self.missing_locals.add(obj.ParentID)
|
|
self._track_orphan(obj.LocalID, parent_id=obj.ParentID)
|
|
obj.Parent = None
|
|
LOG.debug(f"{obj.LocalID} updated with parent {obj.ParentID}, but parent wasn't found!")
|
|
|
|
def _unparent_object(self, obj: Object, old_parent_id: int):
|
|
"""Break any links to ancestor Objects for obj"""
|
|
obj.Parent = None
|
|
if old_parent_id:
|
|
# Had a parent, remove this from the child and orphan lists.
|
|
removed = self._untrack_orphan(obj, old_parent_id)
|
|
|
|
old_parent = self.localid_lookup.get(old_parent_id)
|
|
if old_parent:
|
|
if obj.LocalID in old_parent.ChildIDs:
|
|
idx = old_parent.ChildIDs.index(obj.LocalID)
|
|
del old_parent.ChildIDs[idx]
|
|
del old_parent.Children[idx]
|
|
else:
|
|
# Something is very broken if this happens
|
|
LOG.warning(f"Changing parent of {obj.LocalID}, but old parent didn't correctly adopt, "
|
|
f"was {'' if removed else 'not '}in orphan list")
|
|
else:
|
|
LOG.debug(f"Changing parent of {obj.LocalID}, but couldn't find old parent")
|
|
|
|
def handle_object_reparented(self, obj: Object, old_parent_id: int):
|
|
"""Recreate any links to ancestor Objects for obj due to parent changes"""
|
|
self._unparent_object(obj, old_parent_id)
|
|
# Avatars get sent to the _end_ of the child list when reparented
|
|
self._parent_object(obj, insert_at_head=obj.PCode != PCode.AVATAR)
|
|
|
|
def collect_orphans(self, parent_localid: int) -> Sequence[int]:
|
|
"""Take ownership of any orphan IDs belonging to parent_localid"""
|
|
return self._orphans.pop(parent_localid, [])
|
|
|
|
def _track_orphan(self, local_id: int, parent_id: int):
|
|
if len(self._orphans) > 100:
|
|
LOG.warning(f"Orphaned object dict is getting large: {len(self._orphans)}")
|
|
self._orphans[parent_id].append(local_id)
|
|
|
|
def _untrack_orphan(self, obj: Object, parent_id: int):
|
|
"""Remove obj from parent_id's list of orphans if present"""
|
|
if parent_id not in self._orphans:
|
|
return False
|
|
orphan_list = self._orphans[parent_id]
|
|
removed = False
|
|
if obj.LocalID in orphan_list:
|
|
orphan_list.remove(obj.LocalID)
|
|
removed = True
|
|
# List is empty now, get rid of it.
|
|
if not orphan_list:
|
|
del self._orphans[parent_id]
|
|
return removed
|
|
|
|
def register_future(self, local_id: int, future_type: ObjectUpdateType) -> asyncio.Future[Object]:
|
|
fut = asyncio.Future()
|
|
fut_key = (local_id, future_type)
|
|
local_futs = self._object_futures.get(fut_key, [])
|
|
local_futs.append(fut)
|
|
self._object_futures[fut_key] = local_futs
|
|
fut.add_done_callback(local_futs.remove)
|
|
return fut
|
|
|
|
def resolve_futures(self, obj: Object, update_type: ObjectUpdateType):
|
|
futures = self._object_futures.get((obj.LocalID, update_type), [])
|
|
for fut in futures[:]:
|
|
fut.set_result(obj)
|
|
|
|
def cancel_futures(self, local_id: int):
|
|
# Object went away, so need to kill any pending futures.
|
|
for fut_key, futs in self._object_futures.items():
|
|
if fut_key[0] == local_id:
|
|
for fut in futs:
|
|
fut.cancel()
|
|
break
|
|
|
|
|
|
class LocationType(enum.IntEnum):
|
|
NONE = enum.auto()
|
|
COARSE = enum.auto()
|
|
EXACT = enum.auto()
|
|
|
|
|
|
class Avatar:
|
|
"""Wrapper for an avatar known through ObjectUpdate or CoarseLocationUpdate"""
|
|
|
|
def __init__(
|
|
self,
|
|
full_id: UUID,
|
|
region_handle: int,
|
|
obj: Optional["Object"] = None,
|
|
coarse_location: Optional[Vector3] = None,
|
|
resolved_name: Optional[NameCacheEntry] = None,
|
|
):
|
|
self.FullID: UUID = full_id
|
|
self.Object: Optional["Object"] = obj
|
|
self.RegionHandle: int = region_handle
|
|
self.CoarseLocation = coarse_location
|
|
self.Appearance: Dict[int, float] = {}
|
|
self.COFVersion: int = -1
|
|
self.Valid = True
|
|
self.GuessedZ: Optional[float] = None
|
|
self._resolved_name = resolved_name
|
|
|
|
@property
|
|
def LocationType(self) -> "LocationType":
|
|
if self.Object and self.Object.AncestorsKnown:
|
|
return LocationType.EXACT
|
|
if self.CoarseLocation is not None:
|
|
return LocationType.COARSE
|
|
return LocationType.NONE
|
|
|
|
@property
|
|
def RegionPosition(self) -> Vector3:
|
|
if self.Object and self.Object.AncestorsKnown:
|
|
return self.Object.RegionPosition
|
|
if self.CoarseLocation is not None:
|
|
if self.CoarseLocation.Z == math.inf and self.GuessedZ is not None:
|
|
coarse = self.CoarseLocation
|
|
return Vector3(coarse.X, coarse.Y, self.GuessedZ)
|
|
return self.CoarseLocation
|
|
raise ValueError(f"Avatar {self.FullID} has no known position")
|
|
|
|
@property
|
|
def GlobalPosition(self) -> Vector3:
|
|
return self.RegionPosition + handle_to_global_pos(self.RegionHandle)
|
|
|
|
@property
|
|
def Name(self) -> Optional[str]:
|
|
if not self._resolved_name:
|
|
return None
|
|
return str(self._resolved_name)
|
|
|
|
@property
|
|
def PreferredName(self) -> Optional[str]:
|
|
if not self._resolved_name:
|
|
return None
|
|
return self._resolved_name.preferred_name
|
|
|
|
@property
|
|
def DisplayName(self) -> Optional[str]:
|
|
if not self._resolved_name:
|
|
return None
|
|
return self._resolved_name.display_name
|
|
|
|
@property
|
|
def LegacyName(self) -> Optional[str]:
|
|
if not self._resolved_name:
|
|
return None
|
|
return self._resolved_name.legacy_name
|
|
|
|
def __repr__(self):
|
|
loc_str = str(self.RegionPosition) if self.LocationType != LocationType.NONE else "?"
|
|
return f"<{self.__class__.__name__} {self.FullID} {self.Name!r} @ {loc_str}>"
|