aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-23 23:05:46 +0300
committerValentin Popov <valentin@popov.link>2026-06-23 23:05:46 +0300
commite6b7fa189642bf432dd2bbcf1bcff659bd794750 (patch)
tree13ee0fe976ef617c6fc478035389d44f30357875
parent0e127117e9f826ecf4be312b0c630121f38b4d95 (diff)
downloadfparkan-e6b7fa189642bf432dd2bbcf1bcff659bd794750.tar.xz
fparkan-e6b7fa189642bf432dd2bbcf1bcff659bd794750.zip
feat: probe live Vulkan runtime capabilities
-rw-r--r--adapters/fparkan-render-vulkan/src/lib.rs356
-rw-r--r--apps/fparkan-vulkan-smoke/src/main.rs448
-rw-r--r--fixtures/acceptance/coverage.tsv2
-rw-r--r--fixtures/acceptance/stage_0_2_roadmap.md2
-rw-r--r--xtask/src/main.rs36
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(&current_git_commit_sha())),
+ ("rust_toolchain", json_string(RUST_TOOLCHAIN)),
+ ("target_triple", json_string(&current_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(&current_git_commit_sha()),
- RUST_TOOLCHAIN,
- json_escape(&current_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()