Blacksite/xtask/src/bake_navigation.rs

329 lines
12 KiB
Rust

//! 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<PathBuf>,
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::<Vec<_>>()
.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<Vec<PathBuf>, String> {
let levels = project_root.join("assets/levels");
if !levels.is_dir() {
return Err(format!("missing levels directory {}", levels.display()));
}
let mut scenes: Vec<PathBuf> = 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<PathBuf, String> {
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<PathBuf, String> {
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}"
);
}
}