Fix bounded avatar appearance recovery after login
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-23 18:06:58 +02:00
parent 42f0c62b6b
commit 5a533dfa00
9 changed files with 1114 additions and 41 deletions

View File

@@ -349,6 +349,268 @@ async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Er
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.
async fn profile_warm_capture(
source: &LibremetaverseSceneSource,