Compare commits
306
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2333491cb2 | ||
|
|
f0851576e8 | ||
|
|
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
|
@@ -6,7 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
container: ghcr.io/renovatebot/renovate:43
|
||||
container: ghcr.io/renovatebot/renovate:44
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
|
||||
@@ -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
|
||||
Generated
+1589
-20
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -7,6 +7,7 @@ members = [
|
||||
"crates/fparkan-corpus",
|
||||
"crates/fparkan-diagnostics",
|
||||
"crates/fparkan-fx",
|
||||
"crates/fparkan-inspection",
|
||||
"crates/fparkan-material",
|
||||
"crates/fparkan-mission-format",
|
||||
"crates/fparkan-msh",
|
||||
@@ -17,6 +18,7 @@ members = [
|
||||
"crates/fparkan-render",
|
||||
"crates/fparkan-resource",
|
||||
"crates/fparkan-rsli",
|
||||
"crates/fparkan-script",
|
||||
"crates/fparkan-runtime",
|
||||
"crates/fparkan-terrain",
|
||||
"crates/fparkan-terrain-format",
|
||||
@@ -24,11 +26,12 @@ members = [
|
||||
"crates/fparkan-texm",
|
||||
"crates/fparkan-vfs",
|
||||
"crates/fparkan-world",
|
||||
"adapters/fparkan-platform-sdl",
|
||||
"adapters/fparkan-render-gl",
|
||||
"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",
|
||||
]
|
||||
@@ -36,6 +39,7 @@ members = [
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
rust-version = "1.97"
|
||||
license = "GPL-2.0-only"
|
||||
repository = "https://github.com/valentineus/fparkan"
|
||||
|
||||
|
||||
@@ -24,7 +24,17 @@ Open source проект с реализацией компонентов игр
|
||||
- [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), [apps/fparkan-viewer](apps/fparkan-viewer), [apps/fparkan-headless](apps/fparkan-headless), [apps/fparkan-game](apps/fparkan-game) — composition roots.
|
||||
- [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).
|
||||
|
||||
## Тестирование
|
||||
|
||||
@@ -63,6 +73,37 @@ 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 можно создавать в обоих репозиториях:
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[package]
|
||||
name = "fparkan-platform-sdl"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,123 +0,0 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! SDL platform adapter boundary stubs behind safe `FParkan` ports.
|
||||
|
||||
use fparkan_platform::{
|
||||
EventSource, GraphicsContextRequest, GraphicsProfile, PhysicalSize, PlatformError,
|
||||
PlatformEvent, Version, WindowPort,
|
||||
};
|
||||
|
||||
/// Adapter capabilities compiled into this package.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SdlAdapterCapabilities {
|
||||
/// Supported graphics context requests in preference order.
|
||||
pub graphics: Vec<GraphicsContextRequest>,
|
||||
/// Whether adapter-owned code is free of `unsafe`.
|
||||
pub project_owned_unsafe_free: bool,
|
||||
}
|
||||
|
||||
impl Default for SdlAdapterCapabilities {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
graphics: vec![
|
||||
GraphicsContextRequest {
|
||||
profile: GraphicsProfile::DesktopCore,
|
||||
version: Version { major: 3, minor: 3 },
|
||||
},
|
||||
GraphicsContextRequest {
|
||||
profile: GraphicsProfile::Embedded,
|
||||
version: Version { major: 2, minor: 0 },
|
||||
},
|
||||
],
|
||||
project_owned_unsafe_free: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the project-owned adapter boundary avoids `unsafe`.
|
||||
#[must_use]
|
||||
pub fn project_owned_layer_unsafe_free() -> bool {
|
||||
SdlAdapterCapabilities::default().project_owned_unsafe_free
|
||||
}
|
||||
|
||||
/// In-memory event source used by adapter smoke tests before a concrete SDL
|
||||
/// runtime is selected.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct SdlEventSourceStub {
|
||||
pending: Vec<PlatformEvent>,
|
||||
}
|
||||
|
||||
impl SdlEventSourceStub {
|
||||
/// Creates an event source with deterministic pending events.
|
||||
#[must_use]
|
||||
pub fn new(pending: Vec<PlatformEvent>) -> Self {
|
||||
Self { pending }
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSource for SdlEventSourceStub {
|
||||
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
|
||||
out.append(&mut self.pending);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Safe window-port stub with SDL-compatible drawable-size semantics.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct SdlWindowStub {
|
||||
size: PhysicalSize,
|
||||
presents: u64,
|
||||
}
|
||||
|
||||
impl SdlWindowStub {
|
||||
/// Creates a stub window with a fixed drawable size.
|
||||
#[must_use]
|
||||
pub fn new(size: PhysicalSize) -> Self {
|
||||
Self { size, presents: 0 }
|
||||
}
|
||||
|
||||
/// Number of successful present calls.
|
||||
#[must_use]
|
||||
pub fn presents(&self) -> u64 {
|
||||
self.presents
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowPort for SdlWindowStub {
|
||||
fn drawable_size(&self) -> PhysicalSize {
|
||||
self.size
|
||||
}
|
||||
|
||||
fn present(&mut self) -> Result<(), PlatformError> {
|
||||
self.presents = self.presents.saturating_add(1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn adapter_boundary_is_project_owned_unsafe_free() {
|
||||
assert!(project_owned_layer_unsafe_free());
|
||||
assert_eq!(SdlAdapterCapabilities::default().graphics.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_source_and_window_ports_are_deterministic() -> Result<(), PlatformError> {
|
||||
let mut source = SdlEventSourceStub::new(vec![PlatformEvent::Quit]);
|
||||
let mut events = Vec::new();
|
||||
source.poll(&mut events)?;
|
||||
source.poll(&mut events)?;
|
||||
assert_eq!(events, vec![PlatformEvent::Quit]);
|
||||
|
||||
let mut window = SdlWindowStub::new(PhysicalSize {
|
||||
width: 320,
|
||||
height: 240,
|
||||
});
|
||||
assert_eq!(window.drawable_size().width, 320);
|
||||
window.present()?;
|
||||
assert_eq!(window.presents(), 1);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -1,242 +0,0 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! OpenGL render adapter boundary stubs behind safe `FParkan` render ports.
|
||||
|
||||
use fparkan_render::{
|
||||
canonical_capture, FrameOutput, RenderBackend, RenderCommandList, RenderError,
|
||||
};
|
||||
|
||||
/// Portable OpenGL profile requested by the game composition root.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum GlProfile {
|
||||
/// Desktop OpenGL 3.3 Core.
|
||||
DesktopCore33,
|
||||
/// OpenGL ES 2.0 portable baseline.
|
||||
Gles2,
|
||||
}
|
||||
|
||||
/// Shader stage used in diagnostics.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ShaderStage {
|
||||
/// Vertex shader.
|
||||
Vertex,
|
||||
/// Fragment shader.
|
||||
Fragment,
|
||||
}
|
||||
|
||||
/// Shader compilation diagnostic surfaced by the adapter.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct ShaderCompileError {
|
||||
/// Requested GL profile.
|
||||
pub profile: GlProfile,
|
||||
/// Shader stage.
|
||||
pub stage: ShaderStage,
|
||||
/// Backend compiler log.
|
||||
pub log: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ShaderCompileError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{:?} {:?} shader compile failed: {}",
|
||||
self.profile, self.stage, self.log
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ShaderCompileError {}
|
||||
|
||||
/// Adapter capabilities compiled into this package.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct GlAdapterCapabilities {
|
||||
/// Supported profiles in preference order.
|
||||
pub profiles: Vec<GlProfile>,
|
||||
/// Whether adapter-owned code is free of `unsafe`.
|
||||
pub project_owned_unsafe_free: bool,
|
||||
}
|
||||
|
||||
impl Default for GlAdapterCapabilities {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
profiles: vec![GlProfile::DesktopCore33, GlProfile::Gles2],
|
||||
project_owned_unsafe_free: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether the project-owned adapter boundary avoids `unsafe`.
|
||||
#[must_use]
|
||||
pub fn project_owned_layer_unsafe_free() -> bool {
|
||||
GlAdapterCapabilities::default().project_owned_unsafe_free
|
||||
}
|
||||
|
||||
/// Validates shader source through the adapter diagnostic contract.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`ShaderCompileError`] when the source is empty or contains a
|
||||
/// deterministic synthetic failure marker.
|
||||
pub fn compile_shader_source(
|
||||
profile: GlProfile,
|
||||
stage: ShaderStage,
|
||||
source: &str,
|
||||
) -> Result<(), ShaderCompileError> {
|
||||
if source.trim().is_empty() {
|
||||
return Err(ShaderCompileError {
|
||||
profile,
|
||||
stage,
|
||||
log: "empty shader source".to_string(),
|
||||
});
|
||||
}
|
||||
if source.contains("#error") {
|
||||
return Err(ShaderCompileError {
|
||||
profile,
|
||||
stage,
|
||||
log: "synthetic compiler failure marker".to_string(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Safe render backend stub used for adapter-level command validation.
|
||||
///
|
||||
/// A concrete OpenGL implementation can be injected behind the same
|
||||
/// [`RenderBackend`] port once an audited safe GL facade is selected. This type
|
||||
/// keeps the project-owned adapter API executable without introducing local FFI.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SafeGlCommandBackend {
|
||||
profile: GlProfile,
|
||||
captures: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl SafeGlCommandBackend {
|
||||
/// Creates a backend proof for a requested GL profile.
|
||||
#[must_use]
|
||||
pub fn new(profile: GlProfile) -> Self {
|
||||
Self {
|
||||
profile,
|
||||
captures: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Active GL profile.
|
||||
#[must_use]
|
||||
pub fn profile(&self) -> GlProfile {
|
||||
self.profile
|
||||
}
|
||||
|
||||
/// Deterministic command captures produced by executed frames.
|
||||
#[must_use]
|
||||
pub fn captures(&self) -> &[Vec<u8>] {
|
||||
&self.captures
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderBackend for SafeGlCommandBackend {
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||
self.captures.push(canonical_capture(commands)?);
|
||||
Ok(FrameOutput)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fparkan_render::{
|
||||
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderCommand, RenderPhase,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn adapter_boundary_is_project_owned_unsafe_free() {
|
||||
assert!(project_owned_layer_unsafe_free());
|
||||
assert_eq!(GlAdapterCapabilities::default().profiles.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_executes_and_captures_commands() -> Result<(), RenderError> {
|
||||
let mut backend = SafeGlCommandBackend::new(GlProfile::Gles2);
|
||||
let commands = RenderCommandList {
|
||||
commands: vec![
|
||||
RenderCommand::BeginFrame,
|
||||
RenderCommand::Draw(DrawCommand {
|
||||
id: DrawId(7),
|
||||
phase: RenderPhase::Opaque,
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(11),
|
||||
material: GpuMaterialId(13),
|
||||
transform: [0.0; 16],
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order: 17,
|
||||
}),
|
||||
RenderCommand::EndFrame,
|
||||
],
|
||||
};
|
||||
|
||||
backend.execute(&commands)?;
|
||||
|
||||
assert_eq!(backend.profile(), GlProfile::Gles2);
|
||||
assert_eq!(backend.captures().len(), 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_gl33_triangle_command_capture() -> Result<(), RenderError> {
|
||||
let mut backend = SafeGlCommandBackend::new(GlProfile::DesktopCore33);
|
||||
let commands = triangle_commands();
|
||||
|
||||
backend.execute(&commands)?;
|
||||
|
||||
assert_eq!(backend.profile(), GlProfile::DesktopCore33);
|
||||
assert_eq!(
|
||||
backend.captures(),
|
||||
&[b"B\nD,Opaque,7,11,13,17\nE\n".to_vec()]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gles2_triangle_command_capture() -> Result<(), RenderError> {
|
||||
let mut backend = SafeGlCommandBackend::new(GlProfile::Gles2);
|
||||
let commands = triangle_commands();
|
||||
|
||||
backend.execute(&commands)?;
|
||||
|
||||
assert_eq!(backend.profile(), GlProfile::Gles2);
|
||||
assert_eq!(
|
||||
backend.captures(),
|
||||
&[b"B\nD,Opaque,7,11,13,17\nE\n".to_vec()]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shader_compile_failure_diagnostic_contains_profile_and_log() {
|
||||
let err = compile_shader_source(GlProfile::Gles2, ShaderStage::Fragment, "#error")
|
||||
.expect_err("shader failure");
|
||||
|
||||
assert_eq!(err.profile, GlProfile::Gles2);
|
||||
assert_eq!(err.stage, ShaderStage::Fragment);
|
||||
assert!(err.log.contains("synthetic compiler failure"));
|
||||
assert!(err.to_string().contains("Gles2"));
|
||||
assert!(err.to_string().contains("synthetic compiler failure"));
|
||||
}
|
||||
|
||||
fn triangle_commands() -> RenderCommandList {
|
||||
RenderCommandList {
|
||||
commands: vec![
|
||||
RenderCommand::BeginFrame,
|
||||
RenderCommand::Draw(DrawCommand {
|
||||
id: DrawId(7),
|
||||
phase: RenderPhase::Opaque,
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(11),
|
||||
material: GpuMaterialId(13),
|
||||
transform: [0.0; 16],
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order: 17,
|
||||
}),
|
||||
RenderCommand::EndFrame,
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -6,13 +6,17 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
|
||||
fparkan-nres = { path = "../../crates/fparkan-nres" }
|
||||
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
|
||||
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
||||
fparkan-rsli = { path = "../../crates/fparkan-rsli" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
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
|
||||
|
||||
+830
-109
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,20 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
fparkan-world = { path = "../../crates/fparkan-world" }
|
||||
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
|
||||
|
||||
+1754
-47
File diff suppressed because it is too large
Load Diff
@@ -6,9 +6,9 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
fparkan-world = { path = "../../crates/fparkan-world" }
|
||||
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
|
||||
|
||||
@@ -1,12 +1,32 @@
|
||||
#![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::{
|
||||
create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||
advance_reference_movement, create, load_mission, step_headless, EngineConfig, EngineMode,
|
||||
EngineServices, MissionRequest,
|
||||
};
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use fparkan_world::InputSnapshot;
|
||||
use fparkan_world::{InputSnapshot, OriginalObjectId};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -36,7 +56,7 @@ fn run() -> Result<(), String> {
|
||||
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={} graph_failures={}",
|
||||
"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,
|
||||
@@ -46,9 +66,27 @@ fn run() -> Result<(), String> {
|
||||
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}"))?);
|
||||
@@ -62,10 +100,19 @@ fn run() -> Result<(), String> {
|
||||
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 {
|
||||
@@ -74,6 +121,7 @@ impl Args {
|
||||
root: None,
|
||||
mission: None,
|
||||
ticks: 1,
|
||||
reference_movement: None,
|
||||
};
|
||||
let mut iter = args.iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
@@ -99,16 +147,121 @@ impl Args {
|
||||
.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 usage() -> String {
|
||||
"usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_string()
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,14 +6,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-msh = { path = "../../crates/fparkan-msh" }
|
||||
fparkan-nres = { path = "../../crates/fparkan-nres" }
|
||||
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||
fparkan-rsli = { path = "../../crates/fparkan-rsli" }
|
||||
fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
|
||||
fparkan-texm = { path = "../../crates/fparkan-texm" }
|
||||
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||
fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
|
||||
fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+101
-104
@@ -1,20 +1,36 @@
|
||||
#![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 viewer composition root.
|
||||
//! `FParkan` asset inspection composition root.
|
||||
|
||||
use fparkan_msh::{decode_msh, validate_msh};
|
||||
use fparkan_nres::{decode as decode_nres, ReadProfile as NresReadProfile};
|
||||
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 fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
|
||||
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
||||
use fparkan_texm::decode_texm;
|
||||
use fparkan_vfs::DirectoryVfs;
|
||||
use std::fmt::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn main() {
|
||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||
@@ -44,35 +60,27 @@ fn run(args: &[String]) -> Result<String, String> {
|
||||
fn inspect_archive(args: &[String]) -> Result<String, String> {
|
||||
let file = parse_file(args)?;
|
||||
let limit = parse_limit(args)?;
|
||||
let bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?;
|
||||
if bytes.starts_with(b"NRes") {
|
||||
let document = decode_nres(
|
||||
Arc::from(bytes.into_boxed_slice()),
|
||||
NresReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let sample = render_nres_entries(&document, limit);
|
||||
return Ok(format!(
|
||||
"{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
|
||||
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()),
|
||||
document.entries().len(),
|
||||
document.lookup_order_valid(),
|
||||
sample
|
||||
));
|
||||
}
|
||||
if bytes.get(0..4) == Some(b"NL\0\x01") {
|
||||
let document = fparkan_rsli::decode(
|
||||
Arc::from(bytes.into_boxed_slice()),
|
||||
fparkan_rsli::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
return Ok(format!(
|
||||
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
|
||||
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()),
|
||||
document.entries().len()
|
||||
));
|
||||
entries
|
||||
)),
|
||||
ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", file.display())),
|
||||
}
|
||||
Err(format!("{}: unsupported archive magic", file.display()))
|
||||
}
|
||||
|
||||
fn inspect_model(args: &[String]) -> Result<String, String> {
|
||||
@@ -81,21 +89,18 @@ fn inspect_model(args: &[String]) -> Result<String, String> {
|
||||
}
|
||||
|
||||
let query = parse_resource_query(args)?;
|
||||
let bytes = read_resource(&query)?;
|
||||
let nested = decode_nres(bytes, NresReadProfile::Compatible).map_err(|err| err.to_string())?;
|
||||
let document = decode_msh(&nested).map_err(|err| err.to_string())?;
|
||||
let model = validate_msh(&document).map_err(|err| err.to_string())?;
|
||||
let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
|
||||
|
||||
Ok(format!(
|
||||
"{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
|
||||
"{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
|
||||
json_string(&query.archive),
|
||||
json_string(&query.name),
|
||||
document.streams().len(),
|
||||
model.node_count,
|
||||
model.slots.len(),
|
||||
model.positions.len(),
|
||||
model.indices.len(),
|
||||
model.batches.len()
|
||||
inspection.streams,
|
||||
inspection.nodes,
|
||||
inspection.slots,
|
||||
inspection.positions,
|
||||
inspection.indices,
|
||||
inspection.batches
|
||||
))
|
||||
}
|
||||
|
||||
@@ -117,6 +122,7 @@ impl ViewerModelService {
|
||||
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,
|
||||
@@ -131,7 +137,7 @@ impl ViewerModelService {
|
||||
.count();
|
||||
|
||||
Ok(format!(
|
||||
"{{\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model\",\"draw_commands\":{draw_commands}}}",
|
||||
"{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model-inspection\",\"draw_commands\":{draw_commands}}}",
|
||||
json_string(fixture)
|
||||
))
|
||||
}
|
||||
@@ -139,54 +145,58 @@ impl ViewerModelService {
|
||||
|
||||
fn inspect_texture(args: &[String]) -> Result<String, String> {
|
||||
let query = parse_resource_query(args)?;
|
||||
let document = decode_texm(read_resource(&query)?).map_err(|err| err.to_string())?;
|
||||
let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
|
||||
|
||||
Ok(format!(
|
||||
"{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
|
||||
"{{\"report_kind\":\"texture-inspection\",\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
|
||||
json_string(&query.archive),
|
||||
json_string(&query.name),
|
||||
document.width(),
|
||||
document.height(),
|
||||
json_string(&format!("{:?}", document.format())),
|
||||
document.mip_count(),
|
||||
document.page_rects().len()
|
||||
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 bytes = std::fs::read(&file).map_err(|err| format!("{}: {err}", file.display()))?;
|
||||
let nres = decode_nres(
|
||||
Arc::from(bytes.into_boxed_slice()),
|
||||
NresReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.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}")),
|
||||
},
|
||||
)?;
|
||||
|
||||
match kind.as_str() {
|
||||
"land-msh" => {
|
||||
let land = decode_land_msh(&nres).map_err(|err| err.to_string())?;
|
||||
Ok(format!(
|
||||
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
|
||||
json_string(&file.display().to_string()),
|
||||
land.streams.len(),
|
||||
land.positions.len(),
|
||||
land.faces.len(),
|
||||
land.slots.slots_raw.len()
|
||||
))
|
||||
}
|
||||
"land-map" => {
|
||||
let land = decode_land_map(&nres).map_err(|err| err.to_string())?;
|
||||
Ok(format!(
|
||||
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
|
||||
json_string(&file.display().to_string()),
|
||||
land.areals.len(),
|
||||
land.areal_count,
|
||||
land.grid.cells_x,
|
||||
land.grid.cells_y
|
||||
))
|
||||
}
|
||||
_ => 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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,19 +215,6 @@ fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_resource(query: &ResourceQuery) -> Result<Arc<[u8]>, String> {
|
||||
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(&query.root)));
|
||||
let archive = repository
|
||||
.open_archive(&archive_path(query.archive.as_bytes()).map_err(|err| err.to_string())?)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let entry = repository
|
||||
.find(archive, &resource_name(query.name.as_bytes()))
|
||||
.map_err(|err| err.to_string())?
|
||||
.ok_or_else(|| format!("resource not found: {}/{}", query.archive, query.name))?;
|
||||
let bytes = repository.read(entry).map_err(|err| err.to_string())?;
|
||||
Ok(Arc::from(bytes.into_owned()))
|
||||
}
|
||||
|
||||
fn parse_file(args: &[String]) -> Result<PathBuf, String> {
|
||||
parse_path_option(args, &["--file"], "--file")
|
||||
}
|
||||
@@ -233,19 +230,19 @@ fn parse_limit(args: &[String]) -> Result<usize, String> {
|
||||
.map(|value| value.unwrap_or(0))
|
||||
}
|
||||
|
||||
fn render_nres_entries(document: &fparkan_nres::NresDocument, limit: usize) -> String {
|
||||
fn render_nres_entries(entries: &[NresEntrySummary]) -> String {
|
||||
let mut out = String::new();
|
||||
for (index, entry) in document.entries().iter().take(limit).enumerate() {
|
||||
for (index, entry) in entries.iter().enumerate() {
|
||||
if index > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
let name = String::from_utf8_lossy(entry.name_bytes());
|
||||
let name = &entry.name;
|
||||
let _ = write!(
|
||||
out,
|
||||
"{{\"name\":{},\"type\":{},\"size\":{}}}",
|
||||
json_string(&name),
|
||||
entry.meta().type_id,
|
||||
entry.meta().data_size
|
||||
json_string(name),
|
||||
entry.type_id,
|
||||
entry.data_size
|
||||
);
|
||||
}
|
||||
out
|
||||
@@ -278,7 +275,7 @@ fn json_string(value: &str) -> String {
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if c.is_control() => {
|
||||
let _ = write!(out, "\\u{:04x}", u32::from(c));
|
||||
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||
}
|
||||
c => out.push(c),
|
||||
}
|
||||
@@ -346,7 +343,7 @@ mod tests {
|
||||
fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> {
|
||||
assert_eq!(
|
||||
run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?,
|
||||
"{\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model\",\"draw_commands\":1}"
|
||||
"{\"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,4 +1,22 @@
|
||||
#![forbid(unsafe_code)]
|
||||
#![cfg_attr(
|
||||
test,
|
||||
allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_possible_wrap,
|
||||
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::cast_precision_loss)]
|
||||
//! Deterministic animation sampling contracts.
|
||||
//!
|
||||
@@ -763,16 +781,29 @@ fn write_f32_bits(out: &mut Vec<u8>, value: f32) {
|
||||
}
|
||||
|
||||
fn compose_pose(parent: Pose, child: Pose) -> Result<Pose, AnimationError> {
|
||||
let translated = rotate_point(parent.rotation, child.translation);
|
||||
Ok(Pose {
|
||||
translation: [
|
||||
parent.translation[0] + child.translation[0],
|
||||
parent.translation[1] + child.translation[1],
|
||||
parent.translation[2] + child.translation[2],
|
||||
parent.translation[0] + translated[0],
|
||||
parent.translation[1] + translated[1],
|
||||
parent.translation[2] + translated[2],
|
||||
],
|
||||
rotation: normalize_quat(mul_quat(parent.rotation, child.rotation))?,
|
||||
})
|
||||
}
|
||||
|
||||
fn rotate_point(rotation: [f32; 4], point: [f32; 3]) -> [f32; 3] {
|
||||
let [x, y, z, w] = rotation;
|
||||
let tx = 2.0 * (y * point[2] - z * point[1]);
|
||||
let ty = 2.0 * (z * point[0] - x * point[2]);
|
||||
let tz = 2.0 * (x * point[1] - y * point[0]);
|
||||
[
|
||||
point[0] + w * tx + (y * tz - z * ty),
|
||||
point[1] + w * ty + (z * tx - x * tz),
|
||||
point[2] + w * tz + (x * ty - y * tx),
|
||||
]
|
||||
}
|
||||
|
||||
fn mul_quat(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
|
||||
let [lx, ly, lz, lw] = left;
|
||||
let [rx, ry, rz, rw] = right;
|
||||
@@ -1156,6 +1187,26 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hierarchy_rotates_child_translation_by_parent_orientation() {
|
||||
let half = std::f32::consts::FRAC_1_SQRT_2;
|
||||
let local = [
|
||||
Pose {
|
||||
translation: [1.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, half, half],
|
||||
},
|
||||
Pose {
|
||||
translation: [2.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
},
|
||||
];
|
||||
let buffer = evaluate_hierarchy(&[ParentIndex(None), ParentIndex(Some(0))], &local)
|
||||
.expect("hierarchy");
|
||||
assert!((buffer.poses[1].translation[0] - 1.0).abs() < 0.0001);
|
||||
assert!((buffer.poses[1].translation[1] - 2.0).abs() < 0.0001);
|
||||
assert!(buffer.poses[1].translation[2].abs() < 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_valid_quaternions_remain_finite() {
|
||||
for index in 1..64_u16 {
|
||||
|
||||
@@ -6,16 +6,19 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-material = { path = "../fparkan-material" }
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
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" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+3168
-87
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,23 @@
|
||||
#![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;
|
||||
|
||||
@@ -6,9 +6,17 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
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
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Licensed corpus discovery and aggregate reports.
|
||||
|
||||
use fparkan_binary::{sha256, sha256_hex, Sha256Digest};
|
||||
use fparkan_fx::{decode_fxid, FXID_KIND};
|
||||
use fparkan_material::{decode_mat0, decode_wear, MAT0_KIND, WEAR_KIND};
|
||||
use fparkan_mission_format::{decode_tma, TmaProfile};
|
||||
use fparkan_msh::{decode_msh, validate_msh};
|
||||
use fparkan_nres::NresDocument;
|
||||
use fparkan_path::{ascii_lookup_key, normalize_relative, PathPolicy};
|
||||
use fparkan_prototype::{decode_unit_dat, decode_unit_dat_binding};
|
||||
use fparkan_rsli::{decode as decode_rsli, ReadProfile};
|
||||
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
|
||||
use fparkan_texm::decode_texm;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
const TEXM_KIND: u32 = 0x6d78_6554;
|
||||
|
||||
/// Corpus kind.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum CorpusKind {
|
||||
@@ -39,6 +71,8 @@ pub struct DiscoverOptions {
|
||||
pub struct ManifestEntry {
|
||||
/// Normalized relative path.
|
||||
pub path: String,
|
||||
/// Byte-exact relative host path used for reopening corpus files.
|
||||
pub host_rel_path: PathBuf,
|
||||
/// File size in bytes.
|
||||
pub size: u64,
|
||||
/// SHA-256 content fingerprint.
|
||||
@@ -158,7 +192,7 @@ pub fn discover(root: &Path, options: DiscoverOptions) -> Result<CorpusManifest,
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
walk(root, root, options, &mut files)?;
|
||||
files.sort_by(|a, b| a.path.cmp(&b.path));
|
||||
files.sort_by(|a, b| a.host_rel_path.cmp(&b.host_rel_path));
|
||||
|
||||
let kind = classify(root, &files);
|
||||
let casefold_collisions = detect_casefold_collisions(&files);
|
||||
@@ -213,17 +247,22 @@ fn walk(
|
||||
let rel = path
|
||||
.strip_prefix(root)
|
||||
.map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
|
||||
let rel_text = rel
|
||||
#[cfg(unix)]
|
||||
let rel_bytes = rel.as_os_str().as_bytes();
|
||||
#[cfg(not(unix))]
|
||||
let rel_bytes = rel
|
||||
.to_str()
|
||||
.ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?;
|
||||
let normalized = normalize_relative(rel_text.as_bytes(), PathPolicy::HostCompatible)
|
||||
.map_err(|_| CorpusError::InvalidPath(rel_text.to_string()))?;
|
||||
.ok_or_else(|| CorpusError::InvalidPath(path.display().to_string()))?
|
||||
.as_bytes();
|
||||
let normalized = normalize_relative(rel_bytes, PathPolicy::HostCompatible)
|
||||
.map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
|
||||
let bytes = fs::read(&path).map_err(|source| CorpusError::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
out.push(ManifestEntry {
|
||||
path: normalized.as_str().to_string(),
|
||||
path: normalized.display_lossy().to_string(),
|
||||
host_rel_path: rel.to_path_buf(),
|
||||
size: metadata.len(),
|
||||
hash: sha256(&bytes),
|
||||
});
|
||||
@@ -255,7 +294,7 @@ fn detect_casefold_collisions(files: &[ManifestEntry]) -> Vec<Vec<String>> {
|
||||
let mut grouped: BTreeMap<Vec<u8>, BTreeSet<String>> = BTreeMap::new();
|
||||
for file in files {
|
||||
grouped
|
||||
.entry(ascii_lookup_key(file.path.as_bytes()).0)
|
||||
.entry(ascii_lookup_key(path_identity_bytes(&file.host_rel_path)).0)
|
||||
.or_default()
|
||||
.insert(file.path.clone());
|
||||
}
|
||||
@@ -323,7 +362,7 @@ fn inspect_report_file(
|
||||
) -> CorpusFileRecord {
|
||||
let lower = entry.path.to_ascii_lowercase();
|
||||
let mut variant = inspect_path_metrics(&lower, metrics);
|
||||
let path = root.join(&entry.path);
|
||||
let path = root.join(&entry.host_rel_path);
|
||||
let bytes = match fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(source) => {
|
||||
@@ -336,9 +375,11 @@ fn inspect_report_file(
|
||||
}
|
||||
};
|
||||
if bytes.starts_with(b"NRes") {
|
||||
variant = "nres".to_string();
|
||||
if variant == "file" {
|
||||
variant = "nres".to_string();
|
||||
}
|
||||
bump(metrics, "nres_files", 1);
|
||||
if let Err(message) = inspect_nres_metrics(bytes, metrics) {
|
||||
if let Err(message) = inspect_nres_metrics(&bytes, metrics) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
@@ -346,9 +387,58 @@ fn inspect_report_file(
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
if variant == "land_msh" {
|
||||
if let Err(message) = inspect_land_metrics(&bytes, false) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
variant,
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
if variant == "land_map" {
|
||||
if let Err(message) = inspect_land_metrics(&bytes, true) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
variant,
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if bytes.starts_with(b"NL") {
|
||||
variant = "rsli".to_string();
|
||||
bump(metrics, "rsli_files", 1);
|
||||
if let Err(message) = inspect_rsli_metrics(&bytes) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
variant,
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
} else if lower.ends_with("data.tma") {
|
||||
if let Err(message) = inspect_tma_metrics(&bytes) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
variant: "tma".to_string(),
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
} else if has_extension(&lower, "dat")
|
||||
&& (lower.starts_with("units/") || lower.contains("/units/"))
|
||||
{
|
||||
variant = "unit_dat".to_string();
|
||||
if let Err(message) = inspect_unit_dat_metrics(&bytes) {
|
||||
return CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
status: CorpusFileStatus::Error,
|
||||
variant,
|
||||
message: Some(message),
|
||||
};
|
||||
}
|
||||
}
|
||||
CorpusFileRecord {
|
||||
path: entry.path.clone(),
|
||||
@@ -358,6 +448,17 @@ fn inspect_report_file(
|
||||
}
|
||||
}
|
||||
|
||||
fn path_identity_bytes(path: &Path) -> &[u8] {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
path.as_os_str().as_bytes()
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
path.to_str().unwrap_or_default().as_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> String {
|
||||
let mut variant = "file";
|
||||
if lower.ends_with("data.tma") {
|
||||
@@ -379,26 +480,31 @@ fn inspect_path_metrics(lower: &str, metrics: &mut BTreeMap<String, u64>) -> Str
|
||||
variant.to_string()
|
||||
}
|
||||
|
||||
fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
|
||||
let entries = inspect_nres_entries(bytes)?;
|
||||
bump(metrics, "nres_entries", entries.len() as u64);
|
||||
for entry in entries {
|
||||
fn inspect_nres_metrics(bytes: &[u8], metrics: &mut BTreeMap<String, u64>) -> Result<(), String> {
|
||||
let document = inspect_nres_document(bytes)?;
|
||||
bump(metrics, "nres_entries", document.entries().len() as u64);
|
||||
for entry in document.entries() {
|
||||
let name = String::from_utf8_lossy(entry.name_bytes()).to_ascii_lowercase();
|
||||
if has_extension(&name, "msh") {
|
||||
bump(metrics, "msh_entries", 1);
|
||||
validate_nres_msh_payload(&document, entry)?;
|
||||
}
|
||||
match entry.meta().type_id {
|
||||
0x3054_414D => {
|
||||
MAT0_KIND => {
|
||||
bump(metrics, "mat0_entries", 1);
|
||||
validate_nres_mat0_payload(&document, entry)?;
|
||||
}
|
||||
0x6D78_6554 => {
|
||||
TEXM_KIND => {
|
||||
bump(metrics, "texm_entries", 1);
|
||||
validate_nres_texm_payload(&document, entry)?;
|
||||
}
|
||||
0x4449_5846 => {
|
||||
FXID_KIND => {
|
||||
bump(metrics, "fxid_entries", 1);
|
||||
validate_nres_fxid_payload(&document, entry)?;
|
||||
}
|
||||
0x5241_4557 => {
|
||||
WEAR_KIND => {
|
||||
bump(metrics, "wear_entries", 1);
|
||||
validate_nres_wear_payload(&document, entry)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -406,6 +512,110 @@ fn inspect_nres_metrics(bytes: Vec<u8>, metrics: &mut BTreeMap<String, u64>) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_msh_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document
|
||||
.payload(entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
let nested = fparkan_nres::decode(
|
||||
Arc::from(payload.to_vec().into_boxed_slice()),
|
||||
fparkan_nres::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
let model = decode_msh(&nested).map_err(|err| err.to_string())?;
|
||||
validate_msh(&model).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_mat0_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document
|
||||
.payload(entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
decode_mat0(payload, entry.meta().attr2).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_wear_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document
|
||||
.payload(entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
decode_wear(payload).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_texm_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document
|
||||
.payload(entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
decode_texm(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_nres_fxid_payload(
|
||||
document: &NresDocument,
|
||||
entry: &fparkan_nres::NresEntry,
|
||||
) -> Result<(), String> {
|
||||
let payload = document
|
||||
.payload(entry.id())
|
||||
.map_err(|err| err.to_string())?;
|
||||
decode_fxid(Arc::from(payload.to_vec().into_boxed_slice())).map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_rsli_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||
let _ = decode_rsli(
|
||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||
ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_tma_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||
let _ = decode_tma(
|
||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||
TmaProfile::Strict,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_unit_dat_metrics(bytes: &[u8]) -> Result<(), String> {
|
||||
if decode_unit_dat(bytes).is_err() && decode_unit_dat_binding(bytes).is_err() {
|
||||
return Err("failed to parse unit.dat payload as unit or binding format".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_land_metrics(bytes: &[u8], is_map: bool) -> Result<(), String> {
|
||||
let document = inspect_nres_document(bytes)?;
|
||||
if is_map {
|
||||
decode_land_map(&document).map_err(|err| err.to_string())?;
|
||||
} else {
|
||||
decode_land_msh(&document).map_err(|err| err.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inspect_nres_document(bytes: &[u8]) -> Result<NresDocument, String> {
|
||||
fparkan_nres::decode(
|
||||
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||
fparkan_nres::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn bump(metrics: &mut BTreeMap<String, u64>, key: &str, delta: u64) {
|
||||
if let Some(value) = metrics.get_mut(key) {
|
||||
*value = value.saturating_add(delta);
|
||||
@@ -418,15 +628,6 @@ fn has_extension(path: &str, expected: &str) -> bool {
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
|
||||
}
|
||||
|
||||
fn inspect_nres_entries(bytes: Vec<u8>) -> Result<Vec<fparkan_nres::NresEntry>, String> {
|
||||
let document = fparkan_nres::decode(
|
||||
Arc::from(bytes.into_boxed_slice()),
|
||||
fparkan_nres::ReadProfile::Compatible,
|
||||
)
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(document.entries().to_vec())
|
||||
}
|
||||
|
||||
/// Computes stable manifest fingerprint.
|
||||
#[must_use]
|
||||
pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
|
||||
@@ -586,11 +787,7 @@ mod tests {
|
||||
fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() {
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Part1,
|
||||
files: vec![ManifestEntry {
|
||||
path: "secret/payload.bin".to_string(),
|
||||
size: 4,
|
||||
hash: sha256(b"DATA"),
|
||||
}],
|
||||
files: vec![manifest_entry("secret/payload.bin", 4, sha256(b"DATA"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
let report = report(Path::new("."), &manifest).expect("report");
|
||||
@@ -610,11 +807,7 @@ mod tests {
|
||||
let root = temp_dir("report-missing");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "missing.lib".to_string(),
|
||||
size: 1,
|
||||
hash: sha256(b"missing"),
|
||||
}],
|
||||
files: vec![manifest_entry("missing.lib", 1, sha256(b"missing"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -633,11 +826,7 @@ mod tests {
|
||||
fs::write(root.join("bad.lib"), b"NRes").expect("bad nres");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "bad.lib".to_string(),
|
||||
size: 4,
|
||||
hash: sha256(b"NRes"),
|
||||
}],
|
||||
files: vec![manifest_entry("bad.lib", 4, sha256(b"NRes"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
@@ -676,25 +865,131 @@ mod tests {
|
||||
fs::write(root.join("archive.lib"), &archive).expect("archive");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "archive.lib".to_string(),
|
||||
size: u64::try_from(archive.len()).expect("archive size"),
|
||||
hash: sha256(&archive),
|
||||
}],
|
||||
files: vec![manifest_entry(
|
||||
"archive.lib",
|
||||
u64::try_from(archive.len()).expect("archive size"),
|
||||
sha256(&archive),
|
||||
)],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 1);
|
||||
assert_eq!(report.records.len(), 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||
assert_eq!(report.records[0].variant, "nres");
|
||||
assert_eq!(report.metrics["nres_files"], 1);
|
||||
assert_eq!(report.metrics["nres_entries"], 3);
|
||||
assert_eq!(report.metrics["msh_entries"], 1);
|
||||
assert_eq!(report.metrics["mat0_entries"], 0);
|
||||
assert_eq!(report.metrics["texm_entries"], 0);
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_land_map_paths_use_production_land_parser() {
|
||||
let root = temp_dir("report-land-map");
|
||||
fs::create_dir_all(root.join("WORLD/MAP")).expect("land map dir");
|
||||
fs::write(root.join("WORLD/MAP/land.map"), build_nres(&[])).expect("land map");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![manifest_entry(
|
||||
"WORLD/MAP/land.map",
|
||||
16,
|
||||
sha256(b"land.map"),
|
||||
)],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||
assert_eq!(report.records[0].variant, "land_map");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_land_msh_paths_use_production_land_parser() {
|
||||
let root = temp_dir("report-land-msh");
|
||||
fs::create_dir_all(root.join("WORLD/MAP")).expect("land msh dir");
|
||||
fs::write(root.join("WORLD/MAP/land.msh"), build_nres(&[])).expect("land msh");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![manifest_entry(
|
||||
"WORLD/MAP/land.msh",
|
||||
16,
|
||||
sha256(b"land.msh"),
|
||||
)],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||
assert_eq!(report.records[0].variant, "land_msh");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_tma_paths_use_production_tma_parser() {
|
||||
let root = temp_dir("report-tma");
|
||||
fs::create_dir_all(root.join("MISSIONS/test")).expect("tma dir");
|
||||
fs::write(root.join("MISSIONS/test/data.tma"), b"malformed tma").expect("tma");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![manifest_entry(
|
||||
"MISSIONS/test/data.tma",
|
||||
12,
|
||||
sha256(b"malformed tma"),
|
||||
)],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||
assert_eq!(report.records[0].variant, "tma");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_unit_dat_paths_use_production_unit_parser() {
|
||||
let root = temp_dir("report-unit");
|
||||
fs::create_dir_all(root.join("units")).expect("unit dir");
|
||||
fs::write(root.join("units/unit.dat"), vec![0u8; 120]).expect("unit");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![manifest_entry("units/unit.dat", 120, sha256(&[0u8; 120]))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 0);
|
||||
assert_eq!(report.records.len(), 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Ok);
|
||||
assert_eq!(report.records[0].variant, "nres");
|
||||
assert_eq!(report.metrics["nres_files"], 1);
|
||||
assert_eq!(report.metrics["nres_entries"], 3);
|
||||
assert_eq!(report.metrics["msh_entries"], 1);
|
||||
assert_eq!(report.metrics["mat0_entries"], 1);
|
||||
assert_eq!(report.metrics["texm_entries"], 1);
|
||||
assert_eq!(report.records[0].variant, "unit_dat");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_rsli_paths_use_production_rsli_parser() {
|
||||
let root = temp_dir("report-rsli");
|
||||
fs::write(root.join("patch.nl"), b"NL malformed").expect("rsli");
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![manifest_entry("patch.nl", 12, sha256(b"NL malformed"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
|
||||
let report = report(&root, &manifest).expect("report");
|
||||
|
||||
assert_eq!(report.failures, 1);
|
||||
assert_eq!(report.records[0].status, CorpusFileStatus::Error);
|
||||
assert_eq!(report.records[0].variant, "rsli");
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
@@ -757,16 +1052,8 @@ mod tests {
|
||||
let manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![
|
||||
ManifestEntry {
|
||||
path: "Textures/Foo.TEX".to_string(),
|
||||
size: 1,
|
||||
hash: sha256(b"first"),
|
||||
},
|
||||
ManifestEntry {
|
||||
path: "textures/foo.tex".to_string(),
|
||||
size: 1,
|
||||
hash: sha256(b"second"),
|
||||
},
|
||||
manifest_entry("Textures/Foo.TEX", 1, sha256(b"first")),
|
||||
manifest_entry("textures/foo.tex", 1, sha256(b"second")),
|
||||
],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
@@ -786,11 +1073,7 @@ mod tests {
|
||||
fn fingerprint_changes() {
|
||||
let mut manifest = CorpusManifest {
|
||||
kind: CorpusKind::Unknown,
|
||||
files: vec![ManifestEntry {
|
||||
path: "a".to_string(),
|
||||
size: 1,
|
||||
hash: sha256(b"before"),
|
||||
}],
|
||||
files: vec![manifest_entry("a", 1, sha256(b"before"))],
|
||||
casefold_collisions: Vec::new(),
|
||||
};
|
||||
let a = fingerprint(&manifest);
|
||||
@@ -823,6 +1106,29 @@ mod tests {
|
||||
let _ = fs::remove_file(tmp);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn discover_supports_non_utf8_host_paths() {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
let root = temp_dir("non-utf8");
|
||||
let file_name = OsString::from_vec(vec![0xFF, b'.', b'b', b'i', b'n']);
|
||||
let file_path = root.join(&file_name);
|
||||
if let Err(err) = fs::write(&file_path, b"raw") {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
let _ = fs::remove_dir_all(root);
|
||||
return;
|
||||
}
|
||||
|
||||
let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
|
||||
|
||||
assert_eq!(manifest.files.len(), 1);
|
||||
assert_eq!(manifest.files[0].path, "\u{FFFD}.bin");
|
||||
assert_eq!(manifest.files[0].host_rel_path, PathBuf::from(&file_name));
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
struct TestNresEntry<'a> {
|
||||
name: &'a str,
|
||||
type_id: u32,
|
||||
@@ -869,6 +1175,15 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
fn manifest_entry(path: &str, size: u64, hash: Sha256Digest) -> ManifestEntry {
|
||||
ManifestEntry {
|
||||
path: path.to_string(),
|
||||
host_rel_path: PathBuf::from(path),
|
||||
size,
|
||||
hash,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
||||
out.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
#![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)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Severity {
|
||||
/// Informational note.
|
||||
Info,
|
||||
@@ -15,7 +37,8 @@ pub enum Severity {
|
||||
}
|
||||
|
||||
/// Evidence level for a contract or interpretation.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum EvidenceStatus {
|
||||
/// Described by project documentation.
|
||||
Documented,
|
||||
@@ -30,7 +53,8 @@ pub enum EvidenceStatus {
|
||||
}
|
||||
|
||||
/// Operation phase where a diagnostic was produced.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Phase {
|
||||
/// Discovery.
|
||||
Discover,
|
||||
@@ -55,7 +79,7 @@ pub enum Phase {
|
||||
}
|
||||
|
||||
/// Byte span in an input source.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct SourceSpan {
|
||||
/// Start offset.
|
||||
pub offset: u64,
|
||||
@@ -64,26 +88,31 @@ pub struct SourceSpan {
|
||||
}
|
||||
|
||||
/// Stable diagnostic code.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
#[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)]
|
||||
#[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)]
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
pub struct Diagnostic {
|
||||
/// Stable code.
|
||||
pub code: DiagnosticCode,
|
||||
@@ -145,104 +174,13 @@ pub fn render_human(diagnostic: &Diagnostic) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Renders deterministic JSON without requiring a serialization dependency.
|
||||
/// Renders deterministic JSON using the typed diagnostic schema.
|
||||
#[must_use]
|
||||
pub fn render_json(diagnostic: &Diagnostic) -> String {
|
||||
fn esc(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
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"),
|
||||
_ => out.push(ch),
|
||||
}
|
||||
}
|
||||
out
|
||||
match serde_json::to_string(diagnostic) {
|
||||
Ok(json) => json,
|
||||
Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
|
||||
}
|
||||
|
||||
let mut out = String::new();
|
||||
out.push('{');
|
||||
out.push_str("\"code\":\"");
|
||||
out.push_str(&esc(diagnostic.code.0));
|
||||
out.push_str("\",\"severity\":\"");
|
||||
out.push_str(match diagnostic.severity {
|
||||
Severity::Info => "info",
|
||||
Severity::Warning => "warning",
|
||||
Severity::Error => "error",
|
||||
Severity::Fatal => "fatal",
|
||||
});
|
||||
out.push_str("\",\"message\":\"");
|
||||
out.push_str(&esc(&diagnostic.message));
|
||||
out.push_str("\",\"context\":{");
|
||||
if let Some(phase) = diagnostic.context.phase {
|
||||
out.push_str("\"phase\":\"");
|
||||
out.push_str(match phase {
|
||||
Phase::Discover => "discover",
|
||||
Phase::Read => "read",
|
||||
Phase::Parse => "parse",
|
||||
Phase::Validate => "validate",
|
||||
Phase::Resolve => "resolve",
|
||||
Phase::Prepare => "prepare",
|
||||
Phase::Construct => "construct",
|
||||
Phase::Register => "register",
|
||||
Phase::Simulate => "simulate",
|
||||
Phase::Render => "render",
|
||||
});
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(path) = &diagnostic.context.path {
|
||||
if diagnostic.context.phase.is_some() {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"path\":\"");
|
||||
out.push_str(&esc(path));
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(entry) = &diagnostic.context.archive_entry {
|
||||
if diagnostic.context.phase.is_some() || diagnostic.context.path.is_some() {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"archive_entry\":\"");
|
||||
out.push_str(&esc(entry));
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(key) = &diagnostic.context.object_key {
|
||||
if diagnostic.context.phase.is_some()
|
||||
|| diagnostic.context.path.is_some()
|
||||
|| diagnostic.context.archive_entry.is_some()
|
||||
{
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"object_key\":\"");
|
||||
out.push_str(&esc(key));
|
||||
out.push('"');
|
||||
}
|
||||
if let Some(span) = diagnostic.context.span {
|
||||
if diagnostic.context.phase.is_some()
|
||||
|| diagnostic.context.path.is_some()
|
||||
|| diagnostic.context.archive_entry.is_some()
|
||||
|| diagnostic.context.object_key.is_some()
|
||||
{
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str("\"span\":{\"offset\":");
|
||||
out.push_str(&span.offset.to_string());
|
||||
out.push_str(",\"length\":");
|
||||
out.push_str(&span.length.to_string());
|
||||
out.push('}');
|
||||
}
|
||||
out.push_str("},\"causes\":[");
|
||||
for (idx, cause) in diagnostic.causes.iter().enumerate() {
|
||||
if idx > 0 {
|
||||
out.push(',');
|
||||
}
|
||||
out.push_str(&render_json(cause));
|
||||
}
|
||||
out.push_str("]}");
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -298,4 +236,14 @@ mod tests {
|
||||
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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! FXID effect contracts.
|
||||
//!
|
||||
//! FXID decoding and command framing are implemented as compatibility
|
||||
|
||||
@@ -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
@@ -7,12 +7,12 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
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" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! WEAR/MAT0 material contracts.
|
||||
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
|
||||
@@ -7,7 +7,7 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Count-driven mission format primitives.
|
||||
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
|
||||
@@ -7,10 +7,8 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-animation = { path = "../fparkan-animation" }
|
||||
fparkan-animation = { path = "../fparkan-animation", version = "0.1.0" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Stage-3 MSH asset contract.
|
||||
|
||||
use encoding_rs::WINDOWS_1251;
|
||||
use fparkan_animation::{
|
||||
evaluate_hierarchy, AnimKey24, AnimationTime, NodePoseBuffer, ParentIndex, Pose, TimedPoseKey,
|
||||
TimedPoseTrack,
|
||||
};
|
||||
use fparkan_nres::{EntryMeta, NresDocument, NresError};
|
||||
|
||||
/// Node table stream.
|
||||
@@ -91,6 +114,22 @@ pub struct ModelAsset {
|
||||
pub batches: Vec<Batch>,
|
||||
/// Optional decoded node names.
|
||||
pub node_names: Option<Vec<Option<String>>>,
|
||||
/// Optional decoded node-animation streams.
|
||||
pub animation: Option<ModelAnimation>,
|
||||
}
|
||||
|
||||
/// Decoded MSH node-animation streams.
|
||||
///
|
||||
/// This preserves the exact type-8/type-19 boundary used by `Node38` without
|
||||
/// assigning runtime-state ownership to a static asset.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ModelAnimation {
|
||||
/// Type-8 animation keys in source order.
|
||||
pub keys: Vec<AnimKey24>,
|
||||
/// Type-19 frame-to-key mapping words in source order.
|
||||
pub frame_map: Vec<u16>,
|
||||
/// Declared frame count from type-19 `attr2`.
|
||||
pub frame_count: u32,
|
||||
}
|
||||
|
||||
/// Node id.
|
||||
@@ -108,6 +147,17 @@ pub struct Node {
|
||||
pub raw: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Raw fields of the standard 38-byte node layout that precede slot selection.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Node38Metadata {
|
||||
/// Unassigned source link value at byte offset two.
|
||||
pub parent_or_link_raw: u16,
|
||||
/// Offset into the type-19 frame map, or `0xFFFF` for no map.
|
||||
pub anim_map_start: u16,
|
||||
/// Fallback type-8 key index.
|
||||
pub fallback_key: u16,
|
||||
}
|
||||
|
||||
/// Slot descriptor.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Slot {
|
||||
@@ -368,6 +418,151 @@ pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -
|
||||
(slot < model.slots.len()).then_some(SlotId(u32::from(raw)))
|
||||
}
|
||||
|
||||
/// Returns the undecorated metadata of a standard 38-byte node.
|
||||
#[must_use]
|
||||
pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadata> {
|
||||
if model.node_stride != 38 {
|
||||
return None;
|
||||
}
|
||||
let node_index = usize::try_from(node.0).ok()?;
|
||||
if node_index >= model.node_count {
|
||||
return None;
|
||||
}
|
||||
let offset = node_index.checked_mul(model.node_stride)?;
|
||||
Some(Node38Metadata {
|
||||
parent_or_link_raw: read_u16(&model.nodes_raw, offset + 2)?,
|
||||
anim_map_start: read_u16(&model.nodes_raw, offset + 4)?,
|
||||
fallback_key: read_u16(&model.nodes_raw, offset + 6)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the source fallback pose of a standard node.
|
||||
///
|
||||
/// The legacy sampler uses this key when a node has no frame map or its current
|
||||
/// frame falls outside that map. It is a static-pose input only; callers must
|
||||
/// still recover animation state and the meaning of `parent_or_link_raw` before
|
||||
/// assembling a runtime hierarchy.
|
||||
#[must_use]
|
||||
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
|
||||
let metadata = node38_metadata(model, node)?;
|
||||
model
|
||||
.animation
|
||||
.as_ref()?
|
||||
.keys
|
||||
.get(usize::from(metadata.fallback_key))
|
||||
.map(AnimKey24::sampling_pose)
|
||||
}
|
||||
|
||||
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
|
||||
///
|
||||
/// `0xFFFF` denotes a root. Every non-root source link must name an earlier
|
||||
/// node, which preserves the producer's parent-before-child ordering. Models
|
||||
/// that do not meet this established `Node38` contract return `None` so callers
|
||||
/// can retain an explicitly unhierarchical diagnostic path.
|
||||
#[must_use]
|
||||
pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
|
||||
if model.node_stride != 38 || model.node_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut parents = Vec::with_capacity(model.node_count);
|
||||
let mut poses = Vec::with_capacity(model.node_count);
|
||||
for index in 0..model.node_count {
|
||||
let node = NodeId(u32::try_from(index).ok()?);
|
||||
let metadata = node38_metadata(model, node)?;
|
||||
let parent = if metadata.parent_or_link_raw == u16::MAX {
|
||||
ParentIndex(None)
|
||||
} else {
|
||||
let parent = usize::from(metadata.parent_or_link_raw);
|
||||
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
|
||||
};
|
||||
parents.push(parent);
|
||||
poses.push(node38_fallback_pose(model, node)?);
|
||||
}
|
||||
evaluate_hierarchy(&parents, &poses).ok()
|
||||
}
|
||||
|
||||
/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
|
||||
/// model at one explicitly supplied logical frame.
|
||||
///
|
||||
/// A node without a usable type-19 map, or a requested frame outside the
|
||||
/// declared map length, retains its exact fallback key. Mapped keys are
|
||||
/// sampled against their immediate successor using the decoded key times.
|
||||
/// This is an offline asset-sampling contract; it does not claim ownership of
|
||||
/// the original runtime's animation clock or x87 numeric profile.
|
||||
///
|
||||
/// Returns `None` when the model lacks a complete standard-node animation
|
||||
/// representation or the map cannot be safely resolved.
|
||||
#[must_use]
|
||||
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
|
||||
if model.node_stride != 38 || model.node_count == 0 {
|
||||
return None;
|
||||
}
|
||||
let animation = model.animation.as_ref()?;
|
||||
let mut parents = Vec::with_capacity(model.node_count);
|
||||
let mut poses = Vec::with_capacity(model.node_count);
|
||||
for index in 0..model.node_count {
|
||||
let node = NodeId(u32::try_from(index).ok()?);
|
||||
let metadata = node38_metadata(model, node)?;
|
||||
let parent = if metadata.parent_or_link_raw == u16::MAX {
|
||||
ParentIndex(None)
|
||||
} else {
|
||||
let parent = usize::from(metadata.parent_or_link_raw);
|
||||
(parent < index).then_some(ParentIndex(Some(metadata.parent_or_link_raw)))?
|
||||
};
|
||||
let fallback_index = usize::from(metadata.fallback_key);
|
||||
let _fallback = animation.keys.get(fallback_index)?;
|
||||
let key_index =
|
||||
if metadata.anim_map_start == u16::MAX || u32::from(frame) >= animation.frame_count {
|
||||
fallback_index
|
||||
} else {
|
||||
let mapped_index =
|
||||
usize::from(*animation.frame_map.get(
|
||||
usize::from(metadata.anim_map_start).checked_add(usize::from(frame))?,
|
||||
)?);
|
||||
if mapped_index < fallback_index {
|
||||
mapped_index
|
||||
} else {
|
||||
fallback_index
|
||||
}
|
||||
};
|
||||
let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
|
||||
parents.push(parent);
|
||||
poses.push(pose);
|
||||
}
|
||||
evaluate_hierarchy(&parents, &poses).ok()
|
||||
}
|
||||
|
||||
fn sample_node38_key_pair(
|
||||
keys: &[AnimKey24],
|
||||
key_index: usize,
|
||||
fallback_index: usize,
|
||||
frame: u16,
|
||||
) -> Option<Pose> {
|
||||
let key = *keys.get(key_index)?;
|
||||
if key_index == fallback_index {
|
||||
return Some(key.sampling_pose());
|
||||
}
|
||||
let next = *keys.get(key_index.checked_add(1)?)?;
|
||||
if next.time.0 <= key.time.0 {
|
||||
return Some(key.sampling_pose());
|
||||
}
|
||||
let track = TimedPoseTrack::new(
|
||||
key.sampling_pose(),
|
||||
vec![
|
||||
TimedPoseKey {
|
||||
time: key.time,
|
||||
pose: key.sampling_pose(),
|
||||
},
|
||||
TimedPoseKey {
|
||||
time: next.time,
|
||||
pose: next.sampling_pose(),
|
||||
},
|
||||
],
|
||||
)
|
||||
.ok()?;
|
||||
track.sample(AnimationTime(f32::from(frame))).ok()
|
||||
}
|
||||
|
||||
/// Returns draw batches for a validated slot.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -507,6 +702,7 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
|
||||
let node_names = read_optional_stream(document, STREAM_NAMES)?
|
||||
.map(|raw| parse_res10_names(&raw.bytes, node_count))
|
||||
.transpose()?;
|
||||
let animation = parse_optional_animation(document)?;
|
||||
|
||||
Ok(ModelAsset {
|
||||
node_stride,
|
||||
@@ -519,9 +715,37 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
|
||||
indices,
|
||||
batches,
|
||||
node_names,
|
||||
animation,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_optional_animation(document: &NresDocument) -> Result<Option<ModelAnimation>, MshError> {
|
||||
let keys = read_optional_stream(document, STREAM_ANIMATION_KEYS)?;
|
||||
let frame_map = read_optional_stream(document, STREAM_ANIMATION_FRAME_MAP)?;
|
||||
let (Some(keys), Some(frame_map)) = (keys, frame_map) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !keys.bytes.len().is_multiple_of(24) {
|
||||
return Err(invalid_resource_size("Res8", keys.bytes.len(), 24));
|
||||
}
|
||||
if !frame_map.bytes.len().is_multiple_of(2) {
|
||||
return Err(invalid_resource_size("Res19", frame_map.bytes.len(), 2));
|
||||
}
|
||||
let keys = keys
|
||||
.bytes
|
||||
.chunks_exact(24)
|
||||
.map(AnimKey24::decode)
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|err| MshError::InvalidGeometry(format!("invalid Res8 animation key: {err}")))?;
|
||||
let frame_count = frame_map.attributes.attr2;
|
||||
let frame_map = parse_u16_array(&frame_map.bytes, "Res19")?;
|
||||
Ok(Some(ModelAnimation {
|
||||
keys,
|
||||
frame_map,
|
||||
frame_count,
|
||||
}))
|
||||
}
|
||||
|
||||
struct RawStream {
|
||||
attributes: EntryAttributes,
|
||||
bytes: Vec<u8>,
|
||||
@@ -1044,6 +1268,151 @@ mod tests {
|
||||
assert_eq!(selected_slot(&model, NodeId(0), Lod(2), Group(4)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_node_exposes_fallback_pose_and_unassigned_link() {
|
||||
let mut node = node38([u16::MAX; 15]);
|
||||
node[2..4].copy_from_slice(&7_u16.to_le_bytes());
|
||||
node[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
node[6..8].copy_from_slice(&0_u16.to_le_bytes());
|
||||
let mut key = Vec::new();
|
||||
push_f32(&mut key, 1.0);
|
||||
push_f32(&mut key, 2.0);
|
||||
push_f32(&mut key, 3.0);
|
||||
push_f32(&mut key, 0.0);
|
||||
push_u16(&mut key, 0);
|
||||
push_u16(&mut key, 0);
|
||||
push_u16(&mut key, 0);
|
||||
push_u16(&mut key, 32_767);
|
||||
let document = decode_nested(&build_nres(&[
|
||||
stream(STREAM_NODE_TABLE, 38, b"Res1", &node),
|
||||
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
|
||||
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
|
||||
stream(STREAM_INDICES, 0, b"Res6", &[]),
|
||||
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &key),
|
||||
stream(STREAM_BATCHES, 0, b"Res13", &[]),
|
||||
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
|
||||
]))
|
||||
.expect("nested NRes");
|
||||
let model =
|
||||
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
|
||||
|
||||
assert_eq!(
|
||||
node38_metadata(&model, NodeId(0)),
|
||||
Some(Node38Metadata {
|
||||
parent_or_link_raw: 7,
|
||||
anim_map_start: u16::MAX,
|
||||
fallback_key: 0,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
node38_fallback_pose(&model, NodeId(0)).map(|pose| pose.translation),
|
||||
Some([1.0, 2.0, 3.0])
|
||||
);
|
||||
assert_eq!(
|
||||
model
|
||||
.animation
|
||||
.as_ref()
|
||||
.map(|animation| animation.keys.len()),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_nodes_evaluate_fallback_parent_hierarchy() {
|
||||
let mut root = node38([u16::MAX; 15]);
|
||||
root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
root[6..8].copy_from_slice(&0_u16.to_le_bytes());
|
||||
let mut child = node38([u16::MAX; 15]);
|
||||
child[2..4].copy_from_slice(&0_u16.to_le_bytes());
|
||||
child[6..8].copy_from_slice(&1_u16.to_le_bytes());
|
||||
let mut nodes = root;
|
||||
nodes.extend(child);
|
||||
let mut keys = Vec::new();
|
||||
for (x, y, z, qz, qw) in [
|
||||
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
|
||||
(2.0, 0.0, 0.0, 0_i16, 32_767_i16),
|
||||
] {
|
||||
push_f32(&mut keys, x);
|
||||
push_f32(&mut keys, y);
|
||||
push_f32(&mut keys, z);
|
||||
push_f32(&mut keys, 0.0);
|
||||
push_u16(&mut keys, 0);
|
||||
push_u16(&mut keys, 0);
|
||||
push_u16(&mut keys, qz.cast_unsigned());
|
||||
push_u16(&mut keys, qw.cast_unsigned());
|
||||
}
|
||||
let document = decode_nested(&build_nres(&[
|
||||
stream(STREAM_NODE_TABLE, 38, b"Res1", &nodes),
|
||||
stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
|
||||
stream(STREAM_POSITIONS, 0, b"Res3", &[]),
|
||||
stream(STREAM_INDICES, 0, b"Res6", &[]),
|
||||
stream(STREAM_ANIMATION_KEYS, 0, b"Res8", &keys),
|
||||
stream(STREAM_BATCHES, 0, b"Res13", &[]),
|
||||
stream(STREAM_ANIMATION_FRAME_MAP, 0, b"Res19", &[]),
|
||||
]))
|
||||
.expect("nested NRes");
|
||||
let model =
|
||||
validate_msh(&decode_msh(&document).expect("msh document")).expect("model asset");
|
||||
let hierarchy = node38_fallback_hierarchy(&model).expect("valid node hierarchy");
|
||||
assert!((hierarchy.poses[1].translation[0] - 1.0).abs() < 0.001);
|
||||
assert!((hierarchy.poses[1].translation[1] - 2.0).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_nodes_sample_mapped_key_before_fallback_key() {
|
||||
let mut node = node38([u16::MAX; 15]);
|
||||
node[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
|
||||
node[4..6].copy_from_slice(&0_u16.to_le_bytes());
|
||||
node[6..8].copy_from_slice(&2_u16.to_le_bytes());
|
||||
let model = ModelAsset {
|
||||
node_stride: 38,
|
||||
node_count: 1,
|
||||
nodes_raw: node,
|
||||
slots: Vec::new(),
|
||||
positions: Vec::new(),
|
||||
normals: None,
|
||||
uv0: None,
|
||||
indices: Vec::new(),
|
||||
batches: Vec::new(),
|
||||
node_names: None,
|
||||
animation: Some(ModelAnimation {
|
||||
keys: vec![
|
||||
AnimKey24 {
|
||||
time: AnimationTime(0.0),
|
||||
pose: Pose {
|
||||
translation: [4.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
},
|
||||
},
|
||||
AnimKey24 {
|
||||
time: AnimationTime(2.0),
|
||||
pose: Pose {
|
||||
translation: [8.0, 0.0, 0.0],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
},
|
||||
},
|
||||
AnimKey24 {
|
||||
time: AnimationTime(9.0),
|
||||
pose: Pose::default(),
|
||||
},
|
||||
],
|
||||
frame_map: vec![0, 0, 0],
|
||||
frame_count: 3,
|
||||
}),
|
||||
};
|
||||
|
||||
let pose = node38_sampled_hierarchy(&model, 1)
|
||||
.expect("mapped hierarchy")
|
||||
.poses[0];
|
||||
assert!((pose.translation[0] - 6.0).abs() < f32::EPSILON);
|
||||
assert_eq!(
|
||||
node38_sampled_hierarchy(&model, 9)
|
||||
.expect("fallback hierarchy")
|
||||
.poses[0],
|
||||
Pose::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type2_header_and_slot_tail_framing_are_exact() {
|
||||
let too_small = decode_nested(&build_nres(&[
|
||||
@@ -1336,6 +1705,13 @@ mod tests {
|
||||
let model = validate_msh(&msh).unwrap_or_else(|err| {
|
||||
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
||||
});
|
||||
if model.node_stride == 38 {
|
||||
assert!(
|
||||
node38_fallback_hierarchy(&model).is_some(),
|
||||
"{corpus} {path:?} {:?}: Node38 hierarchy is not parent-before-child",
|
||||
entry.name_bytes()
|
||||
);
|
||||
}
|
||||
let preserved = msh.preserved_streams().unwrap_or_else(|err| {
|
||||
panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
|
||||
});
|
||||
@@ -1689,7 +2065,7 @@ mod tests {
|
||||
out
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[allow(clippy::similar_names, clippy::too_many_arguments)]
|
||||
fn batch_record(
|
||||
batch_flags: u16,
|
||||
material_index: u16,
|
||||
|
||||
@@ -6,8 +6,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+242
-20
@@ -1,7 +1,26 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Strict and lossless `NRes` archive support.
|
||||
|
||||
use fparkan_binary::{Cursor, DecodeError};
|
||||
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError};
|
||||
use fparkan_path::{ascii_lookup_key, LookupKey};
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
@@ -32,6 +51,33 @@ pub enum WriteProfile {
|
||||
CanonicalCompact,
|
||||
}
|
||||
|
||||
/// Decode-time archive limits.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DecodeLimits {
|
||||
/// Maximum accepted source archive bytes.
|
||||
pub max_input_bytes: u64,
|
||||
/// Maximum accepted entry count.
|
||||
pub max_entries: u32,
|
||||
/// Maximum accepted single payload byte length.
|
||||
pub max_decoded_entry_bytes: u64,
|
||||
/// Maximum accepted cumulative payload bytes.
|
||||
pub max_total_decoded_bytes: u64,
|
||||
/// Maximum accepted preserved-region bytes.
|
||||
pub max_preserved_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for DecodeLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_input_bytes: 256 * 1024 * 1024,
|
||||
max_entries: 1_000_000,
|
||||
max_decoded_entry_bytes: 256 * 1024 * 1024,
|
||||
max_total_decoded_bytes: 512 * 1024 * 1024,
|
||||
max_preserved_bytes: 256 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `NRes` archive header.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct NresHeader {
|
||||
@@ -324,16 +370,31 @@ impl From<DecodeError> for NresError {
|
||||
/// Returns [`NresError`] when the header, directory, payload ranges, or strict
|
||||
/// lookup permutation are malformed for the selected [`ReadProfile`].
|
||||
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<NresDocument, NresError> {
|
||||
let header = parse_header(&bytes)?;
|
||||
let entries = parse_entries(&bytes, &header)?;
|
||||
decode_with_limits(bytes, profile, DecodeLimits::default())
|
||||
}
|
||||
|
||||
/// Decodes `NRes` bytes with explicit archive limits.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NresError`] when the input exceeds configured limits, the header,
|
||||
/// directory, payload ranges, or strict lookup permutation are malformed.
|
||||
pub fn decode_with_limits(
|
||||
bytes: Arc<[u8]>,
|
||||
profile: ReadProfile,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<NresDocument, NresError> {
|
||||
let header = parse_header(&bytes, limits)?;
|
||||
let entries = parse_entries(&bytes, &header, limits)?;
|
||||
validate_names(&entries)?;
|
||||
validate_payload_ranges(&entries)?;
|
||||
validate_payload_ranges(&entries, limits)?;
|
||||
let lookup_order_valid = match validate_lookup_order(&entries) {
|
||||
Ok(valid) => valid,
|
||||
Ok(()) => true,
|
||||
Err(err) if profile == ReadProfile::Strict => return Err(err),
|
||||
Err(_) => false,
|
||||
};
|
||||
let preserved_regions = find_preserved_regions(&bytes, &entries, header.directory_offset)?;
|
||||
let preserved_regions =
|
||||
find_preserved_regions(&bytes, &entries, header.directory_offset, limits)?;
|
||||
Ok(NresDocument {
|
||||
bytes,
|
||||
header,
|
||||
@@ -418,6 +479,23 @@ impl NresDocument {
|
||||
Ok(&self.bytes[entry.data_range.clone()])
|
||||
}
|
||||
|
||||
/// Returns an owned view of an entry payload without copying its bytes.
|
||||
///
|
||||
/// The returned owner is the immutable archive buffer. Consumers may retain
|
||||
/// the view after dropping this document.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`NresError::EntryIdOutOfRange`] when `id` is not present in
|
||||
/// this document.
|
||||
pub fn payload_view(&self, id: EntryId) -> Result<(Arc<[u8]>, Range<usize>), NresError> {
|
||||
let entry = self.entry(id).ok_or_else(|| NresError::EntryIdOutOfRange {
|
||||
id: id.0,
|
||||
entry_count: saturating_u32_len(self.entries.len()),
|
||||
})?;
|
||||
Ok((Arc::clone(&self.bytes), entry.data_range.clone()))
|
||||
}
|
||||
|
||||
/// Encodes the document according to the selected write profile.
|
||||
#[must_use]
|
||||
pub fn encode(&self, profile: WriteProfile) -> Vec<u8> {
|
||||
@@ -665,7 +743,11 @@ impl NresEntry {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
|
||||
fn parse_header(bytes: &[u8], limits: DecodeLimits) -> Result<NresHeader, NresError> {
|
||||
enforce_limit(
|
||||
u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?,
|
||||
limits.max_input_bytes,
|
||||
)?;
|
||||
if bytes.len() < HEADER_LEN {
|
||||
let mut got = [0; 4];
|
||||
let copy_len = bytes.len().min(4);
|
||||
@@ -692,6 +774,7 @@ fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
|
||||
}
|
||||
let entry_count =
|
||||
u32::try_from(entry_count_signed).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
enforce_limit(u64::from(entry_count), u64::from(limits.max_entries))?;
|
||||
let total_size = cursor.read_u32_le()?;
|
||||
let actual = u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
if u64::from(total_size) != actual {
|
||||
@@ -731,8 +814,14 @@ fn parse_header(bytes: &[u8]) -> Result<NresHeader, NresError> {
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_entries(bytes: &[u8], header: &NresHeader) -> Result<Vec<NresEntry>, NresError> {
|
||||
let mut entries = Vec::with_capacity(header.entry_count as usize);
|
||||
fn parse_entries(
|
||||
bytes: &[u8],
|
||||
header: &NresHeader,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<Vec<NresEntry>, NresError> {
|
||||
let capacity =
|
||||
checked_allocation_len(u64::from(header.entry_count), u64::from(limits.max_entries))?;
|
||||
let mut entries = Vec::with_capacity(capacity);
|
||||
let directory_offset =
|
||||
usize::try_from(header.directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
for index in 0..header.entry_count {
|
||||
@@ -813,7 +902,7 @@ fn parse_entry(
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> {
|
||||
fn validate_payload_ranges(entries: &[NresEntry], limits: DecodeLimits) -> Result<(), NresError> {
|
||||
let mut ranges: Vec<(u32, Range<usize>)> = entries
|
||||
.iter()
|
||||
.map(|entry| (entry.id.0, entry.data_range.clone()))
|
||||
@@ -824,6 +913,15 @@ fn validate_payload_ranges(entries: &[NresEntry]) -> Result<(), NresError> {
|
||||
.cmp(&right.1.start)
|
||||
.then_with(|| left.1.end.cmp(&right.1.end))
|
||||
});
|
||||
let mut total_payload_bytes = 0_u64;
|
||||
for entry in entries {
|
||||
let payload_len = u64::from(entry.meta.data_size);
|
||||
enforce_limit(payload_len, limits.max_decoded_entry_bytes)?;
|
||||
total_payload_bytes = total_payload_bytes
|
||||
.checked_add(payload_len)
|
||||
.ok_or(DecodeError::IntegerOverflow)?;
|
||||
enforce_limit(total_payload_bytes, limits.max_total_decoded_bytes)?;
|
||||
}
|
||||
for pair in ranges.windows(2) {
|
||||
if pair[0].1.end > pair[1].1.start {
|
||||
return Err(NresError::EntryDataOverlap {
|
||||
@@ -844,7 +942,7 @@ fn validate_names(entries: &[NresEntry]) -> Result<(), NresError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
|
||||
fn validate_lookup_order(entries: &[NresEntry]) -> Result<(), NresError> {
|
||||
let entry_count = saturating_u32_len(entries.len());
|
||||
let mut seen = vec![false; entries.len()];
|
||||
for (position, entry) in entries.iter().enumerate() {
|
||||
@@ -862,7 +960,7 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
|
||||
}
|
||||
seen[index_usize] = true;
|
||||
}
|
||||
for pair in entries.windows(2) {
|
||||
for (position, pair) in entries.windows(2).enumerate() {
|
||||
let left_index =
|
||||
usize::try_from(pair[0].meta.sort_index).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
let right_index =
|
||||
@@ -870,16 +968,19 @@ fn validate_lookup_order(entries: &[NresEntry]) -> Result<bool, NresError> {
|
||||
let left = entries[left_index].name_bytes();
|
||||
let right = entries[right_index].name_bytes();
|
||||
if cmp_ascii_casefold(left, right) == Ordering::Greater {
|
||||
return Ok(false);
|
||||
return Err(NresError::SortOrderMismatch {
|
||||
position: saturating_u32_len(position.saturating_add(1)),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_preserved_regions(
|
||||
bytes: &[u8],
|
||||
entries: &[NresEntry],
|
||||
directory_offset: u32,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<Vec<PreservedRegion>, NresError> {
|
||||
let mut ranges: Vec<Range<usize>> = entries
|
||||
.iter()
|
||||
@@ -895,18 +996,44 @@ fn find_preserved_regions(
|
||||
let directory_offset =
|
||||
usize::try_from(directory_offset).map_err(|_| DecodeError::IntegerOverflow)?;
|
||||
let mut preserved = Vec::new();
|
||||
let mut preserved_bytes = 0_u64;
|
||||
for range in ranges {
|
||||
if cursor < range.start {
|
||||
preserved_bytes = preserved_bytes
|
||||
.checked_add(
|
||||
u64::try_from(range.start - cursor)
|
||||
.map_err(|_| DecodeError::IntegerOverflow)?,
|
||||
)
|
||||
.ok_or(DecodeError::IntegerOverflow)?;
|
||||
enforce_limit(preserved_bytes, limits.max_preserved_bytes)?;
|
||||
preserved.push(make_preserved_region(bytes, cursor..range.start)?);
|
||||
}
|
||||
cursor = cursor.max(range.end);
|
||||
}
|
||||
if cursor < directory_offset {
|
||||
preserved_bytes = preserved_bytes
|
||||
.checked_add(
|
||||
u64::try_from(directory_offset - cursor)
|
||||
.map_err(|_| DecodeError::IntegerOverflow)?,
|
||||
)
|
||||
.ok_or(DecodeError::IntegerOverflow)?;
|
||||
enforce_limit(preserved_bytes, limits.max_preserved_bytes)?;
|
||||
preserved.push(make_preserved_region(bytes, cursor..directory_offset)?);
|
||||
}
|
||||
Ok(preserved)
|
||||
}
|
||||
|
||||
fn enforce_limit(value: u64, limit: u64) -> Result<(), NresError> {
|
||||
if value > limit {
|
||||
return Err(DecodeError::LimitExceeded {
|
||||
count: value,
|
||||
limit,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_preserved_region(bytes: &[u8], range: Range<usize>) -> Result<PreservedRegion, NresError> {
|
||||
let all_zero = bytes[range.clone()].iter().all(|byte| *byte == 0);
|
||||
Ok(PreservedRegion {
|
||||
@@ -1103,6 +1230,8 @@ mod tests {
|
||||
assert_eq!(entry.data_range().end, HEADER_LEN + 1);
|
||||
assert_eq!(doc.header().directory_offset % 8, 0);
|
||||
assert_eq!(doc.payload(EntryId(0)).expect("payload"), b"x");
|
||||
let (owner, range) = doc.payload_view(EntryId(0)).expect("payload view");
|
||||
assert_eq!(&owner[range], b"x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1157,7 +1286,10 @@ mod tests {
|
||||
|
||||
assert!(matches!(
|
||||
decode(arc(bytes), ReadProfile::Strict),
|
||||
Err(NresError::DirectoryOutOfBounds { .. })
|
||||
Err(NresError::Binary(DecodeError::LimitExceeded {
|
||||
count,
|
||||
limit
|
||||
})) if count == i32::MAX as u64 && limit == u64::from(DecodeLimits::default().max_entries)
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1450,7 +1582,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsorted_lookup_table_falls_back_to_linear_lookup() {
|
||||
fn strict_rejects_unsorted_lookup_table() {
|
||||
let mut bytes = build_archive(&[
|
||||
SyntheticEntry {
|
||||
type_id: 1,
|
||||
@@ -1478,9 +1610,99 @@ mod tests {
|
||||
bytes[directory_offset + ENTRY_LEN + 60..directory_offset + ENTRY_LEN + 64]
|
||||
.copy_from_slice(&1_u32.to_le_bytes());
|
||||
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("strict nres");
|
||||
assert!(!doc.lookup_order_valid());
|
||||
assert_eq!(doc.find("A"), Some(EntryId(1)));
|
||||
assert!(matches!(
|
||||
decode(arc(bytes), ReadProfile::Strict),
|
||||
Err(NresError::SortOrderMismatch { position: 1 })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_entry_count_above_limit() {
|
||||
let bytes = build_archive(&[
|
||||
SyntheticEntry {
|
||||
type_id: 1,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "a",
|
||||
payload: b"a",
|
||||
},
|
||||
SyntheticEntry {
|
||||
type_id: 2,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "b",
|
||||
payload: b"b",
|
||||
},
|
||||
]);
|
||||
|
||||
assert!(matches!(
|
||||
decode_with_limits(
|
||||
arc(bytes),
|
||||
ReadProfile::Strict,
|
||||
DecodeLimits {
|
||||
max_entries: 1,
|
||||
..DecodeLimits::default()
|
||||
}
|
||||
),
|
||||
Err(NresError::Binary(DecodeError::LimitExceeded {
|
||||
count: 2,
|
||||
limit: 1
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_input_bytes_above_limit() {
|
||||
let bytes = build_archive(&[SyntheticEntry {
|
||||
type_id: 1,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "payload",
|
||||
payload: b"data",
|
||||
}]);
|
||||
let exact_size = u64::try_from(bytes.len()).expect("archive size");
|
||||
|
||||
assert!(matches!(
|
||||
decode_with_limits(
|
||||
arc(bytes),
|
||||
ReadProfile::Strict,
|
||||
DecodeLimits {
|
||||
max_input_bytes: exact_size - 1,
|
||||
..DecodeLimits::default()
|
||||
}
|
||||
),
|
||||
Err(NresError::Binary(DecodeError::LimitExceeded {
|
||||
count,
|
||||
limit
|
||||
})) if count == exact_size && limit == exact_size - 1
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_preserved_bytes_above_limit() {
|
||||
let bytes = build_archive_with_nonzero_prefix_gap(&[SyntheticEntry {
|
||||
type_id: 1,
|
||||
attr1: 0,
|
||||
attr2: 0,
|
||||
attr3: 0,
|
||||
name: "payload",
|
||||
payload: b"data",
|
||||
}]);
|
||||
|
||||
assert!(matches!(
|
||||
decode_with_limits(
|
||||
arc(bytes),
|
||||
ReadProfile::Strict,
|
||||
DecodeLimits {
|
||||
max_preserved_bytes: 4,
|
||||
..DecodeLimits::default()
|
||||
}
|
||||
),
|
||||
Err(NresError::Binary(DecodeError::LimitExceeded { .. }))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1970,7 +2192,7 @@ mod tests {
|
||||
let mut has_nonzero_preserved_region = false;
|
||||
for path in &files {
|
||||
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
let doc = decode(arc(bytes.clone()), ReadProfile::Strict)
|
||||
let doc = decode(arc(bytes.clone()), ReadProfile::Compatible)
|
||||
.map_err(|err| format!("{}: {err}", path.display()))?;
|
||||
total_entries = total_entries
|
||||
.checked_add(doc.entry_count())
|
||||
|
||||
+145
-20
@@ -1,7 +1,28 @@
|
||||
#![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.
|
||||
@@ -23,14 +44,73 @@ impl OriginalPathBytes {
|
||||
}
|
||||
|
||||
/// Normalized relative path.
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct NormalizedPath(String);
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NormalizedPath {
|
||||
raw: Vec<u8>,
|
||||
display: String,
|
||||
}
|
||||
|
||||
impl NormalizedPath {
|
||||
/// Returns string view.
|
||||
/// 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.0
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,8 +171,6 @@ pub enum PathError {
|
||||
ParentTraversal,
|
||||
/// Host path escape.
|
||||
EscapesRoot,
|
||||
/// Invalid UTF-8 after normalization.
|
||||
InvalidUtf8,
|
||||
}
|
||||
|
||||
impl fmt::Display for PathError {
|
||||
@@ -103,7 +181,6 @@ impl fmt::Display for PathError {
|
||||
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"),
|
||||
Self::InvalidUtf8 => write!(f, "path is not valid UTF-8 after normalization"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,8 +192,7 @@ impl std::error::Error for PathError {}
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PathError`] when the input is empty, absolute, contains an
|
||||
/// embedded NUL, attempts parent traversal, or is not valid UTF-8 after
|
||||
/// legacy separator normalization.
|
||||
/// 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);
|
||||
@@ -124,22 +200,21 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
|
||||
if raw.contains(&0) {
|
||||
return Err(PathError::EmbeddedNul);
|
||||
}
|
||||
let text = std::str::from_utf8(raw).map_err(|_| PathError::InvalidUtf8)?;
|
||||
if text.starts_with('/') || text.starts_with('\\') || has_drive_prefix(text) {
|
||||
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 text.split(['/', '\\']) {
|
||||
if part.is_empty() || part == "." {
|
||||
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 == ".." {
|
||||
if part == b".." {
|
||||
return Err(PathError::ParentTraversal);
|
||||
}
|
||||
if policy == PathPolicy::StrictLegacy && part.contains(':') {
|
||||
if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
|
||||
return Err(PathError::Absolute);
|
||||
}
|
||||
parts.push(part);
|
||||
@@ -147,7 +222,18 @@ pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPa
|
||||
if parts.is_empty() {
|
||||
return Err(PathError::Empty);
|
||||
}
|
||||
Ok(NormalizedPath(parts.join("/")))
|
||||
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.
|
||||
@@ -166,8 +252,7 @@ pub fn normalize_relative_with_original(
|
||||
})
|
||||
}
|
||||
|
||||
fn has_drive_prefix(text: &str) -> bool {
|
||||
let bytes = text.as_bytes();
|
||||
fn has_drive_prefix(bytes: &[u8]) -> bool {
|
||||
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
|
||||
}
|
||||
|
||||
@@ -184,7 +269,11 @@ pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
|
||||
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
|
||||
/// to address a parent directory.
|
||||
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
|
||||
if rel.0.split('/').any(|part| part == "..") {
|
||||
if rel
|
||||
.as_bytes()
|
||||
.split(|byte| *byte == b'/')
|
||||
.any(|part| part == b"..")
|
||||
{
|
||||
Err(PathError::ParentTraversal)
|
||||
} else {
|
||||
Ok(())
|
||||
@@ -198,7 +287,20 @@ pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
|
||||
/// 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_str()))
|
||||
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)]
|
||||
@@ -292,6 +394,15 @@ mod tests {
|
||||
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";
|
||||
@@ -300,4 +411,18 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
raw-window-handle = "0.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,39 +1,125 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Platform ports for clocks, input, events, windows, and graphics requests.
|
||||
#![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.
|
||||
|
||||
/// Monotonic instant.
|
||||
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);
|
||||
|
||||
/// Monotonic clock.
|
||||
/// Platform clock.
|
||||
pub trait MonotonicClock {
|
||||
/// Current instant.
|
||||
fn now(&self) -> MonotonicInstant;
|
||||
}
|
||||
|
||||
/// Platform event.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum PlatformEvent {
|
||||
/// Quit requested.
|
||||
Quit,
|
||||
/// 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.
|
||||
/// Platform error with optional source detail.
|
||||
#[derive(Debug)]
|
||||
pub enum PlatformError {
|
||||
/// Backend failed.
|
||||
Backend,
|
||||
/// 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 {
|
||||
write!(f, "{self:?}")
|
||||
match self {
|
||||
Self::Backend { context, message } => {
|
||||
write!(f, "{context}: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PlatformError {}
|
||||
|
||||
/// Event source.
|
||||
/// Event source contract for polling platform events.
|
||||
pub trait EventSource {
|
||||
/// Polls events.
|
||||
///
|
||||
@@ -43,7 +129,7 @@ pub trait EventSource {
|
||||
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
|
||||
}
|
||||
|
||||
/// Physical size.
|
||||
/// Physical window size.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct PhysicalSize {
|
||||
/// Width.
|
||||
@@ -52,42 +138,96 @@ pub struct PhysicalSize {
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Window port.
|
||||
/// 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 {
|
||||
/// Drawable size.
|
||||
/// Current drawable size.
|
||||
fn drawable_size(&self) -> PhysicalSize;
|
||||
/// Presents.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`PlatformError`] when the backend cannot present the current
|
||||
/// frame.
|
||||
fn present(&mut self) -> Result<(), PlatformError>;
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
||||
/// Graphics profile.
|
||||
/// Render backend request contract.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum GraphicsProfile {
|
||||
/// Desktop core.
|
||||
DesktopCore,
|
||||
/// Embedded profile.
|
||||
Embedded,
|
||||
pub struct RenderRequest {
|
||||
/// Preferred color-space profile.
|
||||
pub color_space: ColorSpace,
|
||||
/// Preferred presentation mode.
|
||||
pub presentation: PresentationMode,
|
||||
/// Requested depth/stencil format.
|
||||
pub depth: DepthStencilSupport,
|
||||
}
|
||||
|
||||
/// Version.
|
||||
/// Color-space profile.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct Version {
|
||||
/// Major.
|
||||
pub major: u8,
|
||||
/// Minor.
|
||||
pub minor: u8,
|
||||
pub enum ColorSpace {
|
||||
/// sRGB nonlinear.
|
||||
Srgb,
|
||||
/// Linear color-space.
|
||||
Linear,
|
||||
}
|
||||
|
||||
/// Graphics context request.
|
||||
/// Presentation mode.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct GraphicsContextRequest {
|
||||
/// Profile.
|
||||
pub profile: GraphicsProfile,
|
||||
/// Version.
|
||||
pub version: Version,
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,14 +7,13 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
encoding_rs = "0.8"
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-material = { path = "../fparkan-material" }
|
||||
fparkan-msh = { path = "../fparkan-msh" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-texm = { path = "../fparkan-texm" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
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" }
|
||||
|
||||
+812
-300
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-world = { path = "../fparkan-world" }
|
||||
fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,8 +1,387 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Backend-neutral render commands and deterministic captures.
|
||||
|
||||
use fparkan_world::OriginalObjectId;
|
||||
|
||||
/// A 64-byte transform block returned by the original Terrain camera ABI.
|
||||
///
|
||||
/// The original uses two selector-dependent transform pointers. Their exact
|
||||
/// matrix convention is still under recovery, so words are deliberately kept
|
||||
/// losslessly rather than treated as a renderer-ready matrix. The three
|
||||
/// translation words have been confirmed at indices 3, 7, and 11.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RawCameraTransform {
|
||||
/// The exact 16 little-endian dwords returned by the legacy camera.
|
||||
pub words: [u32; 16],
|
||||
}
|
||||
|
||||
impl RawCameraTransform {
|
||||
/// Indices of the confirmed X, Y, and Z translation floats.
|
||||
pub const TRANSLATION_WORD_INDICES: [usize; 3] = [3, 7, 11];
|
||||
|
||||
/// Returns the confirmed legacy world position (X, Y, Z).
|
||||
#[must_use]
|
||||
pub fn translation(self) -> [f32; 3] {
|
||||
Self::TRANSLATION_WORD_INDICES.map(|index| f32::from_bits(self.words[index]))
|
||||
}
|
||||
|
||||
/// Inverts a finite row-major affine transform without assigning it a
|
||||
/// camera-space meaning.
|
||||
///
|
||||
/// The legacy SIMD dispatch multiplies these blocks as ordinary row-major
|
||||
/// matrices and the confirmed camera samples have translation in the last
|
||||
/// column. `Some` therefore means only that this block has a non-singular
|
||||
/// affine inverse. Callers must still establish whether it is a
|
||||
/// camera-to-world transform before using the result as a view matrix.
|
||||
#[must_use]
|
||||
pub fn try_inverse_affine_row_major(self) -> Option<[f32; 16]> {
|
||||
let matrix = self.words.map(f32::from_bits);
|
||||
if !matrix.iter().all(|value| value.is_finite())
|
||||
|| matrix[12].abs() > f32::EPSILON
|
||||
|| matrix[13].abs() > f32::EPSILON
|
||||
|| matrix[14].abs() > f32::EPSILON
|
||||
|| (matrix[15] - 1.0).abs() > f32::EPSILON
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let [m00, m01, m02, _, m10, m11, m12, _, m20, m21, m22, _, _, _, _, _] = matrix;
|
||||
let cofactor00 = m11.mul_add(m22, -(m12 * m21));
|
||||
let cofactor01 = m02.mul_add(m21, -(m01 * m22));
|
||||
let cofactor02 = m01.mul_add(m12, -(m02 * m11));
|
||||
let determinant = m00.mul_add(cofactor00, m10.mul_add(cofactor01, m20 * cofactor02));
|
||||
if !determinant.is_finite() || determinant == 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let inverse_determinant = determinant.recip();
|
||||
let inverse = [
|
||||
cofactor00 * inverse_determinant,
|
||||
m02.mul_add(m21, -(m01 * m22)) * inverse_determinant,
|
||||
cofactor02 * inverse_determinant,
|
||||
0.0,
|
||||
m12.mul_add(m20, -(m10 * m22)) * inverse_determinant,
|
||||
m00.mul_add(m22, -(m02 * m20)) * inverse_determinant,
|
||||
m02.mul_add(m10, -(m00 * m12)) * inverse_determinant,
|
||||
0.0,
|
||||
m10.mul_add(m21, -(m11 * m20)) * inverse_determinant,
|
||||
m01.mul_add(m20, -(m00 * m21)) * inverse_determinant,
|
||||
m00.mul_add(m11, -(m01 * m10)) * inverse_determinant,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
];
|
||||
let [translation_x, translation_y, translation_z] = self.translation();
|
||||
let translation = [
|
||||
-(inverse[0].mul_add(
|
||||
translation_x,
|
||||
inverse[1].mul_add(translation_y, inverse[2] * translation_z),
|
||||
)),
|
||||
-(inverse[4].mul_add(
|
||||
translation_x,
|
||||
inverse[5].mul_add(translation_y, inverse[6] * translation_z),
|
||||
)),
|
||||
-(inverse[8].mul_add(
|
||||
translation_x,
|
||||
inverse[9].mul_add(translation_y, inverse[10] * translation_z),
|
||||
)),
|
||||
];
|
||||
|
||||
Some([
|
||||
inverse[0],
|
||||
inverse[1],
|
||||
inverse[2],
|
||||
translation[0],
|
||||
inverse[4],
|
||||
inverse[5],
|
||||
inverse[6],
|
||||
translation[1],
|
||||
inverse[8],
|
||||
inverse[9],
|
||||
inverse[10],
|
||||
translation[2],
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
])
|
||||
}
|
||||
|
||||
/// Reproduces Ngi32's `Direct3D7` view-matrix conversion for this transform.
|
||||
///
|
||||
/// The legacy renderer copies selector-0 into its camera state, then maps
|
||||
/// its axes and translation in this exact order before calling
|
||||
/// `IDirect3DDevice7::SetTransform(D3DTRANSFORMSTATE_VIEW, ...)`. This is
|
||||
/// deliberately distinct from [`Self::try_inverse_affine_row_major`]: it
|
||||
/// includes the original renderer's coordinate-system conversion.
|
||||
///
|
||||
/// The returned matrix is row-major D3D7 data, not yet a Vulkan view
|
||||
/// matrix. A later adapter must explicitly account for clip-space and
|
||||
/// shader-vector conventions.
|
||||
#[must_use]
|
||||
pub fn try_direct3d7_view_row_major(self) -> Option<[f32; 16]> {
|
||||
let matrix = self.words.map(f32::from_bits);
|
||||
if !matrix.iter().all(|value| value.is_finite())
|
||||
|| matrix[12].abs() > f32::EPSILON
|
||||
|| matrix[13].abs() > f32::EPSILON
|
||||
|| matrix[14].abs() > f32::EPSILON
|
||||
|| (matrix[15] - 1.0).abs() > f32::EPSILON
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let [m00, m01, m02, m03, m10, m11, m12, m13, m20, m21, m22, m23, _, _, _, _] = matrix;
|
||||
Some([
|
||||
-m01,
|
||||
m02,
|
||||
m00,
|
||||
0.0,
|
||||
-m11,
|
||||
m12,
|
||||
m10,
|
||||
0.0,
|
||||
-m21,
|
||||
m22,
|
||||
m20,
|
||||
0.0,
|
||||
m23.mul_add(m21, m13.mul_add(m11, m03 * m01)),
|
||||
-(m23.mul_add(m22, m13.mul_add(m12, m03 * m02))),
|
||||
-(m00.mul_add(m03, m23.mul_add(m20, m13 * m10))),
|
||||
1.0,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters consumed by Ngi32's `Direct3D7` projection-matrix builder.
|
||||
///
|
||||
/// These are a separate legacy-renderer boundary from
|
||||
/// [`RawCameraProjection`]. The latter preserves Terrain's source ABI, while
|
||||
/// this type records the already-resolved Ngi32 values submitted to `Direct3D7`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LegacyD3d7Projection {
|
||||
/// Viewport rectangle as `(left, top, right, bottom)`.
|
||||
pub viewport: [i32; 4],
|
||||
/// Positive camera near-plane distance.
|
||||
pub near_plane: f32,
|
||||
/// Camera far-plane distance, greater than [`Self::near_plane`].
|
||||
pub far_plane: f32,
|
||||
/// Full field-of-view angle in radians.
|
||||
pub field_of_view_radians: f32,
|
||||
}
|
||||
|
||||
impl LegacyD3d7Projection {
|
||||
/// Reconstructs the exact row-major matrix passed to D3D7 projection state.
|
||||
///
|
||||
/// Ngi32 uses the viewport's `width / height`, writes `cos(fov / 2)` to
|
||||
/// the diagonal and `sin(fov / 2)` to both the depth scale and
|
||||
/// homogeneous-W term. The ratio after D3D's perspective divide is
|
||||
/// therefore the expected cotangent scale. This remains legacy D3D7 data
|
||||
/// rather than a Vulkan projection.
|
||||
#[must_use]
|
||||
pub fn try_direct3d7_projection_row_major(self) -> Option<[f32; 16]> {
|
||||
let width = self.viewport[2].checked_sub(self.viewport[0])?;
|
||||
let height = self.viewport[3].checked_sub(self.viewport[1])?;
|
||||
if width <= 0
|
||||
|| height <= 0
|
||||
|| !self.near_plane.is_finite()
|
||||
|| !self.far_plane.is_finite()
|
||||
|| !self.field_of_view_radians.is_finite()
|
||||
|| self.near_plane <= 0.0
|
||||
|| self.far_plane <= self.near_plane
|
||||
|| self.field_of_view_radians <= 0.0
|
||||
|| self.field_of_view_radians >= std::f32::consts::PI
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
// Ngi32 converts these signed viewport dimensions into single-precision
|
||||
// arithmetic before building the legacy matrix.
|
||||
#[allow(clippy::cast_precision_loss)]
|
||||
let aspect = (width as f32) / (height as f32);
|
||||
let half_fov = self.field_of_view_radians * 0.5;
|
||||
let cosine = half_fov.cos();
|
||||
let sine = half_fov.sin();
|
||||
let depth_scale = sine / (1.0 - self.near_plane / self.far_plane);
|
||||
[aspect, cosine, sine, depth_scale]
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
.then_some([
|
||||
cosine,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
aspect * cosine,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
depth_scale,
|
||||
sine,
|
||||
0.0,
|
||||
0.0,
|
||||
-(depth_scale * self.near_plane),
|
||||
0.0,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Affine placement inputs consumed by `Iron3D`'s recovered Euler-matrix builder.
|
||||
///
|
||||
/// This records the exact matrix construction at `iron3d.dll` RVA `0x36610`.
|
||||
/// It is intentionally distinct from a mission placement contract: the static
|
||||
/// trace has not yet proved that TMA's three raw orientation floats reach this
|
||||
/// builder unchanged for every object category.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct LegacyIron3dEulerTransform {
|
||||
/// World-space translation passed to the builder.
|
||||
pub translation: [f32; 3],
|
||||
/// Three angles in the builder's `(x, y, z)` input order, in radians.
|
||||
pub orientation_radians: [f32; 3],
|
||||
}
|
||||
|
||||
impl LegacyIron3dEulerTransform {
|
||||
/// Reconstructs the builder's finite row-major affine matrix.
|
||||
///
|
||||
/// The recovered x87 code evaluates `Rz(z) * Ry(y) * Rx(x)` and writes
|
||||
/// translation into the last column. This uses portable `f32` trigonometry;
|
||||
/// any future x87-compatibility path must be separately capture-validated.
|
||||
#[must_use]
|
||||
pub fn try_row_major(self) -> Option<[f32; 16]> {
|
||||
if !self
|
||||
.translation
|
||||
.iter()
|
||||
.chain(self.orientation_radians.iter())
|
||||
.all(|value| value.is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let [x, y, z] = self.orientation_radians;
|
||||
let (sin_x, cos_x) = x.sin_cos();
|
||||
let (sin_y, cos_y) = y.sin_cos();
|
||||
let (sin_z, cos_z) = z.sin_cos();
|
||||
let [translation_x, translation_y, translation_z] = self.translation;
|
||||
let matrix = [
|
||||
cos_z * cos_y,
|
||||
cos_z * sin_y * sin_x - sin_z * cos_x,
|
||||
cos_z * sin_y * cos_x + sin_z * sin_x,
|
||||
translation_x,
|
||||
sin_z * cos_y,
|
||||
sin_z * sin_y * sin_x + cos_z * cos_x,
|
||||
sin_z * sin_y * cos_x - cos_z * sin_x,
|
||||
translation_y,
|
||||
-sin_y,
|
||||
cos_y * sin_x,
|
||||
cos_y * cos_x,
|
||||
translation_z,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
1.0,
|
||||
];
|
||||
matrix
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
.then_some(matrix)
|
||||
}
|
||||
|
||||
/// Applies the recovered rotation after a component-wise local scale.
|
||||
///
|
||||
/// This is the affine `R * (scale * point) + translation` convention used
|
||||
/// by the static source-world bridge. The original dynamic transform path
|
||||
/// needs its own capture evidence before it may replace this static path.
|
||||
#[must_use]
|
||||
pub fn try_transform_scaled_point(self, point: [f32; 3], scale: [f32; 3]) -> Option<[f32; 3]> {
|
||||
if !point
|
||||
.iter()
|
||||
.chain(scale.iter())
|
||||
.all(|value| value.is_finite())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let matrix = self.try_row_major()?;
|
||||
let scaled = [
|
||||
point[0] * scale[0],
|
||||
point[1] * scale[1],
|
||||
point[2] * scale[2],
|
||||
];
|
||||
let transformed = [
|
||||
matrix[0] * scaled[0] + matrix[1] * scaled[1] + matrix[2] * scaled[2] + matrix[3],
|
||||
matrix[4] * scaled[0] + matrix[5] * scaled[1] + matrix[6] * scaled[2] + matrix[7],
|
||||
matrix[8] * scaled[0] + matrix[9] * scaled[1] + matrix[10] * scaled[2] + matrix[11],
|
||||
];
|
||||
transformed
|
||||
.iter()
|
||||
.all(|value| value.is_finite())
|
||||
.then_some(transformed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw camera state observed through the original Terrain camera interface.
|
||||
///
|
||||
/// Selector 0 supplies the currently active transform and selector 2 supplies
|
||||
/// its paired transform. Keeping both lets a later compatibility layer derive
|
||||
/// a view/projection convention without re-reading the legacy process.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RawCameraPose {
|
||||
/// Transform returned by selector 0.
|
||||
pub selector0: RawCameraTransform,
|
||||
/// Transform returned by selector 2.
|
||||
pub selector2: RawCameraTransform,
|
||||
}
|
||||
|
||||
/// Raw projection state observed through the original `CBufferingCamera` ABI.
|
||||
///
|
||||
/// The five-float context block is intentionally represented as original words:
|
||||
/// its indices are observed, but their semantic labels have not yet all been
|
||||
/// recovered. The field-of-view value is the type-0 input to `tan(fov / 2)`.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RawCameraProjection {
|
||||
/// Viewport rectangle as `(left, top, right, bottom)`.
|
||||
pub viewport: [i32; 4],
|
||||
/// Original projection selector.
|
||||
pub projection_type: u32,
|
||||
/// Original IEEE-754 FOV value in radians.
|
||||
pub field_of_view_radians_bits: u32,
|
||||
/// Exact five-float context block returned by the primary renderer.
|
||||
pub context_words: [u32; 5],
|
||||
}
|
||||
|
||||
impl RawCameraProjection {
|
||||
/// Returns the type-0 FOV input in radians.
|
||||
#[must_use]
|
||||
pub fn field_of_view_radians(self) -> f32 {
|
||||
f32::from_bits(self.field_of_view_radians_bits)
|
||||
}
|
||||
|
||||
/// Returns the five context values without assigning semantic labels.
|
||||
#[must_use]
|
||||
pub fn context_values(self) -> [f32; 5] {
|
||||
self.context_words.map(f32::from_bits)
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable camera data visible to command generation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct CameraSnapshot {
|
||||
@@ -10,6 +389,17 @@ pub struct CameraSnapshot {
|
||||
pub view: [f32; 16],
|
||||
/// Projection matrix, row-major.
|
||||
pub projection: [f32; 16],
|
||||
/// Optional unconverted source-camera state.
|
||||
///
|
||||
/// This does not alter rendering until the original matrix and projection
|
||||
/// conventions have been recovered; it preserves the ABI boundary for the
|
||||
/// runtime adapter and deterministic captures.
|
||||
pub raw_pose: Option<RawCameraPose>,
|
||||
/// Optional unconverted source-projection state.
|
||||
///
|
||||
/// This is retained independently of `projection`, because its legacy
|
||||
/// context words have not yet been mapped to a Vulkan clip convention.
|
||||
pub raw_projection: Option<RawCameraProjection>,
|
||||
}
|
||||
|
||||
impl Default for CameraSnapshot {
|
||||
@@ -17,6 +407,8 @@ impl Default for CameraSnapshot {
|
||||
Self {
|
||||
view: identity_transform(),
|
||||
projection: identity_transform(),
|
||||
raw_pose: None,
|
||||
raw_projection: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +444,93 @@ pub enum RenderPhase {
|
||||
Ui,
|
||||
}
|
||||
|
||||
/// Fixed-function blend behaviour represented without a graphics API type.
|
||||
///
|
||||
/// This is a compatibility contract, not yet a decoded MAT0 mapping.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum LegacyBlendMode {
|
||||
/// Do not blend the fragment with the existing colour.
|
||||
#[default]
|
||||
Opaque,
|
||||
/// Blend using source alpha.
|
||||
SourceAlpha,
|
||||
}
|
||||
|
||||
/// Depth-buffer behaviour represented without a graphics API type.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum LegacyDepthMode {
|
||||
/// No depth attachment is used.
|
||||
#[default]
|
||||
Disabled,
|
||||
/// Test depth and write passing fragments.
|
||||
TestWrite,
|
||||
/// Test depth without modifying it.
|
||||
TestReadOnly,
|
||||
}
|
||||
|
||||
/// Triangle culling behaviour represented without a graphics API type.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub enum LegacyCullMode {
|
||||
/// Keep both front- and back-facing triangles.
|
||||
#[default]
|
||||
Disabled,
|
||||
/// Cull back-facing triangles.
|
||||
BackFace,
|
||||
/// Cull front-facing triangles.
|
||||
FrontFace,
|
||||
}
|
||||
|
||||
/// Legacy fixed-function state that changes graphics-pipeline structure.
|
||||
///
|
||||
/// Alpha reference is deliberately not present: it is dynamic material data,
|
||||
/// whereas this state records only whether an alpha-test shader variant is used.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct LegacyPipelineState {
|
||||
/// Colour blend mode.
|
||||
pub blend: LegacyBlendMode,
|
||||
/// Depth test/write mode.
|
||||
pub depth: LegacyDepthMode,
|
||||
/// Face culling mode.
|
||||
pub cull: LegacyCullMode,
|
||||
/// Whether alpha-test shader logic is enabled.
|
||||
pub alpha_test: bool,
|
||||
}
|
||||
|
||||
/// Canonical, backend-neutral key for a graphics-pipeline variant.
|
||||
///
|
||||
/// The value is explicitly packed rather than hashed, so captures and caches
|
||||
/// remain stable across processes and Rust toolchain updates.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct PipelineKey(u8);
|
||||
|
||||
impl PipelineKey {
|
||||
/// Returns the canonical packed representation.
|
||||
#[must_use]
|
||||
pub const fn packed(self) -> u8 {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<LegacyPipelineState> for PipelineKey {
|
||||
fn from(state: LegacyPipelineState) -> Self {
|
||||
let blend = match state.blend {
|
||||
LegacyBlendMode::Opaque => 0,
|
||||
LegacyBlendMode::SourceAlpha => 1,
|
||||
};
|
||||
let depth = match state.depth {
|
||||
LegacyDepthMode::Disabled => 0,
|
||||
LegacyDepthMode::TestWrite => 1,
|
||||
LegacyDepthMode::TestReadOnly => 2,
|
||||
};
|
||||
let cull = match state.cull {
|
||||
LegacyCullMode::Disabled => 0,
|
||||
LegacyCullMode::BackFace => 1,
|
||||
LegacyCullMode::FrontFace => 2,
|
||||
};
|
||||
Self(blend | (depth << 1) | (cull << 3) | (u8::from(state.alpha_test) << 5))
|
||||
}
|
||||
}
|
||||
|
||||
/// Index range.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct IndexRange {
|
||||
@@ -76,6 +555,8 @@ pub struct RenderSnapshotDraw {
|
||||
pub material_slots: Vec<GpuMaterialId>,
|
||||
/// Batch material index into [`Self::material_slots`].
|
||||
pub material_index: u16,
|
||||
/// Fixed-function state resolved for this draw.
|
||||
pub pipeline_state: LegacyPipelineState,
|
||||
/// Node transform matrix, row-major.
|
||||
pub transform: [f32; 16],
|
||||
/// Index range.
|
||||
@@ -113,6 +594,8 @@ pub struct DrawCommand {
|
||||
pub mesh: GpuMeshId,
|
||||
/// Material.
|
||||
pub material: GpuMaterialId,
|
||||
/// Canonical graphics-pipeline variant.
|
||||
pub pipeline_key: PipelineKey,
|
||||
/// Transform matrix, row-major.
|
||||
pub transform: [f32; 16],
|
||||
/// Index range.
|
||||
@@ -350,6 +833,7 @@ pub fn build_commands(
|
||||
object_id: draw.object_id,
|
||||
mesh: draw.mesh,
|
||||
material,
|
||||
pipeline_key: draw.pipeline_state.into(),
|
||||
transform: draw.transform,
|
||||
range: draw.range,
|
||||
stable_order: draw.stable_order,
|
||||
@@ -370,6 +854,11 @@ pub trait RenderBackend {
|
||||
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError>;
|
||||
}
|
||||
|
||||
/// Marker trait for backends that execute draws against a live GPU.
|
||||
///
|
||||
/// Planning and capture-only backends must not implement this trait.
|
||||
pub trait GpuRenderBackend: RenderBackend {}
|
||||
|
||||
/// Backend that validates commands and intentionally produces no pixels.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct NullBackend;
|
||||
@@ -429,8 +918,13 @@ pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, Render
|
||||
RenderCommand::Draw(draw) => {
|
||||
out.extend_from_slice(
|
||||
format!(
|
||||
"D,{:?},{},{},{},{}\n",
|
||||
draw.phase, draw.id.0, draw.mesh.0, draw.material.0, draw.stable_order
|
||||
"D,{:?},{},{},{},{},{}\n",
|
||||
draw.phase,
|
||||
draw.id.0,
|
||||
draw.mesh.0,
|
||||
draw.material.0,
|
||||
draw.pipeline_key.packed(),
|
||||
draw.stable_order
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
@@ -581,6 +1075,191 @@ fn identity_transform() -> [f32; 16] {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
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(|index| left[row * 4 + index] * right[index * 4 + column])
|
||||
.sum();
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn assert_matrix_approximately_identity(matrix: [f32; 16]) {
|
||||
for (index, value) in matrix.into_iter().enumerate() {
|
||||
let expected = if index / 4 == index % 4 { 1.0 } else { 0.0 };
|
||||
assert!(
|
||||
(value - expected).abs() < 0.000_02,
|
||||
"matrix element {index}: expected {expected}, got {value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_camera_pose_preserves_words_and_extracts_confirmed_translation() {
|
||||
let mut active = [0_u32; 16];
|
||||
active[0] = 0x7FC0_0001;
|
||||
active[3] = 491.562_5_f32.to_bits();
|
||||
active[7] = 761.550_8_f32.to_bits();
|
||||
active[11] = 7.361_0_f32.to_bits();
|
||||
let paired = [0xA5A5_5A5A; 16];
|
||||
|
||||
let pose = RawCameraPose {
|
||||
selector0: RawCameraTransform { words: active },
|
||||
selector2: RawCameraTransform { words: paired },
|
||||
};
|
||||
|
||||
assert_eq!(pose.selector0.words, active);
|
||||
assert_eq!(pose.selector2.words, paired);
|
||||
assert_eq!(
|
||||
pose.selector0.translation(),
|
||||
[491.562_5, 761.550_8, 7.361_0]
|
||||
);
|
||||
assert_eq!(CameraSnapshot::default().raw_pose, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_camera_projection_preserves_live_context_words_without_labeling_them() {
|
||||
let projection = RawCameraProjection {
|
||||
viewport: [0, 0, 1024, 768],
|
||||
projection_type: 0,
|
||||
field_of_view_radians_bits: 1.04_f32.to_bits(),
|
||||
context_words: [
|
||||
0.0_f32.to_bits(),
|
||||
0.5_f32.to_bits(),
|
||||
700.0_f32.to_bits(),
|
||||
0.1_f32.to_bits(),
|
||||
0.99_f32.to_bits(),
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(projection.field_of_view_radians(), 1.04);
|
||||
assert_eq!(projection.context_values(), [0.0, 0.5, 700.0, 0.1, 0.99]);
|
||||
assert_eq!(CameraSnapshot::default().raw_projection, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_camera_transform_inverts_only_non_singular_affine_blocks() {
|
||||
let source = [
|
||||
0.0, -1.0, 0.0, 433.544_7, 0.948_985, 0.0, 0.315_322, 652.292_5, -0.315_322, 0.0,
|
||||
0.948_985, 10.673_42, 0.0, 0.0, 0.0, 1.0,
|
||||
];
|
||||
let transform = RawCameraTransform {
|
||||
words: source.map(f32::to_bits),
|
||||
};
|
||||
let inverse = transform
|
||||
.try_inverse_affine_row_major()
|
||||
.expect("observed affine transform is invertible");
|
||||
|
||||
assert_matrix_approximately_identity(multiply_row_major(source, inverse));
|
||||
assert_matrix_approximately_identity(multiply_row_major(inverse, source));
|
||||
|
||||
let singular = RawCameraTransform { words: [0_u32; 16] };
|
||||
assert_eq!(singular.try_inverse_affine_row_major(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_camera_transform_reproduces_direct3d7_view_axis_conversion() {
|
||||
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(),
|
||||
],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
transform.try_direct3d7_view_row_major(),
|
||||
Some([
|
||||
1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, -10.0, -30.0, -20.0,
|
||||
1.0,
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
RawCameraTransform { words: [0_u32; 16] }.try_direct3d7_view_row_major(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_d3d7_projection_matches_recovered_camera_formula() {
|
||||
let projection = LegacyD3d7Projection {
|
||||
viewport: [0, 0, 1024, 768],
|
||||
near_plane: 0.5,
|
||||
far_plane: 700.0,
|
||||
field_of_view_radians: 1.3,
|
||||
};
|
||||
let matrix = projection
|
||||
.try_direct3d7_projection_row_major()
|
||||
.expect("live Ngi32 projection parameters are valid");
|
||||
let half_fov = 0.65_f32;
|
||||
let depth_scale = half_fov.sin() * 700.0_f32 / 699.5;
|
||||
|
||||
assert_eq!(matrix[0], half_fov.cos());
|
||||
assert_eq!(matrix[5], (4.0 / 3.0) * half_fov.cos());
|
||||
assert_eq!(matrix[10], depth_scale);
|
||||
assert_eq!(matrix[11], half_fov.sin());
|
||||
assert_eq!(matrix[14], -(depth_scale * 0.5));
|
||||
assert_eq!(
|
||||
LegacyD3d7Projection {
|
||||
viewport: [0, 0, 0, 768],
|
||||
..projection
|
||||
}
|
||||
.try_direct3d7_projection_row_major(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iron3d_euler_builder_uses_rz_ry_rx_and_last_column_translation() {
|
||||
let matrix = LegacyIron3dEulerTransform {
|
||||
translation: [418.103_18, 717.433, 3.040_938_9],
|
||||
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
|
||||
}
|
||||
.try_row_major()
|
||||
.expect("finite recovered builder inputs");
|
||||
|
||||
assert!((matrix[0]).abs() < 0.000_001);
|
||||
assert!((matrix[1] + 1.0).abs() < 0.000_001);
|
||||
assert!((matrix[4] - 1.0).abs() < 0.000_001);
|
||||
assert!((matrix[5]).abs() < 0.000_001);
|
||||
assert_eq!(matrix[3], 418.103_18);
|
||||
assert_eq!(matrix[7], 717.433);
|
||||
assert_eq!(matrix[11], 3.040_938_9);
|
||||
assert_eq!(matrix[15], 1.0);
|
||||
assert_eq!(
|
||||
LegacyIron3dEulerTransform {
|
||||
translation: [10.0, 20.0, 30.0],
|
||||
orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
|
||||
}
|
||||
.try_transform_scaled_point([2.0, 3.0, 4.0], [2.0, 1.0, 0.5]),
|
||||
Some([7.0, 24.0, 32.0])
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyIron3dEulerTransform {
|
||||
translation: [0.0, 0.0, 0.0],
|
||||
orientation_radians: [f32::NAN, 0.0, 0.0],
|
||||
}
|
||||
.try_row_major(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
fn snapshot_draw(
|
||||
id: u64,
|
||||
phase: RenderPhase,
|
||||
@@ -594,6 +1273,7 @@ mod tests {
|
||||
mesh: GpuMeshId(10 + id),
|
||||
material_slots: vec![GpuMaterialId(31), GpuMaterialId(37)],
|
||||
material_index,
|
||||
pipeline_state: LegacyPipelineState::default(),
|
||||
transform: identity_transform(),
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order,
|
||||
@@ -611,6 +1291,7 @@ mod tests {
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(2),
|
||||
material: GpuMaterialId(3),
|
||||
pipeline_key: LegacyPipelineState::default().into(),
|
||||
transform: [0.0; 16],
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order: 4,
|
||||
@@ -620,10 +1301,35 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
canonical_capture(&list).expect("capture"),
|
||||
b"B\nD,Opaque,1,2,3,4\nE\n"
|
||||
b"B\nD,Opaque,1,2,3,0,4\nE\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pipeline_key_is_explicit_stable_and_sensitive_to_pipeline_structure() {
|
||||
let base = LegacyPipelineState::default();
|
||||
assert_eq!(PipelineKey::from(base).packed(), 0);
|
||||
|
||||
let variant = LegacyPipelineState {
|
||||
blend: LegacyBlendMode::SourceAlpha,
|
||||
depth: LegacyDepthMode::TestReadOnly,
|
||||
cull: LegacyCullMode::BackFace,
|
||||
alpha_test: true,
|
||||
};
|
||||
assert_eq!(PipelineKey::from(variant).packed(), 0b00_101_101);
|
||||
assert_ne!(PipelineKey::from(base), PipelineKey::from(variant));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alpha_test_flag_changes_key_without_encoding_material_threshold() {
|
||||
let opaque = LegacyPipelineState::default();
|
||||
let alpha_test = LegacyPipelineState {
|
||||
alpha_test: true,
|
||||
..opaque
|
||||
};
|
||||
assert_eq!(PipelineKey::from(alpha_test).packed(), 0b10_0000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_backend_validates_without_capture() {
|
||||
let mut backend = NullBackend;
|
||||
@@ -636,6 +1342,7 @@ mod tests {
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(2),
|
||||
material: GpuMaterialId(3),
|
||||
pipeline_key: LegacyPipelineState::default().into(),
|
||||
transform: [0.0; 16],
|
||||
range: IndexRange { start: 0, count: 0 },
|
||||
stable_order: 4,
|
||||
@@ -738,7 +1445,7 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
capture,
|
||||
b"B\nD,Opaque,1,11,31,10\nD,Opaque,2,12,31,20\nD,Transparent,3,13,31,0\nE\n"
|
||||
b"B\nD,Opaque,1,11,31,0,10\nD,Opaque,2,12,31,0,20\nD,Transparent,3,13,31,0,0\nE\n"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -844,6 +1551,7 @@ mod tests {
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(2),
|
||||
material: GpuMaterialId(3),
|
||||
pipeline_key: LegacyPipelineState::default().into(),
|
||||
transform: identity_transform(),
|
||||
range: IndexRange {
|
||||
start: u32::MAX,
|
||||
@@ -898,6 +1606,7 @@ mod tests {
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(2),
|
||||
material: GpuMaterialId(3),
|
||||
pipeline_key: LegacyPipelineState::default().into(),
|
||||
transform: identity_transform(),
|
||||
range: IndexRange {
|
||||
start: 14,
|
||||
@@ -932,6 +1641,7 @@ mod tests {
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(1),
|
||||
material: GpuMaterialId(1),
|
||||
pipeline_key: LegacyPipelineState::default().into(),
|
||||
transform: identity_transform(),
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order: 0,
|
||||
@@ -942,6 +1652,7 @@ mod tests {
|
||||
object_id: None,
|
||||
mesh: GpuMeshId(1),
|
||||
material: GpuMaterialId(1),
|
||||
pipeline_key: LegacyPipelineState::default().into(),
|
||||
transform: identity_transform(),
|
||||
range: IndexRange { start: 0, count: 3 },
|
||||
stable_order: 0,
|
||||
|
||||
@@ -6,11 +6,11 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
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
|
||||
|
||||
+849
-101
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ 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]
|
||||
|
||||
+720
-16
@@ -1,6 +1,26 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Stage-1 `RsLi` archive contract.
|
||||
|
||||
use fparkan_binary::DecodeError;
|
||||
use std::fmt;
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
@@ -59,6 +79,106 @@ pub enum WriteProfile {
|
||||
Lossless,
|
||||
}
|
||||
|
||||
/// Decode and payload loading limits.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct DecodeLimits {
|
||||
/// Maximum accepted source archive bytes.
|
||||
pub max_input_bytes: u64,
|
||||
/// Maximum accepted entry count.
|
||||
pub max_entries: u32,
|
||||
/// Maximum accepted packed entry bytes.
|
||||
pub max_packed_entry_bytes: u64,
|
||||
/// Maximum accepted decoded entry bytes.
|
||||
pub max_decoded_entry_bytes: u64,
|
||||
/// Maximum accepted cumulative decoded bytes for a single load operation.
|
||||
pub max_total_decoded_bytes: u64,
|
||||
}
|
||||
|
||||
impl Default for DecodeLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_input_bytes: 256 * 1024 * 1024,
|
||||
max_entries: 1_000_000,
|
||||
max_packed_entry_bytes: 64 * 1024 * 1024,
|
||||
max_decoded_entry_bytes: 128 * 1024 * 1024,
|
||||
max_total_decoded_bytes: 128 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned when mutable editing is attempted.
|
||||
#[derive(Debug)]
|
||||
pub enum RsliMutationError {
|
||||
/// Entry id is not present in this editable document.
|
||||
EntryNotFound {
|
||||
/// Requested entry id.
|
||||
id: EntryId,
|
||||
},
|
||||
/// Entry name does not fit into a 12-byte fixed field.
|
||||
AuthoringNameTooLong {
|
||||
/// Observed length in bytes.
|
||||
len: usize,
|
||||
/// Maximum accepted length for an authoring field.
|
||||
max: usize,
|
||||
},
|
||||
/// Entry name contains an explicit NUL byte.
|
||||
AuthoringNameContainsNul {
|
||||
/// Byte offset within the provided name.
|
||||
offset: usize,
|
||||
},
|
||||
/// Packed payload size overflows the format `u32` field.
|
||||
PackedPayloadTooLarge {
|
||||
/// Requested packed payload size.
|
||||
size: usize,
|
||||
/// Format maximum (`u32::MAX`).
|
||||
max: usize,
|
||||
},
|
||||
/// Method cannot be represented by the on-disk flags field.
|
||||
UnsupportedMethod {
|
||||
/// Requested method.
|
||||
method: RsliMethod,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RsliMutationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::EntryNotFound { id } => write!(f, "entry id {id:?} is not present"),
|
||||
Self::AuthoringNameTooLong { len, max } => {
|
||||
write!(f, "authoring name is too long: {len} > {max}")
|
||||
}
|
||||
Self::AuthoringNameContainsNul { offset } => {
|
||||
write!(f, "authoring name contains embedded NUL at {offset}")
|
||||
}
|
||||
Self::PackedPayloadTooLarge { size, max } => {
|
||||
write!(f, "packed payload is too large: {size} > {max}")
|
||||
}
|
||||
Self::UnsupportedMethod { method } => {
|
||||
write!(f, "unsupported authoring method: {method:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RsliMutationError {}
|
||||
|
||||
/// Mutable editor for `RsliDocument` that can rebuild lookup tables.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RsliEditor {
|
||||
original_image: Arc<[u8]>,
|
||||
header: RsliHeader,
|
||||
overlay: u32,
|
||||
ao_trailer: Option<[u8; 6]>,
|
||||
entries: Vec<EditableEntry>,
|
||||
dirty: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct EditableEntry {
|
||||
meta: EntryMeta,
|
||||
packed: Vec<u8>,
|
||||
}
|
||||
|
||||
/// `RsLi` compatibility switches.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct RsliCompatibilityProfile {
|
||||
@@ -290,6 +410,8 @@ pub enum RsliError {
|
||||
},
|
||||
/// Integer conversion or arithmetic overflow.
|
||||
IntegerOverflow,
|
||||
/// Shared bounded decode failure.
|
||||
Binary(DecodeError),
|
||||
}
|
||||
|
||||
impl fmt::Display for RsliError {
|
||||
@@ -348,11 +470,25 @@ impl fmt::Display for RsliError {
|
||||
write!(f, "output size mismatch: expected={expected}, got={got}")
|
||||
}
|
||||
Self::IntegerOverflow => write!(f, "integer overflow"),
|
||||
Self::Binary(source) => write!(f, "{source}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RsliError {}
|
||||
impl std::error::Error for RsliError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Self::Binary(source) => Some(source),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DecodeError> for RsliError {
|
||||
fn from(value: DecodeError) -> Self {
|
||||
Self::Binary(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes an `RsLi` document.
|
||||
///
|
||||
@@ -362,7 +498,21 @@ impl std::error::Error for RsliError {}
|
||||
/// compatibility quirks, or packed payloads are invalid for the selected
|
||||
/// profile.
|
||||
pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, RsliError> {
|
||||
decode_with_profile(bytes, profile.into())
|
||||
decode_with_limits(bytes, profile, DecodeLimits::default())
|
||||
}
|
||||
|
||||
/// Decodes an `RsLi` document with explicit archive limits.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliError`] when the input exceeds configured limits or the
|
||||
/// archive is malformed for the selected profile.
|
||||
pub fn decode_with_limits(
|
||||
bytes: Arc<[u8]>,
|
||||
profile: ReadProfile,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<RsliDocument, RsliError> {
|
||||
decode_with_profile_and_limits(bytes, profile.into(), limits)
|
||||
}
|
||||
|
||||
/// Decodes an `RsLi` document with explicit compatibility switches.
|
||||
@@ -375,17 +525,33 @@ pub fn decode(bytes: Arc<[u8]>, profile: ReadProfile) -> Result<RsliDocument, Rs
|
||||
pub fn decode_with_profile(
|
||||
bytes: Arc<[u8]>,
|
||||
profile: RsliReadProfile,
|
||||
) -> Result<RsliDocument, RsliError> {
|
||||
decode_with_profile_and_limits(bytes, profile, DecodeLimits::default())
|
||||
}
|
||||
|
||||
/// Decodes an `RsLi` document with explicit profile and archive limits.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliError`] when the input exceeds configured limits or the
|
||||
/// archive is malformed for the selected profile.
|
||||
pub fn decode_with_profile_and_limits(
|
||||
bytes: Arc<[u8]>,
|
||||
profile: RsliReadProfile,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<RsliDocument, RsliError> {
|
||||
let options = match profile {
|
||||
RsliReadProfile::Strict => ParseOptions {
|
||||
allow_ao_trailer: false,
|
||||
allow_deflate_eof_plus_one: false,
|
||||
allow_invalid_presorted_fallback: false,
|
||||
limits,
|
||||
},
|
||||
RsliReadProfile::Compatible(profile) => ParseOptions {
|
||||
allow_ao_trailer: profile.allow_ao_trailer,
|
||||
allow_deflate_eof_plus_one: profile.allow_deflate_eof_plus_one,
|
||||
allow_invalid_presorted_fallback: profile.allow_invalid_presorted_fallback,
|
||||
limits,
|
||||
},
|
||||
};
|
||||
let ParsedRsli {
|
||||
@@ -461,6 +627,20 @@ impl RsliDocument {
|
||||
/// Returns [`RsliError`] when `id` is invalid or the packed payload cannot
|
||||
/// be decoded to the declared size.
|
||||
pub fn load(&self, id: EntryId) -> Result<Vec<u8>, RsliError> {
|
||||
self.load_with_limits(id, DecodeLimits::default())
|
||||
}
|
||||
|
||||
/// Loads and unpacks an entry with explicit decode limits.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliError`] when the packed payload exceeds configured
|
||||
/// limits, `id` is invalid, or the payload cannot be decoded.
|
||||
pub fn load_with_limits(
|
||||
&self,
|
||||
id: EntryId,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<Vec<u8>, RsliError> {
|
||||
let record = self.record_by_id(id)?;
|
||||
let packed = self.packed_slice(id, record)?;
|
||||
decode_payload(
|
||||
@@ -468,6 +648,7 @@ impl RsliDocument {
|
||||
record.meta.method,
|
||||
record.key16,
|
||||
record.meta.unpacked_size,
|
||||
limits,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -493,6 +674,200 @@ impl RsliDocument {
|
||||
WriteProfile::Lossless => self.bytes.to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a mutable editor from the parsed document.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliError`] when source payloads cannot be copied from the
|
||||
/// underlying archive image.
|
||||
pub fn editor(&self) -> Result<RsliEditor, RsliError> {
|
||||
let mut entries = Vec::with_capacity(self.records.len());
|
||||
for (id, record) in self.records.iter().enumerate() {
|
||||
let entry_id = EntryId(u32::try_from(id).map_err(|_| RsliError::IntegerOverflow)?);
|
||||
let packed = self.packed_slice(entry_id, record)?.to_vec();
|
||||
entries.push(EditableEntry {
|
||||
meta: record.meta.clone(),
|
||||
packed,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(RsliEditor {
|
||||
original_image: self.bytes.clone(),
|
||||
header: self.header.clone(),
|
||||
overlay: self
|
||||
.ao_trailer
|
||||
.as_ref()
|
||||
.map_or(0, |overlay| overlay.overlay),
|
||||
ao_trailer: self.ao_trailer.as_ref().map(|overlay| overlay.raw),
|
||||
entries,
|
||||
dirty: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RsliEditor {
|
||||
/// Returns editable entries by original directory id.
|
||||
#[must_use]
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Replaces packed payload bytes for an entry.
|
||||
///
|
||||
/// `unpacked_size` is stored explicitly for compatibility checks and does
|
||||
/// not imply a packing transform.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliMutationError`] when the entry id is unknown or the packed
|
||||
/// payload is too large for the archive directory format.
|
||||
pub fn set_packed_payload(
|
||||
&mut self,
|
||||
id: EntryId,
|
||||
packed: impl Into<Vec<u8>>,
|
||||
unpacked_size: u32,
|
||||
) -> Result<(), RsliMutationError> {
|
||||
let entry = self.entry_mut(id)?;
|
||||
let packed = packed.into();
|
||||
entry.meta.packed_size =
|
||||
u32::try_from(packed.len()).map_err(|_| RsliMutationError::PackedPayloadTooLarge {
|
||||
size: packed.len(),
|
||||
max: u32::MAX as usize,
|
||||
})?;
|
||||
entry.packed = packed;
|
||||
entry.meta.unpacked_size = unpacked_size;
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces entry packing method in-place.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliMutationError`] when the entry id is unknown.
|
||||
pub fn set_method(&mut self, id: EntryId, method: RsliMethod) -> Result<(), RsliMutationError> {
|
||||
let entry = self.entry_mut(id)?;
|
||||
entry.meta.flags = flags_with_method(entry.meta.flags, method)?;
|
||||
entry.meta.method = method;
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replaces entry name in the fixed 12-byte table field.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliMutationError`] when the entry id is unknown or the name
|
||||
/// cannot be represented in the fixed authoring field.
|
||||
pub fn set_name(&mut self, id: EntryId, name: &[u8]) -> Result<(), RsliMutationError> {
|
||||
let entry = self.entry_mut(id)?;
|
||||
entry.meta.name_raw = authoring_name_raw(name)?;
|
||||
entry.meta.name = decode_name(c_name_bytes(&entry.meta.name_raw));
|
||||
self.dirty = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encodes the document according to editor state.
|
||||
///
|
||||
/// For untouched documents returns the original image verbatim. On any
|
||||
/// mutation this method rebuilds the lookup table and rewrites packed entry
|
||||
/// bytes deterministically.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`RsliError`] when offsets, sizes or ids exceed in-memory limits.
|
||||
pub fn encode(&self) -> Result<Vec<u8>, RsliError> {
|
||||
if !self.dirty {
|
||||
return Ok(self.original_image.to_vec());
|
||||
}
|
||||
self.encode_rebuild()
|
||||
}
|
||||
|
||||
fn encode_rebuild(&self) -> Result<Vec<u8>, RsliError> {
|
||||
let mut output = Vec::with_capacity(self.original_image.len());
|
||||
|
||||
let entry_count =
|
||||
u16::try_from(self.entries.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let table_len = self
|
||||
.entries
|
||||
.len()
|
||||
.checked_mul(32)
|
||||
.ok_or(RsliError::IntegerOverflow)?;
|
||||
|
||||
let mut header = self.header.raw;
|
||||
header[4..6].copy_from_slice(&entry_count.to_le_bytes());
|
||||
output.extend_from_slice(&header);
|
||||
|
||||
let mut sorted = (0..self.entries.len()).collect::<Vec<_>>();
|
||||
sorted.sort_by(|left, right| {
|
||||
cmp_c_string(
|
||||
c_name_bytes(&self.entries[*left].meta.name_raw),
|
||||
c_name_bytes(&self.entries[*right].meta.name_raw),
|
||||
)
|
||||
});
|
||||
|
||||
let mut lookup_map = vec![0i16; self.entries.len()];
|
||||
for (position, original) in sorted.iter().enumerate() {
|
||||
lookup_map[*original] =
|
||||
i16::try_from(position).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
}
|
||||
|
||||
let mut cursor = 32usize
|
||||
.checked_add(table_len)
|
||||
.ok_or(RsliError::IntegerOverflow)?;
|
||||
let mut table_plain = Vec::with_capacity(table_len);
|
||||
for (index, entry) in self.entries.iter().enumerate() {
|
||||
let mut row = [0u8; 32];
|
||||
let name_len = entry.meta.name_raw.len().min(12);
|
||||
row[0..name_len].copy_from_slice(&entry.meta.name_raw[..name_len]);
|
||||
|
||||
row[16..18].copy_from_slice(
|
||||
&i16::try_from(entry.meta.flags)
|
||||
.map_err(|_| RsliError::IntegerOverflow)?
|
||||
.to_le_bytes(),
|
||||
);
|
||||
row[18..20].copy_from_slice(&lookup_map[index].to_le_bytes());
|
||||
row[20..24].copy_from_slice(&entry.meta.unpacked_size.to_le_bytes());
|
||||
|
||||
let packed_len =
|
||||
u32::try_from(entry.packed.len()).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let cursor_u32 = u32::try_from(cursor).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let offset_raw = if self.overlay == 0 {
|
||||
cursor_u32
|
||||
} else {
|
||||
cursor_u32
|
||||
.checked_sub(self.overlay)
|
||||
.ok_or(RsliError::IntegerOverflow)?
|
||||
};
|
||||
|
||||
row[24..28].copy_from_slice(&offset_raw.to_le_bytes());
|
||||
row[28..32].copy_from_slice(&packed_len.to_le_bytes());
|
||||
table_plain.extend_from_slice(&row);
|
||||
|
||||
output.extend_from_slice(&entry.packed);
|
||||
cursor = cursor
|
||||
.checked_add(entry.packed.len())
|
||||
.ok_or(RsliError::IntegerOverflow)?;
|
||||
}
|
||||
|
||||
let seed =
|
||||
u16::try_from(self.header.xor_seed & 0xFFFF).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let encrypted = xor_stream(&table_plain, seed);
|
||||
output.splice(32..32, encrypted);
|
||||
|
||||
if let Some(overlay) = &self.ao_trailer {
|
||||
output.extend_from_slice(overlay);
|
||||
}
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn entry_mut(&mut self, id: EntryId) -> Result<&mut EditableEntry, RsliMutationError> {
|
||||
self.entries
|
||||
.get_mut(usize::try_from(id.0).map_err(|_| RsliMutationError::EntryNotFound { id })?)
|
||||
.ok_or(RsliMutationError::EntryNotFound { id })
|
||||
}
|
||||
}
|
||||
|
||||
impl RsliDocument {
|
||||
@@ -560,6 +935,7 @@ struct ParseOptions {
|
||||
allow_ao_trailer: bool,
|
||||
allow_deflate_eof_plus_one: bool,
|
||||
allow_invalid_presorted_fallback: bool,
|
||||
limits: DecodeLimits,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -580,6 +956,10 @@ struct EntryRecord {
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliError> {
|
||||
enforce_limit(
|
||||
u64::try_from(bytes.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
||||
options.limits.max_input_bytes,
|
||||
)?;
|
||||
if bytes.len() < 32 {
|
||||
return Err(RsliError::EntryTableOutOfBounds {
|
||||
table_offset: 32,
|
||||
@@ -615,6 +995,10 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
|
||||
if count > usize::try_from(u32::MAX).map_err(|_| RsliError::IntegerOverflow)? {
|
||||
return Err(RsliError::TooManyEntries { got: count });
|
||||
}
|
||||
enforce_limit(
|
||||
u64::try_from(count).map_err(|_| RsliError::IntegerOverflow)?,
|
||||
u64::from(options.limits.max_entries),
|
||||
)?;
|
||||
|
||||
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
||||
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
||||
@@ -658,6 +1042,14 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
|
||||
let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
|
||||
let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
|
||||
let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
|
||||
enforce_limit(
|
||||
u64::from(packed_size_declared),
|
||||
options.limits.max_packed_entry_bytes,
|
||||
)?;
|
||||
enforce_limit(
|
||||
u64::from(unpacked_size),
|
||||
options.limits.max_decoded_entry_bytes,
|
||||
)?;
|
||||
let method_raw = u32::from(flags_signed.cast_unsigned()) & 0x1E0;
|
||||
let method = parse_method(method_raw);
|
||||
|
||||
@@ -732,9 +1124,12 @@ fn parse_rsli(bytes: &[u8], options: ParseOptions) -> Result<ParsedRsli, RsliErr
|
||||
}
|
||||
|
||||
if presorted_flag == 0xABBA {
|
||||
if validate_permutation(&records).is_err() {
|
||||
let permutation = validate_permutation(&records);
|
||||
let order = validate_lookup_order(&records);
|
||||
if permutation.is_err() || order.is_err() {
|
||||
if !options.allow_invalid_presorted_fallback {
|
||||
validate_permutation(&records)?;
|
||||
permutation?;
|
||||
order?;
|
||||
}
|
||||
rebuild_sorted_mapping(&mut records)?;
|
||||
}
|
||||
@@ -809,6 +1204,35 @@ fn validate_permutation(records: &[EntryRecord]) -> Result<(), RsliError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_lookup_order(records: &[EntryRecord]) -> Result<(), RsliError> {
|
||||
for pair in records.windows(2) {
|
||||
let left_original = usize::try_from(i32::from(pair[0].meta.sort_to_original))
|
||||
.map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let right_original = usize::try_from(i32::from(pair[1].meta.sort_to_original))
|
||||
.map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let left = records
|
||||
.get(left_original)
|
||||
.ok_or(RsliError::CorruptEntryTable(
|
||||
"sort_to_original is not a permutation",
|
||||
))?;
|
||||
let right = records
|
||||
.get(right_original)
|
||||
.ok_or(RsliError::CorruptEntryTable(
|
||||
"sort_to_original is not a permutation",
|
||||
))?;
|
||||
if cmp_c_string(
|
||||
c_name_bytes(&left.meta.name_raw),
|
||||
c_name_bytes(&right.meta.name_raw),
|
||||
) == std::cmp::Ordering::Greater
|
||||
{
|
||||
return Err(RsliError::CorruptEntryTable(
|
||||
"presorted lookup names are not sorted",
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_method(raw: u32) -> RsliMethod {
|
||||
match raw {
|
||||
0x000 => RsliMethod::Stored,
|
||||
@@ -833,6 +1257,23 @@ fn decode_name(name: &[u8]) -> String {
|
||||
name.iter().map(|byte| char::from(*byte)).collect()
|
||||
}
|
||||
|
||||
fn authoring_name_raw(name: &[u8]) -> Result<[u8; 12], RsliMutationError> {
|
||||
if name.len() > 12 {
|
||||
return Err(RsliMutationError::AuthoringNameTooLong {
|
||||
len: name.len(),
|
||||
max: 12,
|
||||
});
|
||||
}
|
||||
let mut output = [0u8; 12];
|
||||
for (offset, byte) in name.iter().copied().enumerate() {
|
||||
if byte == 0 {
|
||||
return Err(RsliMutationError::AuthoringNameContainsNul { offset });
|
||||
}
|
||||
output[offset] = byte;
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
|
||||
let len = raw.iter().position(|byte| *byte == 0).unwrap_or(raw.len());
|
||||
&raw[..len]
|
||||
@@ -853,32 +1294,39 @@ fn decode_payload(
|
||||
method: RsliMethod,
|
||||
key16: u16,
|
||||
unpacked_size: u32,
|
||||
limits: DecodeLimits,
|
||||
) -> Result<Vec<u8>, RsliError> {
|
||||
enforce_limit(
|
||||
u64::try_from(packed.len()).map_err(|_| RsliError::IntegerOverflow)?,
|
||||
limits.max_packed_entry_bytes,
|
||||
)?;
|
||||
enforce_limit(u64::from(unpacked_size), limits.max_decoded_entry_bytes)?;
|
||||
enforce_limit(u64::from(unpacked_size), limits.max_total_decoded_bytes)?;
|
||||
let expected = usize::try_from(unpacked_size).map_err(|_| RsliError::IntegerOverflow)?;
|
||||
let out = match method {
|
||||
RsliMethod::Stored => {
|
||||
if packed.len() < expected {
|
||||
if packed.len() != expected {
|
||||
return Err(RsliError::OutputSizeMismatch {
|
||||
expected: unpacked_size,
|
||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
packed[..expected].to_vec()
|
||||
packed.to_vec()
|
||||
}
|
||||
RsliMethod::XorOnly => {
|
||||
if packed.len() < expected {
|
||||
if packed.len() != expected {
|
||||
return Err(RsliError::OutputSizeMismatch {
|
||||
expected: unpacked_size,
|
||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
xor_stream(&packed[..expected], key16)
|
||||
xor_stream(packed, key16)
|
||||
}
|
||||
RsliMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
|
||||
RsliMethod::XorLzss => lzss_decompress_simple(packed, expected, Some(key16))?,
|
||||
RsliMethod::AdaptiveLzss => lzss_huffman_decompress(packed, expected, None)?,
|
||||
RsliMethod::XorAdaptiveLzss => lzss_huffman_decompress(packed, expected, Some(key16))?,
|
||||
RsliMethod::RawDeflate => decode_deflate(packed)?,
|
||||
RsliMethod::RawDeflate => decode_deflate(packed, expected)?,
|
||||
RsliMethod::Unknown(raw) => return Err(RsliError::UnsupportedMethod { raw }),
|
||||
};
|
||||
if out.len() != expected {
|
||||
@@ -982,15 +1430,61 @@ fn read_packed_byte(data: &[u8], pos: usize, state: &mut Option<XorState>) -> Op
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>, RsliError> {
|
||||
let mut out = Vec::new();
|
||||
fn decode_deflate(packed: &[u8], expected_size: usize) -> Result<Vec<u8>, RsliError> {
|
||||
let mut out = Vec::with_capacity(expected_size);
|
||||
let mut chunk = [0u8; 4096];
|
||||
let mut decoder = flate2::read::DeflateDecoder::new(packed);
|
||||
decoder
|
||||
.read_to_end(&mut out)
|
||||
.map_err(|_| RsliError::DecompressionFailed("deflate"))?;
|
||||
loop {
|
||||
let read = decoder
|
||||
.read(&mut chunk)
|
||||
.map_err(|_| RsliError::DecompressionFailed("deflate"))?;
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
let next_len = out
|
||||
.len()
|
||||
.checked_add(read)
|
||||
.ok_or(RsliError::IntegerOverflow)?;
|
||||
if next_len > expected_size {
|
||||
return Err(RsliError::OutputSizeMismatch {
|
||||
expected: u32::try_from(expected_size).unwrap_or(u32::MAX),
|
||||
got: u32::try_from(next_len).unwrap_or(u32::MAX),
|
||||
});
|
||||
}
|
||||
out.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn method_bits(method: RsliMethod) -> Result<u16, RsliMutationError> {
|
||||
match method {
|
||||
RsliMethod::Stored => Ok(0x000),
|
||||
RsliMethod::XorOnly => Ok(0x020),
|
||||
RsliMethod::Lzss => Ok(0x040),
|
||||
RsliMethod::XorLzss => Ok(0x060),
|
||||
RsliMethod::AdaptiveLzss => Ok(0x080),
|
||||
RsliMethod::XorAdaptiveLzss => Ok(0x0A0),
|
||||
RsliMethod::RawDeflate => Ok(0x100),
|
||||
RsliMethod::Unknown(_) => Err(RsliMutationError::UnsupportedMethod { method }),
|
||||
}
|
||||
}
|
||||
|
||||
fn flags_with_method(flags: i32, method: RsliMethod) -> Result<i32, RsliMutationError> {
|
||||
let method = i32::from(method_bits(method)?);
|
||||
Ok((flags & !0x1E0) | method)
|
||||
}
|
||||
|
||||
fn enforce_limit(value: u64, limit: u64) -> Result<(), RsliError> {
|
||||
if value > limit {
|
||||
return Err(DecodeError::LimitExceeded {
|
||||
count: value,
|
||||
limit,
|
||||
}
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const LZH_N: usize = 4096;
|
||||
const LZH_F: usize = 60;
|
||||
const LZH_THRESHOLD: usize = 2;
|
||||
@@ -1408,6 +1902,30 @@ mod tests {
|
||||
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_rejects_unsorted_presorted_mapping() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[
|
||||
SyntheticEntry::stored(b"B", 0, b"bee"),
|
||||
SyntheticEntry::stored(b"A", 1, b"aye"),
|
||||
],
|
||||
true,
|
||||
0x0103,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
decode(arc(bytes.clone()), ReadProfile::Strict),
|
||||
Err(RsliError::CorruptEntryTable(
|
||||
"presorted lookup names are not sorted"
|
||||
))
|
||||
));
|
||||
|
||||
let doc = decode(arc(bytes), ReadProfile::Compatible).expect("compatible fallback");
|
||||
assert_eq!(doc.find("A"), Some(EntryId(1)));
|
||||
assert_eq!(doc.find("B"), Some(EntryId(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_profile_controls_invalid_presorted_fallback() {
|
||||
let bytes = synthetic_rsli(
|
||||
@@ -1462,8 +1980,8 @@ mod tests {
|
||||
let packed = xor_stream(&plain, 1);
|
||||
let bytes = synthetic_rsli(
|
||||
&[
|
||||
SyntheticEntry::with_payload(b"A", 0x020, 1, &plain, packed),
|
||||
SyntheticEntry::stored(b"B", 0, b"plain"),
|
||||
SyntheticEntry::with_payload(b"B", 0x020, 1, &plain, packed),
|
||||
SyntheticEntry::stored(b"A", 0, b"plain"),
|
||||
],
|
||||
true,
|
||||
0x2222,
|
||||
@@ -1814,6 +2332,183 @@ mod tests {
|
||||
assert_eq!(doc.encode(WriteProfile::Lossless), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_roundtrip_without_mutations_is_identity() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[
|
||||
SyntheticEntry::stored(b"A", 0, b"alpha"),
|
||||
SyntheticEntry::stored(b"B", 1, b"beta"),
|
||||
],
|
||||
true,
|
||||
0x7777,
|
||||
None,
|
||||
);
|
||||
|
||||
let doc = decode(arc(bytes.clone()), ReadProfile::Strict).expect("editable archive");
|
||||
let editor = doc.editor().expect("editor");
|
||||
|
||||
assert_eq!(editor.encode().expect("editor encode"), bytes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_can_mutate_names_and_payloads() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[
|
||||
SyntheticEntry::stored(b"A", 0, b"alpha"),
|
||||
SyntheticEntry::stored(b"B", 1, b"beta"),
|
||||
],
|
||||
true,
|
||||
0x7778,
|
||||
None,
|
||||
);
|
||||
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||
let mut editor = doc.editor().expect("editor");
|
||||
editor.set_name(EntryId(1), b"ZETA").expect("edit name");
|
||||
let repacked = deflate_bytes(b"repacked-alpha");
|
||||
editor
|
||||
.set_packed_payload(EntryId(0), repacked, 14)
|
||||
.expect("edit packed payload");
|
||||
editor
|
||||
.set_method(EntryId(0), RsliMethod::RawDeflate)
|
||||
.expect("edit method");
|
||||
|
||||
let rebuilt = editor.encode().expect("editor encode");
|
||||
let doc = decode(arc(rebuilt), ReadProfile::Strict).expect("repacked archive");
|
||||
|
||||
let renamed = doc.find("ZETA").expect("renamed entry");
|
||||
assert_eq!(doc.load(renamed).expect("renamed payload"), b"beta");
|
||||
let original = doc
|
||||
.find("A")
|
||||
.or_else(|| doc.find("a"))
|
||||
.expect("original renamed entry fallback");
|
||||
assert_eq!(
|
||||
doc.load(original).expect("updated payload"),
|
||||
b"repacked-alpha"
|
||||
);
|
||||
assert_eq!(
|
||||
doc.entries()[original.0 as usize].method,
|
||||
RsliMethod::RawDeflate
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_method_rejects_unknown_authoring_method() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
|
||||
true,
|
||||
0x7780,
|
||||
None,
|
||||
);
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||
let mut editor = doc.editor().expect("editor");
|
||||
|
||||
assert!(matches!(
|
||||
editor.set_method(EntryId(0), RsliMethod::Unknown(0x1E0)),
|
||||
Err(RsliMutationError::UnsupportedMethod { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_rejects_entry_count_above_limit() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[
|
||||
SyntheticEntry::stored(b"A", 0, b"alpha"),
|
||||
SyntheticEntry::stored(b"B", 1, b"beta"),
|
||||
],
|
||||
true,
|
||||
0x7781,
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
decode_with_limits(
|
||||
arc(bytes),
|
||||
ReadProfile::Strict,
|
||||
DecodeLimits {
|
||||
max_entries: 1,
|
||||
..DecodeLimits::default()
|
||||
}
|
||||
),
|
||||
Err(RsliError::Binary(DecodeError::LimitExceeded {
|
||||
count: 2,
|
||||
limit: 1
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_entries_require_exact_packed_size() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[SyntheticEntry::with_payload(
|
||||
b"A",
|
||||
0x000,
|
||||
0,
|
||||
b"ok",
|
||||
b"ok!".to_vec(),
|
||||
)],
|
||||
true,
|
||||
0x7782,
|
||||
None,
|
||||
);
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored archive");
|
||||
|
||||
assert!(matches!(
|
||||
doc.load(EntryId(0)),
|
||||
Err(RsliError::OutputSizeMismatch {
|
||||
expected: 2,
|
||||
got: 3
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_rejects_unpacked_size_above_limit_before_allocation() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
|
||||
true,
|
||||
0x7783,
|
||||
None,
|
||||
);
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("stored archive");
|
||||
|
||||
assert!(matches!(
|
||||
doc.load_with_limits(
|
||||
EntryId(0),
|
||||
DecodeLimits {
|
||||
max_decoded_entry_bytes: 4,
|
||||
max_total_decoded_bytes: 4,
|
||||
..DecodeLimits::default()
|
||||
}
|
||||
),
|
||||
Err(RsliError::Binary(DecodeError::LimitExceeded {
|
||||
count: 5,
|
||||
limit: 4
|
||||
}))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_rejects_unknown_entry_id_and_invalid_name() {
|
||||
let bytes = synthetic_rsli(
|
||||
&[SyntheticEntry::stored(b"A", 0, b"alpha")],
|
||||
true,
|
||||
0x7779,
|
||||
None,
|
||||
);
|
||||
let doc = decode(arc(bytes), ReadProfile::Strict).expect("editable archive");
|
||||
let mut editor = doc.editor().expect("editor");
|
||||
|
||||
assert!(matches!(
|
||||
editor.set_name(EntryId(10), b"BAD"),
|
||||
Err(RsliMutationError::EntryNotFound { id: EntryId(10) })
|
||||
));
|
||||
assert!(matches!(
|
||||
editor.set_name(EntryId(0), b"TOO_LONG_ENTRY_NAME"),
|
||||
Err(RsliMutationError::AuthoringNameTooLong { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_supported_methods_decode_expected_bytes() {
|
||||
let cases = [
|
||||
@@ -2263,6 +2958,15 @@ mod tests {
|
||||
bytes
|
||||
}
|
||||
|
||||
fn deflate_bytes(plain: &[u8]) -> Vec<u8> {
|
||||
use std::io::Write;
|
||||
|
||||
let mut encoder =
|
||||
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
encoder.write_all(plain).expect("deflate write");
|
||||
encoder.finish().expect("deflate finish")
|
||||
}
|
||||
|
||||
fn two_plain_rows_for_transform_test() -> Vec<[u8; 32]> {
|
||||
let mut a = [0u8; 32];
|
||||
let mut b = [0u8; 32];
|
||||
|
||||
@@ -6,17 +6,16 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-platform = { path = "../fparkan-platform" }
|
||||
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||
fparkan-render = { path = "../fparkan-render" }
|
||||
fparkan-resource = { path = "../fparkan-resource" }
|
||||
fparkan-terrain = { path = "../fparkan-terrain" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||
fparkan-world = { path = "../fparkan-world" }
|
||||
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
|
||||
|
||||
+1638
-115
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,12 @@
|
||||
[package]
|
||||
name = "fparkan-render-gl"
|
||||
name = "fparkan-script"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Terrain disk format primitives.
|
||||
|
||||
use fparkan_binary::{checked_count_bytes, Cursor, DecodeError};
|
||||
@@ -43,6 +62,69 @@ pub struct CompactSurfaceMask(pub u16);
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct MaterialClassMask(pub u8);
|
||||
|
||||
/// The two positional material-table selectors packed into a terrain face tag.
|
||||
///
|
||||
/// The high byte selects from map-local `Land1.wea`; `0xff` is the observed
|
||||
/// no-selection sentinel. The low byte selects from `Land2.wea`.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct TerrainMaterialLayers {
|
||||
/// Optional high-byte selector for `Land1.wea`.
|
||||
pub land1_selector: Option<u8>,
|
||||
/// Low-byte selector for `Land2.wea`.
|
||||
pub land2_selector: u8,
|
||||
}
|
||||
|
||||
/// One `World3D` material-manager lookup key.
|
||||
///
|
||||
/// The original manager selects a WEAR table in the upper 16 bits and its
|
||||
/// positional material row in the lower 16 bits.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct TerrainMaterialSelection {
|
||||
/// Zero-based material-manager table index.
|
||||
pub table_index: u16,
|
||||
/// Positional material row in that WEAR table.
|
||||
pub material_index: u16,
|
||||
}
|
||||
|
||||
impl TerrainMaterialSelection {
|
||||
/// Encodes the original material-manager phase lookup key.
|
||||
#[must_use]
|
||||
pub fn phase_key(self) -> u32 {
|
||||
(u32::from(self.table_index) << 16) | u32::from(self.material_index)
|
||||
}
|
||||
}
|
||||
|
||||
impl TerrainMaterialLayers {
|
||||
/// Constructs the decoded pair from its on-disk packed tag.
|
||||
#[must_use]
|
||||
pub const fn from_packed_tag(material_tag: u16) -> Self {
|
||||
let [land2_selector, land1] = material_tag.to_le_bytes();
|
||||
Self {
|
||||
land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
|
||||
land2_selector,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the table-zero `Land1.wea` selection when it exists.
|
||||
#[must_use]
|
||||
pub fn land1_selection(self) -> Option<TerrainMaterialSelection> {
|
||||
self.land1_selector
|
||||
.map(|material_index| TerrainMaterialSelection {
|
||||
table_index: 0,
|
||||
material_index: u16::from(material_index),
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns the table-one `Land2.wea` selection.
|
||||
#[must_use]
|
||||
pub fn land2_selection(self) -> TerrainMaterialSelection {
|
||||
TerrainMaterialSelection {
|
||||
table_index: 1,
|
||||
material_index: u16::from(self.land2_selector),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terrain face with 28-byte source layout.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TerrainFace28 {
|
||||
@@ -62,6 +144,14 @@ pub struct TerrainFace28 {
|
||||
pub raw: [u8; 28],
|
||||
}
|
||||
|
||||
impl TerrainFace28 {
|
||||
/// Decodes the proven two-table selector layout of [`Self::material_tag`].
|
||||
#[must_use]
|
||||
pub const fn material_layers(&self) -> TerrainMaterialLayers {
|
||||
TerrainMaterialLayers::from_packed_tag(self.material_tag)
|
||||
}
|
||||
}
|
||||
|
||||
/// Terrain stream descriptor.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct TerrainStream {
|
||||
@@ -93,6 +183,59 @@ pub struct TerrainSlotTable {
|
||||
pub slots_raw: Vec<[u8; SLOT_STRIDE]>,
|
||||
}
|
||||
|
||||
/// The proven front fields of one 68-byte terrain slot record.
|
||||
///
|
||||
/// `Terrain.dll!CLandscape` supplies these fields to `GetShade`'s terrain
|
||||
/// batch dispatcher: `pair_table_index` selects the pointer-table entry and
|
||||
/// `pair_count` is the number of adjacent `u16` pairs to process. The pair
|
||||
/// payload and the later material/blend interpretation remain external to the
|
||||
/// disk record.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct TerrainSlotRenderDispatch {
|
||||
/// Index into the runtime pair-pointer table.
|
||||
pub pair_table_index: u16,
|
||||
/// Number of adjacent `u16` pairs in that selected payload.
|
||||
pub pair_count: u16,
|
||||
}
|
||||
|
||||
/// One pair consumed by the original terrain material-batch dispatcher.
|
||||
///
|
||||
/// These four bytes live in the type-11 stream. `material_lookup` is handed to
|
||||
/// `GetShade`'s material lookup, while bit `0x10` of `flags` creates a batch
|
||||
/// boundary. Other flag bits remain raw.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct TerrainMaterialPair {
|
||||
/// Lookup id supplied to the shade material manager.
|
||||
pub material_lookup: u16,
|
||||
/// Opaque pair flags; bit `0x10` is a proven batch boundary.
|
||||
pub flags: u16,
|
||||
}
|
||||
|
||||
impl TerrainMaterialPair {
|
||||
/// Returns the opaque lookup key supplied to `GetShade`.
|
||||
///
|
||||
/// This is deliberately separate from the `Land1.wea`/`Land2.wea`
|
||||
/// selectors carried by [`TerrainMaterialLayers`]. Although `GetShade`
|
||||
/// owns a manager loaded from `Shade.wea`, this key is not a WEAR row:
|
||||
/// observed map values exceed that table's row count.
|
||||
#[must_use]
|
||||
pub const fn shade_lookup_key(self) -> u16 {
|
||||
self.material_lookup
|
||||
}
|
||||
}
|
||||
|
||||
impl TerrainSlotTable {
|
||||
/// Returns the render-dispatch header for one decoded slot record.
|
||||
#[must_use]
|
||||
pub fn render_dispatch(&self, slot_index: usize) -> Option<TerrainSlotRenderDispatch> {
|
||||
let raw = self.slots_raw.get(slot_index)?;
|
||||
Some(TerrainSlotRenderDispatch {
|
||||
pair_table_index: u16::from_le_bytes([raw[0], raw[1]]),
|
||||
pair_count: u16::from_le_bytes([raw[2], raw[3]]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Land mesh document.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LandMeshDocument {
|
||||
@@ -112,12 +255,38 @@ pub struct LandMeshDocument {
|
||||
pub accelerator: Vec<[u8; 4]>,
|
||||
/// Type 14 auxiliary words.
|
||||
pub aux14: Vec<[u8; 4]>,
|
||||
/// Type 18 auxiliary words.
|
||||
/// Type 18 microtexture mapping words.
|
||||
///
|
||||
/// `Terrain.dll!CLandscape` requires this stream and retains it as a
|
||||
/// four-byte-per-entry mapping chunk.
|
||||
pub aux18: Vec<[u8; 4]>,
|
||||
/// Faces.
|
||||
pub faces: Vec<TerrainFace28>,
|
||||
}
|
||||
|
||||
impl LandMeshDocument {
|
||||
/// Resolves the type-11 material pairs addressed by one 68-byte slot.
|
||||
///
|
||||
/// Returns `None` when the slot is absent or its pair range does not fit
|
||||
/// the retained type-11 stream.
|
||||
#[must_use]
|
||||
pub fn slot_material_pairs(&self, slot_index: usize) -> Option<Vec<TerrainMaterialPair>> {
|
||||
let dispatch = self.slots.render_dispatch(slot_index)?;
|
||||
let start = usize::from(dispatch.pair_table_index);
|
||||
let end = start.checked_add(usize::from(dispatch.pair_count))?;
|
||||
let pairs = self.accelerator.get(start..end)?;
|
||||
Some(
|
||||
pairs
|
||||
.iter()
|
||||
.map(|raw| TerrainMaterialPair {
|
||||
material_lookup: u16::from_le_bytes([raw[0], raw[1]]),
|
||||
flags: u16::from_le_bytes([raw[2], raw[3]]),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decoded `Land.map` document.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct LandMapDocument {
|
||||
@@ -1367,6 +1536,111 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_render_dispatch_retains_pair_table_index_and_count() {
|
||||
let mut raw = [0_u8; SLOT_STRIDE];
|
||||
raw[..2].copy_from_slice(&7_u16.to_le_bytes());
|
||||
raw[2..4].copy_from_slice(&3_u16.to_le_bytes());
|
||||
let slots = TerrainSlotTable {
|
||||
header_raw: SLOT_HEADER_ZERO.to_vec(),
|
||||
slots_raw: vec![raw],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
slots.render_dispatch(0),
|
||||
Some(TerrainSlotRenderDispatch {
|
||||
pair_table_index: 7,
|
||||
pair_count: 3,
|
||||
})
|
||||
);
|
||||
assert_eq!(slots.render_dispatch(1), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_material_pairs_use_type11_pair_table_and_preserve_flags() {
|
||||
let nres =
|
||||
decode_nres(&minimal_land_msh(&face([0, 1, 2], [None, None, None]))).expect("nres");
|
||||
let mut document = decode_land_msh(&nres).expect("land mesh");
|
||||
document.slots = TerrainSlotTable {
|
||||
header_raw: SLOT_HEADER_ZERO.to_vec(),
|
||||
slots_raw: vec![[0_u8; SLOT_STRIDE]],
|
||||
};
|
||||
document.slots.slots_raw[0][..2].copy_from_slice(&1_u16.to_le_bytes());
|
||||
document.slots.slots_raw[0][2..4].copy_from_slice(&2_u16.to_le_bytes());
|
||||
document.accelerator = vec![[0, 0, 0, 0], [2, 1, 0, 0], [3, 2, 0x10, 0]];
|
||||
|
||||
assert_eq!(
|
||||
document.slot_material_pairs(0),
|
||||
Some(vec![
|
||||
TerrainMaterialPair {
|
||||
material_lookup: 0x0102,
|
||||
flags: 0,
|
||||
},
|
||||
TerrainMaterialPair {
|
||||
material_lookup: 0x0203,
|
||||
flags: 0x0010,
|
||||
},
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
document.slot_material_pairs(0).expect("material pairs")[0].shade_lookup_key(),
|
||||
0x0102
|
||||
);
|
||||
assert_eq!(document.slot_material_pairs(1), None);
|
||||
|
||||
document.slots.slots_raw[0][2..4].copy_from_slice(&3_u16.to_le_bytes());
|
||||
assert_eq!(document.slot_material_pairs(0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terrain_material_tag_decodes_two_sidecar_selectors() {
|
||||
let mut raw_face = face([0, 1, 2], [None, None, None]);
|
||||
raw_face[4..6].copy_from_slice(&0x0102_u16.to_le_bytes());
|
||||
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
|
||||
let document = decode_land_msh(&nres).expect("land mesh");
|
||||
|
||||
assert_eq!(
|
||||
document.faces[0].material_layers(),
|
||||
TerrainMaterialLayers {
|
||||
land1_selector: Some(1),
|
||||
land2_selector: 2,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
document.faces[0].material_layers().land1_selection(),
|
||||
Some(TerrainMaterialSelection {
|
||||
table_index: 0,
|
||||
material_index: 1,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
document.faces[0]
|
||||
.material_layers()
|
||||
.land2_selection()
|
||||
.phase_key(),
|
||||
0x0001_0002
|
||||
);
|
||||
|
||||
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
|
||||
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
|
||||
let document = decode_land_msh(&nres).expect("land mesh");
|
||||
assert_eq!(
|
||||
document.faces[0].material_layers(),
|
||||
TerrainMaterialLayers {
|
||||
land1_selector: None,
|
||||
land2_selector: 3,
|
||||
}
|
||||
);
|
||||
assert_eq!(document.faces[0].material_layers().land1_selection(), None);
|
||||
assert_eq!(
|
||||
document.faces[0].material_layers().land2_selection(),
|
||||
TerrainMaterialSelection {
|
||||
table_index: 1,
|
||||
material_index: 3,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_minimal_land_map() {
|
||||
let nres = decode_nres(&minimal_land_map([(-1, -1), (-1, -1)], 0)).expect("nres");
|
||||
|
||||
@@ -6,10 +6,10 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||
fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,9 +1,34 @@
|
||||
#![forbid(unsafe_code)]
|
||||
//! Validated terrain runtime queries.
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Validated terrain runtime queries using XY ground coordinates and Z height.
|
||||
|
||||
use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
|
||||
use std::collections::VecDeque;
|
||||
|
||||
/// Terrain material-selector contract preserved from the decoded map mesh.
|
||||
///
|
||||
/// Applications access this semantic terrain API through this terrain crate
|
||||
/// rather than taking a direct dependency on the binary-format parser.
|
||||
pub use fparkan_terrain_format::{TerrainMaterialLayers, TerrainMaterialSelection};
|
||||
|
||||
/// Terrain world.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TerrainWorld {
|
||||
@@ -11,12 +36,14 @@ pub struct TerrainWorld {
|
||||
grid: RuntimeGrid,
|
||||
adjacency: Vec<Vec<ArealId>>,
|
||||
surfaces: Vec<RuntimeTriangle>,
|
||||
surface_bvh: SurfaceBvh,
|
||||
source_mesh: Option<LandMeshDocument>,
|
||||
}
|
||||
|
||||
/// Surface hit.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct SurfaceHit {
|
||||
/// Height.
|
||||
/// Source Z height.
|
||||
pub height: f32,
|
||||
/// Hit position.
|
||||
pub position: [f32; 3],
|
||||
@@ -115,7 +142,7 @@ impl std::error::Error for TerrainError {}
|
||||
|
||||
/// Surface query.
|
||||
pub trait SurfaceQuery {
|
||||
/// Height at position.
|
||||
/// Source Z height at an XY ground position.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -137,7 +164,7 @@ pub trait SurfaceQuery {
|
||||
|
||||
/// Navigation query.
|
||||
pub trait NavigationQuery {
|
||||
/// Locate areal.
|
||||
/// Locates an areal from the XY components of a world position.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
@@ -175,7 +202,7 @@ impl TerrainWorld {
|
||||
polygon: areal
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|vertex| [vertex[0], vertex[2]])
|
||||
.map(|vertex| [vertex[0], vertex[1]])
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
@@ -213,6 +240,8 @@ impl TerrainWorld {
|
||||
grid,
|
||||
adjacency,
|
||||
surfaces: Vec::new(),
|
||||
surface_bvh: SurfaceBvh::default(),
|
||||
source_mesh: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -225,8 +254,11 @@ impl TerrainWorld {
|
||||
pub fn from_land_msh(mesh: &LandMeshDocument) -> Result<Self, TerrainError> {
|
||||
Ok(Self {
|
||||
surfaces: build_surfaces(mesh)?,
|
||||
surface_bvh: SurfaceBvh::default(),
|
||||
source_mesh: Some(mesh.clone()),
|
||||
..Self::default()
|
||||
})
|
||||
}
|
||||
.with_surface_bvh())
|
||||
}
|
||||
|
||||
/// Builds terrain runtime data from decoded `Land.msh` and `Land.map`.
|
||||
@@ -241,6 +273,8 @@ impl TerrainWorld {
|
||||
) -> Result<Self, TerrainError> {
|
||||
let mut world = Self::from_land_map(map)?;
|
||||
world.surfaces = build_surfaces(mesh)?;
|
||||
world.surface_bvh = SurfaceBvh::from_surfaces(&world.surfaces);
|
||||
world.source_mesh = Some(mesh.clone());
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
@@ -256,12 +290,35 @@ impl TerrainWorld {
|
||||
self.surfaces.len()
|
||||
}
|
||||
|
||||
/// Returns the validated source mesh retained for renderer integration.
|
||||
///
|
||||
/// Runtime collision queries use their own compact triangle representation;
|
||||
/// keeping this document preserves the original geometry and UV streams for
|
||||
/// rendering without asking the application layer to decode an archive again.
|
||||
#[must_use]
|
||||
pub fn source_mesh(&self) -> Option<&LandMeshDocument> {
|
||||
self.source_mesh.as_ref()
|
||||
}
|
||||
|
||||
/// Returns source terrain positions without exposing parser ownership to apps.
|
||||
#[must_use]
|
||||
pub fn source_positions(&self) -> Option<&[[f32; 3]]> {
|
||||
self.source_mesh
|
||||
.as_ref()
|
||||
.map(|mesh| mesh.positions.as_slice())
|
||||
}
|
||||
|
||||
fn with_surface_bvh(mut self) -> Self {
|
||||
self.surface_bvh = SurfaceBvh::from_surfaces(&self.surfaces);
|
||||
self
|
||||
}
|
||||
|
||||
fn locate_by_candidates(
|
||||
&self,
|
||||
position: [f32; 3],
|
||||
candidates: &[ArealId],
|
||||
) -> Result<Option<ArealId>, TerrainError> {
|
||||
let point = [position[0], position[2]];
|
||||
let point = [position[0], position[1]];
|
||||
for candidate in candidates {
|
||||
let Some(areal) = usize::try_from(candidate.0)
|
||||
.ok()
|
||||
@@ -352,7 +409,8 @@ impl SurfaceQuery for TerrainWorld {
|
||||
return Err(TerrainError::Unsupported);
|
||||
}
|
||||
let mut best = None;
|
||||
for triangle in &self.surfaces {
|
||||
for index in self.surface_bvh.xy_candidates(position) {
|
||||
let triangle = &self.surfaces[index];
|
||||
if let Some(height) = triangle.height_at(position) {
|
||||
best = Some(best.map_or(height, |current: f32| current.max(height)));
|
||||
}
|
||||
@@ -369,8 +427,11 @@ impl SurfaceQuery for TerrainWorld {
|
||||
if self.surfaces.is_empty() {
|
||||
return Err(TerrainError::Unsupported);
|
||||
}
|
||||
let mut candidates = self.surface_bvh.ray_candidates(origin, direction);
|
||||
candidates.sort_unstable();
|
||||
let mut best: Option<SurfaceHit> = None;
|
||||
for triangle in &self.surfaces {
|
||||
for index in candidates {
|
||||
let triangle = &self.surfaces[index];
|
||||
if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -386,7 +447,7 @@ impl SurfaceQuery for TerrainWorld {
|
||||
origin[2] + direction[2] * distance,
|
||||
];
|
||||
best = Some(SurfaceHit {
|
||||
height: position[1],
|
||||
height: position[2],
|
||||
position,
|
||||
distance,
|
||||
face: triangle.face,
|
||||
@@ -425,20 +486,216 @@ struct RuntimeTriangle {
|
||||
vertices: [[f32; 3]; 3],
|
||||
}
|
||||
|
||||
const SURFACE_BVH_LEAF_SIZE: usize = 8;
|
||||
const SURFACE_BOUNDS_EPSILON: f32 = 1.0e-4;
|
||||
|
||||
/// Deterministic CPU index over validated terrain triangles.
|
||||
///
|
||||
/// The index changes only candidate selection: triangle math and observable
|
||||
/// tie-breaking remain in [`SurfaceQuery`] above.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct SurfaceBvh {
|
||||
nodes: Vec<SurfaceBvhNode>,
|
||||
triangle_indices: Vec<usize>,
|
||||
root: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SurfaceBvhNode {
|
||||
min: [f32; 3],
|
||||
max: [f32; 3],
|
||||
kind: SurfaceBvhNodeKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum SurfaceBvhNodeKind {
|
||||
Leaf { first: usize, count: usize },
|
||||
Branch { left: usize, right: usize },
|
||||
}
|
||||
|
||||
impl SurfaceBvh {
|
||||
fn from_surfaces(surfaces: &[RuntimeTriangle]) -> Self {
|
||||
if surfaces.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
let mut index = Self {
|
||||
nodes: Vec::with_capacity(surfaces.len().saturating_mul(2)),
|
||||
triangle_indices: Vec::with_capacity(surfaces.len()),
|
||||
root: None,
|
||||
};
|
||||
let indices: Vec<usize> = (0..surfaces.len()).collect();
|
||||
index.root = Some(index.build(surfaces, indices));
|
||||
index
|
||||
}
|
||||
|
||||
fn build(&mut self, surfaces: &[RuntimeTriangle], mut indices: Vec<usize>) -> usize {
|
||||
let (min, max) = surface_bounds(surfaces, &indices);
|
||||
if indices.len() <= SURFACE_BVH_LEAF_SIZE {
|
||||
indices.sort_unstable();
|
||||
let first = self.triangle_indices.len();
|
||||
let count = indices.len();
|
||||
self.triangle_indices.extend(indices);
|
||||
let node = self.nodes.len();
|
||||
self.nodes.push(SurfaceBvhNode {
|
||||
min,
|
||||
max,
|
||||
kind: SurfaceBvhNodeKind::Leaf { first, count },
|
||||
});
|
||||
return node;
|
||||
}
|
||||
|
||||
let axis = longest_axis(min, max);
|
||||
indices.sort_by(|left, right| {
|
||||
surface_centroid(&surfaces[*left])[axis]
|
||||
.total_cmp(&surface_centroid(&surfaces[*right])[axis])
|
||||
.then_with(|| left.cmp(right))
|
||||
});
|
||||
let right_indices = indices.split_off(indices.len() / 2);
|
||||
let left = self.build(surfaces, indices);
|
||||
let right = self.build(surfaces, right_indices);
|
||||
let node = self.nodes.len();
|
||||
self.nodes.push(SurfaceBvhNode {
|
||||
min,
|
||||
max,
|
||||
kind: SurfaceBvhNodeKind::Branch { left, right },
|
||||
});
|
||||
node
|
||||
}
|
||||
|
||||
fn xy_candidates(&self, point: [f32; 2]) -> Vec<usize> {
|
||||
let mut candidates = Vec::new();
|
||||
let Some(root) = self.root else {
|
||||
return candidates;
|
||||
};
|
||||
let mut pending = vec![root];
|
||||
while let Some(node_index) = pending.pop() {
|
||||
let node = &self.nodes[node_index];
|
||||
if !point_within_surface_bounds(point, node.min, node.max) {
|
||||
continue;
|
||||
}
|
||||
match node.kind {
|
||||
SurfaceBvhNodeKind::Leaf { first, count } => {
|
||||
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
|
||||
}
|
||||
SurfaceBvhNodeKind::Branch { left, right } => {
|
||||
pending.push(right);
|
||||
pending.push(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
|
||||
fn ray_candidates(&self, origin: [f32; 3], direction: [f32; 3]) -> Vec<usize> {
|
||||
let mut candidates = Vec::new();
|
||||
let Some(root) = self.root else {
|
||||
return candidates;
|
||||
};
|
||||
let mut pending = vec![root];
|
||||
while let Some(node_index) = pending.pop() {
|
||||
let node = &self.nodes[node_index];
|
||||
if !ray_intersects_surface_bounds(origin, direction, node.min, node.max) {
|
||||
continue;
|
||||
}
|
||||
match node.kind {
|
||||
SurfaceBvhNodeKind::Leaf { first, count } => {
|
||||
candidates.extend_from_slice(&self.triangle_indices[first..first + count]);
|
||||
}
|
||||
SurfaceBvhNodeKind::Branch { left, right } => {
|
||||
pending.push(right);
|
||||
pending.push(left);
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates
|
||||
}
|
||||
}
|
||||
|
||||
fn surface_bounds(surfaces: &[RuntimeTriangle], indices: &[usize]) -> ([f32; 3], [f32; 3]) {
|
||||
let mut min = [f32::INFINITY; 3];
|
||||
let mut max = [f32::NEG_INFINITY; 3];
|
||||
for index in indices {
|
||||
for vertex in surfaces[*index].vertices {
|
||||
for axis in 0..3 {
|
||||
min[axis] = min[axis].min(vertex[axis]);
|
||||
max[axis] = max[axis].max(vertex[axis]);
|
||||
}
|
||||
}
|
||||
}
|
||||
(min, max)
|
||||
}
|
||||
|
||||
fn surface_centroid(triangle: &RuntimeTriangle) -> [f32; 3] {
|
||||
let mut centroid = [0.0; 3];
|
||||
for vertex in triangle.vertices {
|
||||
for axis in 0..3 {
|
||||
centroid[axis] += vertex[axis] / 3.0;
|
||||
}
|
||||
}
|
||||
centroid
|
||||
}
|
||||
|
||||
fn longest_axis(min: [f32; 3], max: [f32; 3]) -> usize {
|
||||
let extent = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
|
||||
if extent[1] > extent[0] && extent[1] >= extent[2] {
|
||||
1
|
||||
} else if extent[2] > extent[0] && extent[2] > extent[1] {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
fn point_within_surface_bounds(point: [f32; 2], min: [f32; 3], max: [f32; 3]) -> bool {
|
||||
point[0] >= min[0] - SURFACE_BOUNDS_EPSILON
|
||||
&& point[0] <= max[0] + SURFACE_BOUNDS_EPSILON
|
||||
&& point[1] >= min[1] - SURFACE_BOUNDS_EPSILON
|
||||
&& point[1] <= max[1] + SURFACE_BOUNDS_EPSILON
|
||||
}
|
||||
|
||||
fn ray_intersects_surface_bounds(
|
||||
origin: [f32; 3],
|
||||
direction: [f32; 3],
|
||||
min: [f32; 3],
|
||||
max: [f32; 3],
|
||||
) -> bool {
|
||||
let mut near = 0.0_f32;
|
||||
let mut far = f32::INFINITY;
|
||||
for axis in 0..3 {
|
||||
if direction[axis].abs() <= f32::EPSILON {
|
||||
if origin[axis] < min[axis] - SURFACE_BOUNDS_EPSILON
|
||||
|| origin[axis] > max[axis] + SURFACE_BOUNDS_EPSILON
|
||||
{
|
||||
return false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let inverse = 1.0 / direction[axis];
|
||||
let first = (min[axis] - origin[axis]) * inverse;
|
||||
let second = (max[axis] - origin[axis]) * inverse;
|
||||
near = near.max(first.min(second));
|
||||
far = far.min(first.max(second));
|
||||
if far < near {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
far >= 0.0
|
||||
}
|
||||
|
||||
impl RuntimeTriangle {
|
||||
fn height_at(&self, position: [f32; 2]) -> Option<f32> {
|
||||
let a = [self.vertices[0][0], self.vertices[0][2]];
|
||||
let b = [self.vertices[1][0], self.vertices[1][2]];
|
||||
let c = [self.vertices[2][0], self.vertices[2][2]];
|
||||
let a = [self.vertices[0][0], self.vertices[0][1]];
|
||||
let b = [self.vertices[1][0], self.vertices[1][1]];
|
||||
let c = [self.vertices[2][0], self.vertices[2][1]];
|
||||
let weights = barycentric_2d(position, a, b, c)?;
|
||||
if weights
|
||||
.iter()
|
||||
.all(|weight| *weight >= -1.0e-4 && *weight <= 1.0001)
|
||||
{
|
||||
Some(
|
||||
weights[0] * self.vertices[0][1]
|
||||
+ weights[1] * self.vertices[1][1]
|
||||
+ weights[2] * self.vertices[2][1],
|
||||
weights[0] * self.vertices[0][2]
|
||||
+ weights[1] * self.vertices[1][2]
|
||||
+ weights[2] * self.vertices[2][2],
|
||||
)
|
||||
} else {
|
||||
None
|
||||
@@ -524,26 +781,29 @@ struct RuntimeGrid {
|
||||
|
||||
impl RuntimeGrid {
|
||||
fn from_land_map(map: &LandMapDocument) -> Result<Self, TerrainError> {
|
||||
let mut min = [f32::INFINITY, f32::INFINITY];
|
||||
let mut max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
|
||||
let mut bounds_min = [f32::INFINITY, f32::INFINITY];
|
||||
let mut bounds_max = [f32::NEG_INFINITY, f32::NEG_INFINITY];
|
||||
for areal in &map.areals {
|
||||
for vertex in &areal.vertices {
|
||||
min[0] = min[0].min(vertex[0]);
|
||||
min[1] = min[1].min(vertex[2]);
|
||||
max[0] = max[0].max(vertex[0]);
|
||||
max[1] = max[1].max(vertex[2]);
|
||||
bounds_min[0] = bounds_min[0].min(vertex[0]);
|
||||
bounds_min[1] = bounds_min[1].min(vertex[1]);
|
||||
bounds_max[0] = bounds_max[0].max(vertex[0]);
|
||||
bounds_max[1] = bounds_max[1].max(vertex[1]);
|
||||
}
|
||||
}
|
||||
if !min[0].is_finite() || !min[1].is_finite() || !max[0].is_finite() || !max[1].is_finite()
|
||||
if !bounds_min[0].is_finite()
|
||||
|| !bounds_min[1].is_finite()
|
||||
|| !bounds_max[0].is_finite()
|
||||
|| !bounds_max[1].is_finite()
|
||||
{
|
||||
min = [0.0, 0.0];
|
||||
max = [1.0, 1.0];
|
||||
bounds_min = [0.0, 0.0];
|
||||
bounds_max = [1.0, 1.0];
|
||||
}
|
||||
if (min[0] - max[0]).abs() <= f32::EPSILON {
|
||||
max[0] += 1.0;
|
||||
if (bounds_min[0] - bounds_max[0]).abs() <= f32::EPSILON {
|
||||
bounds_max[0] += 1.0;
|
||||
}
|
||||
if (min[1] - max[1]).abs() <= f32::EPSILON {
|
||||
max[1] += 1.0;
|
||||
if (bounds_min[1] - bounds_max[1]).abs() <= f32::EPSILON {
|
||||
bounds_max[1] += 1.0;
|
||||
}
|
||||
|
||||
let mut cells = Vec::with_capacity(map.grid.cells.len());
|
||||
@@ -568,8 +828,8 @@ impl RuntimeGrid {
|
||||
Ok(Self {
|
||||
cells_x: map.grid.cells_x,
|
||||
cells_y: map.grid.cells_y,
|
||||
min,
|
||||
max,
|
||||
min: bounds_min,
|
||||
max: bounds_max,
|
||||
cells,
|
||||
})
|
||||
}
|
||||
@@ -578,7 +838,7 @@ impl RuntimeGrid {
|
||||
if self.cells_x == 0 || self.cells_y == 0 || self.cells.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let point = [position[0], position[2]];
|
||||
let point = [position[0], position[1]];
|
||||
if point[0] < self.min[0]
|
||||
|| point[0] > self.max[0]
|
||||
|| point[1] < self.min[1]
|
||||
@@ -726,18 +986,18 @@ mod tests {
|
||||
|
||||
assert_eq!(world.areal_count(), 2);
|
||||
assert_eq!(
|
||||
world.locate_areal([0.25, 0.0, 0.25]).expect("locate"),
|
||||
world.locate_areal([0.25, 0.25, 41.0]).expect("locate"),
|
||||
Some(ArealId(0))
|
||||
);
|
||||
assert_eq!(
|
||||
world.locate_areal([1.75, 0.0, 0.25]).expect("locate"),
|
||||
world.locate_areal([1.75, 0.25, -12.0]).expect("locate"),
|
||||
Some(ArealId(1))
|
||||
);
|
||||
assert_eq!(
|
||||
world
|
||||
.route(RouteRequest {
|
||||
start: [0.25, 0.0, 0.25],
|
||||
goal: [1.75, 0.0, 0.25],
|
||||
start: [0.25, 0.25, 0.0],
|
||||
goal: [1.75, 0.25, 0.0],
|
||||
})
|
||||
.expect("route"),
|
||||
Some(ArealRoute {
|
||||
@@ -753,8 +1013,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
world
|
||||
.route(RouteRequest {
|
||||
start: [10.0, 0.0, 10.0],
|
||||
goal: [1.75, 0.0, 0.25],
|
||||
start: [10.0, 10.0, 0.0],
|
||||
goal: [1.75, 0.25, 0.0],
|
||||
})
|
||||
.expect("route"),
|
||||
None
|
||||
@@ -771,8 +1031,8 @@ mod tests {
|
||||
|
||||
let hit = world
|
||||
.raycast(
|
||||
[0.25, 2.0, 0.25],
|
||||
[0.0, -1.0, 0.0],
|
||||
[0.25, 0.25, 2.0],
|
||||
[0.0, 0.0, -1.0],
|
||||
FullSurfaceMask(0x0000_0001),
|
||||
)
|
||||
.expect("raycast")
|
||||
@@ -784,8 +1044,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
world
|
||||
.raycast(
|
||||
[0.25, 2.0, 0.25],
|
||||
[0.0, -1.0, 0.0],
|
||||
[0.25, 0.25, 2.0],
|
||||
[0.0, 0.0, -1.0],
|
||||
FullSurfaceMask(0x8000_0000)
|
||||
)
|
||||
.expect("raycast"),
|
||||
@@ -793,6 +1053,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn surface_bvh_reduces_candidates_without_changing_surface_math() {
|
||||
let surfaces: Vec<_> = (0_u16..16)
|
||||
.map(|face| {
|
||||
let x = f32::from(face) * 10.0;
|
||||
RuntimeTriangle {
|
||||
face: usize::from(face),
|
||||
mask: FullSurfaceMask(1),
|
||||
vertices: [[x, 0.0, x], [x + 1.0, 0.0, x], [x, 1.0, x]],
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let index = SurfaceBvh::from_surfaces(&surfaces);
|
||||
|
||||
let xy = index.xy_candidates([0.25, 0.25]);
|
||||
assert!(xy.contains(&0));
|
||||
assert!(xy.len() < surfaces.len());
|
||||
assert_eq!(surfaces[xy[0]].height_at([0.25, 0.25]), Some(0.0));
|
||||
|
||||
let ray = index.ray_candidates([0.25, 0.25, 5.0], [0.0, 0.0, -1.0]);
|
||||
assert!(ray.contains(&0));
|
||||
assert!(ray.len() < surfaces.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires licensed corpus"]
|
||||
fn licensed_corpus_land_maps_build_navigation_worlds() {
|
||||
@@ -857,6 +1141,7 @@ mod tests {
|
||||
let root = corpus_root(corpus);
|
||||
let mut files = 0usize;
|
||||
let mut faces = 0usize;
|
||||
let mut indexed_queries = 0usize;
|
||||
for path in files_under(&root) {
|
||||
if !path
|
||||
.file_name()
|
||||
@@ -875,12 +1160,33 @@ mod tests {
|
||||
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
||||
let world = TerrainWorld::from_land_msh(&mesh)
|
||||
.unwrap_or_else(|err| panic!("{corpus} {path:?}: {err}"));
|
||||
if world.surface_count() > SURFACE_BVH_LEAF_SIZE {
|
||||
let triangle = &world.surfaces[0];
|
||||
let point = [
|
||||
(triangle.vertices[0][0]
|
||||
+ triangle.vertices[1][0]
|
||||
+ triangle.vertices[2][0])
|
||||
/ 3.0,
|
||||
(triangle.vertices[0][1]
|
||||
+ triangle.vertices[1][1]
|
||||
+ triangle.vertices[2][1])
|
||||
/ 3.0,
|
||||
];
|
||||
let candidates = world.surface_bvh.xy_candidates(point);
|
||||
assert!(candidates.contains(&0), "{corpus} {path:?} face zero");
|
||||
assert!(
|
||||
candidates.len() < world.surface_count(),
|
||||
"{corpus} {path:?} surface index did not reduce candidates"
|
||||
);
|
||||
indexed_queries += 1;
|
||||
}
|
||||
files += 1;
|
||||
faces += world.surface_count();
|
||||
}
|
||||
|
||||
assert_eq!(files, expected_files, "{corpus} Land.msh count");
|
||||
assert_eq!(faces, expected_faces, "{corpus} surface face count");
|
||||
assert_eq!(indexed_queries, files, "{corpus} indexed Land.msh count");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,9 +1204,9 @@ mod tests {
|
||||
},
|
||||
positions: vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[0.0, 1.0, 1.0],
|
||||
[1.0, 2.0, 1.0],
|
||||
[1.0, 1.0, 2.0],
|
||||
],
|
||||
normals: Vec::new(),
|
||||
uv0: Vec::new(),
|
||||
@@ -943,10 +1249,10 @@ mod tests {
|
||||
areals: vec![
|
||||
Areal {
|
||||
prefix_raw: [0; 56],
|
||||
anchor: [0.5, 0.0, 0.5],
|
||||
anchor: [0.5, 0.5, 0.0],
|
||||
reserved_12: 0.0,
|
||||
area_metric: 1.0,
|
||||
normal: [0.0, 1.0, 0.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
logic_flag: 0,
|
||||
reserved_36: 0,
|
||||
class_id: 0,
|
||||
@@ -954,8 +1260,8 @@ mod tests {
|
||||
vertices: vec![
|
||||
[0.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 0.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[0.0, 0.0, 1.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
],
|
||||
links: vec![
|
||||
EdgeLink {
|
||||
@@ -987,10 +1293,10 @@ mod tests {
|
||||
},
|
||||
Areal {
|
||||
prefix_raw: [0; 56],
|
||||
anchor: [1.5, 0.0, 0.5],
|
||||
anchor: [1.5, 0.5, 0.0],
|
||||
reserved_12: 0.0,
|
||||
area_metric: 1.0,
|
||||
normal: [0.0, 1.0, 0.0],
|
||||
normal: [0.0, 0.0, 1.0],
|
||||
logic_flag: 0,
|
||||
reserved_36: 0,
|
||||
class_id: 0,
|
||||
@@ -998,8 +1304,8 @@ mod tests {
|
||||
vertices: vec![
|
||||
[1.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 0.0],
|
||||
[2.0, 0.0, 1.0],
|
||||
[1.0, 0.0, 1.0],
|
||||
[2.0, 1.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
],
|
||||
links: vec![
|
||||
EdgeLink {
|
||||
|
||||
@@ -6,7 +6,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-render = { path = "../fparkan-render" }
|
||||
fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![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};
|
||||
|
||||
@@ -8,7 +8,7 @@ repository.workspace = true
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
fparkan-nres = { path = "../fparkan-nres" }
|
||||
fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,4 +1,23 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Stage-3 Texm texture contract.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -6,8 +6,8 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-path = { path = "../fparkan-path" }
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+263
-99
@@ -1,13 +1,37 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Virtual filesystem ports for resource loading.
|
||||
|
||||
use fparkan_binary::{sha256, Sha256Digest};
|
||||
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::SystemTime;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// VFS metadata.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
@@ -82,7 +106,6 @@ pub trait Vfs: Send + Sync {
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct DirectoryVfs {
|
||||
root: PathBuf,
|
||||
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
|
||||
}
|
||||
|
||||
impl DirectoryVfs {
|
||||
@@ -91,43 +114,44 @@ impl DirectoryVfs {
|
||||
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
root: root.as_ref().to_path_buf(),
|
||||
fingerprint_cache: Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
|
||||
resolve_casefolded(&self.root, path.as_str())
|
||||
resolve_casefolded(&self.root, path)
|
||||
}
|
||||
|
||||
fn metadata_from_host_file(&self, path: &Path) -> Result<VfsMetadata, VfsError> {
|
||||
fn metadata_from_host_file(path: &Path) -> Result<VfsMetadata, VfsError> {
|
||||
let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?;
|
||||
metadata_from_host_file_with_cache(path, &metadata, &self.fingerprint_cache)
|
||||
metadata_from_host_file(path, &metadata)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct CachedHostFingerprint {
|
||||
len: u64,
|
||||
modified: Option<SystemTime>,
|
||||
fingerprint: Sha256Digest,
|
||||
}
|
||||
|
||||
impl Vfs for DirectoryVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
self.metadata_from_host_file(&self.host_path(path)?)
|
||||
Self::metadata_from_host_file(&self.host_path(path)?)
|
||||
}
|
||||
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||
let host = self.host_path(path)?;
|
||||
if fs::symlink_metadata(&host)
|
||||
.map_err(VfsError::Io)?
|
||||
.file_type()
|
||||
.is_symlink()
|
||||
let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
|
||||
if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() {
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
let pre_identity = file_identity(&pre_metadata);
|
||||
let pre_len = pre_metadata.len();
|
||||
let pre_modified = pre_metadata.modified().ok();
|
||||
let bytes = fs::read(&host).map_err(VfsError::Io)?;
|
||||
let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
|
||||
if post_metadata.file_type().is_symlink()
|
||||
|| !post_metadata.is_file()
|
||||
|| post_metadata.len() != pre_len
|
||||
|| post_metadata.modified().ok() != pre_modified
|
||||
|| file_identity(&post_metadata) != pre_identity
|
||||
{
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
let bytes = fs::read(host).map_err(VfsError::Io)?;
|
||||
Ok(Arc::from(bytes.into_boxed_slice()))
|
||||
}
|
||||
|
||||
@@ -138,21 +162,60 @@ impl Vfs for DirectoryVfs {
|
||||
let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
|
||||
entries.push(VfsEntry {
|
||||
path: prefix.clone(),
|
||||
metadata: metadata_from_host_file_with_cache(
|
||||
&base,
|
||||
&metadata,
|
||||
&self.fingerprint_cache,
|
||||
)?,
|
||||
metadata: metadata_from_host_file(&base, &metadata)?,
|
||||
});
|
||||
return Ok(entries);
|
||||
}
|
||||
list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?;
|
||||
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||
list_recursive(&self.root, &base, &mut entries)?;
|
||||
entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> {
|
||||
fn resolve_casefolded(root: &Path, normalized: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
return resolve_casefolded_unix(root, normalized);
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
resolve_casefolded_text(root, normalized.display_lossy())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn resolve_casefolded_unix(root: &Path, normalized: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||
let mut current = root.to_path_buf();
|
||||
for segment in normalized.as_bytes().split(|byte| *byte == b'/') {
|
||||
current = resolve_casefolded_segment(¤t, segment, normalized)?;
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn resolve_casefolded_segment(
|
||||
dir: &Path,
|
||||
segment: &[u8],
|
||||
normalized: &NormalizedPath,
|
||||
) -> Result<PathBuf, VfsError> {
|
||||
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
|
||||
let mut matches = Vec::new();
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(VfsError::Io)?;
|
||||
let name = entry.file_name();
|
||||
if name.as_bytes().eq_ignore_ascii_case(segment) {
|
||||
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
matches.push(entry.path());
|
||||
}
|
||||
}
|
||||
select_casefolded_match(normalized.display_lossy(), dir, segment, matches)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn resolve_casefolded_text(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> {
|
||||
let mut current = root.to_path_buf();
|
||||
for segment in normalized.split('/') {
|
||||
let read_dir = fs::read_dir(¤t).map_err(VfsError::Io)?;
|
||||
@@ -178,10 +241,11 @@ fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError
|
||||
fn select_casefolded_match(
|
||||
normalized: &str,
|
||||
current: &Path,
|
||||
segment: &str,
|
||||
segment: impl AsRef<[u8]>,
|
||||
mut matches: Vec<PathBuf>,
|
||||
) -> Result<PathBuf, VfsError> {
|
||||
matches.sort();
|
||||
let segment = String::from_utf8_lossy(segment.as_ref());
|
||||
match matches.len() {
|
||||
0 => Err(VfsError::NotFound(normalized.to_string())),
|
||||
1 => Ok(matches.remove(0)),
|
||||
@@ -193,12 +257,7 @@ fn select_casefolded_match(
|
||||
}
|
||||
}
|
||||
|
||||
fn list_recursive(
|
||||
root: &Path,
|
||||
dir: &Path,
|
||||
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||
out: &mut Vec<VfsEntry>,
|
||||
) -> Result<(), VfsError> {
|
||||
fn list_recursive(root: &Path, dir: &Path, out: &mut Vec<VfsEntry>) -> Result<(), VfsError> {
|
||||
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
|
||||
let mut children = Vec::new();
|
||||
for entry in read_dir {
|
||||
@@ -212,78 +271,50 @@ fn list_recursive(
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
list_recursive(root, &child, fingerprint_cache, out)?;
|
||||
list_recursive(root, &child, out)?;
|
||||
continue;
|
||||
}
|
||||
if !metadata.is_file() {
|
||||
continue;
|
||||
}
|
||||
let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?;
|
||||
let rel_text = rel.to_str().ok_or(VfsError::Path)?;
|
||||
let path = fparkan_path::normalize_relative(
|
||||
rel_text.as_bytes(),
|
||||
fparkan_path::PathPolicy::HostCompatible,
|
||||
)
|
||||
.map_err(|_| VfsError::Path)?;
|
||||
#[cfg(unix)]
|
||||
let rel_bytes = rel.as_os_str().as_bytes();
|
||||
#[cfg(not(unix))]
|
||||
let rel_bytes = rel.to_str().ok_or(VfsError::Path)?.as_bytes();
|
||||
let path =
|
||||
fparkan_path::normalize_relative(rel_bytes, fparkan_path::PathPolicy::HostCompatible)
|
||||
.map_err(|_| VfsError::Path)?;
|
||||
out.push(VfsEntry {
|
||||
path,
|
||||
metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?,
|
||||
metadata: metadata_from_host_file(&child, &metadata)?,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn metadata_from_host_file_with_cache(
|
||||
path: &Path,
|
||||
metadata: &fs::Metadata,
|
||||
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||
) -> Result<VfsMetadata, VfsError> {
|
||||
fn metadata_from_host_file(path: &Path, metadata: &fs::Metadata) -> Result<VfsMetadata, VfsError> {
|
||||
if !metadata.is_file() {
|
||||
return Err(VfsError::Path);
|
||||
}
|
||||
let len = metadata.len();
|
||||
let modified = metadata.modified().ok();
|
||||
if let Some(cached) = fingerprint_cache
|
||||
.lock()
|
||||
.map_err(|_| VfsError::Path)?
|
||||
.get(path)
|
||||
.cloned()
|
||||
.filter(|cached| cached.len == len && cached.modified == modified)
|
||||
{
|
||||
return Ok(VfsMetadata {
|
||||
len,
|
||||
fingerprint: cached.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = fs::read(path).map_err(VfsError::Io)?;
|
||||
let fingerprint = sha256(&bytes);
|
||||
fingerprint_cache
|
||||
.lock()
|
||||
.map_err(|_| VfsError::Path)?
|
||||
.insert(
|
||||
path.to_path_buf(),
|
||||
CachedHostFingerprint {
|
||||
len,
|
||||
modified,
|
||||
fingerprint,
|
||||
},
|
||||
);
|
||||
Ok(VfsMetadata { len, fingerprint })
|
||||
}
|
||||
|
||||
/// In-memory VFS.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MemoryVfs {
|
||||
files: BTreeMap<String, Arc<[u8]>>,
|
||||
lookup: BTreeMap<Vec<u8>, Vec<String>>,
|
||||
files: BTreeMap<Vec<u8>, Arc<[u8]>>,
|
||||
lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl MemoryVfs {
|
||||
/// Inserts a file.
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
|
||||
let path = path.as_str().to_string();
|
||||
let path = path.as_bytes().to_vec();
|
||||
self.files.insert(path, bytes);
|
||||
self.rebuild_lookup();
|
||||
}
|
||||
@@ -292,7 +323,7 @@ impl MemoryVfs {
|
||||
self.lookup.clear();
|
||||
for path in self.files.keys() {
|
||||
self.lookup
|
||||
.entry(ascii_lookup_key(path.as_bytes()).0)
|
||||
.entry(ascii_lookup_key(path).0)
|
||||
.or_default()
|
||||
.push(path.clone());
|
||||
}
|
||||
@@ -301,27 +332,52 @@ impl MemoryVfs {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_path(&self, path: &NormalizedPath) -> Result<&str, VfsError> {
|
||||
let key = ascii_lookup_key(path.as_str().as_bytes()).0;
|
||||
fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> {
|
||||
let key = ascii_lookup_key(path.as_bytes()).0;
|
||||
let matches = self
|
||||
.lookup
|
||||
.get(&key)
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))?;
|
||||
match matches.as_slice() {
|
||||
[single] => Ok(single.as_str()),
|
||||
[] => Err(VfsError::NotFound(path.as_str().to_string())),
|
||||
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
|
||||
[single] => Ok(single.as_slice()),
|
||||
[] => Err(VfsError::NotFound(path.display_lossy().to_string())),
|
||||
_ => Err(VfsError::Ambiguous(path.display_lossy().to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||
Some(metadata.dev().rotate_left(32) ^ metadata.ino())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||
// Stable Rust does not expose the Windows file index. Keep a best-effort
|
||||
// replacement detector from metadata that is available through the
|
||||
// supported standard-library API; `read` also compares size and
|
||||
// last-write time before returning bytes.
|
||||
Some(
|
||||
metadata.creation_time().rotate_left(17)
|
||||
^ metadata.last_write_time().rotate_left(31)
|
||||
^ u64::from(metadata.file_attributes()).rotate_left(47),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn file_identity(_metadata: &fs::Metadata) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
impl Vfs for MemoryVfs {
|
||||
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||
let resolved = self.resolve_path(path)?;
|
||||
let bytes = self
|
||||
.files
|
||||
.get(resolved)
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))?;
|
||||
Ok(VfsMetadata {
|
||||
len: bytes.len() as u64,
|
||||
fingerprint: sha256(bytes),
|
||||
@@ -333,22 +389,16 @@ impl Vfs for MemoryVfs {
|
||||
self.files
|
||||
.get(resolved)
|
||||
.cloned()
|
||||
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
|
||||
.ok_or_else(|| VfsError::NotFound(path.display_lossy().to_string()))
|
||||
}
|
||||
|
||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||
let mut out = Vec::new();
|
||||
for (path, bytes) in &self.files {
|
||||
if path
|
||||
.as_bytes()
|
||||
.get(..prefix.as_str().len())
|
||||
.is_some_and(|head| head.eq_ignore_ascii_case(prefix.as_str().as_bytes()))
|
||||
{
|
||||
let normalized = fparkan_path::normalize_relative(
|
||||
path.as_bytes(),
|
||||
fparkan_path::PathPolicy::StrictLegacy,
|
||||
)
|
||||
.map_err(|_| VfsError::Path)?;
|
||||
if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
|
||||
let normalized =
|
||||
fparkan_path::normalize_relative(path, fparkan_path::PathPolicy::StrictLegacy)
|
||||
.map_err(|_| VfsError::Path)?;
|
||||
out.push(VfsEntry {
|
||||
path: normalized,
|
||||
metadata: VfsMetadata {
|
||||
@@ -362,6 +412,25 @@ impl Vfs for MemoryVfs {
|
||||
}
|
||||
}
|
||||
|
||||
fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
if haystack.len() < needle.len() {
|
||||
return false;
|
||||
}
|
||||
if haystack.len() == needle.len() {
|
||||
return haystack
|
||||
.iter()
|
||||
.zip(needle.iter())
|
||||
.all(|(left, right)| left.eq_ignore_ascii_case(right));
|
||||
}
|
||||
if haystack[needle.len()] != b'/' {
|
||||
return false;
|
||||
}
|
||||
haystack[..needle.len()]
|
||||
.iter()
|
||||
.zip(needle.iter())
|
||||
.all(|(left, right)| left.eq_ignore_ascii_case(right))
|
||||
}
|
||||
|
||||
/// Layered VFS with deterministic first-layer precedence.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct OverlayVfs {
|
||||
@@ -404,7 +473,7 @@ impl Vfs for OverlayVfs {
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(VfsError::NotFound(path.as_str().to_string()))
|
||||
Err(VfsError::NotFound(path.display_lossy().to_string()))
|
||||
}
|
||||
|
||||
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||
@@ -415,7 +484,7 @@ impl Vfs for OverlayVfs {
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
Err(VfsError::NotFound(path.as_str().to_string()))
|
||||
Err(VfsError::NotFound(path.display_lossy().to_string()))
|
||||
}
|
||||
|
||||
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||
@@ -424,7 +493,7 @@ impl Vfs for OverlayVfs {
|
||||
match layer.list(prefix) {
|
||||
Ok(entries) => {
|
||||
for entry in entries {
|
||||
let key = entry.path.as_str().to_ascii_uppercase();
|
||||
let key = ascii_lookup_key(entry.path.as_bytes()).0;
|
||||
by_key.entry(key).or_insert(entry);
|
||||
}
|
||||
}
|
||||
@@ -433,7 +502,7 @@ impl Vfs for OverlayVfs {
|
||||
}
|
||||
}
|
||||
let mut entries: Vec<_> = by_key.into_values().collect();
|
||||
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||
entries.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
@@ -442,6 +511,10 @@ impl Vfs for OverlayVfs {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use fparkan_path::{normalize_relative, PathPolicy};
|
||||
#[cfg(unix)]
|
||||
use std::ffi::OsString;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
#[test]
|
||||
fn directory_vfs_resolves_ascii_casefolded_segments() {
|
||||
@@ -507,6 +580,22 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_vfs_list_prefix_is_boundary_safe() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||
let sibling =
|
||||
normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||
vfs.insert(exact.clone(), Arc::from(b"exact".as_slice()));
|
||||
vfs.insert(sibling, Arc::from(b"sibling".as_slice()));
|
||||
|
||||
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||
let entries = vfs.list(&prefix).expect("list");
|
||||
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].path.as_str(), exact.as_str());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_vfs_fingerprint_changes_for_same_length_content() {
|
||||
let root = unique_test_dir("content-fingerprint");
|
||||
@@ -546,6 +635,33 @@ mod tests {
|
||||
std::fs::remove_dir_all(outside).expect("cleanup outside");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn directory_vfs_resolves_non_utf8_host_entries_by_raw_bytes() {
|
||||
let root = unique_test_dir("non-utf8");
|
||||
let data_dir = root.join("DATA");
|
||||
std::fs::create_dir_all(&data_dir).expect("mkdir");
|
||||
let file_name = OsString::from_vec(vec![0xFF, b'.', b'b', b'i', b'n']);
|
||||
let raw_path = data_dir.join(&file_name);
|
||||
if let Err(err) = std::fs::write(&raw_path, b"raw") {
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
let vfs = DirectoryVfs::new(&root);
|
||||
let path = normalize_relative(b"data/\xFF.bin", PathPolicy::HostCompatible).expect("path");
|
||||
|
||||
assert_eq!(vfs.read(&path).expect("read raw path").as_ref(), b"raw");
|
||||
let entries = vfs
|
||||
.list(&normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix"))
|
||||
.expect("list");
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert_eq!(entries[0].path.identity_bytes(), b"DATA/\xFF.bin");
|
||||
|
||||
std::fs::remove_dir_all(root).expect("cleanup");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn casefold_selector_reports_ambiguous_segments() {
|
||||
let err = select_casefolded_match(
|
||||
@@ -589,6 +705,26 @@ mod tests {
|
||||
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_vfs_distinguishes_non_utf8_path_bytes() {
|
||||
let mut vfs = MemoryVfs::default();
|
||||
let ascii =
|
||||
normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible).expect("ascii path");
|
||||
let binary =
|
||||
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary path");
|
||||
vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice()));
|
||||
vfs.insert(binary.clone(), Arc::from(b"binary".as_slice()));
|
||||
|
||||
let binary_query =
|
||||
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary query");
|
||||
|
||||
assert_eq!(
|
||||
vfs.read(&binary_query).expect("read binary").as_ref(),
|
||||
b"binary"
|
||||
);
|
||||
assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_vfs_uses_first_matching_layer() {
|
||||
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||
@@ -606,6 +742,34 @@ mod tests {
|
||||
assert_eq!(entries[0].metadata.len, 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlay_vfs_keeps_lossy_equivalent_entries_distinct() {
|
||||
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||
let mut high = MemoryVfs::default();
|
||||
let mut low = MemoryVfs::default();
|
||||
high.insert(
|
||||
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("high path"),
|
||||
Arc::from(b"high".as_slice()),
|
||||
);
|
||||
low.insert(
|
||||
normalize_relative(b"DATA/\xFE.bin", PathPolicy::HostCompatible).expect("low path"),
|
||||
Arc::from(b"low".as_slice()),
|
||||
);
|
||||
|
||||
let overlay = OverlayVfs::from_layers(vec![Arc::new(high), Arc::new(low)]);
|
||||
let entries = overlay.list(&prefix).expect("list");
|
||||
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(
|
||||
entries[0].path.display_lossy(),
|
||||
entries[1].path.display_lossy()
|
||||
);
|
||||
assert_ne!(
|
||||
entries[0].path.identity_bytes(),
|
||||
entries[1].path.identity_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
fn unique_test_dir(name: &str) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("fparkan-vfs-{name}-{}", std::process::id()));
|
||||
|
||||
@@ -6,7 +6,7 @@ license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
fparkan-binary = { path = "../fparkan-binary" }
|
||||
fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,10 +1,29 @@
|
||||
#![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
|
||||
)
|
||||
)]
|
||||
//! Deterministic world identity, queue, lifecycle, and snapshots.
|
||||
|
||||
use fparkan_binary::sha256;
|
||||
use std::collections::VecDeque;
|
||||
|
||||
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0";
|
||||
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v3\0";
|
||||
|
||||
/// Object handle with generation.
|
||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||
@@ -31,6 +50,20 @@ pub struct Tick(pub u64);
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct StateHash(pub [u8; 32]);
|
||||
|
||||
/// Exact source transform carried by one world object.
|
||||
///
|
||||
/// Values are IEEE-754 bit patterns so mission transforms retain their input
|
||||
/// identity in deterministic state before a controller interprets them.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct TransformState {
|
||||
/// Position words in source axis order.
|
||||
pub position: [u32; 3],
|
||||
/// Orientation words in source axis order.
|
||||
pub orientation: [u32; 3],
|
||||
/// Non-uniform scale words in source axis order.
|
||||
pub scale: [u32; 3],
|
||||
}
|
||||
|
||||
/// World phase.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum WorldPhase {
|
||||
@@ -91,6 +124,8 @@ pub struct WorldSnapshot {
|
||||
pub tick: Tick,
|
||||
/// Live object handles.
|
||||
pub objects: Vec<ObjectHandle>,
|
||||
/// Exact transforms for live registered objects in object-handle order.
|
||||
pub transforms: Vec<(ObjectHandle, TransformState)>,
|
||||
/// Commands processed during this step.
|
||||
pub events: Vec<WorldEvent>,
|
||||
/// State hash.
|
||||
@@ -148,6 +183,7 @@ struct Slot {
|
||||
owner_id: Option<OwnerId>,
|
||||
mirror_id: Option<OriginalObjectId>,
|
||||
registration_sequence: Option<u64>,
|
||||
transform: TransformState,
|
||||
}
|
||||
|
||||
/// World.
|
||||
@@ -229,6 +265,7 @@ pub fn construct_object(world: &mut World, draft: ObjectDraft) -> Result<ObjectH
|
||||
owner_id: None,
|
||||
mirror_id: None,
|
||||
registration_sequence: None,
|
||||
transform: TransformState::default(),
|
||||
});
|
||||
Ok(handle)
|
||||
}
|
||||
@@ -316,6 +353,43 @@ pub fn identity_metadata(
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets the exact source transform for a live object.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
|
||||
pub fn set_transform(
|
||||
world: &mut World,
|
||||
handle: ObjectHandle,
|
||||
transform: TransformState,
|
||||
) -> Result<(), WorldError> {
|
||||
checked_slot_mut(world, handle)?.transform = transform;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the exact source transform for a live object.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`WorldError`] if the handle is stale, deleted, or out of range.
|
||||
pub fn transform_state(world: &World, handle: ObjectHandle) -> Result<TransformState, WorldError> {
|
||||
Ok(checked_slot(world, handle)?.transform)
|
||||
}
|
||||
|
||||
/// Returns the live registered handle carrying an original mission id.
|
||||
#[must_use]
|
||||
pub fn handle_by_original_id(world: &World, original_id: OriginalObjectId) -> Option<ObjectHandle> {
|
||||
world.slots.iter().enumerate().find_map(|(index, slot)| {
|
||||
let index = u32::try_from(index).ok()?;
|
||||
(slot.live && slot.registered && slot.original_id == Some(original_id)).then_some(
|
||||
ObjectHandle {
|
||||
generation: slot.generation,
|
||||
slot: index,
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Requests deletion.
|
||||
///
|
||||
/// # Errors
|
||||
@@ -404,6 +478,7 @@ where
|
||||
let snapshot = WorldSnapshot {
|
||||
tick: world.tick,
|
||||
objects: live_registered(world),
|
||||
transforms: live_registered_with_transforms(world),
|
||||
events,
|
||||
hash: canonical_state_hash(world),
|
||||
};
|
||||
@@ -440,6 +515,7 @@ fn canonical_state_bytes(world: &World) -> Vec<u8> {
|
||||
push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0));
|
||||
push_optional_u16(&mut out, slot.owner_id.map(|id| id.0));
|
||||
push_optional_u64(&mut out, slot.registration_sequence);
|
||||
push_transform(&mut out, slot.transform);
|
||||
}
|
||||
push_len(&mut out, world.queue.len());
|
||||
for command in &world.queue {
|
||||
@@ -620,6 +696,17 @@ fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) {
|
||||
push_u32(out, handle.slot);
|
||||
}
|
||||
|
||||
fn push_transform(out: &mut Vec<u8>, transform: TransformState) {
|
||||
for word in transform
|
||||
.position
|
||||
.into_iter()
|
||||
.chain(transform.orientation)
|
||||
.chain(transform.scale)
|
||||
{
|
||||
push_u32(out, word);
|
||||
}
|
||||
}
|
||||
|
||||
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
|
||||
let slot = world
|
||||
.slots
|
||||
@@ -670,6 +757,24 @@ fn live_registered(world: &World) -> Vec<ObjectHandle> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn live_registered_with_transforms(world: &World) -> Vec<(ObjectHandle, TransformState)> {
|
||||
world
|
||||
.slots
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, slot)| {
|
||||
let slot_index = u32::try_from(idx).ok()?;
|
||||
(slot.live && slot.registered).then_some((
|
||||
ObjectHandle {
|
||||
generation: slot.generation,
|
||||
slot: slot_index,
|
||||
},
|
||||
slot.transform,
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -685,6 +790,25 @@ mod tests {
|
||||
assert_eq!(after.objects, vec![handle]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_is_exactly_snapshotted_and_changes_state_hash() {
|
||||
let mut world = new(WorldConfig);
|
||||
let handle =
|
||||
construct_object(&mut world, ObjectDraft { original_id: None }).expect("object");
|
||||
register_object(&mut world, handle).expect("register");
|
||||
let before = step(&mut world, &InputSnapshot).expect("before");
|
||||
let transform = TransformState {
|
||||
position: [1.0_f32.to_bits(), (-2.0_f32).to_bits(), 3.0_f32.to_bits()],
|
||||
orientation: [0.0_f32.to_bits(), 0.5_f32.to_bits(), (-0.25_f32).to_bits()],
|
||||
scale: [1.0_f32.to_bits(), 2.0_f32.to_bits(), 0.5_f32.to_bits()],
|
||||
};
|
||||
set_transform(&mut world, handle, transform).expect("set transform");
|
||||
let after = step(&mut world, &InputSnapshot).expect("after");
|
||||
assert_eq!(transform_state(&world, handle), Ok(transform));
|
||||
assert_eq!(after.transforms, vec![(handle, transform)]);
|
||||
assert_ne!(before.hash, after.hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_sequence_stale_and_duplicate_original_contracts() {
|
||||
let mut world = new(WorldConfig);
|
||||
@@ -704,10 +828,16 @@ mod tests {
|
||||
.expect("second");
|
||||
register_object(&mut world, first).expect("register first");
|
||||
register_object(&mut world, second).expect("register second");
|
||||
assert_eq!(
|
||||
handle_by_original_id(&world, OriginalObjectId(7)),
|
||||
Some(first)
|
||||
);
|
||||
assert_eq!(handle_by_original_id(&world, OriginalObjectId(9)), None);
|
||||
assert_eq!(registration_sequence(&world, first), Ok(Some(0)));
|
||||
assert_eq!(registration_sequence(&world, second), Ok(Some(1)));
|
||||
|
||||
request_delete(&mut world, first).expect("delete");
|
||||
assert_eq!(handle_by_original_id(&world, OriginalObjectId(7)), None);
|
||||
assert_eq!(
|
||||
register_object(&mut world, first),
|
||||
Err(WorldError::StaleHandle)
|
||||
@@ -1034,7 +1164,7 @@ mod tests {
|
||||
handles.push(handle);
|
||||
}
|
||||
for (index, handle) in handles.iter().copied().enumerate() {
|
||||
if (seed as usize + index) % 3 == 0 {
|
||||
if (seed as usize + index).is_multiple_of(3) {
|
||||
request_delete(&mut world, handle).expect("delete");
|
||||
} else {
|
||||
enqueue(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
[graph]
|
||||
all-features = true
|
||||
|
||||
[advisories]
|
||||
yanked = "deny"
|
||||
|
||||
[bans]
|
||||
multiple-versions = "allow"
|
||||
wildcards = "deny"
|
||||
deny = [
|
||||
{ name = "native-tls" },
|
||||
{ name = "openssl" },
|
||||
{ name = "openssl-sys" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
allow = [
|
||||
"Apache-2.0",
|
||||
"BSD-3-Clause",
|
||||
"GPL-2.0-only",
|
||||
"ISC",
|
||||
"MIT",
|
||||
"Unicode-3.0",
|
||||
"Zlib",
|
||||
]
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
@@ -113,43 +113,22 @@ key, configuration, device profile, initial state, input/time script и верс
|
||||
|
||||
## Local evidence requests
|
||||
|
||||
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
|
||||
Для macOS Desktop GL есть только безопасный command/state trace и исторический
|
||||
одноразовый offscreen pixel probe:
|
||||
Поддерживаемая desktop-платформа — только Windows/Vulkan. Локальный smoke
|
||||
`fparkan-vulkan-smoke` уже подтверждает настоящий Win32 surface/swapchain,
|
||||
300 кадров, controlled resize и отсутствие validation warnings/errors.
|
||||
Это закрывает фундамент Stage 0, но не доказывает визуальный паритет игры.
|
||||
|
||||
- `cargo test -p fparkan-render-gl --offline desktop_gl33_triangle_command_capture`;
|
||||
- `fixtures/acceptance/macos-gl33-triangle-capture.json`.
|
||||
Следующие доказательства всё ещё нужны именно для Iron3D-compatible рендера:
|
||||
|
||||
`S3-GL-001` не считается закрытым: временный `rustc` probe создал CGL/OpenGL
|
||||
offscreen FBO, выполнил shader-based triangle draw, прочитал RGBA pixels и
|
||||
сохранил hash capture, но постоянный workspace adapter по-прежнему не создаёт
|
||||
SDL window, GL context, GPU resources, shader programs, draw calls или present.
|
||||
Probe не добавляет project-owned `unsafe` в workspace и остаётся только external
|
||||
evidence request artifact.
|
||||
- capability-gated capture из оригинального GOG процесса с camera/matrix,
|
||||
draw/state и frame-boundary provenance;
|
||||
- фиксированные Windows Vulkan captures статической модели, lightmapped модели
|
||||
и terrain после совпадения backend-neutral command capture;
|
||||
- затем controlled captures анимации, FX, прозрачности, теней и атмосферы.
|
||||
|
||||
Для повышения `S3-GL-001` до `covered` нужен постоянный macOS backend через
|
||||
выбранную safe facade stack: SDL event/window/context lifecycle, Desktop GL 3.3
|
||||
shader/buffer/texture/draw/present path, hidden-window/offscreen smoke test и
|
||||
licensed local model/terrain frame capture.
|
||||
|
||||
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
|
||||
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
|
||||
command/state trace. Локальный Docker probe существующего Rust image не нашёл
|
||||
`libGL`, `libEGL`, `libGLES` или `libOSMesa`, поэтому закрытие этого gate требует
|
||||
отдельно предоставленного Docker image с Rust + Mesa/EGL/OSMesa либо разрешения
|
||||
на установку соответствующего проверочного окружения.
|
||||
|
||||
Для текущей macOS-focused цели `S3-GL-002`, `L3-DEVICE-001` и `L5-RG40-001`
|
||||
помечены как `omitted`: они остаются требованиями portable target scope, но не
|
||||
блокируют локальный macOS acceptance-аудит. При возврате RG40XX/GLES2 в область
|
||||
цели эти gates снова должны требовать внешнего evidence.
|
||||
|
||||
`L3-DEVICE-001` и `L5-RG40-001` не закрываются локально без RG40XX H или
|
||||
эквивалентного удалённого runner-а. Требуемое доказательство: запуск выбранной
|
||||
миссии при 640x480 на целевом профиле, сохранённые stdout/stderr, build
|
||||
fingerprint, manifest игрового каталога, frame/tick budget, memory budget и
|
||||
итоговый pass/fail report. Desktop/headless результаты не считаются заменой
|
||||
on-device smoke.
|
||||
Linux/macOS, GLES2, RG40XX и удалённые portability runners не являются
|
||||
acceptance-гейтами этого проекта. Они не должны появляться в списке блокеров
|
||||
или подменять Windows evidence.
|
||||
|
||||
## Closure criteria
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Кампания, сохранения и восстановление сессии
|
||||
|
||||
## Известная файловая поверхность
|
||||
|
||||
Demo содержит `MISSIONS/dispatcher.ini` и `SAVE/saveslots.cfg`.
|
||||
`dispatcher.ini` хранит campaign progression (в demo — `[COMPLETE]`), а
|
||||
`saveslots.cfg` — ordered UI-метаданные slots, не полный snapshot мира.
|
||||
В Части 2 slots 1 и 7 помечены занятыми без соответствующего payload; поэтому
|
||||
проверяются независимо: наличие metadata record, физического файла и
|
||||
format/version/integrity payload.
|
||||
|
||||
Campaign различает существование миссии, её доступность, старт, успешное или
|
||||
неуспешное завершение и уже применённый результат. Обработка одного
|
||||
mission-complete event идемпотентна. Пустой slot и повреждённый существующий
|
||||
payload — разные состояния.
|
||||
|
||||
## Контракт standalone save
|
||||
|
||||
Полный snapshot сохраняет campaign/mission context, time/pause/step phase,
|
||||
stable object IDs, ownership, transforms, lifecycle/properties/cross-links,
|
||||
world changes, Behavior/Control/AI/research/economy state, script variables/IP/
|
||||
timers, authoritative RNG, gameplay-relevant FX и queued messages. Camera/UI
|
||||
можно сохранить как presentation context; GPU/audio handles и draw buffers
|
||||
восстанавливаются, а не сериализуются.
|
||||
|
||||
Снимок разрешён только после calculation и deferred operations, вне queue
|
||||
traversal, после применённых network messages и до чтения mutable state
|
||||
renderer-ом. Native pointers, vtable/allocator addresses и resource mapping
|
||||
pointers запрещены: ссылки идут через stable IDs и resource keys.
|
||||
|
||||
Новый формат — versioned chunks (`WORLD`, `OBJECTS`, `BEHAVIOR`, `PHYSICS`,
|
||||
`AI`, `SCRIPT`, `RESEARCH`, `RNG`, `CAMERA_UI`, optional network) с magic,
|
||||
format/profile/content fingerprint, size и checksum. Неизвестный optional
|
||||
chunk пропускается; required chunk блокирует load. Это дизайн FParkan, не
|
||||
утверждение о binary format оригинала.
|
||||
|
||||
Запись транзакционна: временный файл → повторное чтение/checksum → fs sync →
|
||||
атомарная замена payload → обновление slot metadata. Загрузка создаёт mission
|
||||
и objects без публикации, восстанавливает IDs/cross-links/controllers/RNG,
|
||||
регистрирует их, валидирует и только затем разрешает следующий tick.
|
||||
|
||||
## Проверки и граница
|
||||
|
||||
`save -> load` обязан давать тот же canonical state hash, OriginalObjectId,
|
||||
cross-links и продолжение RNG. Corrupt required chunk не публикует частичный
|
||||
мир; crash при записи не уничтожает старый slot; completion event идемпотентен.
|
||||
|
||||
Для native format нужны controlled original saves, binary diffs и trace
|
||||
serializer-а. До этого FParkan может иметь совместимую семантику собственных
|
||||
saves, но не заявляет byte/network interoperability с оригинальными файлами.
|
||||
@@ -0,0 +1,335 @@
|
||||
# Сценарная VM, формулы и игровые свойства
|
||||
|
||||
## Подтверждённый surface
|
||||
|
||||
Миссионный сценарный слой задаёт стартовые события, completion/failure,
|
||||
messages, teleports, задачи, research и campaign transitions. Точки входа и
|
||||
файлы: `ai.dll: CreateSuperAI/GetSuperAI`, `MisLoad.dll: LoadResearch`,
|
||||
`ArealMap.dll: CalcFullResearchCost`, `MISSIONS/SCRIPTS/*.scr`, `*.fml`,
|
||||
`*.trf`, `varset.var`, `MISSIONS/dispatcher.ini`, `mission.cfg`, `messages.cfg`
|
||||
и `briefing.cfg`.
|
||||
|
||||
`.scr` — binary package с version checks, symbol/event sections и offsets;
|
||||
полная opcode grammar не доказана. Его внешний framing теперь читает
|
||||
`fparkan-script`: первый little-endian `u32` является числом required opcode
|
||||
handlers, второй — числом event records. Каждый event хранит `name_len`,
|
||||
`name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных
|
||||
records. Вложенный record сохраняет семь `u32` header words (в disk order),
|
||||
список `u32` references после шестого header word и trailing seventh word.
|
||||
Никакой из этих words ещё не получает semantic name. `.fml` — текстовый
|
||||
symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaults.
|
||||
`fparkan-script::parse_varset` уже читает подтверждённые numeric
|
||||
`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются
|
||||
opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и
|
||||
`FUNCTION(...)` пока сохранены за границей этого numeric contract;
|
||||
GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration:
|
||||
31 `float` и 200 `DWORD` (от `f0` до `fY`);
|
||||
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
|
||||
`not found` откатывается к этому shared file. Runtime повторяет данный порядок
|
||||
транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но
|
||||
ещё не исполняет declarations как VM state;
|
||||
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
|
||||
consumer-inferred.
|
||||
|
||||
## Безопасная модель исполнения
|
||||
|
||||
Новая VM разделяет immutable package (bytecode, symbols, events, constants),
|
||||
per-mission variables/timers/frames, bindings logical-name/ObjectId/clan/
|
||||
research key и typed commands к World3D/Behavior/UI/campaign. После varset
|
||||
defaults и bindings она dispatches Init/start, на каждом tick обновляет timers,
|
||||
ставит готовые events в стабильную очередь и исполняет bounded instruction
|
||||
budget. Опасное удаление идёт через World3D queue и общий deferred lifecycle.
|
||||
|
||||
До восстановления opcode table package mode читает header/strings/symbols/
|
||||
event offsets/raw bytecode losslessly. Статический анализ уже выделил отдельный
|
||||
five-way evaluator condition records (`ai.dll` VA `0x10005180`): tags `1..5`,
|
||||
type guards, object lookup и completion flag. Это не следует выдавать за
|
||||
instruction dispatcher или jump table `.scr`: bytecode opcode table всё ещё
|
||||
требует отдельного доказательства. Unknown opcode нельзя пропустить как один
|
||||
byte: это ломает синхронизацию. Для каждого доказанного opcode фиксируются
|
||||
number, size, operands, control flow, effects, errors и минимальный test.
|
||||
|
||||
GOG `ai.dll` доказывает этот framing двумя consumer-ами: loader по
|
||||
`0x10001000` открывает `<bundle>.scr`, `varset.var`, `<bundle>.fml`, затем
|
||||
собирает ровно 73 pointers handlers; `0x10011b20` читает описанную count-driven
|
||||
структуру. Команда
|
||||
|
||||
```powershell
|
||||
cargo run -p fparkan-cli -- script inspect `
|
||||
'C:\GOG Games\Parkan - Iron Strategy\MISSIONS\SCRIPTS\c1m2p.scr' --format json
|
||||
```
|
||||
|
||||
на исходном пакете возвращает `opcode_handler_count=73`, 9 events, 17 nested
|
||||
records, 20 references и 0 trailing bytes. Это corpus evidence для reader-а,
|
||||
но не разрешение на исполнение неизвестных 73 opcodes.
|
||||
|
||||
Теперь установлен selector: loader `0x10001000` создаёт 73 pointers в
|
||||
фиксированном порядке, а `0x10011e70` копирует их без перестановки в runtime
|
||||
array. Во всех 58 GOG `.scr` первый header word каждого nested record равен
|
||||
`0..72` либо `0xffff_ffff`: соответственно 2095 handler selectors и 3992
|
||||
sentinel records. Поэтому `ScriptInstruction::dispatch_selector()` возвращает
|
||||
`Handler(0..72)`, `Sentinel` или сохраняемый `Unknown(u32)`. Первый handler
|
||||
(`Handler(0)`, VA `0x10008034`) только устанавливает current context и flag
|
||||
`+0x50 = 1`; это не даёт ему игрового имени и не заменяет runtime trace.
|
||||
|
||||
`Handler(1)` — второй table entry, VA `0x10007fd0`, — не создаёт игровую
|
||||
команду. Он сохраняет active VM context, берёт один instruction-derived index
|
||||
через current event/instruction offsets `+0x48/+0x4c`, а затем разрешает его
|
||||
в varset object по `this + 0x18`. Resolver `0x10002d30` проверяет
|
||||
`0 <= index < count` и возвращает record `base + index * 0x30`; invalid index
|
||||
вызывает C++ exception, а не становится нулём. Полученный 48-byte record
|
||||
передаётся в `0x10013190`, который возвращает x87 floating result: kinds `0`
|
||||
и `4` идут через отдельный opaque conversion path, kind `1` — signed integer,
|
||||
kind `2` выбирает одну из двух static scalar constants по нулевости payload,
|
||||
kind `3` — float, kind `5` — unsigned integer; остальные и пустые cases дают
|
||||
один fixed fallback scalar. Это доказанный numeric
|
||||
bridge для VM, но пока не Rust handler: неизвестны точный disk operand slot,
|
||||
ownership значения на FPU stack и следующий consumer, поэтому нельзя назвать
|
||||
его арифметическим opcode или silently заменить portable `f32` execution.
|
||||
Отдельный проход по всем 58 GOG `.scr` (6 087 instruction records) не нашёл
|
||||
ни одного selector `1`: из них 2 095 записей выбирают один из handlers, а
|
||||
3 992 являются sentinel. Значит, это установленная, но не corpus-reachable
|
||||
ветка данного издания; её нельзя делать приоритетным execution path без
|
||||
отдельного dynamic/evidence route.
|
||||
|
||||
`Handler(2)` (третья entry table, VA `0x10009610`) уже имеет статический
|
||||
contract, но ещё не Rust execution: он выбирает active event/instruction через
|
||||
runtime offsets `+0x48/+0x4c` и разрешает семь 32-bit slots через varset object
|
||||
`+0x18`. Их доказанный dataflow: slot 0 даёт один `u32` и base string, slot 1
|
||||
даёт numeric scalar, slots 2 и 3 — по `u32`, slots 4, 5 и 6 принимают только
|
||||
kind `5`/`3` и иначе дают `0.0`. Затем он вызывает `0x100059f0` объекта по
|
||||
`this + 0x7c` и очищает flag `+0x50`.
|
||||
|
||||
Этот callee больше не opaque. Он строит key из семи значений, ищет matching
|
||||
record в своей collection по `this + 0x24` и при совпадении обновляет только
|
||||
record fields `+0x0c` и `+0x14`, затем вызывает его refresh path `0x10005070`.
|
||||
При отсутствии record он лениво ищет в event table имена `<base>_Start` и
|
||||
`<base>_Continue`, сохраняет их IDs в indexed state и materializes новый
|
||||
internal record. В этой ветке не видно прямого World3D/Behavior call, поэтому
|
||||
это доказанная scheduler/event-record boundary, а не команда движения, атаки
|
||||
или строительства. Semantic names семи slots и consumer нового record остаются
|
||||
открытыми; до dynamic capture Rust возвращает явный
|
||||
unsupported result, а не «примерный» game command.
|
||||
|
||||
У этой границы также нет скрытого immediate dispatch: после добавления новой
|
||||
записи `0x100059f0` вызывает `0x1000f920`, а Ghidra 12.1.2 декомпилирует эту
|
||||
функцию как пустой `return`. Следовательно, найденные `<base>_Start` и
|
||||
`<base>_Continue` только кэшируются в scheduler state; их фактический consumer
|
||||
находится в отдельном позднем update path. Воспроизводимый read-only extractor:
|
||||
`tools/ghidra/ExportAiVmHandler2Dispatch.java`.
|
||||
|
||||
Corpus priority теперь измерен, а не предполагается: во всех 58 GOG `.scr`
|
||||
имеются 6 087 instruction records, из них 3 992 sentinel; самый частый
|
||||
non-sentinel selector — `Handler(30)`, 246 records. Его VA `0x1000c266`
|
||||
читает первые два reference words активной instruction, разрешает каждый
|
||||
через varset (`0x10002d30` и `0x10013570`) и вызывает внешний callback с
|
||||
тремя `u32`: `(0, first, second)`. Callback не принадлежит `ai.dll`: его
|
||||
кладёт десятый argument экспортного `CreateSuperAI`. Тот же callback встречен
|
||||
у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word
|
||||
`1`; предметная семантика этих modes ещё не доказана. В частности, это пока
|
||||
не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный
|
||||
text-to-varset resolver расположен за wrapper `0x10011ea0` в
|
||||
`0x100174a0`. Воспроизводимые exports: `ExportAiVmHandler30.java`,
|
||||
`FindAiVmHandler30Callback.java`, `ExportAiVarSetLoader.java`.
|
||||
|
||||
Следующий pass восстанавливает эту индексацию. `0x100174a0` добавляет каждый
|
||||
recognized source declaration в encounter order как 48-byte record; GOG shared
|
||||
`varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries
|
||||
образуют точно это index space. `0x10013570` возвращает `DWORD` record kind
|
||||
raw `u32`; float kind проходит `__ftol`, чей x87 rounding profile ещё требует
|
||||
capture. Полный GOG scan всех 246 Handler(30) instructions показывает 492
|
||||
operand references: все 492 in-range и указывают на `DWORD`. Поэтому
|
||||
`VarSet::resolve_handler30` уже materializes точный opaque callback command
|
||||
`(mode=0, first, second)` для данного corpus path, но явно отклоняет float,
|
||||
out-of-range и incomplete instructions вместо silent coercion. Extractors:
|
||||
`ExportAiVarSetParser.java`, `ExportAiVarSetU32Resolver.java`.
|
||||
|
||||
Следующий static pass закрывает equality/update policy. Identity ровно равна
|
||||
`(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и
|
||||
`+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`,
|
||||
slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При
|
||||
совпавшем key refresh случается только когда slot1 сравнивается unequal
|
||||
(включая NaN); он заменяет `+0x14` и `+0x0c`, затем прибавляет сохранённый
|
||||
`+0x28` к `+0x24` с x86 wrapping arithmetic. `fparkan-script` отражает эту
|
||||
изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode,
|
||||
не назначает игровых имён и не делает event lookup за original VM.
|
||||
|
||||
На границе mission runtime выбранный TMA clan `first_resource` теперь
|
||||
материализуется как отдельный `MissionScriptBundle`: loader нормализует
|
||||
`<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable
|
||||
package вместе с clan provenance. Headless report выводит число таких packages
|
||||
и их named events. Это именно wiring входных данных, не VM execution: Init и
|
||||
остальные events пока не dispatch-ятся, а ошибка чтения сохраняет
|
||||
transactional rollback mission loader-а.
|
||||
|
||||
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
|
||||
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
|
||||
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
|
||||
`ClanID`, ore, speed и free-time properties.
|
||||
|
||||
Research/economy работают в simulation: `LoadResearch` и
|
||||
`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный
|
||||
layout prerequisites/modifiers/unlocks. Formula evaluator требует strict
|
||||
grammar/version, typed operands, deterministic numeric policy, bounded stack и
|
||||
явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
|
||||
|
||||
### Handler(19): AutoDemo Init varset initialization
|
||||
|
||||
`Handler(19)` is the twentieth VM-table entry at GOG `ai.dll` VA `0x1000aa38`.
|
||||
It is the only instruction in the `Init` event of the two `default.scr` bundles
|
||||
referenced by `MISSIONS\\Autodemo.00\\data.tma`; together those bundles account for
|
||||
the observed 18 named script events. Each instruction references varset records
|
||||
`224`, `225`, and `226`: `ClanBaseX`, `ClanBaseY`, and `ClanID` respectively.
|
||||
|
||||
The original writes three raw DWORD values in order. It converts the VM fields
|
||||
at `+0x80` and `+0x84` through the x87 `__ftol` helper and stores the resulting
|
||||
words into references 0 and 1. It copies the raw word from `+0x7c` into reference
|
||||
2, then clears VM field `+0x50`. The default targets are `DWORD` records; the
|
||||
shared setter preserves the incoming word for that type. Therefore this is not
|
||||
a license to replace the first two conversions with Rust float casts: their
|
||||
rounding behavior remains an x87 compatibility boundary until it has captured
|
||||
test vectors.
|
||||
|
||||
The missing source-field provenance is now constrained by the public creation
|
||||
boundary. `CreateSuperAI` at `ai.dll` VA `0x1000f710` allocates `0x8b0` bytes
|
||||
and calls constructor `0x10001000` with its first eight arguments. That
|
||||
constructor calls `0x10006340(this + 0x7c, clan_id, base_x, base_y)`: these are
|
||||
the fields later read by `Handler(19)`. `base_x` and `base_y` are unsigned and
|
||||
the constructor rejects values greater than `10000`. The actual `__ftol` helper
|
||||
at `0x1001df70` saves the x87 control word, sets its rounding-control bits to
|
||||
truncate, executes `fistp qword`, then restores the control word.
|
||||
|
||||
Consequently the runtime resolves and retains this one proved vertical slice
|
||||
during mission loading: each selected clan's TMA anchor is accepted only in the recovered
|
||||
`0..=10000` base range, truncated through the recovered x87 rule, and paired
|
||||
with its zero-based clan index. For every `Init` instruction whose selector is
|
||||
`Handler(19)`, `VarSet::resolve_handler19` produces the three per-clan DWORD
|
||||
writes. The runtime then materializes an independent declaration-ordered value
|
||||
array for each selected clan and applies those writes, so later recovered
|
||||
handlers can consume `ClanBaseX`, `ClanBaseY`, and `ClanID` as runtime cells
|
||||
rather than loader defaults. Other Init selectors and all other events remain
|
||||
decoded but unexecuted. AutoDemo validates the path end-to-end: its non-integral first
|
||||
anchor (`500.2857`) yields captured `ClanBaseX=500`, and the live GOG process
|
||||
contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)`;
|
||||
the Rust loader reports `script_init_states=2` and `script_varset_states=2`.
|
||||
|
||||
`GetSuperAI` returns element `n` of the 64-pointer global table at preferred
|
||||
`ai.dll + 0x55398` for `n <= 63`. The read-only
|
||||
`tools/capture-ai-init.ps1` probe observed the running GOG AutoDemo values
|
||||
`(500, 752, 0)` for entry 0 and `(728, 449, 1)` for entry 1 at fields
|
||||
`(+0x80, +0x84, +0x7c)`. These values are integral samples, not a rounding
|
||||
profile.
|
||||
|
||||
The Rust reader exposes `VarSet::resolve_handler19`. It accepts the already
|
||||
converted first two words and the third raw word, produces three typed writes,
|
||||
and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only
|
||||
binds it to the proven creation/anchor path above; it does not guess the
|
||||
remaining script event semantics. The associated Ghidra scripts are
|
||||
`ExportAiVmHandler19.java`, `ExportAiVmHandler19Setter.java`,
|
||||
`ExportAiVmHandler19SetterCallee.java`, and `ExportAiGetSuperAi.java`.
|
||||
The creation and conversion boundaries are reproducible with
|
||||
`ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and
|
||||
`ExportAiFtol.java`.
|
||||
|
||||
### Runtime Handler(30) operand binding
|
||||
|
||||
`resolve_handler30_with_values` preserves the recovered declaration-kind ABI
|
||||
but reads operands from instantiated per-clan cells rather than textual
|
||||
defaults. Runtime exposes `resolve_loaded_handler30` for the exact opaque
|
||||
`(mode=0, first, second)` callback command. It intentionally returns that
|
||||
command without automatically invoking a game-side consumer: only one
|
||||
consumer branch has a safe Rust meaning so far.
|
||||
|
||||
The live GOG AutoDemo closes that consumer boundary: the read-only callback
|
||||
pointer at `ai.dll + 0x555e4` is `0x100611d0`, or `iron3d.dll + 0x611d0` at
|
||||
the observed load base. Its recovered `__cdecl` ABI is `(mode, command,
|
||||
payload)`, matching `Handler(30)` as `(0, first, second)`. In `mode == 0`,
|
||||
`command == 0` and `payload == 0` selects `VOICE_MISSION_FAIL`, records the
|
||||
failed status, and clears an IGame byte; `payload == 1` selects
|
||||
`VOICE_MISSION_COMPLETE`, records completion, and sets that byte. Commands
|
||||
3, 4, and 5 have additional game-side paths; mode 2 is a separate IGame
|
||||
call.
|
||||
|
||||
`command == 1` is now statically traced, but still has no justified domain
|
||||
name. Its payload is a signed integer key for a binary-search tree: the
|
||||
receiver's `+0x04` is the sentinel/root, each node has child links at `+0x08`
|
||||
and `+0x0c`, a key at `+0x10`, and a dispatched payload at `+0x14`. A matching
|
||||
node reaches `FUN_10095600(node + 0x14)`. That payload has one-shot flag
|
||||
`+0x19` and a second flag `+0x18`. On its first dispatch, Iron3D sets `+0x19`,
|
||||
obtains a resource-manager string through virtual slot `+0x1c`, and passes the
|
||||
payload's `+0x0c` through `FUN_10061fb0`. On later dispatches it uses
|
||||
resource-manager IDs `0x181a` and `0x184f`, an IGame selector `0x2ed`, and
|
||||
emits an opaque request with category `3` or `4` selected by `+0x18`. This
|
||||
proves the lookup and one-shot/repeat split, not the UI/message subject or the
|
||||
semantics of either resource ID; Rust therefore retains command `1` as
|
||||
`Unhandled`.
|
||||
|
||||
Reproduce the callback and the command-one consumers with
|
||||
`capture-ai-init.ps1`, `ExportIron3dAiCallback.java`,
|
||||
`ExportIron3dAiCallbackCommand1.java`, and
|
||||
`ExportIron3dAiCallbackCommand1Dispatch.java`.
|
||||
|
||||
Runtime now applies only this recovered branch as
|
||||
`apply_loaded_script_host_callback`: `(0, 0, 0)` transitions a loaded mission
|
||||
to `Failed`, `(0, 0, 1)` transitions it to `Completed`, and a repeated target
|
||||
state is a no-op just as the Iron3D guards require. The effect is deliberately
|
||||
separate from audio/UI playback; commands 1, 3, 4, 5 and mode 2 return
|
||||
`Unhandled` until their consumers are recovered.
|
||||
|
||||
### Handler(8): problem-record state write
|
||||
|
||||
`Handler(8)` is the ninth VM-table entry at GOG `ai.dll` VA `0x10009b0d`.
|
||||
All 179 corpus records have exactly one in-range `DWORD` reference; the two
|
||||
observed entries are `ST_SOLVING=1` (122 records) and `ST_SOLVED=2` (57).
|
||||
The handler resolves loader-bound `dCurrentProblem` through varset index
|
||||
`this+0x868`, uses that live DWORD as a bounds-checked index into a table at
|
||||
`this+0xa0` with 100-byte records, then resolves the instruction's one DWORD
|
||||
and writes it to the selected record at `+0x18`.
|
||||
|
||||
The write has two statically proven exceptional branches. State `2` calls a
|
||||
reset helper that zeroes record words `0..=3` and `6` before invoking two
|
||||
opaque callback slots; state `3` does the same except word `3` is preserved.
|
||||
Both then write `+0x18`. Every other state simply writes the state word.
|
||||
`VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned
|
||||
live record index, resolved state, and explicit reset kind. It does not invent
|
||||
the table owner, the pre-reset helper, or callback semantics. Reproduce the
|
||||
evidence with `ExportAiVmHandler8.java`, `ExportAiVmHandler8Callees.java`, and
|
||||
`ExportAiVmHandler8Transitions.java`.
|
||||
|
||||
### Handler(15): typed target-call boundary
|
||||
|
||||
`Handler(15)` is the sixteenth VM-table entry at GOG `ai.dll` VA `0x10008054`
|
||||
and the second most frequent non-sentinel selector: 236 records in 28 of 58
|
||||
GOG packages. It is not a one-word opcode. Across the complete corpus,
|
||||
references `0..3` are `DWORD`, `4..7` are `float`, and `8` is the DWORD mode.
|
||||
The original resolves the first four through `0x10013570`, the next four
|
||||
through x87 scalar helper `0x10013190`, then reads the mode through
|
||||
`0x10013570`.
|
||||
|
||||
The shipped `varset.var` names the only observed mode values: `NONE=0` uses
|
||||
9 total references; `TARGET_BY_LOGIC_ID=0x0201`, `TARGET_BY_TYPE=0x0203`,
|
||||
`TARGET_NOT_DEFINED=0x0204`, and `TARGET_BY_NAME=0x0205` use 10; and
|
||||
`TARGET_BY_PLACE=0x0202` uses 11. The corpus contains 34/122/80 records in
|
||||
these three arities. The trailing references at positions 9 and, only for
|
||||
`TARGET_BY_PLACE`, 10 are all in-range DWORD declarations.
|
||||
|
||||
After resolving those inputs, the handler looks up an opaque target through
|
||||
virtual slot `+0x1c` on `this+0x3d8` using the first word. A missing target
|
||||
sets VM flag `+0x50=5`; otherwise the handler builds a temporary call record,
|
||||
applies the mode-specific tail, and invokes the target's `+0x0c` virtual slot
|
||||
with that record and the third word. Its zero/non-zero result becomes
|
||||
`+0x50=0/1`. The target type, the virtual method's semantic action, and its
|
||||
return value remain unproven. Accordingly `VarSet::resolve_handler15` only
|
||||
materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`;
|
||||
it never executes the opaque target call. Missing, out-of-range, wrong-type,
|
||||
and unobserved-mode inputs are explicit errors. Reproduce the static evidence
|
||||
with `tools/ghidra/ExportAiVmHandler15.java`.
|
||||
|
||||
## Готовность
|
||||
|
||||
Все demo packages должны проходить package/version checks, offsets оставаться
|
||||
в bytecode, а confirmed disassembler — не терять синхронизацию. VM считается
|
||||
готовой после deterministic Init/basic mission events, stable object bindings,
|
||||
typed research/property tests и save/load script state. Для закрытия остаются
|
||||
dispatcher/jump table, minimal differential packages и traces world/variable
|
||||
effects; до них unknown opcode — явная unsupported branch, не no-op.
|
||||
@@ -0,0 +1,74 @@
|
||||
# Shell, HUD, шрифты и локализация
|
||||
|
||||
## Доказанная граница
|
||||
|
||||
`iron_3d.exe` создаёт процесс и окно; `iron3d.dll` экспортирует `createShell`,
|
||||
`deleteShell` и `getIShell`; `services.dll` предоставляет `getGUIServer` и
|
||||
`getDisplay`. Поэтому меню, briefing, HUD и системные диалоги — отдельная
|
||||
оболочка над World3D, а не игровые объекты, созданные ради отрисовки.
|
||||
|
||||
```text
|
||||
Win32 messages
|
||||
-> shell/GUI input и manual events
|
||||
-> simulation World3D
|
||||
-> world render
|
||||
-> shell/HUD overlay и presentation state
|
||||
```
|
||||
|
||||
Точное положение legacy DirectDraw flip относительно последнего UI draw пока
|
||||
требует API capture. Разделение world и UI pass доказано, но не следует
|
||||
выдавать его за восстановленный layout классов виджетов.
|
||||
|
||||
## Ресурсы и идентичность
|
||||
|
||||
В demo найдены `ui/shell_ctrls.cfg`, `ui/menu_resources.cfg`, `ui/cursor.cfg`,
|
||||
`ui/game_resources.cfg`, `ui/hq.cfg`, `DATA/TextRes.cfg`, `gamefont.rlb`,
|
||||
`sprites.lib` и `Palettes.lib`. Конфигурации задают controls/resources/cursor,
|
||||
overlays и HQ; `TextRes.cfg` связывает символические ключи с текстом.
|
||||
|
||||
`gamefont.rlb` содержит две LZSS-записи (`0x040`), `sprites.lib` — 24 записи
|
||||
raw Deflate (`0x100`). `sprites.lib::INTERF8.TEX` использует известный
|
||||
`deflate_eof_plus_one` quirk. UI обязан пользоваться общим RsLi reader, а не
|
||||
отдельной похожей распаковкой.
|
||||
|
||||
Ключ ресурса, legacy path, локализованный текст и исходные bytes — четыре
|
||||
разные идентичности. Archive keys остаются ASCII-casefold; `TextRes.cfg`,
|
||||
briefing/messages и script strings могут требовать ANSI/CP1251 decoding.
|
||||
Unicode-представление хранит raw bytes для roundtrip и diagnostics.
|
||||
|
||||
`gamefont.rlb` и `sprites.lib` побайтно совпадают в Частях 1 и 2. Во второй
|
||||
части добавлен `ui_factory.lib` (NRes с шестью Texm) и расширен
|
||||
`ui/minimap.lib`; при этом пересобранные `iron3d.dll`/`services.dll` требуют
|
||||
отдельной трассировки lifecycle и HUD state.
|
||||
|
||||
## Контракт новой реализации
|
||||
|
||||
UI scene хранит корневые widgets, focus, viewport/clip rectangles и modal
|
||||
depth. Demo `640x480` — полезный baseline, но не доказанное универсальное
|
||||
design resolution. Отдельно хранятся legacy-layout coordinates, реальный
|
||||
viewport, scale/letterbox policy, mouse-to-layout transform, clipping и
|
||||
z-order. Cursor, sprite и hit-test обязаны применять одно преобразование.
|
||||
|
||||
Font contract включает glyph image, advance, bearing/offset, line height и
|
||||
fallback glyph. Binary glyph metrics `gamefont.rlb` пока не восстановлены:
|
||||
payload читается lossless через RsLi, а семантика устанавливается по consumer
|
||||
trace.
|
||||
|
||||
Нормализованное событие проходит modal widget, затем shell command map; лишь
|
||||
разрешённая gameplay-команда превращается в World3D manual event. Held state и
|
||||
axes попадают в calculation snapshot. Это не позволяет UI-click одновременно
|
||||
исполнить команду мира. HUD только читает presentation view (selected object,
|
||||
resources, mission text, timers, research/build state, camera mode); authority
|
||||
остаётся у simulation.
|
||||
|
||||
## Проверки и открытые вопросы
|
||||
|
||||
- UI cfg читаются до EOF с сохранением неизвестных fields; symbolic key
|
||||
разрешается в RsLi entry независимо от регистра.
|
||||
- Visual rect и hit-test совпадают при разных viewport; modal scene блокирует
|
||||
gameplay input; missing optional sprite/font имеет named fallback.
|
||||
- UI-only и world-only command captures собираются раздельно.
|
||||
|
||||
Не закрыты grammar всех `ui/*.cfg`, hierarchy original widgets, glyph metrics,
|
||||
HUD state machine и pixel-perfect layout. Нужны GUI-factory hooks, event traces
|
||||
и captures меню, briefing, HQ и игрового HUD.
|
||||
@@ -3,11 +3,81 @@
|
||||
Baseline command:
|
||||
|
||||
```text
|
||||
env RUSTC=/Users/valentineus/.rustup/toolchains/stable-aarch64-apple-darwin/bin/rustc /opt/homebrew/bin/rustup run stable cargo test --workspace --offline
|
||||
cargo xtask ci
|
||||
```
|
||||
|
||||
Result on 2026-06-22:
|
||||
Result on 2026-07-18:
|
||||
|
||||
- library and binary unit tests compile and pass after aligning SDL2 versions and pinning `toml` to cached `0.8`;
|
||||
- doctests fail in this shell because `rustdoc` is not in PATH unless `RUSTDOC` is also set to the real toolchain binary;
|
||||
- full online dependency resolution is unavailable in the sandbox.
|
||||
- canonical pipeline passes locally with Rust 1.97.1: formatting, policy,
|
||||
shader provenance, workspace tests, `clippy`, docs and `cargo deny` are
|
||||
executed through `cargo xtask ci`;
|
||||
- `rust-toolchain.toml` pins Rust 1.97.1 and installs only the
|
||||
`x86_64-pc-windows-msvc` target; Linux and macOS target installation is not
|
||||
part of the default developer setup;
|
||||
- internal path dependencies are version-pinned and the Windows-only winit
|
||||
graph enables only `rwh_06`, excluding Unix window-system dependencies;
|
||||
- `cargo deny` runs without advisory exceptions in the supported Windows graph.
|
||||
|
||||
Native Vulkan evidence:
|
||||
|
||||
- Windows x86_64 local smoke passed again on 2026-07-18 with Rust 1.97.1, the
|
||||
system Vulkan loader 1.4.350, and an AMD Radeon Pro WX 3200 Series;
|
||||
- the smoke created a Win32 surface and swapchain, presented 300 frames,
|
||||
exercised a controlled resize (three observed resize events and two
|
||||
swapchain recreations), and shut down with zero validation warnings and
|
||||
errors;
|
||||
- Windows is the sole supported runtime target for this project; Linux and
|
||||
macOS smoke gates are explicitly out of scope.
|
||||
|
||||
Scope labels:
|
||||
|
||||
- Stage 0 codebase gates: locally evidenced.
|
||||
- Stage 0 Windows native runtime: locally evidenced.
|
||||
- Linux/macOS runtime and cross-platform hosted CI: out of scope.
|
||||
|
||||
## Current architecture contract
|
||||
|
||||
The standalone engine keeps binary formats, the resource graph, simulation,
|
||||
animation math and backend-neutral render commands independent from the GPU
|
||||
adapter. Windows presentation uses `winit`, `raw-window-handle`, `ash-window`
|
||||
and `ash`; only the Vulkan/FFI adapters may contain narrowly documented
|
||||
`unsafe` code. Raw Vulkan handles do not cross that boundary.
|
||||
|
||||
The baseline is Vulkan 1.1 with surface, Win32-surface and swapchain support,
|
||||
binary semaphores/fences and a classic render-pass path. Device capability is
|
||||
queried at runtime; dynamic rendering, descriptor indexing, synchronization2,
|
||||
timeline semaphores and extended dynamic state are optional capability-gated
|
||||
enhancements, not requirements. The canonical initial texture upload is RGBA8
|
||||
UNORM. Headless builds remain independent of `winit`, Vulkan and a window
|
||||
system.
|
||||
|
||||
## Current stage model
|
||||
|
||||
The canonical Vulkan-revision plan has six dependency-ordered stages numbered
|
||||
0--5. Keeping its original numbering matters: Stage 4 is an evidence-gated
|
||||
animation/FX runtime, while Stage 5 is the mission/world vertical slice that
|
||||
depends on it. They must not be reported as one completed stage.
|
||||
|
||||
0. reproducible Windows/Vulkan foundation;
|
||||
1. paths, VFS and lossless archives;
|
||||
2. prototype graph and prepared CPU assets;
|
||||
3. static Vulkan model/terrain viewer;
|
||||
4. animation and FX runtime, with reference-only semantics until runtime
|
||||
captures close the x87 and effect-lifecycle evidence gaps;
|
||||
5. transactional map, mission and world vertical slice, rendered from the
|
||||
same immutable snapshot through Vulkan.
|
||||
|
||||
This is a local, Windows-only adoption of the Notion page "План реализации
|
||||
stage 0--5: Vulkan revision" (reviewed on 2026-07-18). Its former
|
||||
Linux/macOS portability and hosted-CI goals are intentionally not imported:
|
||||
they conflict with the current supported-platform boundary above. The
|
||||
portable architectural rules that do apply -- backend-neutral commands,
|
||||
runtime capability queries, narrow Vulkan/FFI `unsafe`, offline shader
|
||||
validation, and command capture before pixel comparison -- are retained in
|
||||
this audit and the rendering tome.
|
||||
|
||||
Contract tests and failure tests precede implementation. Synthetic checks never
|
||||
read licensed roots; licensed corpus checks use absolute paths from the local
|
||||
manifest. Backend-neutral command capture precedes pixel comparison, and GPU
|
||||
addresses, allocator addresses and driver timing are excluded from deterministic
|
||||
state hashes.
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# Сверка локальной книги с FParkan в Notion
|
||||
|
||||
Проверка выполнена 18 июля 2026 года по странице `FParkan`, её восьми томам,
|
||||
плану Vulkan revision и приложениям A--D. Цель — не копировать структуру
|
||||
Notion, а убедиться, что каждый доказательный контракт доступен в `docs/` и
|
||||
остается применимым к Windows/Vulkan scope.
|
||||
|
||||
| Notion | Локальное место |
|
||||
| --- | --- |
|
||||
| Статьи 1--3 | `tomes/01-guide.md` |
|
||||
| Статьи 4--8 | `tomes/02-architecture.md` |
|
||||
| Статьи 9--13 | `tomes/03-resources.md` и `reference/` |
|
||||
| Статьи 14--18 | `tomes/04-world.md` и `reference/tma.md` |
|
||||
| Статьи 19--27 | `tomes/05-render.md`, `reference/` и `rendering/` |
|
||||
| Статьи 28--32 | `tomes/06-behavior.md` |
|
||||
| Статьи 33--37 | `tomes/07-implementation.md` и `baseline/vulkan-revision-plan.md` |
|
||||
| Статьи 38--42 | `tomes/08-evidence.md`, `appendices/glossary.md`, `evidence/` |
|
||||
| Приложение A | этот audit, `baseline/current-project-audit.md` и тематические тома |
|
||||
| Приложение B | `appendices/ui-shell.md` |
|
||||
| Приложение C | `appendices/saves-campaign.md` |
|
||||
| Приложение D | `appendices/script-vm.md` |
|
||||
|
||||
В ходе сверки добавлены отсутствовавшие локальные контракты UI/Shell,
|
||||
сохранений/campaign и Script VM. Повторы не переносились: форматы, ABI и
|
||||
corpus statistics уже находятся рядом с соответствующими readers/consumers.
|
||||
|
||||
Не перенесены только противоречащие утвержденному scope цели: Linux, macOS,
|
||||
MoltenVK, GLES/RG40XX и hosted CI. Они не считаются пробелами. Текущие
|
||||
доказательства Windows/Vulkan и все последующие уточнения ведутся только в
|
||||
локальных файлах.
|
||||
|
||||
## Повторная содержательная сверка (18 июля 2026)
|
||||
|
||||
Повторная сверка проверяет не количество дочерних страниц в Notion, а
|
||||
утверждения, которые они добавляют к реализации. Источником были корневая
|
||||
страница `FParkan`, восемь оглавлений с 42 статьями, а также две специальные
|
||||
страницы: `План реализации stage 0–5: Vulkan revision` (редакция 18 июля) и
|
||||
`Ревью перехода на Vulkan: решение, доказательства и ограничения`.
|
||||
|
||||
| Контракт из Notion | Локальное подтверждение | Результат |
|
||||
| --- | --- | --- |
|
||||
| Статьи 1–3: терминология, уровень доказательств, методика | `tomes/01-guide.md` | Полностью покрыто. |
|
||||
| Статьи 4–8: bootstrap, DLL, frame loop, World3D | `tomes/02-architecture.md` | Полностью покрыто. |
|
||||
| Статьи 9–13: VFS, NRes, RsLi, registry, unit и auxiliary formats | `tomes/03-resources.md` и `reference/` | Полностью покрыто. |
|
||||
| Статьи 14–18: TMA, mission loader, Land, ArealMap и world construction | `tomes/04-world.md`, `reference/tma.md`, `reference/msh.md` | Полностью покрыто. |
|
||||
| Статьи 19–27: Ngi32, MSH, animation, MAT0/WEAR/Texm, terrain и кадр | `tomes/05-render.md`, `reference/`, `rendering/` | Покрыто; локально дополнено более свежими evidence по D3D7 camera, Node38 и Terrain/GetShade. |
|
||||
| Статьи 28–32: AI, control, camera, audio, network | `tomes/06-behavior.md`, `appendices/script-vm.md` | Полностью покрыто. |
|
||||
| Статьи 33–37 и Vulkan revision: ports, stages, deterministic gates, Vulkan profile | `tomes/07-implementation.md`, `baseline/vulkan-revision-plan.md` | Полностью покрыто в Windows-only редакции. |
|
||||
| Статьи 38–42 и приложения A–D: ABI, corpus, knowledge boundaries, glossary, shell, saves, VM | `tomes/08-evidence.md`, `appendices/`, `evidence/`, этот audit | Полностью покрыто. |
|
||||
|
||||
Пропущенных применимых технических контрактов в этом срезе не найдено. В
|
||||
частности, локальный Vulkan plan уже содержит независимость решений Vulkan и
|
||||
`winit`, Vulkan 1.1 baseline, `ash`-изоляцию, SPIR-V manifest/hash, capability
|
||||
gates, canonical RGBA8 upload и первичность backend-neutral command capture.
|
||||
Не переносились только исторические cross-platform acceptance требования из
|
||||
Notion: они прямо отменены текущим Windows-only scope, а не потеряны при
|
||||
синхронизации.
|
||||
|
||||
Следовательно, новые факты следует добавлять непосредственно в тематический
|
||||
локальный документ; повторный перенос дерева или дублирование страниц Notion
|
||||
не требуется.
|
||||
|
||||
## Проверяемые источники и правило разрешения расхождений
|
||||
|
||||
Содержательная сверка опирается не только на оглавления. Были прочитаны
|
||||
корневая страница `FParkan`, оглавления томов I--VIII и актуальные специальные
|
||||
страницы [Vulkan revision](https://app.notion.com/p/387e79f2db3981778f94cdf34db5f93f),
|
||||
[Vulkan review](https://app.notion.com/p/388e79f2db39810eb649edbe90bca529),
|
||||
а также исторические статьи 33--34. Это позволяет отличить контракт от
|
||||
исторического статуса работы.
|
||||
|
||||
- В локальной книге сохранены применимые контракты Vulkan revision: Windows
|
||||
как единственная acceptance-платформа, Vulkan 1.1 baseline, изоляция
|
||||
`ash`/raw handles в adapter-е, capability gates, offline SPIR-V и первичность
|
||||
backend-neutral command capture.
|
||||
- Не переносится прежний статус-аудит Notion (например, утверждения о
|
||||
synthetic-only renderer или незакрытом Windows smoke): он описывал состояние
|
||||
до последующих локальных captures и потому не является спецификацией.
|
||||
- Не переносятся Linux, macOS/MoltenVK и portability-enumeration требования.
|
||||
Это сознательно исключённая область, а не пробел документации.
|
||||
- Если страница Notion и свежий локальный evidence расходятся, локальный
|
||||
evidence с командой воспроизведения, артефактом и датой имеет приоритет;
|
||||
спорный факт отмечается как граница знания, пока не будет перепроверен.
|
||||
|
||||
Таким образом, на момент сверки не обнаружено пропущенных применимых
|
||||
технических контрактов: содержательные добавления из Notion уже разнесены по
|
||||
тематическим локальным документам, а новые результаты разработки должны
|
||||
добавляться только локально.
|
||||
@@ -0,0 +1,158 @@
|
||||
# План реализации: Vulkan revision (Windows)
|
||||
|
||||
Это локальная, действующая редакция плана `stage 0--5`. Она была получена
|
||||
вдумчивой сверкой с одноимённой страницей книги FParkan в Notion 18 июля
|
||||
2026 года. В Notion оставлены исторические формулировки и кроссплатформенные
|
||||
цели; этот документ сохраняет все применимые технические требования и
|
||||
приводит их к текущему контракту: самостоятельный движок для Windows,
|
||||
оригинальные файлы игры и Vulkan.
|
||||
|
||||
## Неизменяемые решения
|
||||
|
||||
- Vulkan — единственный GPU API. Он заменяет прежнюю DirectDraw/Direct3D
|
||||
реализацию на уровне наблюдаемой семантики кадра, а не через эмуляцию COM
|
||||
объектов или буквальный перевод старых вызовов.
|
||||
- `winit` — отдельный adapter окна, ввода и event loop. Vulkan не является
|
||||
заменой SDL2: это независимые слои. В текущем проекте SDL2 не используется.
|
||||
- `ash`, `ash-window` и `raw-window-handle` остаются внутри Windows
|
||||
platform/Vulkan adapters. Backend-neutral crates не экспортируют raw Vulkan
|
||||
handles и сохраняют `#![forbid(unsafe_code)]`; каждый `unsafe` в adapter-е
|
||||
имеет локальный safety contract, правило владения и regression test.
|
||||
- Baseline: Vulkan 1.1, surface + Win32 surface + swapchain, classic render
|
||||
pass, binary semaphores и fences. Format/queue/present/image-count/sampler
|
||||
capabilities запрашиваются у конкретного устройства. Dynamic rendering,
|
||||
descriptor indexing, synchronization2, timeline semaphores и extended
|
||||
dynamic state допускаются только через capability gate.
|
||||
- Исходные MSH, WEAR, MAT0 и Texm остаются CPU-форматами. Стартовый upload
|
||||
путь — канонический RGBA8 UNORM; packed/native GPU formats допустимы только
|
||||
после доказательства эквивалентности. Shader variants собираются offline в
|
||||
SPIR-V и проверяются validator-ом, manifest-ом и hash.
|
||||
- Первичный эталон — backend-neutral command capture. Сравнение пикселей
|
||||
начинается лишь после совпадения draw order, pipeline key, resource IDs,
|
||||
descriptor bindings, ranges и transforms. GPU handles, allocator addresses
|
||||
и driver timing не входят в deterministic state hash.
|
||||
|
||||
Windows — единственная runtime-платформа acceptance. Требования Notion к
|
||||
Linux, macOS/MoltenVK, portability enumeration и hosted CI намеренно не
|
||||
переносятся: они противоречат утверждённой области проекта, а не являются
|
||||
пропуском документации. Headless сборка по-прежнему не зависит от окна,
|
||||
Vulkan loader или `winit`.
|
||||
|
||||
## Stage 0 — воспроизводимая Windows/Vulkan основа
|
||||
|
||||
**Цель:** минимальный реальный Vulkan vertical slice без игровых assets и
|
||||
локальные повторяемые gates.
|
||||
|
||||
- Зафиксировать stable Rust/MSRV, `Cargo.lock` и `--locked`; расширять
|
||||
`cargo xtask ci` форматированием, tests, clippy, документацией, policy для
|
||||
licenses/advisories/sources и проверкой разрешённого `unsafe` allowlist.
|
||||
- Synthetic gate не читает лицензированные каталоги и не может молча пропускать
|
||||
тест. Licensed corpus запускается отдельно по абсолютным путям local
|
||||
manifest. Hosted CI/CD в этот scope не входит.
|
||||
- Поддерживать typed parsing конфигурации xtask и `cargo_metadata`, а не
|
||||
ручную интерпретацию TOML; исключать устаревшие adapter names и Python
|
||||
runtime components из policy.
|
||||
- Поддерживать `fparkan-platform-winit` (lifecycle, resize/DPI, input,
|
||||
suspend/resume, raw handles) и `fparkan-render-vulkan` (instance,
|
||||
validation, device scoring, queues, swapchain, resize/out-of-date/suboptimal
|
||||
handling, deterministic capability report).
|
||||
- Acceptance: Windows smoke создаёт настоящее окно/swapchain, показывает не
|
||||
менее 300 кадров с resize и завершается без validation errors; negative
|
||||
cases проверяют loader/device/present-queue/surface-format failures.
|
||||
|
||||
## Stage 1 — пути, VFS и архивы
|
||||
|
||||
**Цель:** безопасный lossless resource substrate без GPU coupling.
|
||||
|
||||
- Для каждого пути различать raw legacy bytes, normalized path, ASCII lookup
|
||||
key и host path; strict и compatible policy не смешивать.
|
||||
- Применить symlink-safe traversal и casefold-collision policy ко всем VFS.
|
||||
- В каждый parser/decompressor внедрить общие `DecodeLimits` и
|
||||
`AllocationBudget`; malformed offsets, counts и decompression bombs должны
|
||||
завершаться bounded errors.
|
||||
- Довести NRes и RsLi до lossless reader/editor/writer: сохранять unknown и
|
||||
non-zero regions, stable directory order, все наблюдённые decode methods,
|
||||
explicit compatibility profile и output limits.
|
||||
- Resource repository обязан иметь generation handles, decoded-byte budget,
|
||||
deterministic eviction, lock-free decompression section и структурированные
|
||||
ошибки с archive/entry/path/offset/phase/cause chain.
|
||||
- Acceptance: synthetic no-edit и edit roundtrip, stale handles, traversal,
|
||||
symlink/casefold и byte-identical corpus reports; Part 1/Part 2 не дают
|
||||
необъяснённых parser failures.
|
||||
|
||||
## Stage 2 — prototype graph и CPU assets
|
||||
|
||||
**Цель:** полный mission-reachable graph и typed prepared assets до GPU.
|
||||
|
||||
- Разрешить `objects.rlb`, unit DAT, inheritance, BASE/resource variants и
|
||||
все компоненты unit, сохраняя hierarchy, provenance и multi-component
|
||||
composition.
|
||||
- Каждый edge хранит typed provenance: mission object, component, prototype,
|
||||
model, wear, material, texture, lightmap или effect. Циклы, depth limit,
|
||||
optional fallback и corrupt reachable dependency имеют разные outcomes.
|
||||
- `fparkan-assets` — единственный слой CPU preparation; apps и runtime не
|
||||
парсят assets ad hoc. Assets immutable, имеют stable IDs, а graph failures
|
||||
содержат полную parent chain.
|
||||
- Acceptance: graph order/IDs стабильны; все mission-reachable requests
|
||||
обеих частей завершаются с failures 0 и передают runtime только prepared
|
||||
assets.
|
||||
|
||||
## Stage 3 — статический Vulkan viewer
|
||||
|
||||
**Цель:** доказуемый статический MSH/terrain render из оригинальных assets.
|
||||
|
||||
- Закрыть validation streams/slots/batches/indices, Texm decode/mips/palettes/
|
||||
Page rectangles и WEAR/MAT0 fallback с раздельными texture/lightmap identity.
|
||||
- Backend-neutral `LegacyPipelineState` и canonical `PipelineKey` выбираются
|
||||
до GPU. Vulkan adapter владеет staging/device buffers, image transitions,
|
||||
samplers/descriptors, pipeline cache, depth, diffuse/lightmap bindings,
|
||||
alpha/depth/cull/blend mapping и lifecycle per-frame resources.
|
||||
- Viewer/debug modes включают model, texture, material, wireframe, normals,
|
||||
bounds, LOD/group и terrain; upload cache ограничен GPU budget.
|
||||
- Acceptance: CPU golden vectors, descriptor/pipeline-key/row-stride tests,
|
||||
command captures до GPU и fixed-camera captures модели, lightmapped модели
|
||||
и terrain; Windows validation smoke остаётся clean.
|
||||
|
||||
## Stage 4 — animation и FX runtime
|
||||
|
||||
**Цель:** заменить reference stubs доказанным deterministic runtime.
|
||||
|
||||
- Реализовать type 8/type 19 node sampling, fallback keys, hierarchy и
|
||||
material timeline по подтверждённым modes/masks. Portable math не выдают за
|
||||
x87-compatible: второй путь появляется только после captured vectors.
|
||||
- FXID отделяет lifecycle/time/RNG gates от backend: неподтверждённые fields
|
||||
сохраняются raw и не исполняются как догадки; emit формирует
|
||||
backend-neutral primitive/audio commands.
|
||||
- Pose/effect snapshots immutable per frame; Part 1/Part 2 profiles различают
|
||||
только там, где это подтверждено differential captures.
|
||||
- Acceptance: frame-by-frame poses имеют approved references, FXID corpus не
|
||||
имеет parser errors, один seed даёт одинаковые commands. До этого semantic
|
||||
статус строго `reference-only`, а не `runtime-compatible`.
|
||||
|
||||
## Stage 5 — карта, миссия и мир
|
||||
|
||||
**Цель:** транзакционно загрузить миссию, выполнить headless steps и показать
|
||||
тот же immutable world snapshot через Vulkan.
|
||||
|
||||
- Закрыть Land.msh/TerrainFace28, Land.map, grid/graph validation и runtime
|
||||
spatial acceleration для surface/raycast/visibility queries.
|
||||
- Loader выполняет `Context -> Map -> TMA -> Graph -> Assets -> Construct ->
|
||||
Register`, откатывая любую ошибку. Он сохраняет raw transforms, properties,
|
||||
original IDs и provenance всех mission components.
|
||||
- World queue, generation handles, deferred deletion, deterministic clock и
|
||||
snapshot contract проверяются replay/hash tests; terrain/navigation и render
|
||||
читают один опубликованный snapshot, не mutable world.
|
||||
- Acceptance: headless mission replay стабилен, transaction rollback не
|
||||
оставляет частичного мира, а Windows Vulkan frame использует ту же snapshot
|
||||
и имеет связанный command/pixel artifact.
|
||||
|
||||
## Сверка с Notion
|
||||
|
||||
Восемь томов локальной книги покрывают 42 основные статьи Notion по тем же
|
||||
разделам I--VIII; приложения сведены в `appendices/` и том VIII. Специальное
|
||||
Vulkan-ревью дополнительно внесло в локальные материалы следующие точные
|
||||
факты: исходный Ngi32 dynamically resolves DirectDraw/Direct3D, современная
|
||||
граница замены находится выше Vulkan, а совпадающий SHA-256 Ngi32 в Частях 1 и
|
||||
2 позволяет использовать один backend contract. Детали доказательства и
|
||||
текущие native captures находятся в `tomes/05-render.md`,
|
||||
`evidence/original_engine_hashes.md` и `rendering/renderer_truth_table.md`.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user