diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 21:40:01 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 21:40:01 +0300 |
| commit | 69c032accab677b53f3dff61f3afb870c2e8e0a8 (patch) | |
| tree | 626bd9204ae2ade533a1e1828093a676a490610f | |
| parent | 9cc24e715db81edbe21c0d04aadd00f11dddecb8 (diff) | |
| download | fparkan-69c032accab677b53f3dff61f3afb870c2e8e0a8.tar.xz fparkan-69c032accab677b53f3dff61f3afb870c2e8e0a8.zip | |
feat: add Vulkan capability selection boundary
| -rw-r--r-- | Cargo.lock | 88 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/Cargo.toml | 2 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/lib.rs | 477 | ||||
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 6 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_0_2_roadmap.md | 6 |
5 files changed, 577 insertions, 2 deletions
@@ -81,6 +81,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" [[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ash-window" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" +dependencies = [ + "ash", + "raw-window-handle", + "raw-window-metal", +] + +[[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -105,6 +125,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] name = "block2" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -231,6 +257,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] name = "combine" version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -618,6 +674,8 @@ dependencies = [ name = "fparkan-render-vulkan" version = "0.1.0" dependencies = [ + "ash", + "ash-window", "fparkan-platform", "fparkan-render", ] @@ -1016,6 +1074,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1102,6 +1169,15 @@ dependencies = [ ] [[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] name = "objc-sys" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1454,6 +1530,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] +name = "raw-window-metal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" +dependencies = [ + "cocoa", + "core-graphics", + "objc", + "raw-window-handle", +] + +[[package]] name = "redox_syscall" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/adapters/fparkan-render-vulkan/Cargo.toml b/adapters/fparkan-render-vulkan/Cargo.toml index 20b923f..5fd8c6c 100644 --- a/adapters/fparkan-render-vulkan/Cargo.toml +++ b/adapters/fparkan-render-vulkan/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true repository.workspace = true [dependencies] +ash = "0.38" +ash-window = "0.13" fparkan-platform = { path = "../../crates/fparkan-platform" } fparkan-render = { path = "../../crates/fparkan-render" } diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs index 6cae797..75447e8 100644 --- a/adapters/fparkan-render-vulkan/src/lib.rs +++ b/adapters/fparkan-render-vulkan/src/lib.rs @@ -27,12 +27,18 @@ //! //! This crate is the declared low-level Vulkan boundary. +use ash::vk; use fparkan_platform::RenderRequest; use fparkan_render::{ canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError, }; use std::time::{SystemTime, UNIX_EPOCH}; +/// Minimum Vulkan API version accepted by the Stage 0 backend. +pub const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1; +const KHR_SWAPCHAIN_EXTENSION: &str = "VK_KHR_swapchain"; +const KHR_PORTABILITY_SUBSET_EXTENSION: &str = "VK_KHR_portability_subset"; + /// Vulkan backend migration readiness. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum VulkanBackendState { @@ -50,6 +56,330 @@ impl Default for VulkanBackendState { } } +/// Synthetic physical-device type used by deterministic capability scoring. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VulkanDeviceType { + /// Discrete GPU. + DiscreteGpu, + /// Integrated GPU. + IntegratedGpu, + /// CPU or software Vulkan implementation. + Cpu, + /// Other or unknown implementation. + Other, +} + +impl VulkanDeviceType { + const fn score_bonus(self) -> i32 { + match self { + Self::DiscreteGpu => 1_000, + Self::IntegratedGpu => 700, + Self::Cpu => 100, + Self::Other => 10, + } + } +} + +/// Queue-family capabilities needed by the Stage 0 renderer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VulkanQueueFamily { + /// Stable queue-family index. + pub index: u32, + /// Whether the family supports graphics commands. + pub graphics: bool, + /// Whether the family supports presentation for the target surface. + pub present: bool, +} + +/// Surface format capability needed by the Stage 0 swapchain policy. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VulkanSurfaceFormat { + /// Vulkan format numeric value. + pub format: i32, + /// Vulkan color-space numeric value. + pub color_space: i32, +} + +/// Synthetic physical-device capabilities used by negative tests and reports. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanPhysicalDeviceRecord { + /// Human-readable device name. + pub name: String, + /// Reported Vulkan API version. + pub api_version: u32, + /// Device class. + pub device_type: VulkanDeviceType, + /// Supported device-extension names. + pub extensions: Vec<String>, + /// Queue-family capabilities. + pub queue_families: Vec<VulkanQueueFamily>, + /// Surface formats accepted by the target surface. + pub surface_formats: Vec<VulkanSurfaceFormat>, +} + +impl VulkanPhysicalDeviceRecord { + /// Returns whether the device supports an extension name. + #[must_use] + pub fn supports_extension(&self, extension: &str) -> bool { + self.extensions + .iter() + .any(|candidate| candidate == extension) + } +} + +/// Selected device and queue capability report. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanCapabilityReport { + /// Report schema version. + pub schema: u32, + /// Selected device name. + pub device_name: String, + /// Selected Vulkan API version. + pub vulkan_api_version: u32, + /// Deterministic score used for device selection. + pub score: i32, + /// Graphics queue family index. + pub graphics_queue_family: u32, + /// Present queue family index. + pub present_queue_family: u32, + /// Whether portability subset is enabled for the selected device. + pub portability_subset: bool, + /// Enabled device extensions. + pub enabled_extensions: Vec<String>, +} + +/// Vulkan capability selection error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VulkanCapabilityError { + /// No physical devices were available. + NoPhysicalDevice, + /// Device API version is lower than the Stage 0 minimum. + ApiVersionTooLow { + /// Required Vulkan API version. + required: u32, + /// Reported Vulkan API version. + found: u32, + }, + /// Required graphics queue is unavailable. + NoGraphicsQueue { + /// Device name that failed validation. + device: String, + }, + /// Required present queue is unavailable. + NoPresentQueue { + /// Device name that failed validation. + device: String, + }, + /// Swapchain device extension is unavailable. + MissingSwapchainExtension { + /// Device name that failed validation. + device: String, + }, + /// No compatible surface format exists. + MissingSurfaceFormat { + /// Device name that failed validation. + device: String, + }, +} + +impl std::fmt::Display for VulkanCapabilityError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoPhysicalDevice => write!(f, "no Vulkan physical device available"), + Self::ApiVersionTooLow { required, found } => write!( + f, + "Vulkan API version too low: required {}, found {}", + format_api_version(*required), + format_api_version(*found) + ), + Self::NoGraphicsQueue { device } => { + write!(f, "Vulkan device {device} has no graphics queue") + } + Self::NoPresentQueue { device } => { + write!(f, "Vulkan device {device} has no present queue") + } + Self::MissingSwapchainExtension { device } => { + write!(f, "Vulkan device {device} lacks {KHR_SWAPCHAIN_EXTENSION}") + } + Self::MissingSurfaceFormat { device } => { + write!(f, "Vulkan device {device} has no compatible surface format") + } + } + } +} + +impl std::error::Error for VulkanCapabilityError {} + +/// Selects a Vulkan physical device using deterministic Stage 0 policy. +/// +/// # Errors +/// +/// Returns [`VulkanCapabilityError`] when no candidate satisfies the minimum +/// API version, queue, swapchain-extension and surface-format requirements. +pub fn select_physical_device( + devices: &[VulkanPhysicalDeviceRecord], +) -> Result<VulkanCapabilityReport, VulkanCapabilityError> { + if devices.is_empty() { + return Err(VulkanCapabilityError::NoPhysicalDevice); + } + + let mut best = None; + for device in devices { + let report = validate_device(device)?; + match &best { + Some(existing) if compare_reports(&report, existing) != std::cmp::Ordering::Greater => { + } + _ => best = Some(report), + } + } + best.ok_or(VulkanCapabilityError::NoPhysicalDevice) +} + +fn validate_device( + device: &VulkanPhysicalDeviceRecord, +) -> Result<VulkanCapabilityReport, VulkanCapabilityError> { + if device.api_version < MIN_VULKAN_API_VERSION { + return Err(VulkanCapabilityError::ApiVersionTooLow { + required: MIN_VULKAN_API_VERSION, + found: device.api_version, + }); + } + if !device.supports_extension(KHR_SWAPCHAIN_EXTENSION) { + return Err(VulkanCapabilityError::MissingSwapchainExtension { + device: device.name.clone(), + }); + } + if device.surface_formats.is_empty() { + return Err(VulkanCapabilityError::MissingSurfaceFormat { + device: device.name.clone(), + }); + } + let graphics_queue_family = device + .queue_families + .iter() + .find(|family| family.graphics) + .ok_or_else(|| VulkanCapabilityError::NoGraphicsQueue { + device: device.name.clone(), + })? + .index; + let present_queue_family = device + .queue_families + .iter() + .find(|family| family.present) + .ok_or_else(|| VulkanCapabilityError::NoPresentQueue { + device: device.name.clone(), + })? + .index; + + let portability_subset = device.supports_extension(KHR_PORTABILITY_SUBSET_EXTENSION); + let mut enabled_extensions = vec![KHR_SWAPCHAIN_EXTENSION.to_string()]; + if portability_subset { + enabled_extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string()); + } + + Ok(VulkanCapabilityReport { + schema: 1, + device_name: device.name.clone(), + vulkan_api_version: device.api_version, + score: score_device(device, graphics_queue_family, present_queue_family), + graphics_queue_family, + present_queue_family, + portability_subset, + enabled_extensions, + }) +} + +fn score_device( + device: &VulkanPhysicalDeviceRecord, + graphics_queue_family: u32, + present_queue_family: u32, +) -> i32 { + let unified_queue_bonus = if graphics_queue_family == present_queue_family { + 100 + } else { + 0 + }; + let portability_penalty = if device.supports_extension(KHR_PORTABILITY_SUBSET_EXTENSION) { + -50 + } else { + 0 + }; + device.device_type.score_bonus() + + unified_queue_bonus + + portability_penalty + + i32::try_from(device.surface_formats.len()).unwrap_or(i32::MAX) +} + +fn compare_reports( + left: &VulkanCapabilityReport, + right: &VulkanCapabilityReport, +) -> std::cmp::Ordering { + left.score + .cmp(&right.score) + .then_with(|| right.device_name.cmp(&left.device_name)) +} + +/// Renders a deterministic JSON capability report. +#[must_use] +pub fn render_capability_report_json(report: &VulkanCapabilityReport) -> String { + let mut out = String::new(); + out.push_str("{\"schema\":"); + out.push_str(&report.schema.to_string()); + out.push_str(",\"vulkan_api\":\""); + out.push_str(&format_api_version(report.vulkan_api_version)); + out.push_str("\",\"device_name\":"); + push_json_string(&mut out, &report.device_name); + out.push_str(",\"score\":"); + out.push_str(&report.score.to_string()); + out.push_str(",\"graphics_queue_family\":"); + out.push_str(&report.graphics_queue_family.to_string()); + out.push_str(",\"present_queue_family\":"); + out.push_str(&report.present_queue_family.to_string()); + out.push_str(",\"portability_subset\":"); + out.push_str(if report.portability_subset { + "true" + } else { + "false" + }); + out.push_str(",\"enabled_extensions\":["); + for (index, extension) in report.enabled_extensions.iter().enumerate() { + if index > 0 { + out.push(','); + } + push_json_string(&mut out, extension); + } + out.push_str("]}"); + out +} + +fn format_api_version(version: u32) -> String { + format!( + "{}.{}.{}", + vk::api_version_major(version), + vk::api_version_minor(version), + vk::api_version_patch(version) + ) +} + +fn push_json_string(out: &mut String, value: &str) { + out.push('"'); + for ch in value.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() => { + use std::fmt::Write as _; + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); +} + /// Diagnostics for Vulkan backend setup and frame progression. #[derive(Clone, Debug, PartialEq)] pub struct VulkanBackendReport { @@ -194,4 +524,151 @@ mod tests { assert!(backend.report().last_capture_size > 0); Ok(()) } + + #[test] + fn device_scoring_is_deterministic_and_prefers_discrete_unified_queue() { + let devices = vec![ + device("SwiftShader", VulkanDeviceType::Cpu, 0, true, false), + device("Discrete", VulkanDeviceType::DiscreteGpu, 1, true, false), + device( + "Integrated", + VulkanDeviceType::IntegratedGpu, + 2, + true, + false, + ), + ]; + + let report = select_physical_device(&devices).expect("selected device"); + + assert_eq!(report.device_name, "Discrete"); + assert_eq!(report.graphics_queue_family, 1); + assert_eq!(report.present_queue_family, 1); + assert!(!report.portability_subset); + assert_eq!(report.enabled_extensions, vec![KHR_SWAPCHAIN_EXTENSION]); + } + + #[test] + fn portability_subset_is_reported_and_enabled_when_exposed() { + let report = select_physical_device(&[device( + "MoltenVK", + VulkanDeviceType::IntegratedGpu, + 0, + true, + true, + )]) + .expect("selected device"); + + assert!(report.portability_subset); + assert_eq!( + report.enabled_extensions, + vec![ + KHR_SWAPCHAIN_EXTENSION.to_string(), + KHR_PORTABILITY_SUBSET_EXTENSION.to_string() + ] + ); + } + + #[test] + fn missing_loader_candidates_are_reported() { + assert_eq!( + select_physical_device(&[]), + Err(VulkanCapabilityError::NoPhysicalDevice) + ); + } + + #[test] + fn rejects_low_api_version() { + let mut candidate = device("Old GPU", VulkanDeviceType::DiscreteGpu, 0, true, false); + candidate.api_version = vk::API_VERSION_1_0; + + assert!(matches!( + select_physical_device(&[candidate]), + Err(VulkanCapabilityError::ApiVersionTooLow { .. }) + )); + } + + #[test] + fn rejects_missing_graphics_present_swapchain_and_format() { + let mut no_graphics = device("No graphics", VulkanDeviceType::DiscreteGpu, 0, true, false); + no_graphics.queue_families[0].graphics = false; + assert!(matches!( + select_physical_device(&[no_graphics]), + Err(VulkanCapabilityError::NoGraphicsQueue { .. }) + )); + + let mut no_present = device("No present", VulkanDeviceType::DiscreteGpu, 0, true, false); + no_present.queue_families[0].present = false; + assert!(matches!( + select_physical_device(&[no_present]), + Err(VulkanCapabilityError::NoPresentQueue { .. }) + )); + + let no_swapchain = device( + "No swapchain", + VulkanDeviceType::DiscreteGpu, + 0, + false, + false, + ); + assert!(matches!( + select_physical_device(&[no_swapchain]), + Err(VulkanCapabilityError::MissingSwapchainExtension { .. }) + )); + + let mut no_format = device("No format", VulkanDeviceType::DiscreteGpu, 0, true, false); + no_format.surface_formats.clear(); + assert!(matches!( + select_physical_device(&[no_format]), + Err(VulkanCapabilityError::MissingSurfaceFormat { .. }) + )); + } + + #[test] + fn capability_report_json_is_stable() { + let report = select_physical_device(&[device( + "GPU \"A\"", + VulkanDeviceType::DiscreteGpu, + 3, + true, + false, + )]) + .expect("selected device"); + + assert_eq!( + render_capability_report_json(&report), + "{\"schema\":1,\"vulkan_api\":\"1.1.0\",\"device_name\":\"GPU \\\"A\\\"\",\"score\":1101,\"graphics_queue_family\":3,\"present_queue_family\":3,\"portability_subset\":false,\"enabled_extensions\":[\"VK_KHR_swapchain\"]}" + ); + } + + fn device( + name: &str, + device_type: VulkanDeviceType, + queue_index: u32, + swapchain: bool, + portability_subset: bool, + ) -> VulkanPhysicalDeviceRecord { + let mut extensions = Vec::new(); + if swapchain { + extensions.push(KHR_SWAPCHAIN_EXTENSION.to_string()); + } + if portability_subset { + extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string()); + } + VulkanPhysicalDeviceRecord { + name: name.to_string(), + api_version: MIN_VULKAN_API_VERSION, + device_type, + extensions, + queue_families: vec![VulkanQueueFamily { + index: queue_index, + graphics: true, + present: true, + }], + surface_formats: vec![VulkanSurfaceFormat { + format: vk::Format::B8G8R8A8_SRGB.as_raw(), + color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(), + }], + } + } } diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index 2d0c080..cef84e5 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -21,7 +21,11 @@ S0-CORPUS-005 covered cargo test -p fparkan-corpus --offline fingerprint_changes S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version -S0-GL-001 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents +S0-VK-001 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents +S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue +S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed +S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_graphics_present_swapchain_and_format +S0-VK-005 covered cargo test -p fparkan-render-vulkan --offline capability_report_json_is_stable 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 5dd5a60..20ac975 100644 --- a/fixtures/acceptance/stage_0_2_roadmap.md +++ b/fixtures/acceptance/stage_0_2_roadmap.md @@ -21,7 +21,11 @@ `S0-CORPUS-006` `S0-CLI-001` `S0-CLI-002` -`S0-GL-001` +`S0-VK-001` +`S0-VK-002` +`S0-VK-003` +`S0-VK-004` +`S0-VK-005` `S0-LIMIT-001` `S0-LIMIT-002` `L1-P1-NRES-001` |
