//! Deterministically bake navigation artifacts from authored scene components. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use scene::navigation::{ bake_navigation, navigation_artifact_content_hash, resolve_navigation_bake_jobs, write_navigation_artifact, NavigationBakeArtifact, NavigationBakeJob, }; use walkdir::WalkDir; fn main() { if let Err(error) = run() { eprintln!("bake-navigation: {error}"); std::process::exit(1); } } fn run() -> Result<(), String> { let mut project_root = PathBuf::from("."); let mut requested_scene = None; let mut check = false; let mut args = std::env::args().skip(1); while let Some(argument) = args.next() { match argument.as_str() { "--project" => { project_root = PathBuf::from( args.next() .ok_or_else(|| "--project requires a path".to_string())?, ); } "--scene" => { requested_scene = Some(PathBuf::from(args.next().ok_or_else(|| { "--scene requires a project-relative path".to_string() })?)); } "--check" => check = true, _ => return Err(format!("unknown argument `{argument}`")), } } bake_project(project_root, requested_scene, check) } #[derive(Debug)] struct BakeRequest { source_path: PathBuf, output: PathBuf, job: NavigationBakeJob, } fn bake_project( project_root: PathBuf, requested_scene: Option, check: bool, ) -> Result<(), String> { let project_root = project_root .canonicalize() .map_err(|error| format!("could not resolve project root: {error}"))?; let scenes = match requested_scene { Some(scene) => vec![safe_project_path(&project_root, &scene)?], None => discover_scenes(&project_root)?, }; let mut requests = Vec::new(); for scene_path in scenes { let jobs = resolve_navigation_bake_jobs(&project_root, &scene_path, None) .map_err(|error| format!("{}: {error}", scene_path.display()))?; for job in jobs { let output = safe_artifact_path(&project_root, &job.artifact_path)?; requests.push(BakeRequest { source_path: scene_path.clone(), output, job, }); } } reject_duplicate_artifact_ownership(&requests)?; let artifact_count = requests.len(); for request in requests { if check { let fresh = bake_navigation(&request.job.input) .map_err(|error| owner_error(&request, &format!("rebake failed: {error}")))?; let existing = scene::navigation::read_navigation_artifact(&request.output) .map_err(|error| owner_error(&request, &error))?; verify_checked_artifact(&request, &existing, &fresh)?; println!("current {}", request.output.display()); } else { let artifact = bake_navigation(&request.job.input) .map_err(|error| owner_error(&request, &format!("bake failed: {error}")))?; write_navigation_artifact(&request.output, &artifact) .map_err(|error| owner_error(&request, &error))?; println!("baked {}", request.output.display()); } } println!("bake-navigation: {artifact_count} artifacts"); Ok(()) } fn verify_checked_artifact( request: &BakeRequest, existing: &NavigationBakeArtifact, fresh: &NavigationBakeArtifact, ) -> Result<(), String> { if existing.is_stale_for(&request.job.input) { return Err(owner_error( request, "stored artifact fingerprint is stale; rerun `cargo bake-navigation --project .`", )); } let existing_hash = navigation_artifact_content_hash(existing).map_err(|error| owner_error(request, &error))?; let fresh_hash = navigation_artifact_content_hash(fresh).map_err(|error| owner_error(request, &error))?; if existing_hash != fresh_hash { return Err(owner_error( request, &format!( "stored artifact content differs from a deterministic fresh bake (stored {existing_hash}, fresh {fresh_hash}); rerun `cargo bake-navigation --project .`" ), )); } Ok(()) } fn owner_error(request: &BakeRequest, message: &str) -> String { format!( "{}: Navigation Bounds actor `{}` in {}: {message}", request.output.display(), request.job.input.bounds_actor_id, request.source_path.display() ) } fn reject_duplicate_artifact_ownership(requests: &[BakeRequest]) -> Result<(), String> { let mut owners = BTreeMap::<&Path, Vec<&BakeRequest>>::new(); for request in requests { owners.entry(&request.output).or_default().push(request); } let Some((output, duplicate_owners)) = owners.into_iter().find(|(_, owners)| owners.len() > 1) else { return Ok(()); }; let owners = duplicate_owners .iter() .map(|request| { format!( "actor `{}` in {}", request.job.input.bounds_actor_id, request.source_path.display() ) }) .collect::>() .join(", "); Err(format!( "navigation artifact `{}` has duplicate owners: {owners}. Give each Navigation Bounds actor a unique artifact path under {} and rerun the bake", output.display(), shared::NAVIGATION_GENERATED_ARTIFACT_DIRECTORY )) } fn discover_scenes(project_root: &Path) -> Result, String> { let levels = project_root.join("assets/levels"); if !levels.is_dir() { return Err(format!("missing levels directory {}", levels.display())); } let mut scenes: Vec = WalkDir::new(&levels) .follow_links(false) .into_iter() .filter_map(Result::ok) .filter(|entry| entry.file_type().is_file()) .map(|entry| entry.into_path()) .filter(|path| { path.to_string_lossy().ends_with(".scn.ron") && path .strip_prefix(project_root) .is_ok_and(scene::is_runtime_package_asset) }) .collect(); scenes.sort(); Ok(scenes) } fn safe_artifact_path(project_root: &Path, authored: &str) -> Result { let normalized = shared::navigation_generated_artifact_path(authored) .map_err(|error| format!("navigation artifact `{authored}` is invalid: {error}"))?; safe_project_path(project_root, Path::new(&normalized)) } fn safe_project_path(project_root: &Path, relative: &Path) -> Result { if relative.is_absolute() || relative .components() .any(|component| matches!(component, std::path::Component::ParentDir)) { return Err(format!( "project-relative path `{}` is unsafe", relative.display() )); } Ok(project_root.join(relative)) } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU64, Ordering}; static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(1); fn fixture_root() -> PathBuf { let root = std::env::temp_dir().join(format!( "blacksite-navigation-bake-{}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir_all(root.join("assets/levels")).unwrap(); root } fn navigation_scene(actor_id: &str, artifact_path: &str) -> String { let mut bounds = shared::NavigationBounds::for_actor(actor_id); bounds.artifact_path = artifact_path.to_string(); let bounds = ron::to_string(&bounds).unwrap(); format!( "(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":({actor_id:?}),\"shared::navigation::NavigationBounds\":{bounds},}})}})" ) } #[test] fn duplicate_ownership_is_rejected_before_any_artifact_write() { let root = fixture_root(); let artifact_path = "assets/navigation/generated/shared.nav.ron"; std::fs::write( root.join("assets/levels/first.scn.ron"), navigation_scene("first-bounds", artifact_path), ) .unwrap(); std::fs::write( root.join("assets/levels/second.scn.ron"), navigation_scene("second-bounds", artifact_path), ) .unwrap(); let error = bake_project(root.clone(), None, false).unwrap_err(); assert!(error.contains("duplicate owners"), "{error}"); assert!(error.contains("first-bounds"), "{error}"); assert!(error.contains("second-bounds"), "{error}"); assert!(!root.join(artifact_path).exists()); std::fs::remove_dir_all(root).unwrap(); } #[test] fn artifact_output_must_use_generated_navigation_directory() { let root = Path::new("/project"); assert!(safe_artifact_path(root, "assets/navigation/manual.nav.ron").is_err()); assert_eq!( safe_artifact_path(root, "assets\\navigation\\generated\\bounds.nav.ron").unwrap(), root.join("assets/navigation/generated/bounds.nav.ron") ); } #[test] fn scene_discovery_ignores_transient_hidden_snapshots() { let root = fixture_root(); std::fs::write( root.join("assets/levels/main.scn.ron"), "(schema_version:4,resources:{},entities:{})", ) .unwrap(); std::fs::write( root.join("assets/levels/.pie_session.scn.ron"), "(resources:{},entities:{})", ) .unwrap(); let scenes = discover_scenes(&root).unwrap(); assert_eq!(scenes, [root.join("assets/levels/main.scn.ron")]); std::fs::remove_dir_all(root).unwrap(); } #[test] fn check_reports_owner_when_content_differs_despite_current_fingerprint() { let input = scene::navigation::NavigationBakeInput { source_scene: "assets/levels/main.scn.ron".into(), bounds_actor_id: "main-bounds".into(), center: [0.0, 0.0, 0.0], half_extents: [5.0, 2.0, 5.0], agent: shared::NavigationAgentProfile { min_region_size: 1, merge_region_size: 2, ..Default::default() }, geometry: vec![scene::navigation::NavigationGeometryInput { actor_id: "floor".into(), triangles: vec![ [[-5.0, 0.0, -5.0], [5.0, 0.0, 5.0], [5.0, 0.0, -5.0]], [[-5.0, 0.0, -5.0], [-5.0, 0.0, 5.0], [5.0, 0.0, 5.0]], ], }], obstacles: Vec::new(), areas: Vec::new(), links: Vec::new(), samples: Vec::new(), }; let fresh = bake_navigation(&input).unwrap(); let mut stored = fresh.clone(); stored.bounds_actor_id = "unexpected-owner".into(); let request = BakeRequest { source_path: PathBuf::from("/project/assets/levels/main.scn.ron"), output: PathBuf::from("/project/assets/navigation/generated/main-bounds.nav.ron"), job: NavigationBakeJob { artifact_path: "assets/navigation/generated/main-bounds.nav.ron".into(), input, }, }; let error = verify_checked_artifact(&request, &stored, &fresh).unwrap_err(); assert!(error.contains("main-bounds"), "{error}"); assert!(error.contains("assets/levels/main.scn.ron"), "{error}"); assert!( error.contains("differs from a deterministic fresh bake"), "{error}" ); } }