aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-23 21:47:20 +0300
committerValentin Popov <valentin@popov.link>2026-06-23 21:47:20 +0300
commitdc7e72961a67ba8f0eab623dd0172df3ced7f74d (patch)
tree03b41b8b6d8b8f51ba3b027c70a2a4334d44dde3
parent8ea1fd5c188998a005d9bf48c090af4324608eff (diff)
downloadfparkan-dc7e72961a67ba8f0eab623dd0172df3ced7f74d.tar.xz
fparkan-dc7e72961a67ba8f0eab623dd0172df3ced7f74d.zip
feat: add Vulkan instance bootstrap plan
-rw-r--r--adapters/fparkan-render-vulkan/src/lib.rs256
-rw-r--r--fixtures/acceptance/coverage.tsv3
-rw-r--r--fixtures/acceptance/stage_0_2_roadmap.md3
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`