diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 23:05:46 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 23:05:46 +0300 |
| commit | e6b7fa189642bf432dd2bbcf1bcff659bd794750 (patch) | |
| tree | 13ee0fe976ef617c6fc478035389d44f30357875 | |
| parent | 0e127117e9f826ecf4be312b0c630121f38b4d95 (diff) | |
| download | fparkan-e6b7fa189642bf432dd2bbcf1bcff659bd794750.tar.xz fparkan-e6b7fa189642bf432dd2bbcf1bcff659bd794750.zip | |
feat: probe live Vulkan runtime capabilities
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/lib.rs | 356 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/src/main.rs | 448 | ||||
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 2 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_0_2_roadmap.md | 2 | ||||
| -rw-r--r-- | xtask/src/main.rs | 36 |
5 files changed, 750 insertions, 94 deletions
diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs index 3f63c5f..38d521d 100644 --- a/adapters/fparkan-render-vulkan/src/lib.rs +++ b/adapters/fparkan-render-vulkan/src/lib.rs @@ -311,6 +311,104 @@ impl Drop for VulkanSurfaceProbe { } } +/// Live Vulkan device/surface capability probe. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanRuntimeCapabilityProbe { + /// Selected device/queue capability report. + pub capability: VulkanCapabilityReport, + /// Swapchain plan built from the selected device and live surface capabilities. + pub swapchain: VulkanSwapchainPlan, +} + +/// Live Vulkan device/surface capability probe error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VulkanRuntimeCapabilityError { + /// Physical device enumeration failed. + EnumerateDevicesFailed { + /// Vulkan result. + result: String, + }, + /// Device extension enumeration failed. + EnumerateDeviceExtensionsFailed { + /// Device name or index context. + device: String, + /// Vulkan result. + result: String, + }, + /// Queue-family present support query failed. + PresentSupportFailed { + /// Device name. + device: String, + /// Queue-family index. + queue_family: u32, + /// Vulkan result. + result: String, + }, + /// Surface format query failed. + SurfaceFormatsFailed { + /// Device name. + device: String, + /// Vulkan result. + result: String, + }, + /// Surface capability query failed. + SurfaceCapabilitiesFailed { + /// Device name. + device: String, + /// Vulkan result. + result: String, + }, + /// Present mode query failed. + PresentModesFailed { + /// Device name. + device: String, + /// Vulkan result. + result: String, + }, + /// No device satisfied Stage 0 capability policy. + Capability(VulkanCapabilityError), + /// Live surface capabilities could not produce a swapchain plan. + Swapchain(VulkanSwapchainError), +} + +impl std::fmt::Display for VulkanRuntimeCapabilityError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::EnumerateDevicesFailed { result } => { + write!(f, "Vulkan physical device enumeration failed: {result}") + } + Self::EnumerateDeviceExtensionsFailed { device, result } => write!( + f, + "Vulkan device {device} extension enumeration failed: {result}" + ), + Self::PresentSupportFailed { + device, + queue_family, + result, + } => write!( + f, + "Vulkan device {device} queue family {queue_family} present support query failed: {result}" + ), + Self::SurfaceFormatsFailed { device, result } => write!( + f, + "Vulkan device {device} surface format query failed: {result}" + ), + Self::SurfaceCapabilitiesFailed { device, result } => write!( + f, + "Vulkan device {device} surface capabilities query failed: {result}" + ), + Self::PresentModesFailed { device, result } => write!( + f, + "Vulkan device {device} present mode query failed: {result}" + ), + Self::Capability(error) => write!(f, "{error}"), + Self::Swapchain(error) => write!(f, "{error}"), + } + } +} + +impl std::error::Error for VulkanRuntimeCapabilityError {} + /// Builds a deterministic Vulkan surface plan from native window handles. /// /// # Errors @@ -371,6 +469,264 @@ pub fn create_vulkan_surface_probe( }) } +/// Probes live Vulkan device, queue, surface and swapchain capabilities. +/// +/// # Errors +/// +/// Returns [`VulkanRuntimeCapabilityError`] when device enumeration, surface +/// capability queries, Stage 0 device selection, or swapchain planning fails. +pub fn probe_vulkan_runtime_capabilities( + instance: &VulkanInstanceProbe, + surface: &VulkanSurfaceProbe, + drawable_extent: (u32, u32), +) -> Result<VulkanRuntimeCapabilityProbe, VulkanRuntimeCapabilityError> { + let devices = { + // SAFETY: The Vulkan instance is live for this query and no handles are retained. + unsafe { instance.instance.enumerate_physical_devices() }.map_err(|error| { + VulkanRuntimeCapabilityError::EnumerateDevicesFailed { + result: format!("{error:?}"), + } + })? + }; + let mut best: Option<LiveDeviceCandidate> = None; + for (index, device) in devices.iter().copied().enumerate() { + let candidate = live_device_candidate(instance, surface, device, index)?; + match &best { + Some(existing) + if compare_reports(&candidate.capability, &existing.capability) + != std::cmp::Ordering::Greater => {} + _ => best = Some(candidate), + } + } + let best = best.ok_or(VulkanRuntimeCapabilityError::Capability( + VulkanCapabilityError::NoPhysicalDevice, + ))?; + let swapchain = plan_vulkan_swapchain(&VulkanSwapchainRequest { + drawable_extent, + formats: best.surface_formats, + present_modes: best.present_modes, + capabilities: best.surface_capabilities, + preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(), + }) + .map_err(VulkanRuntimeCapabilityError::Swapchain)?; + Ok(VulkanRuntimeCapabilityProbe { + capability: best.capability, + swapchain, + }) +} + +struct LiveDeviceCandidate { + capability: VulkanCapabilityReport, + surface_formats: Vec<VulkanSurfaceFormat>, + present_modes: Vec<i32>, + surface_capabilities: VulkanSwapchainSurfaceCapabilities, +} + +fn live_device_candidate( + instance: &VulkanInstanceProbe, + surface: &VulkanSurfaceProbe, + device: vk::PhysicalDevice, + index: usize, +) -> Result<LiveDeviceCandidate, VulkanRuntimeCapabilityError> { + let properties = { + // SAFETY: `device` was returned by this live instance and the result is copied by value. + unsafe { instance.instance.get_physical_device_properties(device) } + }; + let name = physical_device_name(&properties, index); + let queue_properties = { + // SAFETY: `device` was returned by this live instance and the result is owned by Rust. + unsafe { + instance + .instance + .get_physical_device_queue_family_properties(device) + } + }; + let extensions = live_device_extensions(instance, device, &name)?; + let surface_formats = live_surface_formats(surface, device, &name)?; + let present_modes = live_present_modes(surface, device, &name)?; + let surface_capabilities = live_surface_capabilities(surface, device, &name)?; + let queue_families = queue_properties + .iter() + .enumerate() + .map(|(queue_index, properties)| { + let index = u32::try_from(queue_index).unwrap_or(u32::MAX); + let present = { + // SAFETY: The physical device, surface and queue-family index are live query inputs. + unsafe { + surface.loader.get_physical_device_surface_support( + device, + index, + surface.surface, + ) + } + } + .map_err(|error| VulkanRuntimeCapabilityError::PresentSupportFailed { + device: name.clone(), + queue_family: index, + result: format!("{error:?}"), + })?; + Ok(VulkanQueueFamily { + index, + graphics: properties.queue_flags.contains(vk::QueueFlags::GRAPHICS), + present, + }) + }) + .collect::<Result<Vec<_>, VulkanRuntimeCapabilityError>>()?; + let record = VulkanPhysicalDeviceRecord { + name, + api_version: properties.api_version, + device_type: match properties.device_type { + vk::PhysicalDeviceType::DISCRETE_GPU => VulkanDeviceType::DiscreteGpu, + vk::PhysicalDeviceType::INTEGRATED_GPU => VulkanDeviceType::IntegratedGpu, + vk::PhysicalDeviceType::CPU => VulkanDeviceType::Cpu, + _ => VulkanDeviceType::Other, + }, + extensions, + queue_families, + surface_formats: surface_formats.clone(), + }; + let capability = validate_device(&record).map_err(VulkanRuntimeCapabilityError::Capability)?; + Ok(LiveDeviceCandidate { + capability, + surface_formats, + present_modes, + surface_capabilities, + }) +} + +fn physical_device_name(properties: &vk::PhysicalDeviceProperties, index: usize) -> String { + // SAFETY: Vulkan device names are fixed-size NUL-terminated C strings per the spec. + let name = unsafe { CStr::from_ptr(properties.device_name.as_ptr()) } + .to_string_lossy() + .trim() + .to_string(); + if name.is_empty() { + format!("physical-device-{index}") + } else { + name + } +} + +fn live_device_extensions( + instance: &VulkanInstanceProbe, + device: vk::PhysicalDevice, + name: &str, +) -> Result<Vec<String>, VulkanRuntimeCapabilityError> { + let properties = { + // SAFETY: `device` was returned by this live instance and no borrowed data escapes. + unsafe { + instance + .instance + .enumerate_device_extension_properties(device) + } + } + .map_err( + |error| VulkanRuntimeCapabilityError::EnumerateDeviceExtensionsFailed { + device: name.to_string(), + result: format!("{error:?}"), + }, + )?; + let mut extensions = properties + .iter() + .map(|property| { + // SAFETY: Vulkan extension names are fixed-size NUL-terminated C strings per the spec. + unsafe { CStr::from_ptr(property.extension_name.as_ptr()) } + .to_string_lossy() + .into_owned() + }) + .collect::<Vec<_>>(); + extensions.sort(); + extensions.dedup(); + Ok(extensions) +} + +fn live_surface_formats( + surface: &VulkanSurfaceProbe, + device: vk::PhysicalDevice, + name: &str, +) -> Result<Vec<VulkanSurfaceFormat>, VulkanRuntimeCapabilityError> { + let formats = { + // SAFETY: The physical device and surface are live query inputs and no handles are retained. + unsafe { + surface + .loader + .get_physical_device_surface_formats(device, surface.surface) + } + } + .map_err(|error| VulkanRuntimeCapabilityError::SurfaceFormatsFailed { + device: name.to_string(), + result: format!("{error:?}"), + })?; + Ok(formats + .into_iter() + .map(|format| VulkanSurfaceFormat { + format: format.format.as_raw(), + color_space: format.color_space.as_raw(), + }) + .collect()) +} + +fn live_present_modes( + surface: &VulkanSurfaceProbe, + device: vk::PhysicalDevice, + name: &str, +) -> Result<Vec<i32>, VulkanRuntimeCapabilityError> { + let modes = { + // SAFETY: The physical device and surface are live query inputs and no handles are retained. + unsafe { + surface + .loader + .get_physical_device_surface_present_modes(device, surface.surface) + } + } + .map_err(|error| VulkanRuntimeCapabilityError::PresentModesFailed { + device: name.to_string(), + result: format!("{error:?}"), + })?; + Ok(modes.into_iter().map(vk::PresentModeKHR::as_raw).collect()) +} + +fn live_surface_capabilities( + surface: &VulkanSurfaceProbe, + device: vk::PhysicalDevice, + name: &str, +) -> Result<VulkanSwapchainSurfaceCapabilities, VulkanRuntimeCapabilityError> { + let capabilities = { + // SAFETY: The physical device and surface are live query inputs and no handles are retained. + unsafe { + surface + .loader + .get_physical_device_surface_capabilities(device, surface.surface) + } + } + .map_err( + |error| VulkanRuntimeCapabilityError::SurfaceCapabilitiesFailed { + device: name.to_string(), + result: format!("{error:?}"), + }, + )?; + Ok(VulkanSwapchainSurfaceCapabilities { + current_extent: if capabilities.current_extent.width == u32::MAX { + None + } else { + Some(( + capabilities.current_extent.width, + capabilities.current_extent.height, + )) + }, + min_extent: ( + capabilities.min_image_extent.width, + capabilities.min_image_extent.height, + ), + max_extent: ( + capabilities.max_image_extent.width, + capabilities.max_image_extent.height, + ), + min_image_count: capabilities.min_image_count, + max_image_count: capabilities.max_image_count, + }) +} + /// Renders a deterministic JSON Vulkan surface plan. #[must_use] pub fn render_surface_plan_json(plan: &VulkanSurfacePlan) -> String { diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index 90f7648..da8d68f 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -15,7 +15,8 @@ use fparkan_platform::{NativeWindowHandles, WindowPort}; use fparkan_platform_winit::{probe_smoke_window, WinitWindowPlan}; use fparkan_render_vulkan::{ create_vulkan_instance_probe, create_vulkan_surface_probe, probe_vulkan_loader, - triangle_shader_manifest, validate_shader_manifest, VulkanInstanceConfig, VulkanInstanceProbe, + probe_vulkan_runtime_capabilities, triangle_shader_manifest, validate_shader_manifest, + VulkanInstanceConfig, VulkanInstanceProbe, VulkanRuntimeCapabilityProbe, }; use std::path::PathBuf; use std::process::Command; @@ -205,6 +206,14 @@ struct VulkanBootstrapProbe { window_error: Option<String>, surface_status: VulkanSurfaceStatus, surface_error: Option<String>, + device_status: VulkanDeviceStatus, + device_name: Option<String>, + device_error: Option<String>, + swapchain_status: VulkanSwapchainStatus, + swapchain_width: Option<u32>, + swapchain_height: Option<u32>, + swapchain_image_count: Option<u32>, + swapchain_error: Option<String>, } impl VulkanBootstrapProbe { @@ -234,6 +243,14 @@ impl VulkanBootstrapProbe { window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, + device_status: VulkanDeviceStatus::Skipped, + device_name: None, + device_error: None, + swapchain_status: VulkanSwapchainStatus::Skipped, + swapchain_width: None, + swapchain_height: None, + swapchain_image_count: None, + swapchain_error: None, } } @@ -252,6 +269,14 @@ impl VulkanBootstrapProbe { window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, + device_status: VulkanDeviceStatus::Skipped, + device_name: None, + device_error: None, + swapchain_status: VulkanSwapchainStatus::Skipped, + swapchain_width: None, + swapchain_height: None, + swapchain_image_count: None, + swapchain_error: None, }, Err(err) => Self { loader_status: VulkanLoaderStatus::Unavailable, @@ -266,6 +291,14 @@ impl VulkanBootstrapProbe { window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, + device_status: VulkanDeviceStatus::Skipped, + device_name: None, + device_error: None, + swapchain_status: VulkanSwapchainStatus::Skipped, + swapchain_width: None, + swapchain_height: None, + swapchain_image_count: None, + swapchain_error: None, }, } } @@ -339,8 +372,9 @@ impl VulkanBootstrapProbe { create_vulkan_surface_probe(instance, window_handles) .map_err(|err| err.to_string()) }) { - Ok(_) => { + Ok(surface) => { self.surface_status = VulkanSurfaceStatus::Created; + self.probe_runtime_capabilities(instance, &surface); } Err(err) => { self.surface_status = VulkanSurfaceStatus::Failed; @@ -349,6 +383,44 @@ impl VulkanBootstrapProbe { } } } + + fn probe_runtime_capabilities( + &mut self, + instance: Option<&VulkanInstanceProbe>, + surface: &fparkan_render_vulkan::VulkanSurfaceProbe, + ) { + let Some(instance) = instance else { + self.device_status = VulkanDeviceStatus::Failed; + self.device_error = Some("Vulkan instance probe was not retained".to_string()); + self.swapchain_status = VulkanSwapchainStatus::Skipped; + return; + }; + match probe_vulkan_runtime_capabilities( + instance, + surface, + ( + self.window_width.unwrap_or(1).max(1), + self.window_height.unwrap_or(1).max(1), + ), + ) { + Ok(runtime) => self.record_runtime_capabilities(runtime), + Err(err) => { + self.device_status = VulkanDeviceStatus::Failed; + self.device_error = Some(err.to_string()); + self.swapchain_status = VulkanSwapchainStatus::Failed; + self.swapchain_error = Some(err.to_string()); + } + } + } + + fn record_runtime_capabilities(&mut self, runtime: VulkanRuntimeCapabilityProbe) { + self.device_status = VulkanDeviceStatus::Selected; + self.device_name = Some(runtime.capability.device_name); + self.swapchain_status = VulkanSwapchainStatus::Planned; + self.swapchain_width = Some(runtime.swapchain.extent.0); + self.swapchain_height = Some(runtime.swapchain.extent.1); + self.swapchain_image_count = Some(runtime.swapchain.image_count); + } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -422,6 +494,40 @@ impl VulkanSurfaceStatus { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VulkanDeviceStatus { + Skipped, + Selected, + Failed, +} + +impl VulkanDeviceStatus { + const fn as_str(self) -> &'static str { + match self { + Self::Skipped => "skipped", + Self::Selected => "selected", + Self::Failed => "failed", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VulkanSwapchainStatus { + Skipped, + Planned, + Failed, +} + +impl VulkanSwapchainStatus { + const fn as_str(self) -> &'static str { + match self { + Self::Skipped => "skipped", + Self::Planned => "planned", + Self::Failed => "failed", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] enum SmokePlatform { Windows, Linux, @@ -524,6 +630,16 @@ fn validate_smoke_options( "passed native smoke report requires successful --probe-surface".to_string(), ); } + if bootstrap.device_status != VulkanDeviceStatus::Selected { + return Err( + "passed native smoke report requires selected Vulkan device".to_string() + ); + } + if bootstrap.swapchain_status != VulkanSwapchainStatus::Planned { + return Err( + "passed native smoke report requires planned Vulkan swapchain".to_string(), + ); + } } } Ok(()) @@ -535,97 +651,131 @@ fn render_smoke_report_json( ) -> Result<String, String> { let shader_manifest = validate_shader_manifest(&triangle_shader_manifest()) .map_err(|err| format!("shader manifest: {err}"))?; - let validation_error_count = options - .validation_error_count - .map_or_else(|| "null".to_string(), |value| value.to_string()); - let reason = options - .reason - .as_ref() - .map_or_else(|| "null".to_string(), |value| json_string(value)); - let instance_api = bootstrap - .instance_api - .as_ref() - .map_or_else(|| "null".to_string(), |value| json_string(value)); - let loader_error = bootstrap - .loader_error - .as_ref() - .map_or_else(|| "null".to_string(), |value| json_string(value)); - let instance_error = bootstrap - .instance_error - .as_ref() - .map_or_else(|| "null".to_string(), |value| json_string(value)); - let window_width = bootstrap - .window_width - .map_or_else(|| "null".to_string(), |value| value.to_string()); - let window_height = bootstrap - .window_height - .map_or_else(|| "null".to_string(), |value| value.to_string()); - let window_error = bootstrap - .window_error - .as_ref() - .map_or_else(|| "null".to_string(), |value| json_string(value)); - let surface_error = bootstrap - .surface_error - .as_ref() - .map_or_else(|| "null".to_string(), |value| json_string(value)); - Ok(format!( - concat!( - "{{\n", - " \"schema_version\": \"{}\",\n", - " \"commit_sha\": \"{}\",\n", - " \"rust_toolchain\": \"{}\",\n", - " \"target_triple\": \"{}\",\n", - " \"platform\": \"{}\",\n", - " \"status\": \"{}\",\n", - " \"frames\": {},\n", - " \"resize_count\": {},\n", - " \"swapchain_recreate_count\": {},\n", - " \"validation_error_count\": {},\n", - " \"shader_manifest_hash\": \"{}\",\n", - " \"vulkan_loader_status\": \"{}\",\n", - " \"vulkan_instance_api\": {},\n", - " \"vulkan_loader_error\": {},\n", - " \"vulkan_instance_status\": \"{}\",\n", - " \"vulkan_instance_error\": {},\n", - " \"vulkan_portability_enumeration\": {},\n", - " \"window_status\": \"{}\",\n", - " \"window_width\": {},\n", - " \"window_height\": {},\n", - " \"window_error\": {},\n", - " \"vulkan_surface_status\": \"{}\",\n", - " \"vulkan_surface_error\": {},\n", - " \"reason\": {}\n", - "}}\n" + Ok(render_json_object(&[ + ("schema_version", json_string(SCHEMA_VERSION)), + ("commit_sha", json_string(¤t_git_commit_sha())), + ("rust_toolchain", json_string(RUST_TOOLCHAIN)), + ("target_triple", json_string(¤t_rustc_host_triple())), + ("platform", json_string(options.platform.as_str())), + ("status", json_string(options.status.as_str())), + ("frames", options.frames.to_string()), + ("resize_count", options.resize_count.to_string()), + ( + "swapchain_recreate_count", + options.swapchain_recreate_count.to_string(), ), - SCHEMA_VERSION, - json_escape(¤t_git_commit_sha()), - RUST_TOOLCHAIN, - json_escape(¤t_rustc_host_triple()), - options.platform.as_str(), - options.status.as_str(), - options.frames, - options.resize_count, - options.swapchain_recreate_count, - validation_error_count, - json_escape(&shader_manifest.manifest_hash), - bootstrap.loader_status.as_str(), - instance_api, - loader_error, - bootstrap.instance_status.as_str(), - instance_error, - if bootstrap.portability_enumeration { - "true" - } else { - "false" - }, - bootstrap.window_status.as_str(), - window_width, - window_height, - window_error, - bootstrap.surface_status.as_str(), - surface_error, - reason - )) + ( + "validation_error_count", + optional_u32(options.validation_error_count), + ), + ( + "shader_manifest_hash", + json_string(&shader_manifest.manifest_hash), + ), + ( + "vulkan_loader_status", + json_string(bootstrap.loader_status.as_str()), + ), + ( + "vulkan_instance_api", + optional_string(bootstrap.instance_api.as_deref()), + ), + ( + "vulkan_loader_error", + optional_string(bootstrap.loader_error.as_deref()), + ), + ( + "vulkan_instance_status", + json_string(bootstrap.instance_status.as_str()), + ), + ( + "vulkan_instance_error", + optional_string(bootstrap.instance_error.as_deref()), + ), + ( + "vulkan_portability_enumeration", + bool_json(bootstrap.portability_enumeration), + ), + ( + "window_status", + json_string(bootstrap.window_status.as_str()), + ), + ("window_width", optional_u32(bootstrap.window_width)), + ("window_height", optional_u32(bootstrap.window_height)), + ( + "window_error", + optional_string(bootstrap.window_error.as_deref()), + ), + ( + "vulkan_surface_status", + json_string(bootstrap.surface_status.as_str()), + ), + ( + "vulkan_surface_error", + optional_string(bootstrap.surface_error.as_deref()), + ), + ( + "vulkan_device_status", + json_string(bootstrap.device_status.as_str()), + ), + ( + "vulkan_device_name", + optional_string(bootstrap.device_name.as_deref()), + ), + ( + "vulkan_device_error", + optional_string(bootstrap.device_error.as_deref()), + ), + ( + "vulkan_swapchain_status", + json_string(bootstrap.swapchain_status.as_str()), + ), + ( + "vulkan_swapchain_width", + optional_u32(bootstrap.swapchain_width), + ), + ( + "vulkan_swapchain_height", + optional_u32(bootstrap.swapchain_height), + ), + ( + "vulkan_swapchain_image_count", + optional_u32(bootstrap.swapchain_image_count), + ), + ( + "vulkan_swapchain_error", + optional_string(bootstrap.swapchain_error.as_deref()), + ), + ("reason", optional_string(options.reason.as_deref())), + ])) +} + +fn render_json_object(fields: &[(&str, String)]) -> String { + let mut out = String::from("{\n"); + for (index, (name, value)) in fields.iter().enumerate() { + out.push_str(" "); + out.push_str(&json_string(name)); + out.push_str(": "); + out.push_str(value); + if index + 1 < fields.len() { + out.push(','); + } + out.push('\n'); + } + out.push_str("}\n"); + out +} + +fn optional_string(value: Option<&str>) -> String { + value.map_or_else(|| "null".to_string(), json_string) +} + +fn optional_u32(value: Option<u32>) -> String { + value.map_or_else(|| "null".to_string(), |value| value.to_string()) +} + +fn bool_json(value: bool) -> String { + if value { "true" } else { "false" }.to_string() } fn format_api_version(version: u32) -> String { @@ -694,6 +844,31 @@ mod tests { values.iter().map(|value| (*value).to_string()).collect() } + fn probe_fixture() -> VulkanBootstrapProbe { + VulkanBootstrapProbe { + loader_status: VulkanLoaderStatus::Available, + instance_api: Some("1.3.0".to_string()), + loader_error: None, + instance_status: VulkanInstanceStatus::Created, + instance_error: None, + portability_enumeration: false, + window_status: WinitWindowStatus::Created, + window_width: Some(1280), + window_height: Some(720), + window_error: None, + surface_status: VulkanSurfaceStatus::Created, + surface_error: None, + device_status: VulkanDeviceStatus::Selected, + device_name: Some("Stage 0 GPU".to_string()), + device_error: None, + swapchain_status: VulkanSwapchainStatus::Planned, + swapchain_width: Some(1280), + swapchain_height: Some(720), + swapchain_image_count: Some(3), + swapchain_error: None, + } + } + #[test] fn parses_blocked_smoke_args() -> Result<(), String> { let options = SmokeOptions::parse(&strings(&[ @@ -727,6 +902,7 @@ mod tests { window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, + ..probe_fixture() }, ) } @@ -767,6 +943,7 @@ mod tests { window_error: None, surface_status: VulkanSurfaceStatus::Created, surface_error: None, + ..probe_fixture() }, ), Err("passed native smoke report requires --frames >= 300".to_string()) @@ -809,6 +986,7 @@ mod tests { window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, + ..probe_fixture() }, ), Err("passed native smoke report requires successful --probe-loader".to_string()) @@ -850,6 +1028,7 @@ mod tests { window_error: None, surface_status: VulkanSurfaceStatus::Created, surface_error: None, + ..probe_fixture() }, ), Err("passed native smoke report requires --swapchain-recreate-count >= 1".to_string()) @@ -893,6 +1072,7 @@ mod tests { window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, + ..probe_fixture() }, ), Err("passed native smoke report requires successful --probe-instance".to_string()) @@ -936,6 +1116,7 @@ mod tests { window_error: None, surface_status: VulkanSurfaceStatus::Created, surface_error: None, + ..probe_fixture() }, ), Err("passed native smoke report requires successful --probe-window".to_string()) @@ -980,6 +1161,7 @@ mod tests { window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, + ..probe_fixture() }, ), Err("passed native smoke report requires successful --probe-surface".to_string()) @@ -1023,6 +1205,7 @@ mod tests { window_error: None, surface_status: VulkanSurfaceStatus::Failed, surface_error: Some("Vulkan surface creation failed".to_string()), + ..probe_fixture() }, ), Err("passed native smoke report requires successful --probe-surface".to_string()) @@ -1030,6 +1213,75 @@ mod tests { } #[test] + fn rejects_passed_without_selected_device() { + let options = SmokeOptions::parse(&strings(&[ + "--platform", + "linux", + "--out", + "target/native.json", + "--status", + "passed", + "--frames", + "300", + "--resize-count", + "1", + "--swapchain-recreate-count", + "1", + "--validation-error-count", + "0", + "--probe-surface", + ])) + .expect("options"); + + assert_eq!( + validate_smoke_options( + &options, + &VulkanBootstrapProbe { + device_status: VulkanDeviceStatus::Failed, + device_name: None, + device_error: Some("no Vulkan physical device available".to_string()), + ..probe_fixture() + }, + ), + Err("passed native smoke report requires selected Vulkan device".to_string()) + ); + } + + #[test] + fn rejects_passed_without_planned_swapchain() { + let options = SmokeOptions::parse(&strings(&[ + "--platform", + "linux", + "--out", + "target/native.json", + "--status", + "passed", + "--frames", + "300", + "--resize-count", + "1", + "--swapchain-recreate-count", + "1", + "--validation-error-count", + "0", + "--probe-surface", + ])) + .expect("options"); + + assert_eq!( + validate_smoke_options( + &options, + &VulkanBootstrapProbe { + swapchain_status: VulkanSwapchainStatus::Failed, + swapchain_error: Some("Vulkan swapchain has no surface format".to_string()), + ..probe_fixture() + }, + ), + Err("passed native smoke report requires planned Vulkan swapchain".to_string()) + ); + } + + #[test] fn blocked_report_includes_shader_manifest_and_bootstrap_status() -> Result<(), String> { let options = SmokeOptions::parse(&strings(&[ "--platform", @@ -1060,6 +1312,14 @@ mod tests { "native window/display handles are required for Vulkan surface creation" .to_string(), ), + device_status: VulkanDeviceStatus::Skipped, + device_name: None, + device_error: None, + swapchain_status: VulkanSwapchainStatus::Skipped, + swapchain_width: None, + swapchain_height: None, + swapchain_image_count: None, + swapchain_error: None, }, )?; @@ -1084,6 +1344,10 @@ mod tests { assert!(json.contains( "\"vulkan_surface_error\": \"native window/display handles are required for Vulkan surface creation\"" )); + assert!(json.contains("\"vulkan_device_status\": \"skipped\"")); + assert!(json.contains("\"vulkan_device_name\": null")); + assert!(json.contains("\"vulkan_swapchain_status\": \"skipped\"")); + assert!(json.contains("\"vulkan_swapchain_width\": null")); assert!(json.contains("\"reason\": \"runner unavailable\"")); Ok(()) } diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index 051ffd5..7c8e40d 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -60,6 +60,8 @@ S0-VK-027 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_wi S0-VK-028 covered cargo test -p fparkan-vulkan-smoke --offline reports_rustc_host_triple blocked_report_includes_shader_manifest_and_bootstrap_status S0-VK-029 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports S0-VK-030 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_with_failed_surface +S0-VK-031 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_selected_device +S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_planned_swapchain S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates diff --git a/fixtures/acceptance/stage_0_2_roadmap.md b/fixtures/acceptance/stage_0_2_roadmap.md index 7976b52..5a3c19d 100644 --- a/fixtures/acceptance/stage_0_2_roadmap.md +++ b/fixtures/acceptance/stage_0_2_roadmap.md @@ -60,6 +60,8 @@ `S0-VK-028` `S0-VK-029` `S0-VK-030` +`S0-VK-031` +`S0-VK-032` `S0-LIMIT-001` `S0-LIMIT-002` `L1-P1-NRES-001` diff --git a/xtask/src/main.rs b/xtask/src/main.rs index dbe4983..9ca8da9 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1535,6 +1535,20 @@ fn validate_native_smoke_report( "created", failures, ); + expect_string_field( + platform, + report, + "vulkan_device_status", + "selected", + failures, + ); + expect_string_field( + platform, + report, + "vulkan_swapchain_status", + "planned", + failures, + ); expect_u64_at_least(platform, report, "frames", 300, failures); expect_u64_at_least(platform, report, "resize_count", 1, failures); expect_u64_at_least(platform, report, "swapchain_recreate_count", 1, failures); @@ -1543,6 +1557,16 @@ fn validate_native_smoke_report( expect_nonempty_string(platform, report, "rust_toolchain", failures); expect_nonempty_string(platform, report, "target_triple", failures); expect_nonempty_string(platform, report, "shader_manifest_hash", failures); + expect_nonempty_string(platform, report, "vulkan_device_name", failures); + expect_u64_at_least(platform, report, "vulkan_swapchain_width", 1, failures); + expect_u64_at_least(platform, report, "vulkan_swapchain_height", 1, failures); + expect_u64_at_least( + platform, + report, + "vulkan_swapchain_image_count", + 2, + failures, + ); } fn expect_string_field( @@ -2233,7 +2257,13 @@ mod tests { "vulkan_loader_status": "available", "vulkan_instance_status": "created", "window_status": "created", - "vulkan_surface_status": "created" + "vulkan_surface_status": "created", + "vulkan_device_status": "selected", + "vulkan_device_name": format!("{platform} GPU"), + "vulkan_swapchain_status": "planned", + "vulkan_swapchain_width": 1280, + "vulkan_swapchain_height": 720, + "vulkan_swapchain_image_count": 3 }), ) }) @@ -2261,7 +2291,9 @@ mod tests { "vulkan_loader_status": "unavailable", "vulkan_instance_status": "skipped", "window_status": "planned", - "vulkan_surface_status": "skipped" + "vulkan_surface_status": "skipped", + "vulkan_device_status": "skipped", + "vulkan_swapchain_status": "skipped" }), )] .into_iter() |
