#![cfg(feature = "live-grid")] use libremetaverse_types::{Vector3, compat::CancellationTokenSource}; use metacrate_grid_agent::{ EndpointUrl, GridConnection, GridSession, GridSessionBackend, LibremetaverseClientOwner, LibremetaverseSceneSource, SecretString, SessionSignal, 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 two dedicated accounts 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_the_target_accounts_scene() -> Result<(), Box> { let environment = live_environment()?; let target_owner = LibremetaverseClientOwner::new()?; let renderer_owner = LibremetaverseClientOwner::new()?; let (mut target_session, target_cancel) = login( &target_owner, &environment, "GRID_TEST_UNPRIVILEGED_USER", "GRID_TEST_UNPRIVILEGED_PASSWORD", ) .await?; let (mut renderer_session, renderer_cancel) = match login( &renderer_owner, &environment, "GRID_TEST_AUTHORIZED_USER", "GRID_TEST_AUTHORIZED_PASSWORD", ) .await { Ok(login) => login, Err(error) => { let _ = target_session.logout(target_cancel.token()).await; return Err(error); } }; let result = async { wait_ready("target", &mut target_session, &target_cancel).await?; wait_ready("renderer", &mut renderer_session, &renderer_cancel).await?; let renderer_start_simulator = renderer_owner .client() .network() .current_sim() .ok_or("renderer start simulator unavailable")?; let renderer_start_handle = renderer_start_simulator.handle; let renderer_start_position = conventional_start(&renderer_start_simulator); let target_simulator = target_owner .client() .network() .current_sim() .ok_or("target simulator unavailable")?; let target_position = target_owner.agent().sim_position(); let arrival = Vector3 { x: target_position.x - 10.0, y: target_position.y - 10.0, z: target_position.z + 2.0, }; if !renderer_owner .agent() .teleport_with_u_int64_vector3_vector3_cancellation_token( target_simulator.handle, arrival, target_position, Some(renderer_cancel.token()), ) .await? { return Err("teleport did not complete".into()); } tokio::time::sleep(Duration::from_secs(5)).await; if !renderer_owner .agent() .teleport_with_u_int64_vector3_vector3_cancellation_token( renderer_start_handle, renderer_start_position, Vector3 { x: renderer_start_position.x + 10.0, y: renderer_start_position.y, z: renderer_start_position.z, }, Some(renderer_cancel.token()), ) .await? { return Err("return teleport did not complete".into()); } tokio::time::sleep(Duration::from_secs(5)).await; let mut renderer_simulator = renderer_owner .client() .network() .current_sim() .ok_or("renderer simulator unavailable")?; let mut settled_position = renderer_owner.agent().sim_position(); if let Some(ground) = terrain_ground( &renderer_simulator, renderer_start_position.x, renderer_start_position.y, ) { let destination = Vector3 { x: renderer_start_position.x, y: renderer_start_position.y, z: ground + 2.0, }; if (settled_position.z - destination.z).abs() > 1.0 { if !renderer_owner .agent() .teleport_with_u_int64_vector3_vector3_cancellation_token( renderer_start_handle, destination, Vector3 { x: destination.x + 10.0, ..destination }, Some(renderer_cancel.token()), ) .await? { return Err("ground-settle teleport did not complete".into()); } tokio::time::sleep(Duration::from_secs(5)).await; renderer_simulator = renderer_owner .client() .network() .current_sim() .ok_or("settled renderer simulator unavailable")?; settled_position = renderer_owner.agent().sim_position(); } } wait_scene_settled(&renderer_owner).await?; let mut view_forward = Vector3::unit_x(); if let Some((destination, forward)) = scenic_location(&renderer_simulator) { if !renderer_owner .agent() .teleport_with_u_int64_vector3_vector3_cancellation_token( renderer_start_handle, destination, Vector3 { x: destination.x + forward.x, y: destination.y + forward.y, z: destination.z + forward.z, }, Some(renderer_cancel.token()), ) .await? { return Err("scenic teleport did not complete".into()); } wait_scene_settled(&renderer_owner).await?; renderer_simulator = renderer_owner .client() .network() .current_sim() .ok_or("scenic renderer simulator unavailable")?; settled_position = renderer_owner.agent().sim_position(); view_forward = forward; } { let prims = renderer_simulator .objects_primitives .read() .unwrap_or_else(std::sync::PoisonError::into_inner); let largest_scale = prims.values().fold(0.0_f32, |largest, prim| { largest.max(prim.scale.x.max(prim.scale.y).max(prim.scale.z)) }); println!( "LIVE_SCENE_CACHE=prims:{} roots:{} children:{} attachments:{} sculpted:{} largest_scale:{largest_scale:.2}", prims.len(), prims.values().filter(|prim| prim.parent_id == 0).count(), prims.values().filter(|prim| prim.parent_id != 0).count(), prims.values().filter(|prim| prim.is_attachment).count(), prims.values().filter(|prim| prim.sculpt.is_some()).count(), ); println!( "LIVE_SCENE_GEOMETRY=mesh:{} sculpt:{} legacy:{}", prims .values() .filter(|prim| prim.sculpt.as_ref().is_some_and(|sculpt| sculpt.type_() == libremetaverse_types::SculptType::Mesh)) .count(), prims .values() .filter(|prim| prim.sculpt.as_ref().is_some_and(|sculpt| sculpt.type_() != libremetaverse_types::SculptType::Mesh)) .count(), prims.values().filter(|prim| prim.sculpt.is_none()).count(), ); } let (camera_position, camera_target) = avatar_camera(settled_position, view_forward)?; renderer_owner .agent() .movement .camera .look_at_with_vector3_vector3(camera_position, camera_target)?; let limits = VisionLimits { minimum_interval: Duration::ZERO, ..VisionLimits::default() }; let vision = VisionService::new( Arc::new(LibremetaverseSceneSource::new(&renderer_owner, limits)), 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(8) .clamp(1, 16); 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)?; capture = Some(next); } 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; let _ = target_session.logout(target_cancel.token()).await; result } 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_precision_loss)] // Simulator dimensions are small region coordinates. fn conventional_start(simulator: &libremetaverse::Simulator) -> Vector3 { let x = 128.0_f32.min(simulator.size_x as f32 - 2.0); let y = 128.0_f32.min(simulator.size_y as f32 - 2.0); Vector3 { x, y, z: 50.0 } } #[allow( clippy::cast_possible_truncation, clippy::cast_possible_wrap, clippy::cast_sign_loss )] // Coordinates are already clamped inside the non-negative simulator extent. fn terrain_ground(simulator: &libremetaverse::Simulator, x: f32, y: f32) -> Option { let patches = simulator .terrain .read() .unwrap_or_else(std::sync::PoisonError::into_inner); let patch_x = (x as usize / 16) as i32; let patch_y = (y as usize / 16) as i32; let local_x = x as usize % 16; let local_y = y as usize % 16; patches .iter() .find(|patch| patch.x == patch_x && patch.y == patch_y) .and_then(|patch| patch.data.get(local_y * 16 + local_x)) .copied() } #[allow(clippy::cast_precision_loss)] // Terrain patch indexes are small region coordinates. fn scenic_location(simulator: &libremetaverse::Simulator) -> Option<(Vector3, Vector3)> { let roots = simulator .objects_primitives .read() .unwrap_or_else(std::sync::PoisonError::into_inner) .values() .filter(|prim| prim.parent_id == 0) .cloned() .collect::>(); let patches = simulator .terrain .read() .unwrap_or_else(std::sync::PoisonError::into_inner); patches .iter() .filter(|patch| patch.data.len() == 256) .filter_map(|patch| { let position = Vector3 { x: patch.x as f32 * 16.0 + 8.0, y: patch.y as f32 * 16.0 + 8.0, z: patch.data[8 * 16 + 8] + 2.0, }; if roots.iter().any(|prim| { let extent = prim.scale.x.max(prim.scale.y).max(prim.scale.z); let dx = prim.position.x - position.x; let dy = prim.position.y - position.y; let dz = prim.position.z - position.z; (dx * dx + dy * dy + dz * dz).sqrt() < extent * 0.5 + 3.0 }) { return None; } let mut score = 0.0_f32; let mut forward = Vector3::zero(); for prim in &roots { let dx = prim.position.x - position.x; let dy = prim.position.y - position.y; let horizontal = (dx * dx + dy * dy).sqrt(); let dz = prim.position.z - position.z; let extent = prim.scale.x.max(prim.scale.y).max(prim.scale.z); if !(10.0..=64.0).contains(&horizontal) || dz.abs() > 20.0 || !(2.0..=32.0).contains(&extent) { continue; } let weight = extent.min(10.0) / horizontal; score += extent.min(10.0); forward.x += dx * weight; forward.y += dy * weight; forward.z += dz.clamp(-horizontal * 0.35, horizontal * 0.35) * weight; } (score > 0.0).then_some((score, position, forward)) }) .max_by(|left, right| left.0.total_cmp(&right.0)) .map(|(_, position, forward)| (position, forward)) } fn avatar_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("renderer start direction unavailable".into()); } let position = Vector3 { x: avatar.x, y: avatar.y, z: avatar.z + 2.0, }; Ok(( position, Vector3 { x: position.x + forward.x / length * 30.0, y: position.y + forward.y / length * 30.0, z: position.z + forward.z / length * 30.0, }, )) }