aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-06-25 03:18:32 +0300
committerValentin Popov <valentin@popov.link>2026-06-25 10:45:32 +0300
commitba69bdb6eab57817b45055d60ea1d2f6687757a8 (patch)
tree6c1ea4db8fd2a715c01a38d2fc3fb71140320eb3
parent5cc2c5819f2dcfc9b9a8b86615d604d2b8f4c018 (diff)
downloadfparkan-ba69bdb6eab57817b45055d60ea1d2f6687757a8.tar.xz
fparkan-ba69bdb6eab57817b45055d60ea1d2f6687757a8.zip
feat(stage0): close native smoke acceptance gate
-rw-r--r--.github/workflows/ci.yml49
-rw-r--r--Cargo.lock1
-rw-r--r--README.md34
-rw-r--r--adapters/fparkan-platform-winit/src/lib.rs159
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag8
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag.spvbin0 -> 500 bytes
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert11
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert.spvbin0 -> 1012 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/lib.rs1955
-rw-r--r--apps/fparkan-game/src/main.rs4
-rw-r--r--apps/fparkan-vulkan-smoke/Cargo.toml1
-rw-r--r--apps/fparkan-vulkan-smoke/src/main.rs1747
-rw-r--r--fixtures/acceptance/coverage.tsv2
-rw-r--r--fixtures/acceptance/stage_0_roadmap.md68
-rw-r--r--xtask/src/main.rs44
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
diff --git a/Cargo.lock b/Cargo.lock
index 8198974..061950c 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -717,6 +717,7 @@ dependencies = [
"fparkan-platform",
"fparkan-platform-winit",
"fparkan-render-vulkan",
+ "winit",
]
[[package]]
diff --git a/README.md b/README.md
index 705bcd4..4c80198 100644
--- a/README.md
+++ b/README.md
@@ -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
new file mode 100644
index 0000000..c5d57ee
--- /dev/null
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
Binary files differ
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
new file mode 100644
index 0000000..04321ea
--- /dev/null
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
Binary files differ
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(&current_git_commit_sha())),
- ("rust_toolchain", json_string(RUST_TOOLCHAIN)),
+ ("rust_toolchain", json_string(&current_rustc_release())),
("target_triple", json_string(&current_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",