From a9927fcfcb590a3b436f090c8631c763d6225e9e Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Mon, 10 Aug 2026 08:58:29 +0000 Subject: [PATCH] Implement appearance and current outfit services (#65) --- README.md | 10 + api/SHIM-COVERAGE.md | 2 +- .../libremetaverse/src/appearance_manager.rs | 964 +++++++++++ crates/libremetaverse/src/asset_models.rs | 1 + crates/libremetaverse/src/client_core.rs | 28 + crates/libremetaverse/src/current_outfit.rs | 1457 +++++++++++++++++ crates/libremetaverse/src/generated.rs | 315 ++-- crates/libremetaverse/src/inventory.rs | 19 + crates/libremetaverse/src/lib.rs | 37 +- docs/appearance.md | 61 + tools/check_milestone_09_issue_65.py | 87 + tools/generate_api_shims.py | 146 ++ tools/generate_rust_mapping.py | 15 +- 13 files changed, 2900 insertions(+), 242 deletions(-) create mode 100644 crates/libremetaverse/src/appearance_manager.rs create mode 100644 crates/libremetaverse/src/current_outfit.rs create mode 100644 docs/appearance.md create mode 100644 tools/check_milestone_09_issue_65.py diff --git a/README.md b/README.md index 53a2a32..a1a1bd9 100644 --- a/README.md +++ b/README.md @@ -382,3 +382,13 @@ side-effect removals, and folder versions are validated and reconciled through one atomic store transaction; cancellation and malformed responses cannot partially mutate the cache. Focused compatibility and injected-HTTP tests cover link correction, endpoint recording, error isolation, and metadata updates. + +Native appearance state now tracks layered wearables and attachment points, +sends the inventory-backed wearable and attachment LLUDP messages, and exposes +worn queries by slot, item, and point. The client-owned Current Outfit Folder +service resolves inventory links, preserves layer descriptions, serializes +concurrent mutations through a cancellation-aware gate, and compensates failed +link or attachment replacement without publishing partial local state. +Composite policies make pure decisions from snapshots and receive committed +changes after synchronization is released. The behavior and transaction +boundaries are documented in [`docs/appearance.md`](docs/appearance.md). diff --git a/api/SHIM-COVERAGE.md b/api/SHIM-COVERAGE.md index 847b1ec..1b5558c 100644 --- a/api/SHIM-COVERAGE.md +++ b/api/SHIM-COVERAGE.md @@ -4,7 +4,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand. | Assembly | Types | Members | Status | |---|---:|---:|---| -| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 197 types / 15,177 members; remaining surface is callable failure-only shims | +| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 200 types / 15,251 members; remaining surface is callable failure-only shims | | `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain | | `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain | | `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim | diff --git a/crates/libremetaverse/src/appearance_manager.rs b/crates/libremetaverse/src/appearance_manager.rs new file mode 100644 index 0000000..038b7b2 --- /dev/null +++ b/crates/libremetaverse/src/appearance_manager.rs @@ -0,0 +1,964 @@ +//! Native wearable and attachment state for `AppearanceManager`. + +use crate::client_core::ClientWeakHandle; +use crate::packet_catalog::PacketType; +use crate::packets::{ + AgentIsNowWearingPacket, AgentIsNowWearingPacketWearableDataBlock, + DetachAttachmentIntoInvPacket, RezSingleAttachmentFromInvPacket, +}; +use crate::{ + AgentWearablesReplyEventArgs, AppearanceManagerWearableData, AppearanceSetEventArgs, + GridClient, InventoryBase, InventoryFolder, InventoryItem, InventoryObject, + InventoryObjectClass, InventoryWearable, Permissions, PrimitiveTextureEntry, + appearance::CurrentOutfitFolder, +}; +use libremetaverse_types::compat::{CancellationToken, EventHandler, Subscription}; +use libremetaverse_types::{ + AssetType, AttachmentPoint, Error, FolderType, InventoryType, MultiValueDictionary, UUID, + WearableType, +}; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; + +const ATTACHMENT_ADD: u8 = 0x80; +const MAX_WEARABLE_LAYERS: usize = 60; +const MAX_FOLDER_TRAVERSAL: usize = 256; + +fn mutex(lock: &Mutex) -> std::sync::MutexGuard<'_, T> { + lock.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn read(lock: &RwLock) -> std::sync::RwLockReadGuard<'_, T> { + lock.read() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn write(lock: &RwLock) -> std::sync::RwLockWriteGuard<'_, T> { + lock.write() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn check_cancelled(token: Option<&CancellationToken>) -> Result<(), Error> { + token.map_or(Ok(()), CancellationToken::throw_if_cancellation_requested) +} + +struct EventRegistry { + next_id: AtomicU64, + handlers: Arc>>>, +} + +impl Default for EventRegistry { + fn default() -> Self { + Self { + next_id: AtomicU64::new(1), + handlers: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +impl EventRegistry { + fn subscribe(&self, handler: EventHandler) -> Subscription { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + mutex(&self.handlers).insert(id, handler); + let handlers = Arc::downgrade(&self.handlers); + Subscription::new(move || { + if let Some(handlers) = handlers.upgrade() { + mutex(&handlers).remove(&id); + } + }) + } + + fn emit_with(&self, mut event: impl FnMut() -> T) { + let handlers: Vec<_> = mutex(&self.handlers).values().cloned().collect(); + for handler in handlers { + handler(event()); + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct WearableRecord { + item_id: UUID, + asset_id: UUID, + asset_type: AssetType, + wearable_type: WearableType, +} + +impl WearableRecord { + fn from_item(item: &InventoryItem) -> Option { + if !matches!(item.asset_type(), AssetType::Bodypart | AssetType::Clothing) { + return None; + } + let wearable = InventoryWearable { base: item.clone() }; + Some(Self { + item_id: item.base.uuid(), + asset_id: item.asset_uuid(), + asset_type: item.asset_type(), + wearable_type: wearable.wearable_type(), + }) + } + + fn public(&self) -> AppearanceManagerWearableData { + AppearanceManagerWearableData { + asset: None, + asset_id: self.asset_id, + asset_type: self.asset_type, + item_id: self.item_id, + wearable_type: self.wearable_type, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct AttachmentRecord { + item: InventoryItem, + point: AttachmentPoint, +} + +#[derive(Default)] +struct AppearanceEvents { + agent_wearables_reply: EventRegistry, + appearance_set: EventRegistry, +} + +pub(crate) struct AppearanceManagerInner { + client: ClientWeakHandle, + wearables: RwLock>, + attachments: RwLock>, + last_cof_version: AtomicI32, + busy: AtomicBool, + disposed: AtomicBool, + events: AppearanceEvents, +} + +/// Shared native manager. Public texture fields remain on the compatibility +/// wrapper; wearable and attachment state is shared by every client accessor. +pub struct AppearanceManager { + pub my_textures: PrimitiveTextureEntry, + pub my_visual_parameters: Vec, + inner: Arc, +} + +impl Clone for AppearanceManager { + fn clone(&self) -> Self { + Self::native_from_inner(Arc::clone(&self.inner)) + } +} + +impl fmt::Debug for AppearanceManager { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("AppearanceManager") + .field("wearables", &read(&self.inner.wearables).len()) + .field("attachments", &read(&self.inner.attachments).len()) + .field("busy", &self.native_manager_busy()) + .finish_non_exhaustive() + } +} + +struct BusyGuard<'a>(&'a AtomicBool); + +impl Drop for BusyGuard<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl AppearanceManager { + pub(crate) fn native_new(client: Option>) -> Result { + let client = client.ok_or(Error::ArgumentNull)?; + Ok(Self::native_from_inner(Arc::new(AppearanceManagerInner { + client: client.native_weak_handle(), + wearables: RwLock::new(Vec::new()), + attachments: RwLock::new(Vec::new()), + last_cof_version: AtomicI32::new(-1), + busy: AtomicBool::new(false), + disposed: AtomicBool::new(false), + events: AppearanceEvents::default(), + }))) + } + + pub(crate) fn native_from_inner(inner: Arc) -> Self { + Self { + my_textures: PrimitiveTextureEntry { + default_texture: None, + face_textures: Vec::new(), + }, + my_visual_parameters: Vec::new(), + inner, + } + } + + pub(crate) fn native_inner(&self) -> Arc { + Arc::clone(&self.inner) + } + + fn client(&self) -> Result { + if self.inner.disposed.load(Ordering::Acquire) { + return Err(Error::InvalidOperation); + } + self.inner.client.upgrade().ok_or(Error::InvalidOperation) + } + + fn begin(&self) -> Result, Error> { + self.client()?; + if self + .inner + .busy + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return Err(Error::InvalidOperation); + } + Ok(BusyGuard(&self.inner.busy)) + } + + pub(crate) fn native_subscribe_agent_wearables_reply( + &self, + handler: EventHandler, + ) -> Subscription { + self.inner.events.agent_wearables_reply.subscribe(handler) + } + + pub(crate) fn native_subscribe_appearance_set( + &self, + handler: EventHandler, + ) -> Subscription { + self.inner.events.appearance_set.subscribe(handler) + } + + #[allow(clippy::unnecessary_wraps)] // The mapped Dispose API is fallible. + pub(crate) fn native_dispose(&self) -> Result<(), Error> { + self.inner.disposed.store(true, Ordering::Release); + write(&self.inner.wearables).clear(); + write(&self.inner.attachments).clear(); + Ok(()) + } + + pub(crate) fn native_manager_busy(&self) -> bool { + self.inner.busy.load(Ordering::Acquire) + } + + pub(crate) fn native_last_cof_version(&self) -> i32 { + self.inner.last_cof_version.load(Ordering::Acquire) + } + + #[allow(clippy::unnecessary_wraps)] // The mapped method returns Result<(), Error>. + pub(crate) fn native_update_cof_version(&self, version: i32) -> Result<(), Error> { + self.inner + .last_cof_version + .fetch_max(version, Ordering::AcqRel); + Ok(()) + } + + pub(crate) fn native_add_to_outfit( + &self, + items: Vec, + replace: bool, + ) -> Result<(), Error> { + let busy = self.begin()?; + let mut next = read(&self.inner.wearables).clone(); + apply_wearables(&mut next, items, replace)?; + self.send_wearables(&next)?; + *write(&self.inner.wearables) = next; + drop(busy); + self.inner + .events + .appearance_set + .emit_with(|| AppearanceSetEventArgs); + self.inner + .events + .agent_wearables_reply + .emit_with(|| AgentWearablesReplyEventArgs); + Ok(()) + } + + pub(crate) fn native_remove_from_outfit(&self, items: Vec) -> Result<(), Error> { + let busy = self.begin()?; + let ids: HashSet<_> = items.into_iter().map(|item| item.base.uuid()).collect(); + let mut next = read(&self.inner.wearables).clone(); + next.retain(|wearable| !ids.contains(&wearable.item_id)); + self.send_wearables(&next)?; + *write(&self.inner.wearables) = next; + drop(busy); + self.inner + .events + .appearance_set + .emit_with(|| AppearanceSetEventArgs); + self.inner + .events + .agent_wearables_reply + .emit_with(|| AgentWearablesReplyEventArgs); + Ok(()) + } + + #[allow(clippy::unused_async)] // Preserves the mapped asynchronous API boundary. + pub(crate) async fn native_replace_outfit( + &self, + items: Vec, + safe: bool, + ) -> Result<(), Error> { + let busy = self.begin()?; + let mut next = if safe { + read(&self.inner.wearables) + .iter() + .filter(|wearable| wearable.asset_type == AssetType::Bodypart) + .cloned() + .collect() + } else { + Vec::new() + }; + apply_wearables(&mut next, items, false)?; + self.send_wearables(&next)?; + *write(&self.inner.wearables) = next; + drop(busy); + self.inner + .events + .appearance_set + .emit_with(|| AppearanceSetEventArgs); + self.inner + .events + .agent_wearables_reply + .emit_with(|| AgentWearablesReplyEventArgs); + Ok(()) + } + + pub(crate) async fn native_wear_outfit( + &self, + values: Vec, + replace: bool, + ) -> Result<(), Error> { + let client = self.client()?; + let store = client + .native_inventory()? + .native_store() + .ok_or(Error::InvalidOperation)?; + let items: Vec<_> = values + .into_iter() + .filter_map(|value| store.native_item_value(value.uuid())) + .collect(); + if replace { + self.native_replace_outfit(items, false).await + } else { + self.native_add_to_outfit(items, false) + } + } + + pub(crate) fn native_get_wearables( + &self, + ) -> Result>, Error> { + self.client()?; + let values: Vec<_> = read(&self.inner.wearables) + .iter() + .map(WearableRecord::public) + .collect(); + Ok(Box::new(values.into_iter())) + } + + pub(crate) fn native_get_wearable_assets( + &self, + wearable_type: WearableType, + ) -> Result>, Error> { + self.client()?; + let values: Vec<_> = read(&self.inner.wearables) + .iter() + .filter(|wearable| wearable.wearable_type == wearable_type) + .map(|wearable| wearable.asset_id) + .collect(); + Ok(Box::new(values.into_iter())) + } + + pub(crate) fn native_get_wearables_by_type( + &self, + ) -> Result, Error> { + self.client()?; + let result = MultiValueDictionary::new_with_constructor()?; + for wearable in read(&self.inner.wearables).iter() { + result.add(wearable.wearable_type, Some(wearable.public()))?; + } + Ok(result) + } + + pub(crate) fn native_is_item_worn(&self, item_id: UUID) -> Result { + self.client()?; + Ok(read(&self.inner.wearables) + .iter() + .find(|wearable| wearable.item_id == item_id) + .map_or(WearableType::Invalid, |wearable| wearable.wearable_type)) + } + + #[allow(clippy::unused_async)] // Preserves the mapped asynchronous API boundary. + pub(crate) async fn native_send_outfit( + &self, + cancellation_token: Option, + ) -> Result<(), Error> { + check_cancelled(cancellation_token.as_ref())?; + let busy = self.begin()?; + let snapshot = read(&self.inner.wearables).clone(); + check_cancelled(cancellation_token.as_ref())?; + self.send_wearables(&snapshot)?; + drop(busy); + self.inner + .events + .appearance_set + .emit_with(|| AppearanceSetEventArgs); + Ok(()) + } + + fn send_wearables(&self, wearables: &[WearableRecord]) -> Result<(), Error> { + let client = self.client()?; + let network = client.native_network()?; + let mut packet = AgentIsNowWearingPacket::new_with_constructor()?; + packet.agent_data.agent_id = network.native_agent_id(); + packet.agent_data.session_id = network.native_session_id(); + packet.wearable_data = wearables + .iter() + .map(|wearable| { + let mut block = AgentIsNowWearingPacketWearableDataBlock::new_with_constructor()?; + block.item_id = wearable.item_id; + block.wearable_type = wearable.wearable_type as u8; + Ok(block) + }) + .collect::, Error>>()?; + send_generated( + &network, + PacketType::AgentIsNowWearing, + packet.to_bytes_with_method()?, + )?; + Ok(()) + } + + pub(crate) fn native_attach( + &self, + item: InventoryItem, + point: AttachmentPoint, + replace: bool, + ) -> Result<(), Error> { + let busy = self.begin()?; + let name = item.base.name(); + let description = item.description(); + let permissions = item.permissions(); + self.send_attach_packet( + item.base.uuid(), + item.base.owner_id(), + &name, + &description, + permissions, + item.flags(), + point, + replace, + )?; + self.record_attachment(item, point, replace); + drop(busy); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn native_attach_fields( + &self, + item_id: UUID, + owner_id: UUID, + name: String, + description: String, + permissions: Permissions, + item_flags: u32, + point: AttachmentPoint, + replace: bool, + ) -> Result<(), Error> { + let busy = self.begin()?; + self.send_attach_packet( + item_id, + owner_id, + &name, + &description, + permissions, + item_flags, + point, + replace, + )?; + let mut item = InventoryItem::new_with_inventory_type_uuid(InventoryType::OBJECT, item_id)?; + item.base.set_owner_id(owner_id); + item.base.set_name(name); + item.set_description(description); + item.set_permissions(permissions); + item.set_flags(item_flags); + item.set_asset_type(AssetType::Object); + self.record_attachment(item, point, replace); + drop(busy); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn send_attach_packet( + &self, + item_id: UUID, + owner_id: UUID, + name: &str, + description: &str, + permissions: Permissions, + item_flags: u32, + point: AttachmentPoint, + replace: bool, + ) -> Result<(), Error> { + let client = self.client()?; + let network = client.native_network()?; + send_generated( + &network, + PacketType::RezSingleAttachmentFromInv, + attachment_packet_bytes( + network.native_agent_id(), + network.native_session_id(), + item_id, + owner_id, + name, + description, + permissions, + item_flags, + point, + replace, + )?, + ) + } + + fn record_attachment(&self, item: InventoryItem, point: AttachmentPoint, replace: bool) { + let mut attachments = write(&self.inner.attachments); + if replace && point != AttachmentPoint::Default { + attachments.retain(|existing| existing.point != point); + } + attachments.retain(|existing| existing.item.base.uuid() != item.base.uuid()); + attachments.push(AttachmentRecord { item, point }); + } + + pub(crate) fn native_add_attachments( + &self, + items: Vec, + remove_existing_first: bool, + replace: bool, + ) -> Result<(), Error> { + let busy = self.begin()?; + let existing = read(&self.inner.attachments).clone(); + if remove_existing_first { + for attachment in &existing { + self.send_detach_packet(attachment.item.base.uuid())?; + write(&self.inner.attachments) + .retain(|candidate| candidate.item.base.uuid() != attachment.item.base.uuid()); + } + } + for item in items { + let object = InventoryObject { base: item.clone() }; + let point = object.attach_point(); + let name = item.base.name(); + let description = item.description(); + self.send_attach_packet( + item.base.uuid(), + item.base.owner_id(), + &name, + &description, + item.permissions(), + item.flags(), + point, + replace, + )?; + self.record_attachment(item, point, replace); + } + drop(busy); + Ok(()) + } + + pub(crate) fn native_detach(&self, item_id: UUID) -> Result<(), Error> { + let busy = self.begin()?; + self.send_detach_packet(item_id)?; + write(&self.inner.attachments).retain(|attachment| attachment.item.base.uuid() != item_id); + drop(busy); + Ok(()) + } + + fn send_detach_packet(&self, item_id: UUID) -> Result<(), Error> { + let client = self.client()?; + let network = client.native_network()?; + let mut packet = DetachAttachmentIntoInvPacket::new_with_constructor()?; + packet.object_data.agent_id = network.native_agent_id(); + packet.object_data.item_id = item_id; + send_generated( + &network, + PacketType::DetachAttachmentIntoInv, + packet.to_bytes_with_method()?, + ) + } + + pub(crate) fn native_get_attachments( + &self, + ) -> Result>, Error> { + self.client()?; + let values: Vec<_> = read(&self.inner.attachments) + .iter() + .map(|attachment| attachment.item.clone()) + .collect(); + Ok(Box::new(values.into_iter())) + } + + #[allow(clippy::unused_async)] // Preserves the mapped asynchronous API boundary. + pub(crate) async fn native_get_attachments_by_point( + &self, + cancellation_token: Option, + ) -> Result, Error> { + check_cancelled(cancellation_token.as_ref())?; + self.client()?; + let result = MultiValueDictionary::new_with_constructor()?; + for attachment in read(&self.inner.attachments).iter() { + result.add(attachment.point, Some(attachment.item.clone()))?; + } + Ok(result) + } + + pub(crate) fn native_get_attachments_by_item( + &self, + ) -> Result, Error> { + self.client()?; + Ok(read(&self.inner.attachments) + .iter() + .map(|attachment| (attachment.item.clone(), attachment.point)) + .collect()) + } + + pub(crate) fn native_get_attachments_by_id( + &self, + ) -> Result, Error> { + self.client()?; + Ok(read(&self.inner.attachments) + .iter() + .map(|attachment| (attachment.item.base.uuid(), attachment.point)) + .collect()) + } + + pub(crate) fn native_is_item_attached(&self, item_id: UUID) -> bool { + read(&self.inner.attachments) + .iter() + .any(|attachment| attachment.item.base.uuid() == item_id) + } + + pub(crate) fn native_attachments_at(&self, point: AttachmentPoint) -> Vec { + read(&self.inner.attachments) + .iter() + .filter(|attachment| attachment.point == point) + .map(|attachment| attachment.item.clone()) + .collect() + } + + pub(crate) fn native_attachment_point(&self, item_id: UUID) -> Option { + read(&self.inner.attachments) + .iter() + .find(|attachment| attachment.item.base.uuid() == item_id) + .map(|attachment| attachment.point) + } + + #[allow(clippy::unused_async)] // Preserves the mapped asynchronous API boundary. + pub(crate) async fn native_get_current_outfit_folder( + &self, + cancellation_token: Option, + ) -> Result, Error> { + check_cancelled(cancellation_token.as_ref())?; + let client = self.client()?; + let Some(store) = client.native_inventory()?.native_store() else { + return Ok(None); + }; + let Some(id) = store.find_folder_for_type(FolderType::CurrentOutfit) else { + return Ok(None); + }; + store.get_value_or_default_with_uuid_a7c63fbe(id) + } + + #[allow(clippy::unused_async)] // Preserves the mapped asynchronous API boundary. + pub(crate) async fn native_get_folder_wearables( + &self, + folder: UUID, + cancellation_token: Option, + ) -> Result<(bool, Vec, Vec), Error> { + check_cancelled(cancellation_token.as_ref())?; + let client = self.client()?; + let Some(store) = client.native_inventory()?.native_store() else { + return Ok((false, Vec::new(), Vec::new())); + }; + let mut queue = VecDeque::from([folder]); + let mut visited = HashSet::new(); + let mut scheduled = HashSet::from([folder]); + let mut wearables = Vec::new(); + let mut attachments = Vec::new(); + while let Some(folder_id) = queue.pop_front() { + check_cancelled(cancellation_token.as_ref())?; + if !visited.insert(folder_id) { + continue; + } + let Ok(contents) = store.get_contents_with_uuid(folder_id) else { + return Ok((false, wearables, attachments)); + }; + for value in contents { + if let Some(child) = value.as_any().downcast_ref::() { + let child_id = child.base.uuid(); + if scheduled.insert(child_id) { + if scheduled.len() > MAX_FOLDER_TRAVERSAL { + return Ok((false, wearables, attachments)); + } + queue.push_back(child_id); + } + continue; + } + let Some(item) = inventory_item(value.as_ref()) else { + continue; + }; + if matches!(item.asset_type(), AssetType::Bodypart | AssetType::Clothing) { + wearables.push(InventoryWearable { base: item }); + } else if matches!(item.asset_type(), AssetType::Object | AssetType::Mesh) { + attachments.push(item); + } + } + } + Ok((true, wearables, attachments)) + } + + pub(crate) async fn native_request_agent_worn( + &self, + cancellation_token: Option, + ) -> Result, Error> { + let client = self.client()?; + let cof = CurrentOutfitFolder::new(Some(Arc::new(client)))?; + let links = cof + .get_current_outfit_links(cancellation_token.clone()) + .await?; + check_cancelled(cancellation_token.as_ref())?; + Ok(links.into_iter().map(|item| item.base).collect()) + } +} + +impl PartialEq for AppearanceManagerWearableData { + fn eq(&self, other: &Self) -> bool { + self.asset_id == other.asset_id + && self.asset_type == other.asset_type + && self.item_id == other.item_id + && self.wearable_type == other.wearable_type + } +} + +impl Eq for AppearanceManagerWearableData {} + +pub(crate) const fn wearable_type_to_asset_type(wearable_type: WearableType) -> AssetType { + match wearable_type { + WearableType::Shape | WearableType::Skin | WearableType::Hair | WearableType::Eyes => { + AssetType::Bodypart + } + WearableType::Invalid => AssetType::Unknown, + _ => AssetType::Clothing, + } +} + +fn apply_wearables( + current: &mut Vec, + items: Vec, + replace: bool, +) -> Result<(), Error> { + let mut seen = HashSet::new(); + for item in items { + let wearable = WearableRecord::from_item(&item).ok_or(Error::Argument)?; + if !seen.insert(wearable.item_id) + || current + .iter() + .any(|existing| existing.item_id == wearable.item_id) + { + continue; + } + if replace || wearable.asset_type == AssetType::Bodypart { + current.retain(|existing| existing.wearable_type != wearable.wearable_type); + } else if current + .iter() + .filter(|existing| existing.wearable_type == wearable.wearable_type) + .count() + >= MAX_WEARABLE_LAYERS + { + return Err(Error::InvalidOperation); + } + current.push(wearable); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn attachment_packet_bytes( + agent_id: UUID, + session_id: UUID, + item_id: UUID, + owner_id: UUID, + name: &str, + description: &str, + permissions: Permissions, + item_flags: u32, + point: AttachmentPoint, + replace: bool, +) -> Result, Error> { + let mut packet = RezSingleAttachmentFromInvPacket::new_with_constructor()?; + packet.agent_data.agent_id = agent_id; + packet.agent_data.session_id = session_id; + packet.object_data.item_id = item_id; + packet.object_data.owner_id = owner_id; + packet.object_data.name = name.as_bytes().to_vec(); + packet.object_data.description = description.as_bytes().to_vec(); + packet.object_data.everyone_mask = permissions.everyone_mask.0; + packet.object_data.group_mask = permissions.group_mask.0; + packet.object_data.next_owner_mask = permissions.next_owner_mask.0; + packet.object_data.item_flags = item_flags; + packet.object_data.attachment_pt = (point as u8) | if replace { 0 } else { ATTACHMENT_ADD }; + packet.to_bytes_with_method() +} + +fn send_generated( + network: &crate::NetworkManager, + packet_type: PacketType, + bytes: Vec, +) -> Result<(), Error> { + let simulator = network + .native_current_sim() + .ok_or(Error::InvalidOperation)?; + let length = i32::try_from(bytes.len()).map_err(|_| Error::Argument)?; + simulator.native_send_packet_data(bytes, length, packet_type, false) +} + +fn inventory_item(value: &dyn InventoryObjectClass) -> Option { + if let Some(item) = value.as_any().downcast_ref::() { + return Some(item.clone()); + } + macro_rules! item { + ($kind:ty) => { + if let Some(item) = value.as_any().downcast_ref::<$kind>() { + return Some(item.base.clone()); + } + }; + } + item!(crate::InventoryAnimation); + item!(crate::InventoryAttachment); + item!(crate::InventoryCallingCard); + item!(crate::InventoryCategory); + item!(crate::InventoryGesture); + item!(crate::InventoryLSL); + item!(crate::InventoryLandmark); + item!(crate::InventoryMaterial); + item!(crate::InventoryNotecard); + item!(crate::InventoryObject); + item!(crate::InventorySettings); + item!(crate::InventorySnapshot); + item!(crate::InventorySound); + item!(crate::InventoryTexture); + item!(crate::InventoryWearable); + None +} + +#[cfg(test)] +mod tests { + use super::*; + + fn wearable(id: u64, wearable_type: WearableType, asset_type: AssetType) -> InventoryItem { + let mut wearable = InventoryWearable::new(UUID::new_with_u_int64(id).unwrap()).unwrap(); + wearable.base.set_asset_type(asset_type); + wearable.base.set_inventory_type(InventoryType::WEARABLE); + wearable + .base + .set_asset_uuid(UUID::new_with_u_int64(id + 1_000).unwrap()); + wearable.set_wearable_type(wearable_type); + wearable.base + } + + #[test] + fn wearable_layers_are_bounded_and_bodyparts_replace_by_slot() { + let mut clothing = Vec::new(); + apply_wearables( + &mut clothing, + (1..=60) + .map(|id| wearable(id, WearableType::Shirt, AssetType::Clothing)) + .collect(), + false, + ) + .unwrap(); + assert_eq!(clothing.len(), MAX_WEARABLE_LAYERS); + assert_eq!( + apply_wearables( + &mut clothing, + vec![wearable(61, WearableType::Shirt, AssetType::Clothing)], + false, + ), + Err(Error::InvalidOperation) + ); + + let mut bodyparts = Vec::new(); + apply_wearables( + &mut bodyparts, + vec![wearable(100, WearableType::Shape, AssetType::Bodypart)], + false, + ) + .unwrap(); + apply_wearables( + &mut bodyparts, + vec![wearable(101, WearableType::Shape, AssetType::Bodypart)], + false, + ) + .unwrap(); + assert_eq!(bodyparts.len(), 1); + assert_eq!(bodyparts[0].item_id, UUID::new_with_u_int64(101).unwrap()); + } + + #[test] + fn attachment_packet_and_replacement_state_preserve_inventory_fields() { + let client = GridClient::new().unwrap(); + let item_id = UUID::new_with_u_int64(500).unwrap(); + let owner_id = UUID::new_with_u_int64(501).unwrap(); + let permissions = Permissions::new(1, 2, 3, 4, 5).unwrap(); + let mut item = + InventoryItem::new_with_inventory_type_uuid(InventoryType::OBJECT, item_id).unwrap(); + item.base.set_owner_id(owner_id); + item.base.set_name("recording hat".into()); + item.set_description("packet fields".into()); + item.set_permissions(permissions); + item.set_flags(0x1234_5678); + item.set_asset_type(AssetType::Object); + + let bytes = attachment_packet_bytes( + UUID::zero(), + UUID::zero(), + item_id, + owner_id, + "recording hat", + "packet fields", + permissions, + 0x1234_5678, + AttachmentPoint::Skull, + false, + ) + .unwrap(); + let mut position = 0; + let packet = + RezSingleAttachmentFromInvPacket::new_with_bytes_int32(bytes, &mut position).unwrap(); + assert_eq!(packet.object_data.item_id, item_id); + assert_eq!(packet.object_data.owner_id, owner_id); + assert_eq!(packet.object_data.name, b"recording hat"); + assert_eq!(packet.object_data.description, b"packet fields"); + assert_eq!(packet.object_data.everyone_mask, 2); + assert_eq!(packet.object_data.group_mask, 3); + assert_eq!(packet.object_data.next_owner_mask, 4); + assert_eq!(packet.object_data.item_flags, 0x1234_5678); + assert_eq!( + packet.object_data.attachment_pt, + (AttachmentPoint::Skull as u8) | ATTACHMENT_ADD + ); + let manager = client.appearance(); + manager.record_attachment(item, AttachmentPoint::Skull, false); + let replacement_id = UUID::new_with_u_int64(502).unwrap(); + let mut replacement = + InventoryItem::new_with_inventory_type_uuid(InventoryType::OBJECT, replacement_id) + .unwrap(); + replacement.set_permissions(permissions); + manager.record_attachment(replacement, AttachmentPoint::Skull, true); + let state = manager.native_get_attachments_by_id().unwrap(); + assert_eq!(state.len(), 1); + assert_eq!(state.get(&replacement_id), Some(&AttachmentPoint::Skull)); + } +} diff --git a/crates/libremetaverse/src/asset_models.rs b/crates/libremetaverse/src/asset_models.rs index 53c8724..b3a5cac 100644 --- a/crates/libremetaverse/src/asset_models.rs +++ b/crates/libremetaverse/src/asset_models.rs @@ -521,6 +521,7 @@ impl AssetTexture { } } +#[derive(Clone, Debug)] pub struct AssetWearable { raw: RawAsset, pub creator: UUID, diff --git a/crates/libremetaverse/src/client_core.rs b/crates/libremetaverse/src/client_core.rs index 46417cf..a0fd431 100644 --- a/crates/libremetaverse/src/client_core.rs +++ b/crates/libremetaverse/src/client_core.rs @@ -118,6 +118,7 @@ struct ClientRuntime { caps_rate_limiter: Mutex, network_manager: Mutex>, agent_manager: Mutex>, + appearance_manager: Mutex>>, inventory_manager: Mutex>>, inventory_ais_client: Mutex>, asset_manager: Mutex>>, @@ -149,6 +150,7 @@ impl ClientRuntime { caps_rate_limiter: Mutex::new(caps_rate_limiter), network_manager: Mutex::new(std::sync::Weak::new()), agent_manager: Mutex::new(std::sync::Weak::new()), + appearance_manager: Mutex::new(None), inventory_manager: Mutex::new(None), inventory_ais_client: Mutex::new(None), asset_manager: Mutex::new(None), @@ -449,6 +451,32 @@ impl GridClient { Ok(manager) } + pub(crate) fn native_appearance(&self) -> Result { + let mut cached = self + .runtime + .appearance_manager + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(inner) = cached.as_ref() { + return Ok( + crate::appearance_manager::AppearanceManager::native_from_inner(Arc::clone(inner)), + ); + } + let manager = + crate::appearance_manager::AppearanceManager::native_new(Some(Arc::new(self.clone())))?; + *cached = Some(manager.native_inner()); + Ok(manager) + } + + #[allow(clippy::needless_pass_by_value)] + pub(crate) fn native_set_appearance(&mut self, value: crate::AppearanceManager) { + *self + .runtime + .appearance_manager + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.native_inner()); + } + pub(crate) fn native_assets(&self) -> Result { let mut cached = self .runtime diff --git a/crates/libremetaverse/src/current_outfit.rs b/crates/libremetaverse/src/current_outfit.rs new file mode 100644 index 0000000..1fc78c9 --- /dev/null +++ b/crates/libremetaverse/src/current_outfit.rs @@ -0,0 +1,1457 @@ +//! Current Outfit Folder state, policy composition, and transactional mutations. + +use crate::appearance::ICurrentOutfitPolicy; +use crate::client_core::ClientWeakHandle; +use crate::{ + GridClient, Inventory, InventoryBase, InventoryFolder, InventoryItem, InventoryObject, + InventoryObjectClass, InventoryWearable, Primitive, +}; +use libremetaverse_types::compat::{CancellationToken, Object}; +use libremetaverse_types::{AssetType, AttachmentPoint, Error, FolderType, UUID, WearableType}; +use std::collections::{HashMap, HashSet}; +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock}; + +pub(crate) const MAX_CLOTHING_LAYERS: i32 = 60; +const MAX_ANCESTRY_DEPTH: usize = 256; + +type BackendFuture<'a, T> = Pin> + 'a>>; + +fn read(lock: &RwLock) -> std::sync::RwLockReadGuard<'_, T> { + lock.read() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn write(lock: &RwLock) -> std::sync::RwLockWriteGuard<'_, T> { + lock.write() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn check_cancelled(token: Option<&CancellationToken>) -> Result<(), Error> { + token.map_or(Ok(()), CancellationToken::throw_if_cancellation_requested) +} + +pub(crate) fn default_policy_decision(item: Option>) -> Result { + item.ok_or(Error::ArgumentNull).map(|_| true) +} + +pub(crate) fn default_policy_report( + added: Option>>, + removed: Option>>, + cancellation_token: Option, +) -> Pin> + Send>> { + Box::pin(async move { + let _ = (added, removed); + check_cancelled(cancellation_token.as_ref()) + }) +} + +/// Thread-safe AND-composition of independent, pure outfit policies. +pub struct CompositeCurrentOutfitPolicy { + policies: RwLock>>, +} + +impl CompositeCurrentOutfitPolicy { + #[allow(clippy::unnecessary_wraps)] // The mapped constructor is fallible. + pub(crate) fn native_new() -> Result { + Ok(Self { + policies: RwLock::new(Vec::new()), + }) + } + + pub(crate) fn native_add_policy( + &self, + policy: Option>, + ) -> Result<&Self, Error> { + let policy = policy.ok_or(Error::ArgumentNull)?; + let mut policies = write(&self.policies); + if !policies + .iter() + .any(|candidate| Arc::ptr_eq(candidate, &policy)) + { + policies.push(policy); + } + Ok(self) + } + + #[allow(clippy::unnecessary_wraps)] // The mapped removal method is fallible. + pub(crate) fn native_remove_policy( + &self, + policy: Option>, + ) -> Result<(), Error> { + let Some(policy) = policy else { + return Ok(()); + }; + write(&self.policies).retain(|candidate| !Arc::ptr_eq(candidate, &policy)); + Ok(()) + } + + fn snapshot(&self) -> Vec> { + read(&self.policies).clone() + } + + pub(crate) fn native_can_attach( + &self, + item: Option>, + ) -> Result { + let item = item.ok_or(Error::ArgumentNull)?; + let policies = self.snapshot(); + let mut allowed = true; + for policy in policies { + // Consult every policy even after a denial. Policies may record an + // audit decision, and the C# composite preserves that behavior. + allowed &= policy.can_attach(Some(Arc::clone(&item)))?; + } + Ok(allowed) + } + + pub(crate) fn native_can_detach( + &self, + item: Option>, + ) -> Result { + let item = item.ok_or(Error::ArgumentNull)?; + let policies = self.snapshot(); + let mut allowed = true; + for policy in policies { + allowed &= policy.can_detach(Some(Arc::clone(&item)))?; + } + Ok(allowed) + } + + pub(crate) async fn native_report_item_change( + &self, + added: Option>>, + removed: Option>>, + cancellation_token: Option, + ) -> Result<(), Error> { + check_cancelled(cancellation_token.as_ref())?; + let policies = self.snapshot(); + for policy in policies { + check_cancelled(cancellation_token.as_ref())?; + policy + .report_item_change( + added.as_ref().map(Arc::clone), + removed.as_ref().map(Arc::clone), + cancellation_token.clone(), + ) + .await?; + } + Ok(()) + } +} + +impl ICurrentOutfitPolicy for CompositeCurrentOutfitPolicy { + fn can_attach(&self, item: Option>) -> Result { + self.native_can_attach(item) + } + + fn can_detach(&self, item: Option>) -> Result { + self.native_can_detach(item) + } + + fn report_item_change( + &self, + added: Option>>, + removed: Option>>, + cancellation_token: Option, + ) -> Pin> + Send + '_>> { + Box::pin(self.native_report_item_change(added, removed, cancellation_token)) + } +} + +#[derive(Clone, Debug)] +struct LinkRequest { + item: InventoryItem, + description: String, +} + +trait OutfitBackend: Send + Sync { + fn store(&self) -> Option; + fn attachments_at(&self, point: AttachmentPoint) -> Vec; + fn attachment_point(&self, item: UUID) -> Option; + fn create_links( + &self, + parent: UUID, + links: Vec, + ) -> BackendFuture<'_, Vec>; + fn remove_links(&self, links: Vec) -> BackendFuture<'_, ()>; + fn attach( + &self, + item: InventoryItem, + point: AttachmentPoint, + replace: bool, + ) -> BackendFuture<'_, ()>; + fn detach(&self, item: UUID) -> BackendFuture<'_, ()>; +} + +struct ClientOutfitBackend { + client: ClientWeakHandle, +} + +impl ClientOutfitBackend { + fn client(&self) -> Result { + self.client.upgrade().ok_or(Error::InvalidOperation) + } +} + +impl OutfitBackend for ClientOutfitBackend { + fn store(&self) -> Option { + self.client + .upgrade() + .and_then(|client| client.native_inventory().ok()) + .and_then(|manager| manager.native_store()) + } + + fn attachments_at(&self, point: AttachmentPoint) -> Vec { + self.client + .upgrade() + .and_then(|client| client.native_appearance().ok()) + .map_or_else(Vec::new, |manager| manager.native_attachments_at(point)) + } + + fn attachment_point(&self, item: UUID) -> Option { + self.client + .upgrade() + .and_then(|client| client.native_appearance().ok()) + .and_then(|manager| manager.native_attachment_point(item)) + } + + fn create_links( + &self, + parent: UUID, + links: Vec, + ) -> BackendFuture<'_, Vec> { + Box::pin(async move { + if links.is_empty() { + return Ok(Vec::new()); + } + let client = self.client()?; + let manager = client.native_inventory()?; + let requested: Vec<_> = links + .iter() + .map(|request| (request.item.clone(), request.description.clone())) + .collect(); + let success = Arc::new(AtomicBool::new(false)); + let callback_success = Arc::clone(&success); + manager + .native_create_links( + parent, + Box::new(requested.into_iter()), + Some(Box::new(move |value| { + callback_success.store(value, Ordering::Release); + })), + None, + ) + .await?; + if !success.load(Ordering::Acquire) { + return Err(Error::InvalidOperation); + } + let Some(store) = manager.native_store() else { + return Ok(Vec::new()); + }; + let requested_ids: HashSet<_> = links + .iter() + .map(|request| request.item.base.uuid()) + .collect(); + let mut created = Vec::new(); + for target in requested_ids { + for node in store.find_all_links(target)? { + let Some(value) = node.data() else { + continue; + }; + let Some(item) = inventory_item(value.as_ref()) else { + continue; + }; + if item.base.parent_uuid() == parent { + created.push(item); + } + } + } + Ok(created) + }) + } + + fn remove_links(&self, links: Vec) -> BackendFuture<'_, ()> { + Box::pin(async move { + if links.is_empty() { + return Ok(()); + } + self.client()? + .native_inventory()? + .native_remove_inventory_objects(links, Vec::new(), None) + .await + }) + } + + fn attach( + &self, + item: InventoryItem, + point: AttachmentPoint, + replace: bool, + ) -> BackendFuture<'_, ()> { + Box::pin(async move { + self.client()? + .native_appearance()? + .native_attach(item, point, replace) + }) + } + + fn detach(&self, item: UUID) -> BackendFuture<'_, ()> { + Box::pin(async move { self.client()?.native_appearance()?.native_detach(item) }) + } +} + +#[derive(Default)] +struct CurrentOutfitState { + cof: Option, + shadow_links: HashMap, + shadow_targets: HashMap, +} + +/// Stateful COF service. Mutations are serialized, while policy callbacks and +/// inventory reads are always performed from snapshots outside synchronous locks. +#[derive(Clone)] +pub struct CurrentOutfitFolder { + backend: Arc, + policies: Arc, + state: Arc>, + operation: Arc, + disposed: Arc, +} + +impl CurrentOutfitFolder { + pub(crate) fn native_new(client: Option>) -> Result { + let client = client.ok_or(Error::ArgumentNull)?; + let backend: Arc = Arc::new(ClientOutfitBackend { + client: client.native_weak_handle(), + }); + Self::with_backend(backend) + } + + fn with_backend(backend: Arc) -> Result { + Ok(Self { + backend, + policies: Arc::new(CompositeCurrentOutfitPolicy::native_new()?), + state: Arc::new(RwLock::new(CurrentOutfitState::default())), + operation: Arc::new(tokio::sync::Semaphore::new(1)), + disposed: Arc::new(AtomicBool::new(false)), + }) + } + + fn ensure_active(&self) -> Result<(), Error> { + if self.disposed.load(Ordering::Acquire) { + Err(Error::InvalidOperation) + } else { + Ok(()) + } + } + + async fn acquire_operation<'a>( + &'a self, + cancellation_token: Option<&CancellationToken>, + ) -> Result, Error> { + if let Some(token) = cancellation_token { + tokio::select! { + biased; + () = token.cancelled() => Err(Error::Cancelled), + permit = self.operation.acquire() => permit.map_err(|_| Error::InvalidOperation), + } + } else { + self.operation + .acquire() + .await + .map_err(|_| Error::InvalidOperation) + } + } + + #[allow(clippy::unnecessary_wraps)] // The mapped Dispose API is fallible. + pub(crate) fn native_dispose(&self) -> Result<(), Error> { + self.disposed.store(true, Ordering::Release); + Ok(()) + } + + pub(crate) fn native_add_policy( + &self, + policy: Arc, + ) -> Result, Error> { + self.ensure_active()?; + self.policies.native_add_policy(Some(Arc::clone(&policy)))?; + Ok(policy) + } + + pub(crate) fn native_remove_policy( + &self, + policy: Arc, + ) -> Result<(), Error> { + self.policies.native_remove_policy(Some(policy)) + } + + pub(crate) fn native_cof(&self) -> Option { + read(&self.state).cof.clone().or_else(|| { + let store = self.backend.store()?; + let id = store.find_folder_for_type(FolderType::CurrentOutfit)?; + store + .get_value_or_default_with_uuid_a7c63fbe::(id) + .ok() + .flatten() + }) + } + + pub(crate) fn native_set_cof(&mut self, value: Option) { + write(&self.state).cof = value; + } + + pub(crate) fn native_resolve_inventory_link( + &self, + item: Arc, + ) -> Result>, Error> { + if !item.is_link()? { + return Ok(Some(item)); + } + if let Some(target) = read(&self.state) + .shadow_targets + .get(&item.asset_uuid()) + .cloned() + { + return Ok(Some(Arc::new(target))); + } + let Some(store) = self.backend.store() else { + return Ok(None); + }; + Ok(store.native_item_value(item.asset_uuid()).map(Arc::new)) + } + + pub(crate) fn native_get_attachment_item_id(prim: Primitive) -> Result { + let Some(values) = prim.name_values else { + return Ok(UUID::zero()); + }; + for value in values { + if value.name != "AttachItemID" { + continue; + } + return match value.value { + Some(Object::UUID(id)) => Ok(id), + Some(Object::String(text)) => UUID::parse(text).or(Ok(UUID::zero())), + _ => Ok(UUID::zero()), + }; + } + Ok(UUID::zero()) + } + + #[allow(clippy::unused_async)] // Preserves the mapped asynchronous API boundary. + pub(crate) async fn native_fetch_parent( + &self, + item: InventoryBase, + cancellation_token: Option, + ) -> Result, Error> { + check_cancelled(cancellation_token.as_ref())?; + if item.parent_uuid() == UUID::zero() { + return Ok(None); + } + let Some(store) = self.backend.store() else { + return Ok(None); + }; + store.get_value_or_default_with_uuid(item.parent_uuid()) + } + + pub(crate) async fn native_is_object_descendent_of( + &self, + item: InventoryBase, + parent_id: UUID, + cancellation_token: Option, + ) -> Result { + if parent_id == UUID::zero() { + return Ok(false); + } + let mut current = item; + let mut visited = HashSet::new(); + for _ in 0..MAX_ANCESTRY_DEPTH { + check_cancelled(cancellation_token.as_ref())?; + let parent = current.parent_uuid(); + if parent == parent_id { + return Ok(true); + } + if parent == UUID::zero() || !visited.insert(parent) { + return Ok(false); + } + let Some(next) = self + .native_fetch_parent(current, cancellation_token.clone()) + .await? + else { + return Ok(false); + }; + current = next; + } + Ok(false) + } + + #[allow(clippy::unused_async)] // Preserves the mapped asynchronous API boundary. + pub(crate) async fn native_get_current_outfit_links( + &self, + cancellation_token: Option, + ) -> Result, Error> { + check_cancelled(cancellation_token.as_ref())?; + let Some(cof) = self.native_cof() else { + return Ok(Vec::new()); + }; + let mut links = HashMap::new(); + if let Some(store) = self.backend.store() + && let Ok(contents) = store.get_contents_with_uuid(cof.base.uuid()) + { + for value in contents { + if let Some(item) = inventory_item(value.as_ref()) + && item.is_link()? + { + links.insert(item.base.uuid(), item); + } + } + } + for (id, item) in &read(&self.state).shadow_links { + if item.base.parent_uuid() == cof.base.uuid() { + links.insert(*id, item.clone()); + } + } + let mut links: Vec<_> = links.into_values().collect(); + links.sort_unstable_by_key(|item| item.base.uuid()); + Ok(links) + } + + pub(crate) async fn native_get_worn_at( + &self, + wearable_type: WearableType, + cancellation_token: Option, + ) -> Result, Error> { + let links = self + .native_get_current_outfit_links(cancellation_token.clone()) + .await?; + let mut worn = Vec::new(); + for link in links { + check_cancelled(cancellation_token.as_ref())?; + let Some(item) = self.native_resolve_inventory_link(Arc::new(link))? else { + continue; + }; + if item_wearable_type(&item) == Some(wearable_type) { + worn.push((*item).clone()); + } + } + Ok(worn) + } + + #[allow(clippy::unused_async)] // Preserves the mapped asynchronous API boundary. + pub(crate) async fn native_can_attach_item( + &self, + item: InventoryItem, + cancellation_token: Option, + ) -> Result { + self.ensure_active()?; + check_cancelled(cancellation_token.as_ref())?; + self.policies.native_can_attach(Some(Arc::new(item))) + } + + #[allow(clippy::unused_async)] // Preserves the mapped asynchronous API boundary. + pub(crate) async fn native_can_detach_item( + &self, + item: InventoryItem, + cancellation_token: Option, + ) -> Result { + self.ensure_active()?; + check_cancelled(cancellation_token.as_ref())?; + self.policies.native_can_detach(Some(Arc::new(item))) + } + + pub(crate) async fn native_add_to_outfit( + &self, + requested: Vec, + replace: bool, + cancellation_token: Option, + ) -> Result<(), Error> { + self.ensure_active()?; + check_cancelled(cancellation_token.as_ref())?; + let operation = self.acquire_operation(cancellation_token.as_ref()).await?; + check_cancelled(cancellation_token.as_ref())?; + let changes = self + .plan_add(requested, replace, cancellation_token.clone()) + .await?; + let (added, removed) = self.commit_changes(changes).await?; + drop(operation); + self.report_changes(added, removed, cancellation_token) + .await + } + + pub(crate) async fn native_remove_from_outfit( + &self, + requested: Vec, + cancellation_token: Option, + ) -> Result<(), Error> { + self.ensure_active()?; + check_cancelled(cancellation_token.as_ref())?; + let operation = self.acquire_operation(cancellation_token.as_ref()).await?; + check_cancelled(cancellation_token.as_ref())?; + let links = self + .native_get_current_outfit_links(cancellation_token.clone()) + .await?; + let requested_ids: HashSet<_> = requested + .iter() + .flat_map(|item| [item.base.uuid(), item.resolved_item_id()]) + .collect(); + let remove: Vec<_> = links + .into_iter() + .filter(|link| { + requested_ids.contains(&link.base.uuid()) + || requested_ids.contains(&link.asset_uuid()) + }) + .collect(); + self.backend + .remove_links(remove.iter().map(|link| link.base.uuid()).collect()) + .await?; + self.remove_shadow(&remove); + drop(operation); + self.report_changes(Vec::new(), requested, cancellation_token) + .await + } + + pub(crate) async fn native_attach( + &self, + item: InventoryItem, + point: AttachmentPoint, + replace: bool, + cancellation_token: Option, + ) -> Result<(), Error> { + if !self + .native_can_attach_item(item.clone(), cancellation_token.clone()) + .await? + { + return Err(Error::InvalidOperation); + } + let operation = self.acquire_operation(cancellation_token.as_ref()).await?; + check_cancelled(cancellation_token.as_ref())?; + let Some(cof) = self.native_cof() else { + return Err(Error::InvalidOperation); + }; + let displaced = if replace && point != AttachmentPoint::Default { + self.backend.attachments_at(point) + } else { + Vec::new() + }; + let displaced_ids: HashSet<_> = displaced.iter().map(|item| item.base.uuid()).collect(); + let displaced_links: Vec<_> = self + .native_get_current_outfit_links(cancellation_token.clone()) + .await? + .into_iter() + .filter(|link| displaced_ids.contains(&link.asset_uuid())) + .collect(); + let created = self + .backend + .create_links( + cof.base.uuid(), + vec![LinkRequest { + item: item.clone(), + description: String::new(), + }], + ) + .await?; + if let Err(error) = self.backend.attach(item.clone(), point, replace).await { + let _ = self + .backend + .remove_links(created.iter().map(|link| link.base.uuid()).collect()) + .await; + return Err(error); + } + let remove_result = if displaced_links.is_empty() { + Ok(()) + } else { + self.backend + .remove_links( + displaced_links + .iter() + .map(|link| link.base.uuid()) + .collect(), + ) + .await + }; + if let Err(error) = remove_result { + let _ = self + .backend + .remove_links(created.iter().map(|link| link.base.uuid()).collect()) + .await; + let _ = self.backend.detach(item.base.uuid()).await; + for old in displaced { + let _ = self.backend.attach(old, point, false).await; + } + return Err(error); + } + self.add_shadow( + &created, + &[LinkRequest { + item: item.clone(), + description: String::new(), + }], + ); + self.remove_shadow(&displaced_links); + drop(operation); + self.report_changes(vec![item], displaced, cancellation_token) + .await + } + + pub(crate) async fn native_detach( + &self, + item: InventoryItem, + cancellation_token: Option, + ) -> Result<(), Error> { + if !self + .native_can_detach_item(item.clone(), cancellation_token.clone()) + .await? + { + return Err(Error::InvalidOperation); + } + let operation = self.acquire_operation(cancellation_token.as_ref()).await?; + check_cancelled(cancellation_token.as_ref())?; + let attachment_point = self.backend.attachment_point(item.base.uuid()); + let links = self + .native_get_current_outfit_links(cancellation_token.clone()) + .await?; + let remove: Vec<_> = links + .into_iter() + .filter(|link| link.asset_uuid() == item.base.uuid()) + .collect(); + self.backend.detach(item.base.uuid()).await?; + if let Err(error) = self + .backend + .remove_links(remove.iter().map(|link| link.base.uuid()).collect()) + .await + { + if let Some(point) = attachment_point { + let _ = self.backend.attach(item, point, false).await; + } + return Err(error); + } + self.remove_shadow(&remove); + drop(operation); + self.report_changes(Vec::new(), vec![item], cancellation_token) + .await + } + + pub(crate) async fn native_replace_outfit( + &self, + folder_id: UUID, + cancellation_token: Option, + ) -> Result { + self.ensure_active()?; + check_cancelled(cancellation_token.as_ref())?; + let Some(store) = self.backend.store() else { + return Ok(false); + }; + let Ok(contents) = store.get_contents_with_uuid(folder_id) else { + return Ok(false); + }; + let mut requested = Vec::new(); + for value in contents { + let Some(item) = inventory_item(value.as_ref()) else { + continue; + }; + if item.is_link()? { + if let Some(resolved) = self.native_resolve_inventory_link(Arc::new(item))? { + requested.push((*resolved).clone()); + } + } else { + requested.push(item); + } + } + let requested_ids: HashSet<_> = requested.iter().map(|item| item.base.uuid()).collect(); + let operation = self.acquire_operation(cancellation_token.as_ref()).await?; + check_cancelled(cancellation_token.as_ref())?; + let mut changes = self + .plan_add(requested, true, cancellation_token.clone()) + .await?; + for link in self + .native_get_current_outfit_links(cancellation_token.clone()) + .await? + { + if !requested_ids.contains(&link.asset_uuid()) { + changes.remove.push(link); + } + } + changes.remove.sort_unstable_by_key(|item| item.base.uuid()); + changes.remove.dedup_by_key(|item| item.base.uuid()); + let (added, removed) = self.commit_changes(changes).await?; + drop(operation); + self.report_changes(added, removed, cancellation_token) + .await?; + Ok(true) + } + + async fn plan_add( + &self, + requested: Vec, + replace: bool, + cancellation_token: Option, + ) -> Result { + let Some(cof) = self.native_cof() else { + return Err(Error::InvalidOperation); + }; + let links = self + .native_get_current_outfit_links(cancellation_token.clone()) + .await?; + let mut current = Vec::new(); + for link in &links { + if let Some(item) = self.native_resolve_inventory_link(Arc::new(link.clone()))? { + current.push((link.clone(), (*item).clone())); + } + } + let mut seen = HashSet::new(); + let mut add: Vec = Vec::new(); + let mut remove = Vec::new(); + let mut planned_layers = HashMap::>::new(); + for (link, worn) in ¤t { + if let Some(wearable_type) = item_wearable_type(worn) + && let Some(layer) = parse_layer(&link.description(), wearable_type) + { + planned_layers + .entry(wearable_type) + .or_default() + .insert(layer); + } + } + let mut replaced_types = HashSet::new(); + for item in requested { + check_cancelled(cancellation_token.as_ref())?; + if !seen.insert(item.base.uuid()) + || current + .iter() + .any(|(_, worn)| worn.base.uuid() == item.base.uuid()) + { + continue; + } + let description = if let Some(wearable_type) = item_wearable_type(&item) { + let same_type: Vec<_> = current + .iter() + .filter(|(_, worn)| item_wearable_type(worn) == Some(wearable_type)) + .collect(); + let bodypart = item.asset_type() == AssetType::Bodypart; + if (replace && replaced_types.insert(wearable_type)) || bodypart { + remove.extend(same_type.iter().map(|(link, _)| link.clone())); + } + if bodypart { + add.retain(|request| item_wearable_type(&request.item) != Some(wearable_type)); + String::new() + } else { + if replace + && replaced_types.contains(&wearable_type) + && !add + .iter() + .any(|request| item_wearable_type(&request.item) == Some(wearable_type)) + { + planned_layers.entry(wearable_type).or_default().clear(); + } + let used = planned_layers.entry(wearable_type).or_default(); + let layer = (0..MAX_CLOTHING_LAYERS) + .find(|layer| !used.contains(layer)) + .ok_or(Error::InvalidOperation)?; + used.insert(layer); + format!("{}{:02}", wearable_type as u8, layer) + } + } else if is_attachment(&item) { + String::new() + } else { + return Err(Error::Argument); + }; + add.push(LinkRequest { item, description }); + } + remove.sort_unstable_by_key(|item| item.base.uuid()); + remove.dedup_by_key(|item| item.base.uuid()); + Ok(OutfitChanges { + cof: cof.base.uuid(), + add, + remove, + }) + } + + async fn commit_changes( + &self, + changes: OutfitChanges, + ) -> Result<(Vec, Vec), Error> { + let added_items: Vec<_> = changes.add.iter().map(|entry| entry.item.clone()).collect(); + let removed_items: Vec<_> = changes + .remove + .iter() + .filter_map(|link| { + self.native_resolve_inventory_link(Arc::new(link.clone())) + .ok() + .flatten() + .map(|item| (*item).clone()) + }) + .collect(); + let created = self + .backend + .create_links(changes.cof, changes.add.clone()) + .await?; + if let Err(error) = self + .backend + .remove_links(changes.remove.iter().map(|link| link.base.uuid()).collect()) + .await + { + let _ = self + .backend + .remove_links(created.iter().map(|link| link.base.uuid()).collect()) + .await; + let restore: Vec<_> = changes + .remove + .iter() + .filter_map(|link| { + self.native_resolve_inventory_link(Arc::new(link.clone())) + .ok() + .flatten() + .map(|item| LinkRequest { + item: (*item).clone(), + description: link.description(), + }) + }) + .collect(); + let _ = self.backend.create_links(changes.cof, restore).await; + return Err(error); + } + self.add_shadow(&created, &changes.add); + self.remove_shadow(&changes.remove); + Ok((added_items, removed_items)) + } + + async fn report_changes( + &self, + added: Vec, + removed: Vec, + cancellation_token: Option, + ) -> Result<(), Error> { + // A committed mutation is never rolled back merely because cancellation + // races with notification. Policies receive the committed change exactly once. + let notification_token = + cancellation_token.filter(|token| !token.is_cancellation_requested()); + self.policies + .native_report_item_change( + Some(Arc::new(added)), + Some(Arc::new(removed)), + notification_token, + ) + .await + } + + fn add_shadow(&self, links: &[InventoryItem], requests: &[LinkRequest]) { + let mut state = write(&self.state); + for link in links { + state.shadow_links.insert(link.base.uuid(), link.clone()); + } + for request in requests { + state + .shadow_targets + .insert(request.item.base.uuid(), request.item.clone()); + } + } + + fn remove_shadow(&self, links: &[InventoryItem]) { + let mut state = write(&self.state); + for link in links { + state.shadow_links.remove(&link.base.uuid()); + } + let retained: HashSet<_> = state + .shadow_links + .values() + .map(InventoryItem::asset_uuid) + .collect(); + state + .shadow_targets + .retain(|target, _| retained.contains(target)); + } + + #[allow(dead_code, clippy::unnecessary_wraps, clippy::unused_self)] // The compatibility overload is fallible and instance-based. + pub(crate) fn get_cof_link_description( + &self, + item: &T, + ) -> Result { + if item.inventory_item().asset_type() == AssetType::Bodypart { + return Ok(String::new()); + } + Ok(item + .wearable_type() + .map_or_else(String::new, |value| format!("{}00", value as u8))) + } +} + +struct OutfitChanges { + cof: UUID, + add: Vec, + remove: Vec, +} + +#[allow(dead_code)] // Internal C# overload adapter used by compatibility fixtures. +pub(crate) trait CofLinkItem { + fn inventory_item(&self) -> &InventoryItem; + fn wearable_type(&self) -> Option; +} + +impl CofLinkItem for InventoryWearable { + fn inventory_item(&self) -> &InventoryItem { + &self.base + } + + fn wearable_type(&self) -> Option { + Some(self.wearable_type()) + } +} + +impl CofLinkItem for InventoryObject { + fn inventory_item(&self) -> &InventoryItem { + &self.base + } + + fn wearable_type(&self) -> Option { + None + } +} + +fn inventory_item(value: &dyn InventoryObjectClass) -> Option { + if let Some(item) = value.as_any().downcast_ref::() { + return Some(item.clone()); + } + macro_rules! item { + ($kind:ty) => { + if let Some(item) = value.as_any().downcast_ref::<$kind>() { + return Some(item.base.clone()); + } + }; + } + item!(crate::InventoryAnimation); + item!(crate::InventoryAttachment); + item!(crate::InventoryCallingCard); + item!(crate::InventoryCategory); + item!(crate::InventoryGesture); + item!(crate::InventoryLSL); + item!(crate::InventoryLandmark); + item!(crate::InventoryMaterial); + item!(crate::InventoryNotecard); + item!(crate::InventoryObject); + item!(crate::InventorySettings); + item!(crate::InventorySnapshot); + item!(crate::InventorySound); + item!(crate::InventoryTexture); + item!(crate::InventoryWearable); + None +} + +fn item_wearable_type(item: &InventoryItem) -> Option { + matches!(item.asset_type(), AssetType::Bodypart | AssetType::Clothing).then(|| { + let wearable = InventoryWearable { base: item.clone() }; + wearable.wearable_type() + }) +} + +fn is_attachment(item: &InventoryItem) -> bool { + matches!(item.asset_type(), AssetType::Object | AssetType::Mesh) +} + +fn parse_layer(description: &str, wearable_type: WearableType) -> Option { + let prefix = (wearable_type as u8).to_string(); + description + .strip_prefix(&prefix)? + .parse::() + .ok() + .filter(|layer| (0..MAX_CLOTHING_LAYERS).contains(layer)) +} + +#[allow(clippy::too_many_lines)] // Mirrors every field in the mapped constructor. +pub(crate) fn primitive_defaults() -> Primitive { + Primitive { + acceleration: libremetaverse_types::Vector3::zero(), + active_clients: 0, + angular_velocity: libremetaverse_types::Vector3::zero(), + crc: 0, + child_count: 0, + click_action: libremetaverse_types::ClickAction::Touch, + collision_plane: libremetaverse_types::Vector4::zero(), + extended_mesh_flags: 0, + face_media: Vec::new(), + flags: libremetaverse_types::PrimFlags::NONE, + flexible: None, + group_id: UUID::zero(), + id: UUID::zero(), + is_attachment: false, + joint: libremetaverse_types::JointType::Invalid, + joint_axis_or_anchor: libremetaverse_types::Vector3::zero(), + joint_pivot: libremetaverse_types::Vector3::zero(), + light: None, + light_map: None, + local_id: 0, + media_url: String::new(), + media_version: String::new(), + name_values: None, + owner_id: UUID::zero(), + parent_id: 0, + particle_sys: crate::PrimitiveParticleSystem { + angular_velocity: libremetaverse_types::Vector3::zero(), + blend_func_dest: crate::PrimitiveParticleSystemBlendFunc::OneMinusSourceAlpha as u8, + blend_func_source: crate::PrimitiveParticleSystemBlendFunc::SourceAlpha as u8, + burst_part_count: 0, + burst_radius: 0.0, + burst_rate: 0.0, + burst_speed_max: 0.0, + burst_speed_min: 0.0, + crc: 0, + inner_angle: 0.0, + max_age: 0.0, + outer_angle: 0.0, + part_acceleration: libremetaverse_types::Vector3::zero(), + part_data_flags: crate::PrimitiveParticleSystemParticleDataFlags::NONE, + part_end_color: libremetaverse_types::Color4::white(), + part_end_glow: 0.0, + part_end_scale_x: 1.0, + part_end_scale_y: 1.0, + part_flags: 0, + part_max_age: 0.0, + part_start_color: libremetaverse_types::Color4::white(), + part_start_glow: 0.0, + part_start_scale_x: 1.0, + part_start_scale_y: 1.0, + pattern: crate::PrimitiveParticleSystemSourcePattern::NONE, + start_age: 0.0, + target: UUID::zero(), + texture: UUID::zero(), + }, + physics_props: None, + position: libremetaverse_types::Vector3::zero(), + prim_data: crate::PrimitiveConstructionData { + material: libremetaverse_types::Material::Stone, + p_code: libremetaverse_types::PCode::Prim, + path_begin: 0.0, + path_curve: libremetaverse_types::PathCurve::Line, + path_end: 1.0, + path_radius_offset: 0.0, + path_revolutions: 1.0, + path_scale_x: 1.0, + path_scale_y: 1.0, + path_shear_x: 0.0, + path_shear_y: 0.0, + path_skew: 0.0, + path_taper_x: 0.0, + path_taper_y: 0.0, + path_twist: 0.0, + path_twist_begin: 0.0, + profile_begin: 0.0, + profile_end: 1.0, + profile_hollow: 0.0, + state: 0, + profile_curve_with_field: 0, + }, + properties: None, + region_handle: 0, + rotation: libremetaverse_types::Quaternion::identity(), + scale: libremetaverse_types::Vector3::one(), + scratch_pad: Vec::new(), + sculpt: None, + signaled_animations: None, + sound: UUID::zero(), + sound_flags: libremetaverse_types::SoundFlags::NONE, + sound_gain: 0.0, + sound_radius: 0.0, + tag: None, + text: String::new(), + text_color: libremetaverse_types::Color4::black(), + texture_anim: crate::PrimitiveTextureAnimation { + face: 0, + flags: crate::PrimitiveTextureAnimMode::ANIM_OFF, + length: 0.0, + rate: 0.0, + size_x: 0, + size_y: 0, + start: 0.0, + }, + textures: None, + tree_species: crate::Tree::Pine1, + velocity: libremetaverse_types::Vector3::zero(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use libremetaverse_types::InventoryType; + use libremetaverse_types::compat::CancellationTokenSource; + use std::sync::Mutex; + use std::sync::atomic::AtomicU64; + + fn mutex(lock: &Mutex) -> std::sync::MutexGuard<'_, T> { + lock.lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + #[derive(Default)] + struct RecordingBackend { + events: Mutex>, + next: AtomicU64, + fail_remove: AtomicBool, + attachments: RwLock>, + } + + impl RecordingBackend { + fn events(&self) -> Vec { + mutex(&self.events).clone() + } + } + + impl OutfitBackend for RecordingBackend { + fn store(&self) -> Option { + None + } + + fn attachments_at(&self, point: AttachmentPoint) -> Vec { + read(&self.attachments) + .iter() + .filter(|(_, candidate)| *candidate == point) + .map(|(item, _)| item.clone()) + .collect() + } + + fn attachment_point(&self, item: UUID) -> Option { + read(&self.attachments) + .iter() + .find(|(candidate, _)| candidate.base.uuid() == item) + .map(|(_, point)| *point) + } + + fn create_links( + &self, + parent: UUID, + links: Vec, + ) -> BackendFuture<'_, Vec> { + Box::pin(async move { + let mut created = Vec::new(); + for request in links { + mutex(&self.events).push(format!( + "create:{}:{}", + request.item.base.name(), + request.description + )); + let ordinal = self.next.fetch_add(1, Ordering::AcqRel) + 1; + let mut link = InventoryItem::new_with_uuid(UUID::new_with_u_int64(ordinal)?)?; + link.base.set_parent_uuid(parent); + link.base.set_name(request.item.base.name()); + link.set_asset_type(AssetType::Link); + link.set_inventory_type(request.item.inventory_type()); + link.set_asset_uuid(request.item.base.uuid()); + link.set_description(request.description); + created.push(link); + } + Ok(created) + }) + } + + fn remove_links(&self, links: Vec) -> BackendFuture<'_, ()> { + Box::pin(async move { + mutex(&self.events).push(format!("remove:{}", links.len())); + if self.fail_remove.load(Ordering::Acquire) && !links.is_empty() { + Err(Error::InvalidOperation) + } else { + Ok(()) + } + }) + } + + fn attach( + &self, + item: InventoryItem, + point: AttachmentPoint, + replace: bool, + ) -> BackendFuture<'_, ()> { + Box::pin(async move { + mutex(&self.events).push(format!( + "attach:{}:{}:{replace}", + item.base.name(), + point as u8 + )); + let mut attachments = write(&self.attachments); + if replace && point != AttachmentPoint::Default { + attachments.retain(|(_, candidate)| *candidate != point); + } + attachments.retain(|(existing, _)| existing.base.uuid() != item.base.uuid()); + attachments.push((item, point)); + Ok(()) + }) + } + + fn detach(&self, item: UUID) -> BackendFuture<'_, ()> { + Box::pin(async move { + mutex(&self.events).push(format!("detach:{item}")); + write(&self.attachments).retain(|(existing, _)| existing.base.uuid() != item); + Ok(()) + }) + } + } + + fn block_on(future: F) -> F::Output { + use std::task::{Context, Poll, Wake, Waker}; + struct ThreadWake(std::thread::Thread); + impl Wake for ThreadWake { + fn wake(self: Arc) { + self.0.unpark(); + } + } + let waker = Waker::from(Arc::new(ThreadWake(std::thread::current()))); + let mut context = Context::from_waker(&waker); + let mut future = std::pin::pin!(future); + loop { + match future.as_mut().poll(&mut context) { + Poll::Ready(output) => return output, + Poll::Pending => std::thread::park(), + } + } + } + + fn service(backend: Arc) -> CurrentOutfitFolder { + let backend_dyn: Arc = backend; + let service = CurrentOutfitFolder::with_backend(backend_dyn).unwrap(); + let mut folder = InventoryFolder::new(UUID::new_with_u_int64(900).unwrap()).unwrap(); + folder.base.set_name("Current Outfit".into()); + write(&service.state).cof = Some(folder); + service + } + + fn wearable(name: &str, id: u64, wearable_type: WearableType) -> InventoryItem { + let mut item = InventoryWearable::new(UUID::new_with_u_int64(id).unwrap()).unwrap(); + item.base.base.set_name(name.into()); + item.base + .set_asset_uuid(UUID::new_with_u_int64(id + 100).unwrap()); + item.base.set_asset_type(AssetType::Clothing); + item.base.set_inventory_type(InventoryType::WEARABLE); + item.set_wearable_type(wearable_type); + item.base + } + + fn attachment(name: &str, id: u64) -> InventoryItem { + let mut item = InventoryObject::new(UUID::new_with_u_int64(id).unwrap()).unwrap(); + item.base.base.set_name(name.into()); + item.base.set_asset_type(AssetType::Object); + item.base.set_inventory_type(InventoryType::OBJECT); + item.base + } + + #[test] + fn fake_grid_add_preserves_layers_and_worn_state() { + let backend = Arc::new(RecordingBackend::default()); + let service = service(Arc::clone(&backend)); + block_on(service.native_add_to_outfit( + vec![ + wearable("first", 10, WearableType::Shirt), + wearable("second", 11, WearableType::Shirt), + ], + false, + None, + )) + .unwrap(); + assert_eq!( + backend.events(), + ["create:first:400", "create:second:401", "remove:0"] + ); + let worn = block_on(service.native_get_worn_at(WearableType::Shirt, None)).unwrap(); + assert_eq!(worn.len(), 2); + } + + #[test] + fn failed_replace_keeps_old_shadow_state_and_records_compensation() { + let backend = Arc::new(RecordingBackend::default()); + let service = service(Arc::clone(&backend)); + let old = wearable("old", 20, WearableType::Shirt); + let request = LinkRequest { + item: old.clone(), + description: "400".into(), + }; + let created = block_on( + backend.create_links(UUID::new_with_u_int64(900).unwrap(), vec![request.clone()]), + ) + .unwrap(); + service.add_shadow(&created, &[request]); + mutex(&backend.events).clear(); + backend.fail_remove.store(true, Ordering::Release); + assert_eq!( + block_on(service.native_add_to_outfit( + vec![wearable("new", 21, WearableType::Shirt)], + true, + None, + )), + Err(Error::InvalidOperation) + ); + assert_eq!( + backend.events(), + ["create:new:400", "remove:1", "remove:1", "create:old:400"] + ); + let worn = block_on(service.native_get_worn_at(WearableType::Shirt, None)).unwrap(); + assert_eq!(worn, vec![old]); + } + + #[test] + fn attach_creates_cof_link_before_network_execution() { + let backend = Arc::new(RecordingBackend::default()); + let service = service(Arc::clone(&backend)); + block_on(service.native_attach(attachment("hat", 30), AttachmentPoint::Skull, true, None)) + .unwrap(); + assert_eq!(backend.events(), ["create:hat:", "attach:hat:2:true"]); + assert_eq!( + block_on(service.native_get_current_outfit_links(None)) + .unwrap() + .len(), + 1 + ); + } + + #[test] + fn attachment_replace_removes_displaced_link_after_new_attachment() { + let backend = Arc::new(RecordingBackend::default()); + let service = service(Arc::clone(&backend)); + let old = attachment("old-hat", 31); + block_on(backend.attach(old.clone(), AttachmentPoint::Skull, false)).unwrap(); + let request = LinkRequest { + item: old, + description: String::new(), + }; + let created = block_on( + backend.create_links(UUID::new_with_u_int64(900).unwrap(), vec![request.clone()]), + ) + .unwrap(); + service.add_shadow(&created, &[request]); + mutex(&backend.events).clear(); + + block_on(service.native_attach( + attachment("new-hat", 32), + AttachmentPoint::Skull, + true, + None, + )) + .unwrap(); + + assert_eq!( + backend.events(), + ["create:new-hat:", "attach:new-hat:2:true", "remove:1"] + ); + let attachments = backend.attachments_at(AttachmentPoint::Skull); + assert_eq!(attachments.len(), 1); + assert_eq!(attachments[0].base.name(), "new-hat"); + let links = block_on(service.native_get_current_outfit_links(None)).unwrap(); + assert_eq!(links.len(), 1); + assert_eq!(links[0].asset_uuid(), attachments[0].base.uuid()); + } + + #[test] + fn cancelled_operation_does_not_reach_backend() { + let backend = Arc::new(RecordingBackend::default()); + let service = service(Arc::clone(&backend)); + let source = CancellationTokenSource::new(); + source.cancel(); + assert_eq!( + block_on(service.native_add_to_outfit( + vec![wearable("shirt", 40, WearableType::Shirt)], + false, + Some(source.token()), + )), + Err(Error::Cancelled) + ); + assert!(backend.events().is_empty()); + } +} diff --git a/crates/libremetaverse/src/generated.rs b/crates/libremetaverse/src/generated.rs index 33df888..5e6246c 100644 --- a/crates/libremetaverse/src/generated.rs +++ b/crates/libremetaverse/src/generated.rs @@ -2861,12 +2861,9 @@ impl AppearanceFlags { } /// C# type: `T:LibreMetaverse.AppearanceManager`. -pub struct AppearanceManager { - /// C# member: `F:LibreMetaverse.AppearanceManager.MyTextures`. - pub my_textures: libremetaverse::PrimitiveTextureEntry, - /// C# member: `F:LibreMetaverse.AppearanceManager.MyVisualParameters`. - pub my_visual_parameters: Vec, -} +pub use crate::appearance_manager::AppearanceManager; +/// C# member: `F:LibreMetaverse.AppearanceManager.MyTextures`. +/// C# member: `F:LibreMetaverse.AppearanceManager.MyVisualParameters`. impl AppearanceManager { /// C# member: `E:LibreMetaverse.AppearanceManager.AgentWearablesReply`. pub fn subscribe_agent_wearables_reply( @@ -2875,16 +2872,14 @@ impl AppearanceManager { libremetaverse::AgentWearablesReplyEventArgs, >, ) -> libremetaverse_types::compat::Subscription { - libremetaverse_types::unimplemented_api!( - "E:LibreMetaverse.AppearanceManager.AgentWearablesReply" - ) + self.native_subscribe_agent_wearables_reply(handler) } /// C# member: `E:LibreMetaverse.AppearanceManager.AppearanceSet`. pub fn subscribe_appearance_set( &self, handler: libremetaverse_types::compat::EventHandler, ) -> libremetaverse_types::compat::Subscription { - libremetaverse_types::unimplemented_api!("E:LibreMetaverse.AppearanceManager.AppearanceSet") + self.native_subscribe_appearance_set(handler) } /// C# member: `E:LibreMetaverse.AppearanceManager.CachedBakesReply`. pub fn subscribe_cached_bakes_reply( @@ -3025,9 +3020,7 @@ impl AppearanceManager { pub fn new( client: Option>, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.#ctor(LibreMetaverse.GridClient)", - ) + crate::appearance_manager::AppearanceManager::native_new(client) } /// C# member: `M:LibreMetaverse.AppearanceManager.AddAttachments(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean,System.Boolean)`. pub fn add_attachments( @@ -3036,9 +3029,7 @@ impl AppearanceManager { remove_existing_first: bool, replace: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.AddAttachments(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean,System.Boolean)", - ) + self.native_add_attachments(attachments, remove_existing_first, replace.unwrap_or(false)) } /// C# member: `M:LibreMetaverse.AppearanceManager.AddToOutfit(LibreMetaverse.InventoryItem,System.Boolean)`. pub fn add_to_outfit_with_inventory_item_boolean( @@ -3046,9 +3037,7 @@ impl AppearanceManager { wearable_item: libremetaverse::InventoryItem, replace: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.AddToOutfit(LibreMetaverse.InventoryItem,System.Boolean)", - ) + self.native_add_to_outfit(vec![wearable_item], replace.unwrap_or(false)) } /// C# member: `M:LibreMetaverse.AppearanceManager.AddToOutfit(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean)`. pub fn add_to_outfit_with_list_boolean( @@ -3056,9 +3045,7 @@ impl AppearanceManager { wearable_items: Vec, replace: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.AddToOutfit(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean)", - ) + self.native_add_to_outfit(wearable_items, replace.unwrap_or(false)) } /// C# member: `M:LibreMetaverse.AppearanceManager.ApplyArchetype(LibreMetaverse.GenepoolArchetype)`. pub async fn apply_archetype( @@ -3076,9 +3063,7 @@ impl AppearanceManager { attach_point: libremetaverse_types::AttachmentPoint, replace: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.Attach(LibreMetaverse.InventoryItem,LibreMetaverse.AttachmentPoint,System.Boolean)", - ) + self.native_attach(item, attach_point, replace.unwrap_or(false)) } /// C# member: `M:LibreMetaverse.AppearanceManager.Attach(LibreMetaverse.UUID,LibreMetaverse.UUID,System.String,System.String,LibreMetaverse.Permissions,System.UInt32,LibreMetaverse.AttachmentPoint,System.Boolean)`. pub fn attach_with_uuid_uuid_string_string_permissions_u_int32_attachment_point_boolean( @@ -3092,8 +3077,15 @@ impl AppearanceManager { attach_point: libremetaverse_types::AttachmentPoint, replace: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.Attach(LibreMetaverse.UUID,LibreMetaverse.UUID,System.String,System.String,LibreMetaverse.Permissions,System.UInt32,LibreMetaverse.AttachmentPoint,System.Boolean)", + self.native_attach_fields( + item_id, + owner_id, + name, + description, + perms, + item_flags, + attach_point, + replace.unwrap_or(false), ) } /// C# member: `M:LibreMetaverse.AppearanceManager.BakeTypeToAgentTextureIndex(LibreMetaverse.BakeType)`. @@ -3136,28 +3128,24 @@ impl AppearanceManager { &self, item: libremetaverse::InventoryItem, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.Detach(LibreMetaverse.InventoryItem)", - ) + self.native_detach(item.base.uuid()) } /// C# member: `M:LibreMetaverse.AppearanceManager.Detach(LibreMetaverse.UUID)`. pub fn detach_with_uuid( &self, item_id: libremetaverse_types::UUID, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.Detach(LibreMetaverse.UUID)", - ) + self.native_detach(item_id) } /// C# member: `M:LibreMetaverse.AppearanceManager.Dispose`. pub fn dispose(&self) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented("M:LibreMetaverse.AppearanceManager.Dispose") + self.native_dispose() } /// C# member: `M:LibreMetaverse.AppearanceManager.GetAttachments`. pub fn get_attachments( &self, ) -> Result>, crate::Error> { - libremetaverse_types::not_implemented("M:LibreMetaverse.AppearanceManager.GetAttachments") + self.native_get_attachments() } /// C# member: `M:LibreMetaverse.AppearanceManager.GetAttachmentsByAttachmentPointAsync(System.Threading.CancellationToken)`. pub async fn get_attachments_by_attachment_point( @@ -3170,9 +3158,8 @@ impl AppearanceManager { >, crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.GetAttachmentsByAttachmentPointAsync(System.Threading.CancellationToken)", - ) + self.native_get_attachments_by_point(cancellation_token) + .await } /// C# member: `M:LibreMetaverse.AppearanceManager.GetAttachmentsByInventoryItem`. pub fn get_attachments_by_inventory_item( @@ -3184,9 +3171,7 @@ impl AppearanceManager { >, crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.GetAttachmentsByInventoryItem", - ) + self.native_get_attachments_by_item() } /// C# member: `M:LibreMetaverse.AppearanceManager.GetAttachmentsByItemId`. pub fn get_attachments_by_item_id( @@ -3198,9 +3183,7 @@ impl AppearanceManager { >, crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.GetAttachmentsByItemId", - ) + self.native_get_attachments_by_id() } /// C# member: `M:LibreMetaverse.AppearanceManager.GetColorFromParams(System.Collections.Generic.List{LibreMetaverse.AppearanceManager.ColorParamInfo})`. pub fn get_color_from_params( @@ -3215,9 +3198,8 @@ impl AppearanceManager { &self, cancellation_token: Option, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.GetCurrentOutfitFolderAsync(System.Threading.CancellationToken)", - ) + self.native_get_current_outfit_folder(cancellation_token) + .await } /// C# member: `M:LibreMetaverse.AppearanceManager.GetCurrentParamValues`. pub fn get_current_param_values( @@ -3240,25 +3222,22 @@ impl AppearanceManager { ), crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.GetFolderWearablesAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_get_folder_wearables(folder, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.AppearanceManager.GetWearableAssets(LibreMetaverse.WearableType)`. pub fn get_wearable_assets( &self, type_: libremetaverse_types::WearableType, ) -> Result>, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.GetWearableAssets(LibreMetaverse.WearableType)", - ) + self.native_get_wearable_assets(type_) } /// C# member: `M:LibreMetaverse.AppearanceManager.GetWearables`. pub fn get_wearables( &self, ) -> Result>, crate::Error> { - libremetaverse_types::not_implemented("M:LibreMetaverse.AppearanceManager.GetWearables") + self.native_get_wearables() } /// C# member: `M:LibreMetaverse.AppearanceManager.GetWearablesByType`. pub fn get_wearables_by_type( @@ -3270,27 +3249,21 @@ impl AppearanceManager { >, crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.GetWearablesByType", - ) + self.native_get_wearables_by_type() } /// C# member: `M:LibreMetaverse.AppearanceManager.IsItemWorn(LibreMetaverse.InventoryItem)`. pub fn is_item_worn_with_inventory_item( &self, item: libremetaverse::InventoryItem, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.IsItemWorn(LibreMetaverse.InventoryItem)", - ) + self.native_is_item_worn(item.base.uuid()) } /// C# member: `M:LibreMetaverse.AppearanceManager.IsItemWorn(LibreMetaverse.UUID)`. pub fn is_item_worn_with_uuid( &self, item_id: libremetaverse_types::UUID, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.IsItemWorn(LibreMetaverse.UUID)", - ) + Ok(self.native_is_item_worn(item_id)? != libremetaverse_types::WearableType::Invalid) } /// C# member: `M:LibreMetaverse.AppearanceManager.MakeAppearancePacket`. pub fn make_appearance_packet( @@ -3322,27 +3295,21 @@ impl AppearanceManager { &self, wearable_item: libremetaverse::InventoryItem, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.RemoveFromOutfit(LibreMetaverse.InventoryItem)", - ) + self.native_remove_from_outfit(vec![wearable_item]) } /// C# member: `M:LibreMetaverse.AppearanceManager.RemoveFromOutfit(System.Collections.Generic.List{LibreMetaverse.InventoryItem})`. pub fn remove_from_outfit_with_list( &self, wearable_items: Vec, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.RemoveFromOutfit(System.Collections.Generic.List{LibreMetaverse.InventoryItem})", - ) + self.native_remove_from_outfit(wearable_items) } /// C# member: `M:LibreMetaverse.AppearanceManager.ReplaceOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem})`. pub async fn replace_outfit_with_list( &self, wearable_items: Vec, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.ReplaceOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem})", - ) + self.native_replace_outfit(wearable_items, false).await } /// C# member: `M:LibreMetaverse.AppearanceManager.ReplaceOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean)`. pub async fn replace_outfit_with_list_boolean( @@ -3350,18 +3317,14 @@ impl AppearanceManager { wearable_items: Vec, safe: bool, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.ReplaceOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean)", - ) + self.native_replace_outfit(wearable_items, safe).await } /// C# member: `M:LibreMetaverse.AppearanceManager.RequestAgentWornAsync(System.Threading.CancellationToken)`. pub async fn request_agent_worn( &self, cancellation_token: Option, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.RequestAgentWornAsync(System.Threading.CancellationToken)", - ) + self.native_request_agent_worn(cancellation_token).await } /// C# member: `M:LibreMetaverse.AppearanceManager.RequestCachedBakes`. pub fn request_cached_bakes(&self) -> Result<(), crate::Error> { @@ -3383,9 +3346,7 @@ impl AppearanceManager { &self, cancellation_token: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.SendOutfitToCurrentSimulatorAsync(System.Threading.CancellationToken)", - ) + self.native_send_outfit(cancellation_token).await } /// C# member: `M:LibreMetaverse.AppearanceManager.ServerBakingRegion`. pub fn server_baking_region(&self) -> Result { @@ -3398,9 +3359,7 @@ impl AppearanceManager { &mut self, cof_version: i32, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.UpdateLastReceivedCOFVersion(System.Int32)", - ) + self.native_update_cof_version(cof_version) } /// C# member: `M:LibreMetaverse.AppearanceManager.WearOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryBase},System.Boolean)`. pub async fn wear_outfit( @@ -3408,45 +3367,37 @@ impl AppearanceManager { wearables: Vec, replace_items: bool, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.WearOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryBase},System.Boolean)", - ) + self.native_wear_outfit(wearables, replace_items).await } /// C# member: `M:LibreMetaverse.AppearanceManager.WearableTypeToAssetType(LibreMetaverse.WearableType)`. pub fn wearable_type_to_asset_type( type_: libremetaverse_types::WearableType, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.WearableTypeToAssetType(LibreMetaverse.WearableType)", - ) + Ok(crate::appearance_manager::wearable_type_to_asset_type( + type_, + )) } /// C# member: `M:LibreMetaverse.AppearanceManager.isItemAttached(LibreMetaverse.InventoryItem)`. pub fn is_item_attached_with_inventory_item( &self, item: libremetaverse::InventoryItem, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.isItemAttached(LibreMetaverse.InventoryItem)", - ) + Ok(self.native_is_item_attached(item.base.uuid())) } /// C# member: `M:LibreMetaverse.AppearanceManager.isItemAttached(LibreMetaverse.UUID)`. pub fn is_item_attached_with_uuid( &self, key: libremetaverse_types::UUID, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.AppearanceManager.isItemAttached(LibreMetaverse.UUID)", - ) + Ok(self.native_is_item_attached(key)) } /// C# member: `P:LibreMetaverse.AppearanceManager.LastUpdateReceivedCOFVersion`. pub fn last_update_received_cof_version(&self) -> i32 { - libremetaverse_types::unimplemented_api!( - "P:LibreMetaverse.AppearanceManager.LastUpdateReceivedCOFVersion" - ) + self.native_last_cof_version() } /// C# member: `P:LibreMetaverse.AppearanceManager.ManagerBusy`. pub fn manager_busy(&self) -> bool { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.AppearanceManager.ManagerBusy") + self.native_manager_busy() } /// C# member: `P:LibreMetaverse.AppearanceManager.TextureProvider`. pub fn texture_provider(&self) -> std::sync::Arc { @@ -3507,6 +3458,7 @@ impl AppearanceManagerTextureData { } /// C# type: `T:LibreMetaverse.AppearanceManager.WearableData`. +#[derive(Clone, Debug)] pub struct AppearanceManagerWearableData { /// C# member: `F:LibreMetaverse.AppearanceManager.WearableData.Asset`. pub asset: Option, @@ -8786,11 +8738,14 @@ impl GridClient { } /// C# member: `P:LibreMetaverse.GridClient.Appearance`. pub fn appearance(&self) -> libremetaverse::AppearanceManager { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.GridClient.Appearance") + /* native client-core implementation */ + self.native_appearance() + .unwrap_or_else(|_| panic!("failed to construct AppearanceManager")) } /// Setter for C# member: `P:LibreMetaverse.GridClient.Appearance`. pub fn set_appearance(&mut self, value: libremetaverse::AppearanceManager) { - libremetaverse_types::unimplemented_api!("P:LibreMetaverse.GridClient.Appearance") + /* native client-core implementation */ + self.native_set_appearance(value) } /// C# member: `P:LibreMetaverse.GridClient.Assets`. pub fn assets(&self) -> libremetaverse::AssetManager { @@ -14722,9 +14677,13 @@ impl NameValue { sendto_type: libremetaverse::NameValueSendtoType, value: libremetaverse_types::compat::Object, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.NameValue.#ctor(System.String,LibreMetaverse.NameValue.ValueType,LibreMetaverse.NameValue.ClassType,LibreMetaverse.NameValue.SendtoType,System.Object)", - ) + Ok(crate::NameValue { + class_: class_type, + name, + sendto: sendto_type, + type_: value_type, + value: Some(value), + }) } /// C# member: `M:LibreMetaverse.NameValue.Equals(LibreMetaverse.NameValue)`. pub fn equals_with_name_value(&self, other: libremetaverse::NameValue) -> bool { @@ -18572,7 +18531,7 @@ pub struct Primitive { impl Primitive { /// C# member: `M:LibreMetaverse.Primitive.#ctor`. pub fn new_with_constructor() -> Result { - libremetaverse_types::not_implemented("M:LibreMetaverse.Primitive.#ctor") + Ok(crate::current_outfit::primitive_defaults()) } /// C# member: `M:LibreMetaverse.Primitive.#ctor(LibreMetaverse.Primitive)`. pub fn new_with_primitive(prim: libremetaverse::Primitive) -> Result { @@ -23074,13 +23033,11 @@ pub mod animesh { pub mod appearance { /// C# type: `T:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy`. - pub struct CompositeCurrentOutfitPolicy; + pub use crate::current_outfit::CompositeCurrentOutfitPolicy; impl CompositeCurrentOutfitPolicy { /// C# member: `M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.#ctor`. pub fn new() -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.#ctor", - ) + crate::current_outfit::CompositeCurrentOutfitPolicy::native_new() } /// C# member: `M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.AddPolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)`. pub fn add_policy( @@ -23089,27 +23046,21 @@ pub mod appearance { std::sync::Arc, >, ) -> Result<&Self, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.AddPolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)", - ) + self.native_add_policy(policy_to_add) } /// C# member: `M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.CanAttach(LibreMetaverse.InventoryItem)`. pub fn can_attach( &self, item: Option>, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.CanAttach(LibreMetaverse.InventoryItem)", - ) + self.native_can_attach(item) } /// C# member: `M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.CanDetach(LibreMetaverse.InventoryItem)`. pub fn can_detach( &self, item: Option>, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.CanDetach(LibreMetaverse.InventoryItem)", - ) + self.native_can_detach(item) } /// C# member: `M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.RemovePolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)`. pub fn remove_policy( @@ -23118,9 +23069,7 @@ pub mod appearance { std::sync::Arc, >, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.RemovePolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)", - ) + self.native_remove_policy(policy_to_remove) } /// C# member: `M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.ReportItemChangeAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Threading.CancellationToken)`. pub async fn report_item_change( @@ -23129,23 +23078,19 @@ pub mod appearance { removed_items: Option>>, cancellation_token: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.ReportItemChangeAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Threading.CancellationToken)", - ) + self.native_report_item_change(added_items, removed_items, cancellation_token) + .await } } - impl libremetaverse::appearance::ICurrentOutfitPolicy for CompositeCurrentOutfitPolicy {} /// C# type: `T:LibreMetaverse.Appearance.CurrentOutfitFolder`. - pub struct CurrentOutfitFolder; + pub use crate::current_outfit::CurrentOutfitFolder; impl CurrentOutfitFolder { /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.#ctor(LibreMetaverse.GridClient)`. pub fn new( client: Option>, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.#ctor(LibreMetaverse.GridClient)", - ) + crate::current_outfit::CurrentOutfitFolder::native_new(client) } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.AddPolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)`. pub fn add_policy( @@ -23155,9 +23100,7 @@ pub mod appearance { std::sync::Arc, crate::Error, > { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.AddPolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)", - ) + self.native_add_policy(policy_to_add) } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.AddToOutfitAsync(LibreMetaverse.InventoryItem,System.Boolean,System.Threading.CancellationToken)`. pub async fn add_to_outfit_with_inventory_item_boolean_cancellation_token( @@ -23166,9 +23109,8 @@ pub mod appearance { replace: bool, cancellation_token: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.AddToOutfitAsync(LibreMetaverse.InventoryItem,System.Boolean,System.Threading.CancellationToken)", - ) + self.native_add_to_outfit(vec![item], replace, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.AddToOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean,System.Threading.CancellationToken)`. pub async fn add_to_outfit_with_list_boolean_cancellation_token( @@ -23177,9 +23119,8 @@ pub mod appearance { replace: bool, cancellation_token: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.AddToOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean,System.Threading.CancellationToken)", - ) + self.native_add_to_outfit(requested_items_to_add, replace, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.AttachAsync(LibreMetaverse.InventoryItem,LibreMetaverse.AttachmentPoint,System.Boolean,System.Threading.CancellationToken)`. pub async fn attach( @@ -23189,9 +23130,8 @@ pub mod appearance { replace: bool, cancellation_token: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.AttachAsync(LibreMetaverse.InventoryItem,LibreMetaverse.AttachmentPoint,System.Boolean,System.Threading.CancellationToken)", - ) + self.native_attach(item, point, replace, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.CanAttachItemAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)`. pub async fn can_attach_item( @@ -23199,9 +23139,7 @@ pub mod appearance { item: libremetaverse::InventoryItem, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.CanAttachItemAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)", - ) + self.native_can_attach_item(item, cancellation_token).await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.CanDetachItemAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)`. pub async fn can_detach_item( @@ -23209,9 +23147,7 @@ pub mod appearance { item: libremetaverse::InventoryItem, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.CanDetachItemAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)", - ) + self.native_can_detach_item(item, cancellation_token).await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.DetachAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)`. pub async fn detach( @@ -23219,15 +23155,11 @@ pub mod appearance { item: libremetaverse::InventoryItem, cancellation_token: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.DetachAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)", - ) + self.native_detach(item, cancellation_token).await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.Dispose`. pub fn dispose(&self) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.Dispose", - ) + self.native_dispose() } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.FetchParentAsync(LibreMetaverse.InventoryBase,System.Threading.CancellationToken)`. pub async fn fetch_parent( @@ -23235,26 +23167,21 @@ pub mod appearance { item: libremetaverse::InventoryBase, cancellation_token: Option, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.FetchParentAsync(LibreMetaverse.InventoryBase,System.Threading.CancellationToken)", - ) + self.native_fetch_parent(item, cancellation_token).await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetAttachmentItemID(LibreMetaverse.Primitive)`. pub fn get_attachment_item_id( prim: libremetaverse::Primitive, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetAttachmentItemID(LibreMetaverse.Primitive)", - ) + crate::current_outfit::CurrentOutfitFolder::native_get_attachment_item_id(prim) } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetCurrentOutfitLinksAsync(System.Threading.CancellationToken)`. pub async fn get_current_outfit_links( &self, cancellation_token: Option, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetCurrentOutfitLinksAsync(System.Threading.CancellationToken)", - ) + self.native_get_current_outfit_links(cancellation_token) + .await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetWornAtAsync(LibreMetaverse.WearableType,System.Threading.CancellationToken)`. pub async fn get_worn_at( @@ -23262,9 +23189,7 @@ pub mod appearance { type_: libremetaverse_types::WearableType, cancellation_token: Option, ) -> Result, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetWornAtAsync(LibreMetaverse.WearableType,System.Threading.CancellationToken)", - ) + self.native_get_worn_at(type_, cancellation_token).await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.IsObjectDescendentOfAsync(LibreMetaverse.InventoryBase,LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn is_object_descendent_of( @@ -23273,9 +23198,8 @@ pub mod appearance { parent_id: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.IsObjectDescendentOfAsync(LibreMetaverse.InventoryBase,LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_is_object_descendent_of(item, parent_id, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.RemoveFromOutfitAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)`. pub async fn remove_from_outfit_with_inventory_item_cancellation_token( @@ -23283,9 +23207,8 @@ pub mod appearance { item: libremetaverse::InventoryItem, cancellation_token: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.RemoveFromOutfitAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)", - ) + self.native_remove_from_outfit(vec![item], cancellation_token) + .await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.RemoveFromOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Threading.CancellationToken)`. pub async fn remove_from_outfit_with_list_cancellation_token( @@ -23293,18 +23216,15 @@ pub mod appearance { requested_items_to_remove: Vec, cancellation_token: Option, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.RemoveFromOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Threading.CancellationToken)", - ) + self.native_remove_from_outfit(requested_items_to_remove, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.RemovePolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)`. pub fn remove_policy( &self, policy_to_remove: std::sync::Arc, ) -> Result<(), crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.RemovePolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)", - ) + self.native_remove_policy(policy_to_remove) } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.ReplaceOutfitAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)`. pub async fn replace_outfit( @@ -23312,36 +23232,27 @@ pub mod appearance { new_outfit_folder_id: libremetaverse_types::UUID, cancellation_token: Option, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.ReplaceOutfitAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)", - ) + self.native_replace_outfit(new_outfit_folder_id, cancellation_token) + .await } /// C# member: `M:LibreMetaverse.Appearance.CurrentOutfitFolder.ResolveInventoryLink(LibreMetaverse.InventoryItem)`. pub fn resolve_inventory_link( &self, item_link: std::sync::Arc, ) -> Result>, crate::Error> { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.ResolveInventoryLink(LibreMetaverse.InventoryItem)", - ) + self.native_resolve_inventory_link(item_link) } /// C# member: `P:LibreMetaverse.Appearance.CurrentOutfitFolder.COF`. pub fn cof(&self) -> Option { - libremetaverse_types::unimplemented_api!( - "P:LibreMetaverse.Appearance.CurrentOutfitFolder.COF" - ) + self.native_cof() } /// Setter for C# member: `P:LibreMetaverse.Appearance.CurrentOutfitFolder.COF`. pub fn set_cof(&mut self, value: Option) { - libremetaverse_types::unimplemented_api!( - "P:LibreMetaverse.Appearance.CurrentOutfitFolder.COF" - ) + self.native_set_cof(value) } /// C# member: `P:LibreMetaverse.Appearance.CurrentOutfitFolder.MaxClothingLayers`. pub fn max_clothing_layers(&self) -> i32 { - libremetaverse_types::unimplemented_api!( - "P:LibreMetaverse.Appearance.CurrentOutfitFolder.MaxClothingLayers" - ) + crate::current_outfit::MAX_CLOTHING_LAYERS } } @@ -23352,18 +23263,14 @@ pub mod appearance { &self, item: Option>, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.ICurrentOutfitPolicy.CanAttach(LibreMetaverse.InventoryItem)", - ) + crate::current_outfit::default_policy_decision(item) } /// C# member: `M:LibreMetaverse.Appearance.ICurrentOutfitPolicy.CanDetach(LibreMetaverse.InventoryItem)`. fn can_detach( &self, item: Option>, ) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.ICurrentOutfitPolicy.CanDetach(LibreMetaverse.InventoryItem)", - ) + crate::current_outfit::default_policy_decision(item) } /// C# member: `M:LibreMetaverse.Appearance.ICurrentOutfitPolicy.ReportItemChangeAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Threading.CancellationToken)`. fn report_item_change( @@ -23374,11 +23281,11 @@ pub mod appearance { ) -> std::pin::Pin< Box> + Send + '_>, > { - Box::pin(async { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.ICurrentOutfitPolicy.ReportItemChangeAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Threading.CancellationToken)", - ) - }) + crate::current_outfit::default_policy_report( + added_items, + removed_items, + cancellation_token, + ) } } diff --git a/crates/libremetaverse/src/inventory.rs b/crates/libremetaverse/src/inventory.rs index 2a0888f..fb3a45d 100644 --- a/crates/libremetaverse/src/inventory.rs +++ b/crates/libremetaverse/src/inventory.rs @@ -18,6 +18,7 @@ use libremetaverse_types::{ use std::collections::{HashMap, HashSet}; use std::fmt; use std::fs::{self, File, OpenOptions}; +use std::hash::{Hash, Hasher}; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock, Weak}; @@ -306,6 +307,24 @@ impl PartialEq for InventoryItem { impl Eq for InventoryItem {} +impl Hash for InventoryItem { + fn hash(&self, state: &mut H) { + self.base.hash(state); + self.asset_uuid.hash(state); + self.asset_type.hash(state); + self.inventory_type.hash(state); + self.description.hash(state); + self.group_id.hash(state); + self.group_owned.hash(state); + self.sale_price.hash(state); + self.sale_type.hash(state); + self.flags.hash(state); + self.creation_date.hash(state); + self.last_owner_id.hash(state); + self.permissions.hash(state); + } +} + impl InventoryItem { fn with_type(inventory_type: InventoryType, uuid: UUID) -> Self { Self { diff --git a/crates/libremetaverse/src/lib.rs b/crates/libremetaverse/src/lib.rs index d53b9c2..ab9fa6c 100644 --- a/crates/libremetaverse/src/lib.rs +++ b/crates/libremetaverse/src/lib.rs @@ -7,6 +7,7 @@ mod attention_catalog; mod agent_manager; mod agent_messages; mod agent_movement; +mod appearance_manager; mod asset_cache; mod asset_manager; mod asset_material; @@ -15,6 +16,7 @@ mod bit_pack; mod caps; mod caps_http; mod client_core; +mod current_outfit; mod download_manager; mod event_queue; mod gesture; @@ -122,41 +124,6 @@ impl animesh::AnimeshPlayer { } } -#[allow(dead_code)] -trait CofLinkItem { - fn inventory_item(&self) -> &InventoryItem; - fn wearable_type(&self) -> Option; -} - -impl CofLinkItem for InventoryWearable { - fn inventory_item(&self) -> &InventoryItem { - &self.base - } - - fn wearable_type(&self) -> Option { - Some(self.wearable_type()) - } -} - -impl CofLinkItem for InventoryObject { - fn inventory_item(&self) -> &InventoryItem { - &self.base - } - - fn wearable_type(&self) -> Option { - None - } -} - -#[allow(clippy::unused_self, dead_code)] -impl appearance::CurrentOutfitFolder { - fn get_cof_link_description(&self, _item: &dyn CofLinkItem) -> Result { - libremetaverse_types::not_implemented( - "M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetCofLinkDescription(LibreMetaverse.InventoryItem)", - ) - } -} - #[allow(dead_code)] // Internal C# surface consumed by InventoryManager workflows. impl InventoryAISClient { async fn create_inventory_links( diff --git a/docs/appearance.md b/docs/appearance.md new file mode 100644 index 0000000..380afb9 --- /dev/null +++ b/docs/appearance.md @@ -0,0 +1,61 @@ +# Appearance, wearables, and the Current Outfit Folder + +`AppearanceManager` owns the client-visible wearable and attachment snapshots. +Wearables retain their inventory item, asset, asset type, and wearable slot. A +clothing slot accepts at most 60 ordered layers; body parts are single-value +slots and replace an existing value of the same type. Sending the outfit emits +the native `AgentIsNowWearing` packet from a snapshot, then publishes the +appearance event after the send. Wearable reply and appearance subscribers are +also snapshotted before invocation, so no state or subscriber lock is held +during a callback. + +Attachment calls preserve the inventory owner, name, description, permission +masks, flags, and requested attachment point in +`RezSingleAttachmentFromInv`. Add versus replace is encoded in the attachment +point field, and replace removes only objects at the selected non-default +point. `DetachAttachmentIntoInv` is keyed by inventory item ID. Queries expose +the same state by item, item ID, and attachment point. + +## Current Outfit Folder ownership + +`CurrentOutfitFolder` discovers the `CurrentOutfit` system folder from the +inventory store unless an explicit folder has been supplied. Its contents are +links: link UUIDs identify COF entries and link asset UUIDs identify the worn +inventory objects. Resolution first consults committed local link targets and +then the inventory store. This keeps successful mutations immediately +queryable while an asynchronous inventory reply is still being reconciled. + +Clothing link descriptions encode the wearable type followed by a two-digit +layer number. Add chooses the first free layer, preserves existing layers, and +rejects a 61st layer. Replace removes the selected wearable type before +allocating its new layers. Body-part and attachment descriptions remain empty, +matching the reference link format. Folder ancestry and recursive outfit +discovery are cycle-safe and bounded to 256 nodes. + +Mutations pass through a one-permit asynchronous operation gate. Waiting for +the gate is cancellation-aware, but the gate is not an inventory/state lock; +network awaits never retain a synchronous lock. Cancellation is checked while +planning and before committing. Once a commit begins, it completes or runs its +compensation before reporting the result, preventing a cancellation race from +leaving a half-written outfit. + +Outfit changes create replacement links before removing old links. If removal +fails, new links are removed and old link descriptions are recreated. Attach +creates its COF link before executing the rez request; replacement removes the +displaced links only after the new attachment succeeds. A later failure +detaches the new item and restores displaced attachments. Detach similarly +restores the attachment if its COF link cannot be removed. The local shadow is +changed only after those network steps succeed. + +`ICurrentOutfitPolicy` remains a pure decision/reporting boundary. Composite +policies use a stable subscriber snapshot, consult every policy for attach and +detach decisions, and invoke reports in registration order. Policy locks are +released before callbacks and committed changes are reported exactly once. + +Focused compatibility coverage lives in `current_outfit_folder_semantics`, +`current_outfit_policy_semantics`, the COF link-description cases, and the +issue-relevant appearance cases. Native recording fakes additionally prove +create/remove compensation, create-before-attach ordering, replacement state, +layer preservation, and cancellation before backend interaction. Texture +baking, visual-parameter synthesis, and texture upload are a separate +appearance pipeline and are not owned by this service slice. diff --git a/tools/check_milestone_09_issue_65.py b/tools/check_milestone_09_issue_65.py new file mode 100644 index 0000000..483ea9d --- /dev/null +++ b/tools/check_milestone_09_issue_65.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Audit issue 65's wearable, attachment, COF, and policy boundary.""" + +from __future__ import annotations + +from pathlib import Path +import re + +import generate_api_shims + + +ROOT = Path(__file__).resolve().parents[1] +APPEARANCE = ROOT / "crates" / "libremetaverse" / "src" / "appearance_manager.rs" +COF = ROOT / "crates" / "libremetaverse" / "src" / "current_outfit.rs" +CLIENT = ROOT / "crates" / "libremetaverse" / "src" / "client_core.rs" +COMPAT = ROOT / "tests" / "compat" / "tests" +LINK_TESTS = ROOT / "crates" / "libremetaverse" / "src" / "batch_link_internal_semantics.rs" +DOC = ROOT / "docs" / "appearance.md" +STUB_RE = re.compile(r"\b(?:not_implemented|unimplemented_api)\b|\b(?:todo|unimplemented)!\s*\(") + +REQUIRED_TYPES = { + "T:LibreMetaverse.AppearanceManager", + "T:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy", + "T:LibreMetaverse.Appearance.CurrentOutfitFolder", +} + +REQUIRED_MEMBERS = { + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.AddToOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean,System.Threading.CancellationToken)", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.AttachAsync(LibreMetaverse.InventoryItem,LibreMetaverse.AttachmentPoint,System.Boolean,System.Threading.CancellationToken)", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.DetachAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetCurrentOutfitLinksAsync(System.Threading.CancellationToken)", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetWornAtAsync(LibreMetaverse.WearableType,System.Threading.CancellationToken)", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.ReplaceOutfitAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)", + "M:LibreMetaverse.AppearanceManager.Attach(LibreMetaverse.InventoryItem,LibreMetaverse.AttachmentPoint,System.Boolean)", + "M:LibreMetaverse.AppearanceManager.GetAttachmentsByAttachmentPointAsync(System.Threading.CancellationToken)", + "M:LibreMetaverse.AppearanceManager.GetFolderWearablesAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)", + "M:LibreMetaverse.AppearanceManager.SendOutfitToCurrentSimulatorAsync(System.Threading.CancellationToken)", +} + + +def require_markers(path: Path, markers: tuple[str, ...]) -> None: + text = path.read_text() + missing = [marker for marker in markers if marker not in text] + if missing: + raise SystemExit(f"{path.name}: audit evidence missing: " + ", ".join(missing)) + + +def main() -> None: + native = set(generate_api_shims.NATIVE_TYPES) | set(generate_api_shims.NATIVE_DECLARATIONS) + missing_types = sorted(REQUIRED_TYPES - native) + if missing_types: + raise SystemExit("issue 65 native types missing: " + ", ".join(missing_types)) + missing_members = sorted(REQUIRED_MEMBERS - set(generate_api_shims.NATIVE_MEMBER_BODIES)) + if missing_members: + raise SystemExit("issue 65 native members missing: " + ", ".join(missing_members)) + for source in (APPEARANCE, COF): + if STUB_RE.search(source.read_text()): + raise SystemExit(f"issue 65 owned Rust stubs remain in {source.name}") + + require_markers(APPEARANCE, ( + "MAX_WEARABLE_LAYERS", "MAX_FOLDER_TRAVERSAL", + "AgentIsNowWearing", "RezSingleAttachmentFromInv", + "DetachAttachmentIntoInv", "permissions.everyone_mask", + "native_attachments_at", "emit_with", + "attachment_packet_and_replacement_state_preserve_inventory_fields", + "wearable_layers_are_bounded_and_bodyparts_replace_by_slot", + )) + require_markers(COF, ( + "MAX_CLOTHING_LAYERS", "MAX_ANCESTRY_DEPTH", "shadow_links", + "tokio::sync::Semaphore", "token.cancelled()", "snapshot()", + "create_links", "remove_links", "native_resolve_inventory_link", + "failed_replace_keeps_old_shadow_state_and_records_compensation", + "attach_creates_cof_link_before_network_execution", + "attachment_replace_removes_displaced_link_after_new_attachment", + "cancelled_operation_does_not_reach_backend", + )) + require_markers(CLIENT, ("native_appearance", "native_set_appearance")) + require_markers(COMPAT / "current_outfit_folder_semantics.rs", ("parity-case", "get_current_outfit_links")) + require_markers(COMPAT / "current_outfit_policy_semantics.rs", ("parity-case", "report_item_change")) + require_markers(COMPAT / "appearance_semantics.rs", ("wearable_type_to_asset_type", "last_update_received_cof_version")) + require_markers(LINK_TESTS, ("get_cof_link_description", "clothing_wearable")) + require_markers(DOC, ("60", "256", "create replacement links before removing old links", "no state or subscriber lock", "compensation")) + print("issue 65 audit: native wearable/attachment state, transactional COF links, policy snapshots, cancellation, documentation, and focused fake/compatibility evidence are present") + + +if __name__ == "__main__": + main() diff --git a/tools/generate_api_shims.py b/tools/generate_api_shims.py index 2efc9eb..a342fa4 100644 --- a/tools/generate_api_shims.py +++ b/tools/generate_api_shims.py @@ -291,6 +291,9 @@ NATIVE_TYPES = { # hand-written, while its fixed public methods remain generator-audited. NATIVE_DECLARATIONS = { "T:LibreMetaverse.AgentManager": "crate::agent_manager::AgentManager", + "T:LibreMetaverse.AppearanceManager": "crate::appearance_manager::AppearanceManager", + "T:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy": "crate::current_outfit::CompositeCurrentOutfitPolicy", + "T:LibreMetaverse.Appearance.CurrentOutfitFolder": "crate::current_outfit::CurrentOutfitFolder", "T:LibreMetaverse.GridClient": "crate::client_core::GridClient", "T:LibreMetaverse.InventoryManager": "crate::inventory_manager::InventoryManager", "T:LibreMetaverse.InventoryAISClient": "crate::inventory_ais::InventoryAISClient", @@ -302,6 +305,146 @@ NATIVE_DECLARATIONS = { } NATIVE_MEMBER_BODIES = { + "M:LibreMetaverse.NameValue.#ctor(System.String,LibreMetaverse.NameValue.ValueType,LibreMetaverse.NameValue.ClassType,LibreMetaverse.NameValue.SendtoType,System.Object)": + "Ok(crate::NameValue { class_: class_type, name, sendto: sendto_type, type_: value_type, value: Some(value) })", + "M:LibreMetaverse.Primitive.#ctor": + "Ok(crate::current_outfit::primitive_defaults())", + "M:LibreMetaverse.Appearance.ICurrentOutfitPolicy.CanAttach(LibreMetaverse.InventoryItem)": + "crate::current_outfit::default_policy_decision(item)", + "M:LibreMetaverse.Appearance.ICurrentOutfitPolicy.CanDetach(LibreMetaverse.InventoryItem)": + "crate::current_outfit::default_policy_decision(item)", + "M:LibreMetaverse.Appearance.ICurrentOutfitPolicy.ReportItemChangeAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Threading.CancellationToken)": + "crate::current_outfit::default_policy_report(added_items, removed_items, cancellation_token)", + "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.#ctor": + "crate::current_outfit::CompositeCurrentOutfitPolicy::native_new()", + "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.AddPolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)": + "self.native_add_policy(policy_to_add)", + "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.CanAttach(LibreMetaverse.InventoryItem)": + "self.native_can_attach(item)", + "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.CanDetach(LibreMetaverse.InventoryItem)": + "self.native_can_detach(item)", + "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.RemovePolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)": + "self.native_remove_policy(policy_to_remove)", + "M:LibreMetaverse.Appearance.CompositeCurrentOutfitPolicy.ReportItemChangeAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Threading.CancellationToken)": + "self.native_report_item_change(added_items, removed_items, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.#ctor(LibreMetaverse.GridClient)": + "crate::current_outfit::CurrentOutfitFolder::native_new(client)", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.AddPolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)": + "self.native_add_policy(policy_to_add)", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.AddToOutfitAsync(LibreMetaverse.InventoryItem,System.Boolean,System.Threading.CancellationToken)": + "self.native_add_to_outfit(vec![item], replace, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.AddToOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean,System.Threading.CancellationToken)": + "self.native_add_to_outfit(requested_items_to_add, replace, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.AttachAsync(LibreMetaverse.InventoryItem,LibreMetaverse.AttachmentPoint,System.Boolean,System.Threading.CancellationToken)": + "self.native_attach(item, point, replace, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.CanAttachItemAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)": + "self.native_can_attach_item(item, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.CanDetachItemAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)": + "self.native_can_detach_item(item, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.DetachAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)": + "self.native_detach(item, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.Dispose": + "self.native_dispose()", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.FetchParentAsync(LibreMetaverse.InventoryBase,System.Threading.CancellationToken)": + "self.native_fetch_parent(item, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetAttachmentItemID(LibreMetaverse.Primitive)": + "crate::current_outfit::CurrentOutfitFolder::native_get_attachment_item_id(prim)", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetCurrentOutfitLinksAsync(System.Threading.CancellationToken)": + "self.native_get_current_outfit_links(cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.GetWornAtAsync(LibreMetaverse.WearableType,System.Threading.CancellationToken)": + "self.native_get_worn_at(type_, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.IsObjectDescendentOfAsync(LibreMetaverse.InventoryBase,LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_is_object_descendent_of(item, parent_id, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.RemoveFromOutfitAsync(LibreMetaverse.InventoryItem,System.Threading.CancellationToken)": + "self.native_remove_from_outfit(vec![item], cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.RemoveFromOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Threading.CancellationToken)": + "self.native_remove_from_outfit(requested_items_to_remove, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.RemovePolicy(LibreMetaverse.Appearance.ICurrentOutfitPolicy)": + "self.native_remove_policy(policy_to_remove)", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.ReplaceOutfitAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_replace_outfit(new_outfit_folder_id, cancellation_token).await", + "M:LibreMetaverse.Appearance.CurrentOutfitFolder.ResolveInventoryLink(LibreMetaverse.InventoryItem)": + "self.native_resolve_inventory_link(item_link)", + "P:LibreMetaverse.Appearance.CurrentOutfitFolder.COF": + "self.native_cof()", + "P:LibreMetaverse.Appearance.CurrentOutfitFolder.COF#set": + "self.native_set_cof(value)", + "P:LibreMetaverse.Appearance.CurrentOutfitFolder.MaxClothingLayers": + "crate::current_outfit::MAX_CLOTHING_LAYERS", + "M:LibreMetaverse.AppearanceManager.#ctor(LibreMetaverse.GridClient)": + "crate::appearance_manager::AppearanceManager::native_new(client)", + "E:LibreMetaverse.AppearanceManager.AgentWearablesReply": + "self.native_subscribe_agent_wearables_reply(handler)", + "E:LibreMetaverse.AppearanceManager.AppearanceSet": + "self.native_subscribe_appearance_set(handler)", + "M:LibreMetaverse.AppearanceManager.AddAttachments(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean,System.Boolean)": + "self.native_add_attachments(attachments, remove_existing_first, replace.unwrap_or(false))", + "M:LibreMetaverse.AppearanceManager.AddToOutfit(LibreMetaverse.InventoryItem,System.Boolean)": + "self.native_add_to_outfit(vec![wearable_item], replace.unwrap_or(false))", + "M:LibreMetaverse.AppearanceManager.AddToOutfit(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean)": + "self.native_add_to_outfit(wearable_items, replace.unwrap_or(false))", + "M:LibreMetaverse.AppearanceManager.Attach(LibreMetaverse.InventoryItem,LibreMetaverse.AttachmentPoint,System.Boolean)": + "self.native_attach(item, attach_point, replace.unwrap_or(false))", + "M:LibreMetaverse.AppearanceManager.Attach(LibreMetaverse.UUID,LibreMetaverse.UUID,System.String,System.String,LibreMetaverse.Permissions,System.UInt32,LibreMetaverse.AttachmentPoint,System.Boolean)": + "self.native_attach_fields(item_id, owner_id, name, description, perms, item_flags, attach_point, replace.unwrap_or(false))", + "M:LibreMetaverse.AppearanceManager.Detach(LibreMetaverse.InventoryItem)": + "self.native_detach(item.base.uuid())", + "M:LibreMetaverse.AppearanceManager.Detach(LibreMetaverse.UUID)": + "self.native_detach(item_id)", + "M:LibreMetaverse.AppearanceManager.Dispose": + "self.native_dispose()", + "M:LibreMetaverse.AppearanceManager.GetAttachments": + "self.native_get_attachments()", + "M:LibreMetaverse.AppearanceManager.GetAttachmentsByAttachmentPointAsync(System.Threading.CancellationToken)": + "self.native_get_attachments_by_point(cancellation_token).await", + "M:LibreMetaverse.AppearanceManager.GetAttachmentsByInventoryItem": + "self.native_get_attachments_by_item()", + "M:LibreMetaverse.AppearanceManager.GetAttachmentsByItemId": + "self.native_get_attachments_by_id()", + "M:LibreMetaverse.AppearanceManager.GetCurrentOutfitFolderAsync(System.Threading.CancellationToken)": + "self.native_get_current_outfit_folder(cancellation_token).await", + "M:LibreMetaverse.AppearanceManager.GetFolderWearablesAsync(LibreMetaverse.UUID,System.Threading.CancellationToken)": + "self.native_get_folder_wearables(folder, cancellation_token).await", + "M:LibreMetaverse.AppearanceManager.GetWearableAssets(LibreMetaverse.WearableType)": + "self.native_get_wearable_assets(type_)", + "M:LibreMetaverse.AppearanceManager.GetWearables": + "self.native_get_wearables()", + "M:LibreMetaverse.AppearanceManager.GetWearablesByType": + "self.native_get_wearables_by_type()", + "M:LibreMetaverse.AppearanceManager.IsItemWorn(LibreMetaverse.InventoryItem)": + "self.native_is_item_worn(item.base.uuid())", + "M:LibreMetaverse.AppearanceManager.IsItemWorn(LibreMetaverse.UUID)": + "Ok(self.native_is_item_worn(item_id)? != libremetaverse_types::WearableType::Invalid)", + "M:LibreMetaverse.AppearanceManager.RemoveFromOutfit(LibreMetaverse.InventoryItem)": + "self.native_remove_from_outfit(vec![wearable_item])", + "M:LibreMetaverse.AppearanceManager.RemoveFromOutfit(System.Collections.Generic.List{LibreMetaverse.InventoryItem})": + "self.native_remove_from_outfit(wearable_items)", + "M:LibreMetaverse.AppearanceManager.ReplaceOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem})": + "self.native_replace_outfit(wearable_items, false).await", + "M:LibreMetaverse.AppearanceManager.ReplaceOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryItem},System.Boolean)": + "self.native_replace_outfit(wearable_items, safe).await", + "M:LibreMetaverse.AppearanceManager.RequestAgentWornAsync(System.Threading.CancellationToken)": + "self.native_request_agent_worn(cancellation_token).await", + "M:LibreMetaverse.AppearanceManager.SendOutfitToCurrentSimulatorAsync(System.Threading.CancellationToken)": + "self.native_send_outfit(cancellation_token).await", + "M:LibreMetaverse.AppearanceManager.UpdateLastReceivedCOFVersion(System.Int32)": + "self.native_update_cof_version(cof_version)", + "M:LibreMetaverse.AppearanceManager.WearOutfitAsync(System.Collections.Generic.List{LibreMetaverse.InventoryBase},System.Boolean)": + "self.native_wear_outfit(wearables, replace_items).await", + "M:LibreMetaverse.AppearanceManager.WearableTypeToAssetType(LibreMetaverse.WearableType)": + "Ok(crate::appearance_manager::wearable_type_to_asset_type(type_))", + "M:LibreMetaverse.AppearanceManager.isItemAttached(LibreMetaverse.InventoryItem)": + "Ok(self.native_is_item_attached(item.base.uuid()))", + "M:LibreMetaverse.AppearanceManager.isItemAttached(LibreMetaverse.UUID)": + "Ok(self.native_is_item_attached(key))", + "P:LibreMetaverse.AppearanceManager.LastUpdateReceivedCOFVersion": + "self.native_last_cof_version()", + "P:LibreMetaverse.AppearanceManager.ManagerBusy": + "self.native_manager_busy()", + "P:LibreMetaverse.GridClient.Appearance": + "self.native_appearance().unwrap_or_else(|_| panic!(\"failed to construct AppearanceManager\"))", + "P:LibreMetaverse.GridClient.Appearance#set": + "self.native_set_appearance(value)", "M:LibreMetaverse.Avatar.AvatarProperties.FromOSD(LibreMetaverse.StructuredData.OSD)": "crate::agent_messages::avatar_properties_from_osd(o)", "M:LibreMetaverse.Avatar.AvatarProperties.GetOSD": @@ -1892,6 +2035,7 @@ PRIVATE_LAYOUTS = { } VALUE_DERIVES = { "T:LibreMetaverse.AgentSettings": "Clone, Debug, Eq, PartialEq", + "T:LibreMetaverse.AppearanceManager.WearableData": "Clone, Debug", "T:LibreMetaverse.AssetCacheSettings": "Clone, Debug, Eq, PartialEq", "T:LibreMetaverse.BuddyListEntry": "Clone, Debug, Eq, PartialEq", "T:LibreMetaverse.ConnectionSettings": "Clone, Eq, PartialEq", @@ -2390,6 +2534,8 @@ def render_type(item: dict, type_row: dict, member_rows: dict[str, dict[str, str # of receiving an invalid empty generated impl. if f"T:{base}" in NATIVE_TYPES: continue + if item["doc_id"] in NATIVE_DECLARATIONS: + continue if item["doc_id"] == "T:LibreMetaverse.GridClient" and base == "LibreMetaverse.IGridClient": continue mapped_arguments = [mapper.type(argument, None, set(names)) for argument in arguments] diff --git a/tools/generate_rust_mapping.py b/tools/generate_rust_mapping.py index 1b8528b..95a445f 100644 --- a/tools/generate_rust_mapping.py +++ b/tools/generate_rust_mapping.py @@ -989,6 +989,7 @@ def validate_generated_shims() -> None: "crate::asset_manager::", "crate::asset_material::", "crate::asset_models::", + "crate::appearance_manager::", "crate::byte_order::", "crate::attention_catalog::", "crate::binary::", @@ -1000,8 +1001,10 @@ def validate_generated_shims() -> None: "crate::inventory::", "crate::inventory_ais::", "crate::inventory_manager::", + "crate::current_outfit::", "crate::message_codec::", "crate::message_decoder::", + "crate::NameValue {", "crate::object_material::", "crate::notation::", "crate::packet_catalog::", @@ -1020,8 +1023,16 @@ def validate_generated_shims() -> None: if uses_standardized_or_native_path: continue if FAKE_BODY.search(body): - raise ValueError(f"generated shim returns a plausible fallback: {path.relative_to(ROOT)}") - raise ValueError(f"generated shim function does not use the standardized failure: {path.relative_to(ROOT)}") + excerpt = " ".join(body.strip().split())[:160] + raise ValueError( + "generated shim returns a plausible fallback: " + f"{path.relative_to(ROOT)}: {excerpt}" + ) + excerpt = " ".join(body.strip().split())[:160] + raise ValueError( + "generated shim function does not use the standardized failure: " + f"{path.relative_to(ROOT)}: {excerpt}" + ) def build_type_rows(catalog: dict) -> tuple[list[dict[str, str]], dict[str, str]]: