2 Commits
3 changed files with 115 additions and 10 deletions
+85 -4
View File
@@ -119,6 +119,7 @@ fn run(args: &[String]) -> Result<String, String> {
args.frames, args.frames,
&args.mission, &args.mission,
loaded.object_count, loaded.object_count,
args.readback_out.as_deref(),
); );
} }
@@ -651,11 +652,18 @@ fn run_static_vulkan_mode(
target_frames: u64, target_frames: u64,
mission: &str, mission: &str,
object_count: usize, object_count: usize,
readback_out: Option<&std::path::Path>,
) -> Result<String, String> { ) -> Result<String, String> {
let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?;
event_loop.set_control_flow(ControlFlow::Poll); event_loop.set_control_flow(ControlFlow::Poll);
let mut app = let mut app = StaticVulkanApp::new(
StaticVulkanApp::new(preview, preview_roots, target_frames, mission, object_count); 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) { if let Err(err) = event_loop.run_app(&mut app) {
app.error = Some(format!("winit event loop: {err}")); app.error = Some(format!("winit event loop: {err}"));
} }
@@ -674,6 +682,7 @@ struct StaticVulkanApp {
target_frames: u64, target_frames: u64,
mission: String, mission: String,
object_count: usize, object_count: usize,
readback_out: Option<PathBuf>,
window_id: Option<WindowId>, window_id: Option<WindowId>,
window: Option<Window>, window: Option<Window>,
renderer: Option<VulkanSmokeRenderer>, renderer: Option<VulkanSmokeRenderer>,
@@ -689,6 +698,7 @@ impl StaticVulkanApp {
target_frames: u64, target_frames: u64,
mission: &str, mission: &str,
object_count: usize, object_count: usize,
readback_out: Option<PathBuf>,
) -> Self { ) -> Self {
Self { Self {
mesh: preview.mesh, mesh: preview.mesh,
@@ -702,6 +712,7 @@ impl StaticVulkanApp {
target_frames, target_frames,
mission: mission.to_string(), mission: mission.to_string(),
object_count, object_count,
readback_out,
window_id: None, window_id: None,
window: None, window: None,
renderer: None, renderer: None,
@@ -740,8 +751,32 @@ impl StaticVulkanApp {
event_loop.exit(); event_loop.exit();
return; 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!( 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.camera_mode),
json_string(&self.mission), json_string(&self.mission),
self.object_count, self.object_count,
@@ -756,8 +791,10 @@ impl StaticVulkanApp {
report.renderer_report.swapchain_image_count, report.renderer_report.swapchain_image_count,
report.validation.warning_count, report.validation.warning_count,
report.validation.error_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_byte_count,
report.renderer_report.readback_fnv1a64, report.renderer_report.readback_fnv1a64,
readback_path.as_deref().map_or_else(|| "null".to_string(), json_string),
)); ));
event_loop.exit(); event_loop.exit();
} }
@@ -995,6 +1032,7 @@ struct Args {
frames: u64, frames: u64,
backend: RenderBackendMode, backend: RenderBackendMode,
load_progress: Option<PathBuf>, load_progress: Option<PathBuf>,
readback_out: Option<PathBuf>,
preview_roots: NonZeroUsize, preview_roots: NonZeroUsize,
legacy_camera_capture: Option<PathBuf>, legacy_camera_capture: Option<PathBuf>,
} }
@@ -1012,6 +1050,7 @@ impl Args {
let mut frames = 1; let mut frames = 1;
let mut backend = RenderBackendMode::Planning; let mut backend = RenderBackendMode::Planning;
let mut load_progress = None; let mut load_progress = None;
let mut readback_out = None;
// A native static-Vulkan invocation is the usable mission preview, so // A native static-Vulkan invocation is the usable mission preview, so
// its default scope covers every root. `PreviewRoots` is clamped by the // its default scope covers every root. `PreviewRoots` is clamped by the
// runtime to the decoded mission length; an explicit --preview-roots N // 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())?, .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" => {
preview_roots = iter preview_roots = iter
.next() .next()
@@ -1084,12 +1130,16 @@ impl Args {
if legacy_camera_capture.is_some() && backend != RenderBackendMode::StaticVulkan { if legacy_camera_capture.is_some() && backend != RenderBackendMode::StaticVulkan {
return Err("--legacy-camera-capture requires --backend static-vulkan".to_string()); 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 { Ok(Self {
root, root,
mission, mission,
frames, frames,
backend, backend,
load_progress, load_progress,
readback_out,
preview_roots, preview_roots,
legacy_camera_capture, legacy_camera_capture,
}) })
@@ -1163,7 +1213,7 @@ fn json_hash(hash: &[u8; 32]) -> String {
} }
fn usage() -> String { fn usage() -> String {
"usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--load-progress <path>]".to_string() "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--readback-out <path>] [--load-progress <path>]".to_string()
} }
#[cfg(test)] #[cfg(test)]
@@ -1228,6 +1278,7 @@ mod tests {
frames: 3, frames: 3,
backend: RenderBackendMode::Planning, backend: RenderBackendMode::Planning,
load_progress: None, load_progress: None,
readback_out: None,
preview_roots: NonZeroUsize::MAX, preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None, legacy_camera_capture: None,
}) })
@@ -1251,6 +1302,7 @@ mod tests {
frames: 1, frames: 1,
backend: RenderBackendMode::StaticVulkan, backend: RenderBackendMode::StaticVulkan,
load_progress: None, load_progress: None,
readback_out: None,
preview_roots: NonZeroUsize::MAX, preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None, 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] #[test]
fn rejects_zero_static_preview_root_count() { fn rejects_zero_static_preview_root_count() {
let error = Args::parse(&strings(&[ let error = Args::parse(&strings(&[
@@ -1309,6 +1389,7 @@ mod tests {
frames: 1, frames: 1,
backend: RenderBackendMode::Planning, backend: RenderBackendMode::Planning,
load_progress: Some(PathBuf::from("target/probe.txt")), load_progress: Some(PathBuf::from("target/probe.txt")),
readback_out: None,
preview_roots: NonZeroUsize::MAX, preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None, legacy_camera_capture: None,
}) })
+21 -6
View File
@@ -53,13 +53,28 @@ system.
## Current stage model ## Current stage model
The active implementation plan has five dependency-ordered stages: The canonical Vulkan-revision plan has six dependency-ordered stages numbered
0--5. Keeping its original numbering matters: Stage 4 is an evidence-gated
animation/FX runtime, while Stage 5 is the mission/world vertical slice that
depends on it. They must not be reported as one completed stage.
1. reproducible Windows/Vulkan foundation; 0. reproducible Windows/Vulkan foundation;
2. paths, VFS and lossless archives; 1. paths, VFS and lossless archives;
3. prototype graph and prepared CPU assets; 2. prototype graph and prepared CPU assets;
4. static Vulkan model/terrain viewer; 3. static Vulkan model/terrain viewer;
5. animation/FX runtime and transactional map/mission/world vertical slice. 4. animation and FX runtime, with reference-only semantics until runtime
captures close the x87 and effect-lifecycle evidence gaps;
5. transactional map, mission and world vertical slice, rendered from the
same immutable snapshot through Vulkan.
This is a local, Windows-only adoption of the Notion page "План реализации
stage 0--5: Vulkan revision" (reviewed on 2026-07-18). Its former
Linux/macOS portability and hosted-CI goals are intentionally not imported:
they conflict with the current supported-platform boundary above. The
portable architectural rules that do apply -- backend-neutral commands,
runtime capability queries, narrow Vulkan/FFI `unsafe`, offline shader
validation, and command capture before pixel comparison -- are retained in
this audit and the rendering tome.
Contract tests and failure tests precede implementation. Synthetic checks never Contract tests and failure tests precede implementation. Synthetic checks never
read licensed roots; licensed corpus checks use absolute paths from the local read licensed roots; licensed corpus checks use absolute paths from the local
+9
View File
@@ -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 not claim live camera tracking, material parity, or an original-frame pixel
comparison. comparison.
For visual regression work, `fparkan-game --backend static-vulkan` also accepts
`--readback-out <path>`. 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 The first bounded launch showed that repeated fingerprinting, rather than
Vulkan initialization, was the load-path bottleneck: each new MAT0/TEXM request 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. re-opened an already decoded archive and re-hashed its entire source file.