Files
MetaCrate/crates/metacrate-grid-agent/src/backend.rs
Chili Palmer f6f5abe464
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled
Fix extended-region scene rendering and delivery
2026-08-22 13:45:31 +02:00

1522 lines
57 KiB
Rust

//! 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<Box<dyn Future<Output = T> + 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<GridEvent>,
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<ToolCallOutcome, BackendError>>;
}
/// 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<libremetaverse::AgentManager>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
/// 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<libremetaverse::AgentManager>,
}
#[cfg(feature = "live-grid")]
#[derive(Clone)]
pub struct LibremetaverseEmbodimentSink {
client: libremetaverse::GridClient,
agent: Arc<libremetaverse::AgentManager>,
generation: Arc<std::sync::RwLock<Option<u64>>>,
}
#[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<libremetaverse::AgentManager>,
throttle: libremetaverse::AgentThrottle,
login_url: String,
first_name: String,
last_name: String,
password: crate::config::SecretString,
interaction: Option<crate::interaction::InteractionIngress>,
perception: Option<crate::perception::PerceptionIngress>,
behavior: Option<crate::behavior::BehaviorIngress>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
#[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<Self, BackendError> {
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<libremetaverse::AgentManager> {
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<LibremetaverseSessionBackend, BackendError> {
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<LibremetaverseSessionBackend, BackendError> {
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<LibremetaverseSessionBackend, BackendError> {
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<LibremetaverseSessionBackend, BackendError> {
self.session_backend_inner(
connection,
Some(interaction),
Some(perception),
Some(behavior),
)
}
fn session_backend_inner(
&self,
connection: crate::config::GridConnection,
interaction: Option<crate::interaction::InteractionIngress>,
perception: Option<crate::perception::PerceptionIngress>,
behavior: Option<crate::behavior::BehaviorIngress>,
) -> Result<LibremetaverseSessionBackend, BackendError> {
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<u64> {
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<crate::perception::WorldSnapshot, crate::perception::PerceptionError> {
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<crate::behavior::EmbodiedPose, crate::behavior::BehaviorError> {
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<crate::perception::InventoryMetadata>,
Vec<crate::perception::LandmarkMetadata>,
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<libremetaverse::AgentManager>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
#[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<crate::session::SessionSignal>,
subscriptions: Vec<libremetaverse_types::compat::Subscription>,
interaction: Option<crate::interaction::InteractionIngress>,
perception: Option<crate::perception::PerceptionIngress>,
behavior: Option<crate::behavior::BehaviorIngress>,
delivery_generation: Arc<std::sync::RwLock<Option<u64>>>,
}
#[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<Self>,
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<Box<dyn crate::session::GridSession>, 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<dyn crate::session::GridSession> = 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<crate::interaction::InboundInteraction> {
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<crate::interaction::InboundInteraction> {
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(),
&timestamp,
&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<GridEvent>,
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,
);
}
}