Files
Chili Palmer 5a533dfa00
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled
Fix bounded avatar appearance recovery after login
2026-08-23 18:06:58 +02:00

936 lines
35 KiB
Rust

#![cfg(feature = "live-grid")]
use libremetaverse_types::{
Vector3,
compat::{CancellationToken, CancellationTokenSource},
};
use metacrate_grid_agent::{
EndpointUrl, GridConnection, GridSession, GridSessionBackend, LibremetaverseClientOwner,
LibremetaverseSceneSource, SceneEntityKind, SceneSnapshot, SceneSource, SecretString,
SessionSignal, VisionFuture, VisionLimits, VisionService,
};
use std::{
collections::BTreeMap,
error::Error,
path::PathBuf,
sync::Arc,
time::{Duration, Instant},
};
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "logs the primary agent into the configured live varregion"]
async fn live_primary_login_resolves_varregion_dimensions() -> Result<(), Box<dyn Error>> {
let environment = live_environment()?;
let owner = LibremetaverseClientOwner::new()?;
let (mut session, cancellation) =
login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let result = async {
wait_ready("primary", &mut session, &cancellation).await?;
let simulator = owner
.client()
.network()
.current_sim()
.ok_or("primary simulator unavailable")?;
let size = simulator.region_size();
println!(
"LIVE_PRIMARY_REGION=region:{:?},handle:{},size:{}x{},position:{:?}",
simulator.name,
simulator.handle,
size.0,
size.1,
owner.agent().sim_position()
);
if size != (1024, 1024) {
return Err(format!("expected 1024x1024 varregion, got {}x{}", size.0, size.1).into());
}
Ok(())
}
.await;
let _ = session.logout(cancellation.token()).await;
result
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "profiles primary login, renderer initialization, and first visible-scene warmup"]
async fn live_primary_startup_profile() -> Result<(), Box<dyn Error>> {
let total_started = Instant::now();
let environment = live_environment()?;
let owner_started = Instant::now();
let owner = LibremetaverseClientOwner::new()?;
let owner_initialization = owner_started.elapsed();
let gpu_task = tokio::task::spawn_blocking(|| {
let started = Instant::now();
(
metacrate_rendering_wgpu::Renderer::new_blocking(),
started.elapsed(),
)
});
let login_started = Instant::now();
let (mut session, cancellation) =
login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let login = login_started.elapsed();
let ready_started = Instant::now();
wait_ready("primary", &mut session, &cancellation).await?;
let readiness = ready_started.elapsed();
let settle_started = Instant::now();
wait_scene_settled(&owner).await?;
let scene_settle = settle_started.elapsed();
let limits = VisionLimits {
minimum_interval: Duration::ZERO,
..VisionLimits::default()
};
let source = LibremetaverseSceneSource::new(&owner, limits);
let warmup_started = Instant::now();
let scene = source.capture_scene(0, cancellation.token()).await?;
let scene_warmup = warmup_started.elapsed();
let (renderer, gpu_initialization) = gpu_task.await?;
renderer?;
println!(
"LIVE_STARTUP_PROFILE=owner_initialization_ms:{},grid_login_ms:{},grid_readiness_ms:{},scene_settle_ms:{},gpu_initialization_parallel_ms:{},first_scene_warmup_ms:{},first_scene:{:?},total_ms:{}",
owner_initialization.as_millis(),
login.as_millis(),
readiness.as_millis(),
scene_settle.as_millis(),
gpu_initialization.as_millis(),
scene_warmup.as_millis(),
scene.timings,
total_started.elapsed().as_millis(),
);
let _ = session.logout(cancellation.token()).await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "records the primary region's asynchronous map-name reply"]
async fn live_primary_map_name_reports_varregion_dimensions() -> Result<(), Box<dyn Error>> {
let environment = live_environment()?;
let owner = LibremetaverseClientOwner::new()?;
let (mut session, cancellation) =
login(&owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let result = async {
wait_ready("primary", &mut session, &cancellation).await?;
let simulator = owner
.client()
.network()
.current_sim()
.ok_or("primary simulator unavailable")?;
let name = simulator.name.clone();
let handle = simulator.handle;
let (sender, mut receiver) = tokio::sync::mpsc::unbounded_channel();
let _subscription = owner
.client()
.grid()
.subscribe_grid_region(Arc::new(move |event| {
let _ = sender.send(event.region());
}));
let started = Instant::now();
owner
.client()
.grid()
.request_map_region(name.clone(), libremetaverse::GridLayerType::Objects)?;
let deadline = started + Duration::from_secs(15);
while Instant::now() < deadline {
let remaining = deadline.saturating_duration_since(Instant::now());
let Ok(Some(region)) = tokio::time::timeout(remaining, receiver.recv()).await else {
break;
};
println!(
"LIVE_MAP_NAME_EVENT=elapsed_ms:{},name:{:?},handle:{},size:{}x{}",
started.elapsed().as_millis(),
region.name,
region.region_handle,
region.size_x,
region.size_y
);
if region.region_handle == handle && (region.size_x, region.size_y) == (1024, 1024) {
return Ok::<(), Box<dyn Error>>(());
}
}
Err(format!("no explicit 1024x1024 map-name reply for {name:?}").into())
}
.await;
let _ = session.logout(cancellation.token()).await;
result
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "logs the primary agent into the configured live grid and writes a JPEG"]
#[allow(clippy::too_many_lines)] // The one end-to-end live workflow stays readable in execution order.
async fn live_bevy_capture_renders_in_front_of_avatar() -> Result<(), Box<dyn Error>> {
let environment = live_environment()?;
let renderer_owner = LibremetaverseClientOwner::new()?;
let limits = VisionLimits {
minimum_interval: Duration::ZERO,
..VisionLimits::default()
};
let (_world_events, world_receiver) =
metacrate_game_loop::LibreMetaverseWorldEvents::subscribe_visible(
renderer_owner.client(),
renderer_owner.agent(),
limits.max_distance_meters,
4_096,
)
.ok_or("live world event subscription failed")?;
let world_loop = metacrate_game_loop::start(
metacrate_game_loop::LoopConfig::default(),
world_receiver,
(),
None,
)
.ok_or("live world loop failed to start")?;
let (mut renderer_session, renderer_cancel) =
login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?;
let scene_source = Arc::new(LibremetaverseSceneSource::new(&renderer_owner, limits));
let prefetch_started = Instant::now();
scene_source.start_prefetch();
let result = async {
wait_ready("renderer", &mut renderer_session, &renderer_cancel).await?;
let renderer_simulator = renderer_owner
.client()
.network()
.current_sim()
.ok_or("renderer simulator unavailable")?;
let settled_position = renderer_owner.agent().sim_position();
let region_size = renderer_simulator.region_size();
println!(
"LIVE_RENDER_LOCATION=region:{:?},size:{}x{},position:{settled_position:?}",
renderer_simulator.name, region_size.0, region_size.1
);
wait_scene_settled(&renderer_owner).await?;
tokio::time::timeout(Duration::from_secs(10), async {
while world_loop
.latest_world()
.is_none_or(|world| world.value.region.is_none())
{
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.map_err(|_| "central world state did not become ready")?;
let world = world_loop
.latest_world()
.ok_or("central world snapshot disappeared")?;
println!(
"LIVE_WORLD_STATE=sequence:{},primitives:{},avatars:{},terrain_patches:{},dropped_events:{}",
world.sequence,
world.value.primitives.len(),
world.value.avatars.len(),
world.value.terrain.len(),
world_loop.dropped_world_events(),
);
let view_forward = Vector3::mul_with_vector3_quaternion(
Vector3::unit_x(),
renderer_owner.agent().sim_rotation(),
);
let (camera_position, camera_target) =
avatar_forward_camera(settled_position, view_forward)?;
renderer_owner
.agent()
.movement
.camera
.look_at_with_vector3_vector3(camera_position, camera_target)?;
renderer_owner
.agent()
.movement
.camera
.set_vertical_fov_angle(65_f32.to_radians())?;
tokio::time::timeout(Duration::from_mins(10), async {
while scene_source.prefetch_passes() == 0 {
tokio::time::sleep(Duration::from_millis(250)).await;
}
})
.await
.map_err(|_| "background scene prefetch did not complete")?;
println!(
"LIVE_RENDER_PREFETCH_MILLISECONDS={}",
prefetch_started.elapsed().as_millis()
);
scene_source.stop_prefetch().await;
println!(
"LIVE_RENDER_SCOPE=max_distance_meters:{}",
limits.max_distance_meters
);
let vision = VisionService::new(
Arc::new(DiagnosticSceneSource {
inner: Arc::clone(&scene_source),
material_override: std::env::var("METACRATE_LIVE_RENDER_MATERIAL_OVERRIDE").ok(),
}),
limits,
)?
.prefer_gpu();
let initialization_started = Instant::now();
if !vision.initialize_renderer().await {
return Err("live wgpu renderer initialization failed".into());
}
println!(
"LIVE_RENDER_INITIALIZATION_MILLISECONDS={}",
initialization_started.elapsed().as_millis()
);
vision.set_generation(1);
let mut capture = None;
let output = std::env::var_os("METACRATE_LIVE_RENDER_OUTPUT").map_or_else(
|| std::env::temp_dir().join("metacrate-live-render.jpg"),
PathBuf::from,
);
let frames = std::env::var("METACRATE_LIVE_RENDER_FRAMES")
.ok()
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(12)
.clamp(1, 24);
let mut previous_missing = u32::MAX;
let mut stable_missing = 0_u32;
for frame in 1..=frames {
let frame_started = Instant::now();
let next = vision
.capture(
&format!("live-renderer-evidence-{frame}"),
1,
renderer_cancel.token(),
)
.await?;
println!(
"LIVE_RENDER_FRAME_{frame}_MILLISECONDS={}",
frame_started.elapsed().as_millis()
);
println!("LIVE_RENDER_FRAME_{frame}_SUMMARY={}", next.summary);
std::fs::write(&output, &next.jpeg)?;
if next.completeness.textures_missing == previous_missing {
stable_missing += 1;
} else {
previous_missing = next.completeness.textures_missing;
stable_missing = 0;
}
capture = Some(next);
if previous_missing == 0 || (frame >= 8 && stable_missing >= 3) {
break;
}
}
let capture = capture.ok_or("live renderer produced no frame")?;
let viewport_before = vision
.viewport_stats()
.ok_or("viewport loop did not expose statistics")?;
let soak_started = Instant::now();
tokio::time::sleep(Duration::from_secs(2)).await;
let soak_elapsed = soak_started.elapsed();
let viewport_after = vision
.viewport_stats()
.ok_or("viewport loop stopped exposing statistics")?;
let soak_frames = viewport_after
.completed_frames
.saturating_sub(viewport_before.completed_frames);
let soak_missed = viewport_after
.missed_frame_deadlines
.saturating_sub(viewport_before.missed_frame_deadlines);
let soak_fps = f64::from(u32::try_from(soak_frames)?) / soak_elapsed.as_secs_f64();
println!(
"LIVE_VIEWPORT_SOAK=frames:{soak_frames},elapsed_ms:{},fps:{soak_fps:.2},missed_deadlines:{soak_missed},render_errors:{},last_render_ms:{}",
soak_elapsed.as_millis(),
viewport_after.render_errors,
viewport_after.last_render.as_millis(),
);
if soak_fps < 10.0 || soak_missed != 0 || viewport_after.render_errors != 0 {
return Err("stable viewport did not sustain its 10 Hz target".into());
}
profile_warm_capture(&scene_source, limits, renderer_cancel.token()).await?;
println!("LIVE_RENDER_OUTPUT={}", output.display());
println!("LIVE_RENDER_SHA256={}", capture.image_sha256);
Ok::<(), Box<dyn Error>>(())
}
.await;
scene_source.stop_prefetch().await;
world_loop
.shutdown()
.map_err(|_| "world loop thread panicked")?;
let _ = renderer_session.logout(renderer_cancel.token()).await;
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,
limits: VisionLimits,
cancellation: CancellationToken,
) -> Result<(), Box<dyn Error>> {
let total_started = Instant::now();
let scene_started = Instant::now();
let scene = source.capture_scene(1, cancellation).await?;
let scene_total = scene_started.elapsed();
let preparation_started = Instant::now();
let mut renderables = scene
.entities
.iter()
.flat_map(|entity| {
entity
.renderables
.iter()
.cloned()
.map(move |renderable| (entity.id.to_string(), renderable))
})
.collect::<Vec<_>>();
renderables.sort_by(|left, right| left.0.cmp(&right.0));
let renderables = renderables
.into_iter()
.map(|(_, renderable)| renderable)
.collect::<Vec<_>>();
let renderables: Arc<[metacrate_rendering_wgpu::Renderable]> = renderables.into();
let preparation = preparation_started.elapsed();
let renderer_started = Instant::now();
let renderer = metacrate_game_loop::ViewportRuntime::new_blocking()?;
let renderer_initialization = renderer_started.elapsed();
let render_limits = metacrate_rendering_wgpu::RenderLimits {
width: limits.width,
height: limits.height,
max_triangles: limits.max_triangles,
max_texture_bytes: limits.max_texture_bytes,
max_texture_pixels: limits.max_decode_pixels,
far_distance: 512,
};
let first = renderer.render_scene(
scene.scene_sequence,
scene.camera,
|| Arc::clone(&renderables),
Arc::clone(&scene.textures),
[125, 149, 173, 255],
render_limits,
)?;
let refresh_started = Instant::now();
let refresh = renderer.render_scene(
scene.scene_sequence.saturating_add(1),
scene.camera,
|| Arc::clone(&renderables),
Arc::clone(&scene.textures),
[125, 149, 173, 255],
render_limits,
)?;
let refresh_total = refresh_started.elapsed();
let warm_started = Instant::now();
let warm = renderer.render_scene(
scene.scene_sequence.saturating_add(1),
scene.camera,
|| panic!("unchanged scene was transferred to the renderer again"),
Arc::clone(&scene.textures),
[125, 149, 173, 255],
render_limits,
)?;
let warm_render_total = warm_started.elapsed();
let jpeg_started = Instant::now();
let rgb = warm
.frame
.value
.rgba
.chunks_exact(4)
.flat_map(|pixel| pixel[..3].iter().copied())
.collect::<Vec<_>>();
let mut jpeg = Vec::new();
jpeg_encoder::Encoder::new(&mut jpeg, 92).encode(
&rgb,
u16::try_from(limits.width)?,
u16::try_from(limits.height)?,
jpeg_encoder::ColorType::Rgb,
)?;
let jpeg_encode = jpeg_started.elapsed();
println!(
"LIVE_RENDER_PROFILE=scene_total_ms:{},terrain_wait_ms:{},object_snapshot_and_asset_discovery_ms:{},material_fetch_ms:{},asset_fetch_ms:{},decode_and_geometry_ms:{},renderable_collection_and_sort_ms:{},renderer_initialization_ms:{},first_render_total_ms:{},first_render:{:?},scene_refresh_total_ms:{},scene_refresh:{:?},warm_render_total_ms:{},warm_render:{:?},jpeg_encode_ms:{},profile_total_ms:{}",
scene_total.as_millis(),
scene.timings.terrain_wait.as_millis(),
scene
.timings
.object_snapshot_and_asset_discovery
.as_millis(),
scene.timings.material_fetch.as_millis(),
scene.timings.asset_fetch.as_millis(),
scene.timings.decode_and_geometry.as_millis(),
preparation.as_millis(),
renderer_initialization.as_millis(),
render_total(&first.timings).as_millis(),
first.timings,
refresh_total.as_millis(),
refresh.timings,
warm_render_total.as_millis(),
warm.timings,
jpeg_encode.as_millis(),
total_started.elapsed().as_millis(),
);
if refresh_total >= metacrate_game_loop::DEFAULT_VIEWPORT_FRAME_INTERVAL {
return Err("cached scene refresh exceeded the viewport frame interval".into());
}
Ok(())
}
fn render_total(timings: &metacrate_rendering_wgpu::RenderTimings) -> Duration {
timings.validation
+ timings.command_copy
+ timings.clear_previous_frame
+ timings.texture_cache_sync
+ timings.mesh_cache_sync
+ timings.scene_setup
+ timings.capture
}
struct DiagnosticSceneSource {
inner: Arc<LibremetaverseSceneSource>,
material_override: Option<String>,
}
impl SceneSource for DiagnosticSceneSource {
fn capture_scene(
&self,
generation: u64,
cancellation: CancellationToken,
) -> VisionFuture<'_, SceneSnapshot> {
Box::pin(async move {
let mut scene = self.inner.capture_scene(generation, cancellation).await?;
if let Some(material_override) = &self.material_override {
for renderable in Arc::make_mut(&mut scene.entities)
.iter_mut()
.filter(|entity| entity.kind == SceneEntityKind::Object)
.flat_map(|entity| &mut entity.renderables)
{
match &mut renderable.material {
metacrate_rendering_wgpu::Material::BlinnPhong(material) => {
if material_override == "solid" {
*material = metacrate_rendering_wgpu::BlinnPhongMaterial {
diffuse_color_srgb: [255, 0, 255, 255],
fullbright: true,
double_sided: true,
..Default::default()
};
} else if material_override == "unlit-white" {
material.diffuse_color_srgb = [255; 4];
material.fullbright = true;
}
}
metacrate_rendering_wgpu::Material::Pbr(material) => {
if material_override == "solid" {
*material = metacrate_rendering_wgpu::PbrMaterial {
base_color_srgb: [0, 255, 255, 255],
emissive_factor_srgb: [0, 255, 255],
metallic_factor: 0.0,
roughness_factor: 1.0,
double_sided: true,
..Default::default()
};
}
}
}
}
}
Ok(scene)
})
}
}
async fn login(
owner: &LibremetaverseClientOwner,
environment: &BTreeMap<String, String>,
user: &'static str,
password: &'static str,
) -> Result<(Box<dyn GridSession>, CancellationTokenSource), Box<dyn Error>> {
let cancellation = CancellationTokenSource::new();
let backend = owner.session_backend(GridConnection {
login_url: EndpointUrl::parse("grid.login_url", &required(environment, "GRID_LOGIN_URL")?)?,
avatar_name: required(environment, user)?,
password: SecretString::new("grid.password", required(environment, password)?)?,
})?;
let session = backend.login(1, cancellation.token()).await?;
Ok((session, cancellation))
}
async fn wait_ready(
account: &str,
session: &mut Box<dyn GridSession>,
cancellation: &CancellationTokenSource,
) -> Result<(), Box<dyn Error>> {
let signal = tokio::time::timeout(
Duration::from_secs(30),
session.next_signal(cancellation.token()),
)
.await
.map_err(|_| format!("timed out waiting for {account} session readiness"))?;
match signal {
SessionSignal::Ready => Ok(()),
signal => Err(format!("unexpected {account} session signal: {signal:?}").into()),
}
}
async fn wait_scene_settled(owner: &LibremetaverseClientOwner) -> Result<(), Box<dyn Error>> {
let deadline = Instant::now() + Duration::from_secs(30);
let minimum = Instant::now() + Duration::from_secs(10);
let mut previous = 0;
let mut stable = 0;
loop {
tokio::time::sleep(Duration::from_secs(1)).await;
let simulator = owner
.client()
.network()
.current_sim()
.ok_or("simulator unavailable while settling scene")?;
let count = simulator
.objects_primitives
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.len();
if count == previous && count != 0 {
stable += 1;
} else {
stable = 0;
previous = count;
}
if (Instant::now() >= minimum && stable >= 3) || Instant::now() >= deadline {
return Ok(());
}
}
}
fn live_environment() -> Result<BTreeMap<String, String>, Box<dyn Error>> {
let mut values = std::env::vars().collect::<BTreeMap<_, _>>();
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.env");
for line in std::fs::read_to_string(path)?.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
values.entry(key.trim().to_owned()).or_insert_with(|| {
value
.trim()
.trim_matches(|character| character == '\'' || character == '"')
.to_owned()
});
}
Ok(values)
}
fn required(
environment: &BTreeMap<String, String>,
key: &'static str,
) -> Result<String, Box<dyn Error>> {
environment
.get(key)
.filter(|value| !value.is_empty())
.cloned()
.ok_or_else(|| format!("missing live test setting {key}").into())
}
#[allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss
)] // Coordinates are already clamped inside the non-negative simulator extent.
fn avatar_forward_camera(
avatar: Vector3,
forward: Vector3,
) -> Result<(Vector3, Vector3), Box<dyn Error>> {
let length = (forward.x * forward.x + forward.y * forward.y + forward.z * forward.z).sqrt();
if !length.is_finite() || length < f32::EPSILON {
return Err("avatar facing direction unavailable".into());
}
let position = Vector3 {
x: avatar.x,
y: avatar.y,
z: avatar.z + 1.6,
};
Ok((
position,
Vector3 {
x: position.x + forward.x / length * 24.0,
y: position.y + forward.y / length * 24.0,
z: position.z + forward.z / length * 24.0,
},
))
}
#[test]
fn evidence_camera_uses_avatar_eye_position_and_facing() {
let avatar = Vector3 {
x: 10.0,
y: 20.0,
z: 30.0,
};
let (position, target) = avatar_forward_camera(avatar, Vector3::unit_y()).unwrap();
assert_eq!(
position,
Vector3 {
x: 10.0,
y: 20.0,
z: 31.6
}
);
assert_eq!(
target,
Vector3 {
x: 10.0,
y: 44.0,
z: 31.6
}
);
}