From 5a533dfa00e51ab041ba4e227cd2ff68b372eb3b Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 23 Aug 2026 18:06:58 +0200 Subject: [PATCH] Fix bounded avatar appearance recovery after login --- crates/libremetaverse/CHANGES.md | 16 + .../libremetaverse/src/appearance_manager.rs | 537 +++++++++++++++++- crates/libremetaverse/src/asset_manager.rs | 57 +- crates/libremetaverse/src/lib.rs | 3 + crates/libremetaverse/src/network_manager.rs | 8 + crates/metacrate-grid-agent/src/main.rs | 68 +++ crates/metacrate-grid-agent/src/vision.rs | 135 ++++- .../tests/live_llm_vision.rs | 69 +++ .../tests/live_renderer.rs | 262 +++++++++ 9 files changed, 1114 insertions(+), 41 deletions(-) diff --git a/crates/libremetaverse/CHANGES.md b/crates/libremetaverse/CHANGES.md index fd825db..2c017d6 100644 --- a/crates/libremetaverse/CHANGES.md +++ b/crates/libremetaverse/CHANGES.md @@ -3,6 +3,22 @@ This file records intentional differences from the C# LibreMetaverse source. Preserve or re-evaluate these changes when importing a newer upstream version. +## Bounded login appearance recovery + +- A successful login now consumes `AgentSettings.send_appearance`, initializes the shared + `AppearanceManager`, and starts one generation-fenced appearance request after the simulator is + ready. +- Self `AvatarAppearance` acknowledgements are accepted as complete only when required baked + texture IDs, visual parameters, and the expected COF version are present. Transient failures use + bounded delayed forced rebakes; disconnects and simulator changes cancel stale work. +- COF attachments are compared with simulator child prims by `AttachItemID`; only missing items are + restored through the existing attachment API. Non-secret structured recovery events expose the + bake path, counts, serial, retries, acknowledgement, and elapsed time. +- Baked-texture capability responses are decoded as LLSD, as required by the simulator protocol, + instead of incorrectly treating their uploader and completion maps as JSON. + +Files: `src/network_manager.rs`, `src/appearance_manager.rs`, `src/asset_manager.rs`. + ## OpenSim varregion dimensions - `GridRegion` retains the optional `MapBlockReply.Size` dimensions. Missing or zero dimensions diff --git a/crates/libremetaverse/src/appearance_manager.rs b/crates/libremetaverse/src/appearance_manager.rs index ee3112d..524f4c6 100644 --- a/crates/libremetaverse/src/appearance_manager.rs +++ b/crates/libremetaverse/src/appearance_manager.rs @@ -29,13 +29,17 @@ use std::collections::{HashMap, HashSet, VecDeque}; use std::fmt; use std::hash::{Hash, Hasher}; use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU8, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; +use std::time::{Duration, Instant}; pub(crate) const ATTACHMENT_ADD: u8 = 0x80; const MAX_WEARABLE_LAYERS: usize = 60; const MAX_FOLDER_TRAVERSAL: usize = 256; const MAX_COF_ENTRIES: usize = 512; +const APPEARANCE_RECOVERY_ATTEMPTS: u32 = 3; +const APPEARANCE_ACK_TIMEOUT: Duration = Duration::from_secs(10); +const ATTACHMENT_SETTLE_TIMEOUT: Duration = Duration::from_secs(5); fn mutex(lock: &Mutex) -> std::sync::MutexGuard<'_, T> { lock.lock() @@ -136,6 +140,69 @@ struct AppearanceEvents { appearance_set: EventRegistry, cached_bakes_reply: EventRegistry, rebake_avatar_requested: EventRegistry, + recovery: EventRegistry, +} + +/// A bounded, non-secret appearance recovery phase suitable for diagnostics. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AppearanceRecoveryPhase { + LoginReady, + OutfitReady, + BakeComplete, + AppearanceSent, + AppearanceAcknowledged, + AttachmentsRepaired, + Retrying, + Complete, + Failed, + Cancelled, +} + +/// The operation boundary most recently reached by an appearance request. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(u8)] +pub enum AppearanceRecoveryStage { + Starting, + Outfit, + Cache, + Assets, + Bake, + Publish, + Acknowledgement, + Attachments, +} + +impl AppearanceRecoveryStage { + const fn from_u8(value: u8) -> Self { + match value { + 1 => Self::Outfit, + 2 => Self::Cache, + 3 => Self::Assets, + 4 => Self::Bake, + 5 => Self::Publish, + 6 => Self::Acknowledgement, + 7 => Self::Attachments, + _ => Self::Starting, + } + } +} + +/// Structured progress from automatic login appearance recovery. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct AppearanceRecoveryEvent { + pub phase: AppearanceRecoveryPhase, + pub stage: AppearanceRecoveryStage, + pub generation: u64, + pub retry: u32, + pub elapsed_millis: u64, + pub cof_version: i32, + pub wearable_count: usize, + pub attachment_count: usize, + pub missing_attachment_count: usize, + pub cached_bake_count: usize, + pub server_baking: bool, + pub appearance_serial: u64, + pub acknowledged: bool, } pub(crate) struct AppearanceManagerInner { @@ -158,6 +225,13 @@ pub(crate) struct AppearanceManagerInner { appearance_serial: AtomicU64, appearance_cancel: Mutex>, appearance_gate: Arc, + recovery_stage: AtomicU8, + recovery_generation: AtomicU64, + logged_in: AtomicBool, + acknowledgement_sequence: AtomicU64, + acknowledged_cof_version: AtomicI32, + acknowledged_complete: AtomicBool, + acknowledgement_notify: tokio::sync::Notify, events: AppearanceEvents, login_callback: Mutex>, network_subscriptions: Mutex>, @@ -255,11 +329,35 @@ fn handle_avatar_appearance(inner: &Arc, data: Vec) if packet.sender.id != network.native_agent_id() { return; } + let expected_cof = inner.last_cof_version.load(Ordering::Acquire); if let Some(appearance) = packet.appearance_data.first() { inner .last_cof_version .fetch_max(appearance.cof_version, Ordering::AcqRel); } + let cof_version = packet + .appearance_data + .first() + .map_or(-1, |appearance| appearance.cof_version); + let complete = cof_version >= expected_cof && complete_baked_appearance(&packet); + inner + .acknowledged_cof_version + .store(cof_version, Ordering::Release); + inner + .acknowledged_complete + .store(complete, Ordering::Release); + inner + .acknowledgement_sequence + .fetch_add(1, Ordering::AcqRel); + inner.acknowledgement_notify.notify_waiters(); + emit_recovery( + inner, + AppearanceRecoveryPhase::AppearanceAcknowledged, + 0, + Instant::now(), + inner.recovery_generation.load(Ordering::Acquire), + complete, + ); } fn handle_wearables_update(inner: &Arc, data: Vec) { @@ -365,6 +463,328 @@ fn spawn_appearance_request(inner: &Arc, force_rebake: b }); } +fn install_recovery_lifecycle( + inner: &Arc, + client: &GridClient, +) -> Result<(), Error> { + let network = client.native_network()?; + let weak = Arc::downgrade(inner); + let login = network.native_subscribe_login_progress(Arc::new(move |event| { + let Some(inner) = weak.upgrade() else { + return; + }; + if event.status() == crate::LoginStatus::Success + && inner + .client + .upgrade() + .is_some_and(|client| client.settings_ref().agent.send_appearance) + && !inner.logged_in.swap(true, Ordering::AcqRel) + { + start_recovery(&inner, "appearance-login"); + } + })); + let weak = Arc::downgrade(inner); + let disconnected = network.native_subscribe_disconnected(Arc::new(move |_| { + if let Some(inner) = weak.upgrade() { + inner.logged_in.store(false, Ordering::Release); + cancel_recovery(&inner); + } + })); + let weak = Arc::downgrade(inner); + let changed = network.native_subscribe_sim_changed(Arc::new(move |_| { + if let Some(inner) = weak.upgrade() + && inner.logged_in.load(Ordering::Acquire) + { + start_recovery(&inner, "appearance-region-change"); + } + })); + mutex(&inner.network_subscriptions).extend([login, disconnected, changed]); + Ok(()) +} + +fn cancel_recovery(inner: &AppearanceManagerInner) { + inner.recovery_generation.fetch_add(1, Ordering::AcqRel); + if let Some(source) = mutex(&inner.appearance_cancel).take() { + source.cancel(); + } +} + +fn start_recovery(inner: &Arc, name: &str) { + cancel_recovery(inner); + let generation = inner.recovery_generation.load(Ordering::Acquire); + let manager = AppearanceManager::native_from_inner(Arc::clone(inner)); + let _ = std::thread::Builder::new() + .name(name.to_owned()) + .spawn(move || { + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return; + }; + runtime.block_on(run_recovery(manager, generation)); + }); +} + +async fn run_recovery(manager: AppearanceManager, generation: u64) { + let started = Instant::now(); + let mut requested_attachments = HashSet::new(); + emit_recovery( + &manager.inner, + AppearanceRecoveryPhase::LoginReady, + 0, + started, + generation, + false, + ); + for retry in 0..APPEARANCE_RECOVERY_ATTEMPTS { + if !recovery_current(&manager.inner, generation) { + emit_recovery( + &manager.inner, + AppearanceRecoveryPhase::Cancelled, + retry, + started, + generation, + false, + ); + return; + } + if retry > 0 { + emit_recovery( + &manager.inner, + AppearanceRecoveryPhase::Retrying, + retry, + started, + generation, + false, + ); + tokio::time::sleep(Duration::from_secs((1_u64 << retry).saturating_sub(1))).await; + } + let acknowledgement = manager + .inner + .acknowledgement_sequence + .load(Ordering::Acquire); + if manager + .native_request_set_appearance(retry != 0) + .await + .is_err() + { + continue; + } + if !recovery_current(&manager.inner, generation) { + continue; + } + for phase in [ + AppearanceRecoveryPhase::OutfitReady, + AppearanceRecoveryPhase::BakeComplete, + AppearanceRecoveryPhase::AppearanceSent, + ] { + emit_recovery(&manager.inner, phase, retry, started, generation, false); + } + set_recovery_stage(&manager.inner, AppearanceRecoveryStage::Attachments); + let repaired = repair_missing_attachments(&manager, &mut requested_attachments); + if repaired != 0 { + emit_recovery( + &manager.inner, + AppearanceRecoveryPhase::AttachmentsRepaired, + retry, + started, + generation, + false, + ); + } + set_recovery_stage(&manager.inner, AppearanceRecoveryStage::Acknowledgement); + if !wait_for_acknowledgement(&manager.inner, generation, acknowledgement).await { + continue; + } + set_recovery_stage(&manager.inner, AppearanceRecoveryStage::Attachments); + if wait_for_attachments(&manager.inner, generation).await { + emit_recovery( + &manager.inner, + AppearanceRecoveryPhase::Complete, + retry, + started, + generation, + true, + ); + return; + } + } + emit_recovery( + &manager.inner, + AppearanceRecoveryPhase::Failed, + APPEARANCE_RECOVERY_ATTEMPTS, + started, + generation, + false, + ); +} + +fn recovery_current(inner: &AppearanceManagerInner, generation: u64) -> bool { + !inner.disposed.load(Ordering::Acquire) + && inner.logged_in.load(Ordering::Acquire) + && inner.recovery_generation.load(Ordering::Acquire) == generation +} + +fn set_recovery_stage(inner: &AppearanceManagerInner, stage: AppearanceRecoveryStage) { + inner.recovery_stage.store(stage as u8, Ordering::Release); +} + +async fn wait_for_acknowledgement( + inner: &AppearanceManagerInner, + generation: u64, + previous_sequence: u64, +) -> bool { + tokio::time::timeout(APPEARANCE_ACK_TIMEOUT, async { + loop { + let notified = inner.acknowledgement_notify.notified(); + if inner.acknowledgement_sequence.load(Ordering::Acquire) > previous_sequence + && inner.acknowledged_complete.load(Ordering::Acquire) + && inner.acknowledged_cof_version.load(Ordering::Acquire) + >= inner.last_cof_version.load(Ordering::Acquire) + { + return true; + } + if !recovery_current(inner, generation) { + return false; + } + notified.await; + } + }) + .await + .unwrap_or(false) +} + +async fn wait_for_attachments(inner: &AppearanceManagerInner, generation: u64) -> bool { + let deadline = tokio::time::Instant::now() + ATTACHMENT_SETTLE_TIMEOUT; + loop { + if missing_attachments(inner).is_empty() { + return true; + } + if !recovery_current(inner, generation) || tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +fn repair_missing_attachments(manager: &AppearanceManager, requested: &mut HashSet) -> usize { + let missing = missing_attachments(&manager.inner); + let mut repaired = 0; + for attachment in missing { + let item_id = attachment.item.base.uuid(); + if requested.insert(item_id) { + if manager + .native_attach(attachment.item, attachment.point, false) + .is_ok() + { + repaired += 1; + } else { + requested.remove(&item_id); + } + } + } + repaired +} + +fn missing_attachments(inner: &AppearanceManagerInner) -> Vec { + let desired = read(&inner.attachments).clone(); + if desired.is_empty() { + return Vec::new(); + } + let Some(client) = inner.client.upgrade() else { + return desired; + }; + let Some(simulator) = client + .native_network() + .ok() + .and_then(|network| network.native_current_sim()) + else { + return desired; + }; + let avatar_local_id = client + .cached_agent_manager_inner() + .map_or(0, |agent| agent.avatar_local_id()); + if avatar_local_id == 0 { + return desired; + } + let present: HashSet<_> = read(&simulator.objects_primitives) + .values() + .filter(|primitive| primitive.is_attachment && primitive.parent_id == avatar_local_id) + .filter_map(|primitive| { + crate::appearance::CurrentOutfitFolder::native_get_attachment_item_id(primitive.clone()) + .ok() + }) + .filter(|item| *item != UUID::zero()) + .collect(); + desired + .into_iter() + .filter(|attachment| !present.contains(&attachment.item.base.uuid())) + .collect() +} + +fn complete_baked_appearance(packet: &crate::packets::AvatarAppearancePacket) -> bool { + if !matches!(packet.visual_param.len(), 218 | 251) { + return false; + } + let Ok(length) = i32::try_from(packet.object_data.texture_entry.len()) else { + return false; + }; + let Ok(textures) = PrimitiveTextureEntry::new_with_bytes_int32_int32( + packet.object_data.texture_entry.clone(), + 0, + length, + ) else { + return false; + }; + [8_u32, 9, 10, 11].into_iter().all(|index| { + textures.get_face(index).ok().flatten().is_some_and(|face| { + let id = face.texture_id(); + id != UUID::zero() && id != default_avatar_texture() + }) + }) +} + +fn emit_recovery( + inner: &AppearanceManagerInner, + phase: AppearanceRecoveryPhase, + retry: u32, + started: Instant, + generation: u64, + acknowledged: bool, +) { + let event = AppearanceRecoveryEvent { + phase, + stage: AppearanceRecoveryStage::from_u8(inner.recovery_stage.load(Ordering::Acquire)), + generation, + retry, + elapsed_millis: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + cof_version: inner.last_cof_version.load(Ordering::Acquire), + wearable_count: read(&inner.wearables).len(), + attachment_count: read(&inner.attachments).len(), + missing_attachment_count: missing_attachments(inner).len(), + cached_bake_count: bake_index_to_texture_index() + .into_iter() + .filter(|index| { + read(&inner.texture_slots) + .get(usize::from(*index)) + .is_some_and(|slot| slot.texture_id != UUID::zero()) + }) + .count(), + server_baking: inner + .client + .upgrade() + .and_then(|client| client.native_network().ok()) + .and_then(|network| network.native_current_sim()) + .is_some_and(|simulator| { + simulator.protocols.0 & crate::RegionProtocols::AGENT_APPEARANCE_SERVICE.0 != 0 + }), + appearance_serial: inner.appearance_serial.load(Ordering::Acquire), + acknowledged, + }; + inner.events.recovery.emit_with(|| event); +} + impl AppearanceManager { pub(crate) fn native_new(client: Option>) -> Result { let client = client.ok_or(Error::ArgumentNull)?; @@ -395,6 +815,13 @@ impl AppearanceManager { appearance_serial: AtomicU64::new(0), appearance_cancel: Mutex::new(None), appearance_gate: Arc::new(tokio::sync::Semaphore::new(1)), + recovery_stage: AtomicU8::new(AppearanceRecoveryStage::Starting as u8), + recovery_generation: AtomicU64::new(0), + logged_in: AtomicBool::new(false), + acknowledgement_sequence: AtomicU64::new(0), + acknowledged_cof_version: AtomicI32::new(-1), + acknowledged_complete: AtomicBool::new(false), + acknowledgement_notify: tokio::sync::Notify::new(), events: AppearanceEvents::default(), login_callback: Mutex::new(None), network_subscriptions: Mutex::new(Vec::new()), @@ -413,23 +840,21 @@ impl AppearanceManager { } }, ); - client - .native_network()? - .native_register_login_response_callback( - login_callback.clone(), - Some(vec!["cof_version".to_owned()]), - )?; + let network = client.native_network()?; + network.native_register_login_response_callback( + login_callback.clone(), + Some(vec!["cof_version".to_owned()]), + )?; *mutex(&inner.login_callback) = Some(login_callback); let weak = Arc::downgrade(&inner); - let subscription = client - .native_network()? - .subscribe_raw_packet(Arc::new(move |event| { - let Some(inner) = weak.upgrade() else { - return; - }; - handle_appearance_packet(&inner, event.packet_type, event.data); - })); + let subscription = network.subscribe_raw_packet(Arc::new(move |event| { + let Some(inner) = weak.upgrade() else { + return; + }; + handle_appearance_packet(&inner, event.packet_type, event.data); + })); mutex(&inner.network_subscriptions).push(subscription); + install_recovery_lifecycle(&inner, &client)?; Ok(Self::native_from_inner(inner)) } @@ -497,6 +922,14 @@ impl AppearanceManager { self.inner.events.rebake_avatar_requested.subscribe(handler) } + /// Subscribes to non-secret automatic appearance recovery diagnostics. + pub fn subscribe_recovery( + &self, + handler: EventHandler, + ) -> Subscription { + self.inner.events.recovery.subscribe(handler) + } + pub(crate) fn native_texture_provider(&self) -> Arc { Arc::clone(&read(&self.inner.texture_provider)) } @@ -510,6 +943,7 @@ impl AppearanceManager { #[allow(clippy::unnecessary_wraps)] // The mapped Dispose API is fallible. pub(crate) fn native_dispose(&self) -> Result<(), Error> { + cancel_recovery(&self.inner); if let Some(callback) = mutex(&self.inner.login_callback).take() && let Some(client) = self.inner.client.upgrade() && let Ok(network) = client.native_network() @@ -1068,7 +1502,19 @@ impl AppearanceManager { } else { link.base.uuid() }; - let Some(item) = store.native_item_value(target_id) else { + let item = if let Some(item) = store.native_item_value(target_id) { + item + } else if let Some(item) = inventory + .native_fetch_item( + target_id, + link.base.owner_id(), + cancellation_token.clone(), + false, + ) + .await? + { + item + } else { continue; }; worn.push(item.base.clone()); @@ -1393,6 +1839,7 @@ impl AppearanceManager { &self, force_rebake: bool, ) -> Result<(), Error> { + set_recovery_stage(&self.inner, AppearanceRecoveryStage::Starting); let source = CancellationTokenSource::new(); if let Some(previous) = mutex(&self.inner.appearance_cancel).replace(source.clone()) { previous.cancel(); @@ -1419,6 +1866,7 @@ impl AppearanceManager { .native_current_sim() .ok_or(Error::InvalidOperation)?; let use_server_baking = self.native_server_baking_available()?; + set_recovery_stage(&self.inner, AppearanceRecoveryStage::Outfit); self.native_refresh_worn_for_bake(use_server_baking, token.clone()) .await?; if force_rebake { @@ -1431,9 +1879,11 @@ impl AppearanceManager { } } if use_server_baking { + set_recovery_stage(&self.inner, AppearanceRecoveryStage::Bake); self.native_server_bake(token.clone()).await?; } else { if !force_rebake && !read(&self.inner.wearables).is_empty() { + set_recovery_stage(&self.inner, AppearanceRecoveryStage::Cache); let generation = self.inner.cache_reply_generation.load(Ordering::Acquire); let notified = self.inner.cache_notify.notified(); let request_sent = self.native_request_cached_bakes_sent()?; @@ -1449,11 +1899,14 @@ impl AppearanceManager { let _cache_reply_received = self.inner.cache_reply_generation.load(Ordering::Acquire) != generation; } + set_recovery_stage(&self.inner, AppearanceRecoveryStage::Assets); self.native_gather_baking_inputs(token.clone()).await?; + set_recovery_stage(&self.inner, AppearanceRecoveryStage::Bake); self.native_local_bake(Some(token.clone()), force_rebake) .await?; } token.throw_if_cancellation_requested()?; + set_recovery_stage(&self.inner, AppearanceRecoveryStage::Publish); let packet = self.native_make_appearance_packet()?; let network = self.client()?.native_network()?; if network.native_current_sim().as_ref() != Some(&start_simulator) { @@ -2706,6 +3159,58 @@ mod tests { assert_eq!(manager.native_last_cof_version(), 17); } + #[test] + fn authoritative_appearance_requires_bakes_visuals_and_current_cof() { + let client = GridClient::new().unwrap(); + let manager = client.appearance(); + manager.native_update_cof_version(12).unwrap(); + let mut packet = crate::packets::AvatarAppearancePacket::new_with_constructor().unwrap(); + packet.sender.id = client.network().native_agent_id(); + let mut appearance = + crate::packets::AvatarAppearancePacketAppearanceDataBlock::new_with_constructor() + .unwrap(); + appearance.cof_version = 12; + packet.appearance_data.push(appearance); + packet.visual_param = (0..218) + .map(|_| { + crate::packets::AvatarAppearancePacketVisualParamBlock::new_with_constructor() + .unwrap() + }) + .collect(); + let mut texture_ids = vec![UUID::zero(); 12]; + for (index, value) in [8_usize, 9, 10, 11].into_iter().enumerate() { + texture_ids[value] = UUID::new_with_u_int64(10_000 + index as u64).unwrap(); + } + packet.object_data.texture_entry = encode_texture_entry(&texture_ids).unwrap(); + + assert!(complete_baked_appearance(&packet)); + handle_appearance_packet( + &manager.inner, + PacketType::AvatarAppearance, + packet.to_bytes_with_method().unwrap(), + ); + assert!(manager.inner.acknowledged_complete.load(Ordering::Acquire)); + assert_eq!( + manager + .inner + .acknowledged_cof_version + .load(Ordering::Acquire), + 12 + ); + + packet.visual_param.clear(); + assert!(!complete_baked_appearance(&packet)); + packet.visual_param = (0..218) + .map(|_| { + crate::packets::AvatarAppearancePacketVisualParamBlock::new_with_constructor() + .unwrap() + }) + .collect(); + texture_ids[8] = default_avatar_texture(); + packet.object_data.texture_entry = encode_texture_entry(&texture_ids).unwrap(); + assert!(!complete_baked_appearance(&packet)); + } + #[tokio::test] async fn failed_appearance_request_emits_failure_and_empty_cache_request_does_not_wait() { let client = GridClient::new().unwrap(); diff --git a/crates/libremetaverse/src/asset_manager.rs b/crates/libremetaverse/src/asset_manager.rs index 3ad6fe9..247c825 100644 --- a/crates/libremetaverse/src/asset_manager.rs +++ b/crates/libremetaverse/src/asset_manager.rs @@ -24,11 +24,11 @@ use crate::{ }; use futures_channel::oneshot; use futures_util::{FutureExt, pin_mut, select_biased}; +use libremetaverse_structured_data::{OSD, OSDParser}; use libremetaverse_types::compat::{ CancellationToken, CancellationTokenSource, EventHandler, Subscription, Uri, }; use libremetaverse_types::{AssetType, UUID, Utils}; -use serde_json::Value; use std::collections::{BTreeMap, HashMap}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -54,6 +54,13 @@ fn block_on(future: F) -> F::Output { } } +fn upload_response(bytes: Vec) -> Result, Error> { + let OSD::Map(map) = OSDParser::deserialize_with_bytes(bytes)? else { + return Err(Error::Argument); + }; + Ok(map) +} + fn mutex(value: &Mutex) -> std::sync::MutexGuard<'_, T> { value .lock() @@ -1147,32 +1154,36 @@ impl AssetManager { None, ) .await?; - let meta: Value = serde_json::from_slice(&metadata).map_err(|_| Error::Argument)?; + let meta = upload_response(metadata)?; let uploader = meta .get("uploader") - .and_then(Value::as_str) + .ok_or(Error::Argument)? + .as_uri()? .ok_or(Error::Argument)?; let (_, response) = client .native_http_caps_client() .post_with_uri_string_bytes_cancellation_token_i_progress( - Uri(uploader.to_owned()), + uploader, "application/octet-stream".into(), data, token, None, ) .await?; - let result: Value = serde_json::from_slice(&response).map_err(|_| Error::Argument)?; - if result.get("state").and_then(Value::as_str) != Some("complete") { + let result = upload_response(response)?; + if result + .get("state") + .map(OSD::as_string) + .transpose()? + .as_deref() + != Some("complete") + { return Ok(UUID::zero()); } result .get("new_asset") .or_else(|| result.get("new_asset_id")) - .and_then(Value::as_str) - .map(|id| UUID::new_with_string(id.to_owned())) - .transpose() - .map(|id| id.unwrap_or_else(UUID::zero)) + .map_or(Ok(UUID::zero()), OSD::as_uuid) } pub async fn request_upload_baked_texture( &self, @@ -1411,3 +1422,29 @@ impl AssetManager { self.inner.events.xfer.subscribe(handler) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn baked_upload_responses_are_llsd_maps() { + let id = "12345678-1234-1234-1234-123456789abc"; + let metadata = upload_response( + b"uploaderhttps://grid.invalid/upload" + .to_vec(), + ) + .unwrap(); + assert_eq!( + metadata["uploader"].as_uri().unwrap().unwrap().0, + "https://grid.invalid/upload" + ); + let complete = upload_response( + format!("statecompletenew_asset{id}") + .into_bytes(), + ) + .unwrap(); + assert_eq!(complete["state"].as_string().unwrap(), "complete"); + assert_eq!(complete["new_asset"].as_uuid().unwrap().to_string(), id); + } +} diff --git a/crates/libremetaverse/src/lib.rs b/crates/libremetaverse/src/lib.rs index 85caf10..25ec0c7 100644 --- a/crates/libremetaverse/src/lib.rs +++ b/crates/libremetaverse/src/lib.rs @@ -19,6 +19,9 @@ mod animesh_runtime; mod animesh_skinning; mod appearance_baker; mod appearance_manager; +pub use appearance_manager::{ + AppearanceRecoveryEvent, AppearanceRecoveryPhase, AppearanceRecoveryStage, +}; mod asset_archive; mod asset_cache; mod asset_manager; diff --git a/crates/libremetaverse/src/network_manager.rs b/crates/libremetaverse/src/network_manager.rs index a4d0349..5b82baa 100644 --- a/crates/libremetaverse/src/network_manager.rs +++ b/crates/libremetaverse/src/network_manager.rs @@ -3697,6 +3697,14 @@ impl NetworkManager { self.update_login_status(LoginStatus::Failed, connect_failure_message.to_owned()); return Ok(false); } + if self.inner.client.settings_ref().agent.send_appearance { + self.inner + .client + .native_appearance()? + .native_update_cof_version( + i32::try_from(response.cof_version()).unwrap_or(i32::MAX), + )?; + } self.update_login_status(LoginStatus::Success, success_message); Ok(true) } else { diff --git a/crates/metacrate-grid-agent/src/main.rs b/crates/metacrate-grid-agent/src/main.rs index 39a8a06..9dd1679 100644 --- a/crates/metacrate-grid-agent/src/main.rs +++ b/crates/metacrate-grid-agent/src/main.rs @@ -732,6 +732,7 @@ struct LiveInteractions { policy: Arc, audit: Arc, observability: Arc, + _appearance_recovery: libremetaverse_types::compat::Subscription, _landmark_intake: metacrate_grid_agent::LibremetaverseLandmarkIntake, landmark_roaming: metacrate_grid_agent::LandmarkRoamingHandle, build_control: Arc, @@ -808,6 +809,11 @@ async fn start_live_interactions( journal_queue: config.limits.observable_queue, ..ObservabilityLimits::default() })?; + let appearance_observability = Arc::clone(&observability); + let appearance_recovery = libremetaverse::IGridClient::appearance(owner.client()) + .subscribe_recovery(Arc::new(move |event| { + record_appearance_recovery(&appearance_observability, event); + })); let audit_sink: Arc = Arc::new(UnifiedPolicyAudit::new( audit.clone(), observability.clone(), @@ -930,6 +936,7 @@ async fn start_live_interactions( policy: gateway, audit, observability, + _appearance_recovery: appearance_recovery, _landmark_intake: landmark_intake, landmark_roaming, build_control, @@ -941,6 +948,67 @@ async fn start_live_interactions( }) } +#[cfg(feature = "live-grid")] +fn record_appearance_recovery( + observer: &metacrate_grid_agent::Observability, + event: libremetaverse::AppearanceRecoveryEvent, +) { + use libremetaverse::AppearanceRecoveryPhase as Phase; + use metacrate_grid_agent::{EventDraft, EventFamily, EventOrigin, EventSeverity}; + let phase = match event.phase { + Phase::LoginReady => "login_ready", + Phase::OutfitReady => "outfit_ready", + Phase::BakeComplete => "bake_complete", + Phase::AppearanceSent => "appearance_sent", + Phase::AppearanceAcknowledged => "appearance_acknowledged", + Phase::AttachmentsRepaired => "attachments_repaired", + Phase::Retrying => "retrying", + Phase::Complete => "complete", + Phase::Failed => "failed", + Phase::Cancelled => "cancelled", + }; + let severity = match event.phase { + Phase::Failed => EventSeverity::Error, + Phase::Retrying | Phase::Cancelled => EventSeverity::Warning, + _ => EventSeverity::Info, + }; + let stage = match event.stage { + libremetaverse::AppearanceRecoveryStage::Starting => "starting", + libremetaverse::AppearanceRecoveryStage::Outfit => "outfit", + libremetaverse::AppearanceRecoveryStage::Cache => "cache", + libremetaverse::AppearanceRecoveryStage::Assets => "assets", + libremetaverse::AppearanceRecoveryStage::Bake => "bake", + libremetaverse::AppearanceRecoveryStage::Publish => "publish", + libremetaverse::AppearanceRecoveryStage::Acknowledgement => "acknowledgement", + libremetaverse::AppearanceRecoveryStage::Attachments => "attachments", + }; + let draft = EventDraft::new( + EventFamily::DiagnosticEnvelope, + severity, + "appearance", + EventOrigin::Grid, + ) + .and_then(|draft| draft.code_field("phase", phase)) + .and_then(|draft| draft.code_field("stage", stage)) + .map(|draft| { + draft + .retry_count(event.retry) + .duration_millis(event.elapsed_millis) + }) + .and_then(|draft| draft.field("generation", event.generation.into())) + .and_then(|draft| draft.field("cof_version", event.cof_version.into())) + .and_then(|draft| draft.field("wearables", event.wearable_count.into())) + .and_then(|draft| draft.field("attachments", event.attachment_count.into())) + .and_then(|draft| draft.field("missing_attachments", event.missing_attachment_count.into())) + .and_then(|draft| draft.field("cached_bakes", event.cached_bake_count.into())) + .and_then(|draft| draft.field("server_baking", event.server_baking.into())) + .and_then(|draft| draft.field("appearance_serial", event.appearance_serial.into())) + .and_then(|draft| draft.field("acknowledged", event.acknowledged.into())); + if let Ok(draft) = draft { + let _ = observer.record(draft); + } +} + #[cfg(feature = "live-grid")] fn record_session_observation( observer: &metacrate_grid_agent::Observability, diff --git a/crates/metacrate-grid-agent/src/vision.rs b/crates/metacrate-grid-agent/src/vision.rs index 43f52b9..f51e5d9 100644 --- a/crates/metacrate-grid-agent/src/vision.rs +++ b/crates/metacrate-grid-agent/src/vision.rs @@ -1332,13 +1332,14 @@ impl SceneSource for LibremetaverseSceneSource { }) .map(|avatar| avatar.local_id) .collect::>(); - let (mut world, mut attachments) = { + let (mut world, mut attachments, avatars_with_attachments) = { let primitive_cache = simulator .objects_primitives .read() .unwrap_or_else(std::sync::PoisonError::into_inner); let mut world = Vec::new(); let mut attachments = Vec::new(); + let mut avatars_with_attachments = std::collections::BTreeSet::new(); for prim in primitive_cache.values().cloned() { let Some((prim, attachment_avatar)) = world_primitive(prim, &primitive_cache, &avatar_parents) @@ -1347,6 +1348,7 @@ impl SceneSource for LibremetaverseSceneSource { }; if let Some(avatar) = attachment_avatar { if attachment_avatar_ids.contains(&avatar) { + avatars_with_attachments.insert(avatar); attachments.push(prim); } } else if scene_within_distance( @@ -1357,7 +1359,7 @@ impl SceneSource for LibremetaverseSceneSource { world.push(prim); } } - (world, attachments) + (world, attachments, avatars_with_attachments) }; world.sort_by(|left, right| { let left_visible = scene_center_visible( @@ -1385,17 +1387,18 @@ impl SceneSource for LibremetaverseSceneSource { scene_distance_squared(left.position, camera_position) .total_cmp(&scene_distance_squared(right.position, camera_position)) }); + avatars.retain(|avatar| !avatars_with_attachments.contains(&avatar.local_id)); let avatar_limit = avatars.len().min(entity_limit); let object_limit = entity_limit.saturating_sub(avatar_limit); - let world_limit = world.len().min(object_limit); - let attachment_limit = attachments.len().min(object_limit - world_limit); + let attachment_limit = attachments.len().min(object_limit); + let world_limit = world.len().min(object_limit - attachment_limit); let objects_truncated = world.len() > world_limit || attachments.len() > attachment_limit; let avatars_truncated = avatars.len() > avatar_limit; world.truncate(world_limit); attachments.truncate(attachment_limit); - world.extend(attachments); - let prims = world; + attachments.extend(world); + let prims = attachments; avatars.truncate(avatar_limit); let region_id = simulator.region_id; let region_name = simulator.name.clone(); @@ -2033,6 +2036,19 @@ fn native_entities( )) }); let Some(mesh) = mesh else { continue }; + let skinning = (prim.is_attachment) + .then(|| mesh.skin_data.as_ref()) + .flatten() + .and_then(|skin| { + let skeleton = libremetaverse::rendering::LindenSkeleton::get_default().ok()?; + let matrices = libremetaverse::AnimeshSkinning::compute_skinning_matrices( + Some(std::collections::HashMap::new()), + Some(skeleton), + Some(skin.clone()), + ) + .ok()?; + Some((matrices, matrix4(&skin.bind_shape_matrix))) + }); let object_triangles = mesh .faces .iter() @@ -2069,7 +2085,16 @@ fn native_entities( let gpu_vertices = face .vertices .iter() - .map(|vertex| native_gpu_vertex(vertex, &prim)) + .enumerate() + .map(|(index, vertex)| { + let (position, normal) = skinning + .as_ref() + .and_then(|(matrices, bind_shape)| { + skinned_vertex(face, index, matrices, *bind_shape) + }) + .unwrap_or((vertex.position, vertex.normal)); + native_gpu_vertex(position, normal, vertex.tex_coord, &prim) + }) .collect::>(); let gpu_indices = face .indices @@ -2327,21 +2352,101 @@ pub(crate) fn scene_detail_level( } } +#[cfg(feature = "live-grid")] +fn matrix4(values: &[f32]) -> libremetaverse_types::Matrix4 { + let mut matrix = [0.0; 16]; + if values.len() == matrix.len() { + matrix.copy_from_slice(values); + } else { + matrix = [ + 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, + ]; + } + libremetaverse_types::Matrix4 { + m11: matrix[0], + m12: matrix[1], + m13: matrix[2], + m14: matrix[3], + m21: matrix[4], + m22: matrix[5], + m23: matrix[6], + m24: matrix[7], + m31: matrix[8], + m32: matrix[9], + m33: matrix[10], + m34: matrix[11], + m41: matrix[12], + m42: matrix[13], + m43: matrix[14], + m44: matrix[15], + } +} + +#[cfg(feature = "live-grid")] +fn skinned_vertex( + face: &libremetaverse::rendering::Face, + index: usize, + matrices: &[libremetaverse_types::Matrix4], + bind_shape: libremetaverse_types::Matrix4, +) -> Option<(libremetaverse_types::Vector3, libremetaverse_types::Vector3)> { + let vertex = face.vertices.get(index)?; + let weights = face.weights.as_ref()?.get(index)?; + let position = libremetaverse_types::Vector3::transform(vertex.position, bind_shape).ok()?; + let influences = [ + (weights.joint0, weights.weight0), + (weights.joint1, weights.weight1), + (weights.joint2, weights.weight2), + (weights.joint3, weights.weight3), + ]; + let mut output_position = libremetaverse_types::Vector3::zero(); + let mut output_normal = libremetaverse_types::Vector3::zero(); + for (joint, weight) in influences { + if weight <= 0.0 || !weight.is_finite() { + continue; + } + let matrix = matrices.get(usize::try_from(joint).ok()?)?; + output_position = libremetaverse_types::Vector3::add_with_vector3_vector3( + output_position, + libremetaverse_types::Vector3::multiply_with_vector3_single( + libremetaverse_types::Vector3::transform(position, *matrix).ok()?, + weight, + ) + .ok()?, + ) + .ok()?; + output_normal = libremetaverse_types::Vector3::add_with_vector3_vector3( + output_normal, + libremetaverse_types::Vector3::multiply_with_vector3_single( + libremetaverse_types::Vector3::transform_normal(vertex.normal, *matrix).ok()?, + weight, + ) + .ok()?, + ) + .ok()?; + } + Some(( + output_position, + libremetaverse_types::Vector3::normalize(output_normal).ok()?, + )) +} + #[cfg(feature = "live-grid")] fn native_gpu_vertex( - vertex: &libremetaverse::rendering::Vertex, + vertex_position: libremetaverse_types::Vector3, + vertex_normal: libremetaverse_types::Vector3, + tex_coord: libremetaverse_types::Vector2, prim: &libremetaverse::Primitive, ) -> metacrate_rendering_wgpu::Vertex { let scaled = libremetaverse_types::Vector3 { - x: vertex.position.x * prim.scale.x, - y: vertex.position.y * prim.scale.y, - z: vertex.position.z * prim.scale.z, + x: vertex_position.x * prim.scale.x, + y: vertex_position.y * prim.scale.y, + z: vertex_position.z * prim.scale.z, }; let position = rotate_vector(scaled, prim.rotation); let inverse_scaled_normal = libremetaverse_types::Vector3 { - x: vertex.normal.x / prim.scale.x.abs().max(f32::EPSILON), - y: vertex.normal.y / prim.scale.y.abs().max(f32::EPSILON), - z: vertex.normal.z / prim.scale.z.abs().max(f32::EPSILON), + x: vertex_normal.x / prim.scale.x.abs().max(f32::EPSILON), + y: vertex_normal.y / prim.scale.y.abs().max(f32::EPSILON), + z: vertex_normal.z / prim.scale.z.abs().max(f32::EPSILON), }; let normal = rotate_vector(inverse_scaled_normal, prim.rotation); let normal_length = (normal.x * normal.x + normal.y * normal.y + normal.z * normal.z) @@ -2358,7 +2463,7 @@ fn native_gpu_vertex( normal.y / normal_length, normal.z / normal_length, ], - tex_coord: [vertex.tex_coord.x, vertex.tex_coord.y], + tex_coord: [tex_coord.x, tex_coord.y], color_srgb: [255; 4], } } diff --git a/crates/metacrate-grid-agent/tests/live_llm_vision.rs b/crates/metacrate-grid-agent/tests/live_llm_vision.rs index 800d823..9366e1f 100644 --- a/crates/metacrate-grid-agent/tests/live_llm_vision.rs +++ b/crates/metacrate-grid-agent/tests/live_llm_vision.rs @@ -74,6 +74,75 @@ async fn luna_describes_live_renderer_evidence() -> Result<(), Box> { } } +#[tokio::test] +#[ignore = "sends three observer-rendered views of Myrddin to the configured vision model"] +async fn luna_confirms_myrddin_body_and_attachments() -> Result<(), Box> { + let environment = live_environment()?; + let mut connection = AgentConfig::offline( + required(&environment, "OPENAPI_URL")?, + required(&environment, "OPENAPI_KEY")?, + )? + .llm; + connection.model = Some(required(&environment, "OPENAPI_MODEL")?.to_owned()); + let client = LlmClient::new(connection); + let runtime = Runtime::empty_builder() + .with_store(mentra::runtime::VolatileRuntimeStore::default()) + .with_registered_provider(client.mentra_provider()) + .build()?; + let root = std::env::temp_dir().join(format!( + "metacrate-live-appearance-review-{}", + std::process::id() + )); + let mut config = mentra::AgentConfig { + system: Some( + "Judge only visible evidence in virtual-world renders; do not infer success from the prompt." + .to_owned(), + ), + ..Default::default() + }; + config.compaction.transcript_dir = root.join("transcripts"); + config.task.tasks_dir = root.join("tasks"); + config.team.team_dir = root.join("teams"); + config.workspace.base_dir = root; + let mut agent = runtime.spawn_with_config( + "live-appearance-review", + ModelInfo::new(client.configured_model(), BuiltinProvider::OpenAI), + config, + )?; + let mut content = vec![ContentBlock::text( + "These are front, side, and back views rendered by a second logged-in agent. Determine whether Myrddin is visibly initialized as a recognizable male humanoid rather than a cloud, rectangle, placeholder, or missing avatar. Confirm whether a normal mesh body and coat-like worn attachment are visibly present and correctly follow the body, with no obvious detached, duplicated, or misplaced attachment geometry. Explain the visible evidence briefly. End with `APPEARANCE: PASS` only if every condition is visibly satisfied; otherwise end with `APPEARANCE: FAIL`.", + )]; + for label in ["front", "side", "back"] { + let path = std::env::temp_dir().join(format!("metacrate-myrddin-appearance-{label}.jpg")); + let jpeg = std::fs::read(path)?; + content.push(ContentBlock::text(format!("{label} view:"))); + content.push(ContentBlock::image_url(format!( + "data:image/jpeg;base64,{}", + base64::engine::general_purpose::STANDARD.encode(jpeg) + ))); + } + let response = agent.send(content).await?; + let descriptions = response + .content + .into_iter() + .filter_map(|block| match block { + ContentBlock::Text { text } if !text.trim().is_empty() => Some(text), + _ => None, + }) + .collect::>(); + for description in &descriptions { + println!("LIVE_APPEARANCE_LLM={description}"); + } + if descriptions + .iter() + .any(|description| description.contains("APPEARANCE: PASS")) + { + Ok(()) + } else { + Err("vision model rejected Myrddin appearance evidence".into()) + } +} + fn live_environment() -> Result, Box> { let mut values = std::env::vars().collect::>(); let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env"); diff --git a/crates/metacrate-grid-agent/tests/live_renderer.rs b/crates/metacrate-grid-agent/tests/live_renderer.rs index d22eba2..e4afaf3 100644 --- a/crates/metacrate-grid-agent/tests/live_renderer.rs +++ b/crates/metacrate-grid-agent/tests/live_renderer.rs @@ -349,6 +349,268 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box Result<(), Box> { + let environment = live_environment()?; + let target = LibremetaverseClientOwner::new()?; + let (progress_tx, mut progress_rx) = tokio::sync::mpsc::unbounded_channel(); + let _appearance = target + .client() + .appearance() + .subscribe_recovery(Arc::new(move |event| { + let _ = progress_tx.send(event); + })); + let (mut target_session, target_cancel) = + login(&target, &environment, "GRID_USER", "GRID_PASSWORD").await?; + wait_ready("Myrddin", &mut target_session, &target_cancel).await?; + let recovery = tokio::time::timeout(Duration::from_mins(2), async { + loop { + let event = progress_rx + .recv() + .await + .ok_or("appearance recovery stream closed")?; + println!("LIVE_APPEARANCE_RECOVERY={event:?}"); + if matches!( + event.phase, + libremetaverse::AppearanceRecoveryPhase::AppearanceSent + | libremetaverse::AppearanceRecoveryPhase::Complete + ) { + return Ok::<_, Box>(event); + } + } + }) + .await + .map_err(|_| "timed out waiting for Myrddin appearance recovery")??; + + let observer = LibremetaverseClientOwner::new()?; + let (mut observer_session, observer_cancel) = login( + &observer, + &environment, + "GRID_TEST_AUTHORIZED_USER", + "GRID_TEST_AUTHORIZED_PASSWORD", + ) + .await?; + let result = async { + wait_ready("observer", &mut observer_session, &observer_cancel).await?; + wait_scene_settled(&observer).await?; + let target_name = required(&environment, "GRID_USER")?; + let target_avatar = tokio::time::timeout(Duration::from_secs(30), async { + loop { + let simulator = observer + .client() + .network() + .current_sim() + .ok_or("observer simulator unavailable")?; + if let Some(avatar) = simulator + .objects_avatars + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .find(|avatar| avatar.name() == target_name) + .cloned() + { + return Ok::<_, Box>(avatar); + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + }) + .await + .map_err(|_| "observer did not see Myrddin")??; + let attachment_primitives = observer + .client() + .network() + .current_sim() + .ok_or("observer simulator unavailable")? + .objects_primitives + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .values() + .filter(|primitive| primitive.parent_id == target_avatar.local_id) + .cloned() + .collect::>(); + let attachment_ids = attachment_primitives + .iter() + .map(|primitive| primitive.id) + .collect::>(); + println!( + "LIVE_APPEARANCE_TARGET=position:{:?},local_id:{},observer_attachments:{}", + target_avatar.position, + target_avatar.local_id, + attachment_ids.len() + ); + if attachment_ids.len() < 2 { + return Err("observer did not receive Myrddin's mesh body and coat attachments".into()); + } + let assets = observer.client().assets(); + let mut mesh_loads = tokio::task::JoinSet::new(); + for mesh_id in attachment_primitives + .iter() + .filter_map(|primitive| primitive.sculpt.as_ref()) + .filter(|sculpt| sculpt.type_() == libremetaverse_types::SculptType::Mesh) + .map(|sculpt| sculpt.sculpt_texture) + { + let assets = assets.clone(); + let token = observer_cancel.token(); + mesh_loads.spawn(async move { assets.request_mesh(mesh_id, Some(token)).await }); + } + let mut loaded_meshes = 0; + while let Some(result) = tokio::time::timeout(Duration::from_secs(30), mesh_loads.join_next()) + .await + .map_err(|_| "timed out loading Myrddin attachment mesh")? + { + loaded_meshes += usize::from(matches!(result, Ok(Ok(Some(_))))); + } + println!("LIVE_APPEARANCE_MESHES_LOADED={loaded_meshes}"); + if loaded_meshes < 2 { + return Err("observer could not load Myrddin's mesh body and coat assets".into()); + } + let mut decoded_meshes = 0; + for primitive in &attachment_primitives { + let Some(sculpt) = primitive.sculpt.as_ref() else { + continue; + }; + let Some(bytes) = assets + .cache + .get_cached_asset_bytes_with_uuid(sculpt.sculpt_texture)? + else { + continue; + }; + let asset = libremetaverse::assets::AssetMesh::new_with_uuid_bytes( + sculpt.sculpt_texture, + bytes, + )?; + let mut mesh = None; + decoded_meshes += usize::from( + libremetaverse::rendering::FacetedMesh::try_decode_from_asset( + primitive.clone(), + asset, + libremetaverse::rendering::DetailLevel::Highest, + &mut mesh, + ), + ); + } + println!("LIVE_APPEARANCE_MESHES_DECODED={decoded_meshes}"); + if decoded_meshes < 2 { + return Err("renderer could not decode Myrddin's mesh body and coat assets".into()); + } + let limits = VisionLimits { + width: 640, + height: 640, + max_distance_meters: 8, + max_entities: 128, + max_texture_fetches: 128, + minimum_interval: Duration::ZERO, + ..VisionLimits::default() + }; + let source = Arc::new(LibremetaverseSceneSource::new(&observer, limits)); + let vision = VisionService::new(source.clone(), limits)?.prefer_gpu(); + if !vision.initialize_renderer().await { + return Err("appearance evidence renderer initialization failed".into()); + } + let facing = Vector3::mul_with_vector3_quaternion( + Vector3::unit_x(), + target_avatar.rotation, + ); + let length = (facing.x * facing.x + facing.y * facing.y).sqrt(); + if !length.is_finite() || length < f32::EPSILON { + return Err("Myrddin facing direction unavailable".into()); + } + let forward = Vector3 { + x: facing.x / length, + y: facing.y / length, + z: 0.0, + }; + let side = Vector3 { + x: -forward.y, + y: forward.x, + z: 0.0, + }; + let back = Vector3 { + x: -forward.x, + y: -forward.y, + z: 0.0, + }; + for (view, (label, direction)) in [("front", forward), ("side", side), ("back", back)] + .into_iter() + .enumerate() + { + let generation = u64::try_from(view)? + 1; + let eye = Vector3 { + x: target_avatar.position.x, + y: target_avatar.position.y, + z: target_avatar.position.z + 0.9, + }; + let camera = Vector3 { + x: eye.x + direction.x * 4.0, + y: eye.y + direction.y * 4.0, + z: eye.z, + }; + observer + .agent() + .movement + .camera + .look_at_with_vector3_vector3(camera, eye)?; + observer + .agent() + .movement + .camera + .set_vertical_fov_angle(45_f32.to_radians())?; + tokio::time::sleep(Duration::from_secs(1)).await; + vision.set_generation(generation); + if view == 0 { + let scene = source + .capture_scene(generation, observer_cancel.token()) + .await?; + let rendered_attachments = scene + .entities + .iter() + .filter(|entity| { + attachment_ids.contains(&entity.id) && !entity.renderables.is_empty() + }) + .count(); + println!( + "LIVE_APPEARANCE_SCENE=entities:{},rendered_attachments:{rendered_attachments},textures_missing:{},texture_fetches:{}", + scene.entities.len(), + scene.completeness.textures_missing, + scene.texture_fetches, + ); + if rendered_attachments < 2 { + return Err("renderer did not decode Myrddin's mesh body and coat".into()); + } + } + let capture = vision + .capture( + &format!("myrddin-appearance-{label}"), + generation, + observer_cancel.token(), + ) + .await?; + let output = std::env::temp_dir().join(format!( + "metacrate-myrddin-appearance-{label}.jpg" + )); + std::fs::write(&output, &capture.jpeg)?; + println!("LIVE_APPEARANCE_OUTPUT_{label}={}", output.display()); + println!("LIVE_APPEARANCE_SHA256_{label}={}", capture.image_sha256); + } + source.stop_prefetch().await; + println!( + "LIVE_APPEARANCE_STATE=cof_version:{},wearables:{},attachments:{},missing_attachments:{},elapsed_ms:{}", + recovery.cof_version, + recovery.wearable_count, + recovery.attachment_count, + recovery.missing_attachment_count, + recovery.elapsed_millis, + ); + Ok::<(), Box>(()) + } + .await; + let _ = observer_session.logout(observer_cancel.token()).await; + let _ = target_session.logout(target_cancel.token()).await; + result +} + #[allow(clippy::too_many_lines)] // One ordered live timing record keeps phase boundaries explicit. async fn profile_warm_capture( source: &LibremetaverseSceneSource,