#![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 = "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 (mut renderer_session, renderer_cancel) = login(&renderer_owner, &environment, "GRID_USER", "GRID_PASSWORD").await?; 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?; 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())?; let limits = VisionLimits { minimum_interval: Duration::ZERO, ..VisionLimits::default() }; println!( "LIVE_RENDER_SCOPE=max_distance_meters:{}", limits.max_distance_meters ); let vision = VisionService::new( Arc::new(DiagnosticSceneSource { inner: LibremetaverseSceneSource::new(&renderer_owner, limits), material_override: std::env::var("METACRATE_LIVE_RENDER_MATERIAL_OVERRIDE").ok(), }), limits, )? .prefer_gpu(); 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 next = vision .capture( &format!("live-renderer-evidence-{frame}"), 1, renderer_cancel.token(), ) .await?; 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")?; println!("LIVE_RENDER_OUTPUT={}", output.display()); println!("LIVE_RENDER_SHA256={}", capture.image_sha256); Ok::<(), Box>(()) } .await; let _ = renderer_session.logout(renderer_cancel.token()).await; result } struct DiagnosticSceneSource { inner: LibremetaverseSceneSource, 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 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 } ); }