#![forbid(unsafe_code)] #![cfg_attr( test, allow( clippy::expect_used, clippy::needless_raw_string_hashes, clippy::panic, clippy::unwrap_used ) )] #![allow(clippy::print_stderr, clippy::print_stdout)] //! Native Vulkan smoke runner entrypoint. use fparkan_platform::RenderRequest; use fparkan_platform_winit::{window_native_handles, WinitWindowPlan}; use fparkan_render_vulkan::{ project_land_msh_to_static_mesh, project_msh_to_static_mesh, VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanValidationReport, }; use serde::Serialize; use std::path::PathBuf; use std::process::Command; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::dpi::PhysicalSize; use winit::event::StartCause; use winit::event::WindowEvent; use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; use winit::window::{Window, WindowId}; const SCHEMA_VERSION: &str = "fparkan-native-smoke-v1"; const DEFAULT_TARGET_FRAMES: u32 = 300; const DEFAULT_RESIZE_FRAME: u32 = 120; const DEFAULT_RESIZE_WIDTH: u32 = 960; const DEFAULT_RESIZE_HEIGHT: u32 = 540; const DEFAULT_TIMEOUT_SECONDS: u64 = 120; fn main() { let args = std::env::args().skip(1).collect::>(); let code = match run(&args) { Ok(output) => { println!("{output}"); 0 } Err(err) => { eprintln!("{err}"); 2 } }; std::process::exit(code); } fn run(args: &[String]) -> Result { let options = SmokeOptions::parse(args)?; let mesh = options.load_mesh()?; let materials = options.load_materials(&mesh)?; remove_stale_output(&options)?; let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); let completed = Arc::new(AtomicBool::new(false)); let progress = Arc::new(SharedSmokeProgress::default()); spawn_timeout_watchdog( options.clone(), Arc::clone(&completed), Arc::clone(&progress), ); let mut app = SmokeApp::new(options, mesh, materials, completed, progress); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); } app.finish() } fn spawn_timeout_watchdog( options: SmokeOptions, completed: Arc, progress: Arc, ) { std::thread::spawn(move || { std::thread::sleep(Duration::from_secs(options.timeout_seconds)); if completed.load(Ordering::SeqCst) { return; } let failure_reason = format!( "native smoke timed out after {} seconds", options.timeout_seconds ); if let Ok(report) = render_timeout_failure_report(&options, &failure_reason, &progress) { if let Some(parent) = options.out.parent() { let _ = std::fs::create_dir_all(parent); } let _ = std::fs::write(&options.out, report); } eprintln!("{failure_reason}"); std::process::exit(2); }); } fn remove_stale_output(options: &SmokeOptions) -> Result<(), String> { if !options.out.exists() { return Ok(()); } 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_format = readback_format_from_path(expected)?; let actual_format = readback_format_from_path(actual)?; if expected_format != actual_format { return Err(format!( "pixel readback format mismatch: expected={expected_format} actual={actual_format}" )); } 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(()) } fn readback_format_from_path(path: &std::path::Path) -> Result { let name = path .file_name() .and_then(std::ffi::OsStr::to_str) .ok_or_else(|| { format!( "pixel readback artifact has no UTF-8 file name: {}", path.display() ) })?; let prefix = ".readback-vkformat-"; let raw = name .strip_suffix(".raw") .and_then(|stem| stem.rsplit_once(prefix).map(|(_, raw)| raw)) .ok_or_else(|| { format!( "pixel readback artifact lacks format identity: {}", path.display() ) })?; raw.parse::().map_err(|_| { format!( "pixel readback artifact has invalid format identity: {}", path.display() ) }) } #[derive(Clone, Debug, Eq, PartialEq)] struct SmokeOptions { out: PathBuf, frames: u32, resize_frame: u32, timeout_seconds: u64, expected_readback: Option, mesh_input: MeshInput, texture_input: Option, } #[derive(Clone, Debug, Eq, PartialEq)] struct ResourceInput { root: PathBuf, archive: String, name: String, } #[derive(Clone, Debug, Eq, PartialEq)] struct WearMaterialInput { root: PathBuf, archive: String, name: String, material_index: u16, } #[derive(Clone, Debug, Eq, PartialEq)] struct WearInput { root: PathBuf, archive: String, name: String, } #[derive(Clone, Debug, Eq, PartialEq)] enum TextureInput { Direct(ResourceInput), WearMaterial(WearMaterialInput), WearBatchMaterials(WearInput), } impl TextureInput { fn source(&self) -> &'static str { match self { Self::Direct(_) => "original-texm", Self::WearMaterial(_) => "original-wear-mat0-texm", Self::WearBatchMaterials(_) => "original-wear-mat0-texm-batch", } } fn archive(&self) -> &str { match self { Self::Direct(input) => &input.archive, Self::WearMaterial(_) | Self::WearBatchMaterials(_) => "Textures.lib", } } fn requested_name(&self) -> &str { match self { Self::Direct(input) => &input.name, // The selected name comes from MAT0 at load time and is not a CLI request. Self::WearMaterial(_) | Self::WearBatchMaterials(_) => "", } } } #[derive(Clone, Debug, Eq, PartialEq)] enum MeshInput { SmokeTriangle, Msh { root: PathBuf, archive: String, name: String, }, LandMsh { path: PathBuf, }, } impl MeshInput { fn kind(&self) -> &'static str { match self { Self::SmokeTriangle => "synthetic-smoke-triangle", Self::Msh { .. } => "original-msh", Self::LandMsh { .. } => "original-land-msh", } } fn archive(&self) -> &str { match self { Self::Msh { archive, .. } => archive, Self::SmokeTriangle | Self::LandMsh { .. } => "", } } fn name(&self) -> &str { match self { Self::SmokeTriangle => "", Self::Msh { name, .. } => name, Self::LandMsh { .. } => "Land.msh", } } } impl SmokeOptions { #[allow(clippy::too_many_lines)] fn parse(args: &[String]) -> Result { let mut out = None; 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; let mut terrain_path = None; let mut texture_root = None; let mut texture_archive = None; let mut texture_name = None; let mut wear_root = None; let mut wear_archive = None; let mut wear_name = None; let mut material_index = None; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { "--out" => { out = Some( iter.next() .map(PathBuf::from) .ok_or_else(|| "--out requires a path".to_string())?, ); } "--frames" => { frames = iter .next() .ok_or_else(|| "--frames requires a value".to_string())? .parse() .map_err(|_| "--frames must be an integer".to_string())?; } "--resize-frame" => { resize_frame = iter .next() .ok_or_else(|| "--resize-frame requires a value".to_string())? .parse() .map_err(|_| "--resize-frame must be an integer".to_string())?; } "--timeout-seconds" => { timeout_seconds = iter .next() .ok_or_else(|| "--timeout-seconds requires a value".to_string())? .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() .map(PathBuf::from) .ok_or_else(|| "--model-root requires a path".to_string())?, ); } "--model-archive" => { model_archive = Some( iter.next() .cloned() .ok_or_else(|| "--model-archive requires a value".to_string())?, ); } "--model-name" => { model_name = Some( iter.next() .cloned() .ok_or_else(|| "--model-name requires a value".to_string())?, ); } "--terrain-path" => { terrain_path = Some( iter.next() .map(PathBuf::from) .ok_or_else(|| "--terrain-path requires a path".to_string())?, ); } "--texture-root" => { texture_root = Some( iter.next() .map(PathBuf::from) .ok_or_else(|| "--texture-root requires a path".to_string())?, ); } "--texture-archive" => { texture_archive = Some( iter.next() .cloned() .ok_or_else(|| "--texture-archive requires a value".to_string())?, ); } "--texture-name" => { texture_name = Some( iter.next() .cloned() .ok_or_else(|| "--texture-name requires a value".to_string())?, ); } "--wear-root" => { wear_root = Some( iter.next() .map(PathBuf::from) .ok_or_else(|| "--wear-root requires a path".to_string())?, ); } "--wear-archive" => { wear_archive = Some( iter.next() .cloned() .ok_or_else(|| "--wear-archive requires a value".to_string())?, ); } "--wear-name" => { wear_name = Some( iter.next() .cloned() .ok_or_else(|| "--wear-name requires a value".to_string())?, ); } "--material-index" => { material_index = Some( iter.next() .ok_or_else(|| "--material-index requires a value".to_string())? .parse() .map_err(|_| "--material-index must be a u16".to_string())?, ); } _ => return Err(format!("unknown native smoke option: {arg}")), } } let out = out.ok_or_else(|| "missing --out".to_string())?; if frames < DEFAULT_TARGET_FRAMES { return Err(format!( "native smoke requires --frames >= {DEFAULT_TARGET_FRAMES}" )); } if timeout_seconds == 0 { return Err("native smoke requires --timeout-seconds >= 1".to_string()); } let mesh_input = match (terrain_path, model_root, model_archive, model_name) { (Some(path), None, None, None) => MeshInput::LandMsh { path }, (None, None, None, None) => MeshInput::SmokeTriangle, (None, Some(root), Some(archive), Some(name)) => MeshInput::Msh { root, archive, name, }, (None, _, _, _) => { return Err( "--model-root, --model-archive and --model-name must be supplied together" .to_string(), ); } _ => return Err("--terrain-path cannot be combined with --model-*".to_string()), }; let texture_input = match (texture_root, texture_archive, texture_name) { (None, None, None) => None, (Some(root), Some(archive), Some(name)) => Some(TextureInput::Direct(ResourceInput { root, archive, name, })), _ => { return Err( "--texture-root, --texture-archive and --texture-name must be supplied together" .to_string(), ); } }; let wear_material_input = match (wear_root, wear_archive, wear_name, material_index) { (None, None, None, None) => None, (Some(root), Some(archive), Some(name), Some(material_index)) => { Some(TextureInput::WearMaterial(WearMaterialInput { root, archive, name, material_index, })) } (Some(root), Some(archive), Some(name), None) => { Some(TextureInput::WearBatchMaterials(WearInput { root, archive, name, })) } _ => { return Err( "--wear-root, --wear-archive and --wear-name must be supplied together; --material-index is optional" .to_string(), ); } }; if texture_input.is_some() && wear_material_input.is_some() { return Err( "manual --texture-* input cannot be combined with --wear-* material input" .to_string(), ); } Ok(Self { out, frames, resize_frame, timeout_seconds, expected_readback, mesh_input, texture_input: texture_input.or(wear_material_input), }) } fn load_mesh(&self) -> Result { match &self.mesh_input { MeshInput::SmokeTriangle => Ok(VulkanStaticMesh::smoke_triangle()), MeshInput::Msh { root, archive, name, } => { let model = fparkan_inspection::load_model_from_root(root, archive, name)?; project_msh_to_static_mesh(&model).map_err(|err| err.to_string()) } MeshInput::LandMsh { path } => { let terrain = fparkan_inspection::load_land_msh_from_path(path)?; project_land_msh_to_static_mesh(&terrain).map_err(|err| err.to_string()) } } } fn load_materials(&self, mesh: &VulkanStaticMesh) -> Result, String> { let Some(input) = self.texture_input.as_ref() else { return Ok(Vec::new()); }; match input { TextureInput::Direct(input) => Ok(vec![Self::load_material( 0, { let image = fparkan_inspection::load_texture_mip0_rgba8_from_root( &input.root, &input.archive, &input.name, )?; (image.width, image.height, image.rgba8) }, input.name.clone(), )]), TextureInput::WearMaterial(input) => { let selected = fparkan_inspection::load_wear_material_texture_mip0_rgba8_from_root( &input.root, &input.archive, &input.name, input.material_index, )?; Ok(vec![Self::load_material( input.material_index, ( selected.image.width, selected.image.height, selected.image.rgba8, ), selected.texture_name, )]) } TextureInput::WearBatchMaterials(input) => { let mut selectors = mesh .draw_ranges .iter() .map(|range| range.material_index) .collect::>(); selectors.sort_unstable(); selectors.dedup(); selectors .into_iter() .map(|material_index| { let selected = fparkan_inspection::load_wear_material_texture_mip0_rgba8_from_root( &input.root, &input.archive, &input.name, material_index, )?; Ok(Self::load_material( material_index, ( selected.image.width, selected.image.height, selected.image.rgba8, ), selected.texture_name, )) }) .collect() } } } fn load_material( material_index: u16, image: (u32, u32, Vec), selected_name: String, ) -> LoadedTexture { LoadedTexture { material_index, texture: VulkanStaticTexture { width: image.0, height: image.1, rgba8: image.2, }, selected_name, } } } #[derive(Clone, Debug)] struct LoadedTexture { material_index: u16, texture: VulkanStaticTexture, selected_name: String, } struct SmokeApp { options: SmokeOptions, mesh: VulkanStaticMesh, materials: Vec, completed: Arc, progress: Arc, window_id: Option, window: Option, renderer: Option, final_renderer: Option, error: Option, output: Option, frames_presented: u32, resize_count: u32, resize_requested: bool, last_size: Option<(u32, u32)>, started_at: Instant, } fn drop_renderer_before_window( renderer: &mut Option, window: &mut Option, ) { drop(renderer.take()); drop(window.take()); } #[derive(Clone, Debug)] struct RendererSnapshot { report: VulkanSmokeRendererReport, readback_artifact: Option, swapchain_recreate_count: u32, validation: VulkanValidationReport, } impl From for RendererSnapshot { fn from(report: VulkanSmokeShutdownReport) -> Self { Self { report: report.renderer_report, readback_artifact: report.readback_artifact, swapchain_recreate_count: report.swapchain_recreate_count, validation: report.validation, } } } impl SmokeApp { fn new( options: SmokeOptions, mesh: VulkanStaticMesh, materials: Vec, completed: Arc, progress: Arc, ) -> Self { Self { options, mesh, materials, completed, progress, window_id: None, window: None, renderer: None, final_renderer: None, error: None, output: None, frames_presented: 0, resize_count: 0, resize_requested: false, last_size: None, started_at: Instant::now(), } } fn finish(mut self) -> Result { if let Some(output) = self.output.take() { return Ok(output); } self.completed.store(true, Ordering::SeqCst); let error = self .error .clone() .unwrap_or_else(|| "native smoke exited before producing a report".to_string()); self.write_failure_report(&error)?; Err(error) } fn schedule_next_redraw(&self) { if let Some(window) = self.window.as_ref() { window.request_redraw(); } } fn write_report(&self, report: &str) -> Result<(), String> { if let Some(parent) = self.options.out.parent() { std::fs::create_dir_all(parent) .map_err(|err| format!("{}: {err}", parent.display()))?; } std::fs::write(&self.options.out, report) .map_err(|err| format!("{}: {err}", self.options.out.display())) } 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"); self.options .out .with_file_name(format!("{stem}.readback-vkformat-{}.raw", artifact.format)) } fn write_readback_artifact( &self, artifact: &VulkanReadbackArtifact, ) -> Result { 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 { self.renderer.as_ref().map(|renderer| RendererSnapshot { report: renderer.report().clone(), readback_artifact: None, swapchain_recreate_count: renderer.swapchain_recreate_count(), validation: renderer.validation_report(), }) } fn renderer_snapshot(&self) -> Option { self.final_renderer .clone() .or_else(|| self.live_renderer_snapshot()) } #[allow(clippy::too_many_lines)] fn render_report( &self, status: &'static str, failure_reason: Option<&str>, ) -> Result { let renderer = self.renderer_snapshot(); let validation = renderer .as_ref() .map_or_else(VulkanValidationReport::default, |snapshot| { snapshot.validation.clone() }); let smoke_report = SmokeReport { schema_version: SCHEMA_VERSION, commit_sha: compiled_commit_sha(), git_dirty: compiled_git_dirty(), runner_identity: measured_runner_identity(), runner_architecture: actual_architecture(), rust_toolchain: compiled_rust_toolchain(), target_triple: compiled_target_triple(), platform: actual_platform(), status, failure_reason, frames: self.frames_presented, resize_count: self.resize_count, swapchain_recreate_count: renderer .as_ref() .map_or(0, |snapshot| snapshot.swapchain_recreate_count), validation_warning_count: validation.warning_count, validation_error_count: validation.error_count, validation_vuids: &validation.vuids, requested_frames: self.options.frames, timeout_seconds: self.options.timeout_seconds, mesh_source: self.options.mesh_input.kind(), mesh_archive: self.options.mesh_input.archive(), mesh_name: self.options.mesh_input.name(), mesh_vertex_count: self.mesh.vertices.len(), mesh_index_count: self.mesh.indices.len(), mesh_draw_count: self.mesh.draw_ranges.len(), texture_source: self .options .texture_input .as_ref() .map_or("none", TextureInput::source), texture_archive: self .options .texture_input .as_ref() .map_or("", TextureInput::archive), texture_name: match self.materials.as_slice() { [] => self .options .texture_input .as_ref() .map_or("", TextureInput::requested_name), [material] => material.selected_name.as_str(), _ => "multiple", }, texture_width: self .materials .first() .map_or(0, |material| material.texture.width), texture_height: self .materials .first() .map_or(0, |material| material.texture.height), material_binding_count: self.materials.len(), mesh_material_indices: self .mesh .draw_ranges .iter() .map(|range| range.material_index) .collect(), material_texture_names: self .materials .iter() .map(|material| material.selected_name.clone()) .collect(), shader_manifest_hash: renderer .as_ref() .map_or("", |snapshot| snapshot.report.shader_manifest_hash.as_str()), vulkan_loader_status: if renderer.is_some() { "available" } else { "failed" }, vulkan_instance_status: if renderer.is_some() { "created" } else { "failed" }, window_status: if self.window.is_some() { "created" } else { "failed" }, vulkan_surface_status: if renderer.is_some() { "created" } else { "failed" }, vulkan_device_status: if renderer.is_some() { "selected" } else { "failed" }, vulkan_device_name: renderer .as_ref() .map_or("", |snapshot| snapshot.report.device_name.as_str()), vulkan_logical_device_status: if renderer.is_some() { "created" } else { "failed" }, vulkan_logical_device_graphics_queue_family: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.graphics_queue_family), vulkan_logical_device_present_queue_family: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.present_queue_family), vulkan_logical_device_enabled_extension_count: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.enabled_extension_count), vulkan_swapchain_status: if renderer.is_some() { "created" } else { "failed" }, vulkan_swapchain_width: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.swapchain_extent.0), vulkan_swapchain_height: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.swapchain_extent.1), vulkan_swapchain_image_count: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.swapchain_image_count), vulkan_swapchain_image_format: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.swapchain_image_format), vulkan_swapchain_image_usage: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.swapchain_image_usage), vulkan_readback_copy_count: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.readback_copy_count), vulkan_readback_byte_count: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.readback_byte_count), vulkan_readback_fnv1a64: renderer .as_ref() .map_or(0, |snapshot| snapshot.report.readback_fnv1a64), vulkan_portability_enumeration: renderer .as_ref() .is_some_and(|snapshot| snapshot.report.portability_enumeration), vulkan_portability_subset_enabled: renderer .as_ref() .is_some_and(|snapshot| snapshot.report.portability_subset_enabled), }; serde_json::to_string_pretty(&smoke_report) .map(|json| format!("{json}\n")) .map_err(|err| format!("native smoke report serialization failed: {err}")) } fn write_failure_report(&self, failure_reason: &str) -> Result<(), String> { let report = self.render_report("failed", Some(failure_reason))?; self.write_report(&report) } fn abort_if_timed_out(&mut self, event_loop: &ActiveEventLoop) -> bool { if self.output.is_some() || self.error.is_some() { return false; } if self.started_at.elapsed() <= Duration::from_secs(self.options.timeout_seconds) { return false; } self.error = Some(format!( "native smoke timed out after {} seconds", self.options.timeout_seconds )); event_loop.exit(); true } fn complete(&mut self, event_loop: &ActiveEventLoop) { let Some(renderer) = self.renderer.as_ref() else { self.error = Some("native smoke renderer was not initialized".to_string()); event_loop.exit(); return; }; if self.frames_presented < self.options.frames { self.error = Some("native smoke did not reach the required frame count".to_string()); event_loop.exit(); return; } if self.resize_count == 0 || renderer.swapchain_recreate_count() == 0 { self.error = Some( "native smoke requires at least one measured resize and swapchain recreation" .to_string(), ); event_loop.exit(); return; } let Some(renderer) = self.renderer.take() else { self.error = Some("native smoke renderer was not initialized".to_string()); event_loop.exit(); return; }; let final_renderer = match renderer.shutdown() { Ok(report) => RendererSnapshot::from(report), Err(err) => { self.error = Some(err.to_string()); event_loop.exit(); return; } }; if final_renderer.validation.warning_count != 0 || final_renderer.validation.error_count != 0 { self.final_renderer = Some(final_renderer.clone()); self.error = Some(format!( "native smoke validation must stay clean (warnings={}, errors={})", final_renderer.validation.warning_count, final_renderer.validation.error_count )); event_loop.exit(); return; } self.final_renderer = Some(final_renderer); if let Some(artifact) = self .final_renderer .as_ref() .and_then(|snapshot| snapshot.readback_artifact.as_ref()) { 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, Err(err) => { self.error = Some(err); event_loop.exit(); return; } }; self.completed.store(true, Ordering::SeqCst); if let Err(err) = self.write_report(&report) { self.error = Some(err); event_loop.exit(); return; } self.output = Some(report); event_loop.exit(); } fn request_controlled_resize(&mut self) { if self.resize_requested { return; } let Some(window) = self.window.as_ref() else { return; }; self.resize_requested = true; let requested = PhysicalSize::new(DEFAULT_RESIZE_WIDTH, DEFAULT_RESIZE_HEIGHT); let _ = window.request_inner_size(requested); } } impl Drop for SmokeApp { fn drop(&mut self) { // Keep the native window alive until the Vulkan renderer finishes // destroying swapchain and surface state that still references it. drop_renderer_before_window(&mut self.renderer, &mut self.window); } } fn render_timeout_failure_report( options: &SmokeOptions, failure_reason: &str, progress: &SharedSmokeProgress, ) -> Result { let bootstrap = progress.bootstrap.snapshot(); let smoke_report = SmokeReport { schema_version: SCHEMA_VERSION, commit_sha: compiled_commit_sha(), git_dirty: compiled_git_dirty(), runner_identity: measured_runner_identity(), runner_architecture: actual_architecture(), rust_toolchain: compiled_rust_toolchain(), target_triple: compiled_target_triple(), platform: actual_platform(), status: "failed", failure_reason: Some(failure_reason), frames: progress.frames_presented.load(Ordering::SeqCst), resize_count: progress.resize_count.load(Ordering::SeqCst), swapchain_recreate_count: progress.swapchain_recreate_count.load(Ordering::SeqCst), validation_warning_count: 0, validation_error_count: 0, validation_vuids: &[], requested_frames: options.frames, timeout_seconds: options.timeout_seconds, mesh_source: options.mesh_input.kind(), mesh_archive: options.mesh_input.archive(), mesh_name: options.mesh_input.name(), mesh_vertex_count: 0, mesh_index_count: 0, mesh_draw_count: 0, texture_source: options .texture_input .as_ref() .map_or("none", TextureInput::source), texture_archive: options .texture_input .as_ref() .map_or("", TextureInput::archive), texture_name: options .texture_input .as_ref() .map_or("", TextureInput::requested_name), texture_width: 0, texture_height: 0, material_binding_count: 0, mesh_material_indices: Vec::new(), material_texture_names: Vec::new(), shader_manifest_hash: "", vulkan_loader_status: if bootstrap.loader_available { "available" } else { "failed" }, vulkan_instance_status: if bootstrap.instance_created { "created" } else { "failed" }, window_status: progress.window_phase.status(), vulkan_surface_status: if bootstrap.surface_created { "created" } else { "failed" }, vulkan_device_status: if bootstrap.device_selected { "selected" } else { "failed" }, vulkan_device_name: "", vulkan_logical_device_status: if bootstrap.logical_device_created { "created" } else { "failed" }, vulkan_logical_device_graphics_queue_family: 0, vulkan_logical_device_present_queue_family: 0, vulkan_logical_device_enabled_extension_count: 0, vulkan_swapchain_status: if bootstrap.swapchain_created { "created" } else { "failed" }, vulkan_swapchain_width: 0, vulkan_swapchain_height: 0, vulkan_swapchain_image_count: 0, vulkan_swapchain_image_format: 0, vulkan_swapchain_image_usage: 0, vulkan_readback_copy_count: 0, vulkan_readback_byte_count: 0, vulkan_readback_fnv1a64: 0, vulkan_portability_enumeration: false, vulkan_portability_subset_enabled: false, }; serde_json::to_string_pretty(&smoke_report) .map(|json| format!("{json}\n")) .map_err(|err| format!("native smoke report serialization failed: {err}")) } impl ApplicationHandler for SmokeApp { fn new_events(&mut self, event_loop: &ActiveEventLoop, _cause: StartCause) { let _ = self.abort_if_timed_out(event_loop); } fn resumed(&mut self, event_loop: &ActiveEventLoop) { if self.abort_if_timed_out(event_loop) { return; } self.progress.window_phase.store(WindowPhase::Resumed); if self.window.is_some() { return; } let plan = match WinitWindowPlan::smoke().validate() { Ok(plan) => plan, Err(err) => { self.error = Some(err.to_string()); event_loop.exit(); return; } }; self.progress .window_phase .store(WindowPhase::CreatingWindow); let attributes = Window::default_attributes() .with_title("FParkan Vulkan smoke") .with_inner_size(PhysicalSize::new(plan.width, plan.height)); let window = match event_loop.create_window(attributes) { Ok(window) => window, Err(err) => { self.error = Some(format!("winit window: {err}")); event_loop.exit(); return; } }; let Some(native_handles) = window_native_handles(&window) else { self.error = Some("winit window does not expose native handles".to_string()); event_loop.exit(); return; }; let size = window.inner_size(); let renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { application_name: "fparkan-vulkan-smoke".to_string(), native_handles, drawable_extent: (size.width.max(1), size.height.max(1)), render_request: RenderRequest::conservative(), enable_validation: true, mesh: self.mesh.clone(), materials: self .materials .iter() .map(|material| VulkanStaticMaterial { material_index: material.material_index, texture: material.texture.clone(), }) .collect(), bootstrap_progress: Some(Arc::clone(&self.progress.bootstrap)), }) { Ok(renderer) => renderer, Err(err) => { self.error = Some(err.to_string()); event_loop.exit(); return; } }; self.last_size = Some((size.width, size.height)); self.window_id = Some(window.id()); self.progress.window_phase.store(WindowPhase::Created); self.renderer = Some(renderer); self.window = Some(window); self.schedule_next_redraw(); } fn window_event( &mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent, ) { if self.abort_if_timed_out(event_loop) { return; } if Some(window_id) != self.window_id { return; } match event { WindowEvent::CloseRequested => { if self.output.is_none() { self.error = Some("native smoke window closed before completion".to_string()); } event_loop.exit(); } WindowEvent::Resized(size) => { if self .last_size .is_some_and(|last| last != (size.width, size.height)) { self.resize_count = self.resize_count.saturating_add(1); self.progress .resize_count .store(self.resize_count, Ordering::SeqCst); } self.last_size = Some((size.width, size.height)); if let Some(renderer) = self.renderer.as_mut() { renderer.request_resize((size.width, size.height)); } } WindowEvent::RedrawRequested => { let Some(renderer) = self.renderer.as_mut() else { self.error = Some("native smoke renderer was not initialized".to_string()); event_loop.exit(); return; }; match renderer.draw_frame() { Ok(VulkanSmokeFrameOutcome::Presented) => { self.frames_presented = self.frames_presented.saturating_add(1); self.progress .frames_presented .store(self.frames_presented, Ordering::SeqCst); } Ok( VulkanSmokeFrameOutcome::Recreated | VulkanSmokeFrameOutcome::ZeroExtent, ) => {} Err(err) => { self.error = Some(err.to_string()); event_loop.exit(); return; } } let recreate_count = renderer.swapchain_recreate_count(); self.progress .swapchain_recreate_count .store(recreate_count, Ordering::SeqCst); let should_request_resize = !self.resize_requested && self.frames_presented >= self.options.resize_frame; let should_complete = self.frames_presented >= self.options.frames && self.resize_count > 0 && recreate_count > 0; let _ = renderer; if should_request_resize { self.request_controlled_resize(); } if should_complete { self.complete(event_loop); } else { self.schedule_next_redraw(); } } _ => {} } } fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { if self.abort_if_timed_out(event_loop) { return; } if self.output.is_none() && self.error.is_none() { self.schedule_next_redraw(); } } } #[derive(Serialize)] struct SmokeReport<'a> { schema_version: &'static str, commit_sha: String, git_dirty: bool, runner_identity: String, runner_architecture: &'static str, rust_toolchain: String, target_triple: String, platform: &'static str, status: &'a str, #[serde(skip_serializing_if = "Option::is_none")] failure_reason: Option<&'a str>, frames: u32, resize_count: u32, swapchain_recreate_count: u32, validation_warning_count: u32, validation_error_count: u32, validation_vuids: &'a [String], requested_frames: u32, timeout_seconds: u64, mesh_source: &'a str, mesh_archive: &'a str, mesh_name: &'a str, mesh_vertex_count: usize, mesh_index_count: usize, mesh_draw_count: usize, texture_source: &'a str, texture_archive: &'a str, texture_name: &'a str, texture_width: u32, texture_height: u32, material_binding_count: usize, mesh_material_indices: Vec, material_texture_names: Vec, shader_manifest_hash: &'a str, vulkan_loader_status: &'a str, vulkan_instance_status: &'a str, window_status: &'a str, vulkan_surface_status: &'a str, vulkan_device_status: &'a str, vulkan_device_name: &'a str, vulkan_logical_device_status: &'a str, vulkan_logical_device_graphics_queue_family: u32, vulkan_logical_device_present_queue_family: u32, vulkan_logical_device_enabled_extension_count: u32, vulkan_swapchain_status: &'a str, vulkan_swapchain_width: u32, vulkan_swapchain_height: u32, vulkan_swapchain_image_count: u32, vulkan_swapchain_image_format: i32, vulkan_swapchain_image_usage: u32, vulkan_readback_copy_count: u64, vulkan_readback_byte_count: u64, vulkan_readback_fnv1a64: u64, vulkan_portability_enumeration: bool, vulkan_portability_subset_enabled: bool, } #[derive(Debug, Default)] struct SharedSmokeProgress { bootstrap: Arc, window_phase: AtomicWindowPhase, frames_presented: AtomicU32, resize_count: AtomicU32, swapchain_recreate_count: AtomicU32, } #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] enum WindowPhase { #[default] NotStarted, Resumed, CreatingWindow, Created, } impl WindowPhase { const fn as_u8(self) -> u8 { match self { Self::NotStarted => 0, Self::Resumed => 1, Self::CreatingWindow => 2, Self::Created => 3, } } const fn from_u8(value: u8) -> Self { match value { 1 => Self::Resumed, 2 => Self::CreatingWindow, 3 => Self::Created, _ => Self::NotStarted, } } const fn status(self) -> &'static str { match self { Self::NotStarted => "failed", Self::Resumed => "resumed", Self::CreatingWindow => "creating", Self::Created => "created", } } } #[derive(Debug, Default)] struct AtomicWindowPhase(AtomicU32); impl AtomicWindowPhase { fn store(&self, phase: WindowPhase) { self.0.store(u32::from(phase.as_u8()), Ordering::SeqCst); } fn status(&self) -> &'static str { match u8::try_from(self.0.load(Ordering::SeqCst)) { Ok(value) => WindowPhase::from_u8(value), Err(_) => WindowPhase::NotStarted, } .status() } } fn actual_platform() -> &'static str { match std::env::consts::OS { "windows" => "windows", other => other, } } fn actual_architecture() -> &'static str { match std::env::consts::ARCH { "arm64" => "aarch64", other => other, } } fn compiled_commit_sha() -> String { option_env!("FPARKAN_BUILD_COMMIT_SHA") .filter(|value| is_commit_sha(value)) .map_or_else(runtime_git_commit_sha, ToString::to_string) } fn runtime_git_commit_sha() -> String { Command::new("git") .args(["rev-parse", "HEAD"]) .output() .ok() .filter(|output| output.status.success()) .and_then(|output| String::from_utf8(output.stdout).ok()) .map(|value| value.trim().to_string()) .filter(|value| is_commit_sha(value)) .unwrap_or_else(|| "unknown".to_string()) } fn compiled_git_dirty() -> bool { option_env!("FPARKAN_BUILD_GIT_DIRTY") .and_then(parse_bool_env) .unwrap_or_else(runtime_git_dirty) } fn runtime_git_dirty() -> bool { Command::new("git") .args(["status", "--short"]) .output() .ok() .filter(|output| output.status.success()) .and_then(|output| String::from_utf8(output.stdout).ok()) .is_some_and(|output| !output.trim().is_empty()) } fn compiled_rust_toolchain() -> String { option_env!("FPARKAN_BUILD_RUST_TOOLCHAIN") .filter(|value| !value.trim().is_empty()) .map_or_else(current_rustc_release, ToString::to_string) } fn current_rustc_release() -> String { Command::new("rustc") .arg("-Vv") .output() .ok() .filter(|output| output.status.success()) .and_then(|output| String::from_utf8(output.stdout).ok()) .and_then(|output| { output .lines() .find_map(|line| line.strip_prefix("release: ").map(ToString::to_string)) }) .unwrap_or_else(|| "unknown".to_string()) } fn compiled_target_triple() -> String { option_env!("FPARKAN_BUILD_TARGET_TRIPLE") .filter(|value| !value.trim().is_empty()) .map_or_else(current_rustc_host_triple, ToString::to_string) } fn measured_runner_identity() -> String { if std::env::var_os("GITHUB_ACTIONS").is_some() { let run_id = std::env::var("GITHUB_RUN_ID").unwrap_or_else(|_| "unknown-run".to_string()); let job = std::env::var("GITHUB_JOB").unwrap_or_else(|_| "unknown-job".to_string()); format!("github-actions/{run_id}/{job}") } else if std::env::var_os("CI").is_some() { let job = std::env::var("CI_JOB_NAME") .or_else(|_| std::env::var("BUILD_ID")) .unwrap_or_else(|_| "generic-ci".to_string()); format!("ci/{job}") } else { format!("local/{}", std::env::consts::OS) } } fn current_rustc_host_triple() -> String { Command::new("rustc") .arg("-Vv") .output() .ok() .filter(|output| output.status.success()) .and_then(|output| String::from_utf8(output.stdout).ok()) .and_then(|output| { output .lines() .find_map(|line| line.strip_prefix("host: ").map(ToString::to_string)) }) .unwrap_or_else(|| "unknown".to_string()) } fn is_commit_sha(value: &str) -> bool { value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()) } fn parse_bool_env(value: &str) -> Option { match value { "true" => Some(true), "false" => Some(false), _ => None, } } #[cfg(test)] mod tests { use super::*; use std::cell::RefCell; use std::rc::Rc; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum DropStep { Renderer, Window, } struct DropTracker { step: DropStep, log: Rc>>, } impl Drop for DropTracker { fn drop(&mut self) { self.log.borrow_mut().push(self.step); } } fn tracker(step: DropStep, log: &Rc>>) -> DropTracker { DropTracker { step, log: Rc::clone(log), } } #[test] fn parses_required_args() { let parsed = SmokeOptions::parse(&["--out".to_string(), "target/report.json".to_string()]); assert_eq!( parsed, Ok(SmokeOptions { out: PathBuf::from("target/report.json"), frames: DEFAULT_TARGET_FRAMES, resize_frame: DEFAULT_RESIZE_FRAME, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, expected_readback: None, mesh_input: MeshInput::SmokeTriangle, texture_input: None, }) ); } #[test] fn rejects_too_few_frames() { let parsed = SmokeOptions::parse(&[ "--out".to_string(), "target/report.json".to_string(), "--frames".to_string(), "299".to_string(), ]); assert_eq!( parsed, Err("native smoke requires --frames >= 300".to_string()) ); } #[test] fn parses_timeout_seconds() { let parsed = SmokeOptions::parse(&[ "--out".to_string(), "target/report.json".to_string(), "--timeout-seconds".to_string(), "45".to_string(), ]); assert_eq!( parsed, Ok(SmokeOptions { out: PathBuf::from("target/report.json"), frames: DEFAULT_TARGET_FRAMES, resize_frame: DEFAULT_RESIZE_FRAME, timeout_seconds: 45, expected_readback: None, mesh_input: MeshInput::SmokeTriangle, texture_input: None, }) ); } #[test] fn rejects_zero_timeout_seconds() { let parsed = SmokeOptions::parse(&[ "--out".to_string(), "target/report.json".to_string(), "--timeout-seconds".to_string(), "0".to_string(), ]); assert_eq!( parsed, Err("native smoke requires --timeout-seconds >= 1".to_string()) ); } #[test] fn parses_original_msh_input_as_one_atomic_request() { let parsed = SmokeOptions::parse(&[ "--out".to_string(), "target/report.json".to_string(), "--model-root".to_string(), "C:/GOG".to_string(), "--model-archive".to_string(), "system.rlb".to_string(), "--model-name".to_string(), "MTCHECK.MSH".to_string(), ]); assert!(matches!( parsed, Ok(SmokeOptions { mesh_input: MeshInput::Msh { archive, name, .. }, .. }) if archive == "system.rlb" && name == "MTCHECK.MSH" )); } #[test] fn parses_original_land_msh_as_direct_terrain_input() { let parsed = SmokeOptions::parse(&[ "--out".to_string(), "target/report.json".to_string(), "--terrain-path".to_string(), "C:/GOG/DATA/MAPS/11/Land.msh".to_string(), ]); assert!(matches!( parsed, Ok(SmokeOptions { mesh_input: MeshInput::LandMsh { path }, .. }) if path == std::path::Path::new("C:/GOG/DATA/MAPS/11/Land.msh") )); } #[test] fn rejects_partial_original_msh_input() { let parsed = SmokeOptions::parse(&[ "--out".to_string(), "target/report.json".to_string(), "--model-root".to_string(), "C:/GOG".to_string(), ]); assert_eq!( parsed, Err( "--model-root, --model-archive and --model-name must be supplied together" .to_string() ) ); } #[test] fn parses_original_texm_input_as_one_atomic_request() { let parsed = SmokeOptions::parse(&[ "--out".to_string(), "target/report.json".to_string(), "--texture-root".to_string(), "C:/GOG".to_string(), "--texture-archive".to_string(), "Textures.lib".to_string(), "--texture-name".to_string(), "DEFAULT.0".to_string(), ]); assert!(matches!( parsed, Ok(SmokeOptions { texture_input: Some(TextureInput::Direct(ResourceInput { archive, name, .. })), .. }) if archive == "Textures.lib" && name == "DEFAULT.0" )); } #[test] fn parses_original_wear_material_input_as_one_atomic_request() { let parsed = SmokeOptions::parse(&[ "--out".to_string(), "target/report.json".to_string(), "--wear-root".to_string(), "C:/GOG".to_string(), "--wear-archive".to_string(), "system.rlb".to_string(), "--wear-name".to_string(), "MODEL.WEA".to_string(), "--material-index".to_string(), "7".to_string(), ]); assert!(matches!( parsed, Ok(SmokeOptions { texture_input: Some(TextureInput::WearMaterial(WearMaterialInput { archive, name, material_index, .. })), .. }) if archive == "system.rlb" && name == "MODEL.WEA" && material_index == 7 )); } #[test] fn parses_original_wear_batch_material_input_without_override() { let parsed = SmokeOptions::parse(&[ "--out".to_string(), "target/report.json".to_string(), "--wear-root".to_string(), "C:/GOG".to_string(), "--wear-archive".to_string(), "system.rlb".to_string(), "--wear-name".to_string(), "MODEL.WEA".to_string(), ]); assert!(matches!( parsed, Ok(SmokeOptions { texture_input: Some(TextureInput::WearBatchMaterials(WearInput { archive, name, .. })), .. }) if archive == "system.rlb" && name == "MODEL.WEA" )); } #[test] fn rejects_deprecated_self_assertion_flags() { for flag in [ "--status", "--platform", "--validation-error-count", "--resize-count", "--swapchain-recreate-count", ] { let parsed = SmokeOptions::parse(&[ "--out".to_string(), "target/report.json".to_string(), flag.to_string(), "value".to_string(), ]); assert_eq!(parsed, Err(format!("unknown native smoke option: {flag}"))); } } #[test] fn commit_sha_validation_accepts_hex_head() { assert!(is_commit_sha("0123456789abcdef0123456789abcdef01234567")); } #[test] fn commit_sha_validation_rejects_non_hex_or_wrong_length() { assert!(!is_commit_sha("0123456789abcdef0123456789abcdef0123456")); assert!(!is_commit_sha("zz23456789abcdef0123456789abcdef01234567")); } #[test] fn parses_bool_env_values() { assert_eq!(parse_bool_env("true"), Some(true)); assert_eq!(parse_bool_env("false"), Some(false)); assert_eq!(parse_bool_env("1"), None); } #[test] fn smoke_report_json_contains_expected_fields() { let json = serde_json::to_string_pretty(&SmokeReport { schema_version: SCHEMA_VERSION, commit_sha: "0123456789abcdef0123456789abcdef01234567".to_string(), git_dirty: false, runner_identity: "windows-local/stage0".to_string(), runner_architecture: "x86_64", rust_toolchain: "1.97.1".to_string(), target_triple: "x86_64-pc-windows-msvc".to_string(), platform: "windows", status: "passed", failure_reason: None, frames: 300, resize_count: 1, swapchain_recreate_count: 1, validation_warning_count: 0, validation_error_count: 0, validation_vuids: &["VUID-A".to_string(), "VUID-B".to_string()], requested_frames: 300, timeout_seconds: DEFAULT_TIMEOUT_SECONDS, mesh_source: "original-msh", mesh_archive: "system.rlb", mesh_name: "MTCHECK.MSH", mesh_vertex_count: 128, mesh_index_count: 252, mesh_draw_count: 1, texture_source: "original-texm", texture_archive: "Textures.lib", texture_name: "DEFAULT.0", texture_width: 16, texture_height: 16, material_binding_count: 1, mesh_material_indices: vec![0], material_texture_names: vec!["DEFAULT.0".to_string()], shader_manifest_hash: "deadbeef", vulkan_loader_status: "available", vulkan_instance_status: "created", window_status: "created", vulkan_surface_status: "created", vulkan_device_status: "selected", vulkan_device_name: "Windows test GPU", vulkan_logical_device_status: "created", vulkan_logical_device_graphics_queue_family: 0, vulkan_logical_device_present_queue_family: 0, vulkan_logical_device_enabled_extension_count: 2, vulkan_swapchain_status: "created", vulkan_swapchain_width: 960, vulkan_swapchain_height: 540, vulkan_swapchain_image_count: 3, vulkan_swapchain_image_format: 50, vulkan_swapchain_image_usage: 17, vulkan_readback_copy_count: 300, vulkan_readback_byte_count: 4_147_200, vulkan_readback_fnv1a64: 0x1234_5678_90ab_cdef, vulkan_portability_enumeration: false, vulkan_portability_subset_enabled: false, }) .expect("smoke report should serialize"); assert!(json.contains("\"schema_version\": \"fparkan-native-smoke-v1\"")); assert!(json.contains("\"validation_vuids\": [")); assert!(json.contains("\"vulkan_device_name\": \"Windows test GPU\"")); assert!(json.contains("\"runner_architecture\": \"x86_64\"")); assert!(json.contains("\"mesh_source\": \"original-msh\"")); assert!(json.contains("\"mesh_draw_count\": 1")); assert!(json.contains("\"texture_source\": \"original-texm\"")); assert!(json.contains("\"vulkan_readback_copy_count\": 300")); assert!(json.contains("\"vulkan_readback_byte_count\": 4147200")); } #[test] fn finish_writes_failure_artifact() { let root = std::env::temp_dir().join(format!( "fparkan-native-smoke-{}-{}", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("time") .as_nanos() )); std::fs::create_dir_all(&root).expect("temp dir"); let out = root.join("report.json"); let result = SmokeApp { options: SmokeOptions { out: out.clone(), frames: DEFAULT_TARGET_FRAMES, resize_frame: DEFAULT_RESIZE_FRAME, timeout_seconds: 7, expected_readback: None, mesh_input: MeshInput::SmokeTriangle, texture_input: None, }, mesh: VulkanStaticMesh::smoke_triangle(), materials: Vec::new(), completed: Arc::new(AtomicBool::new(false)), progress: Arc::new(SharedSmokeProgress::default()), window_id: None, window: None, renderer: None, final_renderer: None, error: Some("native smoke timed out after 7 seconds".to_string()), output: None, frames_presented: 42, resize_count: 0, resize_requested: false, last_size: None, started_at: Instant::now(), } .finish(); assert_eq!( result, Err("native smoke timed out after 7 seconds".to_string()) ); let json = std::fs::read_to_string(&out).expect("failure report"); assert!(json.contains("\"status\": \"failed\"")); assert!(json.contains("\"failure_reason\": \"native smoke timed out after 7 seconds\"")); assert!(json.contains("\"timeout_seconds\": 7")); std::fs::remove_file(out).expect("cleanup report"); 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.readback-vkformat-50.raw"); let actual = root.join("actual.readback-vkformat-50.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()) ); let incompatible = root.join("incompatible.readback-vkformat-37.raw"); std::fs::write(&incompatible, [1_u8, 2, 3]).expect("incompatible actual"); assert_eq!( compare_readback_files(&expected, &incompatible), Err("pixel readback format mismatch: expected=50 actual=37".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())); let mut renderer = Some(tracker(DropStep::Renderer, &log)); let mut window = Some(tracker(DropStep::Window, &log)); drop_renderer_before_window(&mut renderer, &mut window); assert!(renderer.is_none()); assert!(window.is_none()); assert_eq!( Rc::into_inner(log).expect("trackers released").into_inner(), vec![DropStep::Renderer, DropStep::Window] ); } }