diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 21:47:20 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 21:47:20 +0300 |
| commit | dc7e72961a67ba8f0eab623dd0172df3ced7f74d (patch) | |
| tree | 03b41b8b6d8b8f51ba3b027c70a2a4334d44dde3 | |
| parent | 8ea1fd5c188998a005d9bf48c090af4324608eff (diff) | |
| download | fparkan-dc7e72961a67ba8f0eab623dd0172df3ced7f74d.tar.xz fparkan-dc7e72961a67ba8f0eab623dd0172df3ced7f74d.zip | |
feat: add Vulkan instance bootstrap plan
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/lib.rs | 256 | ||||
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 3 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_0_2_roadmap.md | 3 |
3 files changed, 261 insertions, 1 deletions
diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs index 9973676..1db4f68 100644 --- a/adapters/fparkan-render-vulkan/src/lib.rs +++ b/adapters/fparkan-render-vulkan/src/lib.rs @@ -32,13 +32,219 @@ use fparkan_platform::RenderRequest; use fparkan_render::{ canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError, }; -use std::ffi::CStr; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; 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"; +const KHR_PORTABILITY_ENUMERATION_EXTENSION: &str = "VK_KHR_portability_enumeration"; + +/// Vulkan instance bootstrap configuration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanInstanceConfig { + /// Application name reported to the loader. + pub application_name: String, + /// Required instance extensions, usually including surface extensions. + pub required_extensions: Vec<String>, + /// Whether `VK_KHR_portability_enumeration` and its create flag are enabled. + pub enable_portability_enumeration: bool, + /// Whether validation layers are requested. + pub enable_validation: bool, +} + +impl VulkanInstanceConfig { + /// Returns a conservative instance configuration for smoke probes. + #[must_use] + pub fn smoke(application_name: impl Into<String>) -> Self { + Self { + application_name: application_name.into(), + required_extensions: Vec::new(), + enable_portability_enumeration: cfg!(target_os = "macos"), + enable_validation: false, + } + } +} + +/// Deterministic Vulkan instance creation plan. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanInstancePlan { + /// Report schema version. + pub schema: u32, + /// Instance extensions requested at creation time. + pub enabled_extensions: Vec<String>, + /// Raw Vulkan instance creation flags. + pub create_flags: u32, + /// Whether validation was requested. + pub validation_requested: bool, +} + +/// Created Vulkan instance probe. +pub struct VulkanInstanceProbe { + _entry: ash::Entry, + instance: ash::Instance, + /// Deterministic instance creation report. + pub report: VulkanInstancePlan, +} + +impl Drop for VulkanInstanceProbe { + fn drop(&mut self) { + // SAFETY: The `Instance` was created by this probe and is destroyed once during drop. + unsafe { self.instance.destroy_instance(None) }; + } +} + +/// Vulkan instance bootstrap error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VulkanInstanceError { + /// The Vulkan loader could not be opened. + Loader(VulkanLoaderError), + /// Application name contained an interior NUL byte. + InvalidApplicationName, + /// An extension name contained an interior NUL byte. + InvalidExtensionName { + /// Invalid extension name. + extension: String, + }, + /// Instance creation failed. + CreateFailed { + /// Vulkan result. + result: String, + }, +} + +impl std::fmt::Display for VulkanInstanceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Loader(error) => write!(f, "{error}"), + Self::InvalidApplicationName => { + write!(f, "Vulkan application name contains an interior NUL byte") + } + Self::InvalidExtensionName { extension } => { + write!( + f, + "Vulkan instance extension name contains an interior NUL byte: {extension:?}" + ) + } + Self::CreateFailed { result } => write!(f, "Vulkan instance creation failed: {result}"), + } + } +} + +impl std::error::Error for VulkanInstanceError {} + +/// Builds the deterministic instance creation plan without touching the loader. +#[must_use] +pub fn plan_vulkan_instance(config: &VulkanInstanceConfig) -> VulkanInstancePlan { + let mut enabled_extensions = config.required_extensions.clone(); + if config.enable_portability_enumeration + && !enabled_extensions + .iter() + .any(|extension| extension == KHR_PORTABILITY_ENUMERATION_EXTENSION) + { + enabled_extensions.push(KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string()); + } + enabled_extensions.sort(); + enabled_extensions.dedup(); + VulkanInstancePlan { + schema: 1, + enabled_extensions, + create_flags: if config.enable_portability_enumeration { + vk::InstanceCreateFlags::ENUMERATE_PORTABILITY_KHR.as_raw() + } else { + 0 + }, + validation_requested: config.enable_validation, + } +} + +/// Creates a Vulkan instance probe from the supplied configuration. +/// +/// # Errors +/// +/// Returns [`VulkanInstanceError`] when the loader is unavailable, names are not +/// valid C strings, or `vkCreateInstance` fails. +pub fn create_vulkan_instance_probe( + config: &VulkanInstanceConfig, +) -> Result<VulkanInstanceProbe, VulkanInstanceError> { + // SAFETY: Loading the entry only resolves loader symbols; no raw Vulkan handles escape. + let entry = unsafe { ash::Entry::load() }.map_err(|error| { + VulkanInstanceError::Loader(VulkanLoaderError::Unavailable { + message: error.to_string(), + }) + })?; + let app_name = CString::new(config.application_name.clone()) + .map_err(|_| VulkanInstanceError::InvalidApplicationName)?; + let engine_name = c"fparkan"; + let plan = plan_vulkan_instance(config); + let extension_names = cstring_vec(&plan.enabled_extensions)?; + let extension_ptrs = cstring_ptrs(&extension_names); + let app_info = vk::ApplicationInfo::default() + .application_name(&app_name) + .application_version(0) + .engine_name(engine_name) + .engine_version(0) + .api_version(MIN_VULKAN_API_VERSION); + let create_info = vk::InstanceCreateInfo::default() + .application_info(&app_info) + .enabled_extension_names(&extension_ptrs) + .flags(vk::InstanceCreateFlags::from_raw(plan.create_flags)); + // SAFETY: `create_info` points to stack-owned Vulkan create data that lives for the call. + let instance = unsafe { entry.create_instance(&create_info, None) }.map_err(|error| { + VulkanInstanceError::CreateFailed { + result: format!("{error:?}"), + } + })?; + Ok(VulkanInstanceProbe { + _entry: entry, + instance, + report: plan, + }) +} + +/// Renders a deterministic JSON Vulkan instance plan. +#[must_use] +pub fn render_instance_plan_json(plan: &VulkanInstancePlan) -> String { + let mut out = String::new(); + out.push_str("{\"schema\":"); + out.push_str(&plan.schema.to_string()); + out.push_str(",\"create_flags\":"); + out.push_str(&plan.create_flags.to_string()); + out.push_str(",\"validation_requested\":"); + out.push_str(if plan.validation_requested { + "true" + } else { + "false" + }); + out.push_str(",\"enabled_extensions\":["); + for (index, extension) in plan.enabled_extensions.iter().enumerate() { + if index > 0 { + out.push(','); + } + push_json_string(&mut out, extension); + } + out.push_str("]}"); + out +} + +fn cstring_vec(values: &[String]) -> Result<Vec<CString>, VulkanInstanceError> { + values + .iter() + .map(|extension| { + CString::new(extension.as_str()).map_err(|_| { + VulkanInstanceError::InvalidExtensionName { + extension: extension.clone(), + } + }) + }) + .collect() +} + +fn cstring_ptrs(values: &[CString]) -> Vec<*const c_char> { + values.iter().map(|value| value.as_ptr()).collect() +} /// Deterministic Vulkan loader probe report. #[derive(Clone, Debug, Eq, PartialEq)] @@ -750,6 +956,54 @@ mod tests { ); } + #[test] + fn instance_plan_is_sorted_deduplicated_and_portability_aware() { + let plan = plan_vulkan_instance(&VulkanInstanceConfig { + application_name: "FParkan".to_string(), + required_extensions: vec![ + "VK_KHR_surface".to_string(), + KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(), + "VK_KHR_surface".to_string(), + ], + enable_portability_enumeration: true, + enable_validation: true, + }); + + assert_eq!( + render_instance_plan_json(&plan), + "{\"schema\":1,\"create_flags\":1,\"validation_requested\":true,\"enabled_extensions\":[\"VK_KHR_portability_enumeration\",\"VK_KHR_surface\"]}" + ); + } + + #[test] + fn instance_plan_adds_portability_extension_when_requested() { + let plan = plan_vulkan_instance(&VulkanInstanceConfig { + application_name: "FParkan".to_string(), + required_extensions: vec!["VK_KHR_surface".to_string()], + enable_portability_enumeration: true, + enable_validation: false, + }); + + assert_eq!( + plan.enabled_extensions, + vec![ + KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(), + "VK_KHR_surface".to_string() + ] + ); + assert_eq!(plan.create_flags, 1); + } + + #[test] + fn invalid_instance_extension_name_is_reported_before_loader_use() { + assert_eq!( + cstring_vec(&["bad\0extension".to_string()]), + Err(VulkanInstanceError::InvalidExtensionName { + extension: "bad\0extension".to_string() + }) + ); + } + fn device( name: &str, device_type: VulkanDeviceType, diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index dd7332a..41af770 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -28,6 +28,9 @@ S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_ S0-VK-005 covered cargo test -p fparkan-render-vulkan --offline capability_report_json_is_stable S0-VK-006 covered cargo test -p fparkan-render-vulkan --offline loader_probe_report_json_is_stable S0-VK-007 covered cargo xtask policy +S0-VK-008 covered cargo test -p fparkan-render-vulkan --offline instance_plan_is_sorted_deduplicated_and_portability_aware +S0-VK-009 covered cargo test -p fparkan-render-vulkan --offline instance_plan_adds_portability_extension_when_requested +S0-VK-010 covered cargo test -p fparkan-render-vulkan --offline invalid_instance_extension_name_is_reported_before_loader_use 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 3655027..1df5041 100644 --- a/fixtures/acceptance/stage_0_2_roadmap.md +++ b/fixtures/acceptance/stage_0_2_roadmap.md @@ -28,6 +28,9 @@ `S0-VK-005` `S0-VK-006` `S0-VK-007` +`S0-VK-008` +`S0-VK-009` +`S0-VK-010` `S0-LIMIT-001` `S0-LIMIT-002` `L1-P1-NRES-001` |
