#![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> { 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> { 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> { 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>(()); } } 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> { 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::().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>(()) } .await; scene_source.stop_prefetch().await; world_loop .shutdown() .map_err(|_| "world loop thread panicked")?; let _ = renderer_session.logout(renderer_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> { 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::>(); renderables.sort_by(|left, right| left.0.cmp(&right.0)); let renderables = renderables .into_iter() .map(|(_, renderable)| renderable) .collect::>(); 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::>(); 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, material_override: Option, } 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, user: &'static str, password: &'static str, ) -> Result<(Box, CancellationTokenSource), Box> { 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, cancellation: &CancellationTokenSource, ) -> Result<(), Box> { 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> { 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, Box> { let mut values = std::env::vars().collect::>(); 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, key: &'static str, ) -> Result> { 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> { 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 } ); }