feat: add Vulkan instance bootstrap plan

This commit is contained in:
2026-06-23 22:47:20 +04:00
parent 8ea1fd5c18
commit dc7e72961a
3 changed files with 261 additions and 1 deletions
+255 -1
View File
@@ -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,
+3
View File
@@ -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
1 # Acceptance coverage manifest.
28 S0-VK-005
29 S0-VK-006
30 S0-VK-007
31 S0-VK-008
32 S0-VK-009
33 S0-VK-010
34 S0-LIMIT-001
35 S0-LIMIT-002
36 L1-P1-NRES-001
+3
View File
@@ -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`