diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 23:14:26 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 23:14:26 +0300 |
| commit | 159731664fae9ea3f08ec594985b82248988732d (patch) | |
| tree | 3c590f6f20517d2ec599e1dddded48305b6baef6 | |
| parent | e6b7fa189642bf432dd2bbcf1bcff659bd794750 (diff) | |
| download | fparkan-159731664fae9ea3f08ec594985b82248988732d.tar.xz fparkan-159731664fae9ea3f08ec594985b82248988732d.zip | |
feat: probe Vulkan logical device creation
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/lib.rs | 173 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/src/main.rs | 167 | ||||
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 1 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_0_2_roadmap.md | 1 | ||||
| -rw-r--r-- | xtask/src/main.rs | 32 |
5 files changed, 354 insertions, 20 deletions
diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs index 38d521d..d2345e1 100644 --- a/adapters/fparkan-render-vulkan/src/lib.rs +++ b/adapters/fparkan-render-vulkan/src/lib.rs @@ -320,6 +320,37 @@ pub struct VulkanRuntimeCapabilityProbe { pub swapchain: VulkanSwapchainPlan, } +/// Created Vulkan logical device probe. +pub struct VulkanLogicalDeviceProbe { + device: ash::Device, + /// Runtime capability report used for device selection. + pub runtime: VulkanRuntimeCapabilityProbe, + /// Deterministic logical device creation report. + pub report: VulkanLogicalDeviceReport, +} + +impl Drop for VulkanLogicalDeviceProbe { + fn drop(&mut self) { + // SAFETY: The logical device was created by this probe and is destroyed once during drop. + unsafe { self.device.destroy_device(None) }; + } +} + +/// Logical device creation report. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanLogicalDeviceReport { + /// Report schema version. + pub schema: u32, + /// Selected physical device name. + pub device_name: String, + /// Graphics queue-family index used by the logical device. + pub graphics_queue_family: u32, + /// Present queue-family index used by the logical device. + pub present_queue_family: u32, + /// Enabled device extensions. + pub enabled_extensions: Vec<String>, +} + /// Live Vulkan device/surface capability probe error. #[derive(Clone, Debug, Eq, PartialEq)] pub enum VulkanRuntimeCapabilityError { @@ -409,6 +440,45 @@ impl std::fmt::Display for VulkanRuntimeCapabilityError { impl std::error::Error for VulkanRuntimeCapabilityError {} +/// Vulkan logical device creation error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VulkanLogicalDeviceError { + /// Runtime capability probing failed. + Runtime(VulkanRuntimeCapabilityError), + /// Device extension name contained an interior NUL byte. + InvalidExtensionName { + /// Invalid extension name. + extension: String, + }, + /// Logical device creation failed. + CreateFailed { + /// Selected device name. + device: String, + /// Vulkan result. + result: String, + }, +} + +impl std::fmt::Display for VulkanLogicalDeviceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Runtime(error) => write!(f, "{error}"), + Self::InvalidExtensionName { extension } => write!( + f, + "Vulkan device extension name contains an interior NUL byte: {extension:?}" + ), + Self::CreateFailed { device, result } => { + write!( + f, + "Vulkan logical device creation failed for {device}: {result}" + ) + } + } + } +} + +impl std::error::Error for VulkanLogicalDeviceError {} + /// Builds a deterministic Vulkan surface plan from native window handles. /// /// # Errors @@ -480,6 +550,78 @@ pub fn probe_vulkan_runtime_capabilities( surface: &VulkanSurfaceProbe, drawable_extent: (u32, u32), ) -> Result<VulkanRuntimeCapabilityProbe, VulkanRuntimeCapabilityError> { + let selected = select_live_device_candidate(instance, surface, drawable_extent)?; + Ok(selected.runtime) +} + +/// Creates a Vulkan logical device for the selected live surface-capable device. +/// +/// # Errors +/// +/// Returns [`VulkanLogicalDeviceError`] when runtime capability probing fails, +/// device extension names are invalid, or `vkCreateDevice` fails. +pub fn create_vulkan_logical_device_probe( + instance: &VulkanInstanceProbe, + surface: &VulkanSurfaceProbe, + drawable_extent: (u32, u32), +) -> Result<VulkanLogicalDeviceProbe, VulkanLogicalDeviceError> { + let selected = select_live_device_candidate(instance, surface, drawable_extent) + .map_err(VulkanLogicalDeviceError::Runtime)?; + let capability = &selected.runtime.capability; + let queue_priorities = [1.0_f32]; + let queue_families = unique_queue_families( + capability.graphics_queue_family, + capability.present_queue_family, + ); + let queue_infos = queue_families + .iter() + .map(|queue_family| { + vk::DeviceQueueCreateInfo::default() + .queue_family_index(*queue_family) + .queue_priorities(&queue_priorities) + }) + .collect::<Vec<_>>(); + let extension_names = device_extension_cstrings(&capability.enabled_extensions) + .map_err(|extension| VulkanLogicalDeviceError::InvalidExtensionName { extension })?; + let extension_ptrs = extension_names + .iter() + .map(|extension| extension.as_ptr()) + .collect::<Vec<_>>(); + let create_info = vk::DeviceCreateInfo::default() + .queue_create_infos(&queue_infos) + .enabled_extension_names(&extension_ptrs); + // SAFETY: `selected.physical_device` belongs to `instance`; create data lives for the call. + let device = unsafe { + instance + .instance + .create_device(selected.physical_device, &create_info, None) + } + .map_err(|error| VulkanLogicalDeviceError::CreateFailed { + device: capability.device_name.clone(), + result: format!("{error:?}"), + })?; + // SAFETY: Queue family indices came from validated live queue families requested above. + let _graphics_queue = unsafe { device.get_device_queue(capability.graphics_queue_family, 0) }; + // SAFETY: Queue family indices came from validated live queue families requested above. + let _present_queue = unsafe { device.get_device_queue(capability.present_queue_family, 0) }; + Ok(VulkanLogicalDeviceProbe { + device, + report: VulkanLogicalDeviceReport { + schema: 1, + device_name: capability.device_name.clone(), + graphics_queue_family: capability.graphics_queue_family, + present_queue_family: capability.present_queue_family, + enabled_extensions: capability.enabled_extensions.clone(), + }, + runtime: selected.runtime, + }) +} + +fn select_live_device_candidate( + instance: &VulkanInstanceProbe, + surface: &VulkanSurfaceProbe, + drawable_extent: (u32, u32), +) -> Result<SelectedLiveDevice, 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| { @@ -509,13 +651,22 @@ pub fn probe_vulkan_runtime_capabilities( preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(), }) .map_err(VulkanRuntimeCapabilityError::Swapchain)?; - Ok(VulkanRuntimeCapabilityProbe { - capability: best.capability, - swapchain, + Ok(SelectedLiveDevice { + physical_device: best.physical_device, + runtime: VulkanRuntimeCapabilityProbe { + capability: best.capability, + swapchain, + }, }) } +struct SelectedLiveDevice { + physical_device: vk::PhysicalDevice, + runtime: VulkanRuntimeCapabilityProbe, +} + struct LiveDeviceCandidate { + physical_device: vk::PhysicalDevice, capability: VulkanCapabilityReport, surface_formats: Vec<VulkanSurfaceFormat>, present_modes: Vec<i32>, @@ -587,6 +738,7 @@ fn live_device_candidate( }; let capability = validate_device(&record).map_err(VulkanRuntimeCapabilityError::Capability)?; Ok(LiveDeviceCandidate { + physical_device: device, capability, surface_formats, present_modes, @@ -594,6 +746,21 @@ fn live_device_candidate( }) } +fn unique_queue_families(graphics: u32, present: u32) -> Vec<u32> { + if graphics == present { + vec![graphics] + } else { + vec![graphics, present] + } +} + +fn device_extension_cstrings(values: &[String]) -> Result<Vec<CString>, String> { + values + .iter() + .map(|extension| CString::new(extension.as_str()).map_err(|_| extension.clone())) + .collect() +} + 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()) } diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index da8d68f..9425c31 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -14,9 +14,9 @@ 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, - probe_vulkan_runtime_capabilities, triangle_shader_manifest, validate_shader_manifest, - VulkanInstanceConfig, VulkanInstanceProbe, VulkanRuntimeCapabilityProbe, + create_vulkan_instance_probe, create_vulkan_logical_device_probe, create_vulkan_surface_probe, + probe_vulkan_loader, triangle_shader_manifest, validate_shader_manifest, VulkanInstanceConfig, + VulkanInstanceProbe, VulkanLogicalDeviceProbe, }; use std::path::PathBuf; use std::process::Command; @@ -209,6 +209,11 @@ struct VulkanBootstrapProbe { device_status: VulkanDeviceStatus, device_name: Option<String>, device_error: Option<String>, + logical_device_status: VulkanLogicalDeviceStatus, + logical_device_graphics_queue_family: Option<u32>, + logical_device_present_queue_family: Option<u32>, + logical_device_enabled_extension_count: Option<u32>, + logical_device_error: Option<String>, swapchain_status: VulkanSwapchainStatus, swapchain_width: Option<u32>, swapchain_height: Option<u32>, @@ -246,6 +251,11 @@ impl VulkanBootstrapProbe { device_status: VulkanDeviceStatus::Skipped, device_name: None, device_error: None, + logical_device_status: VulkanLogicalDeviceStatus::Skipped, + logical_device_graphics_queue_family: None, + logical_device_present_queue_family: None, + logical_device_enabled_extension_count: None, + logical_device_error: None, swapchain_status: VulkanSwapchainStatus::Skipped, swapchain_width: None, swapchain_height: None, @@ -272,6 +282,11 @@ impl VulkanBootstrapProbe { device_status: VulkanDeviceStatus::Skipped, device_name: None, device_error: None, + logical_device_status: VulkanLogicalDeviceStatus::Skipped, + logical_device_graphics_queue_family: None, + logical_device_present_queue_family: None, + logical_device_enabled_extension_count: None, + logical_device_error: None, swapchain_status: VulkanSwapchainStatus::Skipped, swapchain_width: None, swapchain_height: None, @@ -294,6 +309,11 @@ impl VulkanBootstrapProbe { device_status: VulkanDeviceStatus::Skipped, device_name: None, device_error: None, + logical_device_status: VulkanLogicalDeviceStatus::Skipped, + logical_device_graphics_queue_family: None, + logical_device_present_queue_family: None, + logical_device_enabled_extension_count: None, + logical_device_error: None, swapchain_status: VulkanSwapchainStatus::Skipped, swapchain_width: None, swapchain_height: None, @@ -392,10 +412,11 @@ impl VulkanBootstrapProbe { let Some(instance) = instance else { self.device_status = VulkanDeviceStatus::Failed; self.device_error = Some("Vulkan instance probe was not retained".to_string()); + self.logical_device_status = VulkanLogicalDeviceStatus::Skipped; self.swapchain_status = VulkanSwapchainStatus::Skipped; return; }; - match probe_vulkan_runtime_capabilities( + match create_vulkan_logical_device_probe( instance, surface, ( @@ -403,23 +424,36 @@ impl VulkanBootstrapProbe { self.window_height.unwrap_or(1).max(1), ), ) { - Ok(runtime) => self.record_runtime_capabilities(runtime), + Ok(device) => self.record_logical_device_probe(&device), Err(err) => { self.device_status = VulkanDeviceStatus::Failed; self.device_error = Some(err.to_string()); + self.logical_device_status = VulkanLogicalDeviceStatus::Failed; + self.logical_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) { + fn record_logical_device_probe(&mut self, device: &VulkanLogicalDeviceProbe) { self.device_status = VulkanDeviceStatus::Selected; - self.device_name = Some(runtime.capability.device_name); + self.device_name = Some(device.runtime.capability.device_name.clone()); + self.logical_device_status = VulkanLogicalDeviceStatus::Created; + self.logical_device_graphics_queue_family = Some(device.report.graphics_queue_family); + self.logical_device_present_queue_family = Some(device.report.present_queue_family); + self.logical_device_enabled_extension_count = Some( + device + .report + .enabled_extensions + .len() + .try_into() + .unwrap_or(u32::MAX), + ); 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); + self.swapchain_width = Some(device.runtime.swapchain.extent.0); + self.swapchain_height = Some(device.runtime.swapchain.extent.1); + self.swapchain_image_count = Some(device.runtime.swapchain.image_count); } } @@ -511,6 +545,23 @@ impl VulkanDeviceStatus { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum VulkanLogicalDeviceStatus { + Skipped, + Created, + Failed, +} + +impl VulkanLogicalDeviceStatus { + const fn as_str(self) -> &'static str { + match self { + Self::Skipped => "skipped", + Self::Created => "created", + Self::Failed => "failed", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] enum VulkanSwapchainStatus { Skipped, Planned, @@ -635,6 +686,11 @@ fn validate_smoke_options( "passed native smoke report requires selected Vulkan device".to_string() ); } + if bootstrap.logical_device_status != VulkanLogicalDeviceStatus::Created { + return Err( + "passed native smoke report requires created Vulkan logical device".to_string(), + ); + } if bootstrap.swapchain_status != VulkanSwapchainStatus::Planned { return Err( "passed native smoke report requires planned Vulkan swapchain".to_string(), @@ -651,7 +707,17 @@ fn render_smoke_report_json( ) -> Result<String, String> { let shader_manifest = validate_shader_manifest(&triangle_shader_manifest()) .map_err(|err| format!("shader manifest: {err}"))?; - Ok(render_json_object(&[ + let mut fields = base_smoke_report_fields(options, &shader_manifest.manifest_hash); + fields.extend(vulkan_bootstrap_fields(bootstrap)); + fields.push(("reason", optional_string(options.reason.as_deref()))); + Ok(render_json_object(&fields)) +} + +fn base_smoke_report_fields( + options: &SmokeOptions, + shader_manifest_hash: &str, +) -> Vec<(&'static str, String)> { + vec![ ("schema_version", json_string(SCHEMA_VERSION)), ("commit_sha", json_string(¤t_git_commit_sha())), ("rust_toolchain", json_string(RUST_TOOLCHAIN)), @@ -668,10 +734,12 @@ fn render_smoke_report_json( "validation_error_count", optional_u32(options.validation_error_count), ), - ( - "shader_manifest_hash", - json_string(&shader_manifest.manifest_hash), - ), + ("shader_manifest_hash", json_string(shader_manifest_hash)), + ] +} + +fn vulkan_bootstrap_fields(bootstrap: &VulkanBootstrapProbe) -> Vec<(&'static str, String)> { + vec![ ( "vulkan_loader_status", json_string(bootstrap.loader_status.as_str()), @@ -727,6 +795,26 @@ fn render_smoke_report_json( optional_string(bootstrap.device_error.as_deref()), ), ( + "vulkan_logical_device_status", + json_string(bootstrap.logical_device_status.as_str()), + ), + ( + "vulkan_logical_device_graphics_queue_family", + optional_u32(bootstrap.logical_device_graphics_queue_family), + ), + ( + "vulkan_logical_device_present_queue_family", + optional_u32(bootstrap.logical_device_present_queue_family), + ), + ( + "vulkan_logical_device_enabled_extension_count", + optional_u32(bootstrap.logical_device_enabled_extension_count), + ), + ( + "vulkan_logical_device_error", + optional_string(bootstrap.logical_device_error.as_deref()), + ), + ( "vulkan_swapchain_status", json_string(bootstrap.swapchain_status.as_str()), ), @@ -746,8 +834,7 @@ fn render_smoke_report_json( "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 { @@ -861,6 +948,11 @@ mod tests { device_status: VulkanDeviceStatus::Selected, device_name: Some("Stage 0 GPU".to_string()), device_error: None, + logical_device_status: VulkanLogicalDeviceStatus::Created, + logical_device_graphics_queue_family: Some(0), + logical_device_present_queue_family: Some(0), + logical_device_enabled_extension_count: Some(1), + logical_device_error: None, swapchain_status: VulkanSwapchainStatus::Planned, swapchain_width: Some(1280), swapchain_height: Some(720), @@ -1282,6 +1374,40 @@ mod tests { } #[test] + fn rejects_passed_without_created_logical_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 { + logical_device_status: VulkanLogicalDeviceStatus::Failed, + logical_device_error: Some("Vulkan logical device creation failed".to_string()), + ..probe_fixture() + }, + ), + Err("passed native smoke report requires created Vulkan logical device".to_string()) + ); + } + + #[test] fn blocked_report_includes_shader_manifest_and_bootstrap_status() -> Result<(), String> { let options = SmokeOptions::parse(&strings(&[ "--platform", @@ -1315,6 +1441,11 @@ mod tests { device_status: VulkanDeviceStatus::Skipped, device_name: None, device_error: None, + logical_device_status: VulkanLogicalDeviceStatus::Skipped, + logical_device_graphics_queue_family: None, + logical_device_present_queue_family: None, + logical_device_enabled_extension_count: None, + logical_device_error: None, swapchain_status: VulkanSwapchainStatus::Skipped, swapchain_width: None, swapchain_height: None, @@ -1346,6 +1477,8 @@ mod tests { )); assert!(json.contains("\"vulkan_device_status\": \"skipped\"")); assert!(json.contains("\"vulkan_device_name\": null")); + assert!(json.contains("\"vulkan_logical_device_status\": \"skipped\"")); + assert!(json.contains("\"vulkan_logical_device_graphics_queue_family\": null")); assert!(json.contains("\"vulkan_swapchain_status\": \"skipped\"")); assert!(json.contains("\"vulkan_swapchain_width\": null")); assert!(json.contains("\"reason\": \"runner unavailable\"")); diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index 7c8e40d..ed9a090 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -62,6 +62,7 @@ S0-VK-029 covered cargo test -p xtask --offline native_smoke_audit_accepts_compl 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-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device 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 5a3c19d..6fc3239 100644 --- a/fixtures/acceptance/stage_0_2_roadmap.md +++ b/fixtures/acceptance/stage_0_2_roadmap.md @@ -62,6 +62,7 @@ `S0-VK-030` `S0-VK-031` `S0-VK-032` +`S0-VK-033` `S0-LIMIT-001` `S0-LIMIT-002` `L1-P1-NRES-001` diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9ca8da9..4babad2 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1545,6 +1545,13 @@ fn validate_native_smoke_report( expect_string_field( platform, report, + "vulkan_logical_device_status", + "created", + failures, + ); + expect_string_field( + platform, + report, "vulkan_swapchain_status", "planned", failures, @@ -1558,6 +1565,27 @@ fn validate_native_smoke_report( 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_logical_device_enabled_extension_count", + 1, + failures, + ); + expect_u64_at_least( + platform, + report, + "vulkan_logical_device_graphics_queue_family", + 0, + failures, + ); + expect_u64_at_least( + platform, + report, + "vulkan_logical_device_present_queue_family", + 0, + 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( @@ -2260,6 +2288,10 @@ mod tests { "vulkan_surface_status": "created", "vulkan_device_status": "selected", "vulkan_device_name": format!("{platform} 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": 1, "vulkan_swapchain_status": "planned", "vulkan_swapchain_width": 1280, "vulkan_swapchain_height": 720, |
