feat(vulkan): compare readback artifacts
This commit is contained in:
@@ -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()))
|
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)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
struct SmokeOptions {
|
struct SmokeOptions {
|
||||||
out: PathBuf,
|
out: PathBuf,
|
||||||
frames: u32,
|
frames: u32,
|
||||||
resize_frame: u32,
|
resize_frame: u32,
|
||||||
timeout_seconds: u64,
|
timeout_seconds: u64,
|
||||||
|
expected_readback: Option<PathBuf>,
|
||||||
mesh_input: MeshInput,
|
mesh_input: MeshInput,
|
||||||
texture_input: Option<TextureInput>,
|
texture_input: Option<TextureInput>,
|
||||||
}
|
}
|
||||||
@@ -216,6 +245,7 @@ impl SmokeOptions {
|
|||||||
let mut frames = DEFAULT_TARGET_FRAMES;
|
let mut frames = DEFAULT_TARGET_FRAMES;
|
||||||
let mut resize_frame = DEFAULT_RESIZE_FRAME;
|
let mut resize_frame = DEFAULT_RESIZE_FRAME;
|
||||||
let mut timeout_seconds = DEFAULT_TIMEOUT_SECONDS;
|
let mut timeout_seconds = DEFAULT_TIMEOUT_SECONDS;
|
||||||
|
let mut expected_readback = None;
|
||||||
let mut model_root = None;
|
let mut model_root = None;
|
||||||
let mut model_archive = None;
|
let mut model_archive = None;
|
||||||
let mut model_name = None;
|
let mut model_name = None;
|
||||||
@@ -258,6 +288,13 @@ impl SmokeOptions {
|
|||||||
.parse()
|
.parse()
|
||||||
.map_err(|_| "--timeout-seconds must be an integer".to_string())?;
|
.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" => {
|
||||||
model_root = Some(
|
model_root = Some(
|
||||||
iter.next()
|
iter.next()
|
||||||
@@ -413,6 +450,7 @@ impl SmokeOptions {
|
|||||||
frames,
|
frames,
|
||||||
resize_frame,
|
resize_frame,
|
||||||
timeout_seconds,
|
timeout_seconds,
|
||||||
|
expected_readback,
|
||||||
mesh_input,
|
mesh_input,
|
||||||
texture_input: texture_input.or(wear_material_input),
|
texture_input: texture_input.or(wear_material_input),
|
||||||
})
|
})
|
||||||
@@ -629,18 +667,26 @@ impl SmokeApp {
|
|||||||
.map_err(|err| format!("{}: {err}", self.options.out.display()))
|
.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
|
let stem = self
|
||||||
.options
|
.options
|
||||||
.out
|
.out
|
||||||
.file_stem()
|
.file_stem()
|
||||||
.and_then(std::ffi::OsStr::to_str)
|
.and_then(std::ffi::OsStr::to_str)
|
||||||
.unwrap_or("smoke");
|
.unwrap_or("smoke");
|
||||||
let path = self
|
self.options
|
||||||
.options
|
|
||||||
.out
|
.out
|
||||||
.with_file_name(format!("{stem}.readback-vkformat-{}.raw", artifact.format));
|
.with_file_name(format!("{stem}.readback-vkformat-{}.raw", artifact.format))
|
||||||
std::fs::write(&path, &artifact.bytes).map_err(|err| format!("{}: {err}", path.display()))
|
}
|
||||||
|
|
||||||
|
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> {
|
fn live_renderer_snapshot(&self) -> Option<RendererSnapshot> {
|
||||||
@@ -891,10 +937,20 @@ impl SmokeApp {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.and_then(|snapshot| snapshot.readback_artifact.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) {
|
||||||
self.error = Some(err);
|
Ok(path) => path,
|
||||||
event_loop.exit();
|
Err(err) => {
|
||||||
return;
|
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) {
|
let report = match self.render_report("passed", None) {
|
||||||
@@ -1479,6 +1535,7 @@ mod tests {
|
|||||||
frames: DEFAULT_TARGET_FRAMES,
|
frames: DEFAULT_TARGET_FRAMES,
|
||||||
resize_frame: DEFAULT_RESIZE_FRAME,
|
resize_frame: DEFAULT_RESIZE_FRAME,
|
||||||
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
|
timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
|
||||||
|
expected_readback: None,
|
||||||
mesh_input: MeshInput::SmokeTriangle,
|
mesh_input: MeshInput::SmokeTriangle,
|
||||||
texture_input: None,
|
texture_input: None,
|
||||||
})
|
})
|
||||||
@@ -1516,6 +1573,7 @@ mod tests {
|
|||||||
frames: DEFAULT_TARGET_FRAMES,
|
frames: DEFAULT_TARGET_FRAMES,
|
||||||
resize_frame: DEFAULT_RESIZE_FRAME,
|
resize_frame: DEFAULT_RESIZE_FRAME,
|
||||||
timeout_seconds: 45,
|
timeout_seconds: 45,
|
||||||
|
expected_readback: None,
|
||||||
mesh_input: MeshInput::SmokeTriangle,
|
mesh_input: MeshInput::SmokeTriangle,
|
||||||
texture_input: None,
|
texture_input: None,
|
||||||
})
|
})
|
||||||
@@ -1796,6 +1854,7 @@ mod tests {
|
|||||||
frames: DEFAULT_TARGET_FRAMES,
|
frames: DEFAULT_TARGET_FRAMES,
|
||||||
resize_frame: DEFAULT_RESIZE_FRAME,
|
resize_frame: DEFAULT_RESIZE_FRAME,
|
||||||
timeout_seconds: 7,
|
timeout_seconds: 7,
|
||||||
|
expected_readback: None,
|
||||||
mesh_input: MeshInput::SmokeTriangle,
|
mesh_input: MeshInput::SmokeTriangle,
|
||||||
texture_input: None,
|
texture_input: None,
|
||||||
},
|
},
|
||||||
@@ -1831,6 +1890,23 @@ mod tests {
|
|||||||
std::fs::remove_dir(root).expect("cleanup dir");
|
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]
|
#[test]
|
||||||
fn renderer_is_dropped_before_window() {
|
fn renderer_is_dropped_before_window() {
|
||||||
let log = Rc::new(RefCell::new(Vec::new()));
|
let log = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
|||||||
@@ -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
|
потому что bytes не перекодируются; JSON содержит actual raw enum. GOG selected `50` and produced a 4,147,200-byte file
|
||||||
for two 960x540 images. Артефакт остаётся локальным output и не попадает в Git.
|
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`
|
`Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28`
|
||||||
сохраняет source triangle order, а его positions и packed UV0 попадают в тот же
|
сохраняет source triangle order, а его positions и packed UV0 попадают в тот же
|
||||||
static vertex/index upload path. Для текущего диагностического viewer XZ bounds
|
static vertex/index upload path. Для текущего диагностического viewer XZ bounds
|
||||||
|
|||||||
Reference in New Issue
Block a user