diff options
Diffstat (limited to 'apps/fparkan-vulkan-smoke/src')
| -rw-r--r-- | apps/fparkan-vulkan-smoke/src/main.rs | 1973 |
1 files changed, 0 insertions, 1973 deletions
diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs deleted file mode 100644 index ec2d19c..0000000 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ /dev/null @@ -1,1973 +0,0 @@ -#![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, - VulkanStaticCamera, 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::<Vec<_>>(); - let code = match run(&args) { - Ok(output) => { - println!("{output}"); - 0 - } - Err(err) => { - eprintln!("{err}"); - 2 - } - }; - std::process::exit(code); -} - -fn run(args: &[String]) -> Result<String, String> { - 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<AtomicBool>, - progress: Arc<SharedSmokeProgress>, -) { - 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<i32, String> { - 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::<i32>().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<PathBuf>, - mesh_input: MeshInput, - texture_input: Option<TextureInput>, -} - -#[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<Self, String> { - 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<VulkanStaticMesh, String> { - 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<Vec<LoadedTexture>, 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::<Vec<_>>(); - 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<u8>), - 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<LoadedTexture>, - completed: Arc<AtomicBool>, - progress: Arc<SharedSmokeProgress>, - window_id: Option<WindowId>, - window: Option<Window>, - renderer: Option<VulkanSmokeRenderer>, - final_renderer: Option<RendererSnapshot>, - error: Option<String>, - output: Option<String>, - frames_presented: u32, - resize_count: u32, - resize_requested: bool, - last_size: Option<(u32, u32)>, - started_at: Instant, -} - -fn drop_renderer_before_window<Renderer, WindowLike>( - renderer: &mut Option<Renderer>, - window: &mut Option<WindowLike>, -) { - drop(renderer.take()); - drop(window.take()); -} - -#[derive(Clone, Debug)] -struct RendererSnapshot { - report: VulkanSmokeRendererReport, - readback_artifact: Option<VulkanReadbackArtifact>, - swapchain_recreate_count: u32, - validation: VulkanValidationReport, -} - -impl From<VulkanSmokeShutdownReport> 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<LoadedTexture>, - completed: Arc<AtomicBool>, - progress: Arc<SharedSmokeProgress>, - ) -> 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<String, String> { - 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<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> { - 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<RendererSnapshot> { - 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<String, String> { - 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<String, String> { - 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(), - camera: VulkanStaticCamera::default(), - 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; - }; - if let Err(error) = renderer.set_camera(VulkanStaticCamera::default()) { - self.error = Some(error.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<u16>, - material_texture_names: Vec<String>, - 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<VulkanSmokeBootstrapProgress>, - 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<bool> { - 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<RefCell<Vec<DropStep>>>, - } - - impl Drop for DropTracker { - fn drop(&mut self) { - self.log.borrow_mut().push(self.step); - } - } - - fn tracker(step: DropStep, log: &Rc<RefCell<Vec<DropStep>>>) -> 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] - ); - } -} |
