aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-23 21:40:01 +0300
committerValentin Popov <valentin@popov.link>2026-06-23 21:40:01 +0300
commit69c032accab677b53f3dff61f3afb870c2e8e0a8 (patch)
tree626bd9204ae2ade533a1e1828093a676a490610f
parent9cc24e715db81edbe21c0d04aadd00f11dddecb8 (diff)
downloadfparkan-69c032accab677b53f3dff61f3afb870c2e8e0a8.tar.xz
fparkan-69c032accab677b53f3dff61f3afb870c2e8e0a8.zip
feat: add Vulkan capability selection boundary
-rw-r--r--Cargo.lock88
-rw-r--r--adapters/fparkan-render-vulkan/Cargo.toml2
-rw-r--r--adapters/fparkan-render-vulkan/src/lib.rs477
-rw-r--r--fixtures/acceptance/coverage.tsv6
-rw-r--r--fixtures/acceptance/stage_0_2_roadmap.md6
5 files changed, 577 insertions, 2 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 0436d37..f9e0470 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -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`