feat(vulkan): compare readback artifacts
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m8s
Test / Test (push) Skipped
Test / Render parity (push) Skipped

This commit is contained in:
2026-07-18 09:13:52 +04:00
parent d4aa7a223f
commit 172f667bc6
2 changed files with 90 additions and 9 deletions
+82 -6
View File
@@ -107,12 +107,41 @@ fn remove_stale_output(options: &SmokeOptions) -> Result<(), String> {
std::fs::remove_file(&options.out).map_err(|err| format!("{}: {err}", options.out.display()))
}
fn compare_readback_files(
expected: &std::path::Path,
actual: &std::path::Path,
) -> Result<(), String> {
let expected_bytes =
std::fs::read(expected).map_err(|err| format!("{}: {err}", expected.display()))?;
let actual_bytes =
std::fs::read(actual).map_err(|err| format!("{}: {err}", actual.display()))?;
if expected_bytes.len() != actual_bytes.len() {
return Err(format!(
"pixel readback length mismatch: expected={} actual={}",
expected_bytes.len(),
actual_bytes.len()
));
}
if let Some((offset, (expected_byte, actual_byte))) = expected_bytes
.iter()
.zip(&actual_bytes)
.enumerate()
.find(|(_, (expected_byte, actual_byte))| expected_byte != actual_byte)
{
return Err(format!(
"pixel readback mismatch at byte {offset}: expected={expected_byte} actual={actual_byte}"
));
}
Ok(())
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct SmokeOptions {
out: PathBuf,
frames: u32,
resize_frame: u32,
timeout_seconds: u64,
expected_readback: Option<PathBuf>,
mesh_input: MeshInput,
texture_input: Option<TextureInput>,
}
@@ -216,6 +245,7 @@ impl SmokeOptions {
let mut frames = DEFAULT_TARGET_FRAMES;
let mut resize_frame = DEFAULT_RESIZE_FRAME;
let mut timeout_seconds = DEFAULT_TIMEOUT_SECONDS;
let mut expected_readback = None;
let mut model_root = None;
let mut model_archive = None;
let mut model_name = None;
@@ -258,6 +288,13 @@ impl SmokeOptions {
.parse()
.map_err(|_| "--timeout-seconds must be an integer".to_string())?;
}
"--expected-readback" => {
expected_readback = Some(
iter.next()
.map(PathBuf::from)
.ok_or_else(|| "--expected-readback requires a path".to_string())?,
);
}
"--model-root" => {
model_root = Some(
iter.next()
@@ -413,6 +450,7 @@ impl SmokeOptions {
frames,
resize_frame,
timeout_seconds,
expected_readback,
mesh_input,
texture_input: texture_input.or(wear_material_input),
})
@@ -629,18 +667,26 @@ impl SmokeApp {
.map_err(|err| format!("{}: {err}", self.options.out.display()))
}
fn write_readback_artifact(&self, artifact: &VulkanReadbackArtifact) -> Result<(), String> {
fn readback_artifact_path(&self, artifact: &VulkanReadbackArtifact) -> PathBuf {
let stem = self
.options
.out
.file_stem()
.and_then(std::ffi::OsStr::to_str)
.unwrap_or("smoke");
let path = self
.options
self.options
.out
.with_file_name(format!("{stem}.readback-vkformat-{}.raw", artifact.format));
std::fs::write(&path, &artifact.bytes).map_err(|err| format!("{}: {err}", path.display()))
.with_file_name(format!("{stem}.readback-vkformat-{}.raw", artifact.format))
}
fn write_readback_artifact(
&self,
artifact: &VulkanReadbackArtifact,
) -> Result<PathBuf, String> {
let path = self.readback_artifact_path(artifact);
std::fs::write(&path, &artifact.bytes)
.map_err(|err| format!("{}: {err}", path.display()))
.map(|()| path)
}
fn live_renderer_snapshot(&self) -> Option<RendererSnapshot> {
@@ -891,11 +937,21 @@ impl SmokeApp {
.as_ref()
.and_then(|snapshot| snapshot.readback_artifact.as_ref())
{
if let Err(err) = self.write_readback_artifact(artifact) {
let path = match self.write_readback_artifact(artifact) {
Ok(path) => path,
Err(err) => {
self.error = Some(err);
event_loop.exit();
return;
}
};
if let Some(expected) = self.options.expected_readback.as_ref() {
if let Err(err) = compare_readback_files(expected, &path) {
self.error = Some(err);
event_loop.exit();
return;
}
}
}
let report = match self.render_report("passed", None) {
Ok(report) => report,
@@ -1479,6 +1535,7 @@ mod tests {
frames: DEFAULT_TARGET_FRAMES,
resize_frame: DEFAULT_RESIZE_FRAME,
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
expected_readback: None,
mesh_input: MeshInput::SmokeTriangle,
texture_input: None,
})
@@ -1516,6 +1573,7 @@ mod tests {
frames: DEFAULT_TARGET_FRAMES,
resize_frame: DEFAULT_RESIZE_FRAME,
timeout_seconds: 45,
expected_readback: None,
mesh_input: MeshInput::SmokeTriangle,
texture_input: None,
})
@@ -1796,6 +1854,7 @@ mod tests {
frames: DEFAULT_TARGET_FRAMES,
resize_frame: DEFAULT_RESIZE_FRAME,
timeout_seconds: 7,
expected_readback: None,
mesh_input: MeshInput::SmokeTriangle,
texture_input: None,
},
@@ -1831,6 +1890,23 @@ mod tests {
std::fs::remove_dir(root).expect("cleanup dir");
}
#[test]
fn readback_comparator_accepts_exact_bytes_and_reports_first_difference() {
let root = std::env::temp_dir().join(format!("fparkan-readback-{}", std::process::id()));
std::fs::create_dir_all(&root).expect("temp dir");
let expected = root.join("expected.raw");
let actual = root.join("actual.raw");
std::fs::write(&expected, [1_u8, 2, 3]).expect("expected");
std::fs::write(&actual, [1_u8, 2, 3]).expect("actual");
assert_eq!(compare_readback_files(&expected, &actual), Ok(()));
std::fs::write(&actual, [1_u8, 9, 3]).expect("different actual");
assert_eq!(
compare_readback_files(&expected, &actual),
Err("pixel readback mismatch at byte 1: expected=2 actual=9".to_string())
);
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn renderer_is_dropped_before_window() {
let log = Rc::new(RefCell::new(Vec::new()));
+5
View File
@@ -901,6 +901,11 @@ Smoke также сохраняет raw artifact рядом с JSON: `<report-st
потому что bytes не перекодируются; JSON содержит actual raw enum. GOG selected `50` and produced a 4,147,200-byte file
for two 960x540 images. Артефакт остаётся локальным output и не попадает в Git.
Smoke option `--expected-readback <path>` выполняет exact byte comparison после
readback и завершает run с первым differing byte или length mismatch. Fresh GOG
run с предыдущим format-50 artifact прошёл этот gate. Это regression contract
нашего static Vulkan viewer, не comparison с оригинальным renderer.
`Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28`
сохраняет source triangle order, а его positions и packed UV0 попадают в тот же
static vertex/index upload path. Для текущего диагностического viewer XZ bounds