Fix bounded avatar appearance recovery after login
This commit is contained in:
@@ -3,6 +3,22 @@
|
|||||||
This file records intentional differences from the C# LibreMetaverse source. Preserve or
|
This file records intentional differences from the C# LibreMetaverse source. Preserve or
|
||||||
re-evaluate these changes when importing a newer upstream version.
|
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
|
## OpenSim varregion dimensions
|
||||||
|
|
||||||
- `GridRegion` retains the optional `MapBlockReply.Size` dimensions. Missing or zero dimensions
|
- `GridRegion` retains the optional `MapBlockReply.Size` dimensions. Missing or zero dimensions
|
||||||
|
|||||||
@@ -29,13 +29,17 @@ use std::collections::{HashMap, HashSet, VecDeque};
|
|||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
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::sync::{Arc, Mutex, RwLock};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
pub(crate) const ATTACHMENT_ADD: u8 = 0x80;
|
pub(crate) const ATTACHMENT_ADD: u8 = 0x80;
|
||||||
const MAX_WEARABLE_LAYERS: usize = 60;
|
const MAX_WEARABLE_LAYERS: usize = 60;
|
||||||
const MAX_FOLDER_TRAVERSAL: usize = 256;
|
const MAX_FOLDER_TRAVERSAL: usize = 256;
|
||||||
const MAX_COF_ENTRIES: usize = 512;
|
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<T>(lock: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
fn mutex<T>(lock: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||||
lock.lock()
|
lock.lock()
|
||||||
@@ -136,6 +140,69 @@ struct AppearanceEvents {
|
|||||||
appearance_set: EventRegistry<AppearanceSetEventArgs>,
|
appearance_set: EventRegistry<AppearanceSetEventArgs>,
|
||||||
cached_bakes_reply: EventRegistry<AgentCachedBakesReplyEventArgs>,
|
cached_bakes_reply: EventRegistry<AgentCachedBakesReplyEventArgs>,
|
||||||
rebake_avatar_requested: EventRegistry<RebakeAvatarTexturesEventArgs>,
|
rebake_avatar_requested: EventRegistry<RebakeAvatarTexturesEventArgs>,
|
||||||
|
recovery: EventRegistry<AppearanceRecoveryEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
pub(crate) struct AppearanceManagerInner {
|
||||||
@@ -158,6 +225,13 @@ pub(crate) struct AppearanceManagerInner {
|
|||||||
appearance_serial: AtomicU64,
|
appearance_serial: AtomicU64,
|
||||||
appearance_cancel: Mutex<Option<CancellationTokenSource>>,
|
appearance_cancel: Mutex<Option<CancellationTokenSource>>,
|
||||||
appearance_gate: Arc<tokio::sync::Semaphore>,
|
appearance_gate: Arc<tokio::sync::Semaphore>,
|
||||||
|
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,
|
events: AppearanceEvents,
|
||||||
login_callback: Mutex<Option<crate::NetworkManagerLoginResponseCallback>>,
|
login_callback: Mutex<Option<crate::NetworkManagerLoginResponseCallback>>,
|
||||||
network_subscriptions: Mutex<Vec<Subscription>>,
|
network_subscriptions: Mutex<Vec<Subscription>>,
|
||||||
@@ -255,11 +329,35 @@ fn handle_avatar_appearance(inner: &Arc<AppearanceManagerInner>, data: Vec<u8>)
|
|||||||
if packet.sender.id != network.native_agent_id() {
|
if packet.sender.id != network.native_agent_id() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let expected_cof = inner.last_cof_version.load(Ordering::Acquire);
|
||||||
if let Some(appearance) = packet.appearance_data.first() {
|
if let Some(appearance) = packet.appearance_data.first() {
|
||||||
inner
|
inner
|
||||||
.last_cof_version
|
.last_cof_version
|
||||||
.fetch_max(appearance.cof_version, Ordering::AcqRel);
|
.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<AppearanceManagerInner>, data: Vec<u8>) {
|
fn handle_wearables_update(inner: &Arc<AppearanceManagerInner>, data: Vec<u8>) {
|
||||||
@@ -365,6 +463,328 @@ fn spawn_appearance_request(inner: &Arc<AppearanceManagerInner>, force_rebake: b
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn install_recovery_lifecycle(
|
||||||
|
inner: &Arc<AppearanceManagerInner>,
|
||||||
|
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<AppearanceManagerInner>, 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<UUID>) -> 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<AttachmentRecord> {
|
||||||
|
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 {
|
impl AppearanceManager {
|
||||||
pub(crate) fn native_new(client: Option<Arc<GridClient>>) -> Result<Self, Error> {
|
pub(crate) fn native_new(client: Option<Arc<GridClient>>) -> Result<Self, Error> {
|
||||||
let client = client.ok_or(Error::ArgumentNull)?;
|
let client = client.ok_or(Error::ArgumentNull)?;
|
||||||
@@ -395,6 +815,13 @@ impl AppearanceManager {
|
|||||||
appearance_serial: AtomicU64::new(0),
|
appearance_serial: AtomicU64::new(0),
|
||||||
appearance_cancel: Mutex::new(None),
|
appearance_cancel: Mutex::new(None),
|
||||||
appearance_gate: Arc::new(tokio::sync::Semaphore::new(1)),
|
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(),
|
events: AppearanceEvents::default(),
|
||||||
login_callback: Mutex::new(None),
|
login_callback: Mutex::new(None),
|
||||||
network_subscriptions: Mutex::new(Vec::new()),
|
network_subscriptions: Mutex::new(Vec::new()),
|
||||||
@@ -413,23 +840,21 @@ impl AppearanceManager {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
client
|
let network = client.native_network()?;
|
||||||
.native_network()?
|
network.native_register_login_response_callback(
|
||||||
.native_register_login_response_callback(
|
login_callback.clone(),
|
||||||
login_callback.clone(),
|
Some(vec!["cof_version".to_owned()]),
|
||||||
Some(vec!["cof_version".to_owned()]),
|
)?;
|
||||||
)?;
|
|
||||||
*mutex(&inner.login_callback) = Some(login_callback);
|
*mutex(&inner.login_callback) = Some(login_callback);
|
||||||
let weak = Arc::downgrade(&inner);
|
let weak = Arc::downgrade(&inner);
|
||||||
let subscription = client
|
let subscription = network.subscribe_raw_packet(Arc::new(move |event| {
|
||||||
.native_network()?
|
let Some(inner) = weak.upgrade() else {
|
||||||
.subscribe_raw_packet(Arc::new(move |event| {
|
return;
|
||||||
let Some(inner) = weak.upgrade() else {
|
};
|
||||||
return;
|
handle_appearance_packet(&inner, event.packet_type, event.data);
|
||||||
};
|
}));
|
||||||
handle_appearance_packet(&inner, event.packet_type, event.data);
|
|
||||||
}));
|
|
||||||
mutex(&inner.network_subscriptions).push(subscription);
|
mutex(&inner.network_subscriptions).push(subscription);
|
||||||
|
install_recovery_lifecycle(&inner, &client)?;
|
||||||
Ok(Self::native_from_inner(inner))
|
Ok(Self::native_from_inner(inner))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,6 +922,14 @@ impl AppearanceManager {
|
|||||||
self.inner.events.rebake_avatar_requested.subscribe(handler)
|
self.inner.events.rebake_avatar_requested.subscribe(handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Subscribes to non-secret automatic appearance recovery diagnostics.
|
||||||
|
pub fn subscribe_recovery(
|
||||||
|
&self,
|
||||||
|
handler: EventHandler<AppearanceRecoveryEvent>,
|
||||||
|
) -> Subscription {
|
||||||
|
self.inner.events.recovery.subscribe(handler)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn native_texture_provider(&self) -> Arc<dyn crate::IBakingTextureProvider> {
|
pub(crate) fn native_texture_provider(&self) -> Arc<dyn crate::IBakingTextureProvider> {
|
||||||
Arc::clone(&read(&self.inner.texture_provider))
|
Arc::clone(&read(&self.inner.texture_provider))
|
||||||
}
|
}
|
||||||
@@ -510,6 +943,7 @@ impl AppearanceManager {
|
|||||||
|
|
||||||
#[allow(clippy::unnecessary_wraps)] // The mapped Dispose API is fallible.
|
#[allow(clippy::unnecessary_wraps)] // The mapped Dispose API is fallible.
|
||||||
pub(crate) fn native_dispose(&self) -> Result<(), Error> {
|
pub(crate) fn native_dispose(&self) -> Result<(), Error> {
|
||||||
|
cancel_recovery(&self.inner);
|
||||||
if let Some(callback) = mutex(&self.inner.login_callback).take()
|
if let Some(callback) = mutex(&self.inner.login_callback).take()
|
||||||
&& let Some(client) = self.inner.client.upgrade()
|
&& let Some(client) = self.inner.client.upgrade()
|
||||||
&& let Ok(network) = client.native_network()
|
&& let Ok(network) = client.native_network()
|
||||||
@@ -1068,7 +1502,19 @@ impl AppearanceManager {
|
|||||||
} else {
|
} else {
|
||||||
link.base.uuid()
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
worn.push(item.base.clone());
|
worn.push(item.base.clone());
|
||||||
@@ -1393,6 +1839,7 @@ impl AppearanceManager {
|
|||||||
&self,
|
&self,
|
||||||
force_rebake: bool,
|
force_rebake: bool,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
|
set_recovery_stage(&self.inner, AppearanceRecoveryStage::Starting);
|
||||||
let source = CancellationTokenSource::new();
|
let source = CancellationTokenSource::new();
|
||||||
if let Some(previous) = mutex(&self.inner.appearance_cancel).replace(source.clone()) {
|
if let Some(previous) = mutex(&self.inner.appearance_cancel).replace(source.clone()) {
|
||||||
previous.cancel();
|
previous.cancel();
|
||||||
@@ -1419,6 +1866,7 @@ impl AppearanceManager {
|
|||||||
.native_current_sim()
|
.native_current_sim()
|
||||||
.ok_or(Error::InvalidOperation)?;
|
.ok_or(Error::InvalidOperation)?;
|
||||||
let use_server_baking = self.native_server_baking_available()?;
|
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())
|
self.native_refresh_worn_for_bake(use_server_baking, token.clone())
|
||||||
.await?;
|
.await?;
|
||||||
if force_rebake {
|
if force_rebake {
|
||||||
@@ -1431,9 +1879,11 @@ impl AppearanceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if use_server_baking {
|
if use_server_baking {
|
||||||
|
set_recovery_stage(&self.inner, AppearanceRecoveryStage::Bake);
|
||||||
self.native_server_bake(token.clone()).await?;
|
self.native_server_bake(token.clone()).await?;
|
||||||
} else {
|
} else {
|
||||||
if !force_rebake && !read(&self.inner.wearables).is_empty() {
|
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 generation = self.inner.cache_reply_generation.load(Ordering::Acquire);
|
||||||
let notified = self.inner.cache_notify.notified();
|
let notified = self.inner.cache_notify.notified();
|
||||||
let request_sent = self.native_request_cached_bakes_sent()?;
|
let request_sent = self.native_request_cached_bakes_sent()?;
|
||||||
@@ -1449,11 +1899,14 @@ impl AppearanceManager {
|
|||||||
let _cache_reply_received =
|
let _cache_reply_received =
|
||||||
self.inner.cache_reply_generation.load(Ordering::Acquire) != generation;
|
self.inner.cache_reply_generation.load(Ordering::Acquire) != generation;
|
||||||
}
|
}
|
||||||
|
set_recovery_stage(&self.inner, AppearanceRecoveryStage::Assets);
|
||||||
self.native_gather_baking_inputs(token.clone()).await?;
|
self.native_gather_baking_inputs(token.clone()).await?;
|
||||||
|
set_recovery_stage(&self.inner, AppearanceRecoveryStage::Bake);
|
||||||
self.native_local_bake(Some(token.clone()), force_rebake)
|
self.native_local_bake(Some(token.clone()), force_rebake)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
token.throw_if_cancellation_requested()?;
|
token.throw_if_cancellation_requested()?;
|
||||||
|
set_recovery_stage(&self.inner, AppearanceRecoveryStage::Publish);
|
||||||
let packet = self.native_make_appearance_packet()?;
|
let packet = self.native_make_appearance_packet()?;
|
||||||
let network = self.client()?.native_network()?;
|
let network = self.client()?.native_network()?;
|
||||||
if network.native_current_sim().as_ref() != Some(&start_simulator) {
|
if network.native_current_sim().as_ref() != Some(&start_simulator) {
|
||||||
@@ -2706,6 +3159,58 @@ mod tests {
|
|||||||
assert_eq!(manager.native_last_cof_version(), 17);
|
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]
|
#[tokio::test]
|
||||||
async fn failed_appearance_request_emits_failure_and_empty_cache_request_does_not_wait() {
|
async fn failed_appearance_request_emits_failure_and_empty_cache_request_does_not_wait() {
|
||||||
let client = GridClient::new().unwrap();
|
let client = GridClient::new().unwrap();
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use futures_channel::oneshot;
|
use futures_channel::oneshot;
|
||||||
use futures_util::{FutureExt, pin_mut, select_biased};
|
use futures_util::{FutureExt, pin_mut, select_biased};
|
||||||
|
use libremetaverse_structured_data::{OSD, OSDParser};
|
||||||
use libremetaverse_types::compat::{
|
use libremetaverse_types::compat::{
|
||||||
CancellationToken, CancellationTokenSource, EventHandler, Subscription, Uri,
|
CancellationToken, CancellationTokenSource, EventHandler, Subscription, Uri,
|
||||||
};
|
};
|
||||||
use libremetaverse_types::{AssetType, UUID, Utils};
|
use libremetaverse_types::{AssetType, UUID, Utils};
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
@@ -54,6 +54,13 @@ fn block_on<F: std::future::Future>(future: F) -> F::Output {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn upload_response(bytes: Vec<u8>) -> Result<HashMap<String, OSD>, Error> {
|
||||||
|
let OSD::Map(map) = OSDParser::deserialize_with_bytes(bytes)? else {
|
||||||
|
return Err(Error::Argument);
|
||||||
|
};
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
fn mutex<T>(value: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
fn mutex<T>(value: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||||
value
|
value
|
||||||
.lock()
|
.lock()
|
||||||
@@ -1147,32 +1154,36 @@ impl AssetManager {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let meta: Value = serde_json::from_slice(&metadata).map_err(|_| Error::Argument)?;
|
let meta = upload_response(metadata)?;
|
||||||
let uploader = meta
|
let uploader = meta
|
||||||
.get("uploader")
|
.get("uploader")
|
||||||
.and_then(Value::as_str)
|
.ok_or(Error::Argument)?
|
||||||
|
.as_uri()?
|
||||||
.ok_or(Error::Argument)?;
|
.ok_or(Error::Argument)?;
|
||||||
let (_, response) = client
|
let (_, response) = client
|
||||||
.native_http_caps_client()
|
.native_http_caps_client()
|
||||||
.post_with_uri_string_bytes_cancellation_token_i_progress(
|
.post_with_uri_string_bytes_cancellation_token_i_progress(
|
||||||
Uri(uploader.to_owned()),
|
uploader,
|
||||||
"application/octet-stream".into(),
|
"application/octet-stream".into(),
|
||||||
data,
|
data,
|
||||||
token,
|
token,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let result: Value = serde_json::from_slice(&response).map_err(|_| Error::Argument)?;
|
let result = upload_response(response)?;
|
||||||
if result.get("state").and_then(Value::as_str) != Some("complete") {
|
if result
|
||||||
|
.get("state")
|
||||||
|
.map(OSD::as_string)
|
||||||
|
.transpose()?
|
||||||
|
.as_deref()
|
||||||
|
!= Some("complete")
|
||||||
|
{
|
||||||
return Ok(UUID::zero());
|
return Ok(UUID::zero());
|
||||||
}
|
}
|
||||||
result
|
result
|
||||||
.get("new_asset")
|
.get("new_asset")
|
||||||
.or_else(|| result.get("new_asset_id"))
|
.or_else(|| result.get("new_asset_id"))
|
||||||
.and_then(Value::as_str)
|
.map_or(Ok(UUID::zero()), OSD::as_uuid)
|
||||||
.map(|id| UUID::new_with_string(id.to_owned()))
|
|
||||||
.transpose()
|
|
||||||
.map(|id| id.unwrap_or_else(UUID::zero))
|
|
||||||
}
|
}
|
||||||
pub async fn request_upload_baked_texture(
|
pub async fn request_upload_baked_texture(
|
||||||
&self,
|
&self,
|
||||||
@@ -1411,3 +1422,29 @@ impl AssetManager {
|
|||||||
self.inner.events.xfer.subscribe(handler)
|
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"<llsd><map><key>uploader</key><uri>https://grid.invalid/upload</uri></map></llsd>"
|
||||||
|
.to_vec(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
metadata["uploader"].as_uri().unwrap().unwrap().0,
|
||||||
|
"https://grid.invalid/upload"
|
||||||
|
);
|
||||||
|
let complete = upload_response(
|
||||||
|
format!("<llsd><map><key>state</key><string>complete</string><key>new_asset</key><uuid>{id}</uuid></map></llsd>")
|
||||||
|
.into_bytes(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(complete["state"].as_string().unwrap(), "complete");
|
||||||
|
assert_eq!(complete["new_asset"].as_uuid().unwrap().to_string(), id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ mod animesh_runtime;
|
|||||||
mod animesh_skinning;
|
mod animesh_skinning;
|
||||||
mod appearance_baker;
|
mod appearance_baker;
|
||||||
mod appearance_manager;
|
mod appearance_manager;
|
||||||
|
pub use appearance_manager::{
|
||||||
|
AppearanceRecoveryEvent, AppearanceRecoveryPhase, AppearanceRecoveryStage,
|
||||||
|
};
|
||||||
mod asset_archive;
|
mod asset_archive;
|
||||||
mod asset_cache;
|
mod asset_cache;
|
||||||
mod asset_manager;
|
mod asset_manager;
|
||||||
|
|||||||
@@ -3697,6 +3697,14 @@ impl NetworkManager {
|
|||||||
self.update_login_status(LoginStatus::Failed, connect_failure_message.to_owned());
|
self.update_login_status(LoginStatus::Failed, connect_failure_message.to_owned());
|
||||||
return Ok(false);
|
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);
|
self.update_login_status(LoginStatus::Success, success_message);
|
||||||
Ok(true)
|
Ok(true)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -732,6 +732,7 @@ struct LiveInteractions {
|
|||||||
policy: Arc<metacrate_grid_agent::PolicyGateway>,
|
policy: Arc<metacrate_grid_agent::PolicyGateway>,
|
||||||
audit: Arc<metacrate_grid_agent::MemoryPolicyAudit>,
|
audit: Arc<metacrate_grid_agent::MemoryPolicyAudit>,
|
||||||
observability: Arc<metacrate_grid_agent::Observability>,
|
observability: Arc<metacrate_grid_agent::Observability>,
|
||||||
|
_appearance_recovery: libremetaverse_types::compat::Subscription,
|
||||||
_landmark_intake: metacrate_grid_agent::LibremetaverseLandmarkIntake,
|
_landmark_intake: metacrate_grid_agent::LibremetaverseLandmarkIntake,
|
||||||
landmark_roaming: metacrate_grid_agent::LandmarkRoamingHandle,
|
landmark_roaming: metacrate_grid_agent::LandmarkRoamingHandle,
|
||||||
build_control: Arc<dyn metacrate_grid_agent::BuildControl>,
|
build_control: Arc<dyn metacrate_grid_agent::BuildControl>,
|
||||||
@@ -808,6 +809,11 @@ async fn start_live_interactions(
|
|||||||
journal_queue: config.limits.observable_queue,
|
journal_queue: config.limits.observable_queue,
|
||||||
..ObservabilityLimits::default()
|
..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<dyn PolicyAuditSink> = Arc::new(UnifiedPolicyAudit::new(
|
let audit_sink: Arc<dyn PolicyAuditSink> = Arc::new(UnifiedPolicyAudit::new(
|
||||||
audit.clone(),
|
audit.clone(),
|
||||||
observability.clone(),
|
observability.clone(),
|
||||||
@@ -930,6 +936,7 @@ async fn start_live_interactions(
|
|||||||
policy: gateway,
|
policy: gateway,
|
||||||
audit,
|
audit,
|
||||||
observability,
|
observability,
|
||||||
|
_appearance_recovery: appearance_recovery,
|
||||||
_landmark_intake: landmark_intake,
|
_landmark_intake: landmark_intake,
|
||||||
landmark_roaming,
|
landmark_roaming,
|
||||||
build_control,
|
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")]
|
#[cfg(feature = "live-grid")]
|
||||||
fn record_session_observation(
|
fn record_session_observation(
|
||||||
observer: &metacrate_grid_agent::Observability,
|
observer: &metacrate_grid_agent::Observability,
|
||||||
|
|||||||
@@ -1332,13 +1332,14 @@ impl SceneSource for LibremetaverseSceneSource {
|
|||||||
})
|
})
|
||||||
.map(|avatar| avatar.local_id)
|
.map(|avatar| avatar.local_id)
|
||||||
.collect::<std::collections::BTreeSet<_>>();
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
let (mut world, mut attachments) = {
|
let (mut world, mut attachments, avatars_with_attachments) = {
|
||||||
let primitive_cache = simulator
|
let primitive_cache = simulator
|
||||||
.objects_primitives
|
.objects_primitives
|
||||||
.read()
|
.read()
|
||||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
let mut world = Vec::new();
|
let mut world = Vec::new();
|
||||||
let mut attachments = Vec::new();
|
let mut attachments = Vec::new();
|
||||||
|
let mut avatars_with_attachments = std::collections::BTreeSet::new();
|
||||||
for prim in primitive_cache.values().cloned() {
|
for prim in primitive_cache.values().cloned() {
|
||||||
let Some((prim, attachment_avatar)) =
|
let Some((prim, attachment_avatar)) =
|
||||||
world_primitive(prim, &primitive_cache, &avatar_parents)
|
world_primitive(prim, &primitive_cache, &avatar_parents)
|
||||||
@@ -1347,6 +1348,7 @@ impl SceneSource for LibremetaverseSceneSource {
|
|||||||
};
|
};
|
||||||
if let Some(avatar) = attachment_avatar {
|
if let Some(avatar) = attachment_avatar {
|
||||||
if attachment_avatar_ids.contains(&avatar) {
|
if attachment_avatar_ids.contains(&avatar) {
|
||||||
|
avatars_with_attachments.insert(avatar);
|
||||||
attachments.push(prim);
|
attachments.push(prim);
|
||||||
}
|
}
|
||||||
} else if scene_within_distance(
|
} else if scene_within_distance(
|
||||||
@@ -1357,7 +1359,7 @@ impl SceneSource for LibremetaverseSceneSource {
|
|||||||
world.push(prim);
|
world.push(prim);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
(world, attachments)
|
(world, attachments, avatars_with_attachments)
|
||||||
};
|
};
|
||||||
world.sort_by(|left, right| {
|
world.sort_by(|left, right| {
|
||||||
let left_visible = scene_center_visible(
|
let left_visible = scene_center_visible(
|
||||||
@@ -1385,17 +1387,18 @@ impl SceneSource for LibremetaverseSceneSource {
|
|||||||
scene_distance_squared(left.position, camera_position)
|
scene_distance_squared(left.position, camera_position)
|
||||||
.total_cmp(&scene_distance_squared(right.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 avatar_limit = avatars.len().min(entity_limit);
|
||||||
let object_limit = entity_limit.saturating_sub(avatar_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);
|
||||||
let attachment_limit = attachments.len().min(object_limit - world_limit);
|
let world_limit = world.len().min(object_limit - attachment_limit);
|
||||||
let objects_truncated =
|
let objects_truncated =
|
||||||
world.len() > world_limit || attachments.len() > attachment_limit;
|
world.len() > world_limit || attachments.len() > attachment_limit;
|
||||||
let avatars_truncated = avatars.len() > avatar_limit;
|
let avatars_truncated = avatars.len() > avatar_limit;
|
||||||
world.truncate(world_limit);
|
world.truncate(world_limit);
|
||||||
attachments.truncate(attachment_limit);
|
attachments.truncate(attachment_limit);
|
||||||
world.extend(attachments);
|
attachments.extend(world);
|
||||||
let prims = world;
|
let prims = attachments;
|
||||||
avatars.truncate(avatar_limit);
|
avatars.truncate(avatar_limit);
|
||||||
let region_id = simulator.region_id;
|
let region_id = simulator.region_id;
|
||||||
let region_name = simulator.name.clone();
|
let region_name = simulator.name.clone();
|
||||||
@@ -2033,6 +2036,19 @@ fn native_entities(
|
|||||||
))
|
))
|
||||||
});
|
});
|
||||||
let Some(mesh) = mesh else { continue };
|
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
|
let object_triangles = mesh
|
||||||
.faces
|
.faces
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2069,7 +2085,16 @@ fn native_entities(
|
|||||||
let gpu_vertices = face
|
let gpu_vertices = face
|
||||||
.vertices
|
.vertices
|
||||||
.iter()
|
.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::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let gpu_indices = face
|
let gpu_indices = face
|
||||||
.indices
|
.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")]
|
#[cfg(feature = "live-grid")]
|
||||||
fn native_gpu_vertex(
|
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,
|
prim: &libremetaverse::Primitive,
|
||||||
) -> metacrate_rendering_wgpu::Vertex {
|
) -> metacrate_rendering_wgpu::Vertex {
|
||||||
let scaled = libremetaverse_types::Vector3 {
|
let scaled = libremetaverse_types::Vector3 {
|
||||||
x: vertex.position.x * prim.scale.x,
|
x: vertex_position.x * prim.scale.x,
|
||||||
y: vertex.position.y * prim.scale.y,
|
y: vertex_position.y * prim.scale.y,
|
||||||
z: vertex.position.z * prim.scale.z,
|
z: vertex_position.z * prim.scale.z,
|
||||||
};
|
};
|
||||||
let position = rotate_vector(scaled, prim.rotation);
|
let position = rotate_vector(scaled, prim.rotation);
|
||||||
let inverse_scaled_normal = libremetaverse_types::Vector3 {
|
let inverse_scaled_normal = libremetaverse_types::Vector3 {
|
||||||
x: vertex.normal.x / prim.scale.x.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),
|
y: vertex_normal.y / prim.scale.y.abs().max(f32::EPSILON),
|
||||||
z: vertex.normal.z / prim.scale.z.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 = rotate_vector(inverse_scaled_normal, prim.rotation);
|
||||||
let normal_length = (normal.x * normal.x + normal.y * normal.y + normal.z * normal.z)
|
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.y / normal_length,
|
||||||
normal.z / 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],
|
color_srgb: [255; 4],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,75 @@ async fn luna_describes_live_renderer_evidence() -> Result<(), Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<dyn Error>> {
|
||||||
|
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::<Vec<_>>();
|
||||||
|
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<BTreeMap<String, String>, Box<dyn Error>> {
|
fn live_environment() -> Result<BTreeMap<String, String>, Box<dyn Error>> {
|
||||||
let mut values = std::env::vars().collect::<BTreeMap<_, _>>();
|
let mut values = std::env::vars().collect::<BTreeMap<_, _>>();
|
||||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env");
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env");
|
||||||
|
|||||||
@@ -349,6 +349,268 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
#[ignore = "logs Myrddin and a second account into the live grid and renders appearance evidence"]
|
||||||
|
#[allow(clippy::too_many_lines)] // One ordered two-account evidence workflow is easier to audit.
|
||||||
|
async fn live_observer_renders_myrddin_appearance() -> Result<(), Box<dyn Error>> {
|
||||||
|
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<dyn Error>>(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<dyn Error>>(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::<Vec<_>>();
|
||||||
|
let attachment_ids = attachment_primitives
|
||||||
|
.iter()
|
||||||
|
.map(|primitive| primitive.id)
|
||||||
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
|
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<dyn Error>>(())
|
||||||
|
}
|
||||||
|
.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.
|
#[allow(clippy::too_many_lines)] // One ordered live timing record keeps phase boundaries explicit.
|
||||||
async fn profile_warm_capture(
|
async fn profile_warm_capture(
|
||||||
source: &LibremetaverseSceneSource,
|
source: &LibremetaverseSceneSource,
|
||||||
|
|||||||
Reference in New Issue
Block a user