Compare commits
347
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc0b2f9f4a | ||
|
|
fb1ec5857d | ||
|
|
33d16dfca3 | ||
|
|
f49010c723 | ||
|
|
70c7afe25c | ||
|
|
9e2cc1a283 | ||
|
|
bbefe65a62 | ||
|
|
0ee635eb77 | ||
|
|
14096788fc | ||
|
|
d91e63fa9a | ||
|
|
f324017151 | ||
|
|
5d40ba3968 | ||
|
|
d1edf4ba65 | ||
|
|
a0a5a18028 | ||
|
|
24ba0bdd37 | ||
|
|
ea09804778 | ||
|
|
837b613513 | ||
|
|
a7882545b7 | ||
|
|
3619f22b42 | ||
|
|
bd225b318e | ||
|
|
de715c0dfd | ||
|
|
6af81522a1 | ||
|
|
545d661b2b | ||
|
|
9dcf6d456e | ||
|
|
3592b46b0f | ||
|
|
7b0900aa1a | ||
|
|
50a74cc0a6 | ||
|
|
d31d1f8eab | ||
|
|
a7b434aeb2 | ||
|
|
6c444d63c5 | ||
|
|
2d0ded231b | ||
|
|
6e5ee47d51 | ||
|
|
d7150a58d5 | ||
|
|
b7a102caa7 | ||
|
|
3553107514 | ||
|
|
5052edbee9 | ||
|
|
63ac1bd55e | ||
|
|
369e2ead3c | ||
|
|
5e940f92ba | ||
|
|
0736c76d87 | ||
|
|
e339256776 | ||
|
|
b1ff50f9db | ||
|
|
a01c21f865 | ||
|
|
77d38e4c12 | ||
|
|
9788749650 | ||
|
|
a339a3a635 | ||
|
|
273bd9e477 | ||
|
|
5c1ccae393 | ||
|
|
3b6dd392a7 | ||
|
|
e96444459b | ||
|
|
fd9c04cade | ||
|
|
4967f58af4 | ||
|
|
17df86f0f3 | ||
|
|
99a6caf06a | ||
|
|
d9ccf7f64d | ||
|
|
4d024223bb | ||
|
|
cc7a1a0faa | ||
|
|
6ab2f0bc22 | ||
|
|
ed62e3cac5 | ||
|
|
9820b71ff7 | ||
|
|
3f2f56892f | ||
|
|
7c9a6215eb | ||
|
|
62c8423d8b | ||
|
|
e9c1a83c51 | ||
|
|
575124830b | ||
|
|
7a7578d0db | ||
|
|
5577a04a40 | ||
|
|
8b211f9a1c | ||
|
|
d1caf230c6 | ||
|
|
a858910853 | ||
|
|
83caf4d6ab | ||
|
|
4df4686509 | ||
|
|
e0f0e4a18e | ||
|
|
1a8e1eaed1 | ||
|
|
858dcd0d1d | ||
|
|
cfa6d27032 | ||
|
|
16449430d1 | ||
|
|
a59f16d6b5 | ||
|
|
fea984ddf5 | ||
|
|
2a793f1854 | ||
|
|
468ecc4e32 | ||
|
|
438aaebcbc | ||
|
|
733bfcf6fa | ||
|
|
4007f6fcec | ||
|
|
82f0f28ed9 | ||
|
|
d98e033f4f | ||
|
|
bfb048698e | ||
|
|
94de743581 | ||
|
|
f1a3315798 | ||
|
|
c2b3f87e2c | ||
|
|
02bfbcff84 | ||
|
|
4a5a56c098 | ||
|
|
ac1cd9640d | ||
|
|
0506a45d57 | ||
|
|
50a55461e7 | ||
|
|
59d810e5e7 | ||
|
|
1bd50662b1 | ||
|
|
4ea4be8cad | ||
|
|
3d9a324954 | ||
|
|
564edf0bc1 | ||
|
|
2474e48020 | ||
|
|
2e14b1d5dd | ||
|
|
67308d4019 | ||
|
|
d3e8747c18 | ||
|
|
9fd11dc11c | ||
|
|
2048b3695d | ||
|
|
36a1749179 | ||
|
|
a3b19b4349 | ||
|
|
b744a9d880 | ||
|
|
f4fd8f4fce | ||
|
|
bcf1ce9971 | ||
|
|
6b4fefc21f | ||
|
|
f952593e27 | ||
|
|
e166b3afff | ||
|
|
781241742d | ||
|
|
66b224a65c | ||
|
|
3684397dae | ||
|
|
29daf8720c | ||
|
|
04a7bfabd0 | ||
|
|
ceb7ea1fab | ||
|
|
421c9d7aa3 | ||
|
|
c55d835d35 | ||
|
|
8f2e9d9308 | ||
|
|
2cef0c62b9 | ||
|
|
2a85ae5121 | ||
|
|
2969a41bf5 | ||
|
|
6b007d658f | ||
|
|
265d118387 | ||
|
|
ea02915695 | ||
|
|
5df5814488 | ||
|
|
6e077f6c9d | ||
|
|
8ac6a11100 | ||
|
|
46851518fe | ||
|
|
f86b848af5 | ||
|
|
1b62c2c315 | ||
|
|
3633c91a9b | ||
|
|
c8dd5f989f | ||
|
|
2772c39e41 | ||
|
|
0eaa56c10b | ||
|
|
697cbbfd36 | ||
|
|
f75601a86a | ||
|
|
bf34bf9dd9 | ||
|
|
e0c8656146 | ||
|
|
2b735c693b | ||
|
|
8296400cad | ||
|
|
172f667bc6 | ||
|
|
d4aa7a223f | ||
|
|
b24594c6a2 | ||
|
|
f9a13298c0 | ||
|
|
9f6b21638d | ||
|
|
b500e32ca2 | ||
|
|
e7d869d8d0 | ||
|
|
b1408b0907 | ||
|
|
4feada1bf4 | ||
|
|
4b741fb19c | ||
|
|
645eca5510 | ||
|
|
e7c1664171 | ||
|
|
1afe1698db | ||
|
|
2745b938d8 | ||
|
|
efaba9001f | ||
|
|
b915554f3c | ||
|
|
116e7716b9 | ||
|
|
1d9d8a1d29 | ||
|
|
a436190059 | ||
|
|
fd9ce461c2 | ||
|
|
6e6496eebf | ||
|
|
283b48e8c5 | ||
|
|
97b1199b9e | ||
|
|
223b3b948a | ||
|
|
a4c7e4b0ba | ||
|
|
57d8b654e9 | ||
|
|
3f14e1ad7d | ||
|
|
597ffef19c | ||
|
|
598e983a8e | ||
|
|
75c2154b02 | ||
|
|
6ca7ab4f42 | ||
|
|
cd57d16d60 | ||
|
|
fd4be6c825 | ||
|
|
3c44a12fc4 | ||
|
|
958a5a3263 | ||
|
|
ba5992cc56 | ||
|
|
41a2746804 | ||
|
|
fbe5a8497a | ||
|
|
2da28d0495 | ||
|
|
02b4da2e3d | ||
|
|
9f7a94b0d9 | ||
|
|
337894528c | ||
|
|
93cddf1851 | ||
|
|
ff7993223f
|
||
|
|
08550849c7
|
||
|
|
0c54142ad5
|
||
|
|
44627cd532
|
||
|
|
f34c422b20
|
||
|
|
a89bd9af2a
|
||
|
|
b2e193f234
|
||
|
|
66804e0bd2
|
||
|
|
ee9b318172
|
||
|
|
29bf4830cf
|
||
|
|
8a8ef614f2
|
||
|
|
51b54c155c
|
||
|
|
0fd96faf54
|
||
|
|
c7a9c43b5b
|
||
|
|
c0116d32be
|
||
|
|
0a7ba55b44
|
||
|
|
29f7a398ff
|
||
|
|
6f84761b83
|
||
|
|
2b16e5b118
|
||
|
|
716cde2072
|
||
|
|
7337492c30
|
||
|
|
d0bc7f2f26
|
||
|
|
146446d3e2
|
||
|
|
7db0bf8e3d | ||
|
|
6cd23bf02a
|
||
|
|
cbc8ef36f8
|
||
|
|
7c7e91c857
|
||
|
|
7c3b3a53f5
|
||
|
|
0b8776b850
|
||
|
|
e572558d5f
|
||
|
|
5a60671bd6
|
||
|
|
5aff0b64e8
|
||
|
|
757a975d8c
|
||
|
|
d146953bcc
|
||
|
|
b617e2958d
|
||
|
|
607a64ca8d
|
||
|
|
e79d26ea68
|
||
|
|
e3c74485f1
|
||
|
|
ad21704bcc
|
||
|
|
95391a05c6
|
||
|
|
14ea45d49a
|
||
|
|
0caa36d923
|
||
|
|
25d26a87a4
|
||
|
|
8f0dcd7f4d
|
||
|
|
97f56c56ba
|
||
|
|
70813154f2
|
||
|
|
e40349b204
|
||
|
|
61638e083b
|
||
|
|
17c3038a36
|
||
|
|
b473b100c8
|
||
|
|
3c32215665
|
||
|
|
f91378b884
|
||
|
|
8f8fa426d5
|
||
|
|
4d0cb594a7
|
||
|
|
07e30cd040
|
||
|
|
079e531166
|
||
|
|
6a2adbe160
|
||
|
|
b8933dd43a
|
||
|
|
26efa13a01
|
||
|
|
dcd5417af2
|
||
|
|
b6b47ae6f6
|
||
|
|
1eead8d597
|
||
|
|
ce3e5ad813
|
||
|
|
d0552922d9
|
||
|
|
0de5118575
|
||
|
|
0d139b1aae
|
||
|
|
72f6c06eca
|
||
|
|
0a78fc2460
|
||
|
|
5c4fbff2af
|
||
|
|
fd8b03c0bc
|
||
|
|
0b0ed87650
|
||
|
|
6a6393038e
|
||
|
|
ec6645a21f
|
||
|
|
d1b7b43dce
|
||
|
|
adc6c6149c
|
||
|
|
5950c62cec
|
||
|
|
247f86aa09
|
||
|
|
4d8068aef0
|
||
|
|
53715d0d9c
|
||
|
|
c8876d65eb
|
||
|
|
0a2d1bcc32
|
||
|
|
ba69bdb6ea
|
||
|
|
5cc2c5819f
|
||
|
|
27af3806b3 | ||
|
|
021b1c8dac
|
||
|
|
278567d6de
|
||
|
|
7eced77483
|
||
|
|
d41add32c4
|
||
|
|
159731664f
|
||
|
|
e6b7fa1896
|
||
|
|
0e127117e9
|
||
|
|
4d19728c39
|
||
|
|
54f07ee3be
|
||
|
|
ed2b540abf
|
||
|
|
00ae9067d8
|
||
|
|
c71e706d69
|
||
|
|
aa2133d82b
|
||
|
|
71ead678c0
|
||
|
|
f15ea95bf2
|
||
|
|
99bcbf388f
|
||
|
|
227d95fc49
|
||
|
|
dceea70122
|
||
|
|
fd452f6016
|
||
|
|
1d0244c3e4
|
||
|
|
5d9e1cbe38
|
||
|
|
0e76c2ed7c
|
||
|
|
4c1edef21b
|
||
|
|
e6778d43af
|
||
|
|
ec8f6599fc
|
||
|
|
f5fae8e84a
|
||
|
|
a0a4089e4b
|
||
|
|
dc7e72961a
|
||
|
|
8ea1fd5c18
|
||
|
|
69c032acca
|
||
|
|
9cc24e715d
|
||
|
|
f8e447ffee
|
||
|
|
83d763dd70
|
||
|
|
162de8ccab
|
||
|
|
0b23cf48e7
|
||
|
|
7356238ffb
|
||
|
|
42441082f0
|
||
|
|
ccd61c05b0
|
||
|
|
813beec7be
|
||
|
|
91c7a8a14e
|
||
|
|
8b91a0bfbf
|
||
|
|
fb97405e0c
|
||
|
|
d579b696e6
|
||
|
|
aa1b809bd8
|
||
|
|
f69c893a40
|
||
|
|
5436727961
|
||
|
|
be41fa839f
|
||
|
|
8e5e46b7b3
|
||
|
|
d0bdbaa1ed
|
||
|
|
7416fdc7e9 | ||
|
|
78fc5f1deb
|
||
|
|
50c2cf4686
|
||
|
|
a63290fbc8 | ||
|
|
96a25b6c0e | ||
|
|
f4262cf369 | ||
|
|
9b100b8fc3 | ||
|
|
9fceeb9a0a | ||
|
|
4b7f1a16b9 | ||
|
|
ada3b903ad
|
||
|
|
31d849ddbf | ||
|
|
4ef08d0bf6 | ||
|
|
598137ed13
|
||
|
|
cb0ca2f2f0
|
||
|
|
7346e695c4
|
||
|
|
bb827c3928
|
||
|
|
efab61a45c
|
||
|
|
0d7ae6a017 | ||
|
|
a281ffa32e | ||
|
|
18d4c6cf9f | ||
|
|
0e19660eb5 | ||
|
|
8a69872576
|
||
|
|
aa68906a3d
|
||
|
|
8bf3b7b209
|
||
|
|
669fb40a70
|
||
|
|
9c0df3d299
|
@@ -0,0 +1,2 @@
|
||||
[alias]
|
||||
xtask = "run -p xtask --"
|
||||
@@ -3,7 +3,7 @@ name: Docs Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- devel
|
||||
|
||||
jobs:
|
||||
deploy-docs:
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run renovate
|
||||
run: |
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
@@ -21,7 +21,35 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cargo test
|
||||
run: cargo test --workspace --all-features -- --nocapture
|
||||
|
||||
render-parity:
|
||||
name: Render parity
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Install headless GL runtime
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y xvfb libgl1-mesa-dri libgles2-mesa-dev mesa-utils
|
||||
- name: Build render-demo binary
|
||||
run: cargo build -p render-demo --features demo
|
||||
- name: Run frame parity suite
|
||||
run: |
|
||||
xvfb-run -s "-screen 0 1280x720x24" cargo run -p render-parity -- \
|
||||
--manifest parity/cases.toml \
|
||||
--output-dir target/render-parity/current \
|
||||
--demo-bin target/debug/parkan-render-demo \
|
||||
--keep-going
|
||||
- name: Upload parity artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: render-parity-artifacts
|
||||
path: target/render-parity/current
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
name: fparkan-ci
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [devel, main]
|
||||
pull_request:
|
||||
branches: [devel, main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
msrv-backend-neutral:
|
||||
name: Pinned Rust backend-neutral crates
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
||||
with:
|
||||
toolchain: 1.97.1
|
||||
- name: Test backend-neutral crates
|
||||
run: >
|
||||
cargo test
|
||||
-p fparkan-animation
|
||||
-p fparkan-binary
|
||||
-p fparkan-corpus
|
||||
-p fparkan-diagnostics
|
||||
-p fparkan-fx
|
||||
-p fparkan-inspection
|
||||
-p fparkan-material
|
||||
-p fparkan-mission-format
|
||||
-p fparkan-msh
|
||||
-p fparkan-nres
|
||||
-p fparkan-path
|
||||
-p fparkan-platform
|
||||
-p fparkan-prototype
|
||||
-p fparkan-render
|
||||
-p fparkan-resource
|
||||
-p fparkan-rsli
|
||||
-p fparkan-runtime
|
||||
-p fparkan-terrain
|
||||
-p fparkan-terrain-format
|
||||
-p fparkan-texm
|
||||
-p fparkan-vfs
|
||||
-p fparkan-world
|
||||
--all-targets
|
||||
--locked
|
||||
|
||||
stage0-matrix:
|
||||
name: Stage 0 CI (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- 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@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537
|
||||
with:
|
||||
toolchain: 1.97.1
|
||||
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 --version 0.19.9 --locked
|
||||
- name: Verify shader provenance
|
||||
run: cargo xtask shader-provenance
|
||||
- name: Run canonical CI gate
|
||||
run: cargo xtask ci
|
||||
- name: Run native Vulkan smoke
|
||||
run: >
|
||||
cargo run -p fparkan-vulkan-smoke --locked --
|
||||
--out "target/fparkan/native-smoke/${{ matrix.smoke_platform }}.json"
|
||||
--timeout-seconds 120
|
||||
- name: Upload acceptance audit
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: stage-0-acceptance-${{ matrix.os }}
|
||||
path: target/fparkan/acceptance/stage-0-audit.json
|
||||
if-no-files-found: error
|
||||
- name: Upload native smoke report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: 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.97.1
|
||||
- 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
|
||||
+1
-5
@@ -69,10 +69,6 @@ $RECYCLE.BIN/
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
@@ -215,4 +211,4 @@ poetry.toml
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
pyrightconfig.json
|
||||
|
||||
Generated
+1884
File diff suppressed because it is too large
Load Diff
+61
-1
@@ -1,6 +1,66 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
members = [
|
||||
"crates/fparkan-animation",
|
||||
"crates/fparkan-assets",
|
||||
"crates/fparkan-binary",
|
||||
"crates/fparkan-corpus",
|
||||
"crates/fparkan-diagnostics",
|
||||
"crates/fparkan-fx",
|
||||
"crates/fparkan-inspection",
|
||||
"crates/fparkan-material",
|
||||
"crates/fparkan-mission-format",
|
||||
"crates/fparkan-msh",
|
||||
"crates/fparkan-nres",
|
||||
"crates/fparkan-path",
|
||||
"crates/fparkan-platform",
|
||||
"crates/fparkan-prototype",
|
||||
"crates/fparkan-render",
|
||||
"crates/fparkan-resource",
|
||||
"crates/fparkan-rsli",
|
||||
"crates/fparkan-script",
|
||||
"crates/fparkan-runtime",
|
||||
"crates/fparkan-terrain",
|
||||
"crates/fparkan-terrain-format",
|
||||
"crates/fparkan-test-support",
|
||||
"crates/fparkan-texm",
|
||||
"crates/fparkan-vfs",
|
||||
"crates/fparkan-world",
|
||||
"adapters/fparkan-platform-winit",
|
||||
"adapters/fparkan-render-vulkan",
|
||||
"apps/fparkan-cli",
|
||||
"apps/fparkan-game",
|
||||
"apps/fparkan-headless",
|
||||
"apps/fparkan-vulkan-smoke",
|
||||
"apps/fparkan-viewer",
|
||||
"xtask",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.97"
|
||||
license = "GPL-2.0-only"
|
||||
repository = "https://github.com/valentineus/fparkan"
|
||||
|
||||
[workspace.lints.rust]
|
||||
unsafe_code = "forbid"
|
||||
missing_docs = "warn"
|
||||
unreachable_pub = "warn"
|
||||
unused_must_use = "deny"
|
||||
|
||||
[workspace.lints.clippy]
|
||||
all = { level = "deny", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
todo = "deny"
|
||||
unimplemented = "deny"
|
||||
dbg_macro = "deny"
|
||||
print_stdout = "warn"
|
||||
print_stderr = "warn"
|
||||
lossy_float_literal = "deny"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# FParkan
|
||||
|
||||
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования.
|
||||
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»**.
|
||||
|
||||
## Описание
|
||||
|
||||
Проект находится в активной разработке и включает:
|
||||
|
||||
- библиотеки для работы с форматами игровых архивов;
|
||||
- инструменты для валидации/подготовки тестовых данных;
|
||||
- спецификации форматов и сопутствующую документацию.
|
||||
|
||||
## Установка
|
||||
@@ -19,28 +18,92 @@ Open source проект с реализацией компонентов игр
|
||||
- локально: каталог [`docs/`](docs)
|
||||
- сайт: <https://fparkan.popov.link>
|
||||
|
||||
## Инструменты
|
||||
|
||||
Вспомогательные инструменты находятся в каталоге [`tools/`](tools).
|
||||
|
||||
- [tools/archive_roundtrip_validator.py](tools/archive_roundtrip_validator.py) — инструмент верификации документации по архивам `NRes`/`RsLi` на реальных файлах (включая `unpack -> repack -> byte-compare`).
|
||||
- [tools/init_testdata.py](tools/init_testdata.py) — подготовка тестовых данных по сигнатурам с раскладкой по каталогам.
|
||||
|
||||
## Библиотеки
|
||||
|
||||
- [crates/nres](crates/nres) — библиотека для работы с файлами архивов NRes (чтение, поиск, редактирование, сохранение).
|
||||
- [crates/rsli](crates/rsli) — библиотека для работы с файлами архивов RsLi (чтение, поиск, загрузка/распаковка поддерживаемых методов).
|
||||
- [crates/fparkan-nres](crates/fparkan-nres) — strict/lossless модель архивов NRes.
|
||||
- [crates/fparkan-rsli](crates/fparkan-rsli) — чтение, lookup и lossless roundtrip архивов RsLi.
|
||||
- [crates/fparkan-msh](crates/fparkan-msh) — validated static MSH geometry.
|
||||
- [crates/fparkan-runtime](crates/fparkan-runtime) — transactional mission loading и headless runtime foundation.
|
||||
- [apps/fparkan-cli](apps/fparkan-cli) — CLI для архивов, графов и acceptance-отчетов.
|
||||
- [apps/fparkan-viewer](apps/fparkan-viewer) — inspection-only CLI для archive/model/texture/map без live Vulkan draw path.
|
||||
- [apps/fparkan-headless](apps/fparkan-headless) — headless runtime composition root.
|
||||
- [apps/fparkan-game](apps/fparkan-game) — mission composition root: по умолчанию выдаёт planning report; opt-in `--backend static-vulkan` открывает native Vulkan окно и рисует terrain и все подготовленные MSH-компоненты выбранных mission roots. По умолчанию выбираются все roots; `--preview-roots N` оставляет bounded diagnostic scope.
|
||||
|
||||
## Текущий статус рендера
|
||||
|
||||
- `fparkan-vulkan-smoke` доказывает живой Stage 0 Vulkan triangle path с native window, swapchain и validation telemetry.
|
||||
- `VulkanPlanningBackend` и default-режим `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. `fparkan-game --backend static-vulkan` — узкий mission-to-native-Vulkan bridge: он объединяет terrain и подготовленные MSH-компоненты всех выбранных roots, применяет static TMA transforms и загружает первую MAT0 diffuse texture на каждый используемый selector с preview-local remap. Legacy D3D7 camera capture поддерживается; полный corpus, material phases, dynamic ownership/visibility и pixel parity ещё не подтверждены.
|
||||
- `fparkan-viewer` пока является инспектором ассетов. `fparkan-vulkan-smoke` имеет live Stage 3 bridge для original `MSH`/`Texm`/`WEAR`/`MAT0` и geometry-only `Land.msh`; полноценный viewer, исходные terrain-material states, camera и pixel parity ещё не закрыты.
|
||||
- Truth table и evidence-артефакты вынесены в [`docs/rendering/renderer_truth_table.md`](docs/rendering/renderer_truth_table.md) и [`docs/evidence/`](docs/evidence).
|
||||
|
||||
## Тестирование
|
||||
|
||||
Базовое тестирование проходит на синтетических тестах из репозитория.
|
||||
Базовое тестирование проходит на синтетических тестах из репозитория:
|
||||
|
||||
```bash
|
||||
cargo xtask ci
|
||||
```
|
||||
|
||||
Для дополнительного тестирования на реальных игровых ресурсах:
|
||||
|
||||
- используйте [tools/init_testdata.py](tools/init_testdata.py) для подготовки локального набора;
|
||||
- используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy));
|
||||
- разместите игровые каталоги в [`testdata/`](testdata);
|
||||
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
|
||||
|
||||
Локальный licensed gate использует некоммитимый manifest:
|
||||
|
||||
```bash
|
||||
cat > /private/tmp/fparkan-corpora.toml <<'EOF'
|
||||
schema = 1
|
||||
|
||||
[[corpus]]
|
||||
id = "part1-local"
|
||||
kind = "part1"
|
||||
root = "/absolute/path/to/IS"
|
||||
expected_profile = "parkan-is-part1"
|
||||
|
||||
[[corpus]]
|
||||
id = "part2-local"
|
||||
kind = "part2"
|
||||
root = "/absolute/path/to/IS2"
|
||||
expected_profile = "parkan-is-part2"
|
||||
EOF
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
Поддерживается только Windows. Перед запуском убедитесь, что установлен Vulkan runtime
|
||||
от GPU vendor или LunarG Vulkan SDK и активный runtime предоставляет
|
||||
`VK_LAYER_KHRONOS_validation`. Linux, macOS/MoltenVK и их smoke-пути не входят в
|
||||
проектный scope.
|
||||
|
||||
Для полного локального closure gate используйте:
|
||||
|
||||
```bash
|
||||
cargo xtask ci
|
||||
```
|
||||
|
||||
Windows native smoke — единственный platform acceptance path. Вопросы hosted CI
|
||||
и других операционных систем намеренно находятся вне scope разработки.
|
||||
|
||||
## Contributing & Support
|
||||
|
||||
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "fparkan-platform-winit"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
||||
raw-window-handle = "0.6"
|
||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,576 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Minimal `winit`-backed platform adapter shim.
|
||||
|
||||
use fparkan_platform::{
|
||||
EventSource, MonotonicClock, MonotonicInstant, NativeWindowHandles, PhysicalSize,
|
||||
PlatformError, PlatformEvent, RenderRequest, WindowHandle, WindowPort,
|
||||
};
|
||||
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
use std::time::Instant;
|
||||
use winit::event::{Event, MouseButton, WindowEvent};
|
||||
use winit::platform::scancode::PhysicalKeyExtScancode;
|
||||
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;
|
||||
|
||||
fn next_window_id() -> u64 {
|
||||
NEXT_WINDOW_HANDLE_ID.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Simple monotonic clock for windowing abstractions.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WinitClock;
|
||||
|
||||
impl MonotonicClock for WinitClock {
|
||||
fn now(&self) -> MonotonicInstant {
|
||||
let elapsed = CLOCK_START.get_or_init(Instant::now).elapsed();
|
||||
MonotonicInstant(elapsed.as_millis().try_into().unwrap_or(u64::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
/// Event source backed by pre-buffered platform events.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct WinitEventSource {
|
||||
queue: VecDeque<PlatformEvent>,
|
||||
cursor_position: Option<(f64, f64)>,
|
||||
minimized: Option<bool>,
|
||||
occluded: Option<bool>,
|
||||
}
|
||||
|
||||
impl WinitEventSource {
|
||||
/// Creates an empty source.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
queue: VecDeque::new(),
|
||||
cursor_position: None,
|
||||
minimized: None,
|
||||
occluded: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes a synthetic event (used by tests and smoke stubs).
|
||||
pub fn push(&mut self, event: PlatformEvent) {
|
||||
self.queue.push_back(event);
|
||||
}
|
||||
|
||||
/// Pushes a mapped native window event.
|
||||
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,
|
||||
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,
|
||||
y,
|
||||
});
|
||||
}
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.cursor_position = Some((position.x, position.y));
|
||||
self.queue.push_back(PlatformEvent::CursorMoved {
|
||||
x: position.x,
|
||||
y: position.y,
|
||||
});
|
||||
}
|
||||
WindowEvent::Resized(size) => {
|
||||
self.queue.push_back(PlatformEvent::Resize {
|
||||
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,
|
||||
});
|
||||
}
|
||||
WindowEvent::CloseRequested => {
|
||||
self.queue.push_back(PlatformEvent::QuitRequested);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pushes events from an event loop event.
|
||||
pub fn push_event<T>(&mut self, event: &Event<T>) {
|
||||
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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mouse_button_code(button: MouseButton) -> u16 {
|
||||
match button {
|
||||
MouseButton::Left => 0,
|
||||
MouseButton::Right => 1,
|
||||
MouseButton::Middle => 2,
|
||||
MouseButton::Back => 3,
|
||||
MouseButton::Forward => 4,
|
||||
MouseButton::Other(index) => 100_u16.saturating_add(index),
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSource for WinitEventSource {
|
||||
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
|
||||
while let Some(event) = self.queue.pop_front() {
|
||||
out.push(event);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Window creation plan for native smoke entrypoints.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct WinitWindowPlan {
|
||||
/// Requested drawable width in physical pixels.
|
||||
pub width: u32,
|
||||
/// Requested drawable height in physical pixels.
|
||||
pub height: u32,
|
||||
/// Whether native window/display handles are required by the caller.
|
||||
pub requires_native_handles: bool,
|
||||
}
|
||||
|
||||
impl WinitWindowPlan {
|
||||
/// Returns the Stage 0 native smoke window plan.
|
||||
#[must_use]
|
||||
pub const fn smoke() -> Self {
|
||||
Self {
|
||||
width: DEFAULT_SMOKE_WIDTH,
|
||||
height: DEFAULT_SMOKE_HEIGHT,
|
||||
requires_native_handles: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the window plan before a native event loop is entered.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PlatformError`] when the drawable extent is zero.
|
||||
pub fn validate(self) -> Result<Self, PlatformError> {
|
||||
if self.width == 0 || self.height == 0 {
|
||||
return Err(PlatformError::Backend {
|
||||
context: "winit window plan",
|
||||
message: "drawable extent must be non-zero".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Minimal window view over a `winit` window.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WinitWindow {
|
||||
handle: WindowHandle,
|
||||
width: u32,
|
||||
height: u32,
|
||||
scale: f64,
|
||||
focused: bool,
|
||||
minimized: bool,
|
||||
occluded: bool,
|
||||
native_handles: Option<NativeWindowHandles>,
|
||||
}
|
||||
|
||||
impl WinitWindow {
|
||||
/// Builds a stable descriptor from a `winit` window.
|
||||
#[must_use]
|
||||
pub fn from_window(window: &Window) -> Self {
|
||||
let scale = window.scale_factor();
|
||||
let size = window.inner_size();
|
||||
Self {
|
||||
handle: WindowHandle {
|
||||
id: next_window_id(),
|
||||
},
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
scale,
|
||||
focused: true,
|
||||
minimized: false,
|
||||
occluded: false,
|
||||
native_handles: window_native_handles(window),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns conservative defaults if a native window is not available yet.
|
||||
#[must_use]
|
||||
pub fn synthetic(width: u32, height: u32) -> Self {
|
||||
Self {
|
||||
handle: WindowHandle {
|
||||
id: next_window_id(),
|
||||
},
|
||||
width,
|
||||
height,
|
||||
scale: 1.0,
|
||||
focused: true,
|
||||
minimized: false,
|
||||
occluded: false,
|
||||
native_handles: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns requested default render profile for integration points.
|
||||
#[must_use]
|
||||
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 {
|
||||
fn drawable_size(&self) -> PhysicalSize {
|
||||
PhysicalSize {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
}
|
||||
|
||||
fn dpi_scale(&self) -> f64 {
|
||||
self.scale
|
||||
}
|
||||
|
||||
fn has_focus(&self) -> bool {
|
||||
self.focused
|
||||
}
|
||||
|
||||
fn is_minimized(&self) -> bool {
|
||||
self.minimized
|
||||
}
|
||||
|
||||
fn is_occluded(&self) -> bool {
|
||||
self.occluded
|
||||
}
|
||||
|
||||
fn handle(&self) -> WindowHandle {
|
||||
self.handle
|
||||
}
|
||||
|
||||
fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||
self.native_handles
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use winit::event::{DeviceId, ElementState};
|
||||
|
||||
#[test]
|
||||
fn event_source_buffers_synthetic_events() -> Result<(), PlatformError> {
|
||||
let mut source = WinitEventSource::new();
|
||||
source.push(PlatformEvent::Resumed);
|
||||
source.push(PlatformEvent::QuitRequested);
|
||||
let mut events = Vec::new();
|
||||
source.poll(&mut events)?;
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![PlatformEvent::Resumed, PlatformEvent::QuitRequested]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_port_reports_default_request_profile() {
|
||||
let window = WinitWindow::synthetic(640, 360);
|
||||
let request = WinitWindow::default_render_request();
|
||||
assert_eq!(
|
||||
request.presentation,
|
||||
fparkan_platform::PresentationMode::Fifo
|
||||
);
|
||||
assert_eq!(
|
||||
window.drawable_size(),
|
||||
PhysicalSize {
|
||||
width: 640,
|
||||
height: 360
|
||||
}
|
||||
);
|
||||
assert!(window.native_handles().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_window_plan_requires_native_handles_and_nonzero_extent() -> Result<(), PlatformError> {
|
||||
let plan = WinitWindowPlan::smoke().validate()?;
|
||||
|
||||
assert_eq!(plan.width, DEFAULT_SMOKE_WIDTH);
|
||||
assert_eq!(plan.height, DEFAULT_SMOKE_HEIGHT);
|
||||
assert!(plan.requires_native_handles);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smoke_window_plan_rejects_zero_extent() {
|
||||
let plan = WinitWindowPlan {
|
||||
width: 0,
|
||||
height: DEFAULT_SMOKE_HEIGHT,
|
||||
requires_native_handles: true,
|
||||
};
|
||||
|
||||
assert!(matches!(
|
||||
plan.validate(),
|
||||
Err(PlatformError::Backend {
|
||||
context: "winit window plan",
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn monotonic_clock_uses_process_local_epoch() {
|
||||
let clock = WinitClock;
|
||||
let first = clock.now();
|
||||
let second = clock.now();
|
||||
|
||||
assert!(second >= first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_events_push_expected_platform_events() {
|
||||
let mut source = WinitEventSource::new();
|
||||
let size = winit::dpi::PhysicalSize::new(1024u32, 768u32);
|
||||
|
||||
source.push_window_event(&WindowEvent::Resized(size));
|
||||
source.push_window_event(&WindowEvent::Focused(false));
|
||||
source.push_window_event(&WindowEvent::CloseRequested);
|
||||
|
||||
let mut events = Vec::new();
|
||||
source
|
||||
.poll(&mut events)
|
||||
.expect("platform event pump should never fail");
|
||||
|
||||
assert!(events.contains(&PlatformEvent::Resize {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
}));
|
||||
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.
|
||||
@@ -0,0 +1,38 @@
|
||||
[package]
|
||||
name = "fparkan-render-vulkan"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
ash = "0.38"
|
||||
ash-window = "0.13"
|
||||
fparkan-animation = { path = "../../crates/fparkan-animation", version = "0.1.0" }
|
||||
fparkan-binary = { path = "../../crates/fparkan-binary", version = "0.1.0" }
|
||||
fparkan-msh = { path = "../../crates/fparkan-msh", version = "0.1.0" }
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
|
||||
fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format", version = "0.1.0" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[lints.rust]
|
||||
unsafe_code = "allow"
|
||||
missing_docs = "warn"
|
||||
unreachable_pub = "warn"
|
||||
unused_must_use = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
all = { level = "deny", priority = -1 }
|
||||
pedantic = { level = "warn", priority = -1 }
|
||||
unwrap_used = "deny"
|
||||
expect_used = "deny"
|
||||
panic = "deny"
|
||||
todo = "deny"
|
||||
unimplemented = "deny"
|
||||
dbg_macro = "deny"
|
||||
print_stdout = "warn"
|
||||
print_stderr = "warn"
|
||||
lossy_float_literal = "deny"
|
||||
@@ -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":"fe3721202477220d2d9677b6572d7f3baecb374e94f04eea9d7286ed5e98b6c9","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.vert.spv","word_count":358,"sha256":"4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d","descriptor_sets":0,"push_constant_bytes":64,"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":"f76cc92b9aaa2261eda33bd320503e1be100a2dd7e1a9fcfe13351678dda2a06"},{"name":"triangle.frag","stage":"fragment","entry_point":"main","source_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag","source_sha256":"fb3ed435af48e4fb4a817f160b467f7a561c7c547c87b6f0c1e89f5f58af7329","spirv_path":"adapters/fparkan-render-vulkan/shaders/triangle.frag.spv","word_count":296,"sha256":"8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b","descriptor_sets":1,"push_constant_bytes":68,"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":"91552fce09a408a2baf8a608bc642b6632372d3d2316217051384159ba14cee2"}],"manifest_hash":"cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5"}
|
||||
@@ -0,0 +1,19 @@
|
||||
#version 450
|
||||
|
||||
layout(location = 0) in vec3 in_color;
|
||||
layout(location = 1) in vec2 in_uv;
|
||||
layout(location = 0) out vec4 out_color;
|
||||
|
||||
layout(set = 0, binding = 0) uniform sampler2D base_color;
|
||||
|
||||
layout(push_constant) uniform AlphaTestConstants {
|
||||
layout(offset = 64)
|
||||
float alpha_cutoff;
|
||||
} alpha_test;
|
||||
|
||||
void main() {
|
||||
out_color = texture(base_color, in_uv) * vec4(in_color, 1.0);
|
||||
if (out_color.a < alpha_test.alpha_cutoff) {
|
||||
discard;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,18 @@
|
||||
#version 450
|
||||
|
||||
layout(location = 0) in vec3 in_position;
|
||||
layout(location = 1) in vec3 in_color;
|
||||
layout(location = 2) in vec2 in_uv;
|
||||
|
||||
layout(location = 0) out vec3 out_color;
|
||||
layout(location = 1) out vec2 out_uv;
|
||||
|
||||
layout(push_constant) uniform CameraConstants {
|
||||
mat4 clip_from_world;
|
||||
} camera;
|
||||
|
||||
void main() {
|
||||
out_color = in_color;
|
||||
out_uv = in_uv;
|
||||
gl_Position = camera.clip_from_world * vec4(in_position, 1.0);
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,515 @@
|
||||
#![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.
|
||||
|
||||
#[path = "asset_mesh.rs"]
|
||||
mod asset_mesh;
|
||||
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::asset_mesh::{
|
||||
project_land_msh_to_static_mesh, project_land_msh_to_static_mesh_in_legacy_world_space,
|
||||
project_land_msh_to_static_mesh_in_world_space, project_land_msh_to_static_mesh_in_xy_frame,
|
||||
project_msh_to_static_mesh, project_msh_to_static_mesh_in_world_space,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
|
||||
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
|
||||
project_msh_to_static_mesh_in_world_space_with_transform,
|
||||
project_msh_to_static_mesh_in_xy_frame,
|
||||
project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
|
||||
project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanAssetMeshError,
|
||||
VulkanStaticXyFrame,
|
||||
};
|
||||
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_depth_attachment, create_frame_sync,
|
||||
create_readback_buffer, create_static_mesh_index_buffer, create_static_mesh_vertex_buffer,
|
||||
create_static_texture_image, destroy_allocated_buffer, destroy_allocated_image,
|
||||
destroy_depth_attachment, readback_buffer_bytes, VulkanAllocatedBuffer, VulkanAllocatedImage,
|
||||
VulkanDepthAttachment, 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::{
|
||||
VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot,
|
||||
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
|
||||
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
|
||||
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
|
||||
VulkanStaticTexture, VulkanStaticVertex, 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;
|
||||
#[allow(dead_code)]
|
||||
const LEGACY_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,
|
||||
];
|
||||
#[allow(dead_code)]
|
||||
const LEGACY_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,
|
||||
];
|
||||
|
||||
const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT] {
|
||||
let mut words = [0_u32; WORD_COUNT];
|
||||
let mut index = 0;
|
||||
while index < WORD_COUNT {
|
||||
let offset = index * 4;
|
||||
words[index] = u32::from_le_bytes([
|
||||
bytes[offset],
|
||||
bytes[offset + 1],
|
||||
bytes[offset + 2],
|
||||
bytes[offset + 3],
|
||||
]);
|
||||
index += 1;
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
static TRIANGLE_VERTEX_SHADER_DATA: [u32; 358] =
|
||||
spirv_words(include_bytes!("../shaders/triangle.vert.spv"));
|
||||
static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; 296] =
|
||||
spirv_words(include_bytes!("../shaders/triangle.frag.spv"));
|
||||
pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &TRIANGLE_VERTEX_SHADER_DATA;
|
||||
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &TRIANGLE_FRAGMENT_SHADER_DATA;
|
||||
|
||||
#[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,392 @@
|
||||
#![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(),
|
||||
// The runtime is Windows-only, so portability enumeration is not part of
|
||||
// the supported instance contract.
|
||||
enable_portability_enumeration: false,
|
||||
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,751 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::vk;
|
||||
use fparkan_platform::DepthStencilSupport;
|
||||
|
||||
use super::{
|
||||
VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanStaticMesh,
|
||||
VulkanStaticTexture,
|
||||
};
|
||||
use crate::select_depth_stencil_attachment_format;
|
||||
|
||||
pub(super) struct VulkanAllocatedBuffer {
|
||||
pub(super) buffer: vk::Buffer,
|
||||
pub(super) memory: vk::DeviceMemory,
|
||||
}
|
||||
|
||||
pub(super) struct VulkanAllocatedImage {
|
||||
pub(super) image: vk::Image,
|
||||
pub(super) memory: vk::DeviceMemory,
|
||||
pub(super) view: vk::ImageView,
|
||||
}
|
||||
|
||||
/// Depth/stencil image and the exact format selected for its render pass.
|
||||
pub(super) struct VulkanDepthAttachment {
|
||||
pub(super) image: VulkanAllocatedImage,
|
||||
pub(super) format: vk::Format,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(super) fn create_depth_attachment(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
extent: (u32, u32),
|
||||
request: DepthStencilSupport,
|
||||
) -> Result<VulkanDepthAttachment, VulkanSmokeRendererError> {
|
||||
let supported_formats = depth_attachment_formats(instance, device);
|
||||
let format = select_depth_stencil_attachment_format(&supported_formats, request)
|
||||
.map(vk::Format::from_raw)
|
||||
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "logical device has no selected depth attachment format",
|
||||
})?;
|
||||
let image_info = vk::ImageCreateInfo::default()
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(format)
|
||||
.extent(vk::Extent3D {
|
||||
width: extent.0,
|
||||
height: extent.1,
|
||||
depth: 1,
|
||||
})
|
||||
.mip_levels(1)
|
||||
.array_layers(1)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::OPTIMAL)
|
||||
.usage(vk::ImageUsageFlags::DEPTH_STENCIL_ATTACHMENT)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED);
|
||||
// SAFETY: The creation info is stack-owned and uses the live non-zero swapchain extent.
|
||||
let image = unsafe { device.device().create_image(&image_info, None) }.map_err(|result| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateImage(depth attachment)",
|
||||
result,
|
||||
}
|
||||
})?;
|
||||
// SAFETY: The newly created image belongs to this device.
|
||||
let requirements = unsafe { device.device().get_image_memory_requirements(image) };
|
||||
let Some(memory_type_index) = find_memory_type(
|
||||
instance,
|
||||
device.physical_device(),
|
||||
requirements.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
||||
) else {
|
||||
// SAFETY: The unbound image is rolled back on its owning device.
|
||||
unsafe { device.device().destroy_image(image, None) };
|
||||
return Err(VulkanSmokeRendererError::MissingMemoryType {
|
||||
context: "depth attachment",
|
||||
});
|
||||
};
|
||||
let allocation = vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(requirements.size)
|
||||
.memory_type_index(memory_type_index);
|
||||
// SAFETY: Allocation parameters are derived from this image's requirements.
|
||||
let allocation_result = unsafe { device.device().allocate_memory(&allocation, None) };
|
||||
let memory = match allocation_result {
|
||||
Ok(memory) => memory,
|
||||
Err(result) => {
|
||||
// SAFETY: The unbound image is rolled back on allocation failure.
|
||||
unsafe { device.device().destroy_image(image, None) };
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkAllocateMemory(depth attachment)",
|
||||
result,
|
||||
});
|
||||
}
|
||||
};
|
||||
// SAFETY: The allocation satisfies this image's queried requirements.
|
||||
if let Err(result) = unsafe { device.device().bind_image_memory(image, memory, 0) } {
|
||||
// SAFETY: Both newly created resources are rolled back together.
|
||||
unsafe {
|
||||
device.device().destroy_image(image, None);
|
||||
device.device().free_memory(memory, None);
|
||||
}
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkBindImageMemory(depth attachment)",
|
||||
result,
|
||||
});
|
||||
}
|
||||
let range = vk::ImageSubresourceRange::default()
|
||||
.aspect_mask(depth_aspect(format))
|
||||
.base_mip_level(0)
|
||||
.level_count(1)
|
||||
.base_array_layer(0)
|
||||
.layer_count(1);
|
||||
let view_info = vk::ImageViewCreateInfo::default()
|
||||
.image(image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(format)
|
||||
.subresource_range(range);
|
||||
// SAFETY: The image is bound and the depth/stencil aspect matches its selected format.
|
||||
let view_result = unsafe { device.device().create_image_view(&view_info, None) };
|
||||
let view = match view_result {
|
||||
Ok(view) => view,
|
||||
Err(result) => {
|
||||
// SAFETY: Both newly created resources are rolled back together.
|
||||
unsafe {
|
||||
device.device().destroy_image(image, None);
|
||||
device.device().free_memory(memory, None);
|
||||
}
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateImageView(depth attachment)",
|
||||
result,
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(VulkanDepthAttachment {
|
||||
image: VulkanAllocatedImage {
|
||||
image,
|
||||
memory,
|
||||
view,
|
||||
},
|
||||
format,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn destroy_depth_attachment(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
attachment: &VulkanDepthAttachment,
|
||||
) {
|
||||
destroy_allocated_image(device, &attachment.image);
|
||||
}
|
||||
|
||||
fn depth_attachment_formats(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
) -> Vec<i32> {
|
||||
[
|
||||
vk::Format::D16_UNORM,
|
||||
vk::Format::X8_D24_UNORM_PACK32,
|
||||
vk::Format::D32_SFLOAT,
|
||||
vk::Format::D16_UNORM_S8_UINT,
|
||||
vk::Format::D24_UNORM_S8_UINT,
|
||||
vk::Format::D32_SFLOAT_S8_UINT,
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|format| {
|
||||
// SAFETY: The physical device belongs to this instance and the query returns a value copy.
|
||||
unsafe {
|
||||
instance
|
||||
.instance
|
||||
.get_physical_device_format_properties(device.physical_device(), *format)
|
||||
}
|
||||
.optimal_tiling_features
|
||||
.contains(vk::FormatFeatureFlags::DEPTH_STENCIL_ATTACHMENT)
|
||||
})
|
||||
.map(vk::Format::as_raw)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn depth_aspect(format: vk::Format) -> vk::ImageAspectFlags {
|
||||
match format {
|
||||
vk::Format::D16_UNORM_S8_UINT
|
||||
| vk::Format::D24_UNORM_S8_UINT
|
||||
| vk::Format::D32_SFLOAT_S8_UINT => {
|
||||
vk::ImageAspectFlags::DEPTH | vk::ImageAspectFlags::STENCIL
|
||||
}
|
||||
_ => vk::ImageAspectFlags::DEPTH,
|
||||
}
|
||||
}
|
||||
|
||||
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_static_mesh_vertex_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
mesh: &VulkanStaticMesh,
|
||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
let mut bytes = Vec::with_capacity(mesh.vertices.len() * 8 * std::mem::size_of::<f32>());
|
||||
for vertex in &mesh.vertices {
|
||||
for value in vertex
|
||||
.position
|
||||
.into_iter()
|
||||
.chain(vertex.color)
|
||||
.chain(vertex.uv)
|
||||
{
|
||||
bytes.extend_from_slice(&value.to_ne_bytes());
|
||||
}
|
||||
}
|
||||
create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&bytes,
|
||||
vk::BufferUsageFlags::VERTEX_BUFFER,
|
||||
"static mesh vertex buffer",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn create_static_mesh_index_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
mesh: &VulkanStaticMesh,
|
||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
let mut bytes = Vec::with_capacity(mesh.indices.len() * std::mem::size_of::<u32>());
|
||||
for &index in &mesh.indices {
|
||||
bytes.extend_from_slice(&index.to_ne_bytes());
|
||||
}
|
||||
create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&bytes,
|
||||
vk::BufferUsageFlags::INDEX_BUFFER,
|
||||
"static mesh index buffer",
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
pub(super) fn create_static_texture_image(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
texture: &VulkanStaticTexture,
|
||||
) -> Result<VulkanAllocatedImage, VulkanSmokeRendererError> {
|
||||
let staging = create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&texture.rgba8,
|
||||
vk::BufferUsageFlags::TRANSFER_SRC,
|
||||
"static texture staging buffer",
|
||||
)?;
|
||||
let image_info = vk::ImageCreateInfo::default()
|
||||
.image_type(vk::ImageType::TYPE_2D)
|
||||
.format(vk::Format::R8G8B8A8_UNORM)
|
||||
.extent(vk::Extent3D {
|
||||
width: texture.width,
|
||||
height: texture.height,
|
||||
depth: 1,
|
||||
})
|
||||
.mip_levels(1)
|
||||
.array_layers(1)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.tiling(vk::ImageTiling::OPTIMAL)
|
||||
.usage(vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::SAMPLED)
|
||||
.sharing_mode(vk::SharingMode::EXCLUSIVE)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED);
|
||||
// SAFETY: The create info only contains stack-owned values and a validated non-zero extent.
|
||||
let image = unsafe { device.device().create_image(&image_info, None) }.map_err(|result| {
|
||||
destroy_allocated_buffer(device, &staging);
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateImage(static texture)",
|
||||
result,
|
||||
}
|
||||
})?;
|
||||
// SAFETY: The image belongs to this device and is queried immediately after creation.
|
||||
let requirements = unsafe { device.device().get_image_memory_requirements(image) };
|
||||
let Some(memory_type_index) = find_memory_type(
|
||||
instance,
|
||||
device.physical_device(),
|
||||
requirements.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::DEVICE_LOCAL,
|
||||
) else {
|
||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
||||
unsafe { device.device().destroy_image(image, None) };
|
||||
destroy_allocated_buffer(device, &staging);
|
||||
return Err(VulkanSmokeRendererError::MissingMemoryType {
|
||||
context: "static texture image",
|
||||
});
|
||||
};
|
||||
let allocate_info = vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(requirements.size)
|
||||
.memory_type_index(memory_type_index);
|
||||
// SAFETY: The allocation matches the image memory requirements queried above.
|
||||
let memory = {
|
||||
// SAFETY: The allocation matches the image memory requirements queried above.
|
||||
unsafe { device.device().allocate_memory(&allocate_info, None) }
|
||||
}
|
||||
.map_err(|result| {
|
||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
||||
unsafe { device.device().destroy_image(image, None) };
|
||||
destroy_allocated_buffer(device, &staging);
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkAllocateMemory(static texture)",
|
||||
result,
|
||||
}
|
||||
})?;
|
||||
// SAFETY: The allocation matches this image's queried requirements.
|
||||
if let Err(result) = unsafe { device.device().bind_image_memory(image, memory, 0) } {
|
||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
||||
unsafe {
|
||||
device.device().destroy_image(image, None);
|
||||
device.device().free_memory(memory, None);
|
||||
};
|
||||
destroy_allocated_buffer(device, &staging);
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkBindImageMemory(static texture)",
|
||||
result,
|
||||
});
|
||||
}
|
||||
if let Err(error) = upload_static_texture(
|
||||
device,
|
||||
command_pool,
|
||||
staging.buffer,
|
||||
image,
|
||||
texture.width,
|
||||
texture.height,
|
||||
) {
|
||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
||||
unsafe {
|
||||
device.device().destroy_image(image, None);
|
||||
device.device().free_memory(memory, None);
|
||||
};
|
||||
destroy_allocated_buffer(device, &staging);
|
||||
return Err(error);
|
||||
}
|
||||
destroy_allocated_buffer(device, &staging);
|
||||
let view_info = vk::ImageViewCreateInfo::default()
|
||||
.image(image)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(vk::Format::R8G8B8A8_UNORM)
|
||||
.subresource_range(color_subresource_range());
|
||||
// SAFETY: The image is live, initialized and has the stated color subresource.
|
||||
let view = {
|
||||
// SAFETY: The image is live, initialized and has the stated color subresource.
|
||||
unsafe { device.device().create_image_view(&view_info, None) }
|
||||
}
|
||||
.map_err(|result| {
|
||||
// SAFETY: Both resources were created on this device and are being rolled back once.
|
||||
unsafe {
|
||||
device.device().destroy_image(image, None);
|
||||
device.device().free_memory(memory, None);
|
||||
};
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateImageView(static texture)",
|
||||
result,
|
||||
}
|
||||
})?;
|
||||
Ok(VulkanAllocatedImage {
|
||||
image,
|
||||
memory,
|
||||
view,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn upload_static_texture(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
staging_buffer: vk::Buffer,
|
||||
image: vk::Image,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(), VulkanSmokeRendererError> {
|
||||
let allocate_info = vk::CommandBufferAllocateInfo::default()
|
||||
.command_pool(command_pool)
|
||||
.level(vk::CommandBufferLevel::PRIMARY)
|
||||
.command_buffer_count(1);
|
||||
// SAFETY: The command pool is live and owned by the current logical device.
|
||||
let command_buffer = unsafe { device.device().allocate_command_buffers(&allocate_info) }
|
||||
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkAllocateCommandBuffers(texture upload)",
|
||||
result,
|
||||
})?[0];
|
||||
let begin =
|
||||
vk::CommandBufferBeginInfo::default().flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT);
|
||||
// SAFETY: The command buffer is freshly allocated from the current pool.
|
||||
if let Err(result) = unsafe { device.device().begin_command_buffer(command_buffer, &begin) } {
|
||||
// SAFETY: The command buffer belongs to this pool and is released on failure.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.free_command_buffers(command_pool, &[command_buffer]);
|
||||
};
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkBeginCommandBuffer(texture upload)",
|
||||
result,
|
||||
});
|
||||
}
|
||||
let range = color_subresource_range();
|
||||
let to_transfer = vk::ImageMemoryBarrier::default()
|
||||
.old_layout(vk::ImageLayout::UNDEFINED)
|
||||
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.src_access_mask(vk::AccessFlags::empty())
|
||||
.dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
||||
.image(image)
|
||||
.subresource_range(range);
|
||||
let region = vk::BufferImageCopy::default()
|
||||
.image_subresource(
|
||||
vk::ImageSubresourceLayers::default()
|
||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
||||
.mip_level(0)
|
||||
.base_array_layer(0)
|
||||
.layer_count(1),
|
||||
)
|
||||
.image_extent(vk::Extent3D {
|
||||
width,
|
||||
height,
|
||||
depth: 1,
|
||||
});
|
||||
let to_sampled = vk::ImageMemoryBarrier::default()
|
||||
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
.src_access_mask(vk::AccessFlags::TRANSFER_WRITE)
|
||||
.dst_access_mask(vk::AccessFlags::SHADER_READ)
|
||||
.image(image)
|
||||
.subresource_range(range);
|
||||
// SAFETY: The commands operate on live, exclusively owned resources and the stated color subresource.
|
||||
unsafe {
|
||||
device.device().cmd_pipeline_barrier(
|
||||
command_buffer,
|
||||
vk::PipelineStageFlags::TOP_OF_PIPE,
|
||||
vk::PipelineStageFlags::TRANSFER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
&[],
|
||||
&[to_transfer],
|
||||
);
|
||||
device.device().cmd_copy_buffer_to_image(
|
||||
command_buffer,
|
||||
staging_buffer,
|
||||
image,
|
||||
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
|
||||
&[region],
|
||||
);
|
||||
device.device().cmd_pipeline_barrier(
|
||||
command_buffer,
|
||||
vk::PipelineStageFlags::TRANSFER,
|
||||
vk::PipelineStageFlags::FRAGMENT_SHADER,
|
||||
vk::DependencyFlags::empty(),
|
||||
&[],
|
||||
&[],
|
||||
&[to_sampled],
|
||||
);
|
||||
}
|
||||
// SAFETY: Command recording is complete on the current command buffer.
|
||||
if let Err(result) = unsafe { device.device().end_command_buffer(command_buffer) } {
|
||||
// SAFETY: The command buffer belongs to this pool and is released on failure.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.free_command_buffers(command_pool, &[command_buffer]);
|
||||
};
|
||||
return Err(VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkEndCommandBuffer(texture upload)",
|
||||
result,
|
||||
});
|
||||
}
|
||||
let command_buffers = [command_buffer];
|
||||
let submit = [vk::SubmitInfo::default().command_buffers(&command_buffers)];
|
||||
// SAFETY: The graphics queue and submitted command buffer are live for the duration of the wait.
|
||||
let submit_result = unsafe {
|
||||
device
|
||||
.device()
|
||||
.queue_submit(device.graphics_queue(), &submit, vk::Fence::null())
|
||||
};
|
||||
let result = submit_result.and_then(|()| {
|
||||
// SAFETY: The graphics queue remains live until the synchronous idle wait completes.
|
||||
unsafe { device.device().queue_wait_idle(device.graphics_queue()) }
|
||||
});
|
||||
// SAFETY: Submission completed or failed synchronously and the command buffer is no longer needed.
|
||||
unsafe {
|
||||
device
|
||||
.device()
|
||||
.free_command_buffers(command_pool, &[command_buffer]);
|
||||
};
|
||||
result.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkQueueSubmit/WaitIdle(texture upload)",
|
||||
result,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn destroy_allocated_image(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
image: &VulkanAllocatedImage,
|
||||
) {
|
||||
// SAFETY: The image, view and allocation belong to this device and are destroyed once after idle.
|
||||
unsafe {
|
||||
device.device().destroy_image_view(image.view, None);
|
||||
device.device().destroy_image(image.image, None);
|
||||
device.device().free_memory(image.memory, None);
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocates a host-coherent transfer destination for a swapchain image copy.
|
||||
pub(super) fn create_readback_buffer(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
byte_len: usize,
|
||||
) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
|
||||
create_host_visible_buffer(
|
||||
instance,
|
||||
device,
|
||||
&vec![0; byte_len],
|
||||
vk::BufferUsageFlags::TRANSFER_DST,
|
||||
"vkCreateBuffer(readback)",
|
||||
)
|
||||
}
|
||||
|
||||
/// Copies a completed host-coherent readback allocation into CPU-owned bytes.
|
||||
pub(super) fn readback_buffer_bytes(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
buffer: &VulkanAllocatedBuffer,
|
||||
byte_len: usize,
|
||||
) -> Result<Vec<u8>, VulkanSmokeRendererError> {
|
||||
// SAFETY: The caller waits for device idle before mapping this host-visible allocation.
|
||||
let mapped = unsafe {
|
||||
device.device().map_memory(
|
||||
buffer.memory,
|
||||
0,
|
||||
u64::try_from(byte_len).unwrap_or(u64::MAX),
|
||||
vk::MemoryMapFlags::empty(),
|
||||
)
|
||||
}
|
||||
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkMapMemory(readback)",
|
||||
result,
|
||||
})?;
|
||||
let mut bytes = vec![0; byte_len];
|
||||
// SAFETY: Both ranges contain exactly byte_len initialized bytes and do not overlap.
|
||||
unsafe {
|
||||
std::ptr::copy_nonoverlapping(mapped.cast::<u8>(), bytes.as_mut_ptr(), byte_len);
|
||||
device.device().unmap_memory(buffer.memory);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,747 @@
|
||||
use ash::vk;
|
||||
use fparkan_platform::{NativeWindowHandles, RenderRequest};
|
||||
use fparkan_render::{LegacyD3d7Projection, LegacyPipelineState, PipelineKey, RawCameraTransform};
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{
|
||||
VulkanAllocatedBuffer, VulkanAllocatedImage, 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,
|
||||
/// Static indexed geometry uploaded before the first live frame.
|
||||
pub mesh: VulkanStaticMesh,
|
||||
/// Camera contract for static geometry.
|
||||
///
|
||||
/// The default identity matrix retains the initial XY diagnostic viewer.
|
||||
pub camera: VulkanStaticCamera,
|
||||
/// Material textures uploaded before the first live frame.
|
||||
///
|
||||
/// An empty list retains the compatibility white fallback. A singleton list
|
||||
/// is a deliberately explicit one-material compatibility mode; otherwise
|
||||
/// every source batch selector must resolve to one entry in this list.
|
||||
pub materials: Vec<VulkanStaticMaterial>,
|
||||
/// Optional shared bootstrap progress tracker for failure evidence.
|
||||
pub bootstrap_progress: Option<Arc<VulkanSmokeBootstrapProgress>>,
|
||||
}
|
||||
|
||||
/// One vertex accepted by the initial static Vulkan geometry path.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct VulkanStaticVertex {
|
||||
/// World or clip position transformed by [`VulkanStaticCamera`].
|
||||
pub position: [f32; 3],
|
||||
/// Linear RGB vertex color.
|
||||
pub color: [f32; 3],
|
||||
/// Texture coordinate consumed by the static material bridge.
|
||||
pub uv: [f32; 2],
|
||||
}
|
||||
|
||||
/// A static geometry camera represented in the shader's matrix memory order.
|
||||
///
|
||||
/// `clip_from_world` contains row-major D3D7 data. GLSL's default column-major
|
||||
/// `mat4` interpretation deliberately transposes that storage, making
|
||||
/// `matrix * vec4(position, 1)` equivalent to D3D7's row-vector multiplication.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct VulkanStaticCamera {
|
||||
/// Row-major clip-from-world transform consumed by the vertex shader.
|
||||
pub clip_from_world: [f32; 16],
|
||||
}
|
||||
|
||||
impl VulkanStaticCamera {
|
||||
/// Builds a static camera from the recovered Ngi32 D3D7 camera contract.
|
||||
#[must_use]
|
||||
pub fn from_legacy_d3d7(
|
||||
transform: RawCameraTransform,
|
||||
projection: LegacyD3d7Projection,
|
||||
) -> Option<Self> {
|
||||
let view = transform.try_direct3d7_view_row_major()?;
|
||||
let projection = projection.try_direct3d7_projection_row_major()?;
|
||||
Self::from_row_major_view_projection(view, projection)
|
||||
}
|
||||
|
||||
/// Builds a camera from finite row-major view and projection matrices.
|
||||
///
|
||||
/// This is the runtime-facing form of the contract. It is intentionally
|
||||
/// independent of the D3D7 recovery path so a future native camera
|
||||
/// controller can submit its per-frame matrices without recreating Vulkan
|
||||
/// resources.
|
||||
#[must_use]
|
||||
pub fn from_row_major_view_projection(view: [f32; 16], projection: [f32; 16]) -> Option<Self> {
|
||||
view.iter()
|
||||
.chain(projection.iter())
|
||||
.all(|value| value.is_finite())
|
||||
.then_some(Self {
|
||||
clip_from_world: multiply_row_major(view, projection),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns whether every matrix element is finite.
|
||||
#[must_use]
|
||||
pub fn is_finite(self) -> bool {
|
||||
self.clip_from_world.iter().all(|value| value.is_finite())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for VulkanStaticCamera {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
clip_from_world: [
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
|
||||
let mut result = [0.0; 16];
|
||||
for row in 0..4 {
|
||||
for column in 0..4 {
|
||||
result[row * 4 + column] = (0..4)
|
||||
.map(|inner| left[row * 4 + inner] * right[inner * 4 + column])
|
||||
.sum();
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Static indexed geometry uploaded to live Vulkan buffers.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct VulkanStaticMesh {
|
||||
/// Vertex data in pipeline order.
|
||||
pub vertices: Vec<VulkanStaticVertex>,
|
||||
/// Triangle-list indices into [`Self::vertices`].
|
||||
pub indices: Vec<u32>,
|
||||
/// Source-preserving triangle draw ranges in [`Self::indices`].
|
||||
pub draw_ranges: Vec<VulkanStaticDrawRange>,
|
||||
}
|
||||
|
||||
/// One indexed triangle-list draw retained from an original mesh batch.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanStaticDrawRange {
|
||||
/// First index in the shared index buffer.
|
||||
pub first_index: u32,
|
||||
/// Number of indices in this draw.
|
||||
pub index_count: u32,
|
||||
/// Original positional `Batch20.material_index` selector.
|
||||
pub material_index: u16,
|
||||
/// Backend-neutral fixed-function state for this source range.
|
||||
pub pipeline_state: LegacyPipelineState,
|
||||
/// Alpha-test reference in legacy 0..=255 units; dynamic material data.
|
||||
pub alpha_test_reference: u8,
|
||||
}
|
||||
|
||||
impl VulkanStaticDrawRange {
|
||||
/// Returns the canonical key used for Vulkan pipeline selection.
|
||||
#[must_use]
|
||||
pub fn pipeline_key(self) -> PipelineKey {
|
||||
self.pipeline_state.into()
|
||||
}
|
||||
|
||||
/// Returns the normalized cutoff consumed by the fragment shader.
|
||||
#[must_use]
|
||||
pub fn alpha_test_cutoff(self) -> f32 {
|
||||
if self.pipeline_state.alpha_test {
|
||||
f32::from(self.alpha_test_reference) / 255.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One diffuse material texture keyed by an original MSH batch selector.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanStaticMaterial {
|
||||
/// Positional material selector used by one or more source batches.
|
||||
pub material_index: u16,
|
||||
/// Decoded RGBA8 diffuse texture for this selector.
|
||||
pub texture: VulkanStaticTexture,
|
||||
}
|
||||
|
||||
/// Decoded RGBA8 image accepted by the initial Vulkan texture upload path.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanStaticTexture {
|
||||
/// Image width in texels.
|
||||
pub width: u32,
|
||||
/// Image height in texels.
|
||||
pub height: u32,
|
||||
/// Row-major RGBA8 pixels.
|
||||
pub rgba8: Vec<u8>,
|
||||
}
|
||||
|
||||
impl VulkanStaticTexture {
|
||||
pub(super) fn validate(&self) -> Result<(), &'static str> {
|
||||
let pixels = usize::try_from(self.width)
|
||||
.ok()
|
||||
.and_then(|width| {
|
||||
usize::try_from(self.height)
|
||||
.ok()
|
||||
.and_then(|height| width.checked_mul(height))
|
||||
})
|
||||
.and_then(|pixels| pixels.checked_mul(4));
|
||||
match pixels {
|
||||
None => Err("static texture dimensions overflow address space"),
|
||||
Some(0) => Err("static texture has zero extent"),
|
||||
Some(expected) if expected != self.rgba8.len() => {
|
||||
Err("static texture rgba8 byte count does not match extent")
|
||||
}
|
||||
Some(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves each source range to its descriptor-set material index.
|
||||
///
|
||||
/// Empty material input selects the renderer's white fallback. A singleton is
|
||||
/// the explicit direct-TEXM compatibility mode. Multiple materials must map
|
||||
/// each `Batch20.material_index` exactly and never duplicate a selector.
|
||||
pub(super) fn resolve_draw_texture_indices(
|
||||
draw_ranges: &[VulkanStaticDrawRange],
|
||||
materials: &[VulkanStaticMaterial],
|
||||
) -> Result<Vec<usize>, &'static str> {
|
||||
for (index, material) in materials.iter().enumerate() {
|
||||
material.texture.validate()?;
|
||||
if materials[..index]
|
||||
.iter()
|
||||
.any(|previous| previous.material_index == material.material_index)
|
||||
{
|
||||
return Err("static material selectors must be unique");
|
||||
}
|
||||
}
|
||||
draw_ranges
|
||||
.iter()
|
||||
.map(|range| {
|
||||
if materials.len() <= 1 {
|
||||
Ok(0)
|
||||
} else {
|
||||
materials
|
||||
.iter()
|
||||
.position(|material| material.material_index == range.material_index)
|
||||
.ok_or("static mesh draw range has no material texture")
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl VulkanStaticMesh {
|
||||
/// Returns the compatibility triangle used by the native Stage 0 smoke app.
|
||||
#[must_use]
|
||||
pub fn smoke_triangle() -> Self {
|
||||
Self {
|
||||
vertices: vec![
|
||||
VulkanStaticVertex {
|
||||
position: [0.0, -0.55, 0.0],
|
||||
color: [1.0, 0.2, 0.2],
|
||||
uv: [0.5, 0.0],
|
||||
},
|
||||
VulkanStaticVertex {
|
||||
position: [0.55, 0.55, 0.0],
|
||||
color: [0.2, 1.0, 0.2],
|
||||
uv: [1.0, 1.0],
|
||||
},
|
||||
VulkanStaticVertex {
|
||||
position: [-0.55, 0.55, 0.0],
|
||||
color: [0.2, 0.4, 1.0],
|
||||
uv: [0.0, 1.0],
|
||||
},
|
||||
],
|
||||
indices: vec![0, 1, 2],
|
||||
draw_ranges: vec![VulkanStaticDrawRange {
|
||||
first_index: 0,
|
||||
index_count: 3,
|
||||
material_index: 0,
|
||||
pipeline_state: LegacyPipelineState::default(),
|
||||
alpha_test_reference: 0,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.vertices.is_empty() {
|
||||
return Err("static mesh has no vertices");
|
||||
}
|
||||
if self.indices.is_empty() || !self.indices.len().is_multiple_of(3) {
|
||||
return Err("static mesh indices must contain complete triangles");
|
||||
}
|
||||
if self.draw_ranges.is_empty() {
|
||||
return Err("static mesh has no draw ranges");
|
||||
}
|
||||
let mut expected_first = 0_u32;
|
||||
for range in &self.draw_ranges {
|
||||
if range.first_index != expected_first
|
||||
|| range.index_count == 0
|
||||
|| !range.index_count.is_multiple_of(3)
|
||||
{
|
||||
return Err("static mesh draw ranges must be contiguous complete triangles");
|
||||
}
|
||||
expected_first = expected_first
|
||||
.checked_add(range.index_count)
|
||||
.ok_or("static mesh draw range exceeds index count")?;
|
||||
}
|
||||
if usize::try_from(expected_first).ok() != Some(self.indices.len()) {
|
||||
return Err("static mesh draw ranges must cover all indices");
|
||||
}
|
||||
if self.indices.iter().any(|&index| {
|
||||
usize::try_from(index)
|
||||
.ok()
|
||||
.is_none_or(|index| index >= self.vertices.len())
|
||||
}) {
|
||||
return Err("static mesh index exceeds vertex count");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod static_mesh_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn smoke_triangle_is_valid_complete_geometry() {
|
||||
let mesh = VulkanStaticMesh::smoke_triangle();
|
||||
|
||||
assert_eq!(mesh.indices, vec![0, 1, 2]);
|
||||
assert_eq!(mesh.validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_camera_composes_recovered_d3d7_view_before_projection() {
|
||||
let transform = RawCameraTransform {
|
||||
words: [
|
||||
0.0_f32.to_bits(),
|
||||
(-1.0_f32).to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
10.0_f32.to_bits(),
|
||||
1.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
20.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
1.0_f32.to_bits(),
|
||||
30.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
0.0_f32.to_bits(),
|
||||
1.0_f32.to_bits(),
|
||||
],
|
||||
};
|
||||
let projection = LegacyD3d7Projection {
|
||||
viewport: [0, 0, 1024, 768],
|
||||
near_plane: 0.5,
|
||||
far_plane: 700.0,
|
||||
field_of_view_radians: 1.3,
|
||||
};
|
||||
|
||||
let camera = VulkanStaticCamera::from_legacy_d3d7(transform, projection)
|
||||
.expect("recovered camera inputs are valid");
|
||||
|
||||
assert!(camera.is_finite());
|
||||
assert_eq!(camera.clip_from_world[0], 0.65_f32.cos());
|
||||
assert_eq!(camera.clip_from_world[6], 0.65_f32.sin() * 700.0 / 699.5);
|
||||
assert_eq!(camera.clip_from_world[7], 0.65_f32.sin());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() {
|
||||
let identity = VulkanStaticCamera::default().clip_from_world;
|
||||
|
||||
assert_eq!(
|
||||
VulkanStaticCamera::from_row_major_view_projection(identity, identity),
|
||||
Some(VulkanStaticCamera::default())
|
||||
);
|
||||
let mut invalid = identity;
|
||||
invalid[5] = f32::NAN;
|
||||
assert_eq!(
|
||||
VulkanStaticCamera::from_row_major_view_projection(invalid, identity),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_mesh_rejects_bad_triangle_topology_and_indices() {
|
||||
let no_vertices = VulkanStaticMesh {
|
||||
vertices: Vec::new(),
|
||||
indices: vec![0, 1, 2],
|
||||
draw_ranges: VulkanStaticMesh::smoke_triangle().draw_ranges,
|
||||
};
|
||||
let incomplete_triangle = VulkanStaticMesh {
|
||||
vertices: VulkanStaticMesh::smoke_triangle().vertices,
|
||||
indices: vec![0, 1],
|
||||
draw_ranges: vec![VulkanStaticDrawRange {
|
||||
first_index: 0,
|
||||
index_count: 2,
|
||||
material_index: 0,
|
||||
pipeline_state: LegacyPipelineState::default(),
|
||||
alpha_test_reference: 0,
|
||||
}],
|
||||
};
|
||||
let out_of_range_index = VulkanStaticMesh {
|
||||
vertices: VulkanStaticMesh::smoke_triangle().vertices,
|
||||
indices: vec![0, 1, 3],
|
||||
draw_ranges: VulkanStaticMesh::smoke_triangle().draw_ranges,
|
||||
};
|
||||
|
||||
assert_eq!(no_vertices.validate(), Err("static mesh has no vertices"));
|
||||
assert_eq!(
|
||||
incomplete_triangle.validate(),
|
||||
Err("static mesh indices must contain complete triangles")
|
||||
);
|
||||
assert_eq!(
|
||||
out_of_range_index.validate(),
|
||||
Err("static mesh index exceeds vertex count")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_descriptors_follow_source_batch_selectors() {
|
||||
let ranges = [
|
||||
VulkanStaticDrawRange {
|
||||
first_index: 0,
|
||||
index_count: 3,
|
||||
material_index: 7,
|
||||
pipeline_state: LegacyPipelineState::default(),
|
||||
alpha_test_reference: 0,
|
||||
},
|
||||
VulkanStaticDrawRange {
|
||||
first_index: 3,
|
||||
index_count: 3,
|
||||
material_index: 2,
|
||||
pipeline_state: LegacyPipelineState::default(),
|
||||
alpha_test_reference: 0,
|
||||
},
|
||||
];
|
||||
let texture = || VulkanStaticTexture {
|
||||
width: 1,
|
||||
height: 1,
|
||||
rgba8: vec![255; 4],
|
||||
};
|
||||
let materials = [
|
||||
VulkanStaticMaterial {
|
||||
material_index: 2,
|
||||
texture: texture(),
|
||||
},
|
||||
VulkanStaticMaterial {
|
||||
material_index: 7,
|
||||
texture: texture(),
|
||||
},
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_draw_texture_indices(&ranges, &materials),
|
||||
Ok(vec![1, 0])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_range_pipeline_key_follows_backend_neutral_state() {
|
||||
let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0];
|
||||
let blended = VulkanStaticDrawRange {
|
||||
pipeline_state: LegacyPipelineState {
|
||||
blend: fparkan_render::LegacyBlendMode::SourceAlpha,
|
||||
..LegacyPipelineState::default()
|
||||
},
|
||||
..base
|
||||
};
|
||||
|
||||
assert_eq!(base.pipeline_key().packed(), 0);
|
||||
assert_ne!(base.pipeline_key(), blended.pipeline_key());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_cutoff_is_dynamic_and_disabled_outside_alpha_test_pipeline() {
|
||||
let base = VulkanStaticMesh::smoke_triangle().draw_ranges[0];
|
||||
let disabled = VulkanStaticDrawRange {
|
||||
alpha_test_reference: 200,
|
||||
..base
|
||||
};
|
||||
let enabled = VulkanStaticDrawRange {
|
||||
pipeline_state: LegacyPipelineState {
|
||||
alpha_test: true,
|
||||
..LegacyPipelineState::default()
|
||||
},
|
||||
alpha_test_reference: 128,
|
||||
..base
|
||||
};
|
||||
assert_eq!(disabled.alpha_test_cutoff(), 0.0);
|
||||
assert_eq!(enabled.alpha_test_cutoff(), 128.0 / 255.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Current swapchain image format as a raw Vulkan enum value.
|
||||
pub swapchain_image_format: i32,
|
||||
/// Current swapchain image-usage flags as raw Vulkan bits.
|
||||
pub swapchain_image_usage: u32,
|
||||
/// Number of submitted swapchain image-to-buffer copy commands.
|
||||
pub readback_copy_count: u64,
|
||||
/// Byte length of the final completed readback artifact.
|
||||
pub readback_byte_count: u64,
|
||||
/// Stable FNV-1a hash of the final completed readback artifact.
|
||||
pub readback_fnv1a64: u64,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// CPU-owned copy of the final completed swapchain image readback.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct VulkanReadbackArtifact {
|
||||
/// Vulkan swapchain format as a raw enum value.
|
||||
pub format: i32,
|
||||
/// Width of each image in pixels.
|
||||
pub width: u32,
|
||||
/// Height of each image in pixels.
|
||||
pub height: u32,
|
||||
/// One raw four-byte-per-pixel image: the last successfully submitted
|
||||
/// swapchain image before synchronized teardown.
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
/// Optional raw GPU readback retained after synchronization and before teardown.
|
||||
pub readback_artifact: Option<VulkanReadbackArtifact>,
|
||||
/// 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,
|
||||
},
|
||||
/// The submitted static geometry cannot be represented by this path.
|
||||
InvalidStaticMesh {
|
||||
/// Validation failure detail.
|
||||
context: &'static str,
|
||||
},
|
||||
/// The static camera matrix contains a non-finite element.
|
||||
InvalidStaticCamera {
|
||||
/// Validation failure detail.
|
||||
context: &'static str,
|
||||
},
|
||||
/// The submitted static texture cannot be represented by this path.
|
||||
InvalidStaticTexture {
|
||||
/// Validation failure detail.
|
||||
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::InvalidStaticMesh { context } => write!(f, "invalid static mesh: {context}"),
|
||||
Self::InvalidStaticCamera { context } => {
|
||||
write!(f, "invalid static camera: {context}")
|
||||
}
|
||||
Self::InvalidStaticTexture { context } => {
|
||||
write!(f, "invalid static texture: {context}")
|
||||
}
|
||||
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) textures: Vec<VulkanAllocatedImage>,
|
||||
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
|
||||
pub(super) draw_texture_indices: Vec<usize>,
|
||||
pub(super) camera: VulkanStaticCamera,
|
||||
pub(super) frame_sync: Vec<VulkanFrameSync>,
|
||||
pub(super) images_in_flight: Vec<vk::Fence>,
|
||||
pub(super) current_frame: usize,
|
||||
/// Last swapchain image whose color attachment was copied after a
|
||||
/// successful graphics submission. Reset whenever swapchain resources are
|
||||
/// replaced so a teardown never reads a buffer from an old swapchain.
|
||||
pub(super) last_readback_image_index: Option<usize>,
|
||||
pub(super) depth_request: fparkan_platform::DepthStencilSupport,
|
||||
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::from_raw(plan.image_usage))
|
||||
.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,891 @@
|
||||
#![allow(unsafe_code)]
|
||||
|
||||
use ash::vk;
|
||||
use fparkan_platform::DepthStencilSupport;
|
||||
use fparkan_render::{
|
||||
LegacyBlendMode, LegacyCullMode, LegacyDepthMode, LegacyPipelineState, PipelineKey,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::{
|
||||
color_subresource_range, create_depth_attachment, destroy_depth_attachment,
|
||||
VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment, VulkanInstanceProbe,
|
||||
VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe,
|
||||
TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
|
||||
};
|
||||
|
||||
pub(super) struct VulkanSwapchainResources {
|
||||
pub(super) image_views: Vec<vk::ImageView>,
|
||||
pub(super) images: Vec<vk::Image>,
|
||||
pub(super) readback_buffers: Vec<VulkanAllocatedBuffer>,
|
||||
pub(super) depth_attachment: VulkanDepthAttachment,
|
||||
pub(super) render_pass: vk::RenderPass,
|
||||
pub(super) pipeline_layout: vk::PipelineLayout,
|
||||
pub(super) descriptor_set_layout: vk::DescriptorSetLayout,
|
||||
pub(super) descriptor_pool: vk::DescriptorPool,
|
||||
pub(super) descriptor_sets: Vec<vk::DescriptorSet>,
|
||||
pub(super) sampler: vk::Sampler,
|
||||
/// Live graphics pipelines indexed by canonical backend-neutral state.
|
||||
pub(super) pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
|
||||
pub(super) framebuffers: Vec<vk::Framebuffer>,
|
||||
pub(super) command_buffers: Vec<vk::CommandBuffer>,
|
||||
}
|
||||
|
||||
struct PartialSwapchainResources {
|
||||
image_views: Vec<vk::ImageView>,
|
||||
readback_buffers: Vec<VulkanAllocatedBuffer>,
|
||||
depth_attachment: Option<VulkanDepthAttachment>,
|
||||
render_pass: Option<vk::RenderPass>,
|
||||
pipeline_layout: Option<vk::PipelineLayout>,
|
||||
descriptor_set_layout: Option<vk::DescriptorSetLayout>,
|
||||
descriptor_pool: Option<vk::DescriptorPool>,
|
||||
sampler: Option<vk::Sampler>,
|
||||
pipelines: BTreeMap<PipelineKey, vk::Pipeline>,
|
||||
framebuffers: Vec<vk::Framebuffer>,
|
||||
command_buffers: Vec<vk::CommandBuffer>,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
|
||||
pub(super) fn create_swapchain_resources(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
swapchain: &VulkanSwapchainProbe,
|
||||
command_pool: vk::CommandPool,
|
||||
vertex_buffer: &VulkanAllocatedBuffer,
|
||||
index_buffer: &VulkanAllocatedBuffer,
|
||||
textures: &[VulkanAllocatedImage],
|
||||
draw_ranges: &[super::VulkanStaticDrawRange],
|
||||
depth_request: DepthStencilSupport,
|
||||
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,
|
||||
)?,
|
||||
readback_buffers: Vec::new(),
|
||||
depth_attachment: None,
|
||||
render_pass: None,
|
||||
pipeline_layout: None,
|
||||
descriptor_set_layout: None,
|
||||
descriptor_pool: None,
|
||||
sampler: None,
|
||||
pipelines: BTreeMap::new(),
|
||||
framebuffers: Vec::new(),
|
||||
command_buffers: Vec::new(),
|
||||
};
|
||||
let (
|
||||
depth_attachment,
|
||||
render_pass,
|
||||
pipeline_layout,
|
||||
pipelines,
|
||||
descriptor_set_layout,
|
||||
descriptor_pool,
|
||||
descriptor_sets,
|
||||
sampler,
|
||||
) = match create_swapchain_pipeline_bundle(
|
||||
instance,
|
||||
device,
|
||||
swapchain.report.plan.format.format,
|
||||
swapchain.report.plan.extent,
|
||||
textures,
|
||||
draw_ranges,
|
||||
depth_request,
|
||||
) {
|
||||
Ok(bundle) => bundle,
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let depth_view = depth_attachment.image.view;
|
||||
partial.depth_attachment = Some(depth_attachment);
|
||||
partial.render_pass = Some(render_pass);
|
||||
partial.pipeline_layout = Some(pipeline_layout);
|
||||
partial.descriptor_set_layout = Some(descriptor_set_layout);
|
||||
partial.descriptor_pool = Some(descriptor_pool);
|
||||
partial.sampler = Some(sampler);
|
||||
partial.pipelines = pipelines;
|
||||
let framebuffers = match create_swapchain_framebuffers(
|
||||
device,
|
||||
render_pass,
|
||||
&partial.image_views,
|
||||
depth_view,
|
||||
swapchain.report.plan.extent,
|
||||
) {
|
||||
Ok(framebuffers) => framebuffers,
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
partial.framebuffers = framebuffers;
|
||||
if vk::ImageUsageFlags::from_raw(swapchain.report.plan.image_usage)
|
||||
.contains(vk::ImageUsageFlags::TRANSFER_SRC)
|
||||
{
|
||||
let byte_len = usize::try_from(swapchain.report.plan.extent.0)
|
||||
.ok()
|
||||
.and_then(|width| {
|
||||
usize::try_from(swapchain.report.plan.extent.1)
|
||||
.ok()
|
||||
.and_then(|height| width.checked_mul(height))
|
||||
})
|
||||
.and_then(|pixels| pixels.checked_mul(4))
|
||||
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
|
||||
context: "swapchain readback byte length",
|
||||
})?;
|
||||
for _ in &images {
|
||||
match super::create_readback_buffer(instance, device, byte_len) {
|
||||
Ok(buffer) => partial.readback_buffers.push(buffer),
|
||||
Err(error) => {
|
||||
destroy_partial_swapchain_resources(device, command_pool, partial);
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
let depth_attachment =
|
||||
partial
|
||||
.depth_attachment
|
||||
.take()
|
||||
.ok_or(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "depth attachment ownership after swapchain setup",
|
||||
})?;
|
||||
Ok(VulkanSwapchainResources {
|
||||
image_views: partial.image_views,
|
||||
images,
|
||||
readback_buffers: partial.readback_buffers,
|
||||
depth_attachment,
|
||||
render_pass,
|
||||
pipeline_layout,
|
||||
descriptor_set_layout,
|
||||
descriptor_pool,
|
||||
descriptor_sets,
|
||||
sampler,
|
||||
pipelines: partial.pipelines,
|
||||
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)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn create_swapchain_pipeline_bundle(
|
||||
instance: &VulkanInstanceProbe,
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
format: i32,
|
||||
extent: (u32, u32),
|
||||
textures: &[VulkanAllocatedImage],
|
||||
draw_ranges: &[super::VulkanStaticDrawRange],
|
||||
depth_request: DepthStencilSupport,
|
||||
) -> Result<
|
||||
(
|
||||
VulkanDepthAttachment,
|
||||
vk::RenderPass,
|
||||
vk::PipelineLayout,
|
||||
BTreeMap<PipelineKey, vk::Pipeline>,
|
||||
vk::DescriptorSetLayout,
|
||||
vk::DescriptorPool,
|
||||
Vec<vk::DescriptorSet>,
|
||||
vk::Sampler,
|
||||
),
|
||||
VulkanSmokeRendererError,
|
||||
> {
|
||||
let depth_attachment = create_depth_attachment(instance, device, extent, depth_request)?;
|
||||
let render_pass = match create_render_pass(device, format, depth_attachment.format) {
|
||||
Ok(render_pass) => render_pass,
|
||||
Err(error) => {
|
||||
destroy_depth_attachment(device, &depth_attachment);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) =
|
||||
create_texture_descriptor_bundle(device, textures).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) };
|
||||
destroy_depth_attachment(device, &depth_attachment);
|
||||
})?;
|
||||
let pipeline_layout =
|
||||
create_pipeline_layout(device, descriptor_set_layout).inspect_err(|_| {
|
||||
// SAFETY: The descriptor resources and render pass were created above and are rolled back on this device.
|
||||
unsafe {
|
||||
device.device().destroy_sampler(sampler, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_descriptor_pool(descriptor_pool, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_descriptor_set_layout(descriptor_set_layout, None);
|
||||
device.device().destroy_render_pass(render_pass, None);
|
||||
}
|
||||
destroy_depth_attachment(device, &depth_attachment);
|
||||
})?;
|
||||
let pipelines =
|
||||
create_graphics_pipeline_cache(device, render_pass, pipeline_layout, extent, draw_ranges)
|
||||
.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_sampler(sampler, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_descriptor_pool(descriptor_pool, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_descriptor_set_layout(descriptor_set_layout, None);
|
||||
device.device().destroy_render_pass(render_pass, None);
|
||||
}
|
||||
destroy_depth_attachment(device, &depth_attachment);
|
||||
})?;
|
||||
Ok((
|
||||
depth_attachment,
|
||||
render_pass,
|
||||
pipeline_layout,
|
||||
pipelines,
|
||||
descriptor_set_layout,
|
||||
descriptor_pool,
|
||||
descriptor_sets,
|
||||
sampler,
|
||||
))
|
||||
}
|
||||
|
||||
fn create_swapchain_framebuffers(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
render_pass: vk::RenderPass,
|
||||
image_views: &[vk::ImageView],
|
||||
depth_view: 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, depth_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,
|
||||
depth_format: vk::Format,
|
||||
) -> 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 depth_attachment = vk::AttachmentDescription::default()
|
||||
.format(depth_format)
|
||||
.samples(vk::SampleCountFlags::TYPE_1)
|
||||
.load_op(vk::AttachmentLoadOp::CLEAR)
|
||||
.store_op(vk::AttachmentStoreOp::DONT_CARE)
|
||||
.stencil_load_op(vk::AttachmentLoadOp::DONT_CARE)
|
||||
.stencil_store_op(vk::AttachmentStoreOp::DONT_CARE)
|
||||
.initial_layout(vk::ImageLayout::UNDEFINED)
|
||||
.final_layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
|
||||
let depth_attachment_ref = vk::AttachmentReference::default()
|
||||
.attachment(1)
|
||||
.layout(vk::ImageLayout::DEPTH_STENCIL_ATTACHMENT_OPTIMAL);
|
||||
let color_attachments = [color_attachment_ref];
|
||||
let subpass = vk::SubpassDescription::default()
|
||||
.pipeline_bind_point(vk::PipelineBindPoint::GRAPHICS)
|
||||
.color_attachments(&color_attachments)
|
||||
.depth_stencil_attachment(&depth_attachment_ref);
|
||||
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, depth_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,
|
||||
descriptor_set_layout: vk::DescriptorSetLayout,
|
||||
) -> Result<vk::PipelineLayout, VulkanSmokeRendererError> {
|
||||
let set_layouts = [descriptor_set_layout];
|
||||
let push_constant_ranges = [
|
||||
vk::PushConstantRange::default()
|
||||
.stage_flags(vk::ShaderStageFlags::VERTEX)
|
||||
.offset(0)
|
||||
.size(64),
|
||||
vk::PushConstantRange::default()
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT)
|
||||
.offset(64)
|
||||
.size(u32::try_from(std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
||||
];
|
||||
let create_info = vk::PipelineLayoutCreateInfo::default()
|
||||
.set_layouts(&set_layouts)
|
||||
.push_constant_ranges(&push_constant_ranges);
|
||||
// SAFETY: The descriptor-set layout belongs to this live logical device.
|
||||
unsafe { device.device().create_pipeline_layout(&create_info, None) }.map_err(|error| {
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreatePipelineLayout",
|
||||
result: error,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn create_texture_descriptor_bundle(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
textures: &[VulkanAllocatedImage],
|
||||
) -> Result<
|
||||
(
|
||||
vk::DescriptorSetLayout,
|
||||
vk::DescriptorPool,
|
||||
Vec<vk::DescriptorSet>,
|
||||
vk::Sampler,
|
||||
),
|
||||
VulkanSmokeRendererError,
|
||||
> {
|
||||
let binding = vk::DescriptorSetLayoutBinding::default()
|
||||
.binding(0)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(1)
|
||||
.stage_flags(vk::ShaderStageFlags::FRAGMENT);
|
||||
let bindings = [binding];
|
||||
let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
|
||||
// SAFETY: The layout description is stack-owned and references no external memory.
|
||||
let layout = unsafe {
|
||||
device
|
||||
.device()
|
||||
.create_descriptor_set_layout(&layout_info, None)
|
||||
}
|
||||
.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateDescriptorSetLayout",
|
||||
result,
|
||||
})?;
|
||||
let texture_count = u32::try_from(textures.len()).map_err(|_| {
|
||||
VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "static material texture count exceeds Vulkan descriptor limit",
|
||||
}
|
||||
})?;
|
||||
if texture_count == 0 {
|
||||
return Err(VulkanSmokeRendererError::InvariantViolation {
|
||||
context: "static material texture list is empty",
|
||||
});
|
||||
}
|
||||
let pool_size = vk::DescriptorPoolSize::default()
|
||||
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.descriptor_count(texture_count);
|
||||
let pool_sizes = [pool_size];
|
||||
let pool_info = vk::DescriptorPoolCreateInfo::default()
|
||||
.pool_sizes(&pool_sizes)
|
||||
.max_sets(texture_count);
|
||||
let pool =
|
||||
// SAFETY: The pool description is stack-owned and reserves exactly one descriptor.
|
||||
unsafe { device.device().create_descriptor_pool(&pool_info, None) }.map_err(|result| {
|
||||
// SAFETY: The layout was created above on this device and is rolled back once.
|
||||
unsafe { device.device().destroy_descriptor_set_layout(layout, None) };
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateDescriptorPool",
|
||||
result,
|
||||
}
|
||||
})?;
|
||||
let layouts = vec![layout; textures.len()];
|
||||
let allocate_info = vk::DescriptorSetAllocateInfo::default()
|
||||
.descriptor_pool(pool)
|
||||
.set_layouts(&layouts);
|
||||
// SAFETY: The pool and layout are live on this logical device.
|
||||
let descriptor_sets = unsafe { device.device().allocate_descriptor_sets(&allocate_info) }
|
||||
.map_err(|result| {
|
||||
// SAFETY: Resources were created above on this device and are rolled back once.
|
||||
unsafe {
|
||||
device.device().destroy_descriptor_pool(pool, None);
|
||||
device.device().destroy_descriptor_set_layout(layout, None);
|
||||
};
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkAllocateDescriptorSets",
|
||||
result,
|
||||
}
|
||||
})?;
|
||||
let sampler_info = vk::SamplerCreateInfo::default()
|
||||
.mag_filter(vk::Filter::LINEAR)
|
||||
.min_filter(vk::Filter::LINEAR)
|
||||
.mipmap_mode(vk::SamplerMipmapMode::LINEAR)
|
||||
.address_mode_u(vk::SamplerAddressMode::REPEAT)
|
||||
.address_mode_v(vk::SamplerAddressMode::REPEAT)
|
||||
.address_mode_w(vk::SamplerAddressMode::REPEAT)
|
||||
.max_lod(0.0);
|
||||
let sampler =
|
||||
// SAFETY: The sampler create info is stack-owned and has no unsupported optional features.
|
||||
unsafe { device.device().create_sampler(&sampler_info, None) }.map_err(|result| {
|
||||
// SAFETY: Resources were created above on this device and are rolled back once.
|
||||
unsafe {
|
||||
device.device().destroy_descriptor_pool(pool, None);
|
||||
device.device().destroy_descriptor_set_layout(layout, None);
|
||||
};
|
||||
VulkanSmokeRendererError::VulkanOperation {
|
||||
context: "vkCreateSampler",
|
||||
result,
|
||||
}
|
||||
})?;
|
||||
let image_infos = textures
|
||||
.iter()
|
||||
.map(|texture| {
|
||||
vk::DescriptorImageInfo::default()
|
||||
.sampler(sampler)
|
||||
.image_view(texture.view)
|
||||
.image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let writes = descriptor_sets
|
||||
.iter()
|
||||
.copied()
|
||||
.zip(image_infos.iter())
|
||||
.map(|(descriptor_set, image_info)| {
|
||||
vk::WriteDescriptorSet::default()
|
||||
.dst_set(descriptor_set)
|
||||
.dst_binding(0)
|
||||
.descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
|
||||
.image_info(std::slice::from_ref(image_info))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
// SAFETY: Descriptor sets, sampler and image views are live; every texture upload completed its shader-read transition.
|
||||
unsafe { device.device().update_descriptor_sets(&writes, &[]) };
|
||||
Ok((layout, pool, descriptor_sets, sampler))
|
||||
}
|
||||
|
||||
#[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_cache(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
render_pass: vk::RenderPass,
|
||||
pipeline_layout: vk::PipelineLayout,
|
||||
extent: (u32, u32),
|
||||
draw_ranges: &[super::VulkanStaticDrawRange],
|
||||
) -> Result<BTreeMap<PipelineKey, vk::Pipeline>, VulkanSmokeRendererError> {
|
||||
let mut pipelines = BTreeMap::new();
|
||||
for range in draw_ranges {
|
||||
let key = range.pipeline_key();
|
||||
if pipelines.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let pipeline = match create_graphics_pipeline(
|
||||
device,
|
||||
render_pass,
|
||||
pipeline_layout,
|
||||
extent,
|
||||
range.pipeline_state,
|
||||
) {
|
||||
Ok(pipeline) => pipeline,
|
||||
Err(error) => {
|
||||
// SAFETY: All previously created pipelines belong to this live device and are rolled back with their bundle.
|
||||
unsafe {
|
||||
for pipeline in pipelines.into_values() {
|
||||
device.device().destroy_pipeline(pipeline, None);
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
pipelines.insert(key, pipeline);
|
||||
}
|
||||
Ok(pipelines)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn create_graphics_pipeline(
|
||||
device: &VulkanLogicalDeviceProbe,
|
||||
render_pass: vk::RenderPass,
|
||||
pipeline_layout: vk::PipelineLayout,
|
||||
extent: (u32, u32),
|
||||
state: LegacyPipelineState,
|
||||
) -> 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(8 * 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::R32G32B32_SFLOAT)
|
||||
.offset(0),
|
||||
vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(1)
|
||||
.format(vk::Format::R32G32B32_SFLOAT)
|
||||
.offset(u32::try_from(3 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
|
||||
vk::VertexInputAttributeDescription::default()
|
||||
.binding(0)
|
||||
.location(2)
|
||||
.format(vk::Format::R32G32_SFLOAT)
|
||||
.offset(u32::try_from(6 * 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(match state.cull {
|
||||
LegacyCullMode::Disabled => vk::CullModeFlags::NONE,
|
||||
LegacyCullMode::BackFace => vk::CullModeFlags::BACK,
|
||||
LegacyCullMode::FrontFace => vk::CullModeFlags::FRONT,
|
||||
})
|
||||
.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 depth_stencil_state = vk::PipelineDepthStencilStateCreateInfo::default()
|
||||
.depth_test_enable(state.depth != LegacyDepthMode::Disabled)
|
||||
.depth_write_enable(state.depth == LegacyDepthMode::TestWrite)
|
||||
.depth_compare_op(vk::CompareOp::LESS_OR_EQUAL)
|
||||
.depth_bounds_test_enable(false)
|
||||
.stencil_test_enable(false);
|
||||
let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default()
|
||||
.color_write_mask(
|
||||
vk::ColorComponentFlags::R
|
||||
| vk::ColorComponentFlags::G
|
||||
| vk::ColorComponentFlags::B
|
||||
| vk::ColorComponentFlags::A,
|
||||
)
|
||||
.blend_enable(state.blend == LegacyBlendMode::SourceAlpha)
|
||||
.src_color_blend_factor(vk::BlendFactor::SRC_ALPHA)
|
||||
.dst_color_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
|
||||
.color_blend_op(vk::BlendOp::ADD)
|
||||
.src_alpha_blend_factor(vk::BlendFactor::ONE)
|
||||
.dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
|
||||
.alpha_blend_op(vk::BlendOp::ADD);
|
||||
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)
|
||||
.depth_stencil_state(&depth_stencil_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,
|
||||
depth_view: vk::ImageView,
|
||||
extent: (u32, u32),
|
||||
) -> Result<vk::Framebuffer, VulkanSmokeRendererError> {
|
||||
let attachments = [image_view, depth_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);
|
||||
}
|
||||
for pipeline in resources.pipelines.into_values() {
|
||||
device.device().destroy_pipeline(pipeline, None);
|
||||
}
|
||||
device
|
||||
.device()
|
||||
.destroy_pipeline_layout(resources.pipeline_layout, None);
|
||||
device.device().destroy_sampler(resources.sampler, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_descriptor_pool(resources.descriptor_pool, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_descriptor_set_layout(resources.descriptor_set_layout, None);
|
||||
device
|
||||
.device()
|
||||
.destroy_render_pass(resources.render_pass, None);
|
||||
destroy_depth_attachment(device, &resources.depth_attachment);
|
||||
for image_view in resources.image_views {
|
||||
device.device().destroy_image_view(image_view, None);
|
||||
}
|
||||
for buffer in resources.readback_buffers {
|
||||
device.device().destroy_buffer(buffer.buffer, None);
|
||||
device.device().free_memory(buffer.memory, 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);
|
||||
}
|
||||
for pipeline in partial.pipelines.into_values() {
|
||||
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(sampler) = partial.sampler {
|
||||
device.device().destroy_sampler(sampler, None);
|
||||
}
|
||||
if let Some(descriptor_pool) = partial.descriptor_pool {
|
||||
device
|
||||
.device()
|
||||
.destroy_descriptor_pool(descriptor_pool, None);
|
||||
}
|
||||
if let Some(descriptor_set_layout) = partial.descriptor_set_layout {
|
||||
device
|
||||
.device()
|
||||
.destroy_descriptor_set_layout(descriptor_set_layout, None);
|
||||
}
|
||||
if let Some(render_pass) = partial.render_pass {
|
||||
device.device().destroy_render_pass(render_pass, None);
|
||||
}
|
||||
if let Some(depth_attachment) = partial.depth_attachment {
|
||||
destroy_depth_attachment(device, &depth_attachment);
|
||||
}
|
||||
for image_view in partial.image_views {
|
||||
device.device().destroy_image_view(image_view, None);
|
||||
}
|
||||
for buffer in partial.readback_buffers {
|
||||
device.device().destroy_buffer(buffer.buffer, None);
|
||||
device.device().free_memory(buffer.memory, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,832 @@
|
||||
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, LegacyPipelineState, 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),
|
||||
pipeline_key: LegacyPipelineState::default().into(),
|
||||
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),
|
||||
pipeline_key: LegacyPipelineState::default().into(),
|
||||
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,
|
||||
image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
|
||||
};
|
||||
|
||||
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 depth_attachment_selection_is_canonical_and_request_scoped() {
|
||||
let supported = [
|
||||
vk::Format::D32_SFLOAT_S8_UINT.as_raw(),
|
||||
vk::Format::D24_UNORM_S8_UINT.as_raw(),
|
||||
vk::Format::D32_SFLOAT.as_raw(),
|
||||
];
|
||||
assert_eq!(
|
||||
select_depth_stencil_attachment_format(
|
||||
&supported,
|
||||
DepthStencilSupport {
|
||||
depth_bits: 24,
|
||||
stencil_bits: 8,
|
||||
},
|
||||
),
|
||||
Some(vk::Format::D24_UNORM_S8_UINT.as_raw())
|
||||
);
|
||||
assert_eq!(
|
||||
select_depth_stencil_attachment_format(
|
||||
&supported,
|
||||
DepthStencilSupport {
|
||||
depth_bits: 32,
|
||||
stencil_bits: 0,
|
||||
},
|
||||
),
|
||||
Some(vk::Format::D32_SFLOAT.as_raw())
|
||||
);
|
||||
assert_eq!(
|
||||
select_depth_stencil_attachment_format(
|
||||
&supported,
|
||||
DepthStencilSupport {
|
||||
depth_bits: 0,
|
||||
stencil_bits: 0,
|
||||
},
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[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);
|
||||
assert_eq!(
|
||||
plan.image_usage,
|
||||
vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swapchain_plan_enables_transfer_source_when_surface_supports_it() {
|
||||
let mut request = swapchain_request();
|
||||
request.capabilities.supported_usage_flags |= vk::ImageUsageFlags::TRANSFER_SRC.as_raw();
|
||||
|
||||
let plan = plan_vulkan_swapchain(&request).expect("swapchain plan");
|
||||
|
||||
assert_eq!(
|
||||
plan.image_usage,
|
||||
(vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_SRC).as_raw()
|
||||
);
|
||||
}
|
||||
|
||||
#[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,\"image_usage\":16}"
|
||||
);
|
||||
|
||||
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, 358);
|
||||
assert_eq!(
|
||||
report.modules[0].sha256,
|
||||
"4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d"
|
||||
);
|
||||
assert_eq!(report.modules[0].descriptor_sets, 0);
|
||||
assert_eq!(report.modules[0].push_constant_bytes, 64);
|
||||
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,
|
||||
"8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b"
|
||||
);
|
||||
assert_eq!(
|
||||
report.manifest_hash,
|
||||
"cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5"
|
||||
);
|
||||
}
|
||||
|
||||
#[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,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
#![deny(unsafe_code)]
|
||||
//! Vulkan adapter public surface.
|
||||
|
||||
mod ffi;
|
||||
mod planning_backend;
|
||||
mod policy;
|
||||
mod shader_manifest;
|
||||
|
||||
pub use ffi::*;
|
||||
pub use planning_backend::*;
|
||||
pub use policy::*;
|
||||
pub use shader_manifest::*;
|
||||
@@ -0,0 +1,168 @@
|
||||
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, Default, Eq, PartialEq)]
|
||||
pub enum VulkanPlanningBackendState {
|
||||
/// Planning facade is configured and able to accept command lists.
|
||||
#[default]
|
||||
Configured,
|
||||
/// Adapter is tracking a recoverable runtime surface/depth pipeline fault.
|
||||
Degraded,
|
||||
/// Adapter has encountered a non-recoverable error.
|
||||
Error,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
image_usage: vk::ImageUsageFlags::COLOR_ATTACHMENT.as_raw(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
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,
|
||||
/// Selected image-usage flags as raw Vulkan bits.
|
||||
pub image_usage: 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),
|
||||
image_usage: select_swapchain_image_usage(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,
|
||||
image_usage: 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,
|
||||
image_usage: plan.image_usage,
|
||||
},
|
||||
"{\"schema\":0,\"extent\":[0,0],\"format\":0,\"color_space\":0,\"present_mode\":0,\"image_count\":0,\"image_usage\":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;
|
||||
}
|
||||
select_depth_stencil_attachment_format(&device.supported_depth_stencil_formats, depth).is_some()
|
||||
}
|
||||
|
||||
fn select_swapchain_image_usage(capabilities: VulkanSwapchainSurfaceCapabilities) -> u32 {
|
||||
let mut usage = vk::ImageUsageFlags::COLOR_ATTACHMENT;
|
||||
let supported = vk::ImageUsageFlags::from_raw(capabilities.supported_usage_flags);
|
||||
if supported.contains(vk::ImageUsageFlags::TRANSFER_SRC) {
|
||||
usage |= vk::ImageUsageFlags::TRANSFER_SRC;
|
||||
}
|
||||
usage.as_raw()
|
||||
}
|
||||
|
||||
/// Selects the first canonical depth/stencil attachment format supported by a device.
|
||||
///
|
||||
/// The order is part of the Windows Vulkan compatibility contract and is shared
|
||||
/// by device admission and future render-pass allocation.
|
||||
#[must_use]
|
||||
pub fn select_depth_stencil_attachment_format(
|
||||
supported_formats: &[i32],
|
||||
depth: DepthStencilSupport,
|
||||
) -> Option<i32> {
|
||||
if depth.depth_bits == 0 && depth.stencil_bits == 0 {
|
||||
return None;
|
||||
}
|
||||
required_depth_stencil_formats(depth)
|
||||
.iter()
|
||||
.map(|format| format.as_raw())
|
||||
.find(|format| supported_formats.contains(format))
|
||||
}
|
||||
|
||||
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 =
|
||||
"fe3721202477220d2d9677b6572d7f3baecb374e94f04eea9d7286ed5e98b6c9";
|
||||
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 =
|
||||
"fb3ed435af48e4fb4a817f160b467f7a561c7c547c87b6f0c1e89f5f58af7329";
|
||||
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: 64,
|
||||
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: 1,
|
||||
push_constant_bytes: 68,
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# ADR-0001: Modular Monolith
|
||||
|
||||
Status: accepted
|
||||
|
||||
FParkan is implemented as one Cargo workspace with local crates grouped by domain. Binaries and adapters compose domain crates; domain crates do not import platform, windowing, OpenGL, GUI, or application packages.
|
||||
@@ -0,0 +1,5 @@
|
||||
# ADR-0002: Behavior Compatibility, Not ABI Compatibility
|
||||
|
||||
Status: accepted
|
||||
|
||||
The project targets clean-room behavior compatibility for formats, resource lookup, loading order, deterministic runtime behavior, and presentation command semantics. It does not reproduce original DLL boundaries, exports, calling conventions, object layouts, RVAs, or native singleton access patterns.
|
||||
@@ -0,0 +1,5 @@
|
||||
# ADR-0003: Raw And Interpreted Data
|
||||
|
||||
Status: accepted
|
||||
|
||||
Legacy data models keep raw bytes distinct from validated structure and interpreted domain views. Writers preserve raw data unless an explicit editing profile requests canonical rebuilding.
|
||||
@@ -0,0 +1,5 @@
|
||||
# ADR-0004: Synthetic And Licensed Tests
|
||||
|
||||
Status: accepted
|
||||
|
||||
Synthetic tests run everywhere and contain no proprietary data. Licensed corpus tests require an explicit local manifest and fail when requested without configuration. Reports contain metrics and fingerprints, not payload dumps or absolute game roots.
|
||||
@@ -0,0 +1,5 @@
|
||||
# ADR-0005: Deterministic Reference Runtime
|
||||
|
||||
Status: accepted
|
||||
|
||||
Stages 0-5 use a single-threaded deterministic reference profile. Stable ordering, explicit ticks, named random streams, and canonical captures are part of the contract. Wall-clock time, pointer addresses, hash iteration order, and GPU handles are not semantic inputs.
|
||||
@@ -0,0 +1,5 @@
|
||||
# ADR-0006: Error Policy
|
||||
|
||||
Status: accepted
|
||||
|
||||
Missing required resources, malformed bytes, unsupported documented branches, capability mismatches, and budget failures are structured errors. Runtime code must not silently skip mandatory objects or convert corrupted data into empty success values.
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR-0007: Safe SDL/OpenGL Boundary
|
||||
|
||||
Status: provisional
|
||||
|
||||
Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained
|
||||
external crates behind a safe project API; local Objective-C/CGL/SDL/OpenGL FFI
|
||||
inside FParkan is not an acceptable implementation strategy.
|
||||
|
||||
The current adapter crates are safe boundary stubs. They compile the intended
|
||||
ports and deterministic command contracts, but they do not create SDL windows,
|
||||
GL contexts, GPU resources, shaders, draw calls, swapchains, or presents. They
|
||||
must not be treated as backend readiness evidence.
|
||||
|
||||
To close the macOS backend requirement, choose and vendor/lock a maintained
|
||||
safe facade stack, then implement:
|
||||
|
||||
- SDL event source, window creation, GL context lifecycle, drawable size and
|
||||
present;
|
||||
- GL shader compile/link, buffer/texture upload, render state, draw calls and
|
||||
diagnostics;
|
||||
- game/viewer composition roots using those adapters;
|
||||
- hidden-window/offscreen macOS smoke tests and licensed local model/terrain
|
||||
frame captures.
|
||||
|
||||
Until those are implemented, Desktop GL evidence may document external probes
|
||||
only; it does not satisfy the permanent adapter requirement.
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "fparkan-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
|
||||
fparkan-corpus = { path = "../../crates/fparkan-corpus", version = "0.1.0" }
|
||||
fparkan-prototype = { path = "../../crates/fparkan-prototype", version = "0.1.0" }
|
||||
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
||||
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
|
||||
fparkan-resource = { path = "../../crates/fparkan-resource", version = "0.1.0" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
|
||||
fparkan-script = { path = "../../crates/fparkan-script", version = "0.1.0" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "fparkan-game"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
|
||||
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
||||
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
|
||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
|
||||
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
|
||||
fparkan-terrain = { path = "../../crates/fparkan-terrain", version = "0.1.0" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
|
||||
fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "fparkan-headless"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
|
||||
fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,267 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` headless runtime entrypoint.
|
||||
|
||||
use fparkan_runtime::{
|
||||
advance_reference_movement, create, load_mission, step_headless, EngineConfig, EngineMode,
|
||||
EngineServices, MissionRequest,
|
||||
};
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use fparkan_world::{InputSnapshot, OriginalObjectId};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
if let Err(err) = run() {
|
||||
eprintln!("{err}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let raw_args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let args = Args::parse(&raw_args)?;
|
||||
let services = if let Some(root) = &args.root {
|
||||
EngineServices::new(Arc::new(DirectoryVfs::new(root)))
|
||||
} else {
|
||||
EngineServices::default()
|
||||
};
|
||||
let mut engine = create(
|
||||
EngineConfig {
|
||||
mode: EngineMode::Headless,
|
||||
},
|
||||
services,
|
||||
)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
if let Some(mission) = args.mission {
|
||||
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
println!(
|
||||
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} script_init_states={} script_varset_states={} graph_failures={}",
|
||||
loaded.object_count,
|
||||
loaded.areal_count,
|
||||
loaded.surface_count,
|
||||
loaded.graph_root_count,
|
||||
loaded.graph_unit_component_count,
|
||||
loaded.graph_wear_resolved_count,
|
||||
loaded.graph_material_resolved_count,
|
||||
loaded.graph_texture_resolved_count,
|
||||
loaded.graph_lightmap_resolved_count,
|
||||
loaded.script_bundle_count,
|
||||
loaded.script_event_count,
|
||||
loaded.script_varset_declaration_count,
|
||||
loaded.script_init_state_count,
|
||||
loaded.script_varset_state_count,
|
||||
loaded.graph_failure_count
|
||||
);
|
||||
}
|
||||
if let Some(movement) = args.reference_movement {
|
||||
let reached = advance_reference_movement(
|
||||
&mut engine,
|
||||
OriginalObjectId(movement.original_id),
|
||||
movement.target_xy,
|
||||
movement.max_step,
|
||||
)
|
||||
.map_err(|err| format!("{err}"))?;
|
||||
println!(
|
||||
"reference_movement original_id={} target_xy=[{},{}] max_step={} reached={reached}",
|
||||
movement.original_id, movement.target_xy[0], movement.target_xy[1], movement.max_step,
|
||||
);
|
||||
}
|
||||
let mut last = None;
|
||||
for _ in 0..args.ticks {
|
||||
last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
|
||||
}
|
||||
if let Some(frame) = last {
|
||||
println!(
|
||||
"tick={} hash={:02x?}",
|
||||
frame.snapshot.tick.0, frame.snapshot.hash.0
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Args {
|
||||
root: Option<PathBuf>,
|
||||
mission: Option<String>,
|
||||
ticks: u64,
|
||||
reference_movement: Option<ReferenceMovement>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct ReferenceMovement {
|
||||
original_id: u32,
|
||||
target_xy: [f32; 2],
|
||||
max_step: f32,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
fn parse(args: &[String]) -> Result<Self, String> {
|
||||
let mut parsed = Self {
|
||||
root: None,
|
||||
mission: None,
|
||||
ticks: 1,
|
||||
reference_movement: None,
|
||||
};
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--root" => {
|
||||
parsed.root = Some(
|
||||
iter.next()
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| "--root requires a path".to_string())?,
|
||||
);
|
||||
}
|
||||
"--mission" => {
|
||||
parsed.mission = Some(
|
||||
iter.next()
|
||||
.cloned()
|
||||
.ok_or_else(|| "--mission requires a path".to_string())?,
|
||||
);
|
||||
}
|
||||
"--ticks" => {
|
||||
parsed.ticks = iter
|
||||
.next()
|
||||
.ok_or_else(|| "--ticks requires a value".to_string())?
|
||||
.parse()
|
||||
.map_err(|_| "--ticks must be an integer".to_string())?;
|
||||
}
|
||||
"--move-object" => {
|
||||
if parsed.reference_movement.is_some() {
|
||||
return Err("--move-object may be specified once".to_string());
|
||||
}
|
||||
let original_id = iter
|
||||
.next()
|
||||
.ok_or_else(|| "--move-object requires an original object id".to_string())?
|
||||
.parse()
|
||||
.map_err(|_| {
|
||||
"--move-object object id must be an unsigned integer".to_string()
|
||||
})?;
|
||||
let x = parse_finite_argument(
|
||||
iter.next(),
|
||||
"--move-object requires a finite X target",
|
||||
)?;
|
||||
let y = parse_finite_argument(
|
||||
iter.next(),
|
||||
"--move-object requires a finite Y target",
|
||||
)?;
|
||||
let max_step = parse_finite_argument(
|
||||
iter.next(),
|
||||
"--move-object requires a finite positive maximum step",
|
||||
)?;
|
||||
if max_step <= 0.0 {
|
||||
return Err("--move-object maximum step must be positive".to_string());
|
||||
}
|
||||
parsed.reference_movement = Some(ReferenceMovement {
|
||||
original_id,
|
||||
target_xy: [x, y],
|
||||
max_step,
|
||||
});
|
||||
}
|
||||
_ => return Err(usage()),
|
||||
}
|
||||
}
|
||||
if parsed.mission.is_some() && parsed.root.is_none() {
|
||||
return Err("--mission requires --root".to_string());
|
||||
}
|
||||
if parsed.reference_movement.is_some() && parsed.mission.is_none() {
|
||||
return Err("--move-object requires --mission".to_string());
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_finite_argument(value: Option<&String>, error: &str) -> Result<f32, String> {
|
||||
let value: f32 = value
|
||||
.ok_or_else(|| error.to_string())?
|
||||
.parse()
|
||||
.map_err(|_| error.to_string())?;
|
||||
value
|
||||
.is_finite()
|
||||
.then_some(value)
|
||||
.ok_or_else(|| error.to_string())
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: fparkan-headless [--root <path> --mission <path>] [--move-object <original-id> <x> <y> <max-step>] [--ticks <n>]".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn args(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_object_parses_a_single_finite_reference_command() {
|
||||
let parsed = Args::parse(&args(&[
|
||||
"--root",
|
||||
"C:/game",
|
||||
"--mission",
|
||||
"MISSIONS/Autodemo.00/data.tma",
|
||||
"--move-object",
|
||||
"7",
|
||||
"12.5",
|
||||
"-3",
|
||||
"0.25",
|
||||
"--ticks",
|
||||
"2",
|
||||
]))
|
||||
.expect("args");
|
||||
assert_eq!(parsed.ticks, 2);
|
||||
assert_eq!(
|
||||
parsed.reference_movement,
|
||||
Some(ReferenceMovement {
|
||||
original_id: 7,
|
||||
target_xy: [12.5, -3.0],
|
||||
max_step: 0.25,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_object_requires_a_loaded_mission_and_valid_step() {
|
||||
assert_eq!(
|
||||
Args::parse(&args(&["--move-object", "7", "1", "2", "1"])).expect_err("mission"),
|
||||
"--move-object requires --mission"
|
||||
);
|
||||
assert_eq!(
|
||||
Args::parse(&args(&[
|
||||
"--root",
|
||||
"C:/game",
|
||||
"--mission",
|
||||
"M/data.tma",
|
||||
"--move-object",
|
||||
"7",
|
||||
"1",
|
||||
"2",
|
||||
"0",
|
||||
]))
|
||||
.expect_err("step"),
|
||||
"--move-object maximum step must be positive"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fparkan-viewer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,350 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||
//! `FParkan` asset inspection composition root.
|
||||
|
||||
use fparkan_inspection::{
|
||||
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
|
||||
LandFileKind, MapInspection, NresEntrySummary,
|
||||
};
|
||||
use fparkan_render::{
|
||||
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
|
||||
RenderProfile, RenderSnapshot, RenderSnapshotDraw,
|
||||
};
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
let code = match run(&args) {
|
||||
Ok(json) => {
|
||||
println!("{json}");
|
||||
0
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("{err}");
|
||||
2
|
||||
}
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<String, String> {
|
||||
match args {
|
||||
[domain, rest @ ..] if domain == "archive" => inspect_archive(rest),
|
||||
[domain, rest @ ..] if domain == "model" => inspect_model(rest),
|
||||
[domain, rest @ ..] if domain == "texture" => inspect_texture(rest),
|
||||
[domain, rest @ ..] if domain == "map" => inspect_map(rest),
|
||||
_ => Err(usage()),
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_archive(args: &[String]) -> Result<String, String> {
|
||||
let file = parse_file(args)?;
|
||||
let limit = parse_limit(args)?;
|
||||
let inspection = fparkan_inspection::inspect_archive_file(&file, limit)?;
|
||||
|
||||
match inspection {
|
||||
ArchiveInspection::Nres {
|
||||
entries,
|
||||
lookup_order_valid,
|
||||
sample,
|
||||
} => Ok(format!(
|
||||
"{{\"report_kind\":\"archive-inspection\",\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
|
||||
json_string(&file.display().to_string()),
|
||||
entries,
|
||||
lookup_order_valid,
|
||||
render_nres_entries(&sample)
|
||||
)),
|
||||
ArchiveInspection::Rsli { entries } => Ok(format!(
|
||||
"{{\"report_kind\":\"archive-inspection\",\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
|
||||
json_string(&file.display().to_string()),
|
||||
entries
|
||||
)),
|
||||
ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", file.display())),
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_model(args: &[String]) -> Result<String, String> {
|
||||
if let Some(fixture) = parse_option(args, &["--fixture"]) {
|
||||
return ViewerModelService::inspect_synthetic_model(&fixture);
|
||||
}
|
||||
|
||||
let query = parse_resource_query(args)?;
|
||||
let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
|
||||
|
||||
Ok(format!(
|
||||
"{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
|
||||
json_string(&query.archive),
|
||||
json_string(&query.name),
|
||||
inspection.streams,
|
||||
inspection.nodes,
|
||||
inspection.slots,
|
||||
inspection.positions,
|
||||
inspection.indices,
|
||||
inspection.batches
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct ViewerModelService;
|
||||
|
||||
impl ViewerModelService {
|
||||
fn inspect_synthetic_model(fixture: &str) -> Result<String, String> {
|
||||
if fixture != "synthetic/model-basic" {
|
||||
return Err(format!("unknown model fixture: {fixture}"));
|
||||
}
|
||||
|
||||
let snapshot = RenderSnapshot {
|
||||
camera: CameraSnapshot::default(),
|
||||
draws: vec![RenderSnapshotDraw {
|
||||
id: DrawId(1),
|
||||
phase: RenderPhase::Opaque,
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(1),
|
||||
material_slots: vec![GpuMaterialId(7)],
|
||||
material_index: 0,
|
||||
pipeline_state: fparkan_render::LegacyPipelineState::default(),
|
||||
transform: identity_transform(),
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order: 0,
|
||||
}],
|
||||
};
|
||||
let commands = build_commands(&snapshot, RenderProfile::default())
|
||||
.map_err(|err| format!("render command generation: {err}"))?;
|
||||
let draw_commands = commands
|
||||
.commands
|
||||
.iter()
|
||||
.filter(|command| matches!(command, fparkan_render::RenderCommand::Draw(_)))
|
||||
.count();
|
||||
|
||||
Ok(format!(
|
||||
"{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model-inspection\",\"draw_commands\":{draw_commands}}}",
|
||||
json_string(fixture)
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_texture(args: &[String]) -> Result<String, String> {
|
||||
let query = parse_resource_query(args)?;
|
||||
let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
|
||||
|
||||
Ok(format!(
|
||||
"{{\"report_kind\":\"texture-inspection\",\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
|
||||
json_string(&query.archive),
|
||||
json_string(&query.name),
|
||||
inspection.width,
|
||||
inspection.height,
|
||||
json_string(&inspection.format),
|
||||
inspection.mips,
|
||||
inspection.pages
|
||||
))
|
||||
}
|
||||
|
||||
fn inspect_map(args: &[String]) -> Result<String, String> {
|
||||
let file = parse_file(args)?;
|
||||
let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?;
|
||||
let inspection = inspect_land_file(
|
||||
&file,
|
||||
match kind.as_str() {
|
||||
"land-msh" => LandFileKind::LandMsh,
|
||||
"land-map" => LandFileKind::LandMap,
|
||||
_ => return Err(format!("unknown map kind: {kind}")),
|
||||
},
|
||||
)?;
|
||||
|
||||
Ok(render_map_inspection_json(
|
||||
&file.display().to_string(),
|
||||
&kind,
|
||||
&inspection,
|
||||
))
|
||||
}
|
||||
|
||||
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
|
||||
match kind {
|
||||
"land-msh" => format!(
|
||||
"{{\"report_kind\":\"map-inspection\",\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
|
||||
json_string(path),
|
||||
inspection.streams,
|
||||
inspection.positions,
|
||||
inspection.faces,
|
||||
inspection.slots
|
||||
),
|
||||
"land-map" => format!(
|
||||
"{{\"report_kind\":\"map-inspection\",\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
|
||||
json_string(path),
|
||||
inspection.areals,
|
||||
inspection.declared_areals,
|
||||
inspection.grid_width,
|
||||
inspection.grid_height
|
||||
),
|
||||
_ => unreachable!("invalid land kind: {kind}"),
|
||||
}
|
||||
}
|
||||
|
||||
struct ResourceQuery {
|
||||
root: PathBuf,
|
||||
archive: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> {
|
||||
Ok(ResourceQuery {
|
||||
root: parse_path_option(args, &["--root", "--game-root"], "--root")?,
|
||||
archive: parse_option(args, &["--archive"])
|
||||
.ok_or_else(|| "missing --archive".to_string())?,
|
||||
name: parse_option(args, &["--name"]).ok_or_else(|| "missing --name".to_string())?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_file(args: &[String]) -> Result<PathBuf, String> {
|
||||
parse_path_option(args, &["--file"], "--file")
|
||||
}
|
||||
|
||||
fn parse_limit(args: &[String]) -> Result<usize, String> {
|
||||
parse_option(args, &["--limit"])
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<usize>()
|
||||
.map_err(|_| format!("invalid --limit: {value}"))
|
||||
})
|
||||
.transpose()
|
||||
.map(|value| value.unwrap_or(0))
|
||||
}
|
||||
|
||||
fn render_nres_entries(entries: &[NresEntrySummary]) -> String {
|
||||
let mut out = String::new();
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
let name = &entry.name;
|
||||
let _ = write!(
|
||||
out,
|
||||
"{{\"name\":{},\"type\":{},\"size\":{}}}",
|
||||
json_string(name),
|
||||
entry.type_id,
|
||||
entry.data_size
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_path_option(args: &[String], names: &[&str], label: &str) -> Result<PathBuf, String> {
|
||||
parse_option(args, names)
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| format!("missing {label}"))
|
||||
}
|
||||
|
||||
fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
if names.iter().any(|name| arg == name) {
|
||||
return iter.next().cloned();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn json_string(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
out.push('"');
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if c.is_control() => {
|
||||
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
fn identity_transform() -> [f32; 16] {
|
||||
[
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
||||
]
|
||||
}
|
||||
|
||||
fn usage() -> String {
|
||||
"usage: fparkan-viewer archive --file <archive> [--limit N] | model --root <game-root> --archive <archive> --name <msh> | model --fixture synthetic/model-basic | texture --root <game-root> --archive <archive> --name <texm> | map --file <Land.msh|Land.map> --kind land-msh|land-map".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn strings(values: &[&str]) -> Vec<String> {
|
||||
values.iter().map(|value| (*value).to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_resource_query() -> Result<(), String> {
|
||||
let query = parse_resource_query(&strings(&[
|
||||
"--root",
|
||||
"testdata/IS",
|
||||
"--archive",
|
||||
"textures.lib",
|
||||
"--name",
|
||||
"grass.tex",
|
||||
]))?;
|
||||
|
||||
assert_eq!(query.root, PathBuf::from("testdata/IS"));
|
||||
assert_eq!(query.archive, "textures.lib");
|
||||
assert_eq!(query.name, "grass.tex");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_string_escapes_controls() {
|
||||
assert_eq!(json_string("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usage_rejects_empty_args() {
|
||||
assert_eq!(run(&[]), Err(usage()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_limit() {
|
||||
assert_eq!(parse_limit(&strings(&["--limit", "2"])), Ok(2));
|
||||
assert_eq!(parse_limit(&[]), Ok(0));
|
||||
assert_eq!(
|
||||
parse_limit(&strings(&["--limit", "x"])),
|
||||
Err("invalid --limit: x".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> {
|
||||
assert_eq!(
|
||||
run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?,
|
||||
"{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model-inspection\",\"draw_commands\":1}"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "fparkan-vulkan-smoke"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
build = "build.rs"
|
||||
|
||||
[dependencies]
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
|
||||
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
|
||||
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" }
|
||||
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -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
@@ -1,6 +0,0 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
@@ -1,44 +0,0 @@
|
||||
use std::io;
|
||||
|
||||
/// Resource payload that can be either borrowed from mapped bytes or owned.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum ResourceData<'a> {
|
||||
Borrowed(&'a [u8]),
|
||||
Owned(Vec<u8>),
|
||||
}
|
||||
|
||||
impl<'a> ResourceData<'a> {
|
||||
pub fn as_slice(&self) -> &[u8] {
|
||||
match self {
|
||||
Self::Borrowed(slice) => slice,
|
||||
Self::Owned(buf) => buf.as_slice(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn into_owned(self) -> Vec<u8> {
|
||||
match self {
|
||||
Self::Borrowed(slice) => slice.to_vec(),
|
||||
Self::Owned(buf) => buf,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<[u8]> for ResourceData<'_> {
|
||||
fn as_ref(&self) -> &[u8] {
|
||||
self.as_slice()
|
||||
}
|
||||
}
|
||||
|
||||
/// Output sink used by `read_into`/`load_into` APIs.
|
||||
pub trait OutputBuffer {
|
||||
/// Writes the full payload to the sink, replacing any previous content.
|
||||
fn write_exact(&mut self, data: &[u8]) -> io::Result<()>;
|
||||
}
|
||||
|
||||
impl OutputBuffer for Vec<u8> {
|
||||
fn write_exact(&mut self, data: &[u8]) -> io::Result<()> {
|
||||
self.clear();
|
||||
self.extend_from_slice(data);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-animation"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "fparkan-assets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
|
||||
fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
|
||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
||||
fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
|
||||
fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-binary"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,519 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Bounded little-endian binary cursor and checked layout helpers.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// SHA-256 digest bytes.
|
||||
pub type Sha256Digest = [u8; 32];
|
||||
|
||||
/// Parser limits shared by binary formats.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
/// Maximum file bytes.
|
||||
pub max_file_bytes: u64,
|
||||
/// Maximum entries.
|
||||
pub max_entries: u32,
|
||||
/// Maximum string bytes.
|
||||
pub max_string_bytes: u32,
|
||||
/// Maximum array items.
|
||||
pub max_array_items: u32,
|
||||
/// Maximum recursion depth.
|
||||
pub max_recursion_depth: u16,
|
||||
/// Maximum decoded bytes.
|
||||
pub max_decoded_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_file_bytes: 256 * 1024 * 1024,
|
||||
max_entries: 1_000_000,
|
||||
max_string_bytes: 64 * 1024,
|
||||
max_array_items: 1_000_000,
|
||||
max_recursion_depth: 64,
|
||||
max_decoded_bytes: 512 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum DecodeError {
|
||||
/// Input ended before requested bytes.
|
||||
UnexpectedEof {
|
||||
/// Offset where read was attempted.
|
||||
offset: u64,
|
||||
/// Required byte count.
|
||||
needed: u64,
|
||||
/// Remaining byte count.
|
||||
remaining: u64,
|
||||
},
|
||||
/// Arithmetic overflow.
|
||||
IntegerOverflow,
|
||||
/// Count exceeds limit.
|
||||
LimitExceeded {
|
||||
/// Declared count.
|
||||
count: u64,
|
||||
/// Configured limit.
|
||||
limit: u64,
|
||||
},
|
||||
/// Cursor did not end at EOF.
|
||||
TrailingBytes {
|
||||
/// Offset where EOF was expected.
|
||||
offset: u64,
|
||||
/// Remaining byte count.
|
||||
remaining: u64,
|
||||
},
|
||||
/// Invalid data.
|
||||
Invalid(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for DecodeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::UnexpectedEof {
|
||||
offset,
|
||||
needed,
|
||||
remaining,
|
||||
} => write!(
|
||||
f,
|
||||
"unexpected EOF at {offset}: need {needed}, have {remaining}"
|
||||
),
|
||||
Self::IntegerOverflow => write!(f, "integer overflow"),
|
||||
Self::LimitExceeded { count, limit } => {
|
||||
write!(f, "count {count} exceeds limit {limit}")
|
||||
}
|
||||
Self::TrailingBytes { offset, remaining } => {
|
||||
write!(f, "trailing bytes at {offset}: {remaining}")
|
||||
}
|
||||
Self::Invalid(reason) => write!(f, "invalid data: {reason}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DecodeError {}
|
||||
|
||||
/// Cursor checkpoint.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Checkpoint(pub u64);
|
||||
|
||||
/// Bounded cursor.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cursor<'a> {
|
||||
bytes: &'a [u8],
|
||||
offset: usize,
|
||||
}
|
||||
|
||||
impl<'a> Cursor<'a> {
|
||||
/// Creates a cursor.
|
||||
#[must_use]
|
||||
pub fn new(bytes: &'a [u8]) -> Self {
|
||||
Self { bytes, offset: 0 }
|
||||
}
|
||||
|
||||
/// Current offset.
|
||||
#[must_use]
|
||||
pub fn offset(&self) -> u64 {
|
||||
self.offset as u64
|
||||
}
|
||||
|
||||
/// Remaining bytes.
|
||||
#[must_use]
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.bytes.len().saturating_sub(self.offset)
|
||||
}
|
||||
|
||||
/// Creates a checkpoint.
|
||||
#[must_use]
|
||||
pub fn checkpoint(&self) -> Checkpoint {
|
||||
Checkpoint(self.offset())
|
||||
}
|
||||
|
||||
/// Reads exact bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError::IntegerOverflow`] if the requested end offset
|
||||
/// overflows, or [`DecodeError::UnexpectedEof`] if there are not enough
|
||||
/// bytes remaining.
|
||||
pub fn read_exact(&mut self, len: usize) -> Result<&'a [u8], DecodeError> {
|
||||
let end = self
|
||||
.offset
|
||||
.checked_add(len)
|
||||
.ok_or(DecodeError::IntegerOverflow)?;
|
||||
if end > self.bytes.len() {
|
||||
return Err(DecodeError::UnexpectedEof {
|
||||
offset: self.offset(),
|
||||
needed: len as u64,
|
||||
remaining: self.remaining() as u64,
|
||||
});
|
||||
}
|
||||
let out = &self.bytes[self.offset..end];
|
||||
self.offset = end;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reads a little-endian u16.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError`] if two bytes cannot be read.
|
||||
pub fn read_u16_le(&mut self) -> Result<u16, DecodeError> {
|
||||
let b = self.read_exact(2)?;
|
||||
Ok(u16::from_le_bytes([b[0], b[1]]))
|
||||
}
|
||||
|
||||
/// Reads a little-endian u32.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError`] if four bytes cannot be read.
|
||||
pub fn read_u32_le(&mut self) -> Result<u32, DecodeError> {
|
||||
let b = self.read_exact(4)?;
|
||||
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
/// Reads a little-endian i32.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError`] if four bytes cannot be read.
|
||||
pub fn read_i32_le(&mut self) -> Result<i32, DecodeError> {
|
||||
let b = self.read_exact(4)?;
|
||||
Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||
}
|
||||
|
||||
/// Reads a little-endian f32.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError`] if four bytes cannot be read.
|
||||
pub fn read_f32_le(&mut self) -> Result<f32, DecodeError> {
|
||||
Ok(f32::from_bits(self.read_u32_le()?))
|
||||
}
|
||||
|
||||
/// Requires exact EOF.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError::TrailingBytes`] when unread bytes remain.
|
||||
pub fn require_eof(&self) -> Result<(), DecodeError> {
|
||||
if self.remaining() == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(DecodeError::TrailingBytes {
|
||||
offset: self.offset(),
|
||||
remaining: self.remaining() as u64,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates `count * stride <= remaining` and returns bytes as usize.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError::IntegerOverflow`] on arithmetic or conversion
|
||||
/// overflow, or [`DecodeError::UnexpectedEof`] when the declared byte count is
|
||||
/// larger than the remaining bounded input.
|
||||
pub fn checked_count_bytes(count: u64, stride: u64, remaining: u64) -> Result<usize, DecodeError> {
|
||||
let bytes = count
|
||||
.checked_mul(stride)
|
||||
.ok_or(DecodeError::IntegerOverflow)?;
|
||||
if bytes > remaining {
|
||||
return Err(DecodeError::UnexpectedEof {
|
||||
offset: 0,
|
||||
needed: bytes,
|
||||
remaining,
|
||||
});
|
||||
}
|
||||
usize::try_from(bytes).map_err(|_| DecodeError::IntegerOverflow)
|
||||
}
|
||||
|
||||
/// Validates a declared allocation size before constructing the allocation.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError::LimitExceeded`] when `declared` is larger than
|
||||
/// `limit`, or [`DecodeError::IntegerOverflow`] when the accepted size cannot
|
||||
/// be represented by the host `usize`.
|
||||
pub fn checked_allocation_len(declared: u64, limit: u64) -> Result<usize, DecodeError> {
|
||||
if declared > limit {
|
||||
return Err(DecodeError::LimitExceeded {
|
||||
count: declared,
|
||||
limit,
|
||||
});
|
||||
}
|
||||
usize::try_from(declared).map_err(|_| DecodeError::IntegerOverflow)
|
||||
}
|
||||
|
||||
/// Reads length-prefixed bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`DecodeError`] if the length cannot be read, exceeds `max`, or the
|
||||
/// declared payload is truncated.
|
||||
pub fn read_lp_bytes(cursor: &mut Cursor<'_>, max: u32) -> Result<Vec<u8>, DecodeError> {
|
||||
let len = cursor.read_u32_le()?;
|
||||
if len > max {
|
||||
return Err(DecodeError::LimitExceeded {
|
||||
count: u64::from(len),
|
||||
limit: u64::from(max),
|
||||
});
|
||||
}
|
||||
let len = checked_allocation_len(u64::from(len), u64::from(max))?;
|
||||
Ok(cursor.read_exact(len)?.to_vec())
|
||||
}
|
||||
|
||||
/// Computes a SHA-256 content digest without external dependencies.
|
||||
#[must_use]
|
||||
pub fn sha256(bytes: &[u8]) -> Sha256Digest {
|
||||
const K: [u32; 64] = [
|
||||
0x428a_2f98,
|
||||
0x7137_4491,
|
||||
0xb5c0_fbcf,
|
||||
0xe9b5_dba5,
|
||||
0x3956_c25b,
|
||||
0x59f1_11f1,
|
||||
0x923f_82a4,
|
||||
0xab1c_5ed5,
|
||||
0xd807_aa98,
|
||||
0x1283_5b01,
|
||||
0x2431_85be,
|
||||
0x550c_7dc3,
|
||||
0x72be_5d74,
|
||||
0x80de_b1fe,
|
||||
0x9bdc_06a7,
|
||||
0xc19b_f174,
|
||||
0xe49b_69c1,
|
||||
0xefbe_4786,
|
||||
0x0fc1_9dc6,
|
||||
0x240c_a1cc,
|
||||
0x2de9_2c6f,
|
||||
0x4a74_84aa,
|
||||
0x5cb0_a9dc,
|
||||
0x76f9_88da,
|
||||
0x983e_5152,
|
||||
0xa831_c66d,
|
||||
0xb003_27c8,
|
||||
0xbf59_7fc7,
|
||||
0xc6e0_0bf3,
|
||||
0xd5a7_9147,
|
||||
0x06ca_6351,
|
||||
0x1429_2967,
|
||||
0x27b7_0a85,
|
||||
0x2e1b_2138,
|
||||
0x4d2c_6dfc,
|
||||
0x5338_0d13,
|
||||
0x650a_7354,
|
||||
0x766a_0abb,
|
||||
0x81c2_c92e,
|
||||
0x9272_2c85,
|
||||
0xa2bf_e8a1,
|
||||
0xa81a_664b,
|
||||
0xc24b_8b70,
|
||||
0xc76c_51a3,
|
||||
0xd192_e819,
|
||||
0xd699_0624,
|
||||
0xf40e_3585,
|
||||
0x106a_a070,
|
||||
0x19a4_c116,
|
||||
0x1e37_6c08,
|
||||
0x2748_774c,
|
||||
0x34b0_bcb5,
|
||||
0x391c_0cb3,
|
||||
0x4ed8_aa4a,
|
||||
0x5b9c_ca4f,
|
||||
0x682e_6ff3,
|
||||
0x748f_82ee,
|
||||
0x78a5_636f,
|
||||
0x84c8_7814,
|
||||
0x8cc7_0208,
|
||||
0x90be_fffa,
|
||||
0xa450_6ceb,
|
||||
0xbef9_a3f7,
|
||||
0xc671_78f2,
|
||||
];
|
||||
let mut h = [
|
||||
0x6a09_e667,
|
||||
0xbb67_ae85,
|
||||
0x3c6e_f372,
|
||||
0xa54f_f53a,
|
||||
0x510e_527f,
|
||||
0x9b05_688c,
|
||||
0x1f83_d9ab,
|
||||
0x5be0_cd19,
|
||||
];
|
||||
|
||||
let bit_len = (bytes.len() as u64).wrapping_mul(8);
|
||||
let mut chunks = bytes.chunks_exact(64);
|
||||
for chunk in &mut chunks {
|
||||
compress_sha256_chunk(&mut h, chunk, &K);
|
||||
}
|
||||
|
||||
let tail = chunks.remainder();
|
||||
let mut block = [0u8; 128];
|
||||
block[..tail.len()].copy_from_slice(tail);
|
||||
block[tail.len()] = 0x80;
|
||||
let padded_len = if tail.len() < 56 { 64 } else { 128 };
|
||||
block[padded_len - 8..padded_len].copy_from_slice(&bit_len.to_be_bytes());
|
||||
for chunk in block[..padded_len].chunks_exact(64) {
|
||||
compress_sha256_chunk(&mut h, chunk, &K);
|
||||
}
|
||||
|
||||
let mut out = [0u8; 32];
|
||||
for (idx, word) in h.iter().enumerate() {
|
||||
out[idx * 4..idx * 4 + 4].copy_from_slice(&word.to_be_bytes());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders a SHA-256 digest as lowercase hexadecimal.
|
||||
#[must_use]
|
||||
pub fn sha256_hex(digest: &Sha256Digest) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(64);
|
||||
for byte in digest {
|
||||
out.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(clippy::many_single_char_names)]
|
||||
fn compress_sha256_chunk(h: &mut [u32; 8], chunk: &[u8], k: &[u32; 64]) {
|
||||
let mut w = [0u32; 64];
|
||||
for (idx, word) in w.iter_mut().take(16).enumerate() {
|
||||
let base = idx * 4;
|
||||
*word = u32::from_be_bytes([
|
||||
chunk[base],
|
||||
chunk[base + 1],
|
||||
chunk[base + 2],
|
||||
chunk[base + 3],
|
||||
]);
|
||||
}
|
||||
for idx in 16..64 {
|
||||
let s0 = w[idx - 15].rotate_right(7) ^ w[idx - 15].rotate_right(18) ^ (w[idx - 15] >> 3);
|
||||
let s1 = w[idx - 2].rotate_right(17) ^ w[idx - 2].rotate_right(19) ^ (w[idx - 2] >> 10);
|
||||
w[idx] = w[idx - 16]
|
||||
.wrapping_add(s0)
|
||||
.wrapping_add(w[idx - 7])
|
||||
.wrapping_add(s1);
|
||||
}
|
||||
|
||||
let mut a = h[0];
|
||||
let mut b = h[1];
|
||||
let mut c = h[2];
|
||||
let mut d = h[3];
|
||||
let mut e = h[4];
|
||||
let mut f = h[5];
|
||||
let mut g = h[6];
|
||||
let mut hh = h[7];
|
||||
|
||||
for idx in 0..64 {
|
||||
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
|
||||
let ch = (e & f) ^ ((!e) & g);
|
||||
let temp1 = hh
|
||||
.wrapping_add(s1)
|
||||
.wrapping_add(ch)
|
||||
.wrapping_add(k[idx])
|
||||
.wrapping_add(w[idx]);
|
||||
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
|
||||
let maj = (a & b) ^ (a & c) ^ (b & c);
|
||||
let temp2 = s0.wrapping_add(maj);
|
||||
|
||||
hh = g;
|
||||
g = f;
|
||||
f = e;
|
||||
e = d.wrapping_add(temp1);
|
||||
d = c;
|
||||
c = b;
|
||||
b = a;
|
||||
a = temp1.wrapping_add(temp2);
|
||||
}
|
||||
|
||||
h[0] = h[0].wrapping_add(a);
|
||||
h[1] = h[1].wrapping_add(b);
|
||||
h[2] = h[2].wrapping_add(c);
|
||||
h[3] = h[3].wrapping_add(d);
|
||||
h[4] = h[4].wrapping_add(e);
|
||||
h[5] = h[5].wrapping_add(f);
|
||||
h[6] = h[6].wrapping_add(g);
|
||||
h[7] = h[7].wrapping_add(hh);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_count_stride_overflow() {
|
||||
assert_eq!(
|
||||
checked_count_bytes(u64::MAX, 2, u64::MAX),
|
||||
Err(DecodeError::IntegerOverflow)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_eof_reports_trailing() {
|
||||
let mut cursor = Cursor::new(&[1, 2]);
|
||||
assert_eq!(cursor.read_exact(1).expect("byte"), &[1]);
|
||||
assert!(matches!(
|
||||
cursor.require_eof(),
|
||||
Err(DecodeError::TrailingBytes { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_oversized_declared_allocation_before_read() {
|
||||
assert_eq!(
|
||||
checked_allocation_len(1025, 1024),
|
||||
Err(DecodeError::LimitExceeded {
|
||||
count: 1025,
|
||||
limit: 1024
|
||||
})
|
||||
);
|
||||
|
||||
let bytes = 2048u32.to_le_bytes();
|
||||
let mut cursor = Cursor::new(&bytes);
|
||||
assert_eq!(
|
||||
read_lp_bytes(&mut cursor, 1024),
|
||||
Err(DecodeError::LimitExceeded {
|
||||
count: 2048,
|
||||
limit: 1024
|
||||
})
|
||||
);
|
||||
assert_eq!(cursor.offset(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_matches_known_vectors() {
|
||||
assert_eq!(
|
||||
sha256_hex(&sha256(b"")),
|
||||
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
);
|
||||
assert_eq!(
|
||||
sha256_hex(&sha256(b"abc")),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
[package]
|
||||
name = "fparkan-corpus"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
fparkan-fx = { path = "../fparkan-fx", version = "0.1.0" }
|
||||
fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
|
||||
fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
|
||||
fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
|
||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
|
||||
fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fparkan-diagnostics"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,249 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Structured diagnostics shared by `FParkan` crates.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Diagnostic severity.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Severity {
|
||||
/// Informational note.
|
||||
Info,
|
||||
/// Recoverable warning.
|
||||
Warning,
|
||||
/// Error for the current operation.
|
||||
Error,
|
||||
/// Fatal error for the current run.
|
||||
Fatal,
|
||||
}
|
||||
|
||||
/// Evidence level for a contract or interpretation.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EvidenceStatus {
|
||||
/// Described by project documentation.
|
||||
Documented,
|
||||
/// Verified by synthetic fixtures.
|
||||
SyntheticVerified,
|
||||
/// Verified against the licensed corpus.
|
||||
CorpusVerified,
|
||||
/// Verified by runtime capture.
|
||||
RuntimeCaptured,
|
||||
/// Working hypothesis; not a runtime contract.
|
||||
Hypothesis,
|
||||
}
|
||||
|
||||
/// Operation phase where a diagnostic was produced.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Phase {
|
||||
/// Discovery.
|
||||
Discover,
|
||||
/// Read.
|
||||
Read,
|
||||
/// Parse.
|
||||
Parse,
|
||||
/// Validate.
|
||||
Validate,
|
||||
/// Resolve.
|
||||
Resolve,
|
||||
/// Prepare.
|
||||
Prepare,
|
||||
/// Construct.
|
||||
Construct,
|
||||
/// Register.
|
||||
Register,
|
||||
/// Simulate.
|
||||
Simulate,
|
||||
/// Render.
|
||||
Render,
|
||||
}
|
||||
|
||||
/// Byte span in an input source.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct SourceSpan {
|
||||
/// Start offset.
|
||||
pub offset: u64,
|
||||
/// Length in bytes.
|
||||
pub length: u64,
|
||||
}
|
||||
|
||||
/// Stable diagnostic code.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
|
||||
pub struct DiagnosticCode(pub &'static str);
|
||||
|
||||
/// Context attached to a diagnostic.
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
|
||||
pub struct DiagnosticContext {
|
||||
/// Phase.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phase: Option<Phase>,
|
||||
/// Redacted or logical path.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
/// Archive entry name.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub archive_entry: Option<String>,
|
||||
/// Object/prototype key.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub object_key: Option<String>,
|
||||
/// Input span.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub span: Option<SourceSpan>,
|
||||
}
|
||||
|
||||
/// Structured diagnostic with cause chain.
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct Diagnostic {
|
||||
/// Stable code.
|
||||
pub code: DiagnosticCode,
|
||||
/// Severity.
|
||||
pub severity: Severity,
|
||||
/// Human message.
|
||||
pub message: String,
|
||||
/// Context.
|
||||
pub context: DiagnosticContext,
|
||||
/// Causes.
|
||||
pub causes: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
/// Creates a diagnostic with default error severity.
|
||||
#[must_use]
|
||||
pub fn diagnostic(code: DiagnosticCode, message: impl Into<String>) -> Diagnostic {
|
||||
Diagnostic {
|
||||
code,
|
||||
severity: Severity::Error,
|
||||
message: message.into(),
|
||||
context: DiagnosticContext::default(),
|
||||
causes: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Diagnostic {
|
||||
/// Returns a copy with severity changed.
|
||||
#[must_use]
|
||||
pub fn with_severity(mut self, severity: Severity) -> Self {
|
||||
self.severity = severity;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns a copy with context changed.
|
||||
#[must_use]
|
||||
pub fn with_context(mut self, context: DiagnosticContext) -> Self {
|
||||
self.context = context;
|
||||
self
|
||||
}
|
||||
|
||||
/// Adds a cause.
|
||||
pub fn push_cause(&mut self, cause: Diagnostic) {
|
||||
self.causes.push(cause);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a compact human-readable diagnostic.
|
||||
#[must_use]
|
||||
pub fn render_human(diagnostic: &Diagnostic) -> String {
|
||||
let mut out = format!(
|
||||
"{:?} {}: {}",
|
||||
diagnostic.severity, diagnostic.code.0, diagnostic.message
|
||||
);
|
||||
if let Some(path) = &diagnostic.context.path {
|
||||
out.push_str(" [");
|
||||
out.push_str(path);
|
||||
out.push(']');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders deterministic JSON using the typed diagnostic schema.
|
||||
#[must_use]
|
||||
pub fn render_json(diagnostic: &Diagnostic) -> String {
|
||||
match serde_json::to_string(diagnostic) {
|
||||
Ok(json) => json,
|
||||
Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn json_is_stable() {
|
||||
let d = diagnostic(DiagnosticCode("S0-DIAG-001"), "keeps context").with_context(
|
||||
DiagnosticContext {
|
||||
phase: Some(Phase::Parse),
|
||||
..DiagnosticContext::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
render_json(&d),
|
||||
"{\"code\":\"S0-DIAG-001\",\"severity\":\"error\",\"message\":\"keeps context\",\"context\":{\"phase\":\"parse\"},\"causes\":[]}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_chain_preserves_context() {
|
||||
let mut root = diagnostic(DiagnosticCode("ROOT"), "root").with_context(DiagnosticContext {
|
||||
phase: Some(Phase::Resolve),
|
||||
path: Some("archives/material.lib".to_string()),
|
||||
archive_entry: Some("MATERIAL.MAT0".to_string()),
|
||||
object_key: Some("unit/tank".to_string()),
|
||||
span: Some(SourceSpan {
|
||||
offset: 12,
|
||||
length: 4,
|
||||
}),
|
||||
});
|
||||
root.push_cause(diagnostic(DiagnosticCode("CAUSE"), "cause").with_context(
|
||||
DiagnosticContext {
|
||||
phase: Some(Phase::Parse),
|
||||
path: Some("archives/material.lib".to_string()),
|
||||
span: Some(SourceSpan {
|
||||
offset: 16,
|
||||
length: 8,
|
||||
}),
|
||||
..DiagnosticContext::default()
|
||||
},
|
||||
));
|
||||
|
||||
let json = render_json(&root);
|
||||
|
||||
assert!(json.contains("\"code\":\"ROOT\""));
|
||||
assert!(json.contains("\"phase\":\"resolve\""));
|
||||
assert!(json.contains("\"path\":\"archives/material.lib\""));
|
||||
assert!(json.contains("\"archive_entry\":\"MATERIAL.MAT0\""));
|
||||
assert!(json.contains("\"object_key\":\"unit/tank\""));
|
||||
assert!(json.contains("\"span\":{\"offset\":12,\"length\":4}"));
|
||||
assert!(json.contains("\"code\":\"CAUSE\""));
|
||||
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_escapes_all_control_characters() {
|
||||
let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
|
||||
let json = render_json(&value);
|
||||
assert!(json.contains("\\u0000"));
|
||||
assert!(json.contains("\\t"));
|
||||
assert!(!json.contains('\t'));
|
||||
assert!(!json.contains('\r'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "fparkan-fx"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "fparkan-inspection"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-diagnostics = { path = "../fparkan-diagnostics", version = "0.1.0" }
|
||||
fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
|
||||
fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
|
||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
|
||||
fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "fparkan-material"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fparkan-mission-format"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "fparkan-msh"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-animation = { path = "../fparkan-animation", version = "0.1.0" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fparkan-nres"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "fparkan-path"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,428 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Legacy path normalization and ASCII lookup semantics.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Original bytes.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct OriginalPathBytes(pub Vec<u8>);
|
||||
|
||||
impl OriginalPathBytes {
|
||||
/// Returns the preserved byte image.
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Returns the preserved byte image as an owned vector.
|
||||
#[must_use]
|
||||
pub fn into_vec(self) -> Vec<u8> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized relative path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NormalizedPath {
|
||||
raw: Vec<u8>,
|
||||
display: String,
|
||||
}
|
||||
|
||||
impl NormalizedPath {
|
||||
/// Returns normalized byte view used for identity, ordering, and hashing.
|
||||
#[must_use]
|
||||
pub fn identity_bytes(&self) -> &[u8] {
|
||||
&self.raw
|
||||
}
|
||||
|
||||
/// Returns an ASCII-only lookup key for case-insensitive archive matching.
|
||||
#[must_use]
|
||||
pub fn lookup_key(&self) -> LookupKey {
|
||||
ascii_lookup_key(&self.raw)
|
||||
}
|
||||
|
||||
/// Returns normalized byte view.
|
||||
#[must_use]
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
self.identity_bytes()
|
||||
}
|
||||
|
||||
/// Returns a lossy display representation.
|
||||
#[must_use]
|
||||
pub fn display_lossy(&self) -> &str {
|
||||
&self.display
|
||||
}
|
||||
|
||||
/// Returns a lossy string view for UI and diagnostics only.
|
||||
#[must_use]
|
||||
pub fn as_str(&self) -> &str {
|
||||
self.display_lossy()
|
||||
}
|
||||
|
||||
/// Returns an OS path owned path buffer.
|
||||
#[must_use]
|
||||
pub fn as_path(&self) -> PathBuf {
|
||||
as_os_path_from_bytes(&self.raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for NormalizedPath {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.raw == other.raw
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for NormalizedPath {}
|
||||
|
||||
impl PartialOrd for NormalizedPath {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for NormalizedPath {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.raw.cmp(&other.raw)
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for NormalizedPath {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
self.raw.hash(state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalized path paired with its original byte image.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NormalizedPathWithOriginal {
|
||||
normalized: NormalizedPath,
|
||||
original: OriginalPathBytes,
|
||||
}
|
||||
|
||||
impl NormalizedPathWithOriginal {
|
||||
/// Returns normalized path.
|
||||
#[must_use]
|
||||
pub fn normalized(&self) -> &NormalizedPath {
|
||||
&self.normalized
|
||||
}
|
||||
|
||||
/// Returns original path bytes.
|
||||
#[must_use]
|
||||
pub fn original(&self) -> &OriginalPathBytes {
|
||||
&self.original
|
||||
}
|
||||
|
||||
/// Splits into normalized and original path parts.
|
||||
#[must_use]
|
||||
pub fn into_parts(self) -> (NormalizedPath, OriginalPathBytes) {
|
||||
(self.normalized, self.original)
|
||||
}
|
||||
}
|
||||
|
||||
/// ASCII lookup key.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct LookupKey(pub Vec<u8>);
|
||||
|
||||
/// Resource name bytes.
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub struct ResourceName(pub Vec<u8>);
|
||||
|
||||
/// Path policy.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PathPolicy {
|
||||
/// Strict legacy relative resource path.
|
||||
StrictLegacy,
|
||||
/// Host compatible relative path.
|
||||
HostCompatible,
|
||||
}
|
||||
|
||||
/// Path error.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum PathError {
|
||||
/// Empty path.
|
||||
Empty,
|
||||
/// Embedded NUL.
|
||||
EmbeddedNul,
|
||||
/// Absolute path.
|
||||
Absolute,
|
||||
/// Parent traversal.
|
||||
ParentTraversal,
|
||||
/// Host path escape.
|
||||
EscapesRoot,
|
||||
}
|
||||
|
||||
impl fmt::Display for PathError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Empty => write!(f, "path is empty"),
|
||||
Self::EmbeddedNul => write!(f, "path contains an embedded NUL byte"),
|
||||
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
|
||||
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
|
||||
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PathError {}
|
||||
|
||||
/// Normalizes a relative path.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError`] when the input is empty, absolute, contains an
|
||||
/// embedded NUL, attempts parent traversal, or has an invalid drive prefix.
|
||||
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
|
||||
if raw.is_empty() {
|
||||
return Err(PathError::Empty);
|
||||
}
|
||||
if raw.contains(&0) {
|
||||
return Err(PathError::EmbeddedNul);
|
||||
}
|
||||
if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) {
|
||||
return Err(PathError::Absolute);
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') {
|
||||
if part.is_empty() || part == b"." {
|
||||
if policy == PathPolicy::StrictLegacy {
|
||||
return Err(PathError::ParentTraversal);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if part == b".." {
|
||||
return Err(PathError::ParentTraversal);
|
||||
}
|
||||
if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
|
||||
return Err(PathError::Absolute);
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return Err(PathError::Empty);
|
||||
}
|
||||
let mut normalized = Vec::new();
|
||||
for (index, part) in parts.iter().enumerate() {
|
||||
if index > 0 {
|
||||
normalized.push(b'/');
|
||||
}
|
||||
normalized.extend_from_slice(part);
|
||||
}
|
||||
let display = String::from_utf8_lossy(&normalized).into_owned();
|
||||
Ok(NormalizedPath {
|
||||
raw: normalized,
|
||||
display,
|
||||
})
|
||||
}
|
||||
|
||||
/// Normalizes a relative path while preserving its original bytes.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError`] under the same conditions as [`normalize_relative`].
|
||||
pub fn normalize_relative_with_original(
|
||||
raw: &[u8],
|
||||
policy: PathPolicy,
|
||||
) -> Result<NormalizedPathWithOriginal, PathError> {
|
||||
let normalized = normalize_relative(raw, policy)?;
|
||||
Ok(NormalizedPathWithOriginal {
|
||||
normalized,
|
||||
original: OriginalPathBytes(raw.to_vec()),
|
||||
})
|
||||
}
|
||||
|
||||
fn has_drive_prefix(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
/// Builds an ASCII-only casefold lookup key.
|
||||
#[must_use]
|
||||
pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
|
||||
LookupKey(raw.iter().map(u8::to_ascii_uppercase).collect())
|
||||
}
|
||||
|
||||
/// Ensures relative path does not escape.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
|
||||
/// to address a parent directory.
|
||||
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
|
||||
if rel
|
||||
.as_bytes()
|
||||
.split(|byte| *byte == b'/')
|
||||
.any(|part| part == b"..")
|
||||
{
|
||||
Err(PathError::ParentTraversal)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Joins normalized path under root.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError`] if the normalized path fails the escape check.
|
||||
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
|
||||
reject_escape(rel)?;
|
||||
Ok(root.join(rel.as_path()))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
PathBuf::from(OsString::from_vec(raw.to_vec()))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
|
||||
PathBuf::from(String::from_utf8_lossy(raw).into_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn normalizes_separators() {
|
||||
let p = normalize_relative(b"DATA\\MAPS/INTRO/Land.msh", PathPolicy::StrictLegacy)
|
||||
.expect("path");
|
||||
assert_eq!(p.as_str(), "DATA/MAPS/INTRO/Land.msh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_escape() {
|
||||
assert_eq!(
|
||||
normalize_relative(b"DATA/../secret", PathPolicy::StrictLegacy),
|
||||
Err(PathError::ParentTraversal)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_absolute_drive_and_nul_paths() {
|
||||
assert_eq!(
|
||||
normalize_relative(b"/DATA/MAPS", PathPolicy::StrictLegacy),
|
||||
Err(PathError::Absolute)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relative(b"C:\\DATA\\MAPS", PathPolicy::StrictLegacy),
|
||||
Err(PathError::Absolute)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relative(b"DATA\0MAPS", PathPolicy::StrictLegacy),
|
||||
Err(PathError::EmbeddedNul)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_error_display_is_actionable() {
|
||||
assert_eq!(
|
||||
PathError::ParentTraversal.to_string(),
|
||||
"path attempts to traverse outside its root"
|
||||
);
|
||||
assert_eq!(
|
||||
PathError::EmbeddedNul.to_string(),
|
||||
"path contains an embedded NUL byte"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_legacy_rejects_host_only_segments() {
|
||||
assert_eq!(
|
||||
normalize_relative(b"./DATA/MAPS", PathPolicy::StrictLegacy),
|
||||
Err(PathError::ParentTraversal)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relative(b"DATA//MAPS", PathPolicy::StrictLegacy),
|
||||
Err(PathError::ParentTraversal)
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_relative(b"DATA/stream:name", PathPolicy::StrictLegacy),
|
||||
Err(PathError::Absolute)
|
||||
);
|
||||
|
||||
let host = normalize_relative(b"./DATA//MAPS", PathPolicy::HostCompatible).expect("host");
|
||||
assert_eq!(host.as_str(), "DATA/MAPS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_under_keeps_normalized_path_below_root() {
|
||||
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
|
||||
.expect("relative path");
|
||||
let joined = join_under(Path::new("/game"), &rel).expect("join");
|
||||
|
||||
assert_eq!(joined, PathBuf::from("/game/DATA/MAPS/Land.map"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_casefold_does_not_unicode_fold() {
|
||||
assert_eq!(ascii_lookup_key(b"AbZ\xD0"), LookupKey(b"ABZ\xD0".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_original_bytes_remain_stable() {
|
||||
let raw = "DATA/Тест.bin".as_bytes();
|
||||
let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy)
|
||||
.expect("path with non-ASCII UTF-8");
|
||||
|
||||
assert_eq!(path.normalized().as_str().as_bytes(), raw);
|
||||
assert_eq!(path.original().as_bytes(), raw);
|
||||
assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_non_utf8_legacy_bytes() {
|
||||
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||
.expect("raw legacy bytes");
|
||||
|
||||
assert_eq!(path.display_lossy(), "DATA/\u{FFFD}.bin");
|
||||
assert_eq!(path.identity_bytes(), b"DATA/\xFF.bin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn original_separators_and_raw_bytes_are_preserved() {
|
||||
let raw = b"DATA\\Maps/Intro\\Land.msh";
|
||||
let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy).expect("path");
|
||||
|
||||
assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh");
|
||||
assert_eq!(path.original().as_bytes(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossy_display_does_not_affect_identity_or_ordering() {
|
||||
let first = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||
.expect("first raw path");
|
||||
let second = normalize_relative(b"DATA/\xFE.bin", PathPolicy::HostCompatible)
|
||||
.expect("second raw path");
|
||||
|
||||
assert_eq!(first.display_lossy(), second.display_lossy());
|
||||
assert_ne!(first, second);
|
||||
assert_ne!(first.identity_bytes(), second.identity_bytes());
|
||||
assert_ne!(first.cmp(&second), Ordering::Equal);
|
||||
assert_ne!(first.lookup_key(), second.lookup_key());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-platform"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,233 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Platform ports for clocks, event sources and window descriptors.
|
||||
|
||||
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||
|
||||
/// Monotonic instant measured in milliseconds since process start.
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct MonotonicInstant(pub u64);
|
||||
|
||||
/// Platform clock.
|
||||
pub trait MonotonicClock {
|
||||
/// Current instant.
|
||||
fn now(&self) -> MonotonicInstant;
|
||||
}
|
||||
|
||||
/// Platform event.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PlatformEvent {
|
||||
/// Window/application requested to quit.
|
||||
QuitRequested,
|
||||
/// Window focus changed.
|
||||
FocusChanged {
|
||||
/// Whether the window is focused.
|
||||
focused: bool,
|
||||
},
|
||||
/// Window resize or move to a new drawable size.
|
||||
Resize {
|
||||
/// Drawable width in physical pixels.
|
||||
width: u32,
|
||||
/// Drawable height in physical pixels.
|
||||
height: u32,
|
||||
},
|
||||
/// Device pixel ratio changed.
|
||||
DpiChanged {
|
||||
/// Logical-to-physical scale factor.
|
||||
scale: f64,
|
||||
},
|
||||
/// Window minimized/hidden.
|
||||
Minimized {
|
||||
/// Whether the window is minimized.
|
||||
minimized: bool,
|
||||
},
|
||||
/// Window occlusion state changed.
|
||||
Occluded {
|
||||
/// Whether the window is occluded.
|
||||
occluded: bool,
|
||||
},
|
||||
/// Window is being suspended.
|
||||
Suspended,
|
||||
/// Window resumed from suspend.
|
||||
Resumed,
|
||||
/// Keyboard/scancode input.
|
||||
KeyboardInput {
|
||||
/// Platform scancode.
|
||||
scancode: u32,
|
||||
/// Pressed state.
|
||||
pressed: bool,
|
||||
},
|
||||
/// Mouse button input.
|
||||
MouseInput {
|
||||
/// Mouse button code.
|
||||
button: u16,
|
||||
/// Pressed state.
|
||||
pressed: bool,
|
||||
/// X position in window coordinates.
|
||||
x: f64,
|
||||
/// Y position in window coordinates.
|
||||
y: f64,
|
||||
},
|
||||
/// Mouse cursor movement.
|
||||
CursorMoved {
|
||||
/// Cursor x.
|
||||
x: f64,
|
||||
/// Cursor y.
|
||||
y: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Platform error with optional source detail.
|
||||
#[derive(Debug)]
|
||||
pub enum PlatformError {
|
||||
/// Backend/backend-specific failure.
|
||||
Backend {
|
||||
/// Operation or subsystem.
|
||||
context: &'static str,
|
||||
/// Human-readable details.
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PlatformError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Backend { context, message } => {
|
||||
write!(f, "{context}: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PlatformError {}
|
||||
|
||||
/// Event source contract for polling platform events.
|
||||
pub trait EventSource {
|
||||
/// Polls events.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PlatformError`] when the backend cannot collect events.
|
||||
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
|
||||
}
|
||||
|
||||
/// Physical window size.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PhysicalSize {
|
||||
/// Width.
|
||||
pub width: u32,
|
||||
/// Height.
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Window identity as a stable opaque handle token.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||
pub struct WindowHandle {
|
||||
/// Opaque integer token.
|
||||
pub id: u64,
|
||||
}
|
||||
|
||||
/// Native raw window/display handles for render surface creation.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct NativeWindowHandles {
|
||||
/// Raw display handle.
|
||||
pub display: RawDisplayHandle,
|
||||
/// Raw window handle.
|
||||
pub window: RawWindowHandle,
|
||||
}
|
||||
|
||||
/// Window presentation and lifecycle port.
|
||||
///
|
||||
/// Presentation is not owned by the window abstraction. Render adapters
|
||||
/// own swapchain and present lifecycle.
|
||||
pub trait WindowPort {
|
||||
/// Current drawable size.
|
||||
fn drawable_size(&self) -> PhysicalSize;
|
||||
/// DPI scale for this window.
|
||||
fn dpi_scale(&self) -> f64;
|
||||
/// Whether the window is focused.
|
||||
fn has_focus(&self) -> bool;
|
||||
/// Whether the window is minimized.
|
||||
fn is_minimized(&self) -> bool;
|
||||
/// Whether the window is occluded.
|
||||
fn is_occluded(&self) -> bool;
|
||||
/// Opaque window identity.
|
||||
fn handle(&self) -> WindowHandle;
|
||||
/// Raw native handles for render surface creation, when backed by a native window.
|
||||
fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Render backend request contract.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RenderRequest {
|
||||
/// Preferred color-space profile.
|
||||
pub color_space: ColorSpace,
|
||||
/// Preferred presentation mode.
|
||||
pub presentation: PresentationMode,
|
||||
/// Requested depth/stencil format.
|
||||
pub depth: DepthStencilSupport,
|
||||
}
|
||||
|
||||
/// Color-space profile.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ColorSpace {
|
||||
/// sRGB nonlinear.
|
||||
Srgb,
|
||||
/// Linear color-space.
|
||||
Linear,
|
||||
}
|
||||
|
||||
/// Presentation mode.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum PresentationMode {
|
||||
/// `VSync`.
|
||||
Fifo,
|
||||
/// No `VSync`.
|
||||
Immediate,
|
||||
/// Triple-buffer mailbox fallback.
|
||||
Mailbox,
|
||||
}
|
||||
|
||||
/// Depth/stencil support profile requested by the composition root.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DepthStencilSupport {
|
||||
/// Depth bits.
|
||||
pub depth_bits: u8,
|
||||
/// Stencil bits.
|
||||
pub stencil_bits: u8,
|
||||
}
|
||||
|
||||
impl RenderRequest {
|
||||
/// Returns a conservative default request.
|
||||
#[must_use]
|
||||
pub const fn conservative() -> Self {
|
||||
Self {
|
||||
color_space: ColorSpace::Srgb,
|
||||
presentation: PresentationMode::Fifo,
|
||||
depth: DepthStencilSupport {
|
||||
depth_bits: 24,
|
||||
stencil_bits: 8,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "fparkan-prototype"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-render"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "fparkan-resource"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fparkan-rsli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "fparkan-runtime"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-assets = { path = "../fparkan-assets", version = "0.1.0" }
|
||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
|
||||
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
|
||||
fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
|
||||
fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
|
||||
fparkan-script = { path = "../fparkan-script", version = "0.1.0" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
||||
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-script"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fparkan-terrain-format"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "fparkan-terrain"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-test-support"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,44 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Dev-only synthetic builders and fake ports.
|
||||
|
||||
use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
|
||||
|
||||
/// Fake clock.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct FakeClock {
|
||||
/// Current tick.
|
||||
pub tick: u64,
|
||||
}
|
||||
|
||||
/// Recording backend.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RecordingRenderBackend {
|
||||
/// Recorded command lists.
|
||||
pub captures: Vec<RenderCommandList>,
|
||||
}
|
||||
|
||||
impl RenderBackend for RecordingRenderBackend {
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||
self.captures.push(commands.clone());
|
||||
Ok(FrameOutput)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "fparkan-texm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user