diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index f229d95..92176e0 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -119,6 +119,7 @@ fn run(args: &[String]) -> Result { args.frames, &args.mission, loaded.object_count, + args.readback_out.as_deref(), ); } @@ -651,11 +652,18 @@ fn run_static_vulkan_mode( target_frames: u64, mission: &str, object_count: usize, + readback_out: Option<&std::path::Path>, ) -> Result { let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); - let mut app = - StaticVulkanApp::new(preview, preview_roots, target_frames, mission, object_count); + let mut app = StaticVulkanApp::new( + preview, + preview_roots, + target_frames, + mission, + object_count, + readback_out.map(std::path::Path::to_path_buf), + ); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); } @@ -674,6 +682,7 @@ struct StaticVulkanApp { target_frames: u64, mission: String, object_count: usize, + readback_out: Option, window_id: Option, window: Option, renderer: Option, @@ -689,6 +698,7 @@ impl StaticVulkanApp { target_frames: u64, mission: &str, object_count: usize, + readback_out: Option, ) -> Self { Self { mesh: preview.mesh, @@ -702,6 +712,7 @@ impl StaticVulkanApp { target_frames, mission: mission.to_string(), object_count, + readback_out, window_id: None, window: None, renderer: None, @@ -740,8 +751,32 @@ impl StaticVulkanApp { event_loop.exit(); return; } + let readback_path = match (&self.readback_out, &report.readback_artifact) { + (Some(path), Some(artifact)) => { + if let Some(parent) = path.parent() { + if let Err(err) = std::fs::create_dir_all(parent) { + self.error = Some(format!("{}: {err}", parent.display())); + event_loop.exit(); + return; + } + } + if let Err(err) = std::fs::write(path, &artifact.bytes) { + self.error = Some(format!("{}: {err}", path.display())); + event_loop.exit(); + return; + } + Some(path.display().to_string()) + } + (Some(_), None) => { + self.error = + Some("native Vulkan renderer produced no synchronized readback".to_string()); + event_loop.exit(); + return; + } + (None, _) => None, + }; self.output = Some(format!( - "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_bytes\":{},\"readback_hash\":{}}}", + "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_format\":{},\"readback_bytes\":{},\"readback_hash\":{},\"readback_path\":{}}}", json_string(self.camera_mode), json_string(&self.mission), self.object_count, @@ -756,8 +791,10 @@ impl StaticVulkanApp { report.renderer_report.swapchain_image_count, report.validation.warning_count, report.validation.error_count, + report.readback_artifact.as_ref().map_or(0, |artifact| artifact.format), report.renderer_report.readback_byte_count, report.renderer_report.readback_fnv1a64, + readback_path.as_deref().map_or_else(|| "null".to_string(), json_string), )); event_loop.exit(); } @@ -995,6 +1032,7 @@ struct Args { frames: u64, backend: RenderBackendMode, load_progress: Option, + readback_out: Option, preview_roots: NonZeroUsize, legacy_camera_capture: Option, } @@ -1012,6 +1050,7 @@ impl Args { let mut frames = 1; let mut backend = RenderBackendMode::Planning; let mut load_progress = None; + let mut readback_out = None; // A native static-Vulkan invocation is the usable mission preview, so // its default scope covers every root. `PreviewRoots` is clamped by the // runtime to the decoded mission length; an explicit --preview-roots N @@ -1060,6 +1099,13 @@ impl Args { .ok_or_else(|| "--load-progress requires a path".to_string())?, ); } + "--readback-out" => { + readback_out = Some( + iter.next() + .map(PathBuf::from) + .ok_or_else(|| "--readback-out requires a path".to_string())?, + ); + } "--preview-roots" => { preview_roots = iter .next() @@ -1084,12 +1130,16 @@ impl Args { if legacy_camera_capture.is_some() && backend != RenderBackendMode::StaticVulkan { return Err("--legacy-camera-capture requires --backend static-vulkan".to_string()); } + if readback_out.is_some() && backend != RenderBackendMode::StaticVulkan { + return Err("--readback-out requires --backend static-vulkan".to_string()); + } Ok(Self { root, mission, frames, backend, load_progress, + readback_out, preview_roots, legacy_camera_capture, }) @@ -1163,7 +1213,7 @@ fn json_hash(hash: &[u8; 32]) -> String { } fn usage() -> String { - "usage: fparkan-game --root --mission [--frames ] [--backend ] [--preview-roots ] [--legacy-camera-capture ] [--load-progress ]".to_string() + "usage: fparkan-game --root --mission [--frames ] [--backend ] [--preview-roots ] [--legacy-camera-capture ] [--readback-out ] [--load-progress ]".to_string() } #[cfg(test)] @@ -1228,6 +1278,7 @@ mod tests { frames: 3, backend: RenderBackendMode::Planning, load_progress: None, + readback_out: None, preview_roots: NonZeroUsize::MAX, legacy_camera_capture: None, }) @@ -1251,6 +1302,7 @@ mod tests { frames: 1, backend: RenderBackendMode::StaticVulkan, load_progress: None, + readback_out: None, preview_roots: NonZeroUsize::MAX, legacy_camera_capture: None, }) @@ -1276,6 +1328,34 @@ mod tests { ); } + #[test] + fn readback_output_requires_static_vulkan_and_is_retained() { + let common = [ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--readback-out", + "target/frame.raw", + ]; + assert_eq!( + Args::parse(&strings(&common)), + Err("--readback-out requires --backend static-vulkan".to_string()) + ); + let parsed = Args::parse(&strings(&[ + "--root", + "testdata/IS", + "--mission", + "MISSIONS/Autodemo.00/data.tma", + "--backend", + "static-vulkan", + "--readback-out", + "target/frame.raw", + ])) + .expect("valid readback output arguments"); + assert_eq!(parsed.readback_out, Some(PathBuf::from("target/frame.raw"))); + } + #[test] fn rejects_zero_static_preview_root_count() { let error = Args::parse(&strings(&[ @@ -1309,6 +1389,7 @@ mod tests { frames: 1, backend: RenderBackendMode::Planning, load_progress: Some(PathBuf::from("target/probe.txt")), + readback_out: None, preview_roots: NonZeroUsize::MAX, legacy_camera_capture: None, }) diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md index 82ed843..a32d68c 100644 --- a/docs/tomes/05-render.md +++ b/docs/tomes/05-render.md @@ -1498,6 +1498,15 @@ errors. This validates the offline handoff for one captured instant; it does not claim live camera tracking, material parity, or an original-frame pixel comparison. +For visual regression work, `fparkan-game --backend static-vulkan` also accepts +`--readback-out `. It writes the final synchronized Vulkan readback bytes +only after the renderer has completed its normal teardown evidence; the JSON +report records the raw Vulkan format, byte count, hash, and requested path. A +three-frame diagnostic AutoDemo run wrote 7,372,800 bytes at format `50` +(`VK_FORMAT_B8G8R8A8_UNORM`) with the same frame hash reported by the renderer. +The artifact is a Vulkan-side comparison input, not an asserted original-frame +image. + The first bounded launch showed that repeated fingerprinting, rather than Vulkan initialization, was the load-path bottleneck: each new MAT0/TEXM request re-opened an already decoded archive and re-hashed its entire source file.