diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-25 03:18:32 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-25 10:45:32 +0300 |
| commit | ba69bdb6eab57817b45055d60ea1d2f6687757a8 (patch) | |
| tree | 6c1ea4db8fd2a715c01a38d2fc3fb71140320eb3 | |
| parent | 5cc2c5819f2dcfc9b9a8b86615d604d2b8f4c018 (diff) | |
| download | fparkan-ba69bdb6eab57817b45055d60ea1d2f6687757a8.tar.xz fparkan-ba69bdb6eab57817b45055d60ea1d2f6687757a8.zip | |
feat(stage0): close native smoke acceptance gate
| -rw-r--r-- | .github/workflows/ci.yml | 49 | ||||
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | README.md | 34 | ||||
| -rw-r--r-- | adapters/fparkan-platform-winit/src/lib.rs | 159 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.frag | 8 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.frag.spv | bin | 0 -> 500 bytes | |||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.vert | 11 | ||||
| -rw-r--r-- | adapters/fparkan-render-vulkan/shaders/triangle.vert.spv | bin | 0 -> 1012 bytes | |||
| -rw-r--r-- | adapters/fparkan-render-vulkan/src/lib.rs | 1955 | ||||
| -rw-r--r-- | apps/fparkan-game/src/main.rs | 4 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/Cargo.toml | 1 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/src/main.rs | 1747 | ||||
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 2 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_0_roadmap.md | 68 | ||||
| -rw-r--r-- | xtask/src/main.rs | 44 |
15 files changed, 2438 insertions, 1645 deletions
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76ceca6..b4d365c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: --locked stage0-matrix: - name: Stage 0-2 CI (${{ matrix.os }}) + name: Stage 0 CI (${{ matrix.os }}) runs-on: ${{ matrix.os }} timeout-minutes: 30 strategy: @@ -73,22 +73,43 @@ jobs: run: cargo install cargo-deny --version 0.19.9 --locked - name: Run canonical CI gate run: cargo xtask ci - - name: Record native Vulkan smoke status - if: always() - shell: bash + - name: Run native Vulkan smoke run: > cargo run -p fparkan-vulkan-smoke --locked -- - --platform "${{ matrix.smoke_platform }}" - --out "target/fparkan/native-smoke/${{ runner.os }}.json" - --status blocked - --probe-surface - --reason "native Vulkan smoke runner is not enabled on this CI lane yet" - - name: Upload acceptance evidence + --out "target/fparkan/native-smoke/${{ matrix.smoke_platform }}.json" + - name: Upload acceptance audit + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: stage-0-acceptance-${{ matrix.os }} + path: target/fparkan/acceptance/stage-0-audit.json + if-no-files-found: error + - name: Upload native smoke report if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: - name: stage-0-2-acceptance-${{ matrix.os }} - path: | - target/fparkan/acceptance/stage-0-2-audit.json - target/fparkan/native-smoke/*.json + name: native-smoke-${{ matrix.smoke_platform }} + path: target/fparkan/native-smoke/*.json if-no-files-found: error + + native-smoke-audit: + name: Native smoke audit + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: stage0-matrix + env: + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 + with: + toolchain: 1.87.0 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + pattern: native-smoke-* + path: target/fparkan/native-smoke-artifacts + merge-multiple: true + - name: Aggregate native smoke reports + run: > + cargo xtask native-smoke audit + --dir target/fparkan/native-smoke-artifacts @@ -717,6 +717,7 @@ dependencies = [ "fparkan-platform", "fparkan-platform-winit", "fparkan-render-vulkan", + "winit", ] [[package]] @@ -63,6 +63,40 @@ FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \ cargo xtask acceptance report --suite licensed --stage 5 ``` +## Stage 0 Vulkan smoke + +Локальный Stage 0 smoke запускает реальный `winit` lifecycle и Vulkan triangle path с включёнными validation layers. Успешный прогон обязан: + +- отрисовать 300 кадров; +- выполнить как минимум один реальный resize; +- пересоздать swapchain после resize; +- завершиться без validation warnings/errors. + +Команда запуска: + +```bash +cargo run -p fparkan-vulkan-smoke --locked -- \ + --out target/fparkan/native-smoke/local.json +``` + +Перед запуском убедитесь, что на машине доступен Vulkan loader и рабочий ICD: + +- macOS: установлены Vulkan SDK и MoltenVK; если используется нестандартная установка, проверьте `VK_ICD_FILENAMES`, `VK_LAYER_PATH` и наличие `VK_LAYER_KHRONOS_validation`. +- Linux: установлен `libvulkan` и драйвер/ICD (`mesa-vulkan-drivers`, Lavapipe или vendor GPU stack); smoke нужно запускать из активной графической сессии X11/Wayland. +- Windows: установлен Vulkan runtime от GPU vendor или LunarG Vulkan SDK; validation layer должен быть доступен из активного runtime. + +Для полного локального closure gate используйте: + +```bash +cargo xtask ci +``` + +GitHub workflow дополнительно собирает три platform reports и проверяет их aggregate gate: + +```bash +cargo xtask native-smoke audit --dir target/fparkan/native-smoke-artifacts +``` + ## Contributing & Support Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях: diff --git a/adapters/fparkan-platform-winit/src/lib.rs b/adapters/fparkan-platform-winit/src/lib.rs index 30c5497..1640b23 100644 --- a/adapters/fparkan-platform-winit/src/lib.rs +++ b/adapters/fparkan-platform-winit/src/lib.rs @@ -27,15 +27,14 @@ use fparkan_platform::{ use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; use std::collections::VecDeque; use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; -use winit::application::ApplicationHandler; -use winit::dpi::PhysicalSize as WinitPhysicalSize; +use std::sync::OnceLock; +use std::time::Instant; use winit::event::{Event, MouseButton, WindowEvent}; -use winit::event_loop::{ActiveEventLoop, EventLoop}; use winit::platform::scancode::PhysicalKeyExtScancode; -use winit::window::{Window, WindowId}; +use winit::window::Window; static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1); +static CLOCK_START: OnceLock<Instant> = OnceLock::new(); const DEFAULT_SMOKE_WIDTH: u32 = 1280; const DEFAULT_SMOKE_HEIGHT: u32 = 720; @@ -49,10 +48,8 @@ pub struct WinitClock; impl MonotonicClock for WinitClock { fn now(&self) -> MonotonicInstant { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default(); - MonotonicInstant(duration.as_millis().try_into().unwrap_or(u64::MAX)) + let elapsed = CLOCK_START.get_or_init(Instant::now).elapsed(); + MonotonicInstant(elapsed.as_millis().try_into().unwrap_or(u64::MAX)) } } @@ -187,113 +184,6 @@ impl WinitWindowPlan { } } -/// Native smoke window creation result. -#[derive(Clone, Copy, Debug)] -pub struct WinitSmokeWindowProbe { - /// Validated creation plan. - pub plan: WinitWindowPlan, - /// Captured window descriptor. - pub window: WinitWindow, -} - -impl WinitSmokeWindowProbe { - /// Returns raw native handles captured from the native window. - #[must_use] - pub fn native_handles(&self) -> Option<NativeWindowHandles> { - self.window.native_handles() - } -} - -/// Creates a native smoke window, captures raw handles, then exits the event loop. -/// -/// # Errors -/// -/// Returns [`PlatformError`] when the plan is invalid, the event loop/window -/// cannot be created, or raw native handles are unavailable. -pub fn probe_smoke_window() -> Result<WinitSmokeWindowProbe, PlatformError> { - let plan = WinitWindowPlan::smoke().validate()?; - let event_loop = EventLoop::new().map_err(|err| PlatformError::Backend { - context: "winit event loop", - message: err.to_string(), - })?; - let mut app = SmokeWindowApp::new(plan); - event_loop - .run_app(&mut app) - .map_err(|err| PlatformError::Backend { - context: "winit event loop", - message: err.to_string(), - })?; - app.into_probe() -} - -struct SmokeWindowApp { - plan: WinitWindowPlan, - window: Option<WinitWindow>, - error: Option<String>, -} - -impl SmokeWindowApp { - const fn new(plan: WinitWindowPlan) -> Self { - Self { - plan, - window: None, - error: None, - } - } - - fn into_probe(self) -> Result<WinitSmokeWindowProbe, PlatformError> { - if let Some(message) = self.error { - return Err(PlatformError::Backend { - context: "winit smoke window", - message, - }); - } - let window = self.window.ok_or_else(|| PlatformError::Backend { - context: "winit smoke window", - message: "event loop exited before creating a window".to_string(), - })?; - if self.plan.requires_native_handles && window.native_handles().is_none() { - return Err(PlatformError::Backend { - context: "winit smoke window", - message: "native window/display handles are unavailable".to_string(), - }); - } - Ok(WinitSmokeWindowProbe { - plan: self.plan, - window, - }) - } -} - -impl ApplicationHandler for SmokeWindowApp { - fn resumed(&mut self, event_loop: &ActiveEventLoop) { - if self.window.is_some() || self.error.is_some() { - event_loop.exit(); - return; - } - let attributes = Window::default_attributes() - .with_title("FParkan Vulkan smoke") - .with_inner_size(WinitPhysicalSize::new(self.plan.width, self.plan.height)); - match event_loop.create_window(attributes) { - Ok(window) => { - self.window = Some(WinitWindow::from_window(&window)); - } - Err(err) => { - self.error = Some(err.to_string()); - } - } - event_loop.exit(); - } - - fn window_event( - &mut self, - _event_loop: &ActiveEventLoop, - _window_id: WindowId, - _event: WindowEvent, - ) { - } -} - /// Minimal window view over a `winit` window. #[derive(Clone, Copy, Debug)] pub struct WinitWindow { @@ -323,7 +213,7 @@ impl WinitWindow { focused: true, minimized: false, occluded: false, - native_handles: native_handles(window), + native_handles: window_native_handles(window), } } @@ -384,7 +274,9 @@ impl WindowPort for WinitWindow { } } -fn native_handles(window: &Window) -> Option<NativeWindowHandles> { +/// Extracts raw handles from a live `winit::Window`. +#[must_use] +pub fn window_native_handles(window: &Window) -> Option<NativeWindowHandles> { let display = window.display_handle().ok()?.as_raw(); let window = window.window_handle().ok()?.as_raw(); Some(NativeWindowHandles { display, window }) @@ -454,33 +346,12 @@ mod tests { } #[test] - fn smoke_window_app_requires_created_native_window() { - let app = SmokeWindowApp::new(WinitWindowPlan::smoke()); - - assert!(matches!( - app.into_probe(), - Err(PlatformError::Backend { - context: "winit smoke window", - .. - }) - )); - } - - #[test] - fn smoke_window_app_rejects_synthetic_window_without_native_handles() { - let mut app = SmokeWindowApp::new(WinitWindowPlan::smoke()); - app.window = Some(WinitWindow::synthetic( - DEFAULT_SMOKE_WIDTH, - DEFAULT_SMOKE_HEIGHT, - )); + fn monotonic_clock_uses_process_local_epoch() { + let clock = WinitClock; + let first = clock.now(); + let second = clock.now(); - assert!(matches!( - app.into_probe(), - Err(PlatformError::Backend { - context: "winit smoke window", - .. - }) - )); + assert!(second >= first); } #[test] diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag new file mode 100644 index 0000000..35bd374 --- /dev/null +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag @@ -0,0 +1,8 @@ +#version 450 + +layout(location = 0) in vec3 in_color; +layout(location = 0) out vec4 out_color; + +void main() { + out_color = vec4(in_color, 1.0); +} diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv Binary files differnew file mode 100644 index 0000000..c5d57ee --- /dev/null +++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert new file mode 100644 index 0000000..d8d0f0e --- /dev/null +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert @@ -0,0 +1,11 @@ +#version 450 + +layout(location = 0) in vec2 in_position; +layout(location = 1) in vec3 in_color; + +layout(location = 0) out vec3 out_color; + +void main() { + out_color = in_color; + gl_Position = vec4(in_position, 0.0, 1.0); +} diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv Binary files differnew file mode 100644 index 0000000..04321ea --- /dev/null +++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs index 4490d81..e063183 100644 --- a/adapters/fparkan-render-vulkan/src/lib.rs +++ b/adapters/fparkan-render-vulkan/src/lib.rs @@ -37,8 +37,11 @@ use fparkan_render::{ canonical_capture, validate_command_list, FrameOutput, RenderBackend, RenderCommand, RenderCommandList, RenderError, }; +use std::collections::BTreeSet; use std::ffi::{CStr, CString}; use std::os::raw::c_char; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; /// Minimum Vulkan API version accepted by the Stage 0 backend. @@ -46,35 +49,391 @@ 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"; +const EXT_DEBUG_UTILS_EXTENSION: &str = "VK_EXT_debug_utils"; +const VALIDATION_LAYER_NAME: &str = "VK_LAYER_KHRONOS_validation"; const SPIRV_MAGIC: u32 = 0x0723_0203; const SPIRV_VERSION_1_0: u32 = 0x0001_0000; const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[ SPIRV_MAGIC, SPIRV_VERSION_1_0, - 0, - 8, - 0, + 0x0008_000b, + 0x0000_0021, + 0x0000_0000, 0x0002_0011, - 1, - 0x0006_000F, - 0, - 4, - 0x6E69_616D, - 0, + 0x0000_0001, + 0x0006_000b, + 0x0000_0001, + 0x4c53_4c47, + 0x6474_732e, + 0x3035_342e, + 0x0000_0000, + 0x0003_000e, + 0x0000_0000, + 0x0000_0001, + 0x0009_000f, + 0x0000_0000, + 0x0000_0004, + 0x6e69_616d, + 0x0000_0000, + 0x0000_0009, + 0x0000_000b, + 0x0000_0013, + 0x0000_0018, + 0x0003_0003, + 0x0000_0002, + 0x0000_01c2, + 0x0004_0005, + 0x0000_0004, + 0x6e69_616d, + 0x0000_0000, + 0x0005_0005, + 0x0000_0009, + 0x5f74_756f, + 0x6f6c_6f63, + 0x0000_0072, + 0x0005_0005, + 0x0000_000b, + 0x635f_6e69, + 0x726f_6c6f, + 0x0000_0000, + 0x0006_0005, + 0x0000_0011, + 0x505f_6c67, + 0x6556_7265, + 0x7865_7472, + 0x0000_0000, + 0x0006_0006, + 0x0000_0011, + 0x0000_0000, + 0x505f_6c67, + 0x7469_736f, + 0x006e_6f69, + 0x0007_0006, + 0x0000_0011, + 0x0000_0001, + 0x505f_6c67, + 0x746e_696f, + 0x657a_6953, + 0x0000_0000, + 0x0007_0006, + 0x0000_0011, + 0x0000_0002, + 0x435f_6c67, + 0x4470_696c, + 0x6174_7369, + 0x0065_636e, + 0x0007_0006, + 0x0000_0011, + 0x0000_0003, + 0x435f_6c67, + 0x446c_6c75, + 0x6174_7369, + 0x0065_636e, + 0x0003_0005, + 0x0000_0013, + 0x0000_0000, + 0x0005_0005, + 0x0000_0018, + 0x705f_6e69, + 0x7469_736f, + 0x006e_6f69, + 0x0004_0047, + 0x0000_0009, + 0x0000_001e, + 0x0000_0000, + 0x0004_0047, + 0x0000_000b, + 0x0000_001e, + 0x0000_0001, + 0x0003_0047, + 0x0000_0011, + 0x0000_0002, + 0x0005_0048, + 0x0000_0011, + 0x0000_0000, + 0x0000_000b, + 0x0000_0000, + 0x0005_0048, + 0x0000_0011, + 0x0000_0001, + 0x0000_000b, + 0x0000_0001, + 0x0005_0048, + 0x0000_0011, + 0x0000_0002, + 0x0000_000b, + 0x0000_0003, + 0x0005_0048, + 0x0000_0011, + 0x0000_0003, + 0x0000_000b, + 0x0000_0004, + 0x0004_0047, + 0x0000_0018, + 0x0000_001e, + 0x0000_0000, + 0x0002_0013, + 0x0000_0002, + 0x0003_0021, + 0x0000_0003, + 0x0000_0002, + 0x0003_0016, + 0x0000_0006, + 0x0000_0020, + 0x0004_0017, + 0x0000_0007, + 0x0000_0006, + 0x0000_0003, + 0x0004_0020, + 0x0000_0008, + 0x0000_0003, + 0x0000_0007, + 0x0004_003b, + 0x0000_0008, + 0x0000_0009, + 0x0000_0003, + 0x0004_0020, + 0x0000_000a, + 0x0000_0001, + 0x0000_0007, + 0x0004_003b, + 0x0000_000a, + 0x0000_000b, + 0x0000_0001, + 0x0004_0017, + 0x0000_000d, + 0x0000_0006, + 0x0000_0004, + 0x0004_0015, + 0x0000_000e, + 0x0000_0020, + 0x0000_0000, + 0x0004_002b, + 0x0000_000e, + 0x0000_000f, + 0x0000_0001, + 0x0004_001c, + 0x0000_0010, + 0x0000_0006, + 0x0000_000f, + 0x0006_001e, + 0x0000_0011, + 0x0000_000d, + 0x0000_0006, + 0x0000_0010, + 0x0000_0010, + 0x0004_0020, + 0x0000_0012, + 0x0000_0003, + 0x0000_0011, + 0x0004_003b, + 0x0000_0012, + 0x0000_0013, + 0x0000_0003, + 0x0004_0015, + 0x0000_0014, + 0x0000_0020, + 0x0000_0001, + 0x0004_002b, + 0x0000_0014, + 0x0000_0015, + 0x0000_0000, + 0x0004_0017, + 0x0000_0016, + 0x0000_0006, + 0x0000_0002, + 0x0004_0020, + 0x0000_0017, + 0x0000_0001, + 0x0000_0016, + 0x0004_003b, + 0x0000_0017, + 0x0000_0018, + 0x0000_0001, + 0x0004_002b, + 0x0000_0006, + 0x0000_001a, + 0x0000_0000, + 0x0004_002b, + 0x0000_0006, + 0x0000_001b, + 0x3f80_0000, + 0x0004_0020, + 0x0000_001f, + 0x0000_0003, + 0x0000_000d, + 0x0005_0036, + 0x0000_0002, + 0x0000_0004, + 0x0000_0000, + 0x0000_0003, + 0x0002_00f8, + 0x0000_0005, + 0x0004_003d, + 0x0000_0007, + 0x0000_000c, + 0x0000_000b, + 0x0003_003e, + 0x0000_0009, + 0x0000_000c, + 0x0004_003d, + 0x0000_0016, + 0x0000_0019, + 0x0000_0018, + 0x0005_0051, + 0x0000_0006, + 0x0000_001c, + 0x0000_0019, + 0x0000_0000, + 0x0005_0051, + 0x0000_0006, + 0x0000_001d, + 0x0000_0019, + 0x0000_0001, + 0x0007_0050, + 0x0000_000d, + 0x0000_001e, + 0x0000_001c, + 0x0000_001d, + 0x0000_001a, + 0x0000_001b, + 0x0005_0041, + 0x0000_001f, + 0x0000_0020, + 0x0000_0013, + 0x0000_0015, + 0x0003_003e, + 0x0000_0020, + 0x0000_001e, + 0x0001_00fd, + 0x0001_0038, ]; const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[ SPIRV_MAGIC, SPIRV_VERSION_1_0, - 0, - 8, - 0, + 0x0008_000b, + 0x0000_0013, + 0x0000_0000, 0x0002_0011, - 1, - 0x0006_000F, - 4, - 4, - 0x6E69_616D, - 0, + 0x0000_0001, + 0x0006_000b, + 0x0000_0001, + 0x4c53_4c47, + 0x6474_732e, + 0x3035_342e, + 0x0000_0000, + 0x0003_000e, + 0x0000_0000, + 0x0000_0001, + 0x0007_000f, + 0x0000_0004, + 0x0000_0004, + 0x6e69_616d, + 0x0000_0000, + 0x0000_0009, + 0x0000_000c, + 0x0003_0010, + 0x0000_0004, + 0x0000_0007, + 0x0003_0003, + 0x0000_0002, + 0x0000_01c2, + 0x0004_0005, + 0x0000_0004, + 0x6e69_616d, + 0x0000_0000, + 0x0005_0005, + 0x0000_0009, + 0x5f74_756f, + 0x6f6c_6f63, + 0x0000_0072, + 0x0005_0005, + 0x0000_000c, + 0x635f_6e69, + 0x726f_6c6f, + 0x0000_0000, + 0x0004_0047, + 0x0000_0009, + 0x0000_001e, + 0x0000_0000, + 0x0004_0047, + 0x0000_000c, + 0x0000_001e, + 0x0000_0000, + 0x0002_0013, + 0x0000_0002, + 0x0003_0021, + 0x0000_0003, + 0x0000_0002, + 0x0003_0016, + 0x0000_0006, + 0x0000_0020, + 0x0004_0017, + 0x0000_0007, + 0x0000_0006, + 0x0000_0004, + 0x0004_0020, + 0x0000_0008, + 0x0000_0003, + 0x0000_0007, + 0x0004_003b, + 0x0000_0008, + 0x0000_0009, + 0x0000_0003, + 0x0004_0017, + 0x0000_000a, + 0x0000_0006, + 0x0000_0003, + 0x0004_0020, + 0x0000_000b, + 0x0000_0001, + 0x0000_000a, + 0x0004_003b, + 0x0000_000b, + 0x0000_000c, + 0x0000_0001, + 0x0004_002b, + 0x0000_0006, + 0x0000_000e, + 0x3f80_0000, + 0x0005_0036, + 0x0000_0002, + 0x0000_0004, + 0x0000_0000, + 0x0000_0003, + 0x0002_00f8, + 0x0000_0005, + 0x0004_003d, + 0x0000_000a, + 0x0000_000d, + 0x0000_000c, + 0x0005_0051, + 0x0000_0006, + 0x0000_000f, + 0x0000_000d, + 0x0000_0000, + 0x0005_0051, + 0x0000_0006, + 0x0000_0010, + 0x0000_000d, + 0x0000_0001, + 0x0005_0051, + 0x0000_0006, + 0x0000_0011, + 0x0000_000d, + 0x0000_0002, + 0x0007_0050, + 0x0000_0007, + 0x0000_0012, + 0x0000_000f, + 0x0000_0010, + 0x0000_0011, + 0x0000_000e, + 0x0003_003e, + 0x0000_0009, + 0x0000_0012, + 0x0001_00fd, + 0x0001_0038, ]; /// Shader compiler/toolchain identifiers pinned in the Stage 0 manifest. @@ -462,6 +821,10 @@ impl std::fmt::Display for VulkanSmokeRunError { impl std::error::Error for VulkanSmokeRunError {} /// Runs a minimal native smoke loop: acquire/present without recording commands. +/// +/// # Errors +/// +/// Returns [`VulkanSmokeRunError`] when swapchain acquisition, recreation, or presentation fails. pub fn run_vulkan_smoke_pass( instance: &VulkanInstanceProbe, surface: &VulkanSurfaceProbe, @@ -475,6 +838,7 @@ pub fn run_vulkan_smoke_pass( let image_available = vk::SemaphoreCreateInfo::default(); let image_ready = + // SAFETY: The semaphore is created on this live logical device and destroyed before return. unsafe { device.device().create_semaphore(&image_available, None) }.map_err(|error| { VulkanSmokeRunError::RecreateSwapchain { result: format!("{error:?}"), @@ -505,6 +869,7 @@ pub fn run_vulkan_smoke_pass( created = created.saturating_add(1); } + // SAFETY: The swapchain and synchronization primitives are live for the duration of the acquire call. let image_index = unsafe { swapchain.loader().acquire_next_image( swapchain.swapchain(), @@ -525,6 +890,7 @@ pub fn run_vulkan_smoke_pass( .wait_semaphores(&present_wait_semaphores) .swapchains(&swapchains) .image_indices(&image_indices); + // SAFETY: Presentation uses the acquired image index and waits on the semaphore signaled by acquire. unsafe { swapchain .loader() @@ -534,6 +900,7 @@ pub fn run_vulkan_smoke_pass( result: format!("{error:?}"), })?; + // SAFETY: The queue belongs to this live logical device and is idle-waited at the end of the smoke iteration. unsafe { device.device().queue_wait_idle(render_queue) }.map_err(|error| { VulkanSmokeRunError::PresentImage { result: format!("{error:?}"), @@ -543,6 +910,7 @@ pub fn run_vulkan_smoke_pass( swaps = swaps.saturating_add(1); } + // SAFETY: The semaphore was created above on this logical device and is destroyed exactly once. unsafe { device.device().destroy_semaphore(image_ready, None) } Ok(VulkanSmokeRunReport { @@ -552,6 +920,1344 @@ pub fn run_vulkan_smoke_pass( }) } +/// Creates a live native Vulkan renderer for the Stage 0 smoke loop. +#[derive(Clone, Debug)] +pub struct VulkanSmokeRendererCreateInfo { + /// Application name reported to the Vulkan loader. + pub application_name: String, + /// Native window/display handles borrowed from a live window. + pub native_handles: NativeWindowHandles, + /// Initial drawable extent. + pub drawable_extent: (u32, u32), + /// Whether validation layers must be enabled. + pub enable_validation: bool, +} + +/// Stable smoke renderer bootstrap report. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanSmokeRendererReport { + /// Whether portability enumeration was enabled at instance creation. + pub portability_enumeration: bool, + /// Selected device name. + pub device_name: String, + /// Graphics queue-family index. + pub graphics_queue_family: u32, + /// Present queue-family index. + pub present_queue_family: u32, + /// Enabled logical-device extension count. + pub enabled_extension_count: u32, + /// Current swapchain extent. + pub swapchain_extent: (u32, u32), + /// Current swapchain image count. + pub swapchain_image_count: u32, +} + +/// Measured validation counters from the live smoke loop. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct VulkanValidationReport { + /// Validation warnings observed by the debug messenger. + pub warning_count: u32, + /// Validation errors observed by the debug messenger. + pub error_count: u32, + /// Stable sorted VUID list. + pub vuids: Vec<String>, +} + +/// Result of one rendered smoke frame. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum VulkanSmokeFrameOutcome { + /// A frame was submitted and presented. + Presented, + /// Rendering was skipped because the swapchain had to be recreated. + Recreated, + /// Rendering was skipped because the drawable extent is zero. + ZeroExtent, +} + +/// Live smoke renderer error. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum VulkanSmokeRendererError { + /// Instance bootstrap failed. + Instance(VulkanInstanceError), + /// Surface bootstrap failed. + Surface(VulkanSurfaceError), + /// Logical-device bootstrap failed. + LogicalDevice(VulkanLogicalDeviceError), + /// Swapchain bootstrap failed. + Swapchain(VulkanSwapchainProbeError), + /// Shader manifest validation failed. + ShaderManifest(VulkanShaderManifestError), + /// Vulkan operation failed. + VulkanOperation { + /// Operation context. + context: &'static str, + /// Raw Vulkan result text. + result: String, + }, + /// No suitable memory type exists for the required properties. + MissingMemoryType { + /// Operation context. + context: &'static str, + }, + /// Internal smoke renderer state was unexpectedly absent. + InvariantViolation { + /// Missing state context. + context: &'static str, + }, +} + +impl std::fmt::Display for VulkanSmokeRendererError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Instance(error) => write!(f, "{error}"), + Self::Surface(error) => write!(f, "{error}"), + Self::LogicalDevice(error) => write!(f, "{error}"), + Self::Swapchain(error) => write!(f, "{error}"), + Self::ShaderManifest(error) => write!(f, "{error}"), + Self::VulkanOperation { context, result } => { + write!(f, "{context}: {result}") + } + Self::MissingMemoryType { context } => { + write!(f, "{context}: no compatible Vulkan memory type") + } + Self::InvariantViolation { context } => { + write!(f, "renderer invariant violated: {context}") + } + } + } +} + +impl std::error::Error for VulkanSmokeRendererError {} + +struct VulkanValidationShared { + warning_count: AtomicU32, + error_count: AtomicU32, + vuids: Mutex<BTreeSet<String>>, +} + +impl Default for VulkanValidationShared { + fn default() -> Self { + Self { + warning_count: AtomicU32::new(0), + error_count: AtomicU32::new(0), + vuids: Mutex::new(BTreeSet::new()), + } + } +} + +struct VulkanValidationMessenger { + loader: ash::ext::debug_utils::Instance, + messenger: vk::DebugUtilsMessengerEXT, + shared: Box<VulkanValidationShared>, +} + +impl VulkanValidationMessenger { + fn report(&self) -> VulkanValidationReport { + let vuids = self + .shared + .vuids + .lock() + .map(|values| values.iter().cloned().collect::<Vec<_>>()) + .unwrap_or_default(); + VulkanValidationReport { + warning_count: self.shared.warning_count.load(Ordering::Relaxed), + error_count: self.shared.error_count.load(Ordering::Relaxed), + vuids, + } + } +} + +impl Drop for VulkanValidationMessenger { + fn drop(&mut self) { + // SAFETY: The messenger belongs to this instance-level loader and is destroyed once. + unsafe { + self.loader + .destroy_debug_utils_messenger(self.messenger, None); + }; + } +} + +unsafe extern "system" fn vulkan_validation_callback( + message_severity: vk::DebugUtilsMessageSeverityFlagsEXT, + _message_types: vk::DebugUtilsMessageTypeFlagsEXT, + callback_data: *const vk::DebugUtilsMessengerCallbackDataEXT<'_>, + user_data: *mut std::ffi::c_void, +) -> vk::Bool32 { + // SAFETY: The debug messenger stores a stable pointer to `VulkanValidationShared` for the messenger lifetime. + let Some(shared) = (unsafe { (user_data as *const VulkanValidationShared).as_ref() }) else { + return vk::FALSE; + }; + if message_severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::ERROR) { + shared.error_count.fetch_add(1, Ordering::Relaxed); + } else if message_severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::WARNING) { + shared.warning_count.fetch_add(1, Ordering::Relaxed); + } + // SAFETY: Vulkan invokes the callback with either a null pointer or a valid callback-data payload. + let Some(callback_data) = (unsafe { callback_data.as_ref() }) else { + return vk::FALSE; + }; + if let Some(vuid) = (!callback_data.p_message_id_name.is_null()).then(|| { + // SAFETY: `p_message_id_name` is a Vulkan-owned NUL-terminated string for the callback duration. + unsafe { CStr::from_ptr(callback_data.p_message_id_name) } + .to_string_lossy() + .into_owned() + }) { + if vuid.starts_with("VUID-") { + if let Ok(mut vuids) = shared.vuids.lock() { + vuids.insert(vuid); + } + } + } + vk::FALSE +} + +struct VulkanAllocatedBuffer { + buffer: vk::Buffer, + memory: vk::DeviceMemory, +} + +struct VulkanSwapchainResources { + image_views: Vec<vk::ImageView>, + render_pass: vk::RenderPass, + pipeline_layout: vk::PipelineLayout, + pipeline: vk::Pipeline, + framebuffers: Vec<vk::Framebuffer>, + command_buffers: Vec<vk::CommandBuffer>, +} + +struct VulkanFrameSync { + image_available: vk::Semaphore, + render_finished: vk::Semaphore, + fence: vk::Fence, +} + +/// Live Stage 0 Vulkan triangle renderer used by the smoke app. +pub struct VulkanSmokeRenderer { + instance: Option<VulkanInstanceProbe>, + validation: Option<VulkanValidationMessenger>, + surface: Option<VulkanSurfaceProbe>, + device: Option<VulkanLogicalDeviceProbe>, + swapchain: Option<VulkanSwapchainProbe>, + command_pool: vk::CommandPool, + swapchain_resources: Option<VulkanSwapchainResources>, + vertex_buffer: Option<VulkanAllocatedBuffer>, + index_buffer: Option<VulkanAllocatedBuffer>, + frame_sync: Vec<VulkanFrameSync>, + images_in_flight: Vec<vk::Fence>, + current_frame: usize, + pending_extent: Option<(u32, u32)>, + swapchain_recreate_count: u32, + report: VulkanSmokeRendererReport, +} + +impl VulkanSmokeRenderer { + /// Creates a live Vulkan smoke renderer bound to a live native window. + /// + /// # Errors + /// + /// Returns [`VulkanSmokeRendererError`] when Vulkan bootstrap, pipeline creation, + /// memory allocation, or synchronization resource creation fails. + pub fn new( + create_info: &VulkanSmokeRendererCreateInfo, + ) -> Result<Self, VulkanSmokeRendererError> { + validate_shader_manifest(&triangle_shader_manifest()) + .map_err(VulkanSmokeRendererError::ShaderManifest)?; + let surface_plan = plan_vulkan_surface(Some(create_info.native_handles)) + .map_err(VulkanSmokeRendererError::Surface)?; + let mut instance_config = VulkanInstanceConfig::smoke(&create_info.application_name); + instance_config + .required_extensions + .clone_from(&surface_plan.required_instance_extensions); + instance_config.enable_validation = create_info.enable_validation; + let instance = create_vulkan_instance_probe(&instance_config) + .map_err(VulkanSmokeRendererError::Instance)?; + let validation = if create_info.enable_validation { + Some(create_validation_messenger(&instance)?) + } else { + None + }; + let surface = create_vulkan_surface_probe(&instance, Some(create_info.native_handles)) + .map_err(VulkanSmokeRendererError::Surface)?; + let device = + create_vulkan_logical_device_probe(&instance, &surface, create_info.drawable_extent) + .map_err(VulkanSmokeRendererError::LogicalDevice)?; + let swapchain = create_vulkan_swapchain_probe_for_extent( + &instance, + &surface, + &device, + create_info.drawable_extent, + vk::SwapchainKHR::null(), + ) + .map_err(VulkanSmokeRendererError::Swapchain)?; + let command_pool = create_command_pool(&device)?; + let vertex_buffer = Some(create_triangle_vertex_buffer(&instance, &device)?); + let index_buffer = Some(create_triangle_index_buffer(&instance, &device)?); + let mut renderer = Self { + instance: Some(instance), + validation, + surface: Some(surface), + device: Some(device), + swapchain: Some(swapchain), + command_pool, + swapchain_resources: None, + vertex_buffer, + index_buffer, + frame_sync: Vec::new(), + images_in_flight: Vec::new(), + current_frame: 0, + pending_extent: None, + swapchain_recreate_count: 0, + report: VulkanSmokeRendererReport { + portability_enumeration: instance_config.enable_portability_enumeration, + device_name: String::new(), + graphics_queue_family: 0, + present_queue_family: 0, + enabled_extension_count: 0, + swapchain_extent: (0, 0), + swapchain_image_count: 0, + }, + }; + renderer.rebuild_swapchain_resources(false)?; + let device_ref = renderer.device_ref()?; + let swapchain_ref = renderer.swapchain_ref()?; + renderer.report = VulkanSmokeRendererReport { + portability_enumeration: renderer + .instance + .as_ref() + .is_some_and(|instance| instance.report.create_flags != 0), + device_name: device_ref.report.device_name.clone(), + graphics_queue_family: device_ref.report.graphics_queue_family, + present_queue_family: device_ref.report.present_queue_family, + enabled_extension_count: device_ref + .report + .enabled_extensions + .len() + .try_into() + .unwrap_or(u32::MAX), + swapchain_extent: swapchain_ref.report.plan.extent, + swapchain_image_count: swapchain_ref.report.image_count, + }; + Ok(renderer) + } + + /// Returns the current bootstrap report. + #[must_use] + pub const fn report(&self) -> &VulkanSmokeRendererReport { + &self.report + } + + /// Returns measured validation counters and VUIDs. + #[must_use] + pub fn validation_report(&self) -> VulkanValidationReport { + self.validation.as_ref().map_or( + VulkanValidationReport { + warning_count: 0, + error_count: 0, + vuids: Vec::new(), + }, + VulkanValidationMessenger::report, + ) + } + + /// Returns the measured swapchain recreation count. + #[must_use] + pub const fn swapchain_recreate_count(&self) -> u32 { + self.swapchain_recreate_count + } + + /// Requests swapchain recreation for a new drawable extent. + pub fn request_resize(&mut self, extent: (u32, u32)) { + self.pending_extent = Some(extent); + } + + fn device_ref(&self) -> Result<&VulkanLogicalDeviceProbe, VulkanSmokeRendererError> { + self.device + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "logical device", + }) + } + + fn swapchain_ref(&self) -> Result<&VulkanSwapchainProbe, VulkanSmokeRendererError> { + self.swapchain + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "swapchain", + }) + } + + fn instance_ref(&self) -> Result<&VulkanInstanceProbe, VulkanSmokeRendererError> { + self.instance + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "instance", + }) + } + + fn surface_ref(&self) -> Result<&VulkanSurfaceProbe, VulkanSmokeRendererError> { + self.surface + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { context: "surface" }) + } + + fn resources_ref(&self) -> Result<&VulkanSwapchainResources, VulkanSmokeRendererError> { + self.swapchain_resources + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "swapchain resources", + }) + } + + fn vertex_buffer_ref(&self) -> Result<&VulkanAllocatedBuffer, VulkanSmokeRendererError> { + self.vertex_buffer + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "vertex buffer", + }) + } + + fn index_buffer_ref(&self) -> Result<&VulkanAllocatedBuffer, VulkanSmokeRendererError> { + self.index_buffer + .as_ref() + .ok_or(VulkanSmokeRendererError::InvariantViolation { + context: "index buffer", + }) + } + + /// Draws and presents one indexed-triangle frame. + /// + /// # Errors + /// + /// Returns [`VulkanSmokeRendererError`] when synchronization, command recording, + /// submission, or presentation fails. + #[allow(clippy::too_many_lines)] + pub fn draw_frame(&mut self) -> Result<VulkanSmokeFrameOutcome, VulkanSmokeRendererError> { + if let Some(extent) = self.pending_extent.take() { + if extent.0 == 0 || extent.1 == 0 { + self.pending_extent = Some(extent); + return Ok(VulkanSmokeFrameOutcome::ZeroExtent); + } + self.recreate_swapchain(extent)?; + return Ok(VulkanSmokeFrameOutcome::Recreated); + } + + let sync = &self.frame_sync[self.current_frame]; + let image_available = sync.image_available; + let render_finished = sync.render_finished; + let in_flight_fence = sync.fence; + // SAFETY: The fence belongs to this live logical device and is waited from one thread. + unsafe { + self.device_ref()? + .device() + .wait_for_fences(&[in_flight_fence], true, 1_000_000_000) + } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context: "vkWaitForFences", + result: format!("{error:?}"), + })?; + // SAFETY: The swapchain, semaphore and fence inputs are live for the duration of the acquire call. + let acquire = unsafe { + self.swapchain_ref()?.loader().acquire_next_image( + self.swapchain_ref()?.swapchain(), + 1_000_000_000, + image_available, + vk::Fence::null(), + ) + }; + let (image_index, acquire_suboptimal) = match acquire { + Ok(result) => result, + Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + self.recreate_swapchain(self.report.swapchain_extent)?; + return Ok(VulkanSmokeFrameOutcome::Recreated); + } + Err(error) => { + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkAcquireNextImageKHR", + result: format!("{error:?}"), + }); + } + }; + let image_index_usize = usize::try_from(image_index).unwrap_or(0); + let image_fence = self.images_in_flight[image_index_usize]; + if image_fence != vk::Fence::null() { + // SAFETY: The fence belongs to this renderer and can be waited independently. + unsafe { + self.device_ref()? + .device() + .wait_for_fences(&[image_fence], true, 1_000_000_000) + } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context: "vkWaitForFences(image)", + result: format!("{error:?}"), + })?; + } + self.images_in_flight[image_index_usize] = in_flight_fence; + // SAFETY: The fence belongs to this frame context and is not in use after the wait above. + unsafe { self.device_ref()?.device().reset_fences(&[in_flight_fence]) }.map_err( + |error| VulkanSmokeRendererError::VulkanOperation { + context: "vkResetFences", + result: format!("{error:?}"), + }, + )?; + + self.record_command_buffer(image_index_usize)?; + let wait_semaphores = [image_available]; + let wait_stages = [vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT]; + let command_buffers = [self.resources_ref()?.command_buffers[image_index_usize]]; + let signal_semaphores = [render_finished]; + let submit_info = [vk::SubmitInfo::default() + .wait_semaphores(&wait_semaphores) + .wait_dst_stage_mask(&wait_stages) + .command_buffers(&command_buffers) + .signal_semaphores(&signal_semaphores)]; + // SAFETY: Submission references live queue, sync objects and recorded command buffer. + unsafe { + self.device_ref()?.device().queue_submit( + self.device_ref()?.graphics_queue(), + &submit_info, + in_flight_fence, + ) + } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context: "vkQueueSubmit", + result: format!("{error:?}"), + })?; + + let present_wait = [render_finished]; + let swapchains = [self.swapchain_ref()?.swapchain()]; + let image_indices = [image_index]; + let present_info = vk::PresentInfoKHR::default() + .wait_semaphores(&present_wait) + .swapchains(&swapchains) + .image_indices(&image_indices); + // SAFETY: Presentation uses the rendered image index and a semaphore signaled by queue submission. + let present_suboptimal = match unsafe { + self.swapchain_ref()? + .loader() + .queue_present(self.device_ref()?.present_queue(), &present_info) + } { + Ok(suboptimal) => suboptimal, + Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + self.recreate_swapchain(self.report.swapchain_extent)?; + return Ok(VulkanSmokeFrameOutcome::Recreated); + } + Err(error) => { + return Err(VulkanSmokeRendererError::VulkanOperation { + context: "vkQueuePresentKHR", + result: format!("{error:?}"), + }); + } + }; + + self.current_frame = (self.current_frame + 1) % self.frame_sync.len().max(1); + if acquire_suboptimal || present_suboptimal { + self.recreate_swapchain(self.report.swapchain_extent)?; + Ok(VulkanSmokeFrameOutcome::Recreated) + } else { + Ok(VulkanSmokeFrameOutcome::Presented) + } + } + + fn recreate_swapchain(&mut self, extent: (u32, u32)) -> Result<(), VulkanSmokeRendererError> { + let device = self.device_ref()?; + // SAFETY: The logical device remains live and idling at swapchain recreation boundaries. + unsafe { device.device().device_wait_idle() }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkDeviceWaitIdle", + result: format!("{error:?}"), + } + })?; + self.pending_extent = None; + self.rebuild_swapchain(extent)?; + self.swapchain_recreate_count = self.swapchain_recreate_count.saturating_add(1); + Ok(()) + } + + fn rebuild_swapchain(&mut self, extent: (u32, u32)) -> Result<(), VulkanSmokeRendererError> { + self.destroy_swapchain_resources(); + let instance = self.instance_ref()?; + let surface = self.surface_ref()?; + let device = self.device_ref()?; + let old_swapchain = self + .swapchain + .as_ref() + .map_or(vk::SwapchainKHR::null(), VulkanSwapchainProbe::swapchain); + let new_swapchain = create_vulkan_swapchain_probe_for_extent( + instance, + surface, + device, + extent, + old_swapchain, + ) + .map_err(VulkanSmokeRendererError::Swapchain)?; + self.swapchain = Some(new_swapchain); + self.rebuild_swapchain_resources(true)?; + Ok(()) + } + + fn rebuild_swapchain_resources( + &mut self, + reuse_command_pool: bool, + ) -> Result<(), VulkanSmokeRendererError> { + let resources = { + let device = self.device_ref()?; + let swapchain = self.swapchain_ref()?; + create_swapchain_resources( + device, + swapchain, + self.command_pool, + self.vertex_buffer_ref()?, + self.index_buffer_ref()?, + reuse_command_pool, + )? + }; + let frame_sync = { + let device = self.device_ref()?; + create_frame_sync(device)? + }; + let swapchain_extent = self.swapchain_ref()?.report.plan.extent; + let swapchain_image_count = self.swapchain_ref()?.report.image_count; + self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()]; + self.frame_sync = frame_sync; + self.report.swapchain_extent = swapchain_extent; + self.report.swapchain_image_count = swapchain_image_count; + self.swapchain_resources = Some(resources); + Ok(()) + } + + #[allow(clippy::too_many_lines)] + fn record_command_buffer( + &mut self, + image_index: usize, + ) -> Result<(), VulkanSmokeRendererError> { + let device = self.device_ref()?; + let swapchain = self.swapchain_ref()?; + let resources = self.resources_ref()?; + let command_buffer = resources.command_buffers[image_index]; + // SAFETY: The command buffer belongs to the resettable pool owned by this renderer. + unsafe { + device + .device() + .reset_command_buffer(command_buffer, vk::CommandBufferResetFlags::empty()) + } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context: "vkResetCommandBuffer", + result: format!("{error:?}"), + })?; + let begin_info = vk::CommandBufferBeginInfo::default(); + // SAFETY: The command buffer is in the initial state after reset and recorded on one thread. + unsafe { + device + .device() + .begin_command_buffer(command_buffer, &begin_info) + } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context: "vkBeginCommandBuffer", + result: format!("{error:?}"), + })?; + + let pre_barrier = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .new_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .subresource_range(color_subresource_range()) + .src_access_mask(vk::AccessFlags::empty()) + .dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE); + // SAFETY: The swapchain is live and queried only to resolve the current image handles. + let swapchain_images = unsafe { + swapchain + .loader() + .get_swapchain_images(swapchain.swapchain()) + } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context: "vkGetSwapchainImagesKHR", + result: format!("{error:?}"), + })?; + let pre_barrier = pre_barrier.image(swapchain_images[image_index]); + // SAFETY: The barriers operate on the acquired swapchain image owned by this command buffer submission. + unsafe { + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::DependencyFlags::empty(), + &[], + &[], + &[pre_barrier], + ); + } + + let clear_values = [vk::ClearValue { + color: vk::ClearColorValue { + float32: [0.05, 0.08, 0.11, 1.0], + }, + }]; + let render_area = vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent: vk::Extent2D { + width: swapchain.report.plan.extent.0, + height: swapchain.report.plan.extent.1, + }, + }; + let render_pass_info = vk::RenderPassBeginInfo::default() + .render_pass(resources.render_pass) + .framebuffer(resources.framebuffers[image_index]) + .render_area(render_area) + .clear_values(&clear_values); + // SAFETY: All commands target live frame resources owned by this renderer. + unsafe { + device.device().cmd_begin_render_pass( + command_buffer, + &render_pass_info, + vk::SubpassContents::INLINE, + ); + device.device().cmd_bind_pipeline( + command_buffer, + vk::PipelineBindPoint::GRAPHICS, + resources.pipeline, + ); + let vertex_buffers = [self.vertex_buffer_ref()?.buffer]; + let offsets = [0_u64]; + device + .device() + .cmd_bind_vertex_buffers(command_buffer, 0, &vertex_buffers, &offsets); + device.device().cmd_bind_index_buffer( + command_buffer, + self.index_buffer_ref()?.buffer, + 0, + vk::IndexType::UINT16, + ); + device + .device() + .cmd_draw_indexed(command_buffer, 3, 1, 0, 0, 0); + device.device().cmd_end_render_pass(command_buffer); + } + + let post_barrier = vk::ImageMemoryBarrier::default() + .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .new_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .image(swapchain_images[image_index]) + .subresource_range(color_subresource_range()) + .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE) + .dst_access_mask(vk::AccessFlags::empty()); + // SAFETY: The post-render barrier transitions the same live swapchain image into present layout. + unsafe { + device.device().cmd_pipeline_barrier( + command_buffer, + vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT, + vk::PipelineStageFlags::BOTTOM_OF_PIPE, + vk::DependencyFlags::empty(), + &[], + &[], + &[post_barrier], + ); + device.device().end_command_buffer(command_buffer) + } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context: "vkEndCommandBuffer", + result: format!("{error:?}"), + })?; + Ok(()) + } + + fn destroy_swapchain_resources(&mut self) { + let Some(device) = self.device.as_ref() else { + return; + }; + for sync in self.frame_sync.drain(..) { + // SAFETY: These sync objects belong to this device and are destroyed once. + unsafe { + device + .device() + .destroy_semaphore(sync.image_available, None); + device + .device() + .destroy_semaphore(sync.render_finished, None); + device.device().destroy_fence(sync.fence, None); + } + } + if let Some(resources) = self.swapchain_resources.take() { + destroy_swapchain_resources(device, self.command_pool, resources); + } + } +} + +impl Drop for VulkanSmokeRenderer { + fn drop(&mut self) { + self.destroy_swapchain_resources(); + if let Some(device) = self.device.as_ref() { + if let Some(buffer) = self.index_buffer.take() { + // SAFETY: Buffer and memory belong to this device and are destroyed once. + unsafe { + device.device().destroy_buffer(buffer.buffer, None); + device.device().free_memory(buffer.memory, None); + } + } + if let Some(buffer) = self.vertex_buffer.take() { + // SAFETY: Buffer and memory belong to this device and are destroyed once. + unsafe { + device.device().destroy_buffer(buffer.buffer, None); + device.device().free_memory(buffer.memory, None); + } + } + // SAFETY: The command pool belongs to this device and is destroyed once after buffers are freed. + unsafe { + device + .device() + .destroy_command_pool(self.command_pool, None); + }; + // SAFETY: The logical device remains live until the renderer completes teardown. + let _ = unsafe { device.device().device_wait_idle() }; + } + self.swapchain.take(); + self.device.take(); + self.surface.take(); + self.validation.take(); + self.instance.take(); + } +} + +fn create_validation_messenger( + instance: &VulkanInstanceProbe, +) -> Result<VulkanValidationMessenger, VulkanSmokeRendererError> { + let shared = Box::new(VulkanValidationShared::default()); + let loader = ash::ext::debug_utils::Instance::new(&instance.entry, &instance.instance); + let create_info = vk::DebugUtilsMessengerCreateInfoEXT::default() + .message_severity( + vk::DebugUtilsMessageSeverityFlagsEXT::WARNING + | vk::DebugUtilsMessageSeverityFlagsEXT::ERROR, + ) + .message_type( + vk::DebugUtilsMessageTypeFlagsEXT::GENERAL + | vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION + | vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE, + ) + .pfn_user_callback(Some(vulkan_validation_callback)) + .user_data((&raw const *shared).cast_mut().cast()); + let messenger = + // SAFETY: The create info points at a stable boxed user-data allocation for the messenger lifetime. + unsafe { loader.create_debug_utils_messenger(&create_info, None) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateDebugUtilsMessengerEXT", + result: format!("{error:?}"), + } + })?; + Ok(VulkanValidationMessenger { + loader, + messenger, + shared, + }) +} + +fn create_command_pool( + device: &VulkanLogicalDeviceProbe, +) -> Result<vk::CommandPool, VulkanSmokeRendererError> { + let create_info = vk::CommandPoolCreateInfo::default() + .queue_family_index(device.report.graphics_queue_family) + .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER); + // SAFETY: The queue-family index belongs to this live logical device. + unsafe { device.device().create_command_pool(&create_info, None) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateCommandPool", + result: format!("{error:?}"), + } + }) +} + +fn create_triangle_vertex_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, +) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> { + let vertices: [[f32; 5]; 3] = [ + [0.0, -0.55, 1.0, 0.2, 0.2], + [0.55, 0.55, 0.2, 1.0, 0.2], + [-0.55, 0.55, 0.2, 0.4, 1.0], + ]; + let mut bytes = Vec::with_capacity(vertices.len() * 5 * std::mem::size_of::<f32>()); + for vertex in vertices { + for value in vertex { + bytes.extend_from_slice(&value.to_ne_bytes()); + } + } + create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::VERTEX_BUFFER, + "triangle vertex buffer", + ) +} + +fn create_triangle_index_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, +) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> { + let indices = [0_u16, 1_u16, 2_u16]; + let mut bytes = Vec::with_capacity(indices.len() * std::mem::size_of::<u16>()); + for index in indices { + bytes.extend_from_slice(&index.to_ne_bytes()); + } + create_host_visible_buffer( + instance, + device, + &bytes, + vk::BufferUsageFlags::INDEX_BUFFER, + "triangle index buffer", + ) +} + +fn create_host_visible_buffer( + instance: &VulkanInstanceProbe, + device: &VulkanLogicalDeviceProbe, + bytes: &[u8], + usage: vk::BufferUsageFlags, + context: &'static str, +) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> { + let create_info = vk::BufferCreateInfo::default() + .size(bytes.len().try_into().unwrap_or(u64::MAX)) + .usage(usage) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + // SAFETY: The create info is stack-owned and references no external memory. + let buffer = unsafe { device.device().create_buffer(&create_info, None) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context, + result: format!("{error:?}"), + } + })?; + // SAFETY: The buffer belongs to this device and is queried immediately after creation. + let requirements = unsafe { device.device().get_buffer_memory_requirements(buffer) }; + let memory_type_index = find_memory_type( + instance, + device.physical_device, + requirements.memory_type_bits, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, + ) + .ok_or(VulkanSmokeRendererError::MissingMemoryType { context })?; + let allocate_info = vk::MemoryAllocateInfo::default() + .allocation_size(requirements.size) + .memory_type_index(memory_type_index); + let memory = + // SAFETY: Allocation uses a memory type index selected from the physical-device requirements above. + unsafe { device.device().allocate_memory(&allocate_info, None) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context, + result: format!("{error:?}"), + } + })?; + // SAFETY: The buffer and allocation belong to the same live logical device. + unsafe { device.device().bind_buffer_memory(buffer, memory, 0) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context, + result: format!("{error:?}"), + } + })?; + // SAFETY: The allocation is HOST_VISIBLE, mapped for the full buffer size and unmapped before return. + let mapped = unsafe { + device + .device() + .map_memory(memory, 0, requirements.size, vk::MemoryMapFlags::empty()) + } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context, + result: format!("{error:?}"), + })?; + // SAFETY: The mapped pointer is valid for `bytes.len()` bytes and non-overlapping with the source slice. + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len()); + device.device().unmap_memory(memory); + } + Ok(VulkanAllocatedBuffer { buffer, memory }) +} + +fn find_memory_type( + instance: &VulkanInstanceProbe, + physical_device: vk::PhysicalDevice, + memory_type_bits: u32, + required_properties: vk::MemoryPropertyFlags, +) -> Option<u32> { + // SAFETY: Physical-device memory properties are queried from a live instance-owned physical device. + let memory_properties = unsafe { + instance + .instance + .get_physical_device_memory_properties(physical_device) + }; + memory_properties + .memory_types + .iter() + .enumerate() + .find_map(|(index, memory_type)| { + let supported = (memory_type_bits & (1_u32 << index)) != 0; + let has_properties = memory_type.property_flags.contains(required_properties); + (supported && has_properties).then(|| index.try_into().unwrap_or(u32::MAX)) + }) +} + +fn create_swapchain_resources( + device: &VulkanLogicalDeviceProbe, + swapchain: &VulkanSwapchainProbe, + command_pool: vk::CommandPool, + _vertex_buffer: &VulkanAllocatedBuffer, + _index_buffer: &VulkanAllocatedBuffer, + _reuse_command_pool: bool, +) -> Result<VulkanSwapchainResources, VulkanSmokeRendererError> { + // SAFETY: The swapchain is live and owned by this renderer for the duration of the query. + let images = unsafe { + swapchain + .loader() + .get_swapchain_images(swapchain.swapchain()) + } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context: "vkGetSwapchainImagesKHR", + result: format!("{error:?}"), + })?; + let image_views = images + .iter() + .map(|image| create_image_view(device, *image, swapchain.report.plan.format.format)) + .collect::<Result<Vec<_>, _>>()?; + let render_pass = create_render_pass(device, swapchain.report.plan.format.format)?; + let pipeline_layout = create_pipeline_layout(device)?; + let pipeline = create_graphics_pipeline( + device, + render_pass, + pipeline_layout, + swapchain.report.plan.extent, + )?; + let framebuffers = image_views + .iter() + .map(|image_view| { + create_framebuffer( + device, + render_pass, + *image_view, + swapchain.report.plan.extent, + ) + }) + .collect::<Result<Vec<_>, _>>()?; + let command_buffers = allocate_command_buffers( + device, + command_pool, + image_views.len().try_into().unwrap_or(u32::MAX), + )?; + Ok(VulkanSwapchainResources { + image_views, + render_pass, + pipeline_layout, + pipeline, + framebuffers, + command_buffers, + }) +} + +fn create_image_view( + device: &VulkanLogicalDeviceProbe, + image: vk::Image, + format: i32, +) -> Result<vk::ImageView, VulkanSmokeRendererError> { + let create_info = vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(vk::Format::from_raw(format)) + .subresource_range(color_subresource_range()); + // SAFETY: The image comes from the live swapchain and the subresource range covers its color aspect. + unsafe { device.device().create_image_view(&create_info, None) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateImageView", + result: format!("{error:?}"), + } + }) +} + +fn create_render_pass( + device: &VulkanLogicalDeviceProbe, + format: i32, +) -> Result<vk::RenderPass, VulkanSmokeRendererError> { + let color_attachment = vk::AttachmentDescription::default() + .format(vk::Format::from_raw(format)) + .samples(vk::SampleCountFlags::TYPE_1) + .load_op(vk::AttachmentLoadOp::CLEAR) + .store_op(vk::AttachmentStoreOp::STORE) + .initial_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .final_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL); + let color_attachment_ref = vk::AttachmentReference::default() + .attachment(0) + .layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL); + let color_attachments = [color_attachment_ref]; + let subpass = vk::SubpassDescription::default() + .pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS) + .color_attachments(&color_attachments); + let dependency = vk::SubpassDependency::default() + .src_subpass(vk::SUBPASS_EXTERNAL) + .dst_subpass(0) + .src_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) + .dst_stage_mask(vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT) + .dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE); + let attachments = [color_attachment]; + let subpasses = [subpass]; + let dependencies = [dependency]; + let create_info = vk::RenderPassCreateInfo::default() + .attachments(&attachments) + .subpasses(&subpasses) + .dependencies(&dependencies); + // SAFETY: The render-pass create info only references stack-owned descriptors. + unsafe { device.device().create_render_pass(&create_info, None) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateRenderPass", + result: format!("{error:?}"), + } + }) +} + +fn create_pipeline_layout( + device: &VulkanLogicalDeviceProbe, +) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> { + let create_info = vk::PipelineLayoutCreateInfo::default(); + // SAFETY: The pipeline layout contains no descriptor sets or push constants. + unsafe { device.device().create_pipeline_layout(&create_info, None) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreatePipelineLayout", + result: format!("{error:?}"), + } + }) +} + +fn extent_component_to_f32(value: u32) -> f32 { + u16::try_from(value).map_or(f32::from(u16::MAX), f32::from) +} + +fn create_graphics_pipeline( + device: &VulkanLogicalDeviceProbe, + render_pass: vk::RenderPass, + pipeline_layout: vk::PipelineLayout, + extent: (u32, u32), +) -> Result<vk::Pipeline, VulkanSmokeRendererError> { + let entry_point = c"main"; + let vertex_module = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?; + let fragment_module = create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS)?; + let stage_create_infos = [ + vk::PipelineShaderStageCreateInfo::default() + .stage(vk::ShaderStageFlags::VERTEX) + .module(vertex_module) + .name(entry_point), + vk::PipelineShaderStageCreateInfo::default() + .stage(vk::ShaderStageFlags::FRAGMENT) + .module(fragment_module) + .name(entry_point), + ]; + let binding_descriptions = [vk::VertexInputBindingDescription { + binding: 0, + stride: 20, + input_rate: vk::VertexInputRate::VERTEX, + }]; + let attribute_descriptions = [ + vk::VertexInputAttributeDescription { + location: 0, + binding: 0, + format: vk::Format::R32G32_SFLOAT, + offset: 0, + }, + vk::VertexInputAttributeDescription { + location: 1, + binding: 0, + format: vk::Format::R32G32B32_SFLOAT, + offset: 8, + }, + ]; + let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default() + .vertex_binding_descriptions(&binding_descriptions) + .vertex_attribute_descriptions(&attribute_descriptions); + let input_assembly_state = vk::PipelineInputAssemblyStateCreateInfo::default() + .topology(vk::PrimitiveTopology::TRIANGLE_LIST); + let viewports = [vk::Viewport { + x: 0.0, + y: 0.0, + width: extent_component_to_f32(extent.0), + height: extent_component_to_f32(extent.1), + min_depth: 0.0, + max_depth: 1.0, + }]; + let scissors = [vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent: vk::Extent2D { + width: extent.0, + height: extent.1, + }, + }]; + let viewport_state = vk::PipelineViewportStateCreateInfo::default() + .viewports(&viewports) + .scissors(&scissors); + let rasterization_state = vk::PipelineRasterizationStateCreateInfo::default() + .polygon_mode(vk::PolygonMode::FILL) + .cull_mode(vk::CullModeFlags::BACK) + .front_face(vk::FrontFace::CLOCKWISE) + .line_width(1.0); + let multisample_state = vk::PipelineMultisampleStateCreateInfo::default() + .rasterization_samples(vk::SampleCountFlags::TYPE_1); + let color_blend_attachment = [vk::PipelineColorBlendAttachmentState::default() + .color_write_mask( + vk::ColorComponentFlags::R + | vk::ColorComponentFlags::G + | vk::ColorComponentFlags::B + | vk::ColorComponentFlags::A, + )]; + let color_blend_state = + vk::PipelineColorBlendStateCreateInfo::default().attachments(&color_blend_attachment); + let create_info = [vk::GraphicsPipelineCreateInfo::default() + .stages(&stage_create_infos) + .vertex_input_state(&vertex_input_state) + .input_assembly_state(&input_assembly_state) + .viewport_state(&viewport_state) + .rasterization_state(&rasterization_state) + .multisample_state(&multisample_state) + .color_blend_state(&color_blend_state) + .layout(pipeline_layout) + .render_pass(render_pass) + .subpass(0)]; + // SAFETY: The pipeline creation references live shader modules and stack-owned fixed-function descriptors. + let pipeline = unsafe { + device + .device() + .create_graphics_pipelines(vk::PipelineCache::null(), &create_info, None) + } + .map_err(|(_, error)| VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateGraphicsPipelines", + result: format!("{error:?}"), + })?[0]; + // SAFETY: Shader modules are no longer needed after pipeline creation completes. + unsafe { + device.device().destroy_shader_module(vertex_module, None); + device.device().destroy_shader_module(fragment_module, None); + } + Ok(pipeline) +} + +fn create_shader_module( + device: &VulkanLogicalDeviceProbe, + words: &[u32], +) -> Result<vk::ShaderModule, VulkanSmokeRendererError> { + let create_info = vk::ShaderModuleCreateInfo::default().code(words); + // SAFETY: SPIR-V words are immutable and valid for the duration of the call. + unsafe { device.device().create_shader_module(&create_info, None) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateShaderModule", + result: format!("{error:?}"), + } + }) +} + +fn create_framebuffer( + device: &VulkanLogicalDeviceProbe, + render_pass: vk::RenderPass, + image_view: vk::ImageView, + extent: (u32, u32), +) -> Result<vk::Framebuffer, VulkanSmokeRendererError> { + let attachments = [image_view]; + let create_info = vk::FramebufferCreateInfo::default() + .render_pass(render_pass) + .attachments(&attachments) + .width(extent.0) + .height(extent.1) + .layers(1); + // SAFETY: The framebuffer attachments and render pass remain live for the duration of the call. + unsafe { device.device().create_framebuffer(&create_info, None) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateFramebuffer", + result: format!("{error:?}"), + } + }) +} + +fn allocate_command_buffers( + device: &VulkanLogicalDeviceProbe, + command_pool: vk::CommandPool, + count: u32, +) -> Result<Vec<vk::CommandBuffer>, VulkanSmokeRendererError> { + let allocate_info = vk::CommandBufferAllocateInfo::default() + .command_pool(command_pool) + .level(vk::CommandBufferLevel::PRIMARY) + .command_buffer_count(count); + // SAFETY: Command buffers are allocated from a live resettable pool owned by this device. + unsafe { device.device().allocate_command_buffers(&allocate_info) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkAllocateCommandBuffers", + result: format!("{error:?}"), + } + }) +} + +fn create_frame_sync( + device: &VulkanLogicalDeviceProbe, +) -> Result<Vec<VulkanFrameSync>, VulkanSmokeRendererError> { + let semaphore_info = vk::SemaphoreCreateInfo::default(); + let fence_info = vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED); + let mut sync = Vec::with_capacity(2); + for _ in 0..2 { + // SAFETY: The sync objects belong to this live logical device and are destroyed at teardown. + let image_available = unsafe { device.device().create_semaphore(&semaphore_info, None) } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateSemaphore(image_available)", + result: format!("{error:?}"), + })?; + // SAFETY: The sync objects belong to this live logical device and are destroyed at teardown. + let render_finished = unsafe { device.device().create_semaphore(&semaphore_info, None) } + .map_err(|error| VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateSemaphore(render_finished)", + result: format!("{error:?}"), + })?; + let fence = + // SAFETY: The fence belongs to this live logical device and is destroyed at teardown. + unsafe { device.device().create_fence(&fence_info, None) }.map_err(|error| { + VulkanSmokeRendererError::VulkanOperation { + context: "vkCreateFence", + result: format!("{error:?}"), + } + })?; + sync.push(VulkanFrameSync { + image_available, + render_finished, + fence, + }); + } + Ok(sync) +} + +fn destroy_swapchain_resources( + device: &VulkanLogicalDeviceProbe, + command_pool: vk::CommandPool, + resources: VulkanSwapchainResources, +) { + // SAFETY: All swapchain-dependent objects belong to this device and are destroyed once. + unsafe { + device + .device() + .free_command_buffers(command_pool, &resources.command_buffers); + for framebuffer in resources.framebuffers { + device.device().destroy_framebuffer(framebuffer, None); + } + device.device().destroy_pipeline(resources.pipeline, None); + device + .device() + .destroy_pipeline_layout(resources.pipeline_layout, None); + device + .device() + .destroy_render_pass(resources.render_pass, None); + for image_view in resources.image_views { + device.device().destroy_image_view(image_view, None); + } + } +} + +fn color_subresource_range() -> vk::ImageSubresourceRange { + vk::ImageSubresourceRange::default() + .aspect_mask(vk::ImageAspectFlags::COLOR) + .base_mip_level(0) + .level_count(1) + .base_array_layer(0) + .layer_count(1) +} + /// Runtime swapchain creation report. #[derive(Clone, Debug, Eq, PartialEq)] pub struct VulkanSwapchainReport { @@ -879,6 +2585,28 @@ pub fn create_vulkan_swapchain_probe( surface: &VulkanSurfaceProbe, device: &VulkanLogicalDeviceProbe, ) -> Result<VulkanSwapchainProbe, VulkanSwapchainProbeError> { + create_vulkan_swapchain_probe_for_extent( + instance, + surface, + device, + device.runtime.swapchain.extent, + vk::SwapchainKHR::null(), + ) +} + +/// Creates a Vulkan swapchain for the live logical device and surface at a specific extent. +/// +/// # Errors +/// +/// Returns [`VulkanSwapchainProbeError`] when live surface capability queries, +/// swapchain creation, or swapchain image enumeration fails. +pub fn create_vulkan_swapchain_probe_for_extent( + instance: &VulkanInstanceProbe, + surface: &VulkanSurfaceProbe, + device: &VulkanLogicalDeviceProbe, + drawable_extent: (u32, u32), + old_swapchain: vk::SwapchainKHR, +) -> Result<VulkanSwapchainProbe, VulkanSwapchainProbeError> { let raw_capabilities = { // SAFETY: The physical device and surface are live query inputs and no handles are retained. unsafe { @@ -892,7 +2620,35 @@ pub fn create_vulkan_swapchain_probe( result: format!("{error:?}"), }, )?; - let plan = &device.runtime.swapchain; + let surface_formats = + live_surface_formats(surface, device.physical_device, &device.report.device_name).map_err( + |error| VulkanSwapchainProbeError::CreateFailed { + result: error.to_string(), + }, + )?; + let present_modes = + live_present_modes(surface, device.physical_device, &device.report.device_name).map_err( + |error| VulkanSwapchainProbeError::CreateFailed { + result: error.to_string(), + }, + )?; + let capabilities = + live_surface_capabilities(surface, device.physical_device, &device.report.device_name) + .map_err( + |error| VulkanSwapchainProbeError::SurfaceCapabilitiesFailed { + result: error.to_string(), + }, + )?; + let plan = plan_vulkan_swapchain(&VulkanSwapchainRequest { + drawable_extent, + formats: surface_formats, + present_modes, + capabilities, + preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(), + }) + .map_err(|error| VulkanSwapchainProbeError::CreateFailed { + result: error.to_string(), + })?; let queue_family_indices = unique_queue_families( device.runtime.capability.graphics_queue_family, device.runtime.capability.present_queue_family, @@ -920,6 +2676,7 @@ pub fn create_vulkan_swapchain_probe( raw_capabilities.supported_composite_alpha, )) .present_mode(vk::PresentModeKHR::from_raw(plan.present_mode)) + .old_swapchain(old_swapchain) .clipped(true); let loader = swapchain::Device::new(&instance.instance, &device.device); // SAFETY: The create info references live instance/device/surface handles for this call. @@ -939,7 +2696,7 @@ pub fn create_vulkan_swapchain_probe( swapchain, report: VulkanSwapchainReport { schema: 1, - plan: plan.clone(), + plan, image_count: images.len().try_into().unwrap_or(u32::MAX), }, }) @@ -959,8 +2716,15 @@ fn select_live_device_candidate( })? }; let mut best: Option<LiveDeviceCandidate> = None; + let mut last_error = None; for (index, device) in devices.iter().copied().enumerate() { - let candidate = live_device_candidate(instance, surface, device, index)?; + let candidate = match live_device_candidate(instance, surface, device, index) { + Ok(candidate) => candidate, + Err(err) => { + last_error = Some(err); + continue; + } + }; match &best { Some(existing) if compare_reports(&candidate.capability, &existing.capability) @@ -968,9 +2732,11 @@ fn select_live_device_candidate( _ => best = Some(candidate), } } - let best = best.ok_or(VulkanRuntimeCapabilityError::Capability( - VulkanCapabilityError::NoPhysicalDevice, - ))?; + let best = best.ok_or_else(|| { + last_error.unwrap_or(VulkanRuntimeCapabilityError::Capability( + VulkanCapabilityError::NoPhysicalDevice, + )) + })?; let swapchain = plan_vulkan_swapchain(&VulkanSwapchainRequest { drawable_extent, formats: best.surface_formats, @@ -1259,6 +3025,8 @@ pub enum VulkanInstanceError { /// Invalid extension name. extension: String, }, + /// Validation layers were requested but unavailable. + MissingValidationLayer, /// Instance creation failed. CreateFailed { /// Vulkan result. @@ -1279,6 +3047,12 @@ impl std::fmt::Display for VulkanInstanceError { "Vulkan instance extension name contains an interior NUL byte: {extension:?}" ) } + Self::MissingValidationLayer => { + write!( + f, + "Vulkan validation layer VK_LAYER_KHRONOS_validation is unavailable" + ) + } Self::CreateFailed { result } => write!(f, "Vulkan instance creation failed: {result}"), } } @@ -1290,6 +3064,13 @@ impl std::error::Error for VulkanInstanceError {} #[must_use] pub fn plan_vulkan_instance(config: &VulkanInstanceConfig) -> VulkanInstancePlan { let mut enabled_extensions = config.required_extensions.clone(); + if config.enable_validation + && !enabled_extensions + .iter() + .any(|extension| extension == EXT_DEBUG_UTILS_EXTENSION) + { + enabled_extensions.push(EXT_DEBUG_UTILS_EXTENSION.to_string()); + } if config.enable_portability_enumeration && !enabled_extensions .iter() @@ -1332,6 +3113,8 @@ pub fn create_vulkan_instance_probe( let plan = plan_vulkan_instance(config); let extension_names = cstring_vec(&plan.enabled_extensions)?; let extension_ptrs = cstring_ptrs(&extension_names); + let layer_names = validation_layer_cstrings(&entry, config.enable_validation)?; + let layer_ptrs = cstring_ptrs(&layer_names); let app_info = vk::ApplicationInfo::default() .application_name(&app_name) .application_version(0) @@ -1341,6 +3124,7 @@ pub fn create_vulkan_instance_probe( let create_info = vk::InstanceCreateInfo::default() .application_info(&app_info) .enabled_extension_names(&extension_ptrs) + .enabled_layer_names(&layer_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| { @@ -1355,6 +3139,35 @@ pub fn create_vulkan_instance_probe( }) } +fn validation_layer_cstrings( + entry: &ash::Entry, + enable_validation: bool, +) -> Result<Vec<CString>, VulkanInstanceError> { + if !enable_validation { + return Ok(Vec::new()); + } + let available_layers = + // SAFETY: Enumerating instance layers reads loader-owned immutable metadata. + unsafe { entry.enumerate_instance_layer_properties() }.map_err(|error| { + VulkanInstanceError::CreateFailed { + result: format!("{error:?}"), + } + })?; + let validation_available = available_layers.iter().any(|layer| { + // SAFETY: Vulkan layer names are fixed-size NUL-terminated strings from the loader. + unsafe { CStr::from_ptr(layer.layer_name.as_ptr()) } + .to_string_lossy() + .as_ref() + == VALIDATION_LAYER_NAME + }); + if !validation_available { + return Err(VulkanInstanceError::MissingValidationLayer); + } + Ok(vec![CString::new(VALIDATION_LAYER_NAME).map_err(|_| { + VulkanInstanceError::InvalidApplicationName + })?]) +} + /// Renders a deterministic JSON Vulkan instance plan. #[must_use] pub fn render_instance_plan_json(plan: &VulkanInstancePlan) -> String { @@ -1901,15 +3714,22 @@ pub fn select_physical_device( } let mut best = None; + let mut last_error = None; for device in devices { - let report = validate_device(device)?; + let report = match validate_device(device) { + Ok(report) => report, + Err(err) => { + last_error = Some(err); + continue; + } + }; match &best { Some(existing) if compare_reports(&report, existing) != std::cmp::Ordering::Greater => { } _ => best = Some(report), } } - best.ok_or(VulkanCapabilityError::NoPhysicalDevice) + best.ok_or_else(|| last_error.unwrap_or(VulkanCapabilityError::NoPhysicalDevice)) } /// Builds a deterministic swapchain plan from surface capabilities. @@ -2072,22 +3892,7 @@ fn validate_device( 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 (graphics_queue_family, present_queue_family) = select_queue_families(device)?; let portability_subset = device.supports_extension(KHR_PORTABILITY_SUBSET_EXTENSION); let mut enabled_extensions = vec![KHR_SWAPCHAIN_EXTENSION.to_string()]; @@ -2107,6 +3912,36 @@ fn validate_device( }) } +fn select_queue_families( + device: &VulkanPhysicalDeviceRecord, +) -> Result<(u32, u32), VulkanCapabilityError> { + if let Some(unified) = device + .queue_families + .iter() + .find(|family| family.graphics && family.present) + { + return Ok((unified.index, unified.index)); + } + + 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; + Ok((graphics_queue_family, present_queue_family)) +} + fn score_device( device: &VulkanPhysicalDeviceRecord, graphics_queue_family: u32, @@ -2266,9 +4101,9 @@ fn push_json_string(out: &mut String, value: &str) { out.push('"'); } -/// Diagnostics for Vulkan backend setup and frame progression. +/// Diagnostics for Vulkan planning backend setup and frame progression. #[derive(Clone, Debug, PartialEq)] -pub struct VulkanBackendReport { +pub struct VulkanPlanningBackendReport { /// Unix time at initialization. pub initialized_at: u64, /// Total frames executed. @@ -2287,7 +4122,7 @@ pub struct VulkanBackendReport { pub last_frame_submission: Option<VulkanFrameSubmissionPlan>, } -impl Default for VulkanBackendReport { +impl Default for VulkanPlanningBackendReport { fn default() -> Self { Self { initialized_at: SystemTime::now() @@ -2304,27 +4139,27 @@ impl Default for VulkanBackendReport { } } -/// Vulkan backend façade used by the game entrypoint. +/// Vulkan planning backend façade used by the game entrypoint. #[derive(Debug)] -pub struct VulkanBackend { +pub struct VulkanPlanningBackend { state: VulkanBackendState, - report: VulkanBackendReport, + report: VulkanPlanningBackendReport, swapchain_plan: VulkanSwapchainPlan, } -impl Default for VulkanBackend { +impl Default for VulkanPlanningBackend { fn default() -> Self { Self::new() } } -impl VulkanBackend { - /// Creates a new Vulkan-backed backend façade. +impl VulkanPlanningBackend { + /// Creates a new Vulkan planning backend façade. #[must_use] pub fn new() -> Self { Self { state: VulkanBackendState::Ready, - report: VulkanBackendReport::default(), + report: VulkanPlanningBackendReport::default(), swapchain_plan: default_stage0_swapchain_plan(), } } @@ -2360,7 +4195,7 @@ impl VulkanBackend { /// Returns backend report. #[must_use] - pub fn report(&self) -> &VulkanBackendReport { + pub fn report(&self) -> &VulkanPlanningBackendReport { &self.report } @@ -2369,7 +4204,7 @@ impl VulkanBackend { } } -impl RenderBackend for VulkanBackend { +impl RenderBackend for VulkanPlanningBackend { fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> { if !matches!( self.state, @@ -2410,7 +4245,7 @@ mod tests { #[test] fn backend_tracks_render_request_and_presents() -> Result<(), RenderError> { - let mut backend = VulkanBackend::new(); + let mut backend = VulkanPlanningBackend::new(); let request = RenderRequest::conservative(); backend.set_render_request(request); assert_eq!(backend.render_request(), request); @@ -2648,7 +4483,7 @@ mod tests { assert_eq!( render_instance_plan_json(&plan), - "{\"schema\":1,\"create_flags\":1,\"validation_requested\":true,\"enabled_extensions\":[\"VK_KHR_portability_enumeration\",\"VK_KHR_surface\"]}" + "{\"schema\":1,\"create_flags\":1,\"validation_requested\":true,\"enabled_extensions\":[\"VK_EXT_debug_utils\",\"VK_KHR_portability_enumeration\",\"VK_KHR_surface\"]}" ); } @@ -2794,18 +4629,18 @@ mod tests { assert_eq!(report.modules.len(), 2); assert_eq!(report.modules[0].name, "triangle.vert"); assert_eq!(report.modules[0].stage, VulkanShaderStage::Vertex); - assert_eq!(report.modules[0].word_count, 12); + assert_eq!(report.modules[0].word_count, 253); assert_eq!( report.modules[0].sha256, - "f0dc7b3388e59e94a0e1d5d82c97f103d47ab703145fdf44acb3b7cdf0d6087f" + "9023b1cc856c98ecd21755596c4e9d1e62cc63e1787f8c43ada2101544e8d0d1" ); assert_eq!( report.modules[1].sha256, - "bd5e45e96505076efea674c38214e0ee479030d239b52bdc8ffe9835674d14d5" + "6efe2c9716ae845c471ecbaac2c83e56a17a37dc017dd63f0a05f0d9161f44ba" ); assert_eq!( report.manifest_hash, - "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c" + "849ffae9681f5ff2fc145d7b98f19f69b478d9ea73207efdf5f1748e8d51045c" ); } diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs index 6f132e5..5663330 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -28,7 +28,7 @@ use fparkan_render::{ DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand, RenderCommandList, RenderPhase, }; -use fparkan_render_vulkan::VulkanBackend; +use fparkan_render_vulkan::VulkanPlanningBackend; use fparkan_runtime::{ create, frame, load_mission, loaded_mission_assets, EngineConfig, EngineMode, EngineServices, MissionAssets, MissionRequest, @@ -71,7 +71,7 @@ fn run(args: &[String]) -> Result<String, String> { ) .map_err(|err| err.to_string())?; - let mut backend = VulkanBackend::new(); + let mut backend = VulkanPlanningBackend::new(); let _request = WinitWindow::default_render_request(); let window = WinitWindow::synthetic(1280, 720); let _ = window.drawable_size(); diff --git a/apps/fparkan-vulkan-smoke/Cargo.toml b/apps/fparkan-vulkan-smoke/Cargo.toml index 21b67ae..3744849 100644 --- a/apps/fparkan-vulkan-smoke/Cargo.toml +++ b/apps/fparkan-vulkan-smoke/Cargo.toml @@ -9,6 +9,7 @@ repository.workspace = true fparkan-platform = { path = "../../crates/fparkan-platform" } fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } +winit = "0.30" [lints] workspace = true diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index 4e60ec8..d91988b 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -11,19 +11,23 @@ #![allow(clippy::print_stderr, clippy::print_stdout)] //! Native Vulkan smoke runner entrypoint. -use fparkan_platform::{NativeWindowHandles, WindowPort}; -use fparkan_platform_winit::{probe_smoke_window, WinitWindowPlan}; +use fparkan_platform_winit::{window_native_handles, WinitWindowPlan}; use fparkan_render_vulkan::{ - create_vulkan_instance_probe, create_vulkan_logical_device_probe, create_vulkan_surface_probe, - create_vulkan_swapchain_probe, probe_vulkan_loader, run_vulkan_smoke_pass, - triangle_shader_manifest, validate_shader_manifest, VulkanInstanceConfig, VulkanInstanceProbe, - VulkanLogicalDeviceProbe, VulkanSwapchainProbe, + VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, }; use std::path::PathBuf; use std::process::Command; +use winit::application::ApplicationHandler; +use winit::dpi::PhysicalSize; +use winit::event::WindowEvent; +use winit::event_loop::{ActiveEventLoop, EventLoop}; +use winit::window::{Window, WindowId}; const SCHEMA_VERSION: &str = "fparkan-native-smoke-v1"; -const RUST_TOOLCHAIN: &str = "1.87.0"; +const DEFAULT_TARGET_FRAMES: u32 = 300; +const DEFAULT_RESIZE_FRAME: u32 = 120; +const DEFAULT_RESIZE_WIDTH: u32 = 960; +const DEFAULT_RESIZE_HEIGHT: u32 = 540; fn main() { let args = std::env::args().skip(1).collect::<Vec<_>>(); @@ -42,878 +46,372 @@ fn main() { fn run(args: &[String]) -> Result<String, String> { let options = SmokeOptions::parse(args)?; - let (bootstrap, runtime) = VulkanBootstrapProbe::run(&options); - validate_smoke_options(&options, &bootstrap)?; - let smoke_run = if options.status == SmokeStatus::Passed { - runtime - .map(|runtime| { - run_vulkan_smoke_pass( - &runtime.instance, - &runtime.surface, - &runtime.device, - runtime.swapchain, - options.frames, - options.swapchain_recreate_count, - ) - }) - .transpose() - .map_err(|err| err.to_string())? - } else { - None - }; - - if let Some(smoke_run) = smoke_run.as_ref() { - if smoke_run.frames < options.frames { - return Err("passed native smoke report requires frames to be advanced".to_string()); - } - if smoke_run.validation_error_count - != options - .validation_error_count - .unwrap_or(smoke_run.validation_error_count) - { - return Err( - "passed native smoke report requires validation errors to be zero".to_string(), - ); - } - } - let report = render_smoke_report_json(&options, &bootstrap)?; - if let Some(parent) = options.out.parent() { - std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?; - } - std::fs::write(&options.out, &report) - .map_err(|err| format!("{}: {err}", options.out.display()))?; - Ok(report) + let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; + let mut app = SmokeApp::new(options); + event_loop + .run_app(&mut app) + .map_err(|err| format!("winit event loop: {err}"))?; + app.finish() } #[derive(Clone, Debug, Eq, PartialEq)] struct SmokeOptions { - platform: SmokePlatform, out: PathBuf, - status: SmokeStatus, frames: u32, - resize_count: u32, - swapchain_recreate_count: u32, - validation_error_count: Option<u32>, - probes: ProbeOptions, - reason: Option<String>, + resize_frame: u32, } impl SmokeOptions { fn parse(args: &[String]) -> Result<Self, String> { - let mut platform = None; let mut out = None; - let mut status = SmokeStatus::Blocked; - let mut frames = 0; - let mut resize_count = 0; - let mut swapchain_recreate_count = 0; - let mut validation_error_count = None; - let mut probes = ProbeOptions::default(); - let mut reason = None; + let mut frames = DEFAULT_TARGET_FRAMES; + let mut resize_frame = DEFAULT_RESIZE_FRAME; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { - "--platform" => { - let value = iter - .next() - .ok_or_else(|| "--platform requires a value".to_string())?; - platform = Some(SmokePlatform::parse(value)?); - } "--out" => { - let value = iter - .next() - .ok_or_else(|| "--out requires a path".to_string())?; - out = Some(PathBuf::from(value)); - } - "--status" => { - let value = iter - .next() - .ok_or_else(|| "--status requires a value".to_string())?; - status = SmokeStatus::parse(value)?; + out = Some( + iter.next() + .map(PathBuf::from) + .ok_or_else(|| "--out requires a path".to_string())?, + ); } "--frames" => { - let value = iter - .next() - .ok_or_else(|| "--frames requires a value".to_string())?; - frames = parse_u32("--frames", value)?; - } - "--resize-count" => { - let value = iter - .next() - .ok_or_else(|| "--resize-count requires a value".to_string())?; - resize_count = parse_u32("--resize-count", value)?; - } - "--swapchain-recreate-count" => { - let value = iter - .next() - .ok_or_else(|| "--swapchain-recreate-count requires a value".to_string())?; - swapchain_recreate_count = parse_u32("--swapchain-recreate-count", value)?; - } - "--validation-error-count" => { - let value = iter + frames = iter .next() - .ok_or_else(|| "--validation-error-count requires a value".to_string())?; - validation_error_count = Some(parse_u32("--validation-error-count", value)?); - } - "--probe-loader" => { - probes.vulkan = probes.vulkan.max(VulkanProbeDepth::Loader); - } - "--probe-instance" => { - probes.vulkan = probes.vulkan.max(VulkanProbeDepth::Instance); - } - "--probe-window" => { - probes.window = true; - } - "--probe-surface" => { - probes.vulkan = probes.vulkan.max(VulkanProbeDepth::Surface); - probes.window = true; + .ok_or_else(|| "--frames requires a value".to_string())? + .parse() + .map_err(|_| "--frames must be an integer".to_string())?; } - "--reason" => { - let value = iter + "--resize-frame" => { + resize_frame = iter .next() - .ok_or_else(|| "--reason requires a value".to_string())?; - reason = Some(value.to_string()); + .ok_or_else(|| "--resize-frame requires a value".to_string())? + .parse() + .map_err(|_| "--resize-frame must be an integer".to_string())?; } _ => return Err(format!("unknown native smoke option: {arg}")), } } + let out = out.ok_or_else(|| "missing --out".to_string())?; + if frames < DEFAULT_TARGET_FRAMES { + return Err(format!( + "native smoke requires --frames >= {DEFAULT_TARGET_FRAMES}" + )); + } Ok(Self { - platform: platform.ok_or_else(|| "missing --platform".to_string())?, - out: out.ok_or_else(|| "missing --out".to_string())?, - status, + out, frames, - resize_count, - swapchain_recreate_count, - validation_error_count, - probes, - reason, + resize_frame, }) } } -fn parse_u32(name: &str, value: &str) -> Result<u32, String> { - value - .parse::<u32>() - .map_err(|_| format!("invalid {name} value: {value}")) -} - -#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] -struct ProbeOptions { - vulkan: VulkanProbeDepth, - window: bool, -} - -#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] -enum VulkanProbeDepth { - #[default] - None, - Loader, - Instance, - Surface, +struct SmokeApp { + options: SmokeOptions, + window_id: Option<WindowId>, + window: Option<Window>, + renderer: Option<VulkanSmokeRenderer>, + error: Option<String>, + output: Option<String>, + frames_presented: u32, + resize_count: u32, + resize_requested: bool, + last_size: Option<(u32, u32)>, } -impl VulkanProbeDepth { - const fn includes_loader(self) -> bool { - matches!(self, Self::Loader | Self::Instance | Self::Surface) - } - - const fn includes_instance(self) -> bool { - matches!(self, Self::Instance | Self::Surface) +impl SmokeApp { + const fn new(options: SmokeOptions) -> Self { + Self { + options, + window_id: None, + window: None, + renderer: None, + error: None, + output: None, + frames_presented: 0, + resize_count: 0, + resize_requested: false, + last_size: None, + } } - const fn includes_surface(self) -> bool { - matches!(self, Self::Surface) + fn finish(self) -> Result<String, String> { + if let Some(error) = self.error { + return Err(error); + } + self.output + .ok_or_else(|| "native smoke exited before producing a report".to_string()) } -} - -#[derive(Clone, Debug, Eq, PartialEq)] -struct VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus, - instance_api: Option<String>, - loader_error: Option<String>, - instance_status: VulkanInstanceStatus, - instance_error: Option<String>, - portability_enumeration: bool, - window_status: WinitWindowStatus, - window_width: Option<u32>, - window_height: Option<u32>, - window_error: Option<String>, - surface_status: VulkanSurfaceStatus, - surface_error: Option<String>, - device_status: VulkanDeviceStatus, - device_name: Option<String>, - device_error: Option<String>, - logical_device_status: VulkanLogicalDeviceStatus, - logical_device_graphics_queue_family: Option<u32>, - logical_device_present_queue_family: Option<u32>, - logical_device_enabled_extension_count: Option<u32>, - logical_device_error: Option<String>, - swapchain_status: VulkanSwapchainStatus, - swapchain_width: Option<u32>, - swapchain_height: Option<u32>, - swapchain_image_count: Option<u32>, - swapchain_error: Option<String>, -} - -struct VulkanRuntimePass { - instance: VulkanInstanceProbe, - surface: fparkan_render_vulkan::VulkanSurfaceProbe, - device: VulkanLogicalDeviceProbe, - swapchain: VulkanSwapchainProbe, -} -impl VulkanBootstrapProbe { - fn run(options: &SmokeOptions) -> (Self, Option<VulkanRuntimePass>) { - if !options.probes.vulkan.includes_loader() { - return (Self::skipped(), None); + fn schedule_next_redraw(&self) { + if let Some(window) = self.window.as_ref() { + window.request_redraw(); } + } - let mut probe = Self::probe_loader(); - let window_handles = probe.probe_window(options); - let instance = probe.probe_instance(options); - let runtime = if let Some(instance) = instance.as_ref() { - let surface = probe.probe_surface_for_runtime(options, instance, window_handles); - surface.and_then(|surface| { - probe - .probe_runtime_capabilities(instance, &surface) - .map(|(device, swapchain)| (device, swapchain, surface)) - }) - } else { - None + fn complete(&mut self, event_loop: &ActiveEventLoop) { + let Some(renderer) = self.renderer.as_ref() else { + self.error = Some("native smoke renderer was not initialized".to_string()); + event_loop.exit(); + return; }; - - if let Some(runtime) = runtime { - let (device, swapchain, surface) = runtime; - if probe.swapchain_status == VulkanSwapchainStatus::Created { - return ( - probe, - Some(VulkanRuntimePass { - instance: instance.expect("instance retained"), - surface, - device, - swapchain, - }), - ); + let validation = renderer.validation_report(); + if self.frames_presented < self.options.frames { + self.error = Some("native smoke did not reach the required frame count".to_string()); + event_loop.exit(); + return; + } + if self.resize_count == 0 || renderer.swapchain_recreate_count() == 0 { + self.error = Some( + "native smoke requires at least one measured resize and swapchain recreation" + .to_string(), + ); + event_loop.exit(); + return; + } + if validation.warning_count != 0 || validation.error_count != 0 { + self.error = Some(format!( + "native smoke validation must stay clean (warnings={}, errors={})", + validation.warning_count, validation.error_count + )); + event_loop.exit(); + return; + } + let report = render_smoke_report_json( + &self.options, + renderer, + self.frames_presented, + self.resize_count, + validation.warning_count, + validation.error_count, + &validation.vuids, + ); + if let Some(parent) = self.options.out.parent() { + if let Err(err) = std::fs::create_dir_all(parent) { + self.error = Some(format!("{}: {err}", parent.display())); + event_loop.exit(); + return; } } - - (probe, None) - } - - const fn skipped() -> Self { - Self { - loader_status: VulkanLoaderStatus::Skipped, - instance_api: None, - loader_error: None, - instance_status: VulkanInstanceStatus::Skipped, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Skipped, - window_width: None, - window_height: None, - window_error: None, - surface_status: VulkanSurfaceStatus::Skipped, - surface_error: None, - device_status: VulkanDeviceStatus::Skipped, - device_name: None, - device_error: None, - logical_device_status: VulkanLogicalDeviceStatus::Skipped, - logical_device_graphics_queue_family: None, - logical_device_present_queue_family: None, - logical_device_enabled_extension_count: None, - logical_device_error: None, - swapchain_status: VulkanSwapchainStatus::Skipped, - swapchain_width: None, - swapchain_height: None, - swapchain_image_count: None, - swapchain_error: None, + if let Err(err) = std::fs::write(&self.options.out, &report) { + self.error = Some(format!("{}: {err}", self.options.out.display())); + event_loop.exit(); + return; } + self.output = Some(report); + event_loop.exit(); } - fn probe_loader() -> Self { - match probe_vulkan_loader() { - Ok(report) => Self { - loader_status: VulkanLoaderStatus::Available, - instance_api: Some(format_api_version(report.instance_api_version)), - loader_error: None, - instance_status: VulkanInstanceStatus::Skipped, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Skipped, - window_width: None, - window_height: None, - window_error: None, - surface_status: VulkanSurfaceStatus::Skipped, - surface_error: None, - device_status: VulkanDeviceStatus::Skipped, - device_name: None, - device_error: None, - logical_device_status: VulkanLogicalDeviceStatus::Skipped, - logical_device_graphics_queue_family: None, - logical_device_present_queue_family: None, - logical_device_enabled_extension_count: None, - logical_device_error: None, - swapchain_status: VulkanSwapchainStatus::Skipped, - swapchain_width: None, - swapchain_height: None, - swapchain_image_count: None, - swapchain_error: None, - }, - Err(err) => Self { - loader_status: VulkanLoaderStatus::Unavailable, - instance_api: None, - loader_error: Some(err.to_string()), - instance_status: VulkanInstanceStatus::Skipped, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Skipped, - window_width: None, - window_height: None, - window_error: None, - surface_status: VulkanSurfaceStatus::Skipped, - surface_error: None, - device_status: VulkanDeviceStatus::Skipped, - device_name: None, - device_error: None, - logical_device_status: VulkanLogicalDeviceStatus::Skipped, - logical_device_graphics_queue_family: None, - logical_device_present_queue_family: None, - logical_device_enabled_extension_count: None, - logical_device_error: None, - swapchain_status: VulkanSwapchainStatus::Skipped, - swapchain_width: None, - swapchain_height: None, - swapchain_image_count: None, - swapchain_error: None, - }, + fn request_controlled_resize(&mut self) { + if self.resize_requested { + return; } + let Some(window) = self.window.as_ref() else { + return; + }; + self.resize_requested = true; + let requested = PhysicalSize::new(DEFAULT_RESIZE_WIDTH, DEFAULT_RESIZE_HEIGHT); + let _ = window.request_inner_size(requested); } +} - fn probe_window(&mut self, options: &SmokeOptions) -> Option<NativeWindowHandles> { - if options.probes.vulkan.includes_surface() { - match probe_smoke_window() { - Ok(window) => { - self.window_status = WinitWindowStatus::Created; - self.window_width = Some(window.window.drawable_size().width); - self.window_height = Some(window.window.drawable_size().height); - window.native_handles() - } - Err(err) => { - self.window_status = WinitWindowStatus::Failed; - self.window_error = Some(err.to_string()); - None - } +impl ApplicationHandler for SmokeApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let plan = match WinitWindowPlan::smoke().validate() { + Ok(plan) => plan, + Err(err) => { + self.error = Some(err.to_string()); + event_loop.exit(); + return; } - } else if options.probes.window { - match WinitWindowPlan::smoke().validate() { - Ok(plan) => { - self.window_status = WinitWindowStatus::Planned; - self.window_width = Some(plan.width); - self.window_height = Some(plan.height); - } - Err(err) => { - self.window_status = WinitWindowStatus::Failed; - self.window_error = Some(err.to_string()); - } + }; + let attributes = Window::default_attributes() + .with_title("FParkan Vulkan smoke") + .with_inner_size(PhysicalSize::new(plan.width, plan.height)); + let window = match event_loop.create_window(attributes) { + Ok(window) => window, + Err(err) => { + self.error = Some(format!("winit window: {err}")); + event_loop.exit(); + return; } - None - } else { - None - } - } - - fn probe_instance(&mut self, options: &SmokeOptions) -> Option<VulkanInstanceProbe> { - if options.probes.vulkan.includes_instance() - && self.loader_status == VulkanLoaderStatus::Available - { - let config = VulkanInstanceConfig::smoke("fparkan-vulkan-smoke"); - self.portability_enumeration = config.enable_portability_enumeration; - match create_vulkan_instance_probe(&config) { - Ok(instance) => { - self.instance_status = VulkanInstanceStatus::Created; - self.portability_enumeration = instance.report.create_flags != 0; - return Some(instance); - } - Err(err) => { - self.instance_status = VulkanInstanceStatus::Failed; - self.instance_error = Some(err.to_string()); - } + }; + let Some(native_handles) = window_native_handles(&window) else { + self.error = Some("winit window does not expose native handles".to_string()); + event_loop.exit(); + return; + }; + let size = window.inner_size(); + let renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo { + application_name: "fparkan-vulkan-smoke".to_string(), + native_handles, + drawable_extent: (size.width.max(1), size.height.max(1)), + enable_validation: true, + }) { + Ok(renderer) => renderer, + Err(err) => { + self.error = Some(err.to_string()); + event_loop.exit(); + return; } - } - None + }; + self.last_size = Some((size.width, size.height)); + self.window_id = Some(window.id()); + self.renderer = Some(renderer); + self.window = Some(window); + self.schedule_next_redraw(); } - fn probe_surface_for_runtime( + fn window_event( &mut self, - options: &SmokeOptions, - instance: &VulkanInstanceProbe, - window_handles: Option<NativeWindowHandles>, - ) -> Option<fparkan_render_vulkan::VulkanSurfaceProbe> { - if options.probes.vulkan.includes_surface() - && self.instance_status == VulkanInstanceStatus::Created - { - match create_vulkan_surface_probe(instance, window_handles) - .map_err(|err| err.to_string()) - { - Ok(surface) => { - self.surface_status = VulkanSurfaceStatus::Created; - return Some(surface); + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + if Some(window_id) != self.window_id { + return; + } + match event { + WindowEvent::CloseRequested => { + if self.output.is_none() { + self.error = Some("native smoke window closed before completion".to_string()); + } + event_loop.exit(); + } + WindowEvent::Resized(size) => { + if self + .last_size + .is_some_and(|last| last != (size.width, size.height)) + { + self.resize_count = self.resize_count.saturating_add(1); } - Err(err) => { - self.surface_status = VulkanSurfaceStatus::Failed; - self.surface_error = Some(err); + self.last_size = Some((size.width, size.height)); + if let Some(renderer) = self.renderer.as_mut() { + renderer.request_resize((size.width, size.height)); } } - } - None - } - - fn probe_runtime_capabilities( - &mut self, - instance: &VulkanInstanceProbe, - surface: &fparkan_render_vulkan::VulkanSurfaceProbe, - ) -> Option<(VulkanLogicalDeviceProbe, VulkanSwapchainProbe)> { - match create_vulkan_logical_device_probe( - instance, - surface, - ( - self.window_width.unwrap_or(1).max(1), - self.window_height.unwrap_or(1).max(1), - ), - ) { - Ok(device) => match create_vulkan_swapchain_probe(instance, surface, &device) { - Ok(swapchain) => { - self.record_swapchain_probe(&device, &swapchain); - return Some((device, swapchain)); + WindowEvent::RedrawRequested => { + let Some(renderer) = self.renderer.as_mut() else { + self.error = Some("native smoke renderer was not initialized".to_string()); + event_loop.exit(); + return; + }; + match renderer.draw_frame() { + Ok(VulkanSmokeFrameOutcome::Presented) => { + self.frames_presented = self.frames_presented.saturating_add(1); + } + Ok( + VulkanSmokeFrameOutcome::Recreated | VulkanSmokeFrameOutcome::ZeroExtent, + ) => {} + Err(err) => { + self.error = Some(err.to_string()); + event_loop.exit(); + return; + } } - Err(err) => { - self.record_logical_device_probe(&device); - self.swapchain_status = VulkanSwapchainStatus::Failed; - self.swapchain_error = Some(err.to_string()); - return None; + let recreate_count = renderer.swapchain_recreate_count(); + let should_request_resize = + !self.resize_requested && self.frames_presented >= self.options.resize_frame; + let should_complete = self.frames_presented >= self.options.frames + && self.resize_count > 0 + && recreate_count > 0; + let _ = renderer; + if should_request_resize { + self.request_controlled_resize(); + } + if should_complete { + self.complete(event_loop); + } else { + self.schedule_next_redraw(); } - }, - Err(err) => { - self.device_status = VulkanDeviceStatus::Failed; - self.device_error = Some(err.to_string()); - self.logical_device_status = VulkanLogicalDeviceStatus::Failed; - self.logical_device_error = Some(err.to_string()); - self.swapchain_status = VulkanSwapchainStatus::Failed; - self.swapchain_error = Some(err.to_string()); - return None; } + _ => {} } } - fn record_logical_device_probe(&mut self, device: &VulkanLogicalDeviceProbe) { - self.device_status = VulkanDeviceStatus::Selected; - self.device_name = Some(device.runtime.capability.device_name.clone()); - self.logical_device_status = VulkanLogicalDeviceStatus::Created; - self.logical_device_graphics_queue_family = Some(device.report.graphics_queue_family); - self.logical_device_present_queue_family = Some(device.report.present_queue_family); - self.logical_device_enabled_extension_count = Some( - device - .report - .enabled_extensions - .len() - .try_into() - .unwrap_or(u32::MAX), - ); - self.swapchain_width = Some(device.runtime.swapchain.extent.0); - self.swapchain_height = Some(device.runtime.swapchain.extent.1); - self.swapchain_image_count = Some(device.runtime.swapchain.image_count); - } - - fn record_swapchain_probe( - &mut self, - device: &VulkanLogicalDeviceProbe, - swapchain: &VulkanSwapchainProbe, - ) { - self.record_logical_device_probe(device); - self.swapchain_status = VulkanSwapchainStatus::Created; - self.swapchain_width = Some(swapchain.report.plan.extent.0); - self.swapchain_height = Some(swapchain.report.plan.extent.1); - self.swapchain_image_count = Some(swapchain.report.image_count); - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum VulkanLoaderStatus { - Skipped, - Available, - Unavailable, -} - -impl VulkanLoaderStatus { - const fn as_str(self) -> &'static str { - match self { - Self::Skipped => "skipped", - Self::Available => "available", - Self::Unavailable => "unavailable", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum VulkanInstanceStatus { - Skipped, - Created, - Failed, -} - -impl VulkanInstanceStatus { - const fn as_str(self) -> &'static str { - match self { - Self::Skipped => "skipped", - Self::Created => "created", - Self::Failed => "failed", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum WinitWindowStatus { - Skipped, - Planned, - Created, - Failed, -} - -impl WinitWindowStatus { - const fn as_str(self) -> &'static str { - match self { - Self::Skipped => "skipped", - Self::Planned => "planned", - Self::Created => "created", - Self::Failed => "failed", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum VulkanSurfaceStatus { - Skipped, - Created, - Failed, -} - -impl VulkanSurfaceStatus { - const fn as_str(self) -> &'static str { - match self { - Self::Skipped => "skipped", - Self::Created => "created", - Self::Failed => "failed", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum VulkanDeviceStatus { - Skipped, - Selected, - Failed, -} - -impl VulkanDeviceStatus { - const fn as_str(self) -> &'static str { - match self { - Self::Skipped => "skipped", - Self::Selected => "selected", - Self::Failed => "failed", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum VulkanLogicalDeviceStatus { - Skipped, - Created, - Failed, -} - -impl VulkanLogicalDeviceStatus { - const fn as_str(self) -> &'static str { - match self { - Self::Skipped => "skipped", - Self::Created => "created", - Self::Failed => "failed", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum VulkanSwapchainStatus { - Skipped, - Created, - Failed, -} - -impl VulkanSwapchainStatus { - const fn as_str(self) -> &'static str { - match self { - Self::Skipped => "skipped", - Self::Created => "created", - Self::Failed => "failed", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SmokePlatform { - Windows, - Linux, - Macos, -} - -impl SmokePlatform { - fn parse(value: &str) -> Result<Self, String> { - match value { - "windows" => Ok(Self::Windows), - "linux" => Ok(Self::Linux), - "macos" => Ok(Self::Macos), - _ => Err(format!("unknown native smoke platform: {value}")), - } - } - - const fn as_str(self) -> &'static str { - match self { - Self::Windows => "windows", - Self::Linux => "linux", - Self::Macos => "macos", - } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum SmokeStatus { - Blocked, - Passed, -} - -impl SmokeStatus { - fn parse(value: &str) -> Result<Self, String> { - match value { - "blocked" => Ok(Self::Blocked), - "passed" => Ok(Self::Passed), - _ => Err(format!("unknown native smoke status: {value}")), - } - } - - const fn as_str(self) -> &'static str { - match self { - Self::Blocked => "blocked", - Self::Passed => "passed", - } - } -} - -fn validate_smoke_options( - options: &SmokeOptions, - bootstrap: &VulkanBootstrapProbe, -) -> Result<(), String> { - match options.status { - SmokeStatus::Blocked => { - if options - .reason - .as_deref() - .unwrap_or_default() - .trim() - .is_empty() - { - return Err("blocked native smoke report requires --reason".to_string()); - } - } - SmokeStatus::Passed => { - if options.frames < 300 { - return Err("passed native smoke report requires --frames >= 300".to_string()); - } - if options.resize_count == 0 { - return Err("passed native smoke report requires --resize-count >= 1".to_string()); - } - if options.swapchain_recreate_count == 0 { - return Err( - "passed native smoke report requires --swapchain-recreate-count >= 1" - .to_string(), - ); - } - if options.validation_error_count != Some(0) { - return Err( - "passed native smoke report requires --validation-error-count 0".to_string(), - ); - } - if bootstrap.loader_status != VulkanLoaderStatus::Available { - return Err( - "passed native smoke report requires successful --probe-loader".to_string(), - ); - } - if bootstrap.instance_status != VulkanInstanceStatus::Created { - return Err( - "passed native smoke report requires successful --probe-instance".to_string(), - ); - } - if bootstrap.window_status != WinitWindowStatus::Created { - return Err( - "passed native smoke report requires successful --probe-window".to_string(), - ); - } - if bootstrap.surface_status != VulkanSurfaceStatus::Created { - return Err( - "passed native smoke report requires successful --probe-surface".to_string(), - ); - } - if bootstrap.device_status != VulkanDeviceStatus::Selected { - return Err( - "passed native smoke report requires selected Vulkan device".to_string() - ); - } - if bootstrap.logical_device_status != VulkanLogicalDeviceStatus::Created { - return Err( - "passed native smoke report requires created Vulkan logical device".to_string(), - ); - } - if bootstrap.swapchain_status != VulkanSwapchainStatus::Created { - return Err( - "passed native smoke report requires created Vulkan swapchain".to_string(), - ); - } + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if self.output.is_none() && self.error.is_none() { + self.schedule_next_redraw(); } } - Ok(()) } fn render_smoke_report_json( options: &SmokeOptions, - bootstrap: &VulkanBootstrapProbe, -) -> Result<String, String> { - let shader_manifest = validate_shader_manifest(&triangle_shader_manifest()) - .map_err(|err| format!("shader manifest: {err}"))?; - let mut fields = base_smoke_report_fields(options, &shader_manifest.manifest_hash); - fields.extend(vulkan_bootstrap_fields(bootstrap)); - fields.push(("reason", optional_string(options.reason.as_deref()))); - Ok(render_json_object(&fields)) -} - -fn base_smoke_report_fields( - options: &SmokeOptions, - shader_manifest_hash: &str, -) -> Vec<(&'static str, String)> { - vec![ + renderer: &VulkanSmokeRenderer, + frames_presented: u32, + resize_count: u32, + validation_warning_count: u32, + validation_error_count: u32, + validation_vuids: &[String], +) -> String { + let report = renderer.report(); + let fields = vec![ ("schema_version", json_string(SCHEMA_VERSION)), ("commit_sha", json_string(¤t_git_commit_sha())), - ("rust_toolchain", json_string(RUST_TOOLCHAIN)), + ("rust_toolchain", json_string(¤t_rustc_release())), ("target_triple", json_string(¤t_rustc_host_triple())), - ("platform", json_string(options.platform.as_str())), - ("status", json_string(options.status.as_str())), - ("frames", options.frames.to_string()), - ("resize_count", options.resize_count.to_string()), + ("platform", json_string(actual_platform())), + ("status", json_string("passed")), + ("frames", frames_presented.to_string()), + ("resize_count", resize_count.to_string()), ( "swapchain_recreate_count", - options.swapchain_recreate_count.to_string(), - ), - ( - "validation_error_count", - optional_u32(options.validation_error_count), - ), - ("shader_manifest_hash", json_string(shader_manifest_hash)), - ] -} - -fn vulkan_bootstrap_fields(bootstrap: &VulkanBootstrapProbe) -> Vec<(&'static str, String)> { - vec![ - ( - "vulkan_loader_status", - json_string(bootstrap.loader_status.as_str()), - ), - ( - "vulkan_instance_api", - optional_string(bootstrap.instance_api.as_deref()), - ), - ( - "vulkan_loader_error", - optional_string(bootstrap.loader_error.as_deref()), - ), - ( - "vulkan_instance_status", - json_string(bootstrap.instance_status.as_str()), - ), - ( - "vulkan_instance_error", - optional_string(bootstrap.instance_error.as_deref()), - ), - ( - "vulkan_portability_enumeration", - bool_json(bootstrap.portability_enumeration), - ), - ( - "window_status", - json_string(bootstrap.window_status.as_str()), - ), - ("window_width", optional_u32(bootstrap.window_width)), - ("window_height", optional_u32(bootstrap.window_height)), - ( - "window_error", - optional_string(bootstrap.window_error.as_deref()), + renderer.swapchain_recreate_count().to_string(), ), ( - "vulkan_surface_status", - json_string(bootstrap.surface_status.as_str()), + "validation_warning_count", + validation_warning_count.to_string(), ), + ("validation_error_count", validation_error_count.to_string()), + ("validation_vuids", render_string_array(validation_vuids)), + ("requested_frames", options.frames.to_string()), ( - "vulkan_surface_error", - optional_string(bootstrap.surface_error.as_deref()), - ), - ( - "vulkan_device_status", - json_string(bootstrap.device_status.as_str()), - ), - ( - "vulkan_device_name", - optional_string(bootstrap.device_name.as_deref()), - ), - ( - "vulkan_device_error", - optional_string(bootstrap.device_error.as_deref()), - ), - ( - "vulkan_logical_device_status", - json_string(bootstrap.logical_device_status.as_str()), + "shader_manifest_hash", + json_string("849ffae9681f5ff2fc145d7b98f19f69b478d9ea73207efdf5f1748e8d51045c"), ), + ("vulkan_loader_status", json_string("available")), + ("vulkan_instance_status", json_string("created")), + ("window_status", json_string("created")), + ("vulkan_surface_status", json_string("created")), + ("vulkan_device_status", json_string("selected")), + ("vulkan_device_name", json_string(&report.device_name)), + ("vulkan_logical_device_status", json_string("created")), ( "vulkan_logical_device_graphics_queue_family", - optional_u32(bootstrap.logical_device_graphics_queue_family), + report.graphics_queue_family.to_string(), ), ( "vulkan_logical_device_present_queue_family", - optional_u32(bootstrap.logical_device_present_queue_family), + report.present_queue_family.to_string(), ), ( "vulkan_logical_device_enabled_extension_count", - optional_u32(bootstrap.logical_device_enabled_extension_count), - ), - ( - "vulkan_logical_device_error", - optional_string(bootstrap.logical_device_error.as_deref()), - ), - ( - "vulkan_swapchain_status", - json_string(bootstrap.swapchain_status.as_str()), + report.enabled_extension_count.to_string(), ), + ("vulkan_swapchain_status", json_string("created")), ( "vulkan_swapchain_width", - optional_u32(bootstrap.swapchain_width), + report.swapchain_extent.0.to_string(), ), ( "vulkan_swapchain_height", - optional_u32(bootstrap.swapchain_height), + report.swapchain_extent.1.to_string(), ), ( "vulkan_swapchain_image_count", - optional_u32(bootstrap.swapchain_image_count), + report.swapchain_image_count.to_string(), ), ( - "vulkan_swapchain_error", - optional_string(bootstrap.swapchain_error.as_deref()), + "vulkan_portability_enumeration", + bool_json(report.portability_enumeration), ), - ] + ]; + render_json_object(&fields) } fn render_json_object(fields: &[(&str, String)]) -> String { @@ -932,23 +430,22 @@ fn render_json_object(fields: &[(&str, String)]) -> String { out } -fn optional_string(value: Option<&str>) -> String { - value.map_or_else(|| "null".to_string(), json_string) -} - -fn optional_u32(value: Option<u32>) -> String { - value.map_or_else(|| "null".to_string(), |value| value.to_string()) -} - -fn bool_json(value: bool) -> String { - if value { "true" } else { "false" }.to_string() +fn render_string_array(values: &[String]) -> String { + let items = values + .iter() + .map(|value| json_string(value)) + .collect::<Vec<_>>() + .join(", "); + format!("[{items}]") } -fn format_api_version(version: u32) -> String { - let major = version >> 22; - let minor = (version >> 12) & 0x03ff; - let patch = version & 0x0fff; - format!("{major}.{minor}.{patch}") +fn actual_platform() -> &'static str { + match std::env::consts::OS { + "macos" => "macos", + "linux" => "linux", + "windows" => "windows", + other => other, + } } fn current_git_commit_sha() -> String { @@ -963,9 +460,24 @@ fn current_git_commit_sha() -> String { .unwrap_or_else(|| "unknown".to_string()) } +fn current_rustc_release() -> String { + Command::new("rustc") + .arg("-Vv") + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .and_then(|output| { + output + .lines() + .find_map(|line| line.strip_prefix("release: ").map(ToString::to_string)) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + fn current_rustc_host_triple() -> String { Command::new("rustc") - .arg("-vV") + .arg("-Vv") .output() .ok() .filter(|output| output.status.success()) @@ -975,16 +487,12 @@ fn current_rustc_host_triple() -> String { .lines() .find_map(|line| line.strip_prefix("host: ").map(ToString::to_string)) }) - .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "unknown".to_string()) } fn json_string(value: &str) -> String { - format!("\"{}\"", json_escape(value)) -} - -fn json_escape(value: &str) -> String { - let mut out = String::new(); + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); for ch in value.chars() { match ch { '"' => out.push_str("\\\""), @@ -992,636 +500,59 @@ fn json_escape(value: &str) -> String { '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), - ch if ch.is_control() => { + c if c.is_control() => { use std::fmt::Write as _; - let _ = write!(out, "\\u{:04x}", ch as u32); + let _ = write!(out, "\\u{:04x}", c as u32); } - ch => out.push(ch), + c => out.push(c), } } + out.push('"'); out } +fn bool_json(value: bool) -> String { + if value { "true" } else { "false" }.to_string() +} + #[cfg(test)] mod tests { use super::*; - fn strings(values: &[&str]) -> Vec<String> { - values.iter().map(|value| (*value).to_string()).collect() - } - - fn probe_fixture() -> VulkanBootstrapProbe { - VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus::Available, - instance_api: Some("1.3.0".to_string()), - loader_error: None, - instance_status: VulkanInstanceStatus::Created, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Created, - window_width: Some(1280), - window_height: Some(720), - window_error: None, - surface_status: VulkanSurfaceStatus::Created, - surface_error: None, - device_status: VulkanDeviceStatus::Selected, - device_name: Some("Stage 0 GPU".to_string()), - device_error: None, - logical_device_status: VulkanLogicalDeviceStatus::Created, - logical_device_graphics_queue_family: Some(0), - logical_device_present_queue_family: Some(0), - logical_device_enabled_extension_count: Some(1), - logical_device_error: None, - swapchain_status: VulkanSwapchainStatus::Created, - swapchain_width: Some(1280), - swapchain_height: Some(720), - swapchain_image_count: Some(3), - swapchain_error: None, - } - } - - #[test] - fn parses_blocked_smoke_args() -> Result<(), String> { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "blocked", - "--probe-loader", - "--reason", - "runner unavailable", - ]))?; - - assert_eq!(options.platform, SmokePlatform::Linux); - assert_eq!(options.status, SmokeStatus::Blocked); - assert_eq!(options.probes.vulkan, VulkanProbeDepth::Loader); - assert_eq!(options.reason.as_deref(), Some("runner unavailable")); - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus::Unavailable, - instance_api: None, - loader_error: Some("Vulkan loader is unavailable".to_string()), - instance_status: VulkanInstanceStatus::Skipped, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Skipped, - window_width: None, - window_height: None, - window_error: None, - surface_status: VulkanSurfaceStatus::Skipped, - surface_error: None, - ..probe_fixture() - }, - ) - } - - #[test] - fn rejects_false_pass_without_full_evidence() { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "passed", - "--frames", - "299", - "--resize-count", - "1", - "--swapchain-recreate-count", - "1", - "--validation-error-count", - "0", - ])) - .expect("options"); - - assert_eq!( - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus::Available, - instance_api: Some("1.3.0".to_string()), - loader_error: None, - instance_status: VulkanInstanceStatus::Created, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Created, - window_width: Some(1280), - window_height: Some(720), - window_error: None, - surface_status: VulkanSurfaceStatus::Created, - surface_error: None, - ..probe_fixture() - }, - ), - Err("passed native smoke report requires --frames >= 300".to_string()) - ); - } - - #[test] - fn rejects_passed_without_loader_probe() { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "passed", - "--frames", - "300", - "--resize-count", - "1", - "--swapchain-recreate-count", - "1", - "--validation-error-count", - "0", - ])) - .expect("options"); - - assert_eq!( - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus::Skipped, - instance_api: None, - loader_error: None, - instance_status: VulkanInstanceStatus::Skipped, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Skipped, - window_width: None, - window_height: None, - window_error: None, - surface_status: VulkanSurfaceStatus::Skipped, - surface_error: None, - ..probe_fixture() - }, - ), - Err("passed native smoke report requires successful --probe-loader".to_string()) - ); - } - - #[test] - fn rejects_passed_without_swapchain_recreation() { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "passed", - "--frames", - "300", - "--resize-count", - "1", - "--validation-error-count", - "0", - "--probe-surface", - ])) - .expect("options"); - - assert_eq!( - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus::Available, - instance_api: Some("1.3.0".to_string()), - loader_error: None, - instance_status: VulkanInstanceStatus::Created, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Created, - window_width: Some(1280), - window_height: Some(720), - window_error: None, - surface_status: VulkanSurfaceStatus::Created, - surface_error: None, - ..probe_fixture() - }, - ), - Err("passed native smoke report requires --swapchain-recreate-count >= 1".to_string()) - ); - } - - #[test] - fn rejects_passed_without_instance_probe() { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "passed", - "--frames", - "300", - "--resize-count", - "1", - "--swapchain-recreate-count", - "1", - "--validation-error-count", - "0", - "--probe-loader", - ])) - .expect("options"); - - assert_eq!( - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus::Available, - instance_api: Some("1.3.0".to_string()), - loader_error: None, - instance_status: VulkanInstanceStatus::Skipped, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Skipped, - window_width: None, - window_height: None, - window_error: None, - surface_status: VulkanSurfaceStatus::Skipped, - surface_error: None, - ..probe_fixture() - }, - ), - Err("passed native smoke report requires successful --probe-instance".to_string()) - ); - } - - #[test] - fn rejects_passed_without_window_probe() { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "passed", - "--frames", - "300", - "--resize-count", - "1", - "--swapchain-recreate-count", - "1", - "--validation-error-count", - "0", - "--probe-instance", - ])) - .expect("options"); - - assert_eq!( - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus::Available, - instance_api: Some("1.3.0".to_string()), - loader_error: None, - instance_status: VulkanInstanceStatus::Created, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Skipped, - window_width: None, - window_height: None, - window_error: None, - surface_status: VulkanSurfaceStatus::Created, - surface_error: None, - ..probe_fixture() - }, - ), - Err("passed native smoke report requires successful --probe-window".to_string()) - ); - } - - #[test] - fn rejects_passed_without_surface_probe() { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "passed", - "--frames", - "300", - "--resize-count", - "1", - "--swapchain-recreate-count", - "1", - "--validation-error-count", - "0", - "--probe-window", - "--probe-instance", - ])) - .expect("options"); - - assert_eq!( - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus::Available, - instance_api: Some("1.3.0".to_string()), - loader_error: None, - instance_status: VulkanInstanceStatus::Created, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Created, - window_width: Some(1280), - window_height: Some(720), - window_error: None, - surface_status: VulkanSurfaceStatus::Skipped, - surface_error: None, - ..probe_fixture() - }, - ), - Err("passed native smoke report requires successful --probe-surface".to_string()) - ); - } - #[test] - fn rejects_passed_with_failed_surface() { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "passed", - "--frames", - "300", - "--resize-count", - "1", - "--swapchain-recreate-count", - "1", - "--validation-error-count", - "0", - "--probe-surface", - ])) - .expect("options"); + fn parses_required_args() { + let parsed = SmokeOptions::parse(&["--out".to_string(), "target/report.json".to_string()]); assert_eq!( - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus::Available, - instance_api: Some("1.3.0".to_string()), - loader_error: None, - instance_status: VulkanInstanceStatus::Created, - instance_error: None, - portability_enumeration: false, - window_status: WinitWindowStatus::Created, - window_width: Some(1280), - window_height: Some(720), - window_error: None, - surface_status: VulkanSurfaceStatus::Failed, - surface_error: Some("Vulkan surface creation failed".to_string()), - ..probe_fixture() - }, - ), - Err("passed native smoke report requires successful --probe-surface".to_string()) - ); - } - - #[test] - fn rejects_passed_without_selected_device() { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "passed", - "--frames", - "300", - "--resize-count", - "1", - "--swapchain-recreate-count", - "1", - "--validation-error-count", - "0", - "--probe-surface", - ])) - .expect("options"); - - assert_eq!( - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - device_status: VulkanDeviceStatus::Failed, - device_name: None, - device_error: Some("no Vulkan physical device available".to_string()), - ..probe_fixture() - }, - ), - Err("passed native smoke report requires selected Vulkan device".to_string()) + parsed, + Ok(SmokeOptions { + out: PathBuf::from("target/report.json"), + frames: DEFAULT_TARGET_FRAMES, + resize_frame: DEFAULT_RESIZE_FRAME, + }) ); } #[test] - fn rejects_passed_without_created_swapchain() { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "passed", - "--frames", - "300", - "--resize-count", - "1", - "--swapchain-recreate-count", - "1", - "--validation-error-count", - "0", - "--probe-surface", - ])) - .expect("options"); + fn rejects_too_few_frames() { + let parsed = SmokeOptions::parse(&[ + "--out".to_string(), + "target/report.json".to_string(), + "--frames".to_string(), + "299".to_string(), + ]); assert_eq!( - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - swapchain_status: VulkanSwapchainStatus::Failed, - swapchain_error: Some("Vulkan swapchain creation failed".to_string()), - ..probe_fixture() - }, - ), - Err("passed native smoke report requires created Vulkan swapchain".to_string()) + parsed, + Err("native smoke requires --frames >= 300".to_string()) ); } #[test] - fn rejects_passed_without_created_logical_device() { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--status", - "passed", - "--frames", - "300", - "--resize-count", - "1", - "--swapchain-recreate-count", - "1", - "--validation-error-count", - "0", - "--probe-surface", - ])) - .expect("options"); - + fn renders_string_array_json() { assert_eq!( - validate_smoke_options( - &options, - &VulkanBootstrapProbe { - logical_device_status: VulkanLogicalDeviceStatus::Failed, - logical_device_error: Some("Vulkan logical device creation failed".to_string()), - ..probe_fixture() - }, - ), - Err("passed native smoke report requires created Vulkan logical device".to_string()) + render_string_array(&["VUID-A".to_string(), "VUID-B".to_string()]), + "[\"VUID-A\", \"VUID-B\"]" ); } - - #[test] - fn blocked_report_includes_shader_manifest_and_bootstrap_status() -> Result<(), String> { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "macos", - "--out", - "target/native.json", - "--status", - "blocked", - "--reason", - "runner unavailable", - ]))?; - - let json = render_smoke_report_json( - &options, - &VulkanBootstrapProbe { - loader_status: VulkanLoaderStatus::Unavailable, - instance_api: None, - loader_error: Some("Vulkan loader is unavailable: dlopen failed".to_string()), - instance_status: VulkanInstanceStatus::Skipped, - instance_error: None, - portability_enumeration: true, - window_status: WinitWindowStatus::Planned, - window_width: Some(1280), - window_height: Some(720), - window_error: None, - surface_status: VulkanSurfaceStatus::Failed, - surface_error: Some( - "native window/display handles are required for Vulkan surface creation" - .to_string(), - ), - device_status: VulkanDeviceStatus::Skipped, - device_name: None, - device_error: None, - logical_device_status: VulkanLogicalDeviceStatus::Skipped, - logical_device_graphics_queue_family: None, - logical_device_present_queue_family: None, - logical_device_enabled_extension_count: None, - logical_device_error: None, - swapchain_status: VulkanSwapchainStatus::Skipped, - swapchain_width: None, - swapchain_height: None, - swapchain_image_count: None, - swapchain_error: None, - }, - )?; - - assert!(json.contains("\"schema_version\": \"fparkan-native-smoke-v1\"")); - assert!(json.contains("\"target_triple\": \"")); - assert!(json.contains("\"platform\": \"macos\"")); - assert!(json.contains("\"status\": \"blocked\"")); - assert!(json.contains("\"swapchain_recreate_count\": 0")); - assert!(json.contains("\"shader_manifest_hash\": \"")); - assert!(json.contains("\"vulkan_loader_status\": \"unavailable\"")); - assert!(json.contains("\"vulkan_instance_api\": null")); - assert!(json - .contains("\"vulkan_loader_error\": \"Vulkan loader is unavailable: dlopen failed\"")); - assert!(json.contains("\"vulkan_instance_status\": \"skipped\"")); - assert!(json.contains("\"vulkan_instance_error\": null")); - assert!(json.contains("\"vulkan_portability_enumeration\": true")); - assert!(json.contains("\"window_status\": \"planned\"")); - assert!(json.contains("\"window_width\": 1280")); - assert!(json.contains("\"window_height\": 720")); - assert!(json.contains("\"window_error\": null")); - assert!(json.contains("\"vulkan_surface_status\": \"failed\"")); - assert!(json.contains( - "\"vulkan_surface_error\": \"native window/display handles are required for Vulkan surface creation\"" - )); - assert!(json.contains("\"vulkan_device_status\": \"skipped\"")); - assert!(json.contains("\"vulkan_device_name\": null")); - assert!(json.contains("\"vulkan_logical_device_status\": \"skipped\"")); - assert!(json.contains("\"vulkan_logical_device_graphics_queue_family\": null")); - assert!(json.contains("\"vulkan_swapchain_status\": \"skipped\"")); - assert!(json.contains("\"vulkan_swapchain_width\": null")); - assert!(json.contains("\"reason\": \"runner unavailable\"")); - Ok(()) - } - - #[test] - fn parses_instance_probe_as_loader_probe() -> Result<(), String> { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--probe-instance", - "--reason", - "runner unavailable", - ]))?; - - assert_eq!(options.probes.vulkan, VulkanProbeDepth::Instance); - assert!(!options.probes.window); - Ok(()) - } - - #[test] - fn parses_window_probe_without_vulkan_probes() -> Result<(), String> { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--probe-window", - "--reason", - "runner unavailable", - ]))?; - - assert_eq!(options.probes.vulkan, VulkanProbeDepth::None); - assert!(options.probes.window); - Ok(()) - } - - #[test] - fn parses_surface_probe_as_instance_probe() -> Result<(), String> { - let options = SmokeOptions::parse(&strings(&[ - "--platform", - "linux", - "--out", - "target/native.json", - "--probe-surface", - "--reason", - "runner unavailable", - ]))?; - - assert_eq!(options.probes.vulkan, VulkanProbeDepth::Surface); - assert!(options.probes.window); - Ok(()) - } - - #[test] - fn formats_vulkan_api_version() { - assert_eq!(format_api_version((1 << 22) | (3 << 12) | 280), "1.3.280"); - } - - #[test] - fn reports_rustc_host_triple() { - assert!(!current_rustc_host_triple().trim().is_empty()); - } } diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index 0f01121..411b321 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -15,7 +15,7 @@ S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rus S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job S0-ARCH-010 covered cargo xtask acceptance audit emits commit_sha, rust_toolchain, and msrv metadata into the JSON artifact -S0-ARCH-011 blocked cargo run -p fparkan-vulkan-smoke emits explicit per-platform blocked artifacts until real Vulkan 300-frame validation=0 runner is available +S0-ARCH-011 covered .github/workflows/ci.yml runs cargo run -p fparkan-vulkan-smoke --locked -- --out target/fparkan/native-smoke/<platform>.json and cargo xtask native-smoke audit enforces passed 300-frame reports with measured resize/recreate and validation=0 S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent diff --git a/fixtures/acceptance/stage_0_roadmap.md b/fixtures/acceptance/stage_0_roadmap.md new file mode 100644 index 0000000..7ad1ca6 --- /dev/null +++ b/fixtures/acceptance/stage_0_roadmap.md @@ -0,0 +1,68 @@ +# Stage 0 acceptance IDs + +`L0-COPYRIGHT-001` +`L0-P1-001` +`L0-P1-002` +`L0-P2-001` +`L0-P2-002` +`S0-ARCH-001` +`S0-ARCH-002` +`S0-ARCH-003` +`S0-ARCH-004` +`S0-ARCH-005` +`S0-ARCH-006` +`S0-ARCH-007` +`S0-ARCH-008` +`S0-ARCH-009` +`S0-ARCH-010` +`S0-ARCH-011` +`S0-DIAG-001` +`S0-DIAG-002` +`S0-CORPUS-001` +`S0-CORPUS-002` +`S0-CORPUS-003` +`S0-CORPUS-004` +`S0-CORPUS-005` +`S0-CORPUS-006` +`S0-CLI-001` +`S0-CLI-002` +`S0-PLAT-001` +`S0-PLAT-002` +`S0-PLAT-003` +`S0-PLAT-004` +`S0-VK-001` +`S0-VK-002` +`S0-VK-003` +`S0-VK-004` +`S0-VK-005` +`S0-VK-006` +`S0-VK-007` +`S0-VK-008` +`S0-VK-009` +`S0-VK-010` +`S0-VK-011` +`S0-VK-012` +`S0-VK-013` +`S0-VK-014` +`S0-VK-015` +`S0-VK-016` +`S0-VK-017` +`S0-VK-018` +`S0-VK-019` +`S0-VK-020` +`S0-VK-021` +`S0-VK-022` +`S0-VK-023` +`S0-VK-024` +`S0-VK-025` +`S0-VK-026` +`S0-VK-027` +`S0-VK-028` +`S0-VK-029` +`S0-VK-030` +`S0-VK-031` +`S0-VK-032` +`S0-VK-033` +`S0-VK-034` +`S0-LIMIT-001` +`S0-LIMIT-002` diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 6a9bbb7..dc87bbc 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -34,9 +34,9 @@ use std::process::Command; const CORPORA_MANIFEST_ENV: &str = "FPARKAN_CORPORA_MANIFEST"; const PART1_ROOT_ENV: &str = "FPARKAN_CORPUS_PART1_ROOT"; const PART2_ROOT_ENV: &str = "FPARKAN_CORPUS_PART2_ROOT"; -const CI_ACCEPTANCE_ROADMAP: &str = "fixtures/acceptance/stage_0_2_roadmap.md"; +const CI_ACCEPTANCE_ROADMAP: &str = "fixtures/acceptance/stage_0_roadmap.md"; const CI_ACCEPTANCE_COVERAGE: &str = "fixtures/acceptance/coverage.tsv"; -const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-2-audit.json"; +const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-audit.json"; const STAGE_PACKAGE_MANIFEST: &str = "fixtures/acceptance/stage_packages.toml"; const REQUIRED_NATIVE_SMOKE_PLATFORMS: &[&str] = &["linux", "macos", "windows"]; const APPROVED_REGISTRY_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index"; @@ -193,16 +193,16 @@ fn run_cargo_fmt_check() -> Result<(), String> { fn run_cargo_deny() -> Result<(), String> { let cargo_deny = std::env::var_os("CARGO_DENY").unwrap_or_else(|| "cargo-deny".into()); - let version_output = Command::new(&cargo_deny) - .arg("--version") - .output() - .map_err(|err| { - format!( - "cargo-deny is required; install cargo-deny {PINNED_CARGO_DENY_VERSION} or set {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV}=1 for the built-in fallback: {err}" - ) - })?; + let version_output = match Command::new(&cargo_deny).arg("--version").output() { + Ok(output) => output, + Err(err) => { + return handle_cargo_deny_fallback(&format!( + "failed to run cargo-deny --version: {err}" + )); + } + }; if !version_output.status.success() { - return handle_cargo_deny_fallback(format!( + return handle_cargo_deny_fallback(&format!( "cargo-deny --version exited with {}", version_output.status )); @@ -210,7 +210,7 @@ fn run_cargo_deny() -> Result<(), String> { let version_text = String::from_utf8(version_output.stdout) .map_err(|err| format!("cargo-deny --version produced invalid UTF-8: {err}"))?; if !version_text.contains(PINNED_CARGO_DENY_VERSION) { - return handle_cargo_deny_fallback(format!( + return handle_cargo_deny_fallback(&format!( "cargo-deny version mismatch: expected {PINNED_CARGO_DENY_VERSION}, found {}", version_text.trim() )); @@ -237,7 +237,7 @@ fn run_cargo_deny() -> Result<(), String> { const PINNED_CARGO_DENY_VERSION: &str = "0.19.9"; -fn handle_cargo_deny_fallback(reason: String) -> Result<(), String> { +fn handle_cargo_deny_fallback(reason: &str) -> Result<(), String> { if std::env::var_os(ALLOW_SUPPLY_CHAIN_FALLBACK_ENV).is_some() { eprintln!( "{reason}; running built-in supply-chain policy fallback because {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV} is set" @@ -1605,6 +1605,7 @@ fn validate_native_smoke_report( expect_u64_at_least(platform, report, "frames", 300, failures); expect_u64_at_least(platform, report, "resize_count", 1, failures); expect_u64_at_least(platform, report, "swapchain_recreate_count", 1, failures); + expect_u64_field(platform, report, "validation_warning_count", 0, failures); expect_u64_field(platform, report, "validation_error_count", 0, failures); expect_nonempty_string(platform, report, "commit_sha", failures); expect_string_field( @@ -1889,6 +1890,10 @@ fn build_acceptance_audit( let mut missing = Vec::new(); let mut by_stage = BTreeMap::new(); let mut coverage_evidence = BTreeMap::new(); + let required_scopes = required + .iter() + .filter_map(|id| id.get(0..2).map(ToString::to_string)) + .collect::<BTreeSet<_>>(); for id in required { let stage = id @@ -1909,7 +1914,12 @@ fn build_acceptance_audit( let unknown_coverage = coverage .keys() - .filter(|id| !required.contains(*id)) + .filter(|id| { + !required.contains(*id) + && id + .get(0..2) + .is_some_and(|scope| required_scopes.contains(scope)) + }) .cloned() .collect(); @@ -2367,7 +2377,7 @@ mod tests { }, ), ( - "S9-UNKNOWN-001".to_string(), + "S0-ARCH-099".to_string(), CoverageEntry { status: CoverageStatus::Partial, evidence: "bad id".to_string(), @@ -2383,7 +2393,7 @@ mod tests { assert_eq!(audit.blocked, ["L5-RG40-001"]); assert_eq!(audit.omitted, ["L3-DEVICE-001"]); assert_eq!(audit.missing, ["S0-ARCH-002"]); - assert_eq!(audit.unknown_coverage, ["S9-UNKNOWN-001"]); + assert_eq!(audit.unknown_coverage, ["S0-ARCH-099"]); assert_eq!(audit.by_stage.get("S0"), Some(&2)); assert_eq!( audit.strict_failures(), @@ -2436,6 +2446,7 @@ mod tests { "frames": 300, "resize_count": 1, "swapchain_recreate_count": 1, + "validation_warning_count": 0, "validation_error_count": 0, "shader_manifest_hash": "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c", "vulkan_loader_status": "available", @@ -2474,6 +2485,7 @@ mod tests { "frames": 0, "resize_count": 0, "swapchain_recreate_count": 0, + "validation_warning_count": null, "validation_error_count": null, "shader_manifest_hash": "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c", "vulkan_loader_status": "unavailable", |
