60 Commits

Author SHA1 Message Date
renovate[bot] 7e06b6a853 fix(deps): update rust crate toml to v1
Test / Lint (push) Failing after 2m43s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 2m35s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
2026-06-26 00:02:26 +00:00
Valentin Popov 6cd23bf02a fix(smoke): capture final validation after explicit shutdown
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
Test / Lint (push) Failing after 2m58s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-25 22:35:08 +04:00
Valentin Popov cbc8ef36f8 fix(smoke): bind native smoke audit to current provenance 2026-06-25 22:31:35 +04:00
Valentin Popov 7c7e91c857 build(ci): fail closed on shader provenance
Docs Deploy / Build and Deploy MkDocs (push) Successful in 39s
Test / Lint (push) Successful in 2m55s
Test / Test (push) Failing after 3m13s
Test / Render parity (push) Has been skipped
2026-06-25 13:07:58 +04:00
Valentin Popov 7c3b3a53f5 fix(smoke): drop renderer before window teardown 2026-06-25 13:07:40 +04:00
Valentin Popov 0b8776b850 chore(audit): remove stage0 audit files
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m54s
Test / Lint (push) Failing after 1m58s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-25 11:45:41 +04:00
Valentin Popov e572558d5f fix(stage0-ci): satisfy strict clippy gates 2026-06-25 11:45:40 +04:00
Valentin Popov 5a60671bd6 fix(vulkan-policy): report sampled formats and limits 2026-06-25 11:45:40 +04:00
Valentin Popov 5aff0b64e8 fix(vulkan-policy): gate requested depth formats 2026-06-25 11:45:40 +04:00
Valentin Popov 757a975d8c chore(vulkan-smoke): record window bootstrap phases 2026-06-25 11:45:40 +04:00
Valentin Popov d146953bcc fix(vulkan-smoke): track bootstrap timeout evidence 2026-06-25 11:45:40 +04:00
Valentin Popov b617e2958d fix(vulkan-smoke): harden timeout and ci closure 2026-06-25 11:45:40 +04:00
Valentin Popov 607a64ca8d refactor(vulkan-policy): narrow unsafe module boundaries 2026-06-25 11:45:39 +04:00
Valentin Popov e79d26ea68 feat(vulkan-policy): report rejected device diagnostics 2026-06-25 11:45:39 +04:00
Valentin Popov e3c74485f1 feat(platform-winit): bridge native window events 2026-06-25 11:45:39 +04:00
Valentin Popov ad21704bcc feat(platform-winit): apply lifecycle state updates 2026-06-25 11:45:39 +04:00
Valentin Popov 95391a05c6 build(vulkan-shaders): target vulkan 1.1 manifests 2026-06-25 11:45:39 +04:00
Valentin Popov 14ea45d49a build(vulkan-shaders): source tool metadata at build time 2026-06-25 11:45:39 +04:00
Valentin Popov 0caa36d923 build(vulkan-smoke): capture build toolchain metadata 2026-06-25 11:45:38 +04:00
Valentin Popov 25d26a87a4 build(vulkan-smoke): embed compiled artifact provenance 2026-06-25 11:45:38 +04:00
Valentin Popov 8f0dcd7f4d feat(vulkan-smoke): verify macos portability evidence 2026-06-25 11:45:38 +04:00
Valentin Popov 97f56c56ba fix(vulkan-smoke): rollback partial swapchain resources 2026-06-25 11:45:38 +04:00
Valentin Popov 70813154f2 fix(vulkan-smoke): emit structured timeout reports 2026-06-25 11:45:38 +04:00
Valentin Popov e40349b204 ci(vulkan-smoke): pin macos runtime provisioning 2026-06-25 11:45:38 +04:00
Valentin Popov 61638e083b test(vulkan-smoke): cover explicit teardown order 2026-06-25 11:45:38 +04:00
Valentin Popov 17c3038a36 fix(vulkan-smoke): simplify swapchain image layout transitions 2026-06-25 11:45:37 +04:00
Valentin Popov b473b100c8 refactor(vulkan-ffi): extract capability probe module 2026-06-25 11:45:37 +04:00
Valentin Popov 3c32215665 refactor(vulkan-ffi): extract swapchain probe module 2026-06-25 11:45:37 +04:00
Valentin Popov f91378b884 refactor(vulkan-ffi): extract swapchain resource module 2026-06-25 11:45:37 +04:00
Valentin Popov 8f8fa426d5 refactor(vulkan-backend): satisfy ci quality gates 2026-06-25 11:45:37 +04:00
Valentin Popov 4d0cb594a7 refactor(vulkan-ffi): move adapter tests into submodule 2026-06-25 11:45:37 +04:00
Valentin Popov 07e30cd040 refactor(vulkan-ffi): extract smoke renderer types 2026-06-25 11:45:36 +04:00
Valentin Popov 079e531166 refactor(vulkan-backend): clarify planning facade telemetry 2026-06-25 11:45:36 +04:00
Valentin Popov 6a2adbe160 refactor(vulkan-ffi): extract smoke renderer module 2026-06-25 11:45:36 +04:00
Valentin Popov b8933dd43a refactor(vulkan-ffi): extract resource setup module 2026-06-25 11:45:36 +04:00
Valentin Popov 26efa13a01 refactor(vulkan-ffi): extract runtime capability module 2026-06-25 11:45:36 +04:00
Valentin Popov dcd5417af2 refactor(vulkan-ffi): extract validation messenger module 2026-06-25 11:45:36 +04:00
Valentin Popov b6b47ae6f6 refactor(vulkan-ffi): extract surface bootstrap module 2026-06-25 11:45:35 +04:00
Valentin Popov 1eead8d597 refactor(vulkan-ffi): extract instance bootstrap module 2026-06-25 11:45:35 +04:00
Valentin Popov ce3e5ad813 refactor(vulkan-shaders): extract manifest validation module 2026-06-25 11:45:35 +04:00
Valentin Popov d0552922d9 refactor(vulkan-policy): extract pure swapchain and device policy 2026-06-25 11:45:35 +04:00
Valentin Popov 0de5118575 refactor(vulkan-backend): extract planning facade module 2026-06-25 11:45:35 +04:00
Valentin Popov 0d139b1aae fix(vulkan-instance): verify required extensions before create 2026-06-25 11:45:35 +04:00
Valentin Popov 72f6c06eca fix(vulkan-capabilities): harden swapchain capability gate 2026-06-25 11:45:35 +04:00
Valentin Popov 0a78fc2460 refactor(vulkan-reporting): remove manual json assembly 2026-06-25 11:45:34 +04:00
Valentin Popov 5c4fbff2af refactor(vulkan-errors): preserve typed vk results 2026-06-25 11:45:34 +04:00
Valentin Popov fd8b03c0bc refactor(reporting): use typed serde report schemas 2026-06-25 11:45:34 +04:00
Valentin Popov 0b0ed87650 refactor(vulkan-ffi): narrow audited unsafe boundary 2026-06-25 11:45:34 +04:00
Valentin Popov 6a6393038e refactor(vulkan-plan): clarify planning backend telemetry 2026-06-25 11:45:34 +04:00
Valentin Popov ec6645a21f fix(stage0): harden native smoke provenance audit 2026-06-25 11:45:34 +04:00
Valentin Popov d1b7b43dce feat(stage0): record artifact provenance metadata 2026-06-25 11:45:33 +04:00
Valentin Popov adc6c6149c fix(vulkan-smoke): make teardown order explicit 2026-06-25 11:45:33 +04:00
Valentin Popov 5950c62cec ci: tighten supply-chain fallback policy 2026-06-25 11:45:33 +04:00
Valentin Popov 247f86aa09 feat(vulkan-smoke): pin shader manifest provenance 2026-06-25 11:45:33 +04:00
Valentin Popov 4d8068aef0 fix(platform-winit): map lifecycle and pointer state 2026-06-25 11:45:33 +04:00
Valentin Popov 53715d0d9c fix(vulkan-smoke): clean up partial runtime resources 2026-06-25 11:45:33 +04:00
Valentin Popov c8876d65eb refactor(vulkan-smoke): remove legacy acquire-present path 2026-06-25 11:45:33 +04:00
Valentin Popov 0a2d1bcc32 chore(stage0): scope native smoke closure to macos 2026-06-25 11:45:32 +04:00
Valentin Popov ba69bdb6ea feat(stage0): close native smoke acceptance gate 2026-06-25 11:45:32 +04:00
Valentin Popov 5cc2c5819f ci: tighten stage 0 acceptance gates 2026-06-25 11:45:32 +04:00
38 changed files with 9665 additions and 5434 deletions
+98 -29
View File
@@ -2,19 +2,21 @@ name: fparkan-ci
on:
push:
branches: [main]
branches: [devel, main]
pull_request:
branches: [main]
branches: [devel, main]
workflow_dispatch:
jobs:
msrv-backend-neutral:
name: MSRV backend-neutral crates
runs-on: ubuntu-latest
timeout-minutes: 20
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with:
toolchain: 1.87.0
- name: Test backend-neutral crates
@@ -46,45 +48,112 @@ 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:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
smoke_platform: linux
- os: windows-latest
smoke_platform: windows
- os: macos-latest
- os: macos-15
smoke_platform: macos
runner_arch: arm64
moltenvk_version: 1.4.1
vulkan_sdk_version: 1.4.350.1
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
with:
toolchain-file: rust-toolchain.toml
toolchain: 1.87.0
components: clippy,rustfmt
- name: Provision macOS Vulkan runtime
shell: bash
run: |
set -euo pipefail
brew install molten-vk vulkan-loader vulkan-tools vulkan-validationlayers
test "$(uname -m)" = "${{ matrix.runner_arch }}"
ruby <<'RUBY'
require "json"
expected = {
"molten-vk" => "${{ matrix.moltenvk_version }}",
"vulkan-loader" => "${{ matrix.vulkan_sdk_version }}",
"vulkan-tools" => "${{ matrix.vulkan_sdk_version }}",
"vulkan-validationlayers" => "${{ matrix.vulkan_sdk_version }}",
}
payload = JSON.parse(`brew info --json=v2 #{expected.keys.join(" ")}`)
actual = payload.fetch("formulae").to_h do |formula|
[formula.fetch("name"), formula.fetch("versions").fetch("stable")]
end
mismatches = expected.each_with_object({}) do |(name, version), out|
actual_version = actual[name]
next if actual_version == version
out[name] = {
"expected" => version,
"actual" => actual_version,
}
end
unless mismatches.empty?
warn JSON.pretty_generate(mismatches)
abort "unexpected macOS Vulkan formula version"
end
RUBY
HOMEBREW_PREFIX="$(brew --prefix)"
VK_ICD_FILENAMES="$HOMEBREW_PREFIX/opt/molten-vk/etc/vulkan/icd.d/MoltenVK_icd.json"
VK_LAYER_PATH="$HOMEBREW_PREFIX/opt/vulkan-validationlayers/share/vulkan/explicit_layer.d"
DYLD_FALLBACK_LIBRARY_PATH="$HOMEBREW_PREFIX/opt/vulkan-loader/lib:$HOMEBREW_PREFIX/opt/molten-vk/lib"
test -f "$VK_ICD_FILENAMES"
test -d "$VK_LAYER_PATH"
echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES" >> "$GITHUB_ENV"
echo "VK_LAYER_PATH=$VK_LAYER_PATH" >> "$GITHUB_ENV"
echo "DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH" >> "$GITHUB_ENV"
- name: Install cargo-deny
run: cargo install cargo-deny --locked
run: cargo install cargo-deny --version 0.19.9 --locked
- name: Verify shader provenance
run: cargo xtask shader-provenance
- 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"
--timeout-seconds 120
- name: Upload acceptance audit
if: always()
uses: actions/upload-artifact@v4
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
if-no-files-found: ignore
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: 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
Generated
+416 -16
View File
@@ -106,6 +106,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "1.3.2"
@@ -188,22 +194,38 @@ dependencies = [
[[package]]
name = "cargo-platform"
version = "0.3.3"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
checksum = "84982c6c0ae343635a3a4ee6dedef965513735c8b183caa7289fa6e27399ebd4"
dependencies = [
"serde",
"serde_core",
]
[[package]]
name = "cargo-util-schemas"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dc1a6f7b5651af85774ae5a34b4e8be397d9cf4bc063b7e6dbd99a841837830"
dependencies = [
"semver",
"serde",
"serde-untagged",
"serde-value",
"thiserror 2.0.18",
"toml 0.8.23",
"unicode-xid",
"url",
]
[[package]]
name = "cargo_metadata"
version = "0.23.1"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
checksum = "5cfca2aaa699835ba88faf58a06342a314a950d2b9686165e038286c30316868"
dependencies = [
"camino",
"cargo-platform",
"cargo-util-schemas",
"semver",
"serde",
"serde_json",
@@ -350,6 +372,17 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
[[package]]
name = "displaydoc"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "dlib"
version = "0.5.3"
@@ -386,6 +419,17 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
dependencies = [
"serde",
"serde_core",
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
@@ -439,6 +483,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "fparkan-animation"
version = "0.1.0"
@@ -630,6 +683,8 @@ dependencies = [
"fparkan-binary",
"fparkan-platform",
"fparkan-render",
"serde",
"serde_json",
]
[[package]]
@@ -717,6 +772,9 @@ dependencies = [
"fparkan-platform",
"fparkan-platform-winit",
"fparkan-render-vulkan",
"serde",
"serde_json",
"winit",
]
[[package]]
@@ -784,6 +842,109 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "icu_collections"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
dependencies = [
"displaydoc",
"litemap",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_normalizer"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
[[package]]
name = "icu_properties"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
dependencies = [
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
[[package]]
name = "icu_provider"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
dependencies = [
"displaydoc",
"icu_locale_core",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "idna"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
dependencies = [
"idna_adapter",
"smallvec",
"utf8_iter",
]
[[package]]
name = "idna_adapter"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
dependencies = [
"icu_normalizer",
"icu_properties",
]
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -919,6 +1080,12 @@ version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "litemap"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "log"
version = "0.4.33"
@@ -989,6 +1156,15 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_enum"
version = "0.7.6"
@@ -1239,6 +1415,15 @@ dependencies = [
"libredox",
]
[[package]]
name = "ordered-float"
version = "2.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
dependencies = [
"num-traits",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -1306,13 +1491,22 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "potential_utf"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
dependencies = [
"zerovec",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
"toml_edit",
"toml_edit 0.25.12+spec-1.1.0",
]
[[package]]
@@ -1473,6 +1667,28 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "serde-untagged"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058"
dependencies = [
"erased-serde",
"serde",
"serde_core",
"typeid",
]
[[package]]
name = "serde-value"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
dependencies = [
"ordered-float",
"serde",
]
[[package]]
name = "serde_core"
version = "1.0.228"
@@ -1506,6 +1722,15 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_spanned"
version = "0.6.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
dependencies = [
"serde",
]
[[package]]
name = "serde_spanned"
version = "1.1.1"
@@ -1589,6 +1814,12 @@ dependencies = [
"serde",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strict-num"
version = "0.1.1"
@@ -1606,6 +1837,17 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -1672,27 +1914,49 @@ dependencies = [
]
[[package]]
name = "toml"
version = "0.9.12+spec-1.1.0"
name = "tinystr"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
dependencies = [
"displaydoc",
"zerovec",
]
[[package]]
name = "toml"
version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_edit 0.22.27",
]
[[package]]
name = "toml"
version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee"
dependencies = [
"indexmap",
"serde_core",
"serde_spanned",
"toml_datetime 0.7.5+spec-1.1.0",
"serde_spanned 1.1.1",
"toml_datetime 1.1.1+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 0.7.15",
"winnow 1.0.3",
]
[[package]]
name = "toml_datetime"
version = "0.7.5+spec-1.1.0"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
dependencies = [
"serde_core",
"serde",
]
[[package]]
@@ -1704,6 +1968,20 @@ dependencies = [
"serde_core",
]
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"serde",
"serde_spanned 0.6.9",
"toml_datetime 0.6.11",
"toml_write",
"winnow 0.7.15",
]
[[package]]
name = "toml_edit"
version = "0.25.12+spec-1.1.0"
@@ -1725,6 +2003,12 @@ dependencies = [
"winnow 1.0.3",
]
[[package]]
name = "toml_write"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
version = "1.1.1+spec-1.1.0"
@@ -1753,6 +2037,12 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@@ -1765,6 +2055,30 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "version_check"
version = "0.9.5"
@@ -2137,6 +2451,9 @@ name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
dependencies = [
"memchr",
]
[[package]]
name = "winnow"
@@ -2153,6 +2470,12 @@ version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x11-dl"
version = "2.21.0"
@@ -2218,7 +2541,30 @@ dependencies = [
"fparkan-corpus",
"serde",
"serde_json",
"toml",
"toml 1.1.2+spec-1.1.0",
]
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
@@ -2241,6 +2587,60 @@ dependencies = [
"syn",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn",
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
]
[[package]]
name = "zerovec"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
dependencies = [
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zmij"
version = "1.0.21"
+45
View File
@@ -63,6 +63,51 @@ 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 \
--timeout-seconds 120
```
Перед запуском убедитесь, что на машине доступен Vulkan loader и рабочий ICD:
- macOS: используйте ту же схему, что и GitHub CI (`macos-15` arm64):
```bash
brew install molten-vk vulkan-loader vulkan-tools vulkan-validationlayers
export VK_ICD_FILENAMES="$(brew --prefix)/opt/molten-vk/etc/vulkan/icd.d/MoltenVK_icd.json"
export VK_LAYER_PATH="$(brew --prefix)/opt/vulkan-validationlayers/share/vulkan/explicit_layer.d"
export DYLD_FALLBACK_LIBRARY_PATH="$(brew --prefix)/opt/vulkan-loader/lib:$(brew --prefix)/opt/molten-vk/lib"
vulkaninfo --summary
```
Workflow fail-closed проверяет exact formula versions и ожидает наличие `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
```
В текущем macOS-only цикле GitHub workflow собирает только macOS report и проверяет его через `native-smoke audit`. Windows и Linux smoke stages сознательно не входят в этот closure:
```bash
cargo xtask native-smoke audit --dir target/fparkan/native-smoke-artifacts
```
## Contributing & Support
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
+217 -150
View File
@@ -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))
}
}
@@ -60,6 +57,9 @@ impl MonotonicClock for WinitClock {
#[derive(Clone, Debug, Default)]
pub struct WinitEventSource {
queue: VecDeque<PlatformEvent>,
cursor_position: Option<(f64, f64)>,
minimized: Option<bool>,
occluded: Option<bool>,
}
impl WinitEventSource {
@@ -68,6 +68,9 @@ impl WinitEventSource {
pub const fn new() -> Self {
Self {
queue: VecDeque::new(),
cursor_position: None,
minimized: None,
occluded: None,
}
}
@@ -80,20 +83,24 @@ impl WinitEventSource {
pub fn push_window_event(&mut self, event: &WindowEvent) {
match event {
WindowEvent::KeyboardInput { event, .. } => {
if let Some(scancode) = event.physical_key.to_scancode() {
self.queue.push_back(PlatformEvent::KeyboardInput {
scancode: event.physical_key.to_scancode().unwrap_or(0),
scancode,
pressed: event.state.is_pressed(),
});
}
}
WindowEvent::MouseInput { state, button, .. } => {
let (x, y) = self.cursor_position.unwrap_or((0.0, 0.0));
self.queue.push_back(PlatformEvent::MouseInput {
button: mouse_button_code(*button),
pressed: state.is_pressed(),
x: 0.0,
y: 0.0,
x,
y,
});
}
WindowEvent::CursorMoved { position, .. } => {
self.cursor_position = Some((position.x, position.y));
self.queue.push_back(PlatformEvent::CursorMoved {
x: position.x,
y: position.y,
@@ -104,11 +111,24 @@ impl WinitEventSource {
width: size.width,
height: size.height,
});
let minimized = size.width == 0 || size.height == 0;
if self.minimized != Some(minimized) {
self.minimized = Some(minimized);
self.queue.push_back(PlatformEvent::Minimized { minimized });
}
}
WindowEvent::Focused(focused) => {
self.queue
.push_back(PlatformEvent::FocusChanged { focused: *focused });
}
WindowEvent::Occluded(occluded) => {
if self.occluded != Some(*occluded) {
self.occluded = Some(*occluded);
self.queue.push_back(PlatformEvent::Occluded {
occluded: *occluded,
});
}
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.queue.push_back(PlatformEvent::DpiChanged {
scale: *scale_factor,
@@ -123,8 +143,11 @@ impl WinitEventSource {
/// Pushes events from an event loop event.
pub fn push_event<T>(&mut self, event: &Event<T>) {
if let Event::WindowEvent { event, .. } = event {
self.push_window_event(event);
match event {
Event::Resumed => self.queue.push_back(PlatformEvent::Resumed),
Event::Suspended => self.queue.push_back(PlatformEvent::Suspended),
Event::WindowEvent { event, .. } => self.push_window_event(event),
_ => {}
}
}
}
@@ -136,7 +159,7 @@ fn mouse_button_code(button: MouseButton) -> u16 {
MouseButton::Middle => 2,
MouseButton::Back => 3,
MouseButton::Forward => 4,
MouseButton::Other(index) => 100 + index,
MouseButton::Other(index) => 100_u16.saturating_add(index),
}
}
@@ -187,113 +210,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 +239,7 @@ impl WinitWindow {
focused: true,
minimized: false,
occluded: false,
native_handles: native_handles(window),
native_handles: window_native_handles(window),
}
}
@@ -349,6 +265,62 @@ impl WinitWindow {
pub const fn default_render_request() -> RenderRequest {
RenderRequest::conservative()
}
/// Applies one platform event to the cached window descriptor state.
pub fn apply_event(&mut self, event: &PlatformEvent) {
match event {
PlatformEvent::Resize { width, height } => {
self.width = *width;
self.height = *height;
}
PlatformEvent::DpiChanged { scale } => {
self.scale = *scale;
}
PlatformEvent::FocusChanged { focused } => {
self.focused = *focused;
}
PlatformEvent::Minimized { minimized } => {
self.minimized = *minimized;
}
PlatformEvent::Occluded { occluded } => {
self.occluded = *occluded;
}
PlatformEvent::Suspended
| PlatformEvent::Resumed
| PlatformEvent::QuitRequested
| PlatformEvent::KeyboardInput { .. }
| PlatformEvent::MouseInput { .. }
| PlatformEvent::CursorMoved { .. } => {}
}
}
/// Applies a sequence of platform events to the cached window descriptor state.
pub fn apply_events<'a>(&mut self, events: impl IntoIterator<Item = &'a PlatformEvent>) {
for event in events {
self.apply_event(event);
}
}
/// Applies one native `winit` window event to the cached window descriptor state.
pub fn apply_window_event(&mut self, event: &WindowEvent) {
match event {
WindowEvent::Resized(size) => {
self.width = size.width;
self.height = size.height;
self.minimized = size.width == 0 || size.height == 0;
}
WindowEvent::Focused(focused) => {
self.focused = *focused;
}
WindowEvent::Occluded(occluded) => {
self.occluded = *occluded;
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.scale = *scale_factor;
}
_ => {}
}
}
}
impl WindowPort for WinitWindow {
@@ -384,7 +356,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 })
@@ -393,6 +367,7 @@ fn native_handles(window: &Window) -> Option<NativeWindowHandles> {
#[cfg(test)]
mod tests {
use super::*;
use winit::event::{DeviceId, ElementState};
#[test]
fn event_source_buffers_synthetic_events() -> Result<(), PlatformError> {
@@ -454,33 +429,12 @@ mod tests {
}
#[test]
fn smoke_window_app_requires_created_native_window() {
let app = SmokeWindowApp::new(WinitWindowPlan::smoke());
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",
..
})
));
}
#[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,
));
assert!(matches!(
app.into_probe(),
Err(PlatformError::Backend {
context: "winit smoke window",
..
})
));
assert!(second >= first);
}
#[test]
@@ -504,6 +458,119 @@ mod tests {
assert!(events.contains(&PlatformEvent::FocusChanged { focused: false }));
assert!(events.contains(&PlatformEvent::QuitRequested));
}
#[test]
fn push_event_maps_lifecycle_resumed_and_suspended() -> Result<(), PlatformError> {
let mut source = WinitEventSource::new();
source.push_event(&Event::<()>::Resumed);
source.push_event(&Event::<()>::Suspended);
let mut events = Vec::new();
source.poll(&mut events)?;
assert_eq!(
events,
vec![PlatformEvent::Resumed, PlatformEvent::Suspended]
);
Ok(())
}
#[test]
fn cursor_position_and_occlusion_are_preserved_for_mouse_input() -> Result<(), PlatformError> {
let mut source = WinitEventSource::new();
source.push_window_event(&WindowEvent::CursorMoved {
device_id: DeviceId::dummy(),
position: (320.0, 240.0).into(),
});
source.push_window_event(&WindowEvent::MouseInput {
device_id: DeviceId::dummy(),
state: ElementState::Pressed,
button: MouseButton::Other(u16::MAX),
});
source.push_window_event(&WindowEvent::Occluded(true));
let mut events = Vec::new();
source.poll(&mut events)?;
assert!(events.contains(&PlatformEvent::CursorMoved { x: 320.0, y: 240.0 }));
assert!(events.contains(&PlatformEvent::MouseInput {
button: u16::MAX,
pressed: true,
x: 320.0,
y: 240.0,
}));
assert!(events.contains(&PlatformEvent::Occluded { occluded: true }));
Ok(())
}
#[test]
fn zero_extent_resize_updates_minimized_state() -> Result<(), PlatformError> {
let mut source = WinitEventSource::new();
source.push_window_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
0u32, 720u32,
)));
source.push_window_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
1280u32, 720u32,
)));
let mut events = Vec::new();
source.poll(&mut events)?;
assert!(events.contains(&PlatformEvent::Minimized { minimized: true }));
assert!(events.contains(&PlatformEvent::Minimized { minimized: false }));
Ok(())
}
#[test]
fn window_descriptor_applies_lifecycle_and_resize_events() {
let mut window = WinitWindow::synthetic(640, 360);
let events = [
PlatformEvent::Resize {
width: 1280,
height: 720,
},
PlatformEvent::DpiChanged { scale: 2.0 },
PlatformEvent::FocusChanged { focused: false },
PlatformEvent::Minimized { minimized: true },
PlatformEvent::Occluded { occluded: true },
];
window.apply_events(events.iter());
assert_eq!(
window.drawable_size(),
PhysicalSize {
width: 1280,
height: 720,
}
);
assert_eq!(window.dpi_scale(), 2.0);
assert!(!window.has_focus());
assert!(window.is_minimized());
assert!(window.is_occluded());
}
#[test]
fn window_descriptor_applies_native_window_events() {
let mut window = WinitWindow::synthetic(640, 360);
window.apply_window_event(&WindowEvent::Resized(winit::dpi::PhysicalSize::new(
0u32, 720u32,
)));
window.apply_window_event(&WindowEvent::Focused(false));
window.apply_window_event(&WindowEvent::Occluded(true));
assert_eq!(
window.drawable_size(),
PhysicalSize {
width: 0,
height: 720,
}
);
assert!(!window.has_focus());
assert!(window.is_minimized());
assert!(window.is_occluded());
}
}
// SAFETY: no unsafe usage in this crate.
@@ -4,6 +4,7 @@ version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
build = "build.rs"
[dependencies]
ash = "0.38"
@@ -11,6 +12,8 @@ ash-window = "0.13"
fparkan-binary = { path = "../../crates/fparkan-binary" }
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-render = { path = "../../crates/fparkan-render" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints.rust]
unsafe_code = "allow"
+92
View File
@@ -0,0 +1,92 @@
//! Build-time shader tool metadata for Vulkan reports.
use std::env;
use std::path::Path;
use std::process::Command;
const SHADER_COMPILER_NAME: &str = "glslangValidator";
const SPIRV_VALIDATOR_NAME: &str = "spirv-val";
fn main() {
println!("cargo:rerun-if-env-changed=PATH");
println!("cargo:rerun-if-env-changed=FPARKAN_GLSLANG_VALIDATOR");
println!("cargo:rerun-if-env-changed=FPARKAN_SPIRV_VAL");
emit_tool_metadata(
"FPARKAN_BUILD_SHADER_COMPILER",
&tool_path("FPARKAN_GLSLANG_VALIDATOR", SHADER_COMPILER_NAME),
);
emit_tool_metadata(
"FPARKAN_BUILD_SPIRV_VALIDATOR",
&tool_path("FPARKAN_SPIRV_VAL", SPIRV_VALIDATOR_NAME),
);
}
fn tool_path(env_var: &str, fallback: &str) -> String {
env::var(env_var).unwrap_or_else(|_| fallback.to_string())
}
fn emit_tool_metadata(prefix: &str, tool: &str) {
let Some(path) = resolve_tool(tool) else {
return;
};
println!("cargo:rerun-if-changed={}", path.display());
let Some(version) = tool_version(&path) else {
return;
};
let Some(binary_sha256) = tool_sha256(&path) else {
return;
};
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(tool)
.to_string();
println!("cargo:rustc-env={prefix}_NAME={name}");
println!("cargo:rustc-env={prefix}_VERSION={version}");
println!("cargo:rustc-env={prefix}_SHA256={binary_sha256}");
}
fn resolve_tool(tool: &str) -> Option<std::path::PathBuf> {
let candidate = Path::new(tool);
if candidate.components().count() > 1 {
return candidate.is_file().then(|| candidate.to_path_buf());
}
let output = Command::new("which").arg(tool).output().ok()?;
if !output.status.success() {
return None;
}
let path = String::from_utf8(output.stdout).ok()?;
let path = path.trim();
(!path.is_empty()).then(|| path.into())
}
fn tool_version(path: &Path) -> Option<String> {
let output = Command::new(path).arg("--version").output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
stdout
.lines()
.find(|line| !line.trim().is_empty())
.map(str::trim)
.map(|line| {
line.strip_prefix("Glslang Version: ")
.unwrap_or(line)
.to_string()
})
}
fn tool_sha256(path: &Path) -> Option<String> {
let output = Command::new("shasum")
.args(["-a", "256"])
.arg(path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8(output.stdout).ok()?;
stdout.split_whitespace().next().map(ToString::to_string)
}
@@ -0,0 +1 @@
{"schema":2,"target_env":"vulkan1.1","compiler":{"name":"glslangValidator","version":"11:16.3.0","binary_sha256":"9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f"},"validator":{"name":"spirv-val","version":"SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00","binary_sha256":"f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f"},"modules":[{"name":"triangle.vert","stage":"vertex","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert","source_sha256":"1e57f14d193fc61457c0749081c452ad25669998913107df12f3ccc3c33e0341","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","word_count":253,"sha256":"4d3ceca7b42ebc971d831b0a0d816457397bd9aeda47fb8d44c4b1aeaa5e7ba0","descriptor_sets":0,"push_constant_bytes":0,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S vert -e main adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","interface_hash":"23e1d3d9d32e7f7ec0b9ca87f8b86be8f8363c7eb5d745fc5a157cb8433eb138"},{"name":"triangle.frag","stage":"fragment","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag","source_sha256":"f19e74d001d07fb537d4b0f9e621f9b8bc40eeb68816130220853abea6bd4445","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","word_count":125,"sha256":"5a7441be03cd3c25d557268b2e58d5aa50504c87bffcb4c3fd7cbcf007db0b96","descriptor_sets":0,"push_constant_bytes":0,"compile_command":"glslangValidator -V --target-env vulkan1.1 -S frag -e main adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","validate_command":"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","interface_hash":"f09342c22d58c8768151ab8579e54e49af586434a4005d16a24e816d881a64f0"}],"manifest_hash":"11e3feb65200ebd2ac87b7e776e9c6433a5da9d71a651bfadea89a51be17ff05"}
@@ -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);
}
@@ -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);
}
+472
View File
@@ -0,0 +1,472 @@
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![deny(unsafe_op_in_unsafe_fn)]
//! Vulkan adapter facade and migration-ready backend surface contract.
//!
//! This module intentionally keeps backend-agnostic command validation in the
//! shared render crate while exposing deterministic lifecycle telemetry used by
//! Stage 0 acceptance evidence.
//!
//! This crate is the declared low-level Vulkan boundary.
mod capabilities;
mod instance;
mod resources;
mod runtime;
mod smoke;
mod smoke_types;
mod surface;
mod swapchain;
mod swapchain_resources;
mod validation;
pub use self::capabilities::{
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
VulkanRuntimeCapabilityError, VulkanRuntimeCapabilityProbe,
};
pub use self::instance::{
create_vulkan_instance_probe, plan_vulkan_instance, probe_vulkan_loader,
render_instance_plan_json, render_loader_probe_report_json, vulkan_entry_symbol_name,
VulkanInstanceConfig, VulkanInstanceError, VulkanInstancePlan, VulkanInstanceProbe,
VulkanLoaderError, VulkanLoaderProbeReport,
};
#[cfg(test)]
use self::instance::{cstring_vec, ensure_instance_extensions_available};
use self::resources::{
color_subresource_range, create_command_pool, create_frame_sync, create_triangle_index_buffer,
create_triangle_vertex_buffer, destroy_allocated_buffer, VulkanAllocatedBuffer,
VulkanFrameSync,
};
pub use self::runtime::{
create_vulkan_logical_device_probe, create_vulkan_logical_device_probe_for_request,
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanLogicalDeviceReport,
};
pub use self::smoke_types::{
VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot, VulkanSmokeFrameOutcome,
VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanSmokeRendererError,
VulkanSmokeRendererReport, VulkanSmokeShutdownReport, VulkanValidationReport,
};
#[cfg(test)]
use self::surface::extension_name;
pub use self::surface::{
create_vulkan_surface_probe, plan_vulkan_surface, render_surface_plan_json, VulkanSurfaceError,
VulkanSurfacePlan, VulkanSurfaceProbe,
};
pub use self::swapchain::{
create_vulkan_swapchain_probe, create_vulkan_swapchain_probe_for_extent, VulkanSwapchainProbe,
VulkanSwapchainProbeError, VulkanSwapchainReport,
};
use self::swapchain_resources::{
create_swapchain_resources, destroy_swapchain_resources, VulkanSwapchainResources,
};
use self::validation::{create_validation_messenger, VulkanValidationMessenger};
use ash::vk;
/// Minimum Vulkan API version accepted by the Stage 0 backend.
pub const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1;
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";
pub(crate) const SPIRV_MAGIC: u32 = 0x0723_0203;
pub(crate) const SPIRV_VERSION_1_0: u32 = 0x0001_0000;
pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &[
0x0723_0203,
0x0001_0300,
0x0008_000b,
0x0000_0021,
0x0000_0000,
0x0002_0011,
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,
];
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
0x0723_0203,
0x0001_0300,
0x0008_000b,
0x0000_0013,
0x0000_0000,
0x0002_0011,
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,
];
#[cfg(test)]
mod tests;
@@ -0,0 +1,508 @@
#![allow(unsafe_code)]
use ash::vk;
use fparkan_platform::RenderRequest;
use std::ffi::CStr;
use super::{VulkanInstanceProbe, VulkanSurfaceProbe};
use crate::policy::{
compare_reports, plan_vulkan_swapchain, validate_device_for_request, VulkanCapabilityError,
VulkanCapabilityReport, VulkanDeviceLimits, VulkanDeviceType, VulkanPhysicalDeviceRecord,
VulkanQueueFamily, VulkanSurfaceFormat, VulkanSwapchainError, VulkanSwapchainPlan,
VulkanSwapchainRequest, VulkanSwapchainSurfaceCapabilities,
};
/// Live Vulkan device/surface capability probe.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanRuntimeCapabilityProbe {
/// Selected device/queue capability report.
pub capability: VulkanCapabilityReport,
/// Swapchain plan built from the selected device and live surface capabilities.
pub swapchain: VulkanSwapchainPlan,
}
/// Live Vulkan device/surface capability probe error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanRuntimeCapabilityError {
/// Physical device enumeration failed.
EnumerateDevicesFailed {
/// Vulkan result.
result: vk::Result,
},
/// Device extension enumeration failed.
EnumerateDeviceExtensionsFailed {
/// Device name or index context.
device: String,
/// Vulkan result.
result: vk::Result,
},
/// Queue-family present support query failed.
PresentSupportFailed {
/// Device name.
device: String,
/// Queue-family index.
queue_family: u32,
/// Vulkan result.
result: vk::Result,
},
/// Surface format query failed.
SurfaceFormatsFailed {
/// Device name.
device: String,
/// Vulkan result.
result: vk::Result,
},
/// Surface capability query failed.
SurfaceCapabilitiesFailed {
/// Device name.
device: String,
/// Vulkan result.
result: vk::Result,
},
/// Present mode query failed.
PresentModesFailed {
/// Device name.
device: String,
/// Vulkan result.
result: vk::Result,
},
/// No device satisfied Stage 0 capability policy.
Capability(VulkanCapabilityError),
/// Live surface capabilities could not produce a swapchain plan.
Swapchain(VulkanSwapchainError),
}
impl std::fmt::Display for VulkanRuntimeCapabilityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EnumerateDevicesFailed { result } => {
write!(f, "Vulkan physical device enumeration failed: {result:?}")
}
Self::EnumerateDeviceExtensionsFailed { device, result } => write!(
f,
"Vulkan device {device} extension enumeration failed: {result:?}"
),
Self::PresentSupportFailed {
device,
queue_family,
result,
} => write!(
f,
"Vulkan device {device} queue family {queue_family} present support query failed: {result:?}"
),
Self::SurfaceFormatsFailed { device, result } => write!(
f,
"Vulkan device {device} surface format query failed: {result:?}"
),
Self::SurfaceCapabilitiesFailed { device, result } => write!(
f,
"Vulkan device {device} surface capabilities query failed: {result:?}"
),
Self::PresentModesFailed { device, result } => write!(
f,
"Vulkan device {device} present mode query failed: {result:?}"
),
Self::Capability(error) => write!(f, "{error}"),
Self::Swapchain(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for VulkanRuntimeCapabilityError {}
pub(super) struct SelectedLiveDevice {
pub(super) physical_device: vk::PhysicalDevice,
pub(super) runtime: VulkanRuntimeCapabilityProbe,
}
struct LiveDeviceCandidate {
physical_device: vk::PhysicalDevice,
capability: VulkanCapabilityReport,
surface_formats: Vec<VulkanSurfaceFormat>,
present_modes: Vec<i32>,
surface_capabilities: VulkanSwapchainSurfaceCapabilities,
}
/// Probes live Vulkan device, queue, surface and swapchain capabilities.
///
/// # Errors
///
/// Returns [`VulkanRuntimeCapabilityError`] when device enumeration, surface
/// capability queries, Stage 0 device selection, or swapchain planning fails.
pub fn probe_vulkan_runtime_capabilities(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
drawable_extent: (u32, u32),
) -> Result<VulkanRuntimeCapabilityProbe, VulkanRuntimeCapabilityError> {
let selected = select_live_device_candidate_for_request(
instance,
surface,
drawable_extent,
RenderRequest::conservative(),
)?;
Ok(selected.runtime)
}
/// Probes live Vulkan device, queue, surface and swapchain capabilities for a
/// specific Stage 0 render request.
///
/// # Errors
///
/// Returns [`VulkanRuntimeCapabilityError`] when device enumeration, surface
/// capability queries, Stage 0 device selection, or swapchain planning fails.
pub fn probe_vulkan_runtime_capabilities_for_request(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
drawable_extent: (u32, u32),
render_request: RenderRequest,
) -> Result<VulkanRuntimeCapabilityProbe, VulkanRuntimeCapabilityError> {
let selected = select_live_device_candidate_for_request(
instance,
surface,
drawable_extent,
render_request,
)?;
Ok(selected.runtime)
}
pub(super) fn select_live_device_candidate_for_request(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
drawable_extent: (u32, u32),
render_request: RenderRequest,
) -> Result<SelectedLiveDevice, VulkanRuntimeCapabilityError> {
let devices = {
// SAFETY: The Vulkan instance is live for this query and no handles are retained.
unsafe { instance.instance.enumerate_physical_devices() }.map_err(|error| {
VulkanRuntimeCapabilityError::EnumerateDevicesFailed { result: error }
})?
};
let mut best: Option<LiveDeviceCandidate> = None;
let mut last_error = None;
for (index, device) in devices.iter().copied().enumerate() {
let candidate =
match live_device_candidate(instance, surface, device, index, render_request) {
Ok(candidate) => candidate,
Err(err) => {
last_error = Some(err);
continue;
}
};
match &best {
Some(existing)
if compare_reports(&candidate.capability, &existing.capability)
!= std::cmp::Ordering::Greater => {}
_ => best = Some(candidate),
}
}
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,
present_modes: best.present_modes,
capabilities: best.surface_capabilities,
preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(),
})
.map_err(VulkanRuntimeCapabilityError::Swapchain)?;
Ok(SelectedLiveDevice {
physical_device: best.physical_device,
runtime: VulkanRuntimeCapabilityProbe {
capability: best.capability,
swapchain,
},
})
}
fn live_device_candidate(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
device: vk::PhysicalDevice,
index: usize,
render_request: RenderRequest,
) -> Result<LiveDeviceCandidate, VulkanRuntimeCapabilityError> {
let properties = {
// SAFETY: `device` was returned by this live instance and the result is copied by value.
unsafe { instance.instance.get_physical_device_properties(device) }
};
let name = physical_device_name(&properties, index);
let queue_properties = {
// SAFETY: `device` was returned by this live instance and the result is owned by Rust.
unsafe {
instance
.instance
.get_physical_device_queue_family_properties(device)
}
};
let extensions = live_device_extensions(instance, device, &name)?;
let surface_formats = live_surface_formats(surface, device, &name)?;
let present_modes = live_present_modes(surface, device, &name)?;
let surface_capabilities = live_surface_capabilities(surface, device, &name)?;
let supported_depth_stencil_formats = live_depth_stencil_formats(instance, device);
let sampled_image_formats = live_sampled_image_formats(instance, device);
let queue_families = queue_properties
.iter()
.enumerate()
.map(|(queue_index, properties)| {
let index = u32::try_from(queue_index).unwrap_or(u32::MAX);
let present = {
// SAFETY: The physical device, surface and queue-family index are live query inputs.
unsafe {
surface.loader.get_physical_device_surface_support(
device,
index,
surface.surface,
)
}
}
.map_err(|error| VulkanRuntimeCapabilityError::PresentSupportFailed {
device: name.clone(),
queue_family: index,
result: error,
})?;
Ok(VulkanQueueFamily {
index,
graphics: properties.queue_flags.contains(vk::QueueFlags::GRAPHICS),
present,
})
})
.collect::<Result<Vec<_>, VulkanRuntimeCapabilityError>>()?;
let record = VulkanPhysicalDeviceRecord {
name,
api_version: properties.api_version,
device_type: match properties.device_type {
vk::PhysicalDeviceType::DISCRETE_GPU => VulkanDeviceType::DiscreteGpu,
vk::PhysicalDeviceType::INTEGRATED_GPU => VulkanDeviceType::IntegratedGpu,
vk::PhysicalDeviceType::CPU => VulkanDeviceType::Cpu,
_ => VulkanDeviceType::Other,
},
extensions,
queue_families,
surface_formats: surface_formats.clone(),
present_modes: present_modes.clone(),
surface_capabilities,
supported_depth_stencil_formats,
sampled_image_formats,
limits: VulkanDeviceLimits {
max_image_dimension_2d: properties.limits.max_image_dimension2_d,
max_sampler_allocation_count: properties.limits.max_sampler_allocation_count,
max_per_stage_descriptor_samplers: properties.limits.max_per_stage_descriptor_samplers,
max_bound_descriptor_sets: properties.limits.max_bound_descriptor_sets,
},
};
let capability = validate_device_for_request(&record, render_request)
.map_err(VulkanRuntimeCapabilityError::Capability)?;
Ok(LiveDeviceCandidate {
physical_device: device,
capability,
surface_formats,
present_modes,
surface_capabilities,
})
}
pub(super) fn unique_queue_families(graphics: u32, present: u32) -> Vec<u32> {
if graphics == present {
vec![graphics]
} else {
vec![graphics, present]
}
}
fn physical_device_name(properties: &vk::PhysicalDeviceProperties, index: usize) -> String {
// SAFETY: Vulkan device names are fixed-size NUL-terminated C strings per the spec.
let name = unsafe { CStr::from_ptr(properties.device_name.as_ptr()) }
.to_string_lossy()
.trim()
.to_string();
if name.is_empty() {
format!("physical-device-{index}")
} else {
name
}
}
fn live_device_extensions(
instance: &VulkanInstanceProbe,
device: vk::PhysicalDevice,
name: &str,
) -> Result<Vec<String>, VulkanRuntimeCapabilityError> {
let properties = {
// SAFETY: `device` was returned by this live instance and no borrowed data escapes.
unsafe {
instance
.instance
.enumerate_device_extension_properties(device)
}
}
.map_err(
|error| VulkanRuntimeCapabilityError::EnumerateDeviceExtensionsFailed {
device: name.to_string(),
result: error,
},
)?;
let mut extensions = properties
.iter()
.map(|property| {
// SAFETY: Vulkan extension names are fixed-size NUL-terminated C strings per the spec.
unsafe { CStr::from_ptr(property.extension_name.as_ptr()) }
.to_string_lossy()
.into_owned()
})
.collect::<Vec<_>>();
extensions.sort();
extensions.dedup();
Ok(extensions)
}
pub(super) fn live_surface_formats(
surface: &VulkanSurfaceProbe,
device: vk::PhysicalDevice,
name: &str,
) -> Result<Vec<VulkanSurfaceFormat>, VulkanRuntimeCapabilityError> {
let formats = {
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
unsafe {
surface
.loader
.get_physical_device_surface_formats(device, surface.surface)
}
}
.map_err(|error| VulkanRuntimeCapabilityError::SurfaceFormatsFailed {
device: name.to_string(),
result: error,
})?;
Ok(formats
.into_iter()
.map(|format| VulkanSurfaceFormat {
format: format.format.as_raw(),
color_space: format.color_space.as_raw(),
})
.collect())
}
pub(super) fn live_present_modes(
surface: &VulkanSurfaceProbe,
device: vk::PhysicalDevice,
name: &str,
) -> Result<Vec<i32>, VulkanRuntimeCapabilityError> {
let modes = {
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
unsafe {
surface
.loader
.get_physical_device_surface_present_modes(device, surface.surface)
}
}
.map_err(|error| VulkanRuntimeCapabilityError::PresentModesFailed {
device: name.to_string(),
result: error,
})?;
Ok(modes.into_iter().map(vk::PresentModeKHR::as_raw).collect())
}
pub(super) fn live_surface_capabilities(
surface: &VulkanSurfaceProbe,
device: vk::PhysicalDevice,
name: &str,
) -> Result<VulkanSwapchainSurfaceCapabilities, VulkanRuntimeCapabilityError> {
let capabilities = {
// SAFETY: The physical device and surface are live query inputs and no handles are retained.
unsafe {
surface
.loader
.get_physical_device_surface_capabilities(device, surface.surface)
}
}
.map_err(
|error| VulkanRuntimeCapabilityError::SurfaceCapabilitiesFailed {
device: name.to_string(),
result: error,
},
)?;
Ok(VulkanSwapchainSurfaceCapabilities {
current_extent: if capabilities.current_extent.width == u32::MAX {
None
} else {
Some((
capabilities.current_extent.width,
capabilities.current_extent.height,
))
},
min_extent: (
capabilities.min_image_extent.width,
capabilities.min_image_extent.height,
),
max_extent: (
capabilities.max_image_extent.width,
capabilities.max_image_extent.height,
),
min_image_count: capabilities.min_image_count,
max_image_count: capabilities.max_image_count,
supported_usage_flags: capabilities.supported_usage_flags.as_raw(),
})
}
fn live_depth_stencil_formats(
instance: &VulkanInstanceProbe,
device: vk::PhysicalDevice,
) -> Vec<i32> {
[
vk::Format::D16_UNORM,
vk::Format::X8_D24_UNORM_PACK32,
vk::Format::D32_SFLOAT,
vk::Format::S8_UINT,
vk::Format::D16_UNORM_S8_UINT,
vk::Format::D24_UNORM_S8_UINT,
vk::Format::D32_SFLOAT_S8_UINT,
]
.into_iter()
.filter(|format| {
let properties = {
// SAFETY: `device` belongs to `instance`; format-property queries copy data by value.
unsafe {
instance
.instance
.get_physical_device_format_properties(device, *format)
}
};
properties
.optimal_tiling_features
.contains(vk::FormatFeatureFlags::DEPTH_STENCIL_ATTACHMENT)
})
.map(vk::Format::as_raw)
.collect()
}
fn live_sampled_image_formats(
instance: &VulkanInstanceProbe,
device: vk::PhysicalDevice,
) -> Vec<i32> {
[
vk::Format::R8G8B8A8_SRGB,
vk::Format::B8G8R8A8_SRGB,
vk::Format::D16_UNORM,
vk::Format::D32_SFLOAT,
vk::Format::D24_UNORM_S8_UINT,
vk::Format::D32_SFLOAT_S8_UINT,
]
.into_iter()
.filter(|format| {
let properties = {
// SAFETY: `device` belongs to `instance`; format-property queries copy data by value.
unsafe {
instance
.instance
.get_physical_device_format_properties(device, *format)
}
};
properties
.optimal_tiling_features
.contains(vk::FormatFeatureFlags::SAMPLED_IMAGE)
})
.map(vk::Format::as_raw)
.collect()
}
@@ -0,0 +1,390 @@
#![allow(unsafe_code)]
use ash::vk;
use serde::Serialize;
use std::collections::BTreeSet;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use super::{
EXT_DEBUG_UTILS_EXTENSION, KHR_PORTABILITY_ENUMERATION_EXTENSION, MIN_VULKAN_API_VERSION,
VALIDATION_LAYER_NAME,
};
use crate::policy::{format_api_version, serialize_json_or_fallback};
/// Vulkan instance bootstrap configuration.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanInstanceConfig {
/// Application name reported to the loader.
pub application_name: String,
/// Required instance extensions, usually including surface extensions.
pub required_extensions: Vec<String>,
/// Whether `VK_KHR_portability_enumeration` and its create flag are enabled.
pub enable_portability_enumeration: bool,
/// Whether validation layers are requested.
pub enable_validation: bool,
}
impl VulkanInstanceConfig {
/// Returns a conservative instance configuration for smoke probes.
#[must_use]
pub fn smoke(application_name: impl Into<String>) -> Self {
Self {
application_name: application_name.into(),
required_extensions: Vec::new(),
enable_portability_enumeration: cfg!(target_os = "macos"),
enable_validation: false,
}
}
}
/// Deterministic Vulkan instance creation plan.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanInstancePlan {
/// Report schema version.
pub schema: u32,
/// Instance extensions requested at creation time.
pub enabled_extensions: Vec<String>,
/// Raw Vulkan instance creation flags.
pub create_flags: u32,
/// Whether validation was requested.
pub validation_requested: bool,
}
/// Created Vulkan instance probe.
pub struct VulkanInstanceProbe {
pub(super) entry: ash::Entry,
pub(super) instance: ash::Instance,
/// Deterministic instance creation report.
pub report: VulkanInstancePlan,
}
impl Drop for VulkanInstanceProbe {
fn drop(&mut self) {
// SAFETY: The `Instance` was created by this probe and is destroyed once during drop.
unsafe { self.instance.destroy_instance(None) };
}
}
/// Vulkan instance bootstrap error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanInstanceError {
/// The Vulkan loader could not be opened.
Loader(VulkanLoaderError),
/// Application name contained an interior NUL byte.
InvalidApplicationName,
/// An extension name contained an interior NUL byte.
InvalidExtensionName {
/// Invalid extension name.
extension: String,
},
/// A required instance extension is unavailable from the loader.
MissingInstanceExtension {
/// Required extension name.
extension: String,
},
/// Validation layers were requested but unavailable.
MissingValidationLayer,
/// Instance creation failed.
CreateFailed {
/// Vulkan result.
result: vk::Result,
},
}
impl std::fmt::Display for VulkanInstanceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Loader(error) => write!(f, "{error}"),
Self::InvalidApplicationName => {
write!(f, "Vulkan application name contains an interior NUL byte")
}
Self::InvalidExtensionName { extension } => {
write!(
f,
"Vulkan instance extension name contains an interior NUL byte: {extension:?}"
)
}
Self::MissingInstanceExtension { extension } => {
write!(f, "Vulkan instance extension {extension} is unavailable")
}
Self::MissingValidationLayer => {
write!(
f,
"Vulkan validation layer VK_LAYER_KHRONOS_validation is unavailable"
)
}
Self::CreateFailed { result } => {
write!(f, "Vulkan instance creation failed: {result:?}")
}
}
}
}
impl std::error::Error for VulkanInstanceError {}
/// Deterministic Vulkan loader probe report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanLoaderProbeReport {
/// Report schema version.
pub schema: u32,
/// Whether the Vulkan loader was opened successfully.
pub loader_available: bool,
/// Reported loader instance API version.
pub instance_api_version: u32,
}
/// Vulkan loader bootstrap error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanLoaderError {
/// The Vulkan loader library could not be opened.
Unavailable {
/// Loader error text.
message: String,
},
}
impl std::fmt::Display for VulkanLoaderError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Unavailable { message } => {
write!(f, "Vulkan loader is unavailable: {message}")
}
}
}
}
impl std::error::Error for VulkanLoaderError {}
/// Builds the deterministic instance creation plan without touching the loader.
#[must_use]
pub fn plan_vulkan_instance(config: &VulkanInstanceConfig) -> VulkanInstancePlan {
let mut enabled_extensions = config.required_extensions.clone();
if config.enable_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()
.any(|extension| extension == KHR_PORTABILITY_ENUMERATION_EXTENSION)
{
enabled_extensions.push(KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string());
}
enabled_extensions.sort();
enabled_extensions.dedup();
VulkanInstancePlan {
schema: 1,
enabled_extensions,
create_flags: if config.enable_portability_enumeration {
vk::InstanceCreateFlags::ENUMERATE_PORTABILITY_KHR.as_raw()
} else {
0
},
validation_requested: config.enable_validation,
}
}
/// Creates a Vulkan instance probe from the supplied configuration.
///
/// # Errors
///
/// Returns [`VulkanInstanceError`] when the loader is unavailable, names are not
/// valid C strings, or `vkCreateInstance` fails.
pub fn create_vulkan_instance_probe(
config: &VulkanInstanceConfig,
) -> Result<VulkanInstanceProbe, VulkanInstanceError> {
// SAFETY: Loading the entry only resolves loader symbols; no raw Vulkan handles escape.
let entry = unsafe { ash::Entry::load() }.map_err(|error| {
VulkanInstanceError::Loader(VulkanLoaderError::Unavailable {
message: error.to_string(),
})
})?;
let app_name = CString::new(config.application_name.clone())
.map_err(|_| VulkanInstanceError::InvalidApplicationName)?;
let engine_name = c"fparkan";
let plan = plan_vulkan_instance(config);
let available_extensions = available_instance_extensions(&entry)?;
ensure_instance_extensions_available(&plan.enabled_extensions, &available_extensions)?;
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)
.engine_name(engine_name)
.engine_version(0)
.api_version(MIN_VULKAN_API_VERSION);
let create_info = vk::InstanceCreateInfo::default()
.application_info(&app_info)
.enabled_extension_names(&extension_ptrs)
.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| VulkanInstanceError::CreateFailed { result: error })?;
Ok(VulkanInstanceProbe {
entry,
instance,
report: plan,
})
}
/// Renders a deterministic JSON Vulkan instance plan.
#[must_use]
pub fn render_instance_plan_json(plan: &VulkanInstancePlan) -> String {
#[derive(Serialize)]
struct InstancePlanJson<'a> {
schema: u32,
create_flags: u32,
validation_requested: bool,
enabled_extensions: &'a [String],
}
serialize_json_or_fallback(
&InstancePlanJson {
schema: plan.schema,
create_flags: plan.create_flags,
validation_requested: plan.validation_requested,
enabled_extensions: &plan.enabled_extensions,
},
"{\"schema\":0,\"create_flags\":0,\"validation_requested\":false,\"enabled_extensions\":[]}",
)
}
/// Opens the Vulkan loader and reports the supported instance API version.
///
/// # Errors
///
/// Returns [`VulkanLoaderError`] when no Vulkan loader library can be opened on
/// the host.
pub fn probe_vulkan_loader() -> Result<VulkanLoaderProbeReport, VulkanLoaderError> {
// SAFETY: Loading the entry only resolves loader symbols; no raw Vulkan handles escape.
let entry = unsafe { ash::Entry::load() }.map_err(|error| VulkanLoaderError::Unavailable {
message: error.to_string(),
})?;
// SAFETY: The resolved entry only queries the loader-supported instance API version.
let version = unsafe { entry.try_enumerate_instance_version() }
.map_err(|error| VulkanLoaderError::Unavailable {
message: error.to_string(),
})?
.unwrap_or(vk::API_VERSION_1_0);
Ok(VulkanLoaderProbeReport {
schema: 1,
loader_available: true,
instance_api_version: version,
})
}
/// Returns the static Vulkan entry name used by loader probes.
#[must_use]
pub fn vulkan_entry_symbol_name() -> &'static CStr {
c"vkGetInstanceProcAddr"
}
/// Renders a deterministic JSON Vulkan loader report.
#[must_use]
pub fn render_loader_probe_report_json(report: &VulkanLoaderProbeReport) -> String {
#[derive(Serialize)]
struct LoaderProbeReportJson {
schema: u32,
loader_available: bool,
instance_api: String,
}
serialize_json_or_fallback(
&LoaderProbeReportJson {
schema: report.schema,
loader_available: report.loader_available,
instance_api: format_api_version(report.instance_api_version),
},
"{\"schema\":0,\"loader_available\":false,\"instance_api\":\"0.0.0\"}",
)
}
fn available_instance_extensions(entry: &ash::Entry) -> Result<Vec<String>, VulkanInstanceError> {
let available_extensions =
// SAFETY: Enumerating instance extensions reads loader-owned immutable metadata.
unsafe { entry.enumerate_instance_extension_properties(None) }.map_err(|error| {
VulkanInstanceError::CreateFailed {
result: error,
}
})?;
available_extensions
.into_iter()
.map(|extension| {
// SAFETY: Vulkan extension names are fixed-size NUL-terminated strings from the loader.
Ok(unsafe { CStr::from_ptr(extension.extension_name.as_ptr()) }
.to_string_lossy()
.into_owned())
})
.collect()
}
pub(super) fn ensure_instance_extensions_available(
required_extensions: &[String],
available_extensions: &[String],
) -> Result<(), VulkanInstanceError> {
let available = available_extensions
.iter()
.map(String::as_str)
.collect::<BTreeSet<_>>();
for extension in required_extensions {
if !available.contains(extension.as_str()) {
return Err(VulkanInstanceError::MissingInstanceExtension {
extension: extension.clone(),
});
}
}
Ok(())
}
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: 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
})?])
}
pub(super) fn cstring_vec(values: &[String]) -> Result<Vec<CString>, VulkanInstanceError> {
values
.iter()
.map(|extension| {
CString::new(extension.as_str()).map_err(|_| {
VulkanInstanceError::InvalidExtensionName {
extension: extension.clone(),
}
})
})
.collect()
}
fn cstring_ptrs(values: &[CString]) -> Vec<*const c_char> {
values.iter().map(|value| value.as_ptr()).collect()
}
@@ -0,0 +1,265 @@
#![allow(unsafe_code)]
use ash::vk;
use super::{VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError};
pub(super) struct VulkanAllocatedBuffer {
pub(super) buffer: vk::Buffer,
pub(super) memory: vk::DeviceMemory,
}
pub(super) struct VulkanFrameSync {
pub(super) image_available: vk::Semaphore,
pub(super) render_finished: vk::Semaphore,
pub(super) fence: vk::Fence,
}
pub(super) 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: error,
}
})
}
pub(super) 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",
)
}
pub(super) 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: 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 Some(memory_type_index) = find_memory_type(
instance,
device.physical_device(),
requirements.memory_type_bits,
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
) else {
// SAFETY: The buffer was created above on this logical device and is destroyed on setup failure.
unsafe { device.device().destroy_buffer(buffer, None) };
return Err(VulkanSmokeRendererError::MissingMemoryType { context });
};
let allocate_info = vk::MemoryAllocateInfo::default()
.allocation_size(requirements.size)
.memory_type_index(memory_type_index);
let memory =
// SAFETY: The allocation request matches the queried memory requirements for this buffer.
unsafe { device.device().allocate_memory(&allocate_info, None) }.map_err(|error| {
// SAFETY: The buffer was created above on this logical device and is destroyed on setup failure.
unsafe { device.device().destroy_buffer(buffer, None) };
VulkanSmokeRendererError::VulkanOperation {
context,
result: error,
}
})?;
// SAFETY: The allocation satisfies the queried buffer memory requirements for this device.
unsafe { device.device().bind_buffer_memory(buffer, memory, 0) }.map_err(|error| {
// SAFETY: The buffer and allocation were created above on this logical device and are destroyed on setup failure.
unsafe {
device.device().destroy_buffer(buffer, None);
device.device().free_memory(memory, None);
}
VulkanSmokeRendererError::VulkanOperation {
context,
result: error,
}
})?;
// SAFETY: The mapping range is within the host-visible allocation bound to the buffer.
let mapped = unsafe {
device
.device()
.map_memory(memory, 0, requirements.size, vk::MemoryMapFlags::empty())
}
.map_err(|error| {
// SAFETY: The buffer and allocation were created above on this logical device and are destroyed on setup failure.
unsafe {
device.device().destroy_buffer(buffer, None);
device.device().free_memory(memory, None);
}
VulkanSmokeRendererError::VulkanOperation {
context,
result: error,
}
})?;
// SAFETY: The destination points to the mapped allocation and the source slice lives for the copy.
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,
type_bits: u32,
required: vk::MemoryPropertyFlags,
) -> Option<u32> {
let properties =
// SAFETY: The physical device was selected from this live instance and queried by value.
unsafe {
instance
.instance
.get_physical_device_memory_properties(physical_device)
};
let count = usize::try_from(properties.memory_type_count).unwrap_or(0);
properties.memory_types[..count]
.iter()
.enumerate()
.find_map(|(index, memory_type)| {
let index_u32 = u32::try_from(index).ok()?;
let supported = (type_bits & (1_u32 << index_u32)) != 0;
(supported && memory_type.property_flags.contains(required)).then_some(index_u32)
})
}
pub(super) 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: error,
})?;
let render_finished = {
// SAFETY: The sync objects belong to this live logical device and are destroyed at teardown.
match unsafe { device.device().create_semaphore(&semaphore_info, None) } {
Ok(render_finished) => render_finished,
Err(error) => {
destroy_frame_sync_objects(device, &sync);
// SAFETY: The semaphore was created above on this logical device and is destroyed on setup failure.
unsafe { device.device().destroy_semaphore(image_available, None) };
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateSemaphore(render_finished)",
result: error,
});
}
}
};
// SAFETY: The fence belongs to this live logical device and is destroyed at teardown.
let fence = match unsafe { device.device().create_fence(&fence_info, None) } {
Ok(fence) => fence,
Err(error) => {
destroy_frame_sync_objects(device, &sync);
// SAFETY: These semaphores were created above on this logical device and are destroyed on setup failure.
unsafe {
device.device().destroy_semaphore(image_available, None);
device.device().destroy_semaphore(render_finished, None);
}
return Err(VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateFence",
result: error,
});
}
};
sync.push(VulkanFrameSync {
image_available,
render_finished,
fence,
});
}
Ok(sync)
}
fn destroy_frame_sync_objects(device: &VulkanLogicalDeviceProbe, sync: &[VulkanFrameSync]) {
for frame_sync in sync {
// SAFETY: These sync objects belong to this live logical device and are destroyed once during teardown.
unsafe {
device
.device()
.destroy_semaphore(frame_sync.image_available, None);
device
.device()
.destroy_semaphore(frame_sync.render_finished, None);
device.device().destroy_fence(frame_sync.fence, None);
}
}
}
pub(super) fn destroy_allocated_buffer(
device: &VulkanLogicalDeviceProbe,
buffer: &VulkanAllocatedBuffer,
) {
// SAFETY: The buffer and allocation belong to this live logical device and are destroyed once during teardown.
unsafe {
device.device().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, None);
}
}
pub(super) 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)
}
@@ -0,0 +1,212 @@
#![allow(unsafe_code)]
use ash::vk;
use fparkan_platform::RenderRequest;
use std::ffi::CString;
use super::capabilities::{
select_live_device_candidate_for_request, unique_queue_families, VulkanRuntimeCapabilityError,
VulkanRuntimeCapabilityProbe,
};
use super::{VulkanInstanceProbe, VulkanSurfaceProbe};
/// Created Vulkan logical device probe.
pub struct VulkanLogicalDeviceProbe {
device: ash::Device,
physical_device: vk::PhysicalDevice,
/// Runtime capability report used for device selection.
pub runtime: VulkanRuntimeCapabilityProbe,
/// Deterministic logical device creation report.
pub report: VulkanLogicalDeviceReport,
}
impl Drop for VulkanLogicalDeviceProbe {
fn drop(&mut self) {
// SAFETY: The logical device was created by this probe and is destroyed once during drop.
unsafe { self.device.destroy_device(None) };
}
}
impl VulkanLogicalDeviceProbe {
/// Returns the graphics queue selected by the Stage 0 policy.
#[must_use]
pub fn graphics_queue(&self) -> vk::Queue {
// SAFETY: The queue-family index belongs to this live logical device.
unsafe {
self.device
.get_device_queue(self.report.graphics_queue_family, 0)
}
}
/// Returns the presentation queue selected by the Stage 0 policy.
#[must_use]
pub fn present_queue(&self) -> vk::Queue {
// SAFETY: The queue-family index belongs to this live logical device.
unsafe {
self.device
.get_device_queue(self.report.present_queue_family, 0)
}
}
/// Returns a shared reference to the live logical device.
#[must_use]
pub fn device(&self) -> &ash::Device {
&self.device
}
/// Returns the selected physical device handle.
#[must_use]
pub fn physical_device(&self) -> vk::PhysicalDevice {
self.physical_device
}
}
/// Logical device creation report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanLogicalDeviceReport {
/// Report schema version.
pub schema: u32,
/// Selected physical device name.
pub device_name: String,
/// Graphics queue-family index used by the logical device.
pub graphics_queue_family: u32,
/// Present queue-family index used by the logical device.
pub present_queue_family: u32,
/// Enabled device extensions.
pub enabled_extensions: Vec<String>,
}
/// Vulkan logical device creation error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanLogicalDeviceError {
/// Runtime capability probing failed.
Runtime(VulkanRuntimeCapabilityError),
/// Device extension name contained an interior NUL byte.
InvalidExtensionName {
/// Invalid extension name.
extension: String,
},
/// Logical device creation failed.
CreateFailed {
/// Selected device name.
device: String,
/// Vulkan result.
result: vk::Result,
},
}
impl std::fmt::Display for VulkanLogicalDeviceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Runtime(error) => write!(f, "{error}"),
Self::InvalidExtensionName { extension } => write!(
f,
"Vulkan device extension name contains an interior NUL byte: {extension:?}"
),
Self::CreateFailed { device, result } => {
write!(
f,
"Vulkan logical device creation failed for {device}: {result:?}"
)
}
}
}
}
impl std::error::Error for VulkanLogicalDeviceError {}
/// Creates a Vulkan logical device for the selected live surface-capable device.
///
/// # Errors
///
/// Returns [`VulkanLogicalDeviceError`] when runtime capability probing fails,
/// device extension names are invalid, or `vkCreateDevice` fails.
pub fn create_vulkan_logical_device_probe(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
drawable_extent: (u32, u32),
) -> Result<VulkanLogicalDeviceProbe, VulkanLogicalDeviceError> {
create_vulkan_logical_device_probe_for_request(
instance,
surface,
drawable_extent,
RenderRequest::conservative(),
)
}
/// Creates a Vulkan logical device for a specific Stage 0 render request.
///
/// # Errors
///
/// Returns [`VulkanLogicalDeviceError`] when runtime capability probing fails,
/// device extension names are invalid, or `vkCreateDevice` fails.
pub fn create_vulkan_logical_device_probe_for_request(
instance: &VulkanInstanceProbe,
surface: &VulkanSurfaceProbe,
drawable_extent: (u32, u32),
render_request: RenderRequest,
) -> Result<VulkanLogicalDeviceProbe, VulkanLogicalDeviceError> {
let selected = select_live_device_candidate_for_request(
instance,
surface,
drawable_extent,
render_request,
)
.map_err(VulkanLogicalDeviceError::Runtime)?;
let capability = &selected.runtime.capability;
let queue_priorities = [1.0_f32];
let queue_families = unique_queue_families(
capability.graphics_queue_family,
capability.present_queue_family,
);
let queue_infos = queue_families
.iter()
.map(|queue_family| {
vk::DeviceQueueCreateInfo::default()
.queue_family_index(*queue_family)
.queue_priorities(&queue_priorities)
})
.collect::<Vec<_>>();
let extension_names = device_extension_cstrings(&capability.enabled_extensions)
.map_err(|extension| VulkanLogicalDeviceError::InvalidExtensionName { extension })?;
let extension_ptrs = extension_names
.iter()
.map(|extension| extension.as_ptr())
.collect::<Vec<_>>();
let create_info = vk::DeviceCreateInfo::default()
.queue_create_infos(&queue_infos)
.enabled_extension_names(&extension_ptrs);
// SAFETY: `selected.physical_device` belongs to `instance`; create data lives for the call.
let device = unsafe {
instance
.instance
.create_device(selected.physical_device, &create_info, None)
}
.map_err(|error| VulkanLogicalDeviceError::CreateFailed {
device: capability.device_name.clone(),
result: error,
})?;
// SAFETY: Queue family indices came from validated live queue families requested above.
let _graphics_queue = unsafe { device.get_device_queue(capability.graphics_queue_family, 0) };
// SAFETY: Queue family indices came from validated live queue families requested above.
let _present_queue = unsafe { device.get_device_queue(capability.present_queue_family, 0) };
Ok(VulkanLogicalDeviceProbe {
device,
physical_device: selected.physical_device,
report: VulkanLogicalDeviceReport {
schema: 1,
device_name: capability.device_name.clone(),
graphics_queue_family: capability.graphics_queue_family,
present_queue_family: capability.present_queue_family,
enabled_extensions: capability.enabled_extensions.clone(),
},
runtime: selected.runtime,
})
}
fn device_extension_cstrings(values: &[String]) -> Result<Vec<CString>, String> {
values
.iter()
.map(|extension| CString::new(extension.as_str()).map_err(|_| extension.clone()))
.collect()
}
@@ -0,0 +1,914 @@
#![allow(unsafe_code)]
use ash::vk;
use super::{
create_command_pool, create_frame_sync, create_swapchain_resources,
create_triangle_index_buffer, create_triangle_vertex_buffer, create_validation_messenger,
create_vulkan_instance_probe, create_vulkan_logical_device_probe_for_request,
create_vulkan_surface_probe, create_vulkan_swapchain_probe_for_extent,
destroy_allocated_buffer, destroy_swapchain_resources, plan_vulkan_surface,
VulkanAllocatedBuffer, VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe,
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainResources, VulkanValidationMessenger,
VulkanValidationReport,
};
use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION;
use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest};
fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device, Swapchain>(
instance: &mut Option<Instance>,
validation: &mut Option<Validation>,
surface: &mut Option<Surface>,
device: &mut Option<Device>,
swapchain: &mut Option<Swapchain>,
) {
swapchain.take();
device.take();
surface.take();
validation.take();
instance.take();
}
fn take_runtime_owners_with_validation_snapshot<
Instance,
Validation,
Surface,
Device,
Swapchain,
Snapshot,
Capture,
>(
instance: &mut Option<Instance>,
validation: &mut Option<Validation>,
surface: &mut Option<Surface>,
device: &mut Option<Device>,
swapchain: &mut Option<Swapchain>,
capture: Capture,
) -> Option<Snapshot>
where
Capture: FnOnce(&Validation) -> Snapshot,
{
let snapshot = validation.as_ref().map(capture);
take_runtime_owners_in_dependency_order(instance, validation, surface, device, swapchain);
snapshot
}
struct RollbackOnDrop<T, F>
where
F: FnOnce(T),
{
value: Option<T>,
rollback: Option<F>,
}
impl<T, F> RollbackOnDrop<T, F>
where
F: FnOnce(T),
{
fn new(value: T, rollback: F) -> Self {
Self {
value: Some(value),
rollback: Some(rollback),
}
}
fn commit(mut self) -> T {
self.rollback.take();
match self.value.take() {
Some(value) => value,
None => unreachable!("rollback guard must hold a value until commit"),
}
}
}
impl<T, F> Drop for RollbackOnDrop<T, F>
where
F: FnOnce(T),
{
fn drop(&mut self) {
if let (Some(value), Some(rollback)) = (self.value.take(), self.rollback.take()) {
rollback(value);
}
}
}
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.
#[allow(clippy::too_many_lines)]
pub fn new(
create_info: &VulkanSmokeRendererCreateInfo,
) -> Result<Self, VulkanSmokeRendererError> {
let bootstrap_progress = create_info.bootstrap_progress.as_ref();
let shader_manifest = 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)?;
if let Some(progress) = bootstrap_progress {
progress.mark_loader_available();
progress.mark_instance_created();
}
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)?;
if let Some(progress) = bootstrap_progress {
progress.mark_surface_created();
}
let device = create_vulkan_logical_device_probe_for_request(
&instance,
&surface,
create_info.drawable_extent,
create_info.render_request,
)
.map_err(VulkanSmokeRendererError::LogicalDevice)?;
if let Some(progress) = bootstrap_progress {
progress.mark_logical_device_created();
}
let swapchain = create_vulkan_swapchain_probe_for_extent(
&instance,
&surface,
&device,
create_info.drawable_extent,
vk::SwapchainKHR::null(),
)
.map_err(VulkanSmokeRendererError::Swapchain)?;
if let Some(progress) = bootstrap_progress {
progress.mark_swapchain_created();
}
let command_pool = create_command_pool(&device)?;
let vertex_buffer = match create_triangle_vertex_buffer(&instance, &device) {
Ok(buffer) => buffer,
Err(error) => {
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure.
unsafe { device.device().destroy_command_pool(command_pool, None) };
return Err(error);
}
};
let index_buffer = match create_triangle_index_buffer(&instance, &device) {
Ok(buffer) => buffer,
Err(error) => {
// SAFETY: The command pool belongs to this live logical device and is destroyed on setup failure.
unsafe { device.device().destroy_command_pool(command_pool, None) };
destroy_allocated_buffer(&device, &vertex_buffer);
return Err(error);
}
};
let mut renderer = Self {
instance: Some(instance),
validation,
surface: Some(surface),
device: Some(device),
swapchain: Some(swapchain),
command_pool,
swapchain_resources: None,
vertex_buffer: Some(vertex_buffer),
index_buffer: Some(index_buffer),
frame_sync: Vec::new(),
images_in_flight: Vec::new(),
current_frame: 0,
pending_extent: None,
swapchain_recreate_count: 0,
report: VulkanSmokeRendererReport {
shader_manifest_hash: shader_manifest.manifest_hash.clone(),
portability_enumeration: instance_config.enable_portability_enumeration,
portability_subset_enabled: false,
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 {
shader_manifest_hash: shader_manifest.manifest_hash,
portability_enumeration: renderer
.instance
.as_ref()
.is_some_and(|instance| instance.report.create_flags != 0),
portability_subset_enabled: device_ref
.report
.enabled_extensions
.iter()
.any(|extension| extension == KHR_PORTABILITY_SUBSET_EXTENSION),
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
}
/// Explicitly idles and tears down the renderer while the native window is still alive.
///
/// # Errors
///
/// Returns [`VulkanSmokeRendererError`] when the renderer cannot reach a
/// stable idle point before teardown begins.
pub fn shutdown(mut self) -> Result<VulkanSmokeShutdownReport, VulkanSmokeRendererError> {
self.shutdown_inner()
}
/// 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: 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: 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: 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: 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: 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: 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: 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 device = self.device_ref()?;
let swapchain = self.swapchain_ref()?;
let resources = RollbackOnDrop::new(
create_swapchain_resources(
device,
swapchain,
self.command_pool,
self.vertex_buffer_ref()?,
self.index_buffer_ref()?,
reuse_command_pool,
)?,
|resources| destroy_swapchain_resources(device, self.command_pool, resources),
);
let frame_sync = create_frame_sync(device)?;
let swapchain_extent = self.swapchain_ref()?.report.plan.extent;
let swapchain_image_count = self.swapchain_ref()?.report.image_count;
let resources = resources.commit();
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: 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: error,
})?;
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);
}
// SAFETY: The render pass owns the attachment layout transitions for this clear-and-present path.
unsafe { device.device().end_command_buffer(command_buffer) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkEndCommandBuffer",
result: 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);
}
self.images_in_flight.clear();
self.current_frame = 0;
}
fn destroy_device_owned_resources(&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 after the device has been idled and frame work has been torn down.
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 after the device has been idled and frame work has been torn down.
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 the device is idle and all command buffers allocated from it were freed above.
unsafe {
device
.device()
.destroy_command_pool(self.command_pool, None);
};
}
self.pending_extent = None;
}
fn shutdown_inner(&mut self) -> Result<VulkanSmokeShutdownReport, VulkanSmokeRendererError> {
if let Some(device) = self.device.as_ref() {
// SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts.
unsafe { device.device().device_wait_idle() }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkDeviceWaitIdle",
result: error,
}
})?;
}
self.destroy_device_owned_resources();
let validation = take_runtime_owners_with_validation_snapshot(
&mut self.instance,
&mut self.validation,
&mut self.surface,
&mut self.device,
&mut self.swapchain,
VulkanValidationMessenger::report,
)
.unwrap_or_default();
Ok(VulkanSmokeShutdownReport {
renderer_report: self.report.clone(),
swapchain_recreate_count: self.swapchain_recreate_count,
validation,
})
}
fn teardown(&mut self) {
if let Some(device) = self.device.as_ref() {
// SAFETY: The logical device remains live until teardown finishes and idling prevents in-flight work from touching swapchain, buffers, sync objects or the command pool after destruction starts.
let _ = unsafe { device.device().device_wait_idle() };
}
self.destroy_device_owned_resources();
let _ = take_runtime_owners_with_validation_snapshot(
&mut self.instance,
&mut self.validation,
&mut self.surface,
&mut self.device,
&mut self.swapchain,
VulkanValidationMessenger::report,
);
}
}
impl Drop for VulkanSmokeRenderer {
fn drop(&mut self) {
self.teardown();
}
}
#[cfg(test)]
mod tests {
use super::{
take_runtime_owners_in_dependency_order, take_runtime_owners_with_validation_snapshot,
RollbackOnDrop,
};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum TeardownStep {
Snapshot,
Instance,
Validation,
Surface,
Device,
Swapchain,
}
struct DropTracker {
step: TeardownStep,
log: Rc<RefCell<Vec<TeardownStep>>>,
}
impl Drop for DropTracker {
fn drop(&mut self) {
self.log.borrow_mut().push(self.step);
}
}
fn tracker(step: TeardownStep, log: &Rc<RefCell<Vec<TeardownStep>>>) -> DropTracker {
DropTracker {
step,
log: Rc::clone(log),
}
}
fn record_teardown_steps(present_steps: &[TeardownStep]) -> Vec<TeardownStep> {
let log = Rc::new(RefCell::new(Vec::new()));
let mut instance = present_steps
.contains(&TeardownStep::Instance)
.then(|| tracker(TeardownStep::Instance, &log));
let mut validation = present_steps
.contains(&TeardownStep::Validation)
.then(|| tracker(TeardownStep::Validation, &log));
let mut surface = present_steps
.contains(&TeardownStep::Surface)
.then(|| tracker(TeardownStep::Surface, &log));
let mut device = present_steps
.contains(&TeardownStep::Device)
.then(|| tracker(TeardownStep::Device, &log));
let mut swapchain = present_steps
.contains(&TeardownStep::Swapchain)
.then(|| tracker(TeardownStep::Swapchain, &log));
take_runtime_owners_in_dependency_order(
&mut instance,
&mut validation,
&mut surface,
&mut device,
&mut swapchain,
);
Rc::into_inner(log)
.expect("all drop trackers released")
.into_inner()
}
#[test]
fn runtime_owners_drop_in_explicit_dependency_order() {
assert_eq!(
record_teardown_steps(&[
TeardownStep::Instance,
TeardownStep::Validation,
TeardownStep::Surface,
TeardownStep::Device,
TeardownStep::Swapchain,
]),
vec![
TeardownStep::Swapchain,
TeardownStep::Device,
TeardownStep::Surface,
TeardownStep::Validation,
TeardownStep::Instance,
]
);
}
#[test]
fn runtime_owners_drop_remaining_children_after_partial_init_failures() {
let cases = [
(vec![TeardownStep::Instance], vec![TeardownStep::Instance]),
(
vec![TeardownStep::Instance, TeardownStep::Validation],
vec![TeardownStep::Validation, TeardownStep::Instance],
),
(
vec![
TeardownStep::Instance,
TeardownStep::Validation,
TeardownStep::Surface,
],
vec![
TeardownStep::Surface,
TeardownStep::Validation,
TeardownStep::Instance,
],
),
(
vec![
TeardownStep::Instance,
TeardownStep::Validation,
TeardownStep::Surface,
TeardownStep::Device,
],
vec![
TeardownStep::Device,
TeardownStep::Surface,
TeardownStep::Validation,
TeardownStep::Instance,
],
),
(
vec![
TeardownStep::Instance,
TeardownStep::Surface,
TeardownStep::Device,
TeardownStep::Swapchain,
],
vec![
TeardownStep::Swapchain,
TeardownStep::Device,
TeardownStep::Surface,
TeardownStep::Instance,
],
),
];
for (present_steps, expected) in cases {
assert_eq!(record_teardown_steps(&present_steps), expected);
}
}
#[test]
fn final_validation_snapshot_is_captured_before_validation_drop() {
let log = Rc::new(RefCell::new(Vec::new()));
let mut instance = Some(tracker(TeardownStep::Instance, &log));
let mut validation = Some(tracker(TeardownStep::Validation, &log));
let mut surface = Some(tracker(TeardownStep::Surface, &log));
let mut device = Some(tracker(TeardownStep::Device, &log));
let mut swapchain = Some(tracker(TeardownStep::Swapchain, &log));
let snapshot = take_runtime_owners_with_validation_snapshot(
&mut instance,
&mut validation,
&mut surface,
&mut device,
&mut swapchain,
|_| {
log.borrow_mut().push(TeardownStep::Snapshot);
TeardownStep::Validation
},
);
assert_eq!(snapshot, Some(TeardownStep::Validation));
assert_eq!(
Rc::into_inner(log)
.expect("all drop trackers released")
.into_inner(),
vec![
TeardownStep::Snapshot,
TeardownStep::Swapchain,
TeardownStep::Device,
TeardownStep::Surface,
TeardownStep::Validation,
TeardownStep::Instance,
]
);
}
#[test]
fn rollback_guard_runs_cleanup_when_later_step_fails() {
let log = Rc::new(RefCell::new(Vec::new()));
{
let _guard = RollbackOnDrop::new(tracker(TeardownStep::Swapchain, &log), |tracker| {
drop(tracker)
});
}
assert_eq!(log.borrow().as_slice(), &[TeardownStep::Swapchain]);
}
#[test]
fn rollback_guard_skips_cleanup_after_commit() {
let log = Rc::new(RefCell::new(Vec::new()));
let tracker = RollbackOnDrop::new(tracker(TeardownStep::Swapchain, &log), |tracker| {
drop(tracker)
})
.commit();
assert!(log.borrow().is_empty());
drop(tracker);
assert_eq!(log.borrow().as_slice(), &[TeardownStep::Swapchain]);
}
}
@@ -0,0 +1,235 @@
use ash::vk;
use fparkan_platform::{NativeWindowHandles, RenderRequest};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::Arc;
use super::{
VulkanAllocatedBuffer, VulkanFrameSync, VulkanInstanceError, VulkanInstanceProbe,
VulkanLogicalDeviceError, VulkanLogicalDeviceProbe, VulkanSurfaceError, VulkanSurfaceProbe,
VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources,
VulkanValidationMessenger,
};
use crate::shader_manifest::VulkanShaderManifestError;
/// 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),
/// Stage 0 render request used for capability gating.
pub render_request: RenderRequest,
/// Whether validation layers must be enabled.
pub enable_validation: bool,
/// Optional shared bootstrap progress tracker for failure evidence.
pub bootstrap_progress: Option<Arc<VulkanSmokeBootstrapProgress>>,
}
/// Shared bootstrap progress used to report partial renderer startup evidence.
#[derive(Debug, Default)]
pub struct VulkanSmokeBootstrapProgress {
flags: AtomicU8,
}
impl VulkanSmokeBootstrapProgress {
/// Marks the Vulkan loader as available.
pub fn mark_loader_available(&self) {
self.set_flag(BOOTSTRAP_LOADER_AVAILABLE);
}
/// Marks the Vulkan instance as created.
pub fn mark_instance_created(&self) {
self.set_flag(BOOTSTRAP_INSTANCE_CREATED);
}
/// Marks the Vulkan surface as created.
pub fn mark_surface_created(&self) {
self.set_flag(BOOTSTRAP_SURFACE_CREATED);
}
/// Marks a suitable Vulkan device as selected and the logical device as created.
pub fn mark_logical_device_created(&self) {
self.set_flag(BOOTSTRAP_DEVICE_SELECTED | BOOTSTRAP_LOGICAL_DEVICE_CREATED);
}
/// Marks the Vulkan swapchain as created.
pub fn mark_swapchain_created(&self) {
self.set_flag(BOOTSTRAP_SWAPCHAIN_CREATED);
}
/// Returns a stable snapshot of the measured bootstrap state.
#[must_use]
pub fn snapshot(&self) -> VulkanSmokeBootstrapSnapshot {
let flags = self.flags.load(Ordering::SeqCst);
VulkanSmokeBootstrapSnapshot {
loader_available: flags & BOOTSTRAP_LOADER_AVAILABLE != 0,
instance_created: flags & BOOTSTRAP_INSTANCE_CREATED != 0,
surface_created: flags & BOOTSTRAP_SURFACE_CREATED != 0,
device_selected: flags & BOOTSTRAP_DEVICE_SELECTED != 0,
logical_device_created: flags & BOOTSTRAP_LOGICAL_DEVICE_CREATED != 0,
swapchain_created: flags & BOOTSTRAP_SWAPCHAIN_CREATED != 0,
}
}
fn set_flag(&self, flag: u8) {
self.flags.fetch_or(flag, Ordering::SeqCst);
}
}
/// Stable snapshot of measured bootstrap progress.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
#[allow(clippy::struct_excessive_bools)]
pub struct VulkanSmokeBootstrapSnapshot {
/// Whether the Vulkan loader was resolved.
pub loader_available: bool,
/// Whether the Vulkan instance was created.
pub instance_created: bool,
/// Whether the Vulkan surface was created.
pub surface_created: bool,
/// Whether a suitable Vulkan device was selected.
pub device_selected: bool,
/// Whether the logical device was created.
pub logical_device_created: bool,
/// Whether the swapchain was created.
pub swapchain_created: bool,
}
const BOOTSTRAP_LOADER_AVAILABLE: u8 = 1 << 0;
const BOOTSTRAP_INSTANCE_CREATED: u8 = 1 << 1;
const BOOTSTRAP_SURFACE_CREATED: u8 = 1 << 2;
const BOOTSTRAP_DEVICE_SELECTED: u8 = 1 << 3;
const BOOTSTRAP_LOGICAL_DEVICE_CREATED: u8 = 1 << 4;
const BOOTSTRAP_SWAPCHAIN_CREATED: u8 = 1 << 5;
/// Stable smoke renderer bootstrap report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSmokeRendererReport {
/// Checked-in shader manifest hash used by the renderer.
pub shader_manifest_hash: String,
/// Whether portability enumeration was enabled at instance creation.
pub portability_enumeration: bool,
/// Whether the logical device enabled `VK_KHR_portability_subset`.
pub portability_subset_enabled: 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, Default, 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>,
}
/// Final smoke renderer shutdown evidence captured after explicit teardown.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSmokeShutdownReport {
/// Stable renderer bootstrap and swapchain report.
pub renderer_report: VulkanSmokeRendererReport,
/// Measured swapchain recreation count for the completed smoke loop.
pub swapchain_recreate_count: u32,
/// Final validation snapshot captured before the debug messenger is destroyed.
pub validation: VulkanValidationReport,
}
/// 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 code.
result: vk::Result,
},
/// 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 {}
/// Live Stage 0 Vulkan triangle renderer used by the smoke app.
pub struct VulkanSmokeRenderer {
pub(super) instance: Option<VulkanInstanceProbe>,
pub(super) validation: Option<VulkanValidationMessenger>,
pub(super) surface: Option<VulkanSurfaceProbe>,
pub(super) device: Option<VulkanLogicalDeviceProbe>,
pub(super) swapchain: Option<VulkanSwapchainProbe>,
pub(super) command_pool: vk::CommandPool,
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>,
pub(super) current_frame: usize,
pub(super) pending_extent: Option<(u32, u32)>,
pub(super) swapchain_recreate_count: u32,
pub(super) report: VulkanSmokeRendererReport,
}
@@ -0,0 +1,159 @@
#![allow(unsafe_code)]
use ash::{khr::surface, vk};
use fparkan_platform::NativeWindowHandles;
use serde::Serialize;
use std::ffi::CStr;
use std::os::raw::c_char;
use super::VulkanInstanceProbe;
use crate::policy::serialize_json_or_fallback;
/// Deterministic Vulkan surface creation plan.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSurfacePlan {
/// Report schema version.
pub schema: u32,
/// Instance extensions required by the native display backend.
pub required_instance_extensions: Vec<String>,
}
/// Vulkan surface bootstrap error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanSurfaceError {
/// No native raw window/display handles were available.
MissingNativeHandles,
/// Required platform surface extensions could not be enumerated.
RequiredExtensionsFailed {
/// Vulkan result.
result: vk::Result,
},
/// A required extension pointer was not valid UTF-8.
InvalidExtensionName,
/// Surface creation failed.
CreateFailed {
/// Vulkan result.
result: vk::Result,
},
}
impl std::fmt::Display for VulkanSurfaceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingNativeHandles => {
write!(
f,
"native window/display handles are required for Vulkan surface creation"
)
}
Self::RequiredExtensionsFailed { result } => write!(
f,
"failed to enumerate required Vulkan surface extensions: {result:?}"
),
Self::InvalidExtensionName => {
write!(f, "Vulkan surface extension name is not valid UTF-8")
}
Self::CreateFailed { result } => {
write!(f, "Vulkan surface creation failed: {result:?}")
}
}
}
}
impl std::error::Error for VulkanSurfaceError {}
/// Created Vulkan surface probe.
pub struct VulkanSurfaceProbe {
pub(super) loader: surface::Instance,
pub(super) surface: vk::SurfaceKHR,
/// Deterministic surface creation report.
pub report: VulkanSurfacePlan,
}
impl Drop for VulkanSurfaceProbe {
fn drop(&mut self) {
// SAFETY: The `SurfaceKHR` was created by this probe and is destroyed once during drop.
unsafe { self.loader.destroy_surface(self.surface, None) };
}
}
/// Builds a deterministic Vulkan surface plan from native window handles.
///
/// # Errors
///
/// Returns [`VulkanSurfaceError`] when no native handles exist or the platform
/// display backend has no Vulkan surface extension mapping.
pub fn plan_vulkan_surface(
handles: Option<NativeWindowHandles>,
) -> Result<VulkanSurfacePlan, VulkanSurfaceError> {
let handles = handles.ok_or(VulkanSurfaceError::MissingNativeHandles)?;
let required = ash_window::enumerate_required_extensions(handles.display)
.map_err(|error| VulkanSurfaceError::RequiredExtensionsFailed { result: error })?;
let mut required_instance_extensions = Vec::with_capacity(required.len());
for extension in required {
let name = extension_name(*extension)?;
required_instance_extensions.push(name);
}
required_instance_extensions.sort();
required_instance_extensions.dedup();
Ok(VulkanSurfacePlan {
schema: 1,
required_instance_extensions,
})
}
/// Creates a Vulkan surface probe from native window handles.
///
/// # Errors
///
/// Returns [`VulkanSurfaceError`] when handles are missing, required extensions
/// cannot be planned, or `vkCreate*SurfaceKHR` fails.
pub fn create_vulkan_surface_probe(
instance: &VulkanInstanceProbe,
handles: Option<NativeWindowHandles>,
) -> Result<VulkanSurfaceProbe, VulkanSurfaceError> {
let handles = handles.ok_or(VulkanSurfaceError::MissingNativeHandles)?;
let report = plan_vulkan_surface(Some(handles))?;
// SAFETY: The platform handles are only used to create a child surface owned by this probe.
let surface = unsafe {
ash_window::create_surface(
&instance.entry,
&instance.instance,
handles.display,
handles.window,
None,
)
}
.map_err(|error| VulkanSurfaceError::CreateFailed { result: error })?;
Ok(VulkanSurfaceProbe {
loader: surface::Instance::new(&instance.entry, &instance.instance),
surface,
report,
})
}
/// Renders a deterministic JSON Vulkan surface plan.
#[must_use]
pub fn render_surface_plan_json(plan: &VulkanSurfacePlan) -> String {
#[derive(Serialize)]
struct SurfacePlanJson<'a> {
schema: u32,
required_instance_extensions: &'a [String],
}
serialize_json_or_fallback(
&SurfacePlanJson {
schema: plan.schema,
required_instance_extensions: &plan.required_instance_extensions,
},
"{\"schema\":0,\"required_instance_extensions\":[]}",
)
}
pub(super) fn extension_name(extension: *const c_char) -> Result<String, VulkanSurfaceError> {
// SAFETY: `ash-window` returns extension pointers to static NUL-terminated Vulkan names.
let name = unsafe { CStr::from_ptr(extension) };
name.to_str()
.map(str::to_string)
.map_err(|_| VulkanSurfaceError::InvalidExtensionName)
}
@@ -0,0 +1,220 @@
#![allow(unsafe_code)]
use ash::{khr::swapchain, vk};
use super::{
capabilities::{
live_present_modes, live_surface_capabilities, live_surface_formats, unique_queue_families,
},
VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanRuntimeCapabilityError,
VulkanSurfaceProbe,
};
use crate::policy::{
plan_vulkan_swapchain, select_composite_alpha, VulkanSwapchainError, VulkanSwapchainPlan,
VulkanSwapchainRequest,
};
/// Created Vulkan swapchain probe.
pub struct VulkanSwapchainProbe {
loader: swapchain::Device,
swapchain: vk::SwapchainKHR,
/// Deterministic swapchain creation report.
pub report: VulkanSwapchainReport,
}
impl Drop for VulkanSwapchainProbe {
fn drop(&mut self) {
// SAFETY: The swapchain was created by this probe and is destroyed once during drop.
unsafe { self.loader.destroy_swapchain(self.swapchain, None) };
}
}
impl VulkanSwapchainProbe {
/// Returns the live swapchain handle.
#[must_use]
pub fn swapchain(&self) -> vk::SwapchainKHR {
self.swapchain
}
/// Returns the swapchain extension loader for this live swapchain.
#[must_use]
pub fn loader(&self) -> &swapchain::Device {
&self.loader
}
}
/// Runtime swapchain creation report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSwapchainReport {
/// Report schema version.
pub schema: u32,
/// Deterministic swapchain policy used for creation.
pub plan: VulkanSwapchainPlan,
/// Number of images returned by `vkGetSwapchainImagesKHR`.
pub image_count: u32,
}
/// Vulkan swapchain creation error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanSwapchainProbeError {
/// Live runtime capability probing failed before swapchain creation.
Runtime(VulkanRuntimeCapabilityError),
/// Deterministic swapchain planning failed before create.
Plan(VulkanSwapchainError),
/// Surface capability query failed.
SurfaceCapabilitiesFailed {
/// Vulkan result.
result: vk::Result,
},
/// Swapchain creation failed.
CreateFailed {
/// Vulkan result.
result: vk::Result,
},
/// Swapchain image query failed.
ImagesFailed {
/// Vulkan result.
result: vk::Result,
},
}
impl std::fmt::Display for VulkanSwapchainProbeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Runtime(error) => write!(f, "{error}"),
Self::Plan(error) => write!(f, "{error}"),
Self::SurfaceCapabilitiesFailed { result } => {
write!(f, "Vulkan surface capabilities query failed: {result:?}")
}
Self::CreateFailed { result } => {
write!(f, "Vulkan swapchain creation failed: {result:?}")
}
Self::ImagesFailed { result } => {
write!(f, "Vulkan swapchain image query failed: {result:?}")
}
}
}
}
impl std::error::Error for VulkanSwapchainProbeError {}
/// Creates a Vulkan swapchain for the live logical device and surface.
///
/// # Errors
///
/// Returns [`VulkanSwapchainProbeError`] when live surface capability queries,
/// swapchain creation, or swapchain image enumeration fails.
pub fn create_vulkan_swapchain_probe(
instance: &VulkanInstanceProbe,
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 {
surface
.loader
.get_physical_device_surface_capabilities(device.physical_device(), surface.surface)
}
}
.map_err(|error| VulkanSwapchainProbeError::SurfaceCapabilitiesFailed { result: error })?;
let surface_formats = live_surface_formats(
surface,
device.physical_device(),
&device.report.device_name,
)
.map_err(VulkanSwapchainProbeError::Runtime)?;
let present_modes = live_present_modes(
surface,
device.physical_device(),
&device.report.device_name,
)
.map_err(VulkanSwapchainProbeError::Runtime)?;
let capabilities = live_surface_capabilities(
surface,
device.physical_device(),
&device.report.device_name,
)
.map_err(VulkanSwapchainProbeError::Runtime)?;
let plan = plan_vulkan_swapchain(&VulkanSwapchainRequest {
drawable_extent,
formats: surface_formats,
present_modes,
capabilities,
preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(),
})
.map_err(VulkanSwapchainProbeError::Plan)?;
let queue_family_indices = unique_queue_families(
device.runtime.capability.graphics_queue_family,
device.runtime.capability.present_queue_family,
);
let sharing_mode = if queue_family_indices.len() > 1 {
vk::SharingMode::CONCURRENT
} else {
vk::SharingMode::EXCLUSIVE
};
let create_info = vk::SwapchainCreateInfoKHR::default()
.surface(surface.surface)
.min_image_count(plan.image_count)
.image_format(vk::Format::from_raw(plan.format.format))
.image_color_space(vk::ColorSpaceKHR::from_raw(plan.format.color_space))
.image_extent(vk::Extent2D {
width: plan.extent.0,
height: plan.extent.1,
})
.image_array_layers(1)
.image_usage(vk::ImageUsageFlags::COLOR_ATTACHMENT)
.image_sharing_mode(sharing_mode)
.queue_family_indices(&queue_family_indices)
.pre_transform(raw_capabilities.current_transform)
.composite_alpha(select_composite_alpha(
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.
let swapchain = unsafe { loader.create_swapchain(&create_info, None) }
.map_err(|error| VulkanSwapchainProbeError::CreateFailed { result: error })?;
// SAFETY: The swapchain was created above and the returned image handles are owned by it.
let images = match unsafe { loader.get_swapchain_images(swapchain) } {
Ok(images) => images,
Err(error) => {
// SAFETY: The swapchain was created above on this loader/device pair and is destroyed on setup failure.
unsafe { loader.destroy_swapchain(swapchain, None) };
return Err(VulkanSwapchainProbeError::ImagesFailed { result: error });
}
};
Ok(VulkanSwapchainProbe {
loader,
swapchain,
report: VulkanSwapchainReport {
schema: 1,
plan,
image_count: images.len().try_into().unwrap_or(u32::MAX),
},
})
}
@@ -0,0 +1,501 @@
#![allow(unsafe_code)]
use ash::vk;
use super::{
color_subresource_range, VulkanAllocatedBuffer, VulkanLogicalDeviceProbe,
VulkanSmokeRendererError, VulkanSwapchainProbe, TRIANGLE_FRAGMENT_SHADER_WORDS,
TRIANGLE_VERTEX_SHADER_WORDS,
};
pub(super) struct VulkanSwapchainResources {
pub(super) image_views: Vec<vk::ImageView>,
pub(super) render_pass: vk::RenderPass,
pub(super) pipeline_layout: vk::PipelineLayout,
pub(super) pipeline: vk::Pipeline,
pub(super) framebuffers: Vec<vk::Framebuffer>,
pub(super) command_buffers: Vec<vk::CommandBuffer>,
}
struct PartialSwapchainResources {
image_views: Vec<vk::ImageView>,
render_pass: Option<vk::RenderPass>,
pipeline_layout: Option<vk::PipelineLayout>,
pipeline: Option<vk::Pipeline>,
framebuffers: Vec<vk::Framebuffer>,
command_buffers: Vec<vk::CommandBuffer>,
}
pub(super) 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: error,
})?;
let mut partial = PartialSwapchainResources {
image_views: create_swapchain_image_views(
device,
&images,
swapchain.report.plan.format.format,
)?,
render_pass: None,
pipeline_layout: None,
pipeline: None,
framebuffers: Vec::new(),
command_buffers: Vec::new(),
};
let (render_pass, pipeline_layout, pipeline) = match create_swapchain_pipeline_bundle(
device,
swapchain.report.plan.format.format,
swapchain.report.plan.extent,
) {
Ok(bundle) => bundle,
Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
};
partial.render_pass = Some(render_pass);
partial.pipeline_layout = Some(pipeline_layout);
partial.pipeline = Some(pipeline);
let framebuffers = match create_swapchain_framebuffers(
device,
render_pass,
&partial.image_views,
swapchain.report.plan.extent,
) {
Ok(framebuffers) => framebuffers,
Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
};
partial.framebuffers = framebuffers;
reset_reusable_command_pool(device, command_pool, reuse_command_pool)?;
let command_buffers = match allocate_command_buffers(
device,
command_pool,
u32::try_from(images.len()).unwrap_or(u32::MAX),
) {
Ok(command_buffers) => command_buffers,
Err(error) => {
destroy_partial_swapchain_resources(device, command_pool, partial);
return Err(error);
}
};
partial.command_buffers = command_buffers;
let _ = (vertex_buffer, index_buffer);
Ok(VulkanSwapchainResources {
image_views: partial.image_views,
render_pass,
pipeline_layout,
pipeline,
framebuffers: partial.framebuffers,
command_buffers: partial.command_buffers,
})
}
fn create_swapchain_image_views(
device: &VulkanLogicalDeviceProbe,
images: &[vk::Image],
format: i32,
) -> Result<Vec<vk::ImageView>, VulkanSmokeRendererError> {
let mut image_views = Vec::with_capacity(images.len());
for image in images.iter().copied() {
image_views.push(create_image_view(device, image, format)?);
}
Ok(image_views)
}
fn create_swapchain_pipeline_bundle(
device: &VulkanLogicalDeviceProbe,
format: i32,
extent: (u32, u32),
) -> Result<(vk::RenderPass, vk::PipelineLayout, vk::Pipeline), VulkanSmokeRendererError> {
let render_pass = create_render_pass(device, format)?;
let pipeline_layout = create_pipeline_layout(device).inspect_err(|_| {
// SAFETY: The render pass was created above on this live logical device and is destroyed on setup failure.
unsafe { device.device().destroy_render_pass(render_pass, None) };
})?;
let pipeline = create_graphics_pipeline(device, render_pass, pipeline_layout, extent)
.inspect_err(|_| {
// SAFETY: These objects were created above on this live logical device and are destroyed on setup failure.
unsafe {
device
.device()
.destroy_pipeline_layout(pipeline_layout, None);
device.device().destroy_render_pass(render_pass, None);
}
})?;
Ok((render_pass, pipeline_layout, pipeline))
}
fn create_swapchain_framebuffers(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
image_views: &[vk::ImageView],
extent: (u32, u32),
) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> {
let mut framebuffers = Vec::with_capacity(image_views.len());
for image_view in image_views.iter().copied() {
match create_framebuffer(device, render_pass, image_view, extent) {
Ok(framebuffer) => framebuffers.push(framebuffer),
Err(error) => {
// SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure.
unsafe {
for framebuffer in framebuffers.iter().copied() {
device.device().destroy_framebuffer(framebuffer, None);
}
}
return Err(error);
}
}
}
Ok(framebuffers)
}
fn reset_reusable_command_pool(
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
reuse_command_pool: bool,
) -> Result<(), VulkanSmokeRendererError> {
if !reuse_command_pool {
return Ok(());
}
// SAFETY: All command buffers allocated from the live pool are freed before reallocating them.
unsafe {
device
.device()
.reset_command_pool(command_pool, vk::CommandPoolResetFlags::empty())
}
.map_err(|error| VulkanSmokeRendererError::VulkanOperation {
context: "vkResetCommandPool",
result: error,
})
}
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: 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::UNDEFINED)
.final_layout(vk::ImageLayout::PRESENT_SRC_KHR);
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)
.src_access_mask(vk::AccessFlags::empty())
.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: error,
}
})
}
fn create_pipeline_layout(
device: &VulkanLogicalDeviceProbe,
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
let create_info = vk::PipelineLayoutCreateInfo::default();
// SAFETY: The pipeline layout has no descriptor sets or push constants in Stage 0 smoke.
unsafe { device.device().create_pipeline_layout(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreatePipelineLayout",
result: error,
}
})
}
#[allow(clippy::cast_precision_loss)]
fn extent_component_to_f32(value: u32) -> f32 {
value as f32
}
#[allow(clippy::too_many_lines)]
fn create_graphics_pipeline(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
pipeline_layout: vk::PipelineLayout,
extent: (u32, u32),
) -> Result<vk::Pipeline, VulkanSmokeRendererError> {
let vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS)?;
let fragment_shader = match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS) {
Ok(module) => module,
Err(error) => {
// SAFETY: The shader module was created above on this live logical device and is destroyed on setup failure.
unsafe { device.device().destroy_shader_module(vertex_shader, None) };
return Err(error);
}
};
let entry_point = c"main";
let shader_stages = [
vk::PipelineShaderStageCreateInfo::default()
.module(vertex_shader)
.name(entry_point)
.stage(vk::ShaderStageFlags::VERTEX),
vk::PipelineShaderStageCreateInfo::default()
.module(fragment_shader)
.name(entry_point)
.stage(vk::ShaderStageFlags::FRAGMENT),
];
let vertex_binding = vk::VertexInputBindingDescription::default()
.binding(0)
.stride(u32::try_from(5 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
.input_rate(vk::VertexInputRate::VERTEX);
let vertex_attributes = [
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(0)
.format(vk::Format::R32G32_SFLOAT)
.offset(0),
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(1)
.format(vk::Format::R32G32B32_SFLOAT)
.offset(u32::try_from(2 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
];
let vertex_bindings = [vertex_binding];
let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default()
.vertex_binding_descriptions(&vertex_bindings)
.vertex_attribute_descriptions(&vertex_attributes);
let input_assembly_state = vk::PipelineInputAssemblyStateCreateInfo::default()
.topology(vk::PrimitiveTopology::TRIANGLE_LIST)
.primitive_restart_enable(false);
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()
.depth_clamp_enable(false)
.rasterizer_discard_enable(false)
.polygon_mode(vk::PolygonMode::FILL)
.line_width(1.0)
.cull_mode(vk::CullModeFlags::BACK)
.front_face(vk::FrontFace::CLOCKWISE)
.depth_bias_enable(false);
let multisample_state = vk::PipelineMultisampleStateCreateInfo::default()
.sample_shading_enable(false)
.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,
)
.blend_enable(false);
let color_blend_attachments = [color_blend_attachment];
let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default()
.logic_op_enable(false)
.attachments(&color_blend_attachments);
let create_info = vk::GraphicsPipelineCreateInfo::default()
.stages(&shader_stages)
.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);
let create_infos = [create_info];
// SAFETY: Pipeline creation references only stack-owned descriptions and live render-pass/layout handles.
let pipeline_result = unsafe {
device
.device()
.create_graphics_pipelines(vk::PipelineCache::null(), &create_infos, None)
};
// SAFETY: The shader modules were created above on this live logical device and are no longer needed after pipeline creation.
unsafe {
device.device().destroy_shader_module(vertex_shader, None);
device.device().destroy_shader_module(fragment_shader, None);
}
let pipelines =
pipeline_result.map_err(|(_, error)| VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateGraphicsPipelines",
result: error,
})?;
Ok(pipelines[0])
}
fn create_shader_module(
device: &VulkanLogicalDeviceProbe,
words: &[u32],
) -> Result<vk::ShaderModule, VulkanSmokeRendererError> {
let create_info = vk::ShaderModuleCreateInfo::default().code(words);
// SAFETY: The SPIR-V slice points to static checked-in words and lives for the duration of the call.
unsafe { device.device().create_shader_module(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateShaderModule",
result: 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 references a live image view and render pass owned by this logical device.
unsafe { device.device().create_framebuffer(&create_info, None) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkCreateFramebuffer",
result: 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: The command pool belongs to this live logical device and the allocation info is stack-owned.
unsafe { device.device().allocate_command_buffers(&allocate_info) }.map_err(|error| {
VulkanSmokeRendererError::VulkanOperation {
context: "vkAllocateCommandBuffers",
result: error,
}
})
}
pub(super) fn destroy_swapchain_resources(
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
resources: VulkanSwapchainResources,
) {
// SAFETY: All handles belong to this live logical device and are destroyed during renderer teardown/recreation.
unsafe {
if !resources.command_buffers.is_empty() {
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 destroy_partial_swapchain_resources(
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
partial: PartialSwapchainResources,
) {
// SAFETY: All handles in the partial bundle belong to this live logical device and are destroyed on setup failure.
unsafe {
if !partial.command_buffers.is_empty() {
device
.device()
.free_command_buffers(command_pool, &partial.command_buffers);
}
for framebuffer in partial.framebuffers {
device.device().destroy_framebuffer(framebuffer, None);
}
if let Some(pipeline) = partial.pipeline {
device.device().destroy_pipeline(pipeline, None);
}
if let Some(pipeline_layout) = partial.pipeline_layout {
device
.device()
.destroy_pipeline_layout(pipeline_layout, None);
}
if let Some(render_pass) = partial.render_pass {
device.device().destroy_render_pass(render_pass, None);
}
for image_view in partial.image_views {
device.device().destroy_image_view(image_view, None);
}
}
}
@@ -0,0 +1,772 @@
use super::*;
use crate::policy::{KHR_PORTABILITY_SUBSET_EXTENSION, KHR_SWAPCHAIN_EXTENSION};
use crate::shader_manifest::{
SHADER_COMPILER_BINARY_SHA256, SHADER_COMPILER_NAME, SHADER_COMPILER_VERSION,
SHADER_MANIFEST_SCHEMA, SHADER_TARGET_ENV, SPIRV_MAGIC, SPIRV_VALIDATOR_BINARY_SHA256,
SPIRV_VALIDATOR_NAME, SPIRV_VALIDATOR_VERSION, SPIRV_VERSION_1_0,
TRIANGLE_VERTEX_COMPILE_COMMAND, TRIANGLE_VERTEX_SOURCE_PATH, TRIANGLE_VERTEX_SOURCE_SHA256,
TRIANGLE_VERTEX_SPIRV_PATH, TRIANGLE_VERTEX_VALIDATE_COMMAND,
};
use crate::*;
use fparkan_platform::{DepthStencilSupport, RenderRequest};
use fparkan_render::{
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase,
};
use fparkan_render::{RenderBackend, RenderError};
#[test]
fn planning_backend_tracks_render_request_and_simulated_present() -> Result<(), RenderError> {
let mut backend = VulkanPlanningBackend::new();
let request = RenderRequest {
presentation: fparkan_platform::PresentationMode::Immediate,
..RenderRequest::conservative()
};
backend.set_render_request(request);
assert_eq!(backend.render_request(), request);
assert_eq!(backend.report().request.current_request, request);
assert_eq!(backend.report().request.request_updates, 1);
let commands = fparkan_render::RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(11),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(1),
material: GpuMaterialId(2),
transform: [1.0; 16],
range: IndexRange { start: 0, count: 3 },
stable_order: 7,
}),
RenderCommand::EndFrame,
],
};
backend.execute(&commands)?;
assert_eq!(backend.state(), VulkanPlanningBackendState::Configured);
assert_eq!(backend.report().execution.planned_frames, 1);
assert_eq!(backend.report().execution.submission_plans, 1);
assert_eq!(backend.report().execution.simulated_presents, 1);
assert!(backend.report().execution.last_capture_size > 0);
assert_eq!(
backend.report().last_frame_submission,
Some(VulkanFrameSubmissionPlan {
schema: 1,
frames_in_flight: 2,
command_buffers: 2,
semaphores_per_frame: 2,
fences_per_frame: 1,
draw_count: 1,
indexed_vertex_count: 3,
})
);
Ok(())
}
#[test]
fn frame_submission_plan_json_is_stable() -> Result<(), RenderError> {
let commands = fparkan_render::RenderCommandList {
commands: vec![
RenderCommand::BeginFrame,
RenderCommand::Draw(DrawCommand {
id: DrawId(11),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(1),
material: GpuMaterialId(2),
transform: [1.0; 16],
range: IndexRange { start: 0, count: 3 },
stable_order: 7,
}),
RenderCommand::EndFrame,
],
};
let swapchain = VulkanSwapchainPlan {
schema: 1,
extent: (1, 1),
format: VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
},
present_mode: vk::PresentModeKHR::FIFO.as_raw(),
image_count: 3,
};
let plan = plan_vulkan_frame_submission(&swapchain, &commands)?;
assert_eq!(plan.frames_in_flight, 2);
assert_eq!(plan.command_buffers, 3);
assert_eq!(plan.draw_count, 1);
assert_eq!(plan.indexed_vertex_count, 3);
assert_eq!(
render_frame_submission_plan_json(&plan),
"{\"schema\":1,\"frames_in_flight\":2,\"command_buffers\":3,\"semaphores_per_frame\":2,\"fences_per_frame\":1,\"draw_count\":1,\"indexed_vertex_count\":3}"
);
Ok(())
}
#[test]
fn device_scoring_is_deterministic_and_prefers_discrete_unified_queue() {
let devices = vec![
device("SwiftShader", VulkanDeviceType::Cpu, 0, true, false),
device("Discrete", VulkanDeviceType::DiscreteGpu, 1, true, false),
device(
"Integrated",
VulkanDeviceType::IntegratedGpu,
2,
true,
false,
),
];
let report = select_physical_device(&devices).expect("selected device");
assert_eq!(report.device_name, "Discrete");
assert_eq!(report.graphics_queue_family, 1);
assert_eq!(report.present_queue_family, 1);
assert!(!report.portability_subset);
assert_eq!(report.enabled_extensions, vec![KHR_SWAPCHAIN_EXTENSION]);
}
#[test]
fn device_selection_skips_rejected_candidates_before_accepting_valid_gpu() {
let mut rejected = device("Rejected", VulkanDeviceType::DiscreteGpu, 0, true, false);
rejected.queue_families[0].present = false;
let accepted = device("Accepted", VulkanDeviceType::IntegratedGpu, 2, true, false);
let report = select_physical_device(&[rejected, accepted]).expect("selected fallback device");
assert_eq!(report.device_name, "Accepted");
assert_eq!(report.graphics_queue_family, 2);
assert_eq!(report.present_queue_family, 2);
assert_eq!(
report.rejected_devices,
vec![VulkanRejectedDeviceReport {
device_name: "Rejected".to_string(),
reason_code: "no_present_queue",
reason: "Vulkan device Rejected has no present queue".to_string(),
}]
);
}
#[test]
fn queue_family_selection_prefers_lowest_index_unified_family() {
let mut candidate = device(
"Unified later in list",
VulkanDeviceType::DiscreteGpu,
7,
true,
false,
);
candidate.queue_families = vec![
VulkanQueueFamily {
index: 9,
graphics: true,
present: true,
},
VulkanQueueFamily {
index: 3,
graphics: true,
present: true,
},
VulkanQueueFamily {
index: 1,
graphics: true,
present: false,
},
];
let report = select_physical_device(&[candidate]).expect("selected unified queue");
assert_eq!(report.graphics_queue_family, 3);
assert_eq!(report.present_queue_family, 3);
}
#[test]
fn portability_subset_is_reported_and_enabled_when_exposed() {
let report = select_physical_device(&[device(
"MoltenVK",
VulkanDeviceType::IntegratedGpu,
0,
true,
true,
)])
.expect("selected device");
assert!(report.portability_subset);
assert_eq!(
report.enabled_extensions,
vec![
KHR_SWAPCHAIN_EXTENSION.to_string(),
KHR_PORTABILITY_SUBSET_EXTENSION.to_string()
]
);
}
#[test]
fn missing_loader_candidates_are_reported() {
assert_eq!(
select_physical_device(&[]),
Err(VulkanCapabilityError::NoPhysicalDevice)
);
}
#[test]
fn rejects_low_api_version() {
let mut candidate = device("Old GPU", VulkanDeviceType::DiscreteGpu, 0, true, false);
candidate.api_version = vk::API_VERSION_1_0;
assert!(matches!(
select_physical_device(&[candidate]),
Err(VulkanCapabilityError::ApiVersionTooLow { .. })
));
}
#[test]
fn rejects_missing_graphics_present_swapchain_and_format() {
let mut no_graphics = device("No graphics", VulkanDeviceType::DiscreteGpu, 0, true, false);
no_graphics.queue_families[0].graphics = false;
assert!(matches!(
select_physical_device(&[no_graphics]),
Err(VulkanCapabilityError::NoGraphicsQueue { .. })
));
let mut no_present = device("No present", VulkanDeviceType::DiscreteGpu, 0, true, false);
no_present.queue_families[0].present = false;
assert!(matches!(
select_physical_device(&[no_present]),
Err(VulkanCapabilityError::NoPresentQueue { .. })
));
let no_swapchain = device(
"No swapchain",
VulkanDeviceType::DiscreteGpu,
0,
false,
false,
);
assert!(matches!(
select_physical_device(&[no_swapchain]),
Err(VulkanCapabilityError::MissingSwapchainExtension { .. })
));
let mut no_format = device("No format", VulkanDeviceType::DiscreteGpu, 0, true, false);
no_format.surface_formats.clear();
assert!(matches!(
select_physical_device(&[no_format]),
Err(VulkanCapabilityError::MissingSurfaceFormat { .. })
));
let mut no_present_mode = device(
"No present mode",
VulkanDeviceType::DiscreteGpu,
0,
true,
false,
);
no_present_mode.present_modes.clear();
assert!(matches!(
select_physical_device(&[no_present_mode]),
Err(VulkanCapabilityError::MissingPresentMode { .. })
));
let mut no_color_attachment = device(
"No color attachment",
VulkanDeviceType::DiscreteGpu,
0,
true,
false,
);
no_color_attachment
.surface_capabilities
.supported_usage_flags = vk::ImageUsageFlags::TRANSFER_DST.as_raw();
assert!(matches!(
select_physical_device(&[no_color_attachment]),
Err(VulkanCapabilityError::MissingColorAttachmentUsage { .. })
));
}
#[test]
fn capability_gate_rejects_devices_without_requested_depth_stencil_support() {
let mut no_depth = device("No depth", VulkanDeviceType::DiscreteGpu, 0, true, false);
no_depth.supported_depth_stencil_formats = vec![vk::Format::D32_SFLOAT.as_raw()];
assert!(matches!(
select_physical_device(&[no_depth]),
Err(VulkanCapabilityError::MissingDepthStencilFormat { .. })
));
}
#[test]
fn capability_gate_respects_request_specific_depth_profiles() {
let mut no_stencil = device("No stencil", VulkanDeviceType::DiscreteGpu, 0, true, false);
no_stencil.supported_depth_stencil_formats = vec![vk::Format::D32_SFLOAT.as_raw()];
let relaxed_request = RenderRequest {
depth: DepthStencilSupport {
depth_bits: 32,
stencil_bits: 0,
},
..RenderRequest::conservative()
};
let report = select_physical_device_for_request(&[no_stencil], relaxed_request)
.expect("selected device for depth-only request");
assert_eq!(report.device_name, "No stencil");
assert!(report.rejected_devices.is_empty());
}
#[test]
fn capability_report_preserves_informational_sampled_formats_and_limits() {
let report = select_physical_device(&[device(
"Telemetry GPU",
VulkanDeviceType::DiscreteGpu,
0,
true,
false,
)])
.expect("selected device");
assert_eq!(
report.informational_capabilities.sampled_color_formats,
vec![vk::Format::B8G8R8A8_SRGB.as_raw()]
);
assert_eq!(
report.informational_capabilities.sampled_depth_formats,
vec![vk::Format::D32_SFLOAT.as_raw()]
);
assert_eq!(
report.informational_capabilities.limits,
VulkanDeviceLimits {
max_image_dimension_2d: 4096,
max_sampler_allocation_count: 4096,
max_per_stage_descriptor_samplers: 16,
max_bound_descriptor_sets: 4,
}
);
}
#[test]
fn capability_report_json_is_stable() {
let mut rejected = device("Rejected", VulkanDeviceType::IntegratedGpu, 0, true, false);
rejected.present_modes.clear();
let report = select_physical_device(&[
rejected,
device("GPU \"A\"", VulkanDeviceType::DiscreteGpu, 3, true, false),
])
.expect("selected device");
assert_eq!(
render_capability_report_json(&report),
"{\"schema\":1,\"vulkan_api\":\"1.1.0\",\"device_name\":\"GPU \\\"A\\\"\",\"score\":1101,\"graphics_queue_family\":3,\"present_queue_family\":3,\"portability_subset\":false,\"enabled_extensions\":[\"VK_KHR_swapchain\"],\"informational_capabilities\":{\"sampled_color_formats\":[50],\"sampled_depth_formats\":[126],\"limits\":{\"max_image_dimension_2d\":4096,\"max_sampler_allocation_count\":4096,\"max_per_stage_descriptor_samplers\":16,\"max_bound_descriptor_sets\":4}},\"rejected_devices\":[{\"device_name\":\"Rejected\",\"reason_code\":\"missing_present_mode\",\"reason\":\"Vulkan device Rejected has no supported present mode\"}]}"
);
}
#[test]
fn loader_probe_report_json_is_stable() {
assert_eq!(
vulkan_entry_symbol_name().to_bytes(),
b"vkGetInstanceProcAddr"
);
assert_eq!(
render_loader_probe_report_json(&VulkanLoaderProbeReport {
schema: 1,
loader_available: true,
instance_api_version: vk::API_VERSION_1_2,
}),
"{\"schema\":1,\"loader_available\":true,\"instance_api\":\"1.2.0\"}"
);
}
#[test]
fn loader_error_display_is_actionable() {
assert_eq!(
VulkanLoaderError::Unavailable {
message: "dlopen failed".to_string(),
}
.to_string(),
"Vulkan loader is unavailable: dlopen failed"
);
}
#[test]
fn instance_plan_is_sorted_deduplicated_and_portability_aware() {
let plan = plan_vulkan_instance(&VulkanInstanceConfig {
application_name: "FParkan".to_string(),
required_extensions: vec![
"VK_KHR_surface".to_string(),
KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(),
"VK_KHR_surface".to_string(),
],
enable_portability_enumeration: true,
enable_validation: true,
});
assert_eq!(
render_instance_plan_json(&plan),
"{\"schema\":1,\"create_flags\":1,\"validation_requested\":true,\"enabled_extensions\":[\"VK_EXT_debug_utils\",\"VK_KHR_portability_enumeration\",\"VK_KHR_surface\"]}"
);
}
#[test]
fn instance_plan_adds_portability_extension_when_requested() {
let plan = plan_vulkan_instance(&VulkanInstanceConfig {
application_name: "FParkan".to_string(),
required_extensions: vec!["VK_KHR_surface".to_string()],
enable_portability_enumeration: true,
enable_validation: false,
});
assert_eq!(
plan.enabled_extensions,
vec![
KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(),
"VK_KHR_surface".to_string()
]
);
assert_eq!(plan.create_flags, 1);
}
#[test]
fn invalid_instance_extension_name_is_reported_before_loader_use() {
assert_eq!(
cstring_vec(&["bad\0extension".to_string()]),
Err(VulkanInstanceError::InvalidExtensionName {
extension: "bad\0extension".to_string()
})
);
}
#[test]
fn missing_instance_extension_is_reported_before_create_instance() {
assert_eq!(
ensure_instance_extensions_available(
&[
"VK_EXT_debug_utils".to_string(),
"VK_KHR_surface".to_string(),
],
&["VK_KHR_surface".to_string()],
),
Err(VulkanInstanceError::MissingInstanceExtension {
extension: "VK_EXT_debug_utils".to_string(),
})
);
}
#[test]
fn surface_plan_requires_native_handles() {
assert_eq!(
plan_vulkan_surface(None),
Err(VulkanSurfaceError::MissingNativeHandles)
);
assert_eq!(
VulkanSurfaceError::MissingNativeHandles.to_string(),
"native window/display handles are required for Vulkan surface creation"
);
}
#[test]
fn surface_plan_json_is_stable() {
assert_eq!(
render_surface_plan_json(&VulkanSurfacePlan {
schema: 1,
required_instance_extensions: vec![
"VK_KHR_surface".to_string(),
"VK_EXT_metal_surface".to_string(),
],
}),
"{\"schema\":1,\"required_instance_extensions\":[\"VK_KHR_surface\",\"VK_EXT_metal_surface\"]}"
);
}
#[test]
fn static_surface_extension_name_is_decoded() {
let name = extension_name(ash::khr::surface::NAME.as_ptr()).expect("extension name");
assert_eq!(name, "VK_KHR_surface");
}
#[test]
fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() {
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
assert_eq!(
plan.format,
VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}
);
assert_eq!(plan.present_mode, vk::PresentModeKHR::MAILBOX.as_raw());
assert_eq!(plan.extent, (1024, 720));
assert_eq!(plan.image_count, 3);
}
#[test]
fn swapchain_plan_uses_fifo_and_current_extent_fallbacks() {
let mut request = swapchain_request();
request.preferred_present_mode = vk::PresentModeKHR::IMMEDIATE.as_raw();
request.present_modes = vec![vk::PresentModeKHR::FIFO.as_raw()];
request.capabilities.current_extent = Some((800, 600));
let plan = plan_vulkan_swapchain(&request).expect("swapchain plan");
assert_eq!(plan.present_mode, vk::PresentModeKHR::FIFO.as_raw());
assert_eq!(plan.extent, (800, 600));
}
#[test]
fn swapchain_plan_accepts_undefined_surface_format_by_picking_stage0_default() {
let mut request = swapchain_request();
request.formats = vec![VulkanSurfaceFormat {
format: vk::Format::UNDEFINED.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}];
let plan = plan_vulkan_swapchain(&request).expect("swapchain plan");
assert_eq!(
plan.format,
VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}
);
}
#[test]
fn swapchain_plan_rejects_missing_surface_data_and_empty_extent() {
let mut request = swapchain_request();
request.formats.clear();
assert_eq!(
plan_vulkan_swapchain(&request),
Err(VulkanSwapchainError::MissingSurfaceFormat)
);
let mut request = swapchain_request();
request.present_modes.clear();
assert_eq!(
plan_vulkan_swapchain(&request),
Err(VulkanSwapchainError::MissingPresentMode)
);
let mut request = swapchain_request();
request.capabilities.current_extent = Some((0, 600));
assert_eq!(
plan_vulkan_swapchain(&request),
Err(VulkanSwapchainError::EmptyExtent)
);
}
#[test]
fn swapchain_plan_json_and_recreation_reports_are_stable() {
let plan = plan_vulkan_swapchain(&swapchain_request()).expect("swapchain plan");
assert_eq!(
render_swapchain_plan_json(&plan),
"{\"schema\":1,\"extent\":[1024,720],\"format\":50,\"color_space\":0,\"present_mode\":1,\"image_count\":3}"
);
let report = swapchain_recreation_report(
VulkanSwapchainRecreationReason::OutOfDate,
(1024, 720),
(1280, 720),
);
assert_eq!(
render_swapchain_recreation_report_json(&report),
"{\"schema\":1,\"reason\":\"out_of_date\",\"previous_extent\":[1024,720],\"next_extent\":[1280,720]}"
);
}
#[test]
fn triangle_shader_manifest_hashes_are_stable() {
let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
assert_eq!(report.schema, SHADER_MANIFEST_SCHEMA);
assert_eq!(report.target_env, SHADER_TARGET_ENV);
assert_eq!(
report.compiler,
VulkanShaderToolManifest {
name: SHADER_COMPILER_NAME,
version: SHADER_COMPILER_VERSION,
binary_sha256: SHADER_COMPILER_BINARY_SHA256,
}
);
assert_eq!(
report.validator,
VulkanShaderToolManifest {
name: SPIRV_VALIDATOR_NAME,
version: SPIRV_VALIDATOR_VERSION,
binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256,
}
);
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].source_path, TRIANGLE_VERTEX_SOURCE_PATH);
assert_eq!(
report.modules[0].source_sha256,
TRIANGLE_VERTEX_SOURCE_SHA256
);
assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH);
assert_eq!(report.modules[0].word_count, 253);
assert_eq!(
report.modules[0].sha256,
"4d3ceca7b42ebc971d831b0a0d816457397bd9aeda47fb8d44c4b1aeaa5e7ba0"
);
assert_eq!(report.modules[0].descriptor_sets, 0);
assert_eq!(report.modules[0].push_constant_bytes, 0);
assert_eq!(
report.modules[0].compile_command,
TRIANGLE_VERTEX_COMPILE_COMMAND
);
assert_eq!(
report.modules[0].validate_command,
TRIANGLE_VERTEX_VALIDATE_COMMAND
);
assert!(!report.modules[0].interface_hash.is_empty());
assert_eq!(
report.modules[1].sha256,
"5a7441be03cd3c25d557268b2e58d5aa50504c87bffcb4c3fd7cbcf007db0b96"
);
assert_eq!(
report.manifest_hash,
"11e3feb65200ebd2ac87b7e776e9c6433a5da9d71a651bfadea89a51be17ff05"
);
}
#[test]
fn shader_manifest_report_json_is_stable() {
let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
let json = render_shader_manifest_report_json(&report);
assert!(json.contains(SHADER_COMPILER_NAME));
assert!(json.contains(SPIRV_VALIDATOR_NAME));
assert!(json.contains(TRIANGLE_VERTEX_SOURCE_PATH));
assert!(json.contains(TRIANGLE_VERTEX_COMPILE_COMMAND));
}
#[test]
fn checked_in_shader_manifest_matches_generated_report() {
let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
assert_eq!(
render_shader_manifest_report_json(&report),
include_str!("../../shaders/manifest.json").trim()
);
}
#[test]
fn shader_manifest_rejects_invalid_spirv_containers() {
let mut module = triangle_shader_manifest().remove(0);
module.words = &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0];
assert_eq!(
validate_shader_manifest(&[module]),
Err(VulkanShaderManifestError::InvalidMagic {
name: "triangle.vert",
found: 0xFFFF_FFFF,
})
);
let mut module = triangle_shader_manifest().remove(0);
module.words = &[SPIRV_MAGIC, 0, 0, 1, 0];
assert_eq!(
validate_shader_manifest(&[module]),
Err(VulkanShaderManifestError::UnsupportedVersion {
name: "triangle.vert",
found: 0,
})
);
let mut module = triangle_shader_manifest().remove(0);
module.words = &[SPIRV_MAGIC, SPIRV_VERSION_1_0, 0, 0, 0];
assert_eq!(
validate_shader_manifest(&[module]),
Err(VulkanShaderManifestError::InvalidBound {
name: "triangle.vert",
})
);
}
fn device(
name: &str,
device_type: VulkanDeviceType,
queue_index: u32,
swapchain: bool,
portability_subset: bool,
) -> VulkanPhysicalDeviceRecord {
let mut extensions = Vec::new();
if swapchain {
extensions.push(KHR_SWAPCHAIN_EXTENSION.to_string());
}
if portability_subset {
extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string());
}
VulkanPhysicalDeviceRecord {
name: name.to_string(),
api_version: MIN_VULKAN_API_VERSION,
device_type,
extensions,
queue_families: vec![VulkanQueueFamily {
index: queue_index,
graphics: true,
present: true,
}],
surface_formats: vec![VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}],
present_modes: vec![
vk::PresentModeKHR::FIFO.as_raw(),
vk::PresentModeKHR::MAILBOX.as_raw(),
],
surface_capabilities: default_surface_capabilities(),
supported_depth_stencil_formats: vec![
vk::Format::D24_UNORM_S8_UINT.as_raw(),
vk::Format::D32_SFLOAT_S8_UINT.as_raw(),
vk::Format::D32_SFLOAT.as_raw(),
],
sampled_image_formats: vec![
vk::Format::B8G8R8A8_SRGB.as_raw(),
vk::Format::D32_SFLOAT.as_raw(),
],
limits: VulkanDeviceLimits {
max_image_dimension_2d: 4096,
max_sampler_allocation_count: 4096,
max_per_stage_descriptor_samplers: 16,
max_bound_descriptor_sets: 4,
},
}
}
fn swapchain_request() -> VulkanSwapchainRequest {
VulkanSwapchainRequest {
drawable_extent: (1280, 720),
formats: vec![
VulkanSurfaceFormat {
format: vk::Format::R8G8B8A8_UNORM.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
},
VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
},
],
present_modes: vec![
vk::PresentModeKHR::FIFO.as_raw(),
vk::PresentModeKHR::MAILBOX.as_raw(),
],
capabilities: default_surface_capabilities(),
preferred_present_mode: vk::PresentModeKHR::MAILBOX.as_raw(),
}
}
fn default_surface_capabilities() -> VulkanSwapchainSurfaceCapabilities {
VulkanSwapchainSurfaceCapabilities {
current_extent: None,
min_extent: (320, 240),
max_extent: (1024, 768),
min_image_count: 2,
max_image_count: 3,
supported_usage_flags: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
}
}
@@ -0,0 +1,125 @@
#![allow(unsafe_code)]
use ash::vk;
use std::collections::BTreeSet;
use std::ffi::CStr;
use std::sync::{
atomic::{AtomicU32, Ordering},
Mutex,
};
use super::{VulkanInstanceProbe, VulkanSmokeRendererError, VulkanValidationReport};
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()),
}
}
}
pub(super) struct VulkanValidationMessenger {
loader: ash::ext::debug_utils::Instance,
messenger: vk::DebugUtilsMessengerEXT,
shared: Box<VulkanValidationShared>,
}
impl VulkanValidationMessenger {
pub(super) 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
}
pub(super) 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: error,
}
})?;
Ok(VulkanValidationMessenger {
loader,
messenger,
shared,
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,172 @@
use ash::vk;
use fparkan_platform::RenderRequest;
use fparkan_render::{
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
};
use crate::{
plan_vulkan_frame_submission, VulkanFrameSubmissionPlan, VulkanSurfaceFormat,
VulkanSwapchainPlan,
};
/// Vulkan backend migration readiness.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VulkanPlanningBackendState {
/// Planning facade is configured and able to accept command lists.
Configured,
/// Adapter is tracking a recoverable runtime surface/depth pipeline fault.
Degraded,
/// Adapter has encountered a non-recoverable error.
Error,
}
impl Default for VulkanPlanningBackendState {
fn default() -> Self {
Self::Configured
}
}
/// Diagnostics for planning-facade request tracking.
#[derive(Clone, Debug, PartialEq)]
pub struct VulkanPlanningRequestReport {
/// Last render request observed by the planning facade.
pub current_request: RenderRequest,
/// Number of meaningful request updates applied to the facade.
pub request_updates: u64,
}
impl Default for VulkanPlanningRequestReport {
fn default() -> Self {
Self {
current_request: RenderRequest::conservative(),
request_updates: 0,
}
}
}
/// Diagnostics for planning-facade execution telemetry.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct VulkanPlanningExecutionReport {
/// Total frames planned by the facade.
pub planned_frames: u64,
/// Total frame-submission plans emitted by the facade.
pub submission_plans: u64,
/// Last command-capture byte size.
pub last_capture_size: usize,
/// Number of simulated present calls issued by the planning facade.
pub simulated_presents: u64,
}
/// Diagnostics for Vulkan planning backend setup and frame progression.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct VulkanPlanningBackendReport {
/// Request-tracking telemetry.
pub request: VulkanPlanningRequestReport,
/// Execution-planning telemetry.
pub execution: VulkanPlanningExecutionReport,
/// Last deterministic frame submission plan.
pub last_frame_submission: Option<VulkanFrameSubmissionPlan>,
}
/// Vulkan planning backend facade used by the game entrypoint.
#[derive(Debug)]
pub struct VulkanPlanningBackend {
state: VulkanPlanningBackendState,
report: VulkanPlanningBackendReport,
swapchain_plan: VulkanSwapchainPlan,
}
impl Default for VulkanPlanningBackend {
fn default() -> Self {
Self::new()
}
}
impl VulkanPlanningBackend {
/// Creates a new Vulkan planning backend facade.
#[must_use]
pub fn new() -> Self {
Self {
state: VulkanPlanningBackendState::Configured,
report: VulkanPlanningBackendReport::default(),
swapchain_plan: default_stage0_swapchain_plan(),
}
}
/// Replaces active surface/profile request.
pub fn set_render_request(&mut self, request: RenderRequest) {
if self.report.request.current_request != request {
self.report.request.current_request = request;
self.report.request.request_updates =
self.report.request.request_updates.saturating_add(1);
}
}
/// Returns active render request policy.
#[must_use]
pub const fn render_request(&self) -> RenderRequest {
self.report.request.current_request
}
/// Replaces active swapchain plan used for frame submission planning.
pub fn set_swapchain_plan(&mut self, plan: VulkanSwapchainPlan) {
self.swapchain_plan = plan;
}
/// Returns active swapchain plan.
#[must_use]
pub const fn swapchain_plan(&self) -> &VulkanSwapchainPlan {
&self.swapchain_plan
}
/// Returns adapter state.
#[must_use]
pub const fn state(&self) -> VulkanPlanningBackendState {
self.state
}
/// Returns backend report.
#[must_use]
pub fn report(&self) -> &VulkanPlanningBackendReport {
&self.report
}
fn simulate_present(&mut self) {
self.report.execution.simulated_presents =
self.report.execution.simulated_presents.saturating_add(1);
}
}
impl RenderBackend for VulkanPlanningBackend {
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
if !matches!(
self.state,
VulkanPlanningBackendState::Configured | VulkanPlanningBackendState::Degraded
) {
return Err(RenderError::InvalidRange);
}
let capture = canonical_capture(commands)?;
let frame_plan = plan_vulkan_frame_submission(&self.swapchain_plan, commands)?;
self.report.execution.planned_frames =
self.report.execution.planned_frames.saturating_add(1);
self.report.execution.submission_plans =
self.report.execution.submission_plans.saturating_add(1);
self.report.execution.last_capture_size = capture.len();
self.report.last_frame_submission = Some(frame_plan);
self.simulate_present();
Ok(FrameOutput)
}
}
fn default_stage0_swapchain_plan() -> VulkanSwapchainPlan {
VulkanSwapchainPlan {
schema: 1,
extent: (1, 1),
format: VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
},
present_mode: vk::PresentModeKHR::FIFO.as_raw(),
image_count: 2,
}
}
@@ -0,0 +1,887 @@
use ash::vk;
use fparkan_platform::{DepthStencilSupport, RenderRequest};
use fparkan_render::{validate_command_list, RenderCommand, RenderCommandList, RenderError};
use serde::Serialize;
const MIN_VULKAN_API_VERSION: u32 = vk::API_VERSION_1_1;
pub(crate) const KHR_SWAPCHAIN_EXTENSION: &str = "VK_KHR_swapchain";
pub(crate) const KHR_PORTABILITY_SUBSET_EXTENSION: &str = "VK_KHR_portability_subset";
/// Synthetic physical-device type used by deterministic capability scoring.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VulkanDeviceType {
/// Discrete GPU.
DiscreteGpu,
/// Integrated GPU.
IntegratedGpu,
/// CPU or software Vulkan implementation.
Cpu,
/// Other or unknown implementation.
Other,
}
impl VulkanDeviceType {
const fn score_bonus(self) -> i32 {
match self {
Self::DiscreteGpu => 1_000,
Self::IntegratedGpu => 700,
Self::Cpu => 100,
Self::Other => 10,
}
}
}
/// Queue-family capabilities needed by the Stage 0 renderer.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanQueueFamily {
/// Stable queue-family index.
pub index: u32,
/// Whether the family supports graphics commands.
pub graphics: bool,
/// Whether the family supports presentation for the target surface.
pub present: bool,
}
/// Surface format capability needed by the Stage 0 swapchain policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanSurfaceFormat {
/// Vulkan format numeric value.
pub format: i32,
/// Vulkan color-space numeric value.
pub color_space: i32,
}
/// Surface capabilities needed by the Stage 0 swapchain policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanSwapchainSurfaceCapabilities {
/// Current surface extent, when dictated by the platform.
pub current_extent: Option<(u32, u32)>,
/// Minimum supported swapchain extent.
pub min_extent: (u32, u32),
/// Maximum supported swapchain extent.
pub max_extent: (u32, u32),
/// Minimum supported image count.
pub min_image_count: u32,
/// Maximum supported image count, or 0 when unbounded.
pub max_image_count: u32,
/// Supported swapchain image-usage flags as raw Vulkan bits.
pub supported_usage_flags: u32,
}
/// Deterministic swapchain planning input.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSwapchainRequest {
/// Requested drawable extent.
pub drawable_extent: (u32, u32),
/// Available surface formats.
pub formats: Vec<VulkanSurfaceFormat>,
/// Available present modes as raw Vulkan values.
pub present_modes: Vec<i32>,
/// Surface capabilities.
pub capabilities: VulkanSwapchainSurfaceCapabilities,
/// Preferred present mode.
pub preferred_present_mode: i32,
}
/// Deterministic swapchain plan.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSwapchainPlan {
/// Report schema version.
pub schema: u32,
/// Selected swapchain extent.
pub extent: (u32, u32),
/// Selected surface format.
pub format: VulkanSurfaceFormat,
/// Selected present mode raw Vulkan value.
pub present_mode: i32,
/// Selected image count.
pub image_count: u32,
}
/// Swapchain planning error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanSwapchainError {
/// No surface format was available.
MissingSurfaceFormat,
/// No present mode was available.
MissingPresentMode,
/// Requested or current extent is empty.
EmptyExtent,
}
impl std::fmt::Display for VulkanSwapchainError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingSurfaceFormat => write!(f, "Vulkan swapchain has no surface format"),
Self::MissingPresentMode => write!(f, "Vulkan swapchain has no present mode"),
Self::EmptyExtent => write!(f, "Vulkan swapchain extent must be non-zero"),
}
}
}
impl std::error::Error for VulkanSwapchainError {}
/// Swapchain recreation reason.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VulkanSwapchainRecreationReason {
/// Drawable extent changed.
Resize,
/// Vulkan reported `VK_ERROR_OUT_OF_DATE_KHR`.
OutOfDate,
/// Vulkan reported `VK_SUBOPTIMAL_KHR`.
Suboptimal,
}
/// Deterministic swapchain recreation report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanSwapchainRecreationReport {
/// Report schema version.
pub schema: u32,
/// Recreation reason.
pub reason: VulkanSwapchainRecreationReason,
/// Previous extent.
pub previous_extent: (u32, u32),
/// Next extent.
pub next_extent: (u32, u32),
}
/// Deterministic frame submission plan for command buffers and sync objects.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanFrameSubmissionPlan {
/// Report schema version.
pub schema: u32,
/// Frames allowed in flight.
pub frames_in_flight: u32,
/// Swapchain-backed primary command buffers.
pub command_buffers: u32,
/// Binary semaphores allocated per frame.
pub semaphores_per_frame: u32,
/// Fences allocated per frame.
pub fences_per_frame: u32,
/// Draw commands encoded into the frame.
pub draw_count: u32,
/// Total indexed vertices submitted by draw commands.
pub indexed_vertex_count: u32,
}
/// Synthetic physical-device capabilities used by negative tests and reports.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanPhysicalDeviceRecord {
/// Human-readable device name.
pub name: String,
/// Reported Vulkan API version.
pub api_version: u32,
/// Device class.
pub device_type: VulkanDeviceType,
/// Supported device-extension names.
pub extensions: Vec<String>,
/// Queue-family capabilities.
pub queue_families: Vec<VulkanQueueFamily>,
/// Surface formats accepted by the target surface.
pub surface_formats: Vec<VulkanSurfaceFormat>,
/// Present modes accepted by the target surface.
pub present_modes: Vec<i32>,
/// Surface capabilities accepted by the target surface.
pub surface_capabilities: VulkanSwapchainSurfaceCapabilities,
/// Depth/stencil attachment formats supported by the device.
pub supported_depth_stencil_formats: Vec<i32>,
/// Formats that can be used as sampled images.
pub sampled_image_formats: Vec<i32>,
/// Informational device limits relevant to the future Stage 0 baseline.
pub limits: VulkanDeviceLimits,
}
impl VulkanPhysicalDeviceRecord {
/// Returns whether the device supports an extension name.
#[must_use]
pub fn supports_extension(&self, extension: &str) -> bool {
self.extensions
.iter()
.any(|candidate| candidate == extension)
}
}
/// Informational device limits relevant to future Stage 0 capability growth.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanDeviceLimits {
/// Maximum 2D image dimension supported by the device.
pub max_image_dimension_2d: u32,
/// Maximum number of live samplers supported by the device.
pub max_sampler_allocation_count: u32,
/// Maximum number of sampler descriptors per stage.
pub max_per_stage_descriptor_samplers: u32,
/// Maximum number of bound descriptor sets.
pub max_bound_descriptor_sets: u32,
}
/// Informational capabilities preserved in deterministic Stage 0 reports.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanInformationalCapabilities {
/// Color formats that support sampled-image usage.
pub sampled_color_formats: Vec<i32>,
/// Depth/stencil formats that support sampled-image usage.
pub sampled_depth_formats: Vec<i32>,
/// Future-baseline device limits.
pub limits: VulkanDeviceLimits,
}
/// Selected device and queue capability report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanCapabilityReport {
/// Report schema version.
pub schema: u32,
/// Selected device name.
pub device_name: String,
/// Selected Vulkan API version.
pub vulkan_api_version: u32,
/// Deterministic score used for device selection.
pub score: i32,
/// Graphics queue family index.
pub graphics_queue_family: u32,
/// Present queue family index.
pub present_queue_family: u32,
/// Whether portability subset is enabled for the selected device.
pub portability_subset: bool,
/// Enabled device extensions.
pub enabled_extensions: Vec<String>,
/// Informational capabilities retained for future baseline planning.
pub informational_capabilities: VulkanInformationalCapabilities,
/// Devices rejected by deterministic Stage 0 capability validation.
pub rejected_devices: Vec<VulkanRejectedDeviceReport>,
}
/// Deterministic rejection reason for an unsuitable physical device.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanRejectedDeviceReport {
/// Human-readable device name.
pub device_name: String,
/// Stable machine-readable rejection code.
pub reason_code: &'static str,
/// Actionable rejection summary.
pub reason: String,
}
/// Vulkan capability selection error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanCapabilityError {
/// No physical devices were available.
NoPhysicalDevice,
/// Device API version is lower than the Stage 0 minimum.
ApiVersionTooLow {
/// Required Vulkan API version.
required: u32,
/// Reported Vulkan API version.
found: u32,
},
/// Required graphics queue is unavailable.
NoGraphicsQueue {
/// Device name that failed validation.
device: String,
},
/// Required present queue is unavailable.
NoPresentQueue {
/// Device name that failed validation.
device: String,
},
/// Swapchain device extension is unavailable.
MissingSwapchainExtension {
/// Device name that failed validation.
device: String,
},
/// No compatible surface format exists.
MissingSurfaceFormat {
/// Device name that failed validation.
device: String,
},
/// No present mode is available for the target surface.
MissingPresentMode {
/// Device name that failed validation.
device: String,
},
/// Swapchain images cannot be used as color attachments.
MissingColorAttachmentUsage {
/// Device name that failed validation.
device: String,
},
/// No compatible depth/stencil attachment format exists for the render request.
MissingDepthStencilFormat {
/// Device name that failed validation.
device: String,
/// Requested depth/stencil profile.
requested: DepthStencilSupport,
},
}
impl std::fmt::Display for VulkanCapabilityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoPhysicalDevice => write!(f, "no Vulkan physical device available"),
Self::ApiVersionTooLow { required, found } => write!(
f,
"Vulkan API version too low: required {}, found {}",
format_api_version(*required),
format_api_version(*found)
),
Self::NoGraphicsQueue { device } => {
write!(f, "Vulkan device {device} has no graphics queue")
}
Self::NoPresentQueue { device } => {
write!(f, "Vulkan device {device} has no present queue")
}
Self::MissingSwapchainExtension { device } => {
write!(f, "Vulkan device {device} lacks {KHR_SWAPCHAIN_EXTENSION}")
}
Self::MissingSurfaceFormat { device } => {
write!(f, "Vulkan device {device} has no compatible surface format")
}
Self::MissingPresentMode { device } => {
write!(f, "Vulkan device {device} has no supported present mode")
}
Self::MissingColorAttachmentUsage { device } => write!(
f,
"Vulkan device {device} surface does not support COLOR_ATTACHMENT usage"
),
Self::MissingDepthStencilFormat { device, requested } => write!(
f,
"Vulkan device {device} lacks a depth/stencil attachment format for {}-bit depth and {}-bit stencil",
requested.depth_bits,
requested.stencil_bits
),
}
}
}
impl std::error::Error for VulkanCapabilityError {}
/// Selects a Vulkan physical device using deterministic Stage 0 policy.
///
/// # Errors
///
/// Returns [`VulkanCapabilityError`] when no candidate satisfies the minimum
/// API version, queue, swapchain-extension and surface-format requirements.
pub fn select_physical_device(
devices: &[VulkanPhysicalDeviceRecord],
) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
select_physical_device_for_request(devices, RenderRequest::conservative())
}
/// Selects a Vulkan physical device for a specific Stage 0 render request.
///
/// # Errors
///
/// Returns [`VulkanCapabilityError`] when no candidate satisfies the minimum
/// API version, queue, swapchain-extension, surface-format or depth/stencil
/// requirements for the requested profile.
pub fn select_physical_device_for_request(
devices: &[VulkanPhysicalDeviceRecord],
render_request: RenderRequest,
) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
if devices.is_empty() {
return Err(VulkanCapabilityError::NoPhysicalDevice);
}
let mut best = None;
let mut rejected_devices = Vec::new();
let mut last_error = None;
for device in devices {
let report = match validate_device_for_request(device, render_request) {
Ok(report) => report,
Err(err) => {
rejected_devices.push(rejected_device_report(device, &err));
last_error = Some(err);
continue;
}
};
match &best {
Some(existing) if compare_reports(&report, existing) != std::cmp::Ordering::Greater => {
}
_ => best = Some(report),
}
}
let mut best =
best.ok_or_else(|| last_error.unwrap_or(VulkanCapabilityError::NoPhysicalDevice))?;
best.rejected_devices = rejected_devices;
Ok(best)
}
/// Builds a deterministic swapchain plan from surface capabilities.
///
/// # Errors
///
/// Returns [`VulkanSwapchainError`] when formats, present modes or extent are
/// unusable.
pub fn plan_vulkan_swapchain(
request: &VulkanSwapchainRequest,
) -> Result<VulkanSwapchainPlan, VulkanSwapchainError> {
let format = select_surface_format(&request.formats)?;
let present_mode = select_present_mode(&request.present_modes, request.preferred_present_mode)?;
let extent = select_swapchain_extent(request)?;
if extent.0 == 0 || extent.1 == 0 {
return Err(VulkanSwapchainError::EmptyExtent);
}
Ok(VulkanSwapchainPlan {
schema: 1,
extent,
format,
present_mode,
image_count: select_image_count(request.capabilities),
})
}
/// Builds a deterministic swapchain recreation report.
#[must_use]
pub const fn swapchain_recreation_report(
reason: VulkanSwapchainRecreationReason,
previous_extent: (u32, u32),
next_extent: (u32, u32),
) -> VulkanSwapchainRecreationReport {
VulkanSwapchainRecreationReport {
schema: 1,
reason,
previous_extent,
next_extent,
}
}
/// Builds a deterministic frame submission plan for a validated command list.
///
/// Stage 0 keeps this as a pure planning boundary so command-pool, command-buffer
/// and synchronization policy can be tested without requiring a native surface.
///
/// # Errors
///
/// Returns [`RenderError`] when the command list has invalid frame framing,
/// ordering, draw ranges, mesh bounds, or non-finite transforms.
pub fn plan_vulkan_frame_submission(
swapchain: &VulkanSwapchainPlan,
commands: &RenderCommandList,
) -> Result<VulkanFrameSubmissionPlan, RenderError> {
validate_command_list(commands)?;
let mut draw_count = 0_u32;
let mut indexed_vertex_count = 0_u32;
for command in &commands.commands {
if let RenderCommand::Draw(draw) = command {
draw_count = draw_count.saturating_add(1);
indexed_vertex_count = indexed_vertex_count.saturating_add(draw.range.count);
}
}
Ok(VulkanFrameSubmissionPlan {
schema: 1,
frames_in_flight: swapchain.image_count.clamp(1, 2),
command_buffers: swapchain.image_count,
semaphores_per_frame: 2,
fences_per_frame: 1,
draw_count,
indexed_vertex_count,
})
}
/// Renders a deterministic JSON capability report.
#[must_use]
pub fn render_capability_report_json(report: &VulkanCapabilityReport) -> String {
#[derive(Serialize)]
struct CapabilityReportJson<'a> {
schema: u32,
vulkan_api: String,
device_name: &'a str,
score: i32,
graphics_queue_family: u32,
present_queue_family: u32,
portability_subset: bool,
enabled_extensions: &'a [String],
informational_capabilities: &'a VulkanInformationalCapabilities,
rejected_devices: &'a [VulkanRejectedDeviceReport],
}
serialize_json_or_fallback(
&CapabilityReportJson {
schema: report.schema,
vulkan_api: format_api_version(report.vulkan_api_version),
device_name: &report.device_name,
score: report.score,
graphics_queue_family: report.graphics_queue_family,
present_queue_family: report.present_queue_family,
portability_subset: report.portability_subset,
enabled_extensions: &report.enabled_extensions,
informational_capabilities: &report.informational_capabilities,
rejected_devices: &report.rejected_devices,
},
"{\"schema\":0,\"vulkan_api\":\"0.0.0\",\"device_name\":\"unknown\",\"score\":0,\"graphics_queue_family\":0,\"present_queue_family\":0,\"portability_subset\":false,\"enabled_extensions\":[],\"informational_capabilities\":{\"sampled_color_formats\":[],\"sampled_depth_formats\":[],\"limits\":{\"max_image_dimension_2d\":0,\"max_sampler_allocation_count\":0,\"max_per_stage_descriptor_samplers\":0,\"max_bound_descriptor_sets\":0}},\"rejected_devices\":[]}",
)
}
/// Renders a deterministic JSON swapchain plan.
#[must_use]
pub fn render_swapchain_plan_json(plan: &VulkanSwapchainPlan) -> String {
#[derive(Serialize)]
struct SwapchainPlanJson {
schema: u32,
extent: [u32; 2],
format: i32,
color_space: i32,
present_mode: i32,
image_count: u32,
}
serialize_json_or_fallback(
&SwapchainPlanJson {
schema: plan.schema,
extent: [plan.extent.0, plan.extent.1],
format: plan.format.format,
color_space: plan.format.color_space,
present_mode: plan.present_mode,
image_count: plan.image_count,
},
"{\"schema\":0,\"extent\":[0,0],\"format\":0,\"color_space\":0,\"present_mode\":0,\"image_count\":0}",
)
}
/// Renders a deterministic JSON swapchain recreation report.
#[must_use]
pub fn render_swapchain_recreation_report_json(report: &VulkanSwapchainRecreationReport) -> String {
#[derive(Serialize)]
struct SwapchainRecreationReportJson<'a> {
schema: u32,
reason: &'a str,
previous_extent: [u32; 2],
next_extent: [u32; 2],
}
serialize_json_or_fallback(
&SwapchainRecreationReportJson {
schema: report.schema,
reason: match report.reason {
VulkanSwapchainRecreationReason::Resize => "resize",
VulkanSwapchainRecreationReason::OutOfDate => "out_of_date",
VulkanSwapchainRecreationReason::Suboptimal => "suboptimal",
},
previous_extent: [report.previous_extent.0, report.previous_extent.1],
next_extent: [report.next_extent.0, report.next_extent.1],
},
"{\"schema\":0,\"reason\":\"unknown\",\"previous_extent\":[0,0],\"next_extent\":[0,0]}",
)
}
/// Renders a deterministic JSON frame submission plan.
#[must_use]
pub fn render_frame_submission_plan_json(plan: &VulkanFrameSubmissionPlan) -> String {
serialize_json_or_fallback(
plan,
"{\"schema\":0,\"frames_in_flight\":0,\"command_buffers\":0,\"semaphores_per_frame\":0,\"fences_per_frame\":0,\"draw_count\":0,\"indexed_vertex_count\":0}",
)
}
pub(crate) fn select_composite_alpha(
supported: vk::CompositeAlphaFlagsKHR,
) -> vk::CompositeAlphaFlagsKHR {
if supported.contains(vk::CompositeAlphaFlagsKHR::OPAQUE) {
vk::CompositeAlphaFlagsKHR::OPAQUE
} else if supported.contains(vk::CompositeAlphaFlagsKHR::PRE_MULTIPLIED) {
vk::CompositeAlphaFlagsKHR::PRE_MULTIPLIED
} else if supported.contains(vk::CompositeAlphaFlagsKHR::POST_MULTIPLIED) {
vk::CompositeAlphaFlagsKHR::POST_MULTIPLIED
} else {
vk::CompositeAlphaFlagsKHR::INHERIT
}
}
pub(crate) fn serialize_json_or_fallback<T: Serialize>(value: &T, fallback: &str) -> String {
match serde_json::to_string(value) {
Ok(json) => json,
Err(_) => fallback.to_string(),
}
}
pub(crate) fn format_api_version(version: u32) -> String {
format!(
"{}.{}.{}",
vk::api_version_major(version),
vk::api_version_minor(version),
vk::api_version_patch(version)
)
}
fn select_surface_format(
formats: &[VulkanSurfaceFormat],
) -> Result<VulkanSurfaceFormat, VulkanSwapchainError> {
if let Some(format) = undefined_surface_format_override(formats) {
return Ok(format);
}
formats
.iter()
.copied()
.find(|format| {
format.format == vk::Format::B8G8R8A8_SRGB.as_raw()
&& format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw()
})
.or_else(|| formats.first().copied())
.ok_or(VulkanSwapchainError::MissingSurfaceFormat)
}
fn undefined_surface_format_override(
formats: &[VulkanSurfaceFormat],
) -> Option<VulkanSurfaceFormat> {
match formats {
[format] if format.format == vk::Format::UNDEFINED.as_raw() => Some(VulkanSurfaceFormat {
format: vk::Format::B8G8R8A8_SRGB.as_raw(),
color_space: format.color_space,
}),
_ => None,
}
}
fn select_present_mode(present_modes: &[i32], preferred: i32) -> Result<i32, VulkanSwapchainError> {
if present_modes.contains(&preferred) {
Ok(preferred)
} else if present_modes.contains(&vk::PresentModeKHR::FIFO.as_raw()) {
Ok(vk::PresentModeKHR::FIFO.as_raw())
} else {
present_modes
.first()
.copied()
.ok_or(VulkanSwapchainError::MissingPresentMode)
}
}
fn select_swapchain_extent(
request: &VulkanSwapchainRequest,
) -> Result<(u32, u32), VulkanSwapchainError> {
if let Some(extent) = request.capabilities.current_extent {
return if extent.0 == 0 || extent.1 == 0 {
Err(VulkanSwapchainError::EmptyExtent)
} else {
Ok(extent)
};
}
let width = request.drawable_extent.0.clamp(
request.capabilities.min_extent.0,
request.capabilities.max_extent.0,
);
let height = request.drawable_extent.1.clamp(
request.capabilities.min_extent.1,
request.capabilities.max_extent.1,
);
Ok((width, height))
}
fn select_image_count(capabilities: VulkanSwapchainSurfaceCapabilities) -> u32 {
let requested = capabilities.min_image_count.saturating_add(1).max(2);
if capabilities.max_image_count == 0 {
requested
} else {
requested.min(capabilities.max_image_count)
}
}
pub(crate) fn validate_device_for_request(
device: &VulkanPhysicalDeviceRecord,
render_request: RenderRequest,
) -> Result<VulkanCapabilityReport, VulkanCapabilityError> {
if device.api_version < MIN_VULKAN_API_VERSION {
return Err(VulkanCapabilityError::ApiVersionTooLow {
required: MIN_VULKAN_API_VERSION,
found: device.api_version,
});
}
if !device.supports_extension(KHR_SWAPCHAIN_EXTENSION) {
return Err(VulkanCapabilityError::MissingSwapchainExtension {
device: device.name.clone(),
});
}
if !supports_surface_formats(device) {
return Err(VulkanCapabilityError::MissingSurfaceFormat {
device: device.name.clone(),
});
}
if device.present_modes.is_empty() {
return Err(VulkanCapabilityError::MissingPresentMode {
device: device.name.clone(),
});
}
if !supports_color_attachment_usage(device.surface_capabilities) {
return Err(VulkanCapabilityError::MissingColorAttachmentUsage {
device: device.name.clone(),
});
}
if !supports_depth_stencil_request(device, render_request.depth) {
return Err(VulkanCapabilityError::MissingDepthStencilFormat {
device: device.name.clone(),
requested: render_request.depth,
});
}
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()];
if portability_subset {
enabled_extensions.push(KHR_PORTABILITY_SUBSET_EXTENSION.to_string());
}
Ok(VulkanCapabilityReport {
schema: 1,
device_name: device.name.clone(),
vulkan_api_version: device.api_version,
score: score_device(device, graphics_queue_family, present_queue_family),
graphics_queue_family,
present_queue_family,
portability_subset,
enabled_extensions,
informational_capabilities: informational_capabilities(device),
rejected_devices: Vec::new(),
})
}
fn rejected_device_report(
device: &VulkanPhysicalDeviceRecord,
error: &VulkanCapabilityError,
) -> VulkanRejectedDeviceReport {
VulkanRejectedDeviceReport {
device_name: device.name.clone(),
reason_code: capability_error_code(error),
reason: error.to_string(),
}
}
const fn capability_error_code(error: &VulkanCapabilityError) -> &'static str {
match error {
VulkanCapabilityError::NoPhysicalDevice => "no_physical_device",
VulkanCapabilityError::ApiVersionTooLow { .. } => "api_version_too_low",
VulkanCapabilityError::NoGraphicsQueue { .. } => "no_graphics_queue",
VulkanCapabilityError::NoPresentQueue { .. } => "no_present_queue",
VulkanCapabilityError::MissingSwapchainExtension { .. } => "missing_swapchain_extension",
VulkanCapabilityError::MissingSurfaceFormat { .. } => "missing_surface_format",
VulkanCapabilityError::MissingPresentMode { .. } => "missing_present_mode",
VulkanCapabilityError::MissingColorAttachmentUsage { .. } => {
"missing_color_attachment_usage"
}
VulkanCapabilityError::MissingDepthStencilFormat { .. } => "missing_depth_stencil_format",
}
}
fn select_queue_families(
device: &VulkanPhysicalDeviceRecord,
) -> Result<(u32, u32), VulkanCapabilityError> {
if let Some(unified) = device
.queue_families
.iter()
.filter(|family| family.graphics && family.present)
.min_by_key(|family| family.index)
{
return Ok((unified.index, unified.index));
}
let graphics_queue_family = device
.queue_families
.iter()
.filter(|family| family.graphics)
.min_by_key(|family| family.index)
.ok_or_else(|| VulkanCapabilityError::NoGraphicsQueue {
device: device.name.clone(),
})?
.index;
let present_queue_family = device
.queue_families
.iter()
.filter(|family| family.present)
.min_by_key(|family| family.index)
.ok_or_else(|| VulkanCapabilityError::NoPresentQueue {
device: device.name.clone(),
})?
.index;
Ok((graphics_queue_family, present_queue_family))
}
fn supports_surface_formats(device: &VulkanPhysicalDeviceRecord) -> bool {
!device.surface_formats.is_empty()
}
fn supports_color_attachment_usage(capabilities: VulkanSwapchainSurfaceCapabilities) -> bool {
capabilities.supported_usage_flags & vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw() != 0
}
fn supports_depth_stencil_request(
device: &VulkanPhysicalDeviceRecord,
depth: DepthStencilSupport,
) -> bool {
if depth.depth_bits == 0 && depth.stencil_bits == 0 {
return true;
}
required_depth_stencil_formats(depth).iter().any(|format| {
device
.supported_depth_stencil_formats
.contains(&format.as_raw())
})
}
fn informational_capabilities(
device: &VulkanPhysicalDeviceRecord,
) -> VulkanInformationalCapabilities {
let (sampled_depth_formats, sampled_color_formats): (Vec<_>, Vec<_>) = device
.sampled_image_formats
.iter()
.copied()
.partition(|format| is_depth_stencil_format(*format));
VulkanInformationalCapabilities {
sampled_color_formats,
sampled_depth_formats,
limits: device.limits,
}
}
fn required_depth_stencil_formats(depth: DepthStencilSupport) -> &'static [vk::Format] {
match (depth.depth_bits, depth.stencil_bits) {
(16, 0) => &[vk::Format::D16_UNORM, vk::Format::D32_SFLOAT],
(24, 0) => &[vk::Format::X8_D24_UNORM_PACK32, vk::Format::D32_SFLOAT],
(32, 0) => &[vk::Format::D32_SFLOAT],
(16, 8) => &[vk::Format::D16_UNORM_S8_UINT, vk::Format::D24_UNORM_S8_UINT],
(24, 8) => &[
vk::Format::D24_UNORM_S8_UINT,
vk::Format::D32_SFLOAT_S8_UINT,
],
(32, 8) => &[vk::Format::D32_SFLOAT_S8_UINT],
_ => &[],
}
}
fn is_depth_stencil_format(format: i32) -> bool {
matches!(
vk::Format::from_raw(format),
vk::Format::D16_UNORM
| vk::Format::X8_D24_UNORM_PACK32
| vk::Format::D32_SFLOAT
| vk::Format::S8_UINT
| vk::Format::D16_UNORM_S8_UINT
| vk::Format::D24_UNORM_S8_UINT
| vk::Format::D32_SFLOAT_S8_UINT
)
}
fn score_device(
device: &VulkanPhysicalDeviceRecord,
graphics_queue_family: u32,
present_queue_family: u32,
) -> i32 {
let unified_queue_bonus = if graphics_queue_family == present_queue_family {
100
} else {
0
};
let portability_penalty = if device.supports_extension(KHR_PORTABILITY_SUBSET_EXTENSION) {
-50
} else {
0
};
device.device_type.score_bonus()
+ unified_queue_bonus
+ portability_penalty
+ i32::try_from(device.surface_formats.len()).unwrap_or(i32::MAX)
}
pub(crate) fn compare_reports(
left: &VulkanCapabilityReport,
right: &VulkanCapabilityReport,
) -> std::cmp::Ordering {
left.score
.cmp(&right.score)
.then_with(|| right.device_name.cmp(&left.device_name))
}
@@ -0,0 +1,399 @@
use fparkan_binary::{sha256, sha256_hex};
use serde::Serialize;
pub(crate) use crate::ffi::{
SPIRV_MAGIC, SPIRV_VERSION_1_0, TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
};
use crate::policy::serialize_json_or_fallback;
pub(crate) const SHADER_MANIFEST_SCHEMA: u32 = 2;
pub(crate) const SHADER_TARGET_ENV: &str = "vulkan1.1";
pub(crate) const SHADER_COMPILER_NAME: &str = "glslangValidator";
pub(crate) const SHADER_COMPILER_VERSION: &str = "11:16.3.0";
pub(crate) const SHADER_COMPILER_BINARY_SHA256: &str =
"9bcd69d830b350aaa6e2254915ff74e46070e217b67f38daad27c1fc1f22910f";
pub(crate) const SPIRV_VALIDATOR_NAME: &str = "spirv-val";
pub(crate) const SPIRV_VALIDATOR_VERSION: &str =
"SPIRV-Tools v2026.2 unknown hash, 2026-04-29T17:02:58+00:00";
pub(crate) const SPIRV_VALIDATOR_BINARY_SHA256: &str =
"f6d5b96ff19f073f3af0c0bcfa0c18702d288d3ec598efc242d01cd104d8354f";
pub(crate) const TRIANGLE_VERTEX_SOURCE_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.vert";
pub(crate) const TRIANGLE_VERTEX_SOURCE_SHA256: &str =
"1e57f14d193fc61457c0749081c452ad25669998913107df12f3ccc3c33e0341";
pub(crate) const TRIANGLE_VERTEX_SPIRV_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
pub(crate) const TRIANGLE_VERTEX_COMPILE_COMMAND: &str =
"glslangValidator -V --target-env vulkan1.1 -S vert -e main adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
pub(crate) const TRIANGLE_VERTEX_VALIDATE_COMMAND: &str =
"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.vert.spv";
const TRIANGLE_FRAGMENT_SOURCE_PATH: &str = "adapters/fparkan-render-vulkan/shaders/triangle.frag";
const TRIANGLE_FRAGMENT_SOURCE_SHA256: &str =
"f19e74d001d07fb537d4b0f9e621f9b8bc40eeb68816130220853abea6bd4445";
const TRIANGLE_FRAGMENT_SPIRV_PATH: &str =
"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
const TRIANGLE_FRAGMENT_COMPILE_COMMAND: &str =
"glslangValidator -V --target-env vulkan1.1 -S frag -e main adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
const TRIANGLE_FRAGMENT_VALIDATE_COMMAND: &str =
"spirv-val --target-env vulkan1.1 adapters/fparkan-render-vulkan/shaders/triangle.frag.spv";
fn shader_compiler_name() -> &'static str {
option_env!("FPARKAN_BUILD_SHADER_COMPILER_NAME").unwrap_or(SHADER_COMPILER_NAME)
}
fn shader_compiler_version() -> &'static str {
option_env!("FPARKAN_BUILD_SHADER_COMPILER_VERSION").unwrap_or(SHADER_COMPILER_VERSION)
}
fn shader_compiler_binary_sha256() -> &'static str {
option_env!("FPARKAN_BUILD_SHADER_COMPILER_SHA256").unwrap_or(SHADER_COMPILER_BINARY_SHA256)
}
fn spirv_validator_name() -> &'static str {
option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_NAME").unwrap_or(SPIRV_VALIDATOR_NAME)
}
fn spirv_validator_version() -> &'static str {
option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_VERSION").unwrap_or(SPIRV_VALIDATOR_VERSION)
}
fn spirv_validator_binary_sha256() -> &'static str {
option_env!("FPARKAN_BUILD_SPIRV_VALIDATOR_SHA256").unwrap_or(SPIRV_VALIDATOR_BINARY_SHA256)
}
/// Shader tool metadata pinned in the Stage 0 manifest.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanShaderToolManifest {
/// Tool executable name.
pub name: &'static str,
/// Tool version string.
pub version: &'static str,
/// Tool binary SHA-256.
pub binary_sha256: &'static str,
}
/// Vulkan shader stage.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VulkanShaderStage {
/// Vertex stage.
Vertex,
/// Fragment stage.
Fragment,
}
/// Offline SPIR-V shader manifest entry.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanShaderModuleManifest {
/// Logical shader name.
pub name: &'static str,
/// Shader stage.
pub stage: VulkanShaderStage,
/// SPIR-V entry point.
pub entry_point: &'static str,
/// Descriptor set count.
pub descriptor_sets: u32,
/// Push constant byte count.
pub push_constant_bytes: u32,
/// Checked-in GLSL source path.
pub source_path: &'static str,
/// Checked-in GLSL source SHA-256.
pub source_sha256: &'static str,
/// Checked-in SPIR-V module path.
pub spirv_path: &'static str,
/// Exact offline compile command used for the checked-in SPIR-V artifact.
pub compile_command: &'static str,
/// Exact offline validation command used for the checked-in SPIR-V artifact.
pub validate_command: &'static str,
/// SPIR-V words.
pub words: &'static [u32],
}
/// Shader manifest validation report.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanShaderManifestReport {
/// Report schema version.
pub schema: u32,
/// Explicit Vulkan target environment for the checked-in SPIR-V.
pub target_env: &'static str,
/// Pinned compiler metadata.
pub compiler: VulkanShaderToolManifest,
/// Pinned validator metadata.
pub validator: VulkanShaderToolManifest,
/// Shader module reports.
pub modules: Vec<VulkanShaderModuleReport>,
/// Hash of the normalized shader manifest.
pub manifest_hash: String,
}
/// Shader module validation report.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct VulkanShaderModuleReport {
/// Logical shader name.
pub name: &'static str,
/// Shader stage.
pub stage: VulkanShaderStage,
/// SPIR-V entry point.
pub entry_point: &'static str,
/// Checked-in GLSL source path.
pub source_path: &'static str,
/// Checked-in GLSL source SHA-256.
pub source_sha256: &'static str,
/// Checked-in SPIR-V module path.
pub spirv_path: &'static str,
/// SPIR-V word count.
pub word_count: usize,
/// SPIR-V byte hash.
pub sha256: String,
/// Descriptor set count.
pub descriptor_sets: u32,
/// Push constant byte count.
pub push_constant_bytes: u32,
/// Exact offline compile command used for the checked-in SPIR-V artifact.
pub compile_command: &'static str,
/// Exact offline validation command used for the checked-in SPIR-V artifact.
pub validate_command: &'static str,
/// Stable hash of the reflected interface contract for this module.
pub interface_hash: String,
}
/// Shader manifest validation error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanShaderManifestError {
/// SPIR-V module is too short to contain a header.
TooShort {
/// Shader name.
name: &'static str,
},
/// SPIR-V module has an invalid magic word.
InvalidMagic {
/// Shader name.
name: &'static str,
/// Found magic word.
found: u32,
},
/// SPIR-V module version is below 1.0.
UnsupportedVersion {
/// Shader name.
name: &'static str,
/// Found version word.
found: u32,
},
/// SPIR-V module declares an invalid bound.
InvalidBound {
/// Shader name.
name: &'static str,
},
}
impl std::fmt::Display for VulkanShaderManifestError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooShort { name } => write!(f, "shader {name} SPIR-V module is too short"),
Self::InvalidMagic { name, found } => {
write!(f, "shader {name} has invalid SPIR-V magic 0x{found:08x}")
}
Self::UnsupportedVersion { name, found } => write!(
f,
"shader {name} has unsupported SPIR-V version 0x{found:08x}"
),
Self::InvalidBound { name } => write!(f, "shader {name} has invalid SPIR-V bound"),
}
}
}
impl std::error::Error for VulkanShaderManifestError {}
/// Returns the built-in Stage 0 indexed-triangle shader manifest.
#[must_use]
pub fn triangle_shader_manifest() -> Vec<VulkanShaderModuleManifest> {
vec![
VulkanShaderModuleManifest {
name: "triangle.vert",
stage: VulkanShaderStage::Vertex,
entry_point: "main",
descriptor_sets: 0,
push_constant_bytes: 0,
source_path: TRIANGLE_VERTEX_SOURCE_PATH,
source_sha256: TRIANGLE_VERTEX_SOURCE_SHA256,
spirv_path: TRIANGLE_VERTEX_SPIRV_PATH,
compile_command: TRIANGLE_VERTEX_COMPILE_COMMAND,
validate_command: TRIANGLE_VERTEX_VALIDATE_COMMAND,
words: TRIANGLE_VERTEX_SHADER_WORDS,
},
VulkanShaderModuleManifest {
name: "triangle.frag",
stage: VulkanShaderStage::Fragment,
entry_point: "main",
descriptor_sets: 0,
push_constant_bytes: 0,
source_path: TRIANGLE_FRAGMENT_SOURCE_PATH,
source_sha256: TRIANGLE_FRAGMENT_SOURCE_SHA256,
spirv_path: TRIANGLE_FRAGMENT_SPIRV_PATH,
compile_command: TRIANGLE_FRAGMENT_COMPILE_COMMAND,
validate_command: TRIANGLE_FRAGMENT_VALIDATE_COMMAND,
words: TRIANGLE_FRAGMENT_SHADER_WORDS,
},
]
}
/// Validates shader SPIR-V containers and renders a deterministic report.
///
/// # Errors
///
/// Returns [`VulkanShaderManifestError`] when a module fails Stage 0 SPIR-V
/// container validation.
pub fn validate_shader_manifest(
modules: &[VulkanShaderModuleManifest],
) -> Result<VulkanShaderManifestReport, VulkanShaderManifestError> {
let mut reports = Vec::with_capacity(modules.len());
for module in modules {
validate_spirv_container(module)?;
let bytes = spirv_words_to_bytes(module.words);
reports.push(VulkanShaderModuleReport {
name: module.name,
stage: module.stage,
entry_point: module.entry_point,
source_path: module.source_path,
source_sha256: module.source_sha256,
spirv_path: module.spirv_path,
word_count: module.words.len(),
sha256: sha256_hex(&sha256(&bytes)),
descriptor_sets: module.descriptor_sets,
push_constant_bytes: module.push_constant_bytes,
compile_command: module.compile_command,
validate_command: module.validate_command,
interface_hash: shader_interface_hash(module),
});
}
let normalized = render_shader_manifest_without_hash_json(&reports);
Ok(VulkanShaderManifestReport {
schema: SHADER_MANIFEST_SCHEMA,
target_env: SHADER_TARGET_ENV,
compiler: VulkanShaderToolManifest {
name: shader_compiler_name(),
version: shader_compiler_version(),
binary_sha256: shader_compiler_binary_sha256(),
},
validator: VulkanShaderToolManifest {
name: spirv_validator_name(),
version: spirv_validator_version(),
binary_sha256: spirv_validator_binary_sha256(),
},
modules: reports,
manifest_hash: sha256_hex(&sha256(normalized.as_bytes())),
})
}
/// Renders a deterministic JSON shader manifest report.
#[must_use]
pub fn render_shader_manifest_report_json(report: &VulkanShaderManifestReport) -> String {
#[derive(Serialize)]
struct ShaderManifestReportJson<'a> {
schema: u32,
target_env: &'a str,
compiler: &'a VulkanShaderToolManifest,
validator: &'a VulkanShaderToolManifest,
modules: &'a [VulkanShaderModuleReport],
manifest_hash: &'a str,
}
serialize_json_or_fallback(
&ShaderManifestReportJson {
schema: report.schema,
target_env: report.target_env,
compiler: &report.compiler,
validator: &report.validator,
modules: &report.modules,
manifest_hash: &report.manifest_hash,
},
"{\"schema\":0,\"target_env\":\"unknown\",\"compiler\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"validator\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"modules\":[],\"manifest_hash\":\"unknown\"}",
)
}
fn shader_interface_hash(module: &VulkanShaderModuleManifest) -> String {
#[derive(Serialize)]
struct ShaderInterfaceHashJson<'a> {
stage: VulkanShaderStage,
entry_point: &'a str,
descriptor_sets: u32,
push_constant_bytes: u32,
}
let normalized = serialize_json_or_fallback(
&ShaderInterfaceHashJson {
stage: module.stage,
entry_point: module.entry_point,
descriptor_sets: module.descriptor_sets,
push_constant_bytes: module.push_constant_bytes,
},
"{\"stage\":\"vertex\",\"entry_point\":\"main\",\"descriptor_sets\":0,\"push_constant_bytes\":0}",
);
sha256_hex(&sha256(normalized.as_bytes()))
}
fn validate_spirv_container(
module: &VulkanShaderModuleManifest,
) -> Result<(), VulkanShaderManifestError> {
if module.words.len() < 5 {
return Err(VulkanShaderManifestError::TooShort { name: module.name });
}
if module.words[0] != SPIRV_MAGIC {
return Err(VulkanShaderManifestError::InvalidMagic {
name: module.name,
found: module.words[0],
});
}
if module.words[1] < SPIRV_VERSION_1_0 {
return Err(VulkanShaderManifestError::UnsupportedVersion {
name: module.name,
found: module.words[1],
});
}
if module.words[3] == 0 {
return Err(VulkanShaderManifestError::InvalidBound { name: module.name });
}
Ok(())
}
fn spirv_words_to_bytes(words: &[u32]) -> Vec<u8> {
let mut out = Vec::with_capacity(words.len() * 4);
for word in words {
out.extend_from_slice(&word.to_le_bytes());
}
out
}
fn render_shader_manifest_without_hash_json(modules: &[VulkanShaderModuleReport]) -> String {
#[derive(Serialize)]
struct ShaderManifestWithoutHashJson<'a> {
schema: u32,
target_env: &'a str,
compiler: VulkanShaderToolManifest,
validator: VulkanShaderToolManifest,
modules: &'a [VulkanShaderModuleReport],
}
let json = serialize_json_or_fallback(
&ShaderManifestWithoutHashJson {
schema: SHADER_MANIFEST_SCHEMA,
target_env: SHADER_TARGET_ENV,
compiler: VulkanShaderToolManifest {
name: SHADER_COMPILER_NAME,
version: SHADER_COMPILER_VERSION,
binary_sha256: SHADER_COMPILER_BINARY_SHA256,
},
validator: VulkanShaderToolManifest {
name: SPIRV_VALIDATOR_NAME,
version: SPIRV_VALIDATOR_VERSION,
binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256,
},
modules,
},
"{\"schema\":0,\"target_env\":\"unknown\",\"compiler\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"validator\":{\"name\":\"unknown\",\"version\":\"unknown\",\"binary_sha256\":\"unknown\"},\"modules\":[]}",
);
match json.strip_suffix('}') {
Some(stripped) => stripped.to_string(),
None => json,
}
}
+4 -4
View File
@@ -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();
@@ -104,8 +104,8 @@ fn run(args: &[String]) -> Result<String, String> {
args.frames,
last_tick,
last_draw_count,
capture_report.submissions,
capture_report.last_capture_size,
capture_report.execution.submission_plans,
capture_report.execution.last_capture_size,
json_hash(&last_hash)
))
}
+4
View File
@@ -4,11 +4,15 @@ version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
build = "build.rs"
[dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
winit = "0.30"
[lints]
workspace = true
+134
View File
@@ -0,0 +1,134 @@
//! Build-time provenance for native smoke artifacts.
use std::env;
use std::path::{Path, PathBuf};
use std::process::Command;
fn main() {
println!("cargo:rerun-if-env-changed=TARGET");
println!("cargo:rerun-if-env-changed=RUSTC");
println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN");
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
println!("cargo:rerun-if-env-changed=SOURCE_VERSION");
println!("cargo:rerun-if-env-changed=BUILD_VCS_NUMBER");
if let Ok(target) = env::var("TARGET") {
println!("cargo:rustc-env=FPARKAN_BUILD_TARGET_TRIPLE={target}");
}
if let Some(toolchain) = rustc_release() {
println!("cargo:rustc-env=FPARKAN_BUILD_RUST_TOOLCHAIN={toolchain}");
}
if let Some(workspace_root) = workspace_root() {
if let Some(git_dir) = git_dir(&workspace_root) {
emit_git_rerun_hints(&git_dir);
}
if let Some(commit_sha) = env_commit_sha().or_else(|| git_head_commit_sha(&workspace_root))
{
println!("cargo:rustc-env=FPARKAN_BUILD_COMMIT_SHA={commit_sha}");
}
if let Some(git_dirty) = git_dirty(&workspace_root) {
println!("cargo:rustc-env=FPARKAN_BUILD_GIT_DIRTY={git_dirty}");
}
}
}
fn workspace_root() -> Option<PathBuf> {
env::var_os("CARGO_MANIFEST_DIR")
.map(PathBuf::from)
.map(|manifest_dir| manifest_dir.join("../.."))
}
fn env_commit_sha() -> Option<String> {
["GITHUB_SHA", "SOURCE_VERSION", "BUILD_VCS_NUMBER"]
.into_iter()
.filter_map(|name| env::var(name).ok())
.find(|value| is_commit_sha(value))
}
fn git_head_commit_sha(workspace_root: &Path) -> Option<String> {
let output = Command::new("git")
.args(["-C"])
.arg(workspace_root)
.args(["rev-parse", "HEAD"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let value = value.trim().to_string();
is_commit_sha(&value).then_some(value)
}
fn git_dirty(workspace_root: &Path) -> Option<bool> {
let output = Command::new("git")
.args(["-C"])
.arg(workspace_root)
.args(["status", "--short"])
.output()
.ok()?;
output
.status
.success()
.then(|| !String::from_utf8_lossy(&output.stdout).trim().is_empty())
}
fn git_dir(workspace_root: &Path) -> Option<PathBuf> {
let output = Command::new("git")
.args(["-C"])
.arg(workspace_root)
.args(["rev-parse", "--git-dir"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let value = value.trim();
(!value.is_empty()).then(|| workspace_root.join(value))
}
fn emit_git_rerun_hints(git_dir: &Path) {
let head = git_dir.join("HEAD");
println!("cargo:rerun-if-changed={}", head.display());
println!(
"cargo:rerun-if-changed={}",
git_dir.join("packed-refs").display()
);
println!("cargo:rerun-if-changed={}", git_dir.join("index").display());
let Some(reference) = std::fs::read_to_string(&head).ok().and_then(|value| {
value
.strip_prefix("ref: ")
.map(str::trim)
.map(ToOwned::to_owned)
}) else {
return;
};
println!(
"cargo:rerun-if-changed={}",
git_dir.join(reference).display()
);
}
fn is_commit_sha(value: &str) -> bool {
value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn rustc_release() -> Option<String> {
let rustc = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
let output = Command::new(rustc).arg("-Vv").output().ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()?
.lines()
.find_map(|line| {
line.strip_prefix("release: ")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToString::to_string)
})
}
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
[graph]
all-features = true
[advisories]
yanked = "deny"
[bans]
multiple-versions = "allow"
wildcards = "deny"
deny = [
{ name = "native-tls" },
{ name = "openssl" },
{ name = "openssl-sys" },
]
[licenses]
unlicensed = "deny"
copyleft = "allow"
allow = [
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"CC0-1.0",
"GPL-2.0-only",
"ISC",
"MIT",
"MPL-2.0",
"Unicode-3.0",
"Zlib",
]
[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
+8 -8
View File
@@ -11,11 +11,11 @@ S0-ARCH-003 covered cargo xtask policy rejects platform/render adapter dependenc
S0-ARCH-004 covered cargo xtask policy scans workspace-owned Rust/TOML for unsafe constructs and workspace lints forbid unsafe_code
S0-ARCH-005 covered cargo xtask policy rejects Python source files, Python shebangs, and Python CI workflow steps while allowing docs requirements.txt
S0-ARCH-006 covered cargo xtask policy rejects non-fparkan package directories under crates/
S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rustdoc warnings, cargo-deny or built-in supply-chain fallback, and strict acceptance audit
S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rustdoc warnings, cargo-deny with reviewed deny.toml, and strict acceptance audit; built-in supply-chain fallback is opt-in local-only and forbidden when CI is set
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-010 covered cargo xtask acceptance audit emits measured commit_sha, git_dirty, runner_identity, rust_toolchain, and msrv metadata into the JSON artifact
S0-ARCH-011 covered .github/workflows/ci.yml runs cargo run -p fparkan-vulkan-smoke --locked -- --out target/fparkan/native-smoke/macos.json and cargo xtask native-smoke audit enforces a passed macOS 300-frame report with measured resize/recreate, validation=0, clean git provenance, exact commit SHA shape, and a platform-consistent target triple
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
@@ -30,7 +30,7 @@ S0-PLAT-001 covered cargo test -p fparkan-platform-winit --offline window_port_r
S0-PLAT-002 covered cargo clippy -p fparkan-platform -p fparkan-platform-winit --all-targets --all-features --locked -- -D warnings
S0-PLAT-003 covered cargo test -p fparkan-platform-winit --offline smoke_window_plan_requires_native_handles_and_nonzero_extent smoke_window_plan_rejects_zero_extent
S0-PLAT-004 covered cargo test -p fparkan-platform-winit --offline smoke_window_app_requires_created_native_window smoke_window_app_rejects_synthetic_window_without_native_handles
S0-VK-001 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
S0-VK-001 covered cargo test -p fparkan-render-vulkan --offline planning_backend_tracks_render_request_and_simulated_present
S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue
S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed
S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_graphics_present_swapchain_and_format
@@ -47,23 +47,23 @@ S0-VK-014 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_p
S0-VK-015 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_uses_fifo_and_current_extent_fallbacks
S0-VK-016 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_rejects_missing_surface_data_and_empty_extent
S0-VK-017 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_json_and_recreation_reports_are_stable
S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable
S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable checked_in_shader_manifest_matches_generated_report
S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable
S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline planning_backend_tracks_render_request_and_simulated_present
S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status
S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version
S0-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe
S0-VK-026 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_window_probe rejects_passed_without_surface_probe parses_surface_probe_as_instance_probe
S0-VK-027 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_swapchain_recreation blocked_report_includes_shader_manifest_and_bootstrap_status
S0-VK-028 covered cargo test -p fparkan-vulkan-smoke --offline reports_rustc_host_triple blocked_report_includes_shader_manifest_and_bootstrap_status
S0-VK-029 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
S0-VK-029 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_required_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
S0-VK-030 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_with_failed_surface
S0-VK-031 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_selected_device
S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_swapchain
S0-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device
S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_required_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
1 # Acceptance coverage manifest.
11 S0-ARCH-004
12 S0-ARCH-005
13 S0-ARCH-006
14 S0-ARCH-007
15 S0-ARCH-008
16 S0-ARCH-009
17 S0-ARCH-010
18 S0-ARCH-011
19 S0-DIAG-001
20 S0-DIAG-002
21 S0-CORPUS-001
30 S0-PLAT-002
31 S0-PLAT-003
32 S0-PLAT-004
33 S0-VK-001
34 S0-VK-002
35 S0-VK-003
36 S0-VK-004
47 S0-VK-015
48 S0-VK-016
49 S0-VK-017
50 S0-VK-018
51 S0-VK-019
52 S0-VK-020
53 S0-VK-021
54 S0-VK-022
55 S0-VK-023
56 S0-VK-024
57 S0-VK-025
58 S0-VK-026
59 S0-VK-027
60 S0-VK-028
61 S0-VK-029
62 S0-VK-030
63 S0-VK-031
64 S0-VK-032
65 S0-VK-033
66 S0-VK-034
67 S0-LIMIT-001
68 S0-LIMIT-002
69 L1-P1-NRES-001
+68
View File
@@ -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`
+47
View File
@@ -0,0 +1,47 @@
schema = 1
[stages]
"0" = [
"fparkan-binary",
"fparkan-corpus",
"fparkan-diagnostics",
"fparkan-platform",
"fparkan-platform-winit",
"fparkan-render",
"fparkan-render-vulkan",
"fparkan-test-support",
"fparkan-vulkan-smoke",
"xtask",
]
"1" = [
"fparkan-cli",
"fparkan-inspection",
"fparkan-nres",
"fparkan-path",
"fparkan-resource",
"fparkan-rsli",
"fparkan-vfs",
]
"2" = [
"fparkan-prototype",
]
"3" = [
"fparkan-assets",
"fparkan-material",
"fparkan-msh",
"fparkan-texm",
"fparkan-viewer",
]
"4" = [
"fparkan-animation",
"fparkan-fx",
]
"5" = [
"fparkan-game",
"fparkan-headless",
"fparkan-mission-format",
"fparkan-runtime",
"fparkan-terrain",
"fparkan-terrain-format",
"fparkan-world",
]
-643
View File
@@ -1,643 +0,0 @@
# FParkan — аудит Stage 0 и план полного закрытия
**Проект:** `valentineus/fparkan`
**Проверенная ветка:** `devel` GitHub-зеркала
**Дата аудита:** 23 июня 2026 года
**Область:** только Stage 0 — Governance, reproducibility и Vulkan foundation
**Метод:** статический архитектурный и кодовый аудит
**Сборка и исполнение:** не выполнялись; `cargo build`, `cargo test`, Vulkan smoke и validation jobs не запускались
---
## 1. Итоговый вердикт
**Stage 0 не закрыт и находится в статусе `BLOCKED`.**
Главный критерий Stage 0 — воспроизводимый репозиторий и минимальный настоящий Vulkan vertical slice на Windows, Linux и macOS. В проверенном состоянии:
- отсутствует `fparkan-platform-winit`;
- отсутствует `fparkan-render-vulkan`;
- отсутствуют Vulkan instance/device/surface/swapchain;
- `fparkan-game` использует `RecordingBackend`, а не GPU backend;
- workspace по-прежнему содержит SDL/OpenGL stub adapters;
- Rust toolchain закреплён только как изменяемый канал `stable`;
- `cargo xtask ci` не реализует полный канонический gate;
- нет подтверждённых артефактов Windows/Linux/macOS smoke jobs.
### Сводная оценка
| Группа требований | Статус | Основной блокер |
|---|---|---|
| Reproducibility и toolchain | **FAIL** | Toolchain не закреплён точной версией, MSRV не объявлен |
| Repository policy и CI | **FAIL** | Неполные fmt/test/clippy/doc/security gates |
| Platform abstraction | **FAIL** | Core API содержит OpenGL-specific contract; `winit` adapter отсутствует |
| Vulkan backend | **FAIL** | Нет Vulkan loader/device/surface/swapchain/pipeline |
| macOS portability | **FAIL** | Нет MoltenVK integration и portability handling |
| Offline shaders | **FAIL** | Нет SPIR-V build/validation/hash pipeline |
| Legacy cleanup | **FAIL** | SDL/GL stubs остаются workspace members |
| Headless isolation | **PASS на manifest-level** | Автоматическое доказательство dependency closure ещё требуется |
| Native acceptance | **FAIL / NOT RUNNABLE** | Нет реального backend и platform artifacts |
Stage 0 можно объявить закрытым только после прохождения реального Vulkan smoke на всех трёх системах и публикации machine-readable артефактов.
---
## 2. Область и ограничения аудита
Канонические требования взяты из документа:
- «План реализации stage 05: Vulkan revision»;
- <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f>.
Проверялась ветка:
- <https://github.com/valentineus/fparkan/tree/devel>.
Ограничения:
1. Ветка `devel` является движущейся ссылкой. Следующий formal audit следует выполнять на закреплённом commit SHA или tag.
2. README указывает self-hosted repository как primary. Его закрытые CI runners и artifacts не были доступны.
3. Код не собирался и не запускался по условию аудита.
4. Vulkan runtime, validation layers, MoltenVK и native window creation не проверялись динамически.
5. Статический анализ достаточен для определения текущих архитектурных блокеров: требуемых adapters и зависимостей в workspace нет.
---
## 3. Матрица требований Stage 0
| Требование | Статус | Текущее состояние | Необходимо для закрытия |
|---|---|---|---|
| Exact stable Rust toolchain | **FAIL** | `rust-toolchain.toml`: `channel = "stable"` | Закрепить точную версию, например `1.xx.y` |
| Объявленный MSRV | **FAIL** | `workspace.package.rust-version` отсутствует | Добавить `rust-version` и отдельный MSRV job |
| Полный `cargo xtask ci` | **FAIL** | Есть custom rustfmt, policy, workspace test и clippy | Добавить канонические fmt/test/clippy/doc/security gates |
| `--all-targets --all-features` | **FAIL** | Не используются текущим `ci` | Добавить к test/clippy/doc gates |
| Clippy `-D warnings` | **FAIL** | Явно не передаётся | Сделать предупреждения blocking |
| Rustdoc broken-link gate | **FAIL** | Отсутствует | Добавить `RUSTDOCFLAGS=-D warnings -D rustdoc::broken_intra_doc_links` |
| License/advisory/source policy | **PARTIAL / UNVERIFIED** | Есть custom policy и GPL workspace license | Подключить `cargo-deny` или эквивалент и хранить versioned policy |
| Typed TOML parsing | **FAIL** | Licensed manifest разбирается вручную построчно | `serde` + TOML schema + `deny_unknown_fields` |
| `cargo_metadata` policy | **FAIL** | Dependency rules не опираются на typed Cargo graph | Добавить `cargo_metadata` и package-ID based checks |
| CI matrix Windows/Linux/macOS | **UNVERIFIED / BLOCKER** | Доступных platform artifacts нет | Создать native matrix и сохранять reports |
| Backend-neutral platform API | **FAIL** | В core есть `GraphicsProfile`, GL/GLES versions и `WindowPort::present()` | Удалить GL context concepts; present перенести в renderer |
| `fparkan-platform-winit` | **FAIL** | В workspace только SDL-named stub | Реализовать настоящий event loop/window adapter |
| `fparkan-render-vulkan` | **FAIL** | В workspace только GL-named recording stub | Реализовать настоящий Vulkan backend |
| Vulkan loader/instance/device | **FAIL** | Vulkan bindings отсутствуют | Добавить `ash`, instance, device selection, queues |
| Surface/swapchain/present | **FAIL** | Отсутствуют | Реализовать platform surface и swapchain lifecycle |
| Indexed triangle | **FAIL** | Есть только command capture | Нарисовать реальный indexed triangle |
| Resize/out-of-date/suboptimal | **FAIL** | Swapchain отсутствует | Реализовать полную recreation policy |
| Deterministic capability report | **FAIL** | Device discovery отсутствует | Pure scoring policy + JSON capability report |
| macOS portability | **FAIL** | MoltenVK integration отсутствует | Portability enumeration, subset и packaged MoltenVK |
| Offline SPIR-V pipeline | **FAIL** | GL stub проверяет только synthetic markers | Pinned compiler, validator, descriptor manifest и hashes |
| Legacy adapter removal | **FAIL** | SDL/GL crates входят в workspace | Удалить crates и все references после замены |
| Game/viewer composition | **FAIL** | Game использует `RecordingBackend`; viewer — CLI inspector | Подключить winit + Vulkan только в composition roots |
| Headless isolation | **PASS на manifest-level** | Нет window/Vulkan dependency | Добавить automated Cargo metadata assertion |
| 300 frames + resize + validation=0 | **FAIL** | Невозможно выполнить без backend | Native smoke jobs на трёх OS |
| Negative Vulkan tests | **FAIL** | Нет Vulkan error model | Loader/device/queue/format failure fixtures |
---
## 4. Замечания
### S0-B01 — Workspace содержит удаляемые SDL/OpenGL stub crates
**Приоритет:** BLOCKER
**Файлы:** `Cargo.toml`, `adapters/fparkan-platform-sdl`, `adapters/fparkan-render-gl`
Root workspace включает оба прежних adapter crate. При этом:
- SDL adapter не зависит от SDL и содержит in-memory stubs;
- GL adapter не зависит от OpenGL и только сохраняет canonical command captures;
- их tests доказывают deterministic stub behavior, а не platform/GPU integration.
Это создаёт ложноположительный сигнал готовности backend-а.
**Рекомендация:**
1. До появления замены пометить crates как `legacy-proof` и исключить из default production composition.
2. Добавить policy, запрещающий приложениям зависеть от них.
3. После подключения `platform-winit` и `render-vulkan` удалить crates, lockfile references, docs и tests.
### S0-B02 — Core platform contract остаётся OpenGL-specific
**Приоритет:** BLOCKER
**Файл:** `crates/fparkan-platform/src/lib.rs`
Проблемы:
- `GraphicsProfile::DesktopCore/Embedded` описывает GL/GLES profile;
- `GraphicsContextRequest` описывает создание GL context;
- `WindowPort::present()` ошибочно закрепляет presentation за window abstraction;
- `PlatformEvent` содержит только `Quit`;
- отсутствуют resize, scale factor, focus, keyboard, mouse, suspend/resume и raw handles;
- `PlatformError::Backend` не содержит source/context.
Для Vulkan окно не выполняет present. Surface, swapchain, image acquisition и queue presentation принадлежат render adapter.
**Рекомендация:** platform crate должен предоставлять только:
- event/lifecycle model;
- physical и logical size;
- scale factor;
- normalized input;
- raw window/display handles;
- structured platform errors.
### S0-B03 — Реального Vulkan code path нет
**Приоритет:** BLOCKER
В inspected manifests отсутствуют `ash`, `ash-window`, `winit` и `raw-window-handle`. Следовательно, текущий код не может создать Vulkan instance/device/surface/swapchain.
`fparkan-game` выполняет backend-neutral capture через `RecordingBackend`. Это полезный CPU oracle, но не Vulkan renderer.
**Definition of fixed:** отдельный smoke executable открывает окно, создаёт Vulkan swapchain, рисует indexed triangle, обрабатывает resize и корректно завершается.
### S0-B04 — `cargo xtask ci` не соответствует exit gate
**Приоритет:** BLOCKER
**Файл:** `xtask/src/main.rs`
Текущий gate не подтверждает:
- все targets и features;
- clippy с `-D warnings`;
- rustdoc warnings и broken links;
- advisory/source policy;
- dependency denylist;
- отсутствие project-owned unsafe вне разрешённого Vulkan boundary;
- корректность typed acceptance manifests;
- platform-native smoke jobs.
Custom recursive rustfmt также может расходиться с canonical `cargo fmt --all -- --check`.
### S0-B05 — Toolchain не воспроизводим
**Приоритет:** BLOCKER
**Файл:** `rust-toolchain.toml`
Канал `stable` изменяется. Один и тот же commit может использовать разные компиляторы в разные дни. MSRV также не объявлен.
**Рекомендация:**
- закрепить точный Rust release;
- указать `rust-version`;
- обновлять toolchain отдельным reviewed PR;
- сохранять toolchain и SDK versions в acceptance report.
### S0-H01 — Нужен изолированный audited unsafe boundary
**Приоритет:** HIGH
`unsafe_code = "forbid"` правильно сохранять для backend-neutral crates. Однако Vulkan FFI требует локальных unsafe calls.
Нельзя ослаблять policy всему workspace.
**Целевая схема:**
- unsafe разрешён только в `fparkan-render-vulkan` low-level modules;
- `unsafe_op_in_unsafe_fn = deny`;
- каждый block имеет `// SAFETY:` comment;
- ownership/lifetime rules документированы;
- raw Vulkan handles не выходят в public neutral API;
- custom policy scanner проверяет allowlist.
### S0-H02 — Neutral render IDs не должны быть GPU allocation IDs
**Приоритет:** HIGH, не блокирует первый hardcoded triangle
**Файл:** `crates/fparkan-render/src/lib.rs`
`GpuMeshId` и `GpuMaterialId` появляются до существования GPU registry. Это смешивает CPU asset identity и backend-local allocation identity.
**Рекомендация:** использовать neutral `MeshAssetId`/`MaterialAssetId`; Vulkan adapter должен самостоятельно отображать их на buffers, images и descriptors.
### S0-M01 — Документация рассогласована с Vulkan revision
**Приоритет:** MEDIUM
`docs/tomes/07-implementation.md` сохраняет старую последовательность и multi-backend формулировки. Parity documentation ссылается на отсутствующий workspace crate, а active parity cases не определены.
**Рекомендация:** один versioned source of truth для stages и автоматическая проверка упомянутых crates, commands и backend names.
---
## 5. Сильные стороны, которые следует сохранить
- Workspace lint policy строгая и подходит для backend-neutral crates.
- `Cargo.lock` присутствует, а команды используют `--locked`.
- Synthetic и licensed corpus paths концептуально разделены.
- `fparkan-headless` не зависит от platform/render adapters на manifest-level.
- `fparkan-render` уже предоставляет deterministic command ordering, validation и canonical capture.
- Composition roots отделены от большинства core crates.
Эти элементы позволяют построить Vulkan foundation без переписывания CPU/data foundation.
---
## 6. Целевая архитектура Stage 0
```text
apps/fparkan-game, apps/fparkan-viewer
├── fparkan-platform-winit
│ └── winit + raw-window-handle
└── fparkan-render-vulkan
├── ash-window
├── ash
├── surface / swapchain
├── device / queues
├── shaders / pipelines
└── synchronization / presentation
apps/fparkan-headless
└── runtime/core only
no winit, ash, MoltenVK or window dependencies
```
Разделение ответственности:
- `fparkan-platform`: события, input, lifecycle, sizes и handle access;
- `fparkan-platform-winit`: concrete window/event-loop implementation;
- `fparkan-render`: backend-neutral command/snapshot contracts;
- `fparkan-render-vulkan`: Vulkan resources, synchronization и present;
- game/viewer: composition root;
- headless: полностью изолированный путь.
---
## 7. План полного закрытия Stage 0
Порядок PR важен. Vulkan adapter не следует строить поверх текущего GL-oriented platform contract.
### PR S0-01 — Reproducible toolchain и metadata
**Изменения**
- закрепить exact Rust toolchain;
- добавить `workspace.package.rust-version`;
- зафиксировать supported triples;
- добавить `cargo xtask doctor`;
- включать commit SHA, Rust version и platform SDK versions в reports.
**Acceptance**
- clean checkout формирует одинаковый metadata report;
- MSRV job собирает backend-neutral crates;
- pinned toolchain проходит полный synthetic gate.
### PR S0-02 — Typed xtask configuration
**Изменения**
- `serde` + TOML schemas для corpus/acceptance manifests;
- `deny_unknown_fields`;
- duplicate/missing/unknown-field validation;
- absolute canonical paths для local licensed manifest;
- `cargo_metadata` для dependency и workspace policy;
- удалить ручной line parser.
**Acceptance**
- malformed manifest всегда даёт non-zero exit;
- неизвестные поля не игнорируются;
- dependency policy работает по Cargo package IDs, targets и features.
### PR S0-03 — Полный synthetic CI gate
Обязательные команды:
```bash
cargo fmt --all -- --check
cargo test --workspace --all-targets --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
RUSTDOCFLAGS="-D warnings -D rustdoc::broken_intra_doc_links" \
cargo doc --workspace --no-deps --all-features --locked
cargo deny check advisories bans licenses sources
cargo xtask policy
cargo xtask acceptance audit --strict
```
Добавить reports для каждого gate и запрет silent skip.
### PR S0-04 — Redesign `fparkan-platform`
**Изменения**
- удалить `GraphicsProfile`, `GraphicsContextRequest` и GL version negotiation;
- убрать `present()` из window port;
- добавить normalized keyboard/mouse events;
- physical/logical size и scale factor;
- focus, minimize, occlusion, suspend/resume;
- deterministic lifecycle state machine;
- structured errors с source chain.
**Synthetic tests**
- resize coalescing;
- zero-size/minimized window;
- scale-factor changes;
- focus loss clears held input;
- key repeat и modifiers;
- suspend/resume;
- deterministic event ordering.
### PR S0-05 — `fparkan-platform-winit`
**Изменения**
- winit event loop;
- native window lifecycle;
- raw window/display handles;
- platform-specific event normalization;
- отсутствие GPU ownership.
**Acceptance**
- window-only smoke на Windows, Linux и macOS;
- native event trace соответствует synthetic model.
### PR S0-06 — Vulkan low-level boundary
**Изменения**
- `ash` и `ash-window`;
- dynamic Vulkan loader;
- instance и debug messenger;
- physical device capability records;
- pure deterministic device scoring;
- graphics/present queue selection;
- deterministic capability JSON;
- audited unsafe allowlist.
**Negative tests**
- loader отсутствует;
- Vulkan 1.1 недоступен;
- graphics queue отсутствует;
- present queue отсутствует;
- `VK_KHR_swapchain` отсутствует;
- required surface format отсутствует.
### PR S0-07 — Swapchain, triangle и offline shaders
**Изменения**
- surface и swapchain;
- format/present-mode/image-count policy;
- render pass и graphics pipeline;
- indexed triangle;
- command pools/buffers;
- binary semaphores и fences;
- frames in flight;
- resize/out-of-date/suboptimal/zero extent handling;
- pinned offline shader compiler;
- SPIR-V validation;
- descriptor/push-constant manifest;
- shader content hashes.
### PR S0-08 — macOS portability proof
**Изменения**
- `VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR`;
- portability extension enumeration;
- `VK_KHR_portability_subset` enablement, если объявлен device;
- MoltenVK packaging strategy;
- deterministic portability report;
- `.app` bundle smoke.
### PR S0-09 — Composition roots и legacy removal
**Изменения**
- game/viewer подключают winit + Vulkan adapters;
- headless остаётся без window/GPU graph;
- удалить SDL/GL stub crates;
- очистить lockfile, policy и docs;
- заменить GPU-named neutral IDs на asset IDs;
- запретить stale backend names automated policy check-ом.
### PR S0-10 — Native acceptance matrix
**Jobs**
- Windows MSVC + system Vulkan loader;
- Linux X11 или Wayland surface;
- macOS Apple Silicon + MoltenVK;
- отдельный software-Vulkan Linux PR job допустим как быстрый gate;
- native GPU jobs остаются release evidence.
**Обязательный сценарий**
1. Создать окно.
2. Создать real Vulkan swapchain.
3. Показать indexed triangle.
4. Выполнить не менее 300 frames.
5. Изменить размер окна.
6. Пересоздать swapchain.
7. Корректно завершить event loop.
8. Получить `validation_error_count = 0`.
9. Сохранить capability, shader и validation reports как artifacts.
**Stage 0 закрывается только после merge S0-01…S0-10 и зелёных native artifacts.**
---
## 8. Требуемая CI/acceptance модель
### 8.1 Synthetic PR gate
Должен работать без игровых каталогов и без silent skip:
1. fmt, clippy, docs, security и policy;
2. все unit/integration tests;
3. platform lifecycle state-machine tests;
4. device scoring tests на synthetic capability records;
5. swapchain policy tests;
6. shader manifest/hash tests;
7. Vulkan negative-path tests без обязательного GPU;
8. headless dependency assertion;
9. report schema validation.
Tests, требующие native GPU или licensed data, должны иметь отдельные suites и machine-readable ownership/reason, а не оставаться обычными `#[ignore]` без evidence trail.
### 8.2 Native platform gate
| Platform | Минимальный gate | Дополнительное evidence |
|---|---|---|
| Windows | system loader, swapchain, triangle, resize, 300 frames, validation=0 | Периодическая NVIDIA/AMD/Intel coverage |
| Linux | X11 или Wayland surface, swapchain, resize, validation=0 | Software Vulkan PR job + Mesa/NVIDIA native release jobs |
| macOS | MoltenVK, portability enumeration/subset, CAMetalLayer surface, resize, validation=0 | Apple Silicon как primary target |
### 8.3 Формат machine-readable отчёта
Минимальные поля:
```json
{
"schema": 1,
"commit": "<sha>",
"target": "x86_64-pc-windows-msvc",
"rustc": "1.xx.y",
"vulkan_api": "1.1",
"device_name": "...",
"driver": "...",
"portability_subset": false,
"frames": 300,
"resize_count": 1,
"swapchain_recreate_count": 1,
"validation_error_count": 0,
"shader_manifest_hash": "...",
"result": "pass"
}
```
---
## 9. Definition of Done
Stage 0 считается закрытым, когда выполнены **все** пункты:
- [ ] Exact Rust toolchain закреплён.
- [ ] MSRV объявлен и проверяется.
- [ ] Full fmt/test/clippy/doc/security/source/license gate проходит.
- [ ] Typed TOML manifests используются.
- [ ] Dependency policy работает через `cargo_metadata`.
- [ ] Windows/Linux/macOS matrix сохраняет artifacts.
- [ ] `fparkan-platform` больше не содержит GL-specific context concepts.
- [ ] `fparkan-platform-winit` реализован.
- [ ] `fparkan-render-vulkan` реализован.
- [ ] Vulkan 1.1 instance/device/queues/surface/swapchain реализованы.
- [ ] Deterministic device scoring и capability report реализованы.
- [ ] Indexed triangle рисуется настоящим Vulkan backend.
- [ ] Resize, zero extent, out-of-date и suboptimal обработаны.
- [ ] MoltenVK portability path реализован.
- [ ] Offline SPIR-V validation и hash manifest реализованы.
- [ ] Unsafe разрешён только в audited Vulkan/FFI modules.
- [ ] Legacy SDL/GL adapters и references удалены.
- [ ] Game/viewer используют новые composition adapters.
- [ ] Headless dependency graph не содержит winit/Vulkan/MoltenVK.
- [ ] 300-frame + resize smoke проходит на трёх OS.
- [ ] Validation error count равен нулю на трёх OS.
- [ ] Acceptance reports включают commit SHA и сохраняются как artifacts.
Наличие crates или unit tests с соответствующими названиями само по себе не является закрытием Stage 0.
---
## 10. Рекомендуемые automated policy checks
Добавить в `cargo xtask policy`:
### Workspace denylist
- запрещены `fparkan-platform-sdl` и `fparkan-render-gl` после миграции;
- запрещены stale symbols `GraphicsProfile`, `DesktopCore`, `Embedded`, `Gles2` в canonical platform/render API;
- canonical docs не содержат OpenGL как production backend.
### Dependency rules
- headless не зависит от `winit`, `raw-window-handle`, `ash`, `ash-window` или Vulkan adapter;
- backend-neutral crates не зависят от concrete platform/render adapters;
- только composition roots связывают platform и renderer;
- raw Vulkan types не экспортируются из adapter public boundary.
### Unsafe rules
- project-owned unsafe разрешён только в exact allowlisted files/modules;
- каждый block содержит `SAFETY:`;
- `unsafe_op_in_unsafe_fn` запрещён;
- изменение allowlist требует отдельного reviewed diff.
### Test и report rules
- synthetic gate не получает licensed paths;
- ignored tests обязаны иметь registered reason и owner;
- acceptance IDs уникальны;
- reports проходят schema validation;
- report всегда содержит commit SHA и target triple.
### Documentation rules
- документированные crates и commands существуют;
- canonical stage version совпадает с acceptance schema;
- старые backend names отсутствуют;
- README не объявляет незакрытый Vulkan path реализованным.
---
## 11. Основные риски
| Риск | Последствие | Снижение |
|---|---|---|
| Vulkan adapter начнут до redesign platform API | Повторная переделка surface/lifecycle/present | Сначала S0-04, затем S0-05/S0-06 |
| `unsafe_code` ослабят всему workspace | Рост FFI и lifetime рисков | Изолированный audited adapter и allowlist scanner |
| Stubs будут приняты за production backend | Ложное закрытие Stage 0 | Удаление legacy crates и real native smoke |
| Linux software Vulkan будет единственным evidence | Не выявятся vendor-driver проблемы | Native Mesa/NVIDIA jobs перед release |
| macOS будет проверен без portability subset report | Скрытая несовместимость MoltenVK | Обязательное capability evidence |
| Shader compiler останется неприкреплённым | Невоспроизводимый SPIR-V | Pinned compiler + manifest hashes |
| GitHub mirror и primary repository разойдутся | Audit и release относятся к разному коду | Commit SHA, canonical remote и artifact metadata |
| Документация останется отдельным source of truth | Повторное рассогласование | Versioned stage schema и automated doc checks |
---
## 12. Реестр доказательств
### Canonical requirement
- Vulkan revision: <https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f>
### Workspace и governance
- Root manifest: <https://github.com/valentineus/fparkan/blob/devel/Cargo.toml>
- Toolchain: <https://github.com/valentineus/fparkan/blob/devel/rust-toolchain.toml>
- Cargo config: <https://github.com/valentineus/fparkan/blob/devel/.cargo/config.toml>
- xtask manifest: <https://github.com/valentineus/fparkan/blob/devel/xtask/Cargo.toml>
- xtask implementation: <https://github.com/valentineus/fparkan/blob/devel/xtask/src/main.rs>
- README: <https://github.com/valentineus/fparkan/blob/devel/README.md>
### Platform и render
- Platform core: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-platform/src/lib.rs>
- SDL stub adapter: <https://github.com/valentineus/fparkan/blob/devel/adapters/fparkan-platform-sdl/src/lib.rs>
- Render core: <https://github.com/valentineus/fparkan/blob/devel/crates/fparkan-render/src/lib.rs>
- GL stub adapter: <https://github.com/valentineus/fparkan/blob/devel/adapters/fparkan-render-gl/src/lib.rs>
- Game composition: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-game/src/main.rs>
- Viewer composition: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-viewer/src/main.rs>
- Headless manifest: <https://github.com/valentineus/fparkan/blob/devel/apps/fparkan-headless/Cargo.toml>
### Documentation drift
- Implementation tome: <https://github.com/valentineus/fparkan/blob/devel/docs/tomes/07-implementation.md>
- Parity README: <https://github.com/valentineus/fparkan/blob/devel/parity/README.md>
- Parity cases: <https://github.com/valentineus/fparkan/blob/devel/parity/cases.toml>
---
## 13. Финальное заключение
У проекта уже имеется пригодный backend-neutral фундамент: deterministic render commands, строгие neutral-crate lints, отдельный headless composition root и разделение synthetic/licensed tests. Однако Stage 0 пока представлен интерфейсными proof/stub crates, а не настоящим Vulkan vertical slice.
Критический путь:
```text
reproducible toolchain
→ complete CI/policy gate
→ backend-neutral platform redesign
→ winit adapter
→ Vulkan loader/device/surface/swapchain
→ indexed triangle + shaders + synchronization
→ MoltenVK portability
→ composition integration
→ legacy removal
→ three-platform acceptance artifacts
```
До прохождения этого пути рекомендуемый статус:
```text
Stage 0: IN PROGRESS / BLOCKED
```
Главный критерий закрытия:
> Stage 0 завершён не тогда, когда существуют crates с названиями `winit` и `vulkan`, а когда один закреплённый commit создаёт настоящий Vulkan swapchain, рисует triangle, переживает resize и завершается без validation errors на Windows, Linux и macOS, сохраняя воспроизводимые machine-readable artifacts.
+2 -2
View File
@@ -7,10 +7,10 @@ repository.workspace = true
[dependencies]
fparkan-corpus = { path = "../crates/fparkan-corpus" }
cargo_metadata = "0.23"
cargo_metadata = "0.21.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
toml = "0.9"
toml = "1.0"
[lints]
workspace = true
+1220 -245
View File
File diff suppressed because it is too large Load Diff