//! Narrow injected boundaries between orchestration and grid/world I/O. use crate::policy::AuthorizedAction; use crate::types::{GridEvent, GridEventKind, ToolCallOutcome}; #[cfg(feature = "live-grid")] use libremetaverse_types::UUID; use libremetaverse_types::compat::CancellationToken; #[cfg(feature = "live-grid")] use std::collections::BTreeMap; use std::error::Error; use std::fmt; use std::future::Future; use std::pin::Pin; #[cfg(feature = "live-grid")] use std::sync::Arc; #[cfg(feature = "live-grid")] use std::sync::atomic::{AtomicBool, Ordering}; #[cfg(feature = "live-grid")] use std::time::Duration; use tokio::sync::mpsc; pub type BackendFuture<'a, T> = Pin + Send + 'a>>; #[derive(Clone, Debug, Eq, PartialEq)] pub enum BackendError { Configuration { component: &'static str }, EventQueueClosed, RejectedMutation, Operation { operation: &'static str }, } impl fmt::Display for BackendError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Configuration { component } => { write!(formatter, "backend configuration failed for {component}") } Self::EventQueueClosed => formatter.write_str("grid-event owner closed its queue"), Self::RejectedMutation => { formatter.write_str("world mutation lacks an approved policy decision") } Self::Operation { operation } => { write!(formatter, "backend operation failed: {operation}") } } } } impl Error for BackendError {} /// One owned grid event source. Implementations send into the coordinator-owned /// bounded queue and must finish when cancellation is requested. pub trait GridBackend: Send + Sync + 'static { fn name(&self) -> &'static str; fn run( &self, events: mpsc::Sender, cancellation: CancellationToken, ) -> BackendFuture<'_, Result<(), BackendError>>; } /// Backend for an already authorized tool action. `AuthorizedAction` has no /// public constructor and binds the exact name, arguments, principal, budget, /// and one policy authorization. pub trait AuthorizedToolBackend: Send + Sync + 'static { fn apply( &self, action: AuthorizedAction, cancellation: CancellationToken, ) -> BackendFuture<'_, Result>; } /// Marker for world-mutating backends. Production mutations use the same /// opaque authorization boundary as every other tool backend. pub trait WorldMutator: AuthorizedToolBackend {} /// Inert deterministic backend used by the foundational offline service. /// /// It performs no login or network operation and is available without the /// live-grid dependency graph. #[derive(Clone, Copy, Debug, Default)] pub struct OfflineGridBackend; impl OfflineGridBackend { #[must_use] pub const fn new() -> Self { Self } } /// Live-feature composition owner that guarantees production adapters reuse /// the existing `libremetaverse` manager/client graph. #[cfg(feature = "live-grid")] pub struct LibremetaverseClientOwner { client: libremetaverse::GridClient, network: libremetaverse::NetworkManager, agent: Arc, delivery_generation: Arc>>, } /// Read-only adapter over caches already owned and populated by the shared /// native manager graph. Capture never sends a packet or requests a capability. #[cfg(feature = "live-grid")] #[derive(Clone)] pub struct LibremetaverseWorldSnapshotSource { client: libremetaverse::GridClient, agent: Arc, } #[cfg(feature = "live-grid")] #[derive(Clone)] pub struct LibremetaverseEmbodimentSink { client: libremetaverse::GridClient, agent: Arc, generation: Arc>>, } #[cfg(feature = "live-grid")] impl fmt::Debug for LibremetaverseClientOwner { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("LibremetaverseClientOwner") .field("client", &"[NATIVE CLIENT REDACTED]") .finish_non_exhaustive() } } /// Feature-gated session adapter that reuses the existing native /// `NetworkManager` login, event, disconnect, and logout lifecycle. #[cfg(feature = "live-grid")] #[derive(Clone)] pub struct LibremetaverseSessionBackend { network: libremetaverse::NetworkManager, agent: Arc, throttle: libremetaverse::AgentThrottle, login_url: String, first_name: String, last_name: String, password: crate::config::SecretString, interaction: Option, perception: Option, behavior: Option, delivery_generation: Arc>>, } #[cfg(feature = "live-grid")] impl fmt::Debug for LibremetaverseSessionBackend { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("LibremetaverseSessionBackend") .field("transport_connected", &self.network.native_connected()) .field("login_url", &"[REDACTED ENDPOINT]") .field("avatar", &"[REDACTED IDENTITY]") .field("password", &self.password) .finish_non_exhaustive() } } #[cfg(feature = "live-grid")] impl LibremetaverseClientOwner { /// Builds the shared client composition root without starting login or I/O. /// /// # Errors /// /// Returns a backend configuration error if the shared client defaults are invalid. pub fn new() -> Result { let client = libremetaverse::GridClientBuilder::default() .build() .map_err(|_| BackendError::Configuration { component: "libremetaverse client defaults", })?; // The compatibility client caches its network manager weakly. Retain the // composition root before managers register packet subscriptions on it. let network = client.network(); let _grid = client.grid(); let _terrain = client.terrain(); let agent = Arc::new( libremetaverse::AgentManager::new(Some(Arc::new(client.clone()))).map_err(|_| { BackendError::Configuration { component: "native agent manager", } })?, ); Ok(Self { client, network, agent, delivery_generation: Arc::new(std::sync::RwLock::new(None)), }) } #[must_use] pub const fn client(&self) -> &libremetaverse::GridClient { &self.client } /// Returns the one native agent manager owned by this composition root. /// `MetaCrate` adapters clone the `Arc`; they never construct a parallel /// compatibility manager graph. #[must_use] pub fn agent(&self) -> Arc { Arc::clone(&self.agent) } /// Creates the supervised live-session adapter without logging in. /// /// # Errors /// /// Rejects a malformed avatar identity before any network operation. pub fn session_backend( &self, connection: crate::config::GridConnection, ) -> Result { self.session_backend_inner(connection, None, None, None) } /// Creates a supervised live session whose generation owns exactly one /// native chat and IM subscription. /// /// # Errors /// /// Rejects a malformed avatar identity before any subscription or login. pub fn session_backend_with_interaction( &self, connection: crate::config::GridConnection, ingress: crate::interaction::InteractionIngress, ) -> Result { self.session_backend_inner(connection, Some(ingress), None, None) } /// Creates a session that generation-fences interaction and perception. /// /// # Errors /// /// Rejects a malformed avatar identity before any subscription or login. pub fn session_backend_with_services( &self, connection: crate::config::GridConnection, interaction: crate::interaction::InteractionIngress, perception: crate::perception::PerceptionIngress, ) -> Result { self.session_backend_inner(connection, Some(interaction), Some(perception), None) } /// Creates a session that generation-fences interaction, perception, and embodiment. /// /// # Errors /// /// Rejects a malformed avatar identity before any subscription or login. pub fn session_backend_with_agent_services( &self, connection: crate::config::GridConnection, interaction: crate::interaction::InteractionIngress, perception: crate::perception::PerceptionIngress, behavior: crate::behavior::BehaviorIngress, ) -> Result { self.session_backend_inner( connection, Some(interaction), Some(perception), Some(behavior), ) } fn session_backend_inner( &self, connection: crate::config::GridConnection, interaction: Option, perception: Option, behavior: Option, ) -> Result { let avatar = connection.avatar_name.trim(); let (first_name, last_name) = avatar .split_once(char::is_whitespace) .map_or((avatar, "Resident"), |(first, last)| (first, last.trim())); if first_name.is_empty() || last_name.is_empty() { return Err(BackendError::Configuration { component: "grid avatar identity", }); } Ok(LibremetaverseSessionBackend { network: self.network.clone(), agent: Arc::clone(&self.agent), throttle: libremetaverse::AgentThrottle::new_with_grid_client(self.client.clone()) .map_err(|_| BackendError::Configuration { component: "libremetaverse agent throttle", })?, login_url: connection.login_url.expose_url().to_owned(), first_name: first_name.to_owned(), last_name: last_name.to_owned(), password: connection.password, interaction, perception, behavior, delivery_generation: Arc::clone(&self.delivery_generation), }) } #[must_use] pub fn interaction_sink(&self) -> LibremetaverseInteractionSink { LibremetaverseInteractionSink { agent: Arc::clone(&self.agent), delivery_generation: Arc::clone(&self.delivery_generation), } } #[must_use] pub fn world_snapshot_source(&self) -> LibremetaverseWorldSnapshotSource { LibremetaverseWorldSnapshotSource { client: self.client.clone(), agent: Arc::clone(&self.agent), } } #[must_use] pub fn embodiment_sink(&self) -> LibremetaverseEmbodimentSink { LibremetaverseEmbodimentSink { client: self.client.clone(), agent: Arc::clone(&self.agent), generation: Arc::clone(&self.delivery_generation), } } } #[cfg(feature = "live-grid")] impl LibremetaverseWorldSnapshotSource { #[must_use] pub fn current_region_handle(&self) -> Option { self.client .network() .native_current_sim() .map(|simulator| simulator.handle) } // One atomic projection keeps native cache reads together. Terrain lookup // takes integral local coordinates, so finite region positions are floored. #[allow(clippy::too_many_lines, clippy::cast_possible_truncation)] fn capture_blocking( &self, generation: u64, ) -> Result { let network = self.client.network(); let simulator = network .native_current_sim() .ok_or(crate::perception::PerceptionError::SourceUnavailable)?; if !network.native_connected() || !simulator .native_is_event_queue_running(None) .unwrap_or(false) { return Err(crate::perception::PerceptionError::NotReady); } let agent_position = self.agent.sim_position(); let position = crate::perception::WorldPosition { x: f64::from(agent_position.x), y: f64::from(agent_position.y), z: f64::from(agent_position.z), }; let avatar_cache = simulator .objects_avatars .read() .unwrap_or_else(std::sync::PoisonError::into_inner); let avatars_truncated = avatar_cache.len() > 2_048; let mut avatars = BTreeMap::new(); for (local_id, avatar) in avatar_cache .iter() .filter(|(_, avatar)| avatar.id != UUID::zero()) { avatars.insert( *local_id, crate::perception::ObservedAvatar { id: avatar.id, name: crate::perception::sanitize_untrusted(&avatar.name()), position: native_position(avatar.position), }, ); if avatars.len() > 2_048 { avatars.pop_last(); } } drop(avatar_cache); let avatars = avatars.into_values().collect(); let object_cache = simulator .objects_primitives .read() .unwrap_or_else(std::sync::PoisonError::into_inner); let objects_truncated = object_cache.len() > 2_048; let mut objects = BTreeMap::new(); for (local_id, primitive) in object_cache .iter() .filter(|(_, primitive)| primitive.id != UUID::zero()) { let observed = { let (name, description) = primitive.properties.as_ref().map_or_else( || (String::new(), String::new()), |properties| { ( crate::perception::sanitize_untrusted(&properties.name), crate::perception::sanitize_untrusted(&properties.description), ) }, ); crate::perception::ObservedObject { id: primitive.id, local_id: primitive.local_id, name, description, hover_text: crate::perception::sanitize_untrusted(&primitive.text), position: native_position(primitive.position), scale: native_position(primitive.scale), is_attachment: primitive.is_attachment, } }; objects.insert(*local_id, observed); if objects.len() > 2_048 { objects.pop_last(); } } drop(object_cache); let objects = objects.into_values().collect(); let parcel = simulator .parcels .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .values() .filter(|parcel| { agent_position.x >= parcel.aabb_min.x && agent_position.x <= parcel.aabb_max.x && agent_position.y >= parcel.aabb_min.y && agent_position.y <= parcel.aabb_max.y }) .min_by_key(|parcel| parcel.local_id) .map(|parcel| crate::perception::ParcelSnapshot { name: crate::perception::sanitize_untrusted(&parcel.name), description: crate::perception::sanitize_untrusted(&parcel.desc), area: Some(parcel.area), category: Some(format!("{:?}", parcel.category)), }); if let Some(data) = self .client .environment() .region_environment() .and_then(|message| message.environment()) { // Read the environment data from the manager's last region message. let _ = data.day_cycle(); } let mut terrain_height = 0.0; let terrain_observed = agent_position.x.is_finite() && agent_position.y.is_finite() && simulator .terrain_height_at_point( agent_position.x.floor() as i32, agent_position.y.floor() as i32, &mut terrain_height, ) .unwrap_or(false); let environment = Some(crate::perception::EnvironmentSnapshot { water_height: Some(simulator.water_height), terrain_height: terrain_observed.then_some(terrain_height), sun_phase: None, region_access: Some(format!("{:?}", simulator.access)), }); let (inventory, landmarks, inventory_truncated) = native_inventory_metadata(&self.client); Ok(crate::perception::WorldSnapshot { generation, observed_unix_millis: crate::perception::unix_millis_now(), region_id: simulator.region_id, region_name: crate::perception::sanitize_untrusted(&simulator.name), agent: crate::perception::AgentSnapshot { id: self.agent.agent_id(), position: Some(position), health: Some(self.agent.health()), sitting_on_local_id: (self.agent.sitting_on() != 0) .then_some(self.agent.sitting_on()), }, avatars, avatars_truncated, objects, objects_truncated, parcel, environment, inventory, inventory_truncated, landmarks, }) } } #[cfg(feature = "live-grid")] impl crate::perception::WorldSnapshotSource for LibremetaverseWorldSnapshotSource { fn capture( &self, generation: u64, cancellation: CancellationToken, ) -> crate::perception::SnapshotFuture<'_> { let source = self.clone(); Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::perception::PerceptionError::Cancelled); } let worker = tokio::task::spawn_blocking(move || source.capture_blocking(generation)); tokio::select! { () = cancellation.cancelled() => Err(crate::perception::PerceptionError::Cancelled), result = worker => result .map_err(|_| crate::perception::PerceptionError::SourceUnavailable)?, } }) } } #[cfg(feature = "live-grid")] impl LibremetaverseEmbodimentSink { fn ensure_generation(&self, generation: u64) -> Result<(), crate::behavior::BehaviorError> { let current = *self .generation .read() .unwrap_or_else(std::sync::PoisonError::into_inner); if current == Some(generation) && self.client.network().native_connected() { Ok(()) } else { Err(crate::behavior::BehaviorError::NotReady) } } fn native_pose( &self, generation: u64, ) -> Result { self.ensure_generation(generation)?; let simulator = self .client .network() .native_current_sim() .ok_or(crate::behavior::BehaviorError::NotReady)?; let position = self.agent.sim_position(); let rotation = self.agent.sim_rotation(); let sin_yaw = 2.0 * f64::from(rotation.w * rotation.z + rotation.x * rotation.y); let cos_yaw = 1.0 - 2.0 * f64::from(rotation.y * rotation.y + rotation.z * rotation.z); Ok(crate::behavior::EmbodiedPose { generation, region_id: simulator.region_id, position: native_position(position), heading_degrees: sin_yaw.atan2(cos_yaw).to_degrees().rem_euclid(360.0), sitting: self.agent.sitting_on() != 0, }) } } #[cfg(feature = "live-grid")] #[allow(clippy::cast_possible_truncation)] impl crate::behavior::EmbodimentSink for LibremetaverseEmbodimentSink { fn current_pose( &self, generation: u64, cancellation: CancellationToken, ) -> crate::behavior::EmbodimentFuture<'_, crate::behavior::EmbodiedPose> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::behavior::BehaviorError::Cancelled); } self.native_pose(generation) }) } fn resolve_avatar( &self, generation: u64, avatar_id: UUID, cancellation: CancellationToken, ) -> crate::behavior::EmbodimentFuture<'_, crate::perception::WorldPosition> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::behavior::BehaviorError::Cancelled); } self.ensure_generation(generation)?; let simulator = self .client .network() .native_current_sim() .ok_or(crate::behavior::BehaviorError::NotReady)?; let object_position = simulator .objects_avatars .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .values() .find(|avatar| avatar.id == avatar_id) .map(|avatar| native_position(avatar.position)); let coarse_position = self .client .grid() .native_coarse_position(simulator.handle, avatar_id); object_position .or_else(|| { let origin = self.agent.sim_position(); coarse_position.map(|position| crate::perception::WorldPosition { x: unwrap_coarse_axis(position.x, origin.x), y: unwrap_coarse_axis(position.y, origin.y), z: f64::from(position.z), }) }) .ok_or(crate::behavior::BehaviorError::TargetUnavailable) }) } fn face_point( &self, generation: u64, point: crate::perception::WorldPosition, cancellation: CancellationToken, ) -> crate::behavior::EmbodimentFuture<'_, ()> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::behavior::BehaviorError::Cancelled); } self.ensure_generation(generation)?; self.agent .movement .turn_toward( libremetaverse_types::Vector3 { x: point.x as f32, y: point.y as f32, z: point.z as f32, }, Some(true), ) .map_err(|_| crate::behavior::BehaviorError::NativeOperation)?; Ok(()) }) } fn begin_walk( &self, generation: u64, point: crate::perception::WorldPosition, cancellation: CancellationToken, ) -> crate::behavior::EmbodimentFuture<'_, ()> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::behavior::BehaviorError::Cancelled); } self.ensure_generation(generation)?; self.agent .auto_pilot_local( point.x.round() as i32, point.y.round() as i32, point.z as f32, ) .map_err(|_| crate::behavior::BehaviorError::NativeOperation) }) } fn validate_walk_target( &self, generation: u64, point: crate::perception::WorldPosition, cancellation: CancellationToken, ) -> crate::behavior::EmbodimentFuture<'_, ()> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::behavior::BehaviorError::Cancelled); } self.ensure_generation(generation)?; let simulator = self .client .network() .native_current_sim() .ok_or(crate::behavior::BehaviorError::NotReady)?; let current_position = self.agent.sim_position(); let reported_bounds_contain_agent = f64::from(current_position.x) <= f64::from(simulator.size_x) && f64::from(current_position.y) <= f64::from(simulator.size_y); if point.x < 0.0 || point.y < 0.0 || (reported_bounds_contain_agent && (point.x > f64::from(simulator.size_x) || point.y > f64::from(simulator.size_y))) { return Err(crate::behavior::BehaviorError::RegionBoundary); } let parcels = simulator .parcels .read() .unwrap_or_else(std::sync::PoisonError::into_inner); let parcel_at = |x: f32, y: f32| { parcels .values() .filter(|parcel| { x >= parcel.aabb_min.x && x <= parcel.aabb_max.x && y >= parcel.aabb_min.y && y <= parcel.aabb_max.y }) .map(|parcel| parcel.local_id) .min() }; let current = parcel_at(current_position.x, current_position.y); let target = parcel_at(point.x as f32, point.y as f32); if current .zip(target) .is_some_and(|(current, target)| current != target) || (reported_bounds_contain_agent && (current.is_none() || target.is_none())) { return Err(crate::behavior::BehaviorError::RegionBoundary); } Ok(()) }) } fn stop( &self, generation: u64, cancellation: CancellationToken, ) -> crate::behavior::EmbodimentFuture<'_, ()> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::behavior::BehaviorError::Cancelled); } self.ensure_generation(generation)?; self.agent .auto_pilot_cancel() .map(|_| ()) .map_err(|_| crate::behavior::BehaviorError::NativeOperation) }) } fn sit( &self, generation: u64, cancellation: CancellationToken, ) -> crate::behavior::EmbodimentFuture<'_, ()> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::behavior::BehaviorError::Cancelled); } self.ensure_generation(generation)?; self.agent .sit() .map_err(|_| crate::behavior::BehaviorError::NativeOperation) }) } fn stand( &self, generation: u64, cancellation: CancellationToken, ) -> crate::behavior::EmbodimentFuture<'_, ()> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::behavior::BehaviorError::Cancelled); } self.ensure_generation(generation)?; self.agent .stand() .map(|_| ()) .map_err(|_| crate::behavior::BehaviorError::NativeOperation) }) } fn set_camera( &self, generation: u64, position: crate::perception::WorldPosition, target: crate::perception::WorldPosition, vertical_fov_degrees: f64, cancellation: CancellationToken, ) -> crate::behavior::EmbodimentFuture<'_, ()> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::behavior::BehaviorError::Cancelled); } self.ensure_generation(generation)?; self.agent .movement .camera .look_at_with_vector3_vector3( libremetaverse_types::Vector3 { x: position.x as f32, y: position.y as f32, z: position.z as f32, }, libremetaverse_types::Vector3 { x: target.x as f32, y: target.y as f32, z: target.z as f32, }, ) .map_err(|_| crate::behavior::BehaviorError::NativeOperation)?; self.agent .movement .set_fov_vertical_angle((vertical_fov_degrees as f32).to_radians()) .and_then(|()| self.agent.movement.send_update_with_boolean(Some(true))) .map_err(|_| crate::behavior::BehaviorError::NativeOperation) }) } fn reset_camera( &self, generation: u64, cancellation: CancellationToken, ) -> crate::behavior::EmbodimentFuture<'_, ()> { Box::pin(async move { if cancellation.is_cancellation_requested() { return Err(crate::behavior::BehaviorError::Cancelled); } self.ensure_generation(generation)?; native_reset_camera(&self.agent) .and_then(|()| self.agent.movement.send_update_with_boolean(Some(true))) .map_err(|_| crate::behavior::BehaviorError::NativeOperation) }) } } #[cfg(feature = "live-grid")] fn unwrap_coarse_axis(encoded: f32, origin: f32) -> f64 { f64::from(encoded + ((origin - encoded) / 256.0).round() * 256.0) } #[cfg(all(test, feature = "live-grid"))] mod live_grid_tests { #[test] fn coarse_avatar_axis_unwraps_to_nearest_varregion_tile() { assert!((super::unwrap_coarse_axis(164.0, 678.0) - 676.0).abs() < f64::EPSILON); assert!((super::unwrap_coarse_axis(26.0, 539.0) - 538.0).abs() < f64::EPSILON); } } #[cfg(feature = "live-grid")] fn native_position(value: libremetaverse_types::Vector3) -> crate::perception::WorldPosition { crate::perception::WorldPosition { x: f64::from(value.x), y: f64::from(value.y), z: f64::from(value.z), } } #[cfg(feature = "live-grid")] fn native_reset_camera(agent: &libremetaverse::AgentManager) -> Result<(), libremetaverse::Error> { let avatar = agent.sim_position(); let forward = libremetaverse_types::Vector3::mul_with_vector3_quaternion( libremetaverse_types::Vector3::unit_x(), agent.sim_rotation(), ); let position = libremetaverse_types::Vector3 { x: avatar.x, y: avatar.y, z: avatar.z + 1.6, }; let target = libremetaverse_types::Vector3 { x: position.x + forward.x * 4.0, y: position.y + forward.y * 4.0, z: position.z + forward.z * 4.0, }; agent .movement .camera .look_at_with_vector3_vector3(position, target)?; agent.movement.set_fov_vertical_angle(60.0_f32.to_radians()) } #[cfg(feature = "live-grid")] fn native_inventory_metadata( client: &libremetaverse::GridClient, ) -> ( Vec, Vec, bool, ) { let Some(store) = client.inventory().store() else { return (Vec::new(), Vec::new(), false); }; let mut inventory = Vec::new(); let mut landmarks = Vec::new(); let mut pending = vec![(store.root_node(), String::new())]; let mut visited = 0_usize; let mut truncated = false; while let Some((node, parent_path)) = pending.pop() { if visited >= 2_048 { truncated = true; break; } visited += 1; let path = node.data().map_or_else( || parent_path.clone(), |data| { let base = data.inventory_base(); let name = crate::perception::sanitize_untrusted(&base.name()); let path = crate::perception::sanitize_untrusted(&if parent_path.is_empty() { format!("/{name}") } else { format!("{parent_path}/{name}") }); if let Some(item) = data.inventory_item() { let modified_unix_millis = item .creation_date() .duration_since(std::time::UNIX_EPOCH) .ok() .and_then(|duration| u64::try_from(duration.as_millis()).ok()); inventory.push(crate::perception::InventoryMetadata { id: base.uuid(), name: name.clone(), kind: format!("{:?}", item.inventory_type()), path: path.clone(), modified_unix_millis, }); if item.asset_type() == libremetaverse_types::AssetType::Landmark { landmarks.push(crate::perception::LandmarkMetadata { id: base.uuid(), name, // Region is stored in the landmark asset body. The // read-only metadata tool never downloads or decodes it. region_id: None, }); } } path }, ); let mut children = node.nodes().values(); children.sort_by_key(|child| { child.data().map_or_else(String::new, |value| { crate::perception::sanitize_untrusted(&value.inventory_base().name()) }) }); for child in children.into_iter().rev() { pending.push((child, path.clone())); } } (inventory, landmarks, truncated || !pending.is_empty()) } #[cfg(feature = "live-grid")] #[derive(Clone)] pub struct LibremetaverseInteractionSink { agent: Arc, delivery_generation: Arc>>, } #[cfg(feature = "live-grid")] impl fmt::Debug for LibremetaverseInteractionSink { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("LibremetaverseInteractionSink") .field("agent", &"[NATIVE AGENT MANAGER]") .finish_non_exhaustive() } } #[cfg(feature = "live-grid")] impl crate::interaction::InteractionSink for LibremetaverseInteractionSink { fn deliver( &self, outbound: crate::interaction::OutboundInteraction, cancellation: CancellationToken, ) -> crate::interaction::DeliveryFuture<'_> { let result = if cancellation.is_cancellation_requested() { Err(crate::interaction::InteractionDeliveryError::Disconnected) } else { let generation = self .delivery_generation .read() .unwrap_or_else(std::sync::PoisonError::into_inner); if *generation == Some(outbound.generation) { match outbound.channel { crate::interaction::InteractionChannel::PublicChat => self.agent.chat( outbound.body.into_inner(), 0, libremetaverse::ChatType::Normal, Some(false), ), crate::interaction::InteractionChannel::DirectIm => { self.agent.instant_message_with_uuid_string( outbound.recipient_id, outbound.body.into_inner(), ) } } .map_err(|_| crate::interaction::InteractionDeliveryError::Failed) } else { Err(crate::interaction::InteractionDeliveryError::Disconnected) } }; Box::pin(async move { result }) } } #[cfg(feature = "live-grid")] struct LibremetaverseSession { generation: u64, network: libremetaverse::NetworkManager, signals: mpsc::Receiver, subscriptions: Vec, interaction: Option, perception: Option, behavior: Option, delivery_generation: Arc>>, } #[cfg(feature = "live-grid")] impl crate::session::GridSession for LibremetaverseSession { fn generation(&self) -> u64 { self.generation } fn next_signal( &mut self, cancellation: CancellationToken, ) -> crate::session::SessionFuture<'_, crate::session::SessionSignal> { Box::pin(async move { tokio::select! { signal = self.signals.recv() => signal.unwrap_or_else(|| { crate::session::SessionSignal::Disconnected( crate::session::SessionFailure::new( crate::session::SessionFailureKind::TransientTransport, ), ) }), () = cancellation.cancelled() => crate::session::SessionSignal::Disconnected( crate::session::SessionFailure::new( crate::session::SessionFailureKind::TransientTransport, ), ), } }) } fn logout( mut self: Box, cancellation: CancellationToken, ) -> crate::session::SessionFuture<'static, Result<(), crate::session::SessionFailure>> { Box::pin(async move { if let Some(interaction) = &self.interaction { let _ = interaction.try_disconnected(); } if let Some(perception) = &self.perception { perception.disconnected(); } if let Some(behavior) = &self.behavior { behavior.disconnected(); tokio::task::yield_now().await; } *self .delivery_generation .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = None; self.subscriptions.clear(); self.network .native_logout_async(Some(cancellation)) .await .map_err(|_| { crate::session::SessionFailure::new( crate::session::SessionFailureKind::TransientTransport, ) }) }) } } #[cfg(feature = "live-grid")] impl crate::session::GridSessionBackend for LibremetaverseSessionBackend { #[allow(clippy::too_many_lines)] fn login( &self, generation: u64, cancellation: CancellationToken, ) -> crate::session::SessionFuture< '_, Result, crate::session::SessionFailure>, > { Box::pin(async move { *self .delivery_generation .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = None; let mut start = "last"; loop { let mut params = self .network .native_default_login_params( self.first_name.clone(), self.last_name.clone(), self.password.expose_secret().to_owned(), "MetaCrate".to_owned(), env!("CARGO_PKG_VERSION").to_owned(), ) .map_err(|_| { crate::session::SessionFailure::new( crate::session::SessionFailureKind::InvalidConfiguration, ) })?; params.uri.clone_from(&self.login_url); start.clone_into(&mut params.start); let logged_in = self .network .native_login(params, Some(cancellation.clone())) .await .map_err(|_| { crate::session::SessionFailure::new( crate::session::SessionFailureKind::TransientTransport, ) })?; if logged_in { let simulator = self.network.current_sim().ok_or_else(|| { crate::session::SessionFailure::new( crate::session::SessionFailureKind::TransientTransport, ) })?; self.throttle .set_with_simulator(Some(simulator)) .map_err(|_| { crate::session::SessionFailure::new( crate::session::SessionFailureKind::TransientTransport, ) })?; native_reset_camera(&self.agent).map_err(|_| { crate::session::SessionFailure::new( crate::session::SessionFailureKind::TransientTransport, ) })?; break; } let error_key = self.network.native_login_error_key(); let message = self.network.native_login_message(); if start == "last" && native_last_location_unavailable(&message) { start = "home"; continue; } return Err(classify_native_login_failure(&error_key, &message)); } // Native login has already installed the current simulator and // parsed inventory state. The existing GridClient managers retain // world/inventory and movement-worker ownership; this generation // adds only one disconnect and one readiness subscription. let (sender, signals) = mpsc::channel(8); let disconnected_sender = sender.clone(); let disconnected_interaction = self.interaction.clone(); let disconnected_perception = self.perception.clone(); let disconnected_behavior = self.behavior.clone(); let disconnected_generation = Arc::clone(&self.delivery_generation); let disconnected = self .network .native_subscribe_disconnected(Arc::new(move |event| { *disconnected_generation .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = None; if let Some(interaction) = &disconnected_interaction { let _ = interaction.try_disconnected(); } if let Some(perception) = &disconnected_perception { perception.disconnected(); } if let Some(behavior) = &disconnected_behavior { behavior.disconnected(); } let kind = match event.reason() { libremetaverse::NetworkManagerDisconnectType::NetworkTimeout | libremetaverse::NetworkManagerDisconnectType::ClientInitiated => { crate::session::SessionFailureKind::TransientTransport } libremetaverse::NetworkManagerDisconnectType::ServerInitiated => { crate::session::SessionFailureKind::Kicked } libremetaverse::NetworkManagerDisconnectType::SimShutdown => { crate::session::SessionFailureKind::Maintenance } }; let _ = disconnected_sender.try_send(crate::session::SessionSignal::Disconnected( crate::session::SessionFailure::new(kind), )); })); let ready_sender = sender.clone(); let ready_interaction = self.interaction.clone(); let ready_perception = self.perception.clone(); let ready_behavior = self.behavior.clone(); let ready_network = self.network.clone(); let ready_agent = Arc::clone(&self.agent); let ready_generation = Arc::clone(&self.delivery_generation); let ready_once = Arc::new(AtomicBool::new(false)); let callback_ready = Arc::clone(&ready_once); let ready = self .network .native_subscribe_event_queue_running(Arc::new(move |_| { if !callback_ready.swap(true, Ordering::AcqRel) { *ready_generation .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation); if let Some(interaction) = &ready_interaction { let _ = interaction.try_connected(generation, ready_agent.agent_id()); } if let (Some(perception), Some(simulator)) = (&ready_perception, ready_network.native_current_sim()) { let _ = perception.connected(generation, simulator.region_id); } if let (Some(behavior), Some(simulator)) = (&ready_behavior, ready_network.native_current_sim()) { let _ = behavior.connected(generation, simulator.region_id); } let _ = ready_sender.try_send(crate::session::SessionSignal::Ready); } })); let mut subscriptions = vec![disconnected, ready]; if let Some(perception) = &self.perception { let crossing_perception = perception.clone(); let crossing_behavior = self.behavior.clone(); let crossing_network = self.network.clone(); subscriptions.push(self.network.native_subscribe_sim_changed(Arc::new( move |_| { if let Some(simulator) = crossing_network.native_current_sim() { let _ = crossing_perception.region_changed(generation, simulator.region_id); if let Some(behavior) = &crossing_behavior { let _ = behavior.region_changed(generation, simulator.region_id); } } }, ))); } if let Some(interaction) = &self.interaction { let chat_ingress = interaction.clone(); let chat_agent = Arc::clone(&self.agent); subscriptions.push(self.agent.subscribe_chat_from_simulator(Arc::new( move |event| { if let Some(inbound) = native_public_interaction(&chat_agent, &event) { let _ = chat_ingress.try_submit(inbound); } }, ))); let im_ingress = interaction.clone(); let im_agent = Arc::clone(&self.agent); subscriptions.push(self.agent.subscribe_im(Arc::new(move |event| { if let Some(inbound) = native_im_interaction(&im_agent, &event) { let _ = im_ingress.try_submit(inbound); } }))); } if !self.network.native_connected() { let _ = sender.try_send(crate::session::SessionSignal::Disconnected( crate::session::SessionFailure::new( crate::session::SessionFailureKind::TransientTransport, ), )); } else if self .network .native_current_sim() .and_then(|simulator| simulator.native_is_event_queue_running(None).ok()) .unwrap_or(false) && !ready_once.swap(true, Ordering::AcqRel) { *self .delivery_generation .write() .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(generation); if let Some(interaction) = &self.interaction { let _ = interaction.try_connected(generation, self.agent.agent_id()); } if let (Some(perception), Some(simulator)) = (&self.perception, self.network.native_current_sim()) { let _ = perception.connected(generation, simulator.region_id); } if let (Some(behavior), Some(simulator)) = (&self.behavior, self.network.native_current_sim()) { let _ = behavior.connected(generation, simulator.region_id); } let _ = sender.try_send(crate::session::SessionSignal::Ready); } let session: Box = Box::new(LibremetaverseSession { generation, network: self.network.clone(), signals, subscriptions, interaction: self.interaction.clone(), perception: self.perception.clone(), behavior: self.behavior.clone(), delivery_generation: Arc::clone(&self.delivery_generation), }); Ok(session) }) } fn flush_audit( &self, _cancellation: CancellationToken, ) -> crate::session::SessionFuture<'_, Result<(), crate::session::SessionFailure>> { Box::pin(async { Ok(()) }) } } #[cfg(feature = "live-grid")] fn native_public_interaction( agent: &libremetaverse::AgentManager, event: &libremetaverse::ChatEventArgs, ) -> Option { if event.audible_level() == libremetaverse::ChatAudibleLevel::Not || !matches!( event.type_(), libremetaverse::ChatType::Normal | libremetaverse::ChatType::Whisper | libremetaverse::ChatType::Shout ) { return None; } let source = match event.source_type() { libremetaverse::ChatSourceType::Agent => crate::interaction::InboundSource::Avatar, libremetaverse::ChatSourceType::Object => crate::interaction::InboundSource::Object, libremetaverse::ChatSourceType::System => crate::interaction::InboundSource::System, }; let source_id = event.source_id(); let owner_id = event.owner_id(); let message = event.message(); let name = event.from_name(); let bucket = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or(Duration::ZERO) .as_secs() / 5; let delivery_id = native_delivery_id( "chat", &[ &source_id.to_string(), &owner_id.to_string(), &bucket.to_string(), &message, ], ); crate::interaction::InboundInteraction::new( delivery_id, source_id, name, crate::interaction::InteractionChannel::PublicChat, source, crate::interaction::ImDialogKind::OneToOneMessage, event.audible_level() == libremetaverse::ChatAudibleLevel::Fully, native_muted(agent, source_id, owner_id), message, ) .ok() } #[cfg(feature = "live-grid")] fn native_im_interaction( agent: &libremetaverse::AgentManager, event: &libremetaverse::InstantMessageEventArgs, ) -> Option { let im = event.im(); let dialog = match im.dialog { libremetaverse::InstantMessageDialog::MessageFromAgent if !im.group_im => { crate::interaction::ImDialogKind::OneToOneMessage } libremetaverse::InstantMessageDialog::StartTyping | libremetaverse::InstantMessageDialog::StopTyping => { crate::interaction::ImDialogKind::Typing } libremetaverse::InstantMessageDialog::SessionSend => { crate::interaction::ImDialogKind::GroupOrConference } _ => crate::interaction::ImDialogKind::Unsupported, }; let timestamp = im .timestamp .duration_since(std::time::UNIX_EPOCH) .unwrap_or(Duration::ZERO) .as_millis() .to_string(); let delivery_id = native_delivery_id( "im", &[ &im.from_agent_id.to_string(), &im.im_session_id.to_string(), ×tamp, &im.message, ], ); crate::interaction::InboundInteraction::new( delivery_id, im.from_agent_id, im.from_agent_name, crate::interaction::InteractionChannel::DirectIm, crate::interaction::InboundSource::Avatar, dialog, false, native_muted(agent, im.from_agent_id, UUID::zero()), im.message, ) .ok() } #[cfg(feature = "live-grid")] fn native_muted(agent: &libremetaverse::AgentManager, source: UUID, owner: UUID) -> bool { agent .mute_list .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .values() .any(|entry| entry.id == source || (owner != UUID::zero() && entry.id == owner)) } #[cfg(feature = "live-grid")] fn native_delivery_id(prefix: &str, fields: &[&str]) -> String { use sha2::Digest as _; use std::fmt::Write as _; let mut digest = sha2::Sha256::new(); for field in fields { digest.update(field.as_bytes()); digest.update([0]); } let mut output = String::with_capacity(prefix.len() + 65); output.push_str(prefix); output.push('-'); for byte in digest.finalize() { let _ = write!(output, "{byte:02x}"); } output } #[cfg(feature = "live-grid")] fn classify_native_login_failure(error_key: &str, message: &str) -> crate::session::SessionFailure { let normalized = error_key.trim().to_ascii_lowercase(); let kind = if native_last_location_unavailable(message) || message .trim() .to_ascii_lowercase() .contains("already logged in") { crate::session::SessionFailureKind::ServerFailure } else if matches!( normalized.as_str(), "key" | "password" | "credential" | "account" | "username" | "user" ) { crate::session::SessionFailureKind::InvalidCredentials } else if normalized == "canceled" { crate::session::SessionFailureKind::TransientTransport } else { crate::session::SessionFailureKind::ServerFailure }; crate::session::SessionFailure::new(kind) } #[cfg(feature = "live-grid")] fn native_last_location_unavailable(message: &str) -> bool { let message = message.trim().to_ascii_lowercase(); message.contains("failed to verify user presence") || message.contains("access denied to region") } impl GridBackend for OfflineGridBackend { fn name(&self) -> &'static str { "offline-fake" } fn run( &self, events: mpsc::Sender, cancellation: CancellationToken, ) -> BackendFuture<'_, Result<(), BackendError>> { Box::pin(async move { let ready = GridEvent { sequence: 1, kind: GridEventKind::BackendReady, }; tokio::select! { () = cancellation.cancelled() => return Ok(()), result = events.send(ready) => { result.map_err(|_| BackendError::EventQueueClosed)?; } } cancellation.cancelled().await; Ok(()) }) } } #[cfg(test)] mod tests { use super::*; use libremetaverse_types::compat::CancellationTokenSource; #[tokio::test] async fn offline_backend_stops_on_cancellation() { let backend = OfflineGridBackend::new(); let (sender, mut receiver) = mpsc::channel(1); let cancellation = CancellationTokenSource::new(); let run = backend.run(sender, cancellation.token()); tokio::pin!(run); tokio::select! { event = receiver.recv() => { assert_eq!(event.expect("ready event").kind, GridEventKind::BackendReady); } result = &mut run => panic!("backend exited before ready: {result:?}"), } cancellation.cancel(); run.await.expect("clean cancellation"); } #[cfg(feature = "live-grid")] #[test] fn stale_presence_is_retryable_without_masking_bad_credentials() { assert!(native_last_location_unavailable( "Failed to verify user presence in the grid, access denied to region", )); assert_eq!( classify_native_login_failure( "account", "Failed to verify user presence in the grid, access denied to region", ) .kind(), crate::session::SessionFailureKind::ServerFailure, ); assert_eq!( classify_native_login_failure("account", "Invalid credentials").kind(), crate::session::SessionFailureKind::InvalidCredentials, ); } }