aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.cargo/config.toml2
-rw-r--r--.devcontainer/devcontainer.json9
-rw-r--r--.gitea/workflows/renovate.yml25
-rw-r--r--.gitea/workflows/test.yml55
-rw-r--r--.github/workflows/ci.yml159
-rw-r--r--.gitignore222
-rw-r--r--.renovaterc30
-rw-r--r--Cargo.lock1907
-rw-r--r--Cargo.toml29
-rw-r--r--README.md185
-rw-r--r--adapters/fparkan-platform-winit/Cargo.toml5
-rw-r--r--adapters/fparkan-platform-winit/src/lib.rs19
-rw-r--r--adapters/fparkan-render-vulkan/Cargo.toml34
-rw-r--r--adapters/fparkan-render-vulkan/build.rs92
-rw-r--r--adapters/fparkan-render-vulkan/shaders/manifest.json1
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag126
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.frag.spvbin1184 -> 10388 bytes
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert264
-rw-r--r--adapters/fparkan-render-vulkan/shaders/triangle.vert.spvbin1432 -> 18580 bytes
-rw-r--r--adapters/fparkan-render-vulkan/src/asset_mesh.rs935
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi.rs67
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/instance.rs44
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/resources.rs697
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke.rs1582
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs683
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/surface.rs20
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs472
-rw-r--r--adapters/fparkan-render-vulkan/src/ffi/tests.rs326
-rw-r--r--adapters/fparkan-render-vulkan/src/lib.rs6
-rw-r--r--adapters/fparkan-render-vulkan/src/planning_backend.rs168
-rw-r--r--adapters/fparkan-render-vulkan/src/policy.rs183
-rw-r--r--adapters/fparkan-render-vulkan/src/shader.rs82
-rw-r--r--adapters/fparkan-render-vulkan/src/shader_manifest.rs399
-rw-r--r--adr/ADR-0001-modular-monolith.md5
-rw-r--r--adr/ADR-0002-behavior-not-abi.md5
-rw-r--r--adr/ADR-0003-raw-and-interpreted-data.md5
-rw-r--r--adr/ADR-0004-synthetic-and-licensed-tests.md5
-rw-r--r--adr/ADR-0005-deterministic-reference-runtime.md5
-rw-r--r--adr/ADR-0006-error-policy.md5
-rw-r--r--adr/ADR-0007-safe-sdl-opengl.md26
-rw-r--r--apps/fparkan-cli/Cargo.toml22
-rw-r--r--apps/fparkan-cli/src/main.rs1070
-rw-r--r--apps/fparkan-game/Cargo.toml29
-rw-r--r--apps/fparkan-game/src/audio.rs1798
-rw-r--r--apps/fparkan-game/src/main.rs6753
-rw-r--r--apps/fparkan-headless/Cargo.toml14
-rw-r--r--apps/fparkan-headless/src/main.rs267
-rw-r--r--apps/fparkan-viewer/Cargo.toml13
-rw-r--r--apps/fparkan-viewer/src/main.rs350
-rw-r--r--apps/fparkan-vulkan-smoke/Cargo.toml19
-rw-r--r--apps/fparkan-vulkan-smoke/build.rs134
-rw-r--r--apps/fparkan-vulkan-smoke/src/main.rs1973
-rw-r--r--crates/fparkan-animation/Cargo.toml3
-rw-r--r--crates/fparkan-animation/src/lib.rs70
-rw-r--r--crates/fparkan-assets/Cargo.toml27
-rw-r--r--crates/fparkan-assets/src/lib.rs1466
-rw-r--r--crates/fparkan-binary/Cargo.toml3
-rw-r--r--crates/fparkan-binary/src/lib.rs20
-rw-r--r--crates/fparkan-corpus/Cargo.toml22
-rw-r--r--crates/fparkan-corpus/src/lib.rs1239
-rw-r--r--crates/fparkan-diagnostics/Cargo.toml13
-rw-r--r--crates/fparkan-diagnostics/src/lib.rs249
-rw-r--r--crates/fparkan-fx/Cargo.toml7
-rw-r--r--crates/fparkan-fx/src/atmosphere.rs1756
-rw-r--r--crates/fparkan-fx/src/environment.rs2803
-rw-r--r--crates/fparkan-fx/src/lib.rs59
-rw-r--r--crates/fparkan-fx/src/shadow.rs1785
-rw-r--r--crates/fparkan-fx/src/sky.rs2079
-rw-r--r--crates/fparkan-inspection/Cargo.toml21
-rw-r--r--crates/fparkan-inspection/src/lib.rs1009
-rw-r--r--crates/fparkan-material/Cargo.toml11
-rw-r--r--crates/fparkan-material/src/lib.rs951
-rw-r--r--crates/fparkan-mission-format/Cargo.toml5
-rw-r--r--crates/fparkan-mission-format/src/lib.rs19
-rw-r--r--crates/fparkan-msh/Cargo.toml7
-rw-r--r--crates/fparkan-msh/src/lib.rs732
-rw-r--r--crates/fparkan-nres/Cargo.toml7
-rw-r--r--crates/fparkan-nres/src/lib.rs19
-rw-r--r--crates/fparkan-path/Cargo.toml3
-rw-r--r--crates/fparkan-path/src/lib.rs19
-rw-r--r--crates/fparkan-platform/Cargo.toml3
-rw-r--r--crates/fparkan-platform/src/lib.rs19
-rw-r--r--crates/fparkan-prototype/Cargo.toml13
-rw-r--r--crates/fparkan-prototype/src/lib.rs438
-rw-r--r--crates/fparkan-render/Cargo.toml4
-rw-r--r--crates/fparkan-render/src/lib.rs1233
-rw-r--r--crates/fparkan-resource/Cargo.toml13
-rw-r--r--crates/fparkan-resource/src/lib.rs19
-rw-r--r--crates/fparkan-rsli/Cargo.toml5
-rw-r--r--crates/fparkan-rsli/src/lib.rs20
-rw-r--r--crates/fparkan-runtime/Cargo.toml23
-rw-r--r--crates/fparkan-runtime/src/lib.rs716
-rw-r--r--crates/fparkan-script/Cargo.toml5
-rw-r--r--crates/fparkan-script/src/lib.rs5
-rw-r--r--crates/fparkan-terrain-format/Cargo.toml7
-rw-r--r--crates/fparkan-terrain-format/src/lib.rs168
-rw-r--r--crates/fparkan-terrain/Cargo.toml7
-rw-r--r--crates/fparkan-terrain/src/lib.rs92
-rw-r--r--crates/fparkan-test-support/Cargo.toml12
-rw-r--r--crates/fparkan-test-support/src/lib.rs44
-rw-r--r--crates/fparkan-texm/Cargo.toml5
-rw-r--r--crates/fparkan-texm/src/lib.rs19
-rw-r--r--crates/fparkan-vfs/Cargo.toml7
-rw-r--r--crates/fparkan-vfs/src/lib.rs25
-rw-r--r--crates/fparkan-world/Cargo.toml5
-rw-r--r--crates/fparkan-world/src/lib.rs19
-rw-r--r--deny.toml30
-rw-r--r--docs/appendices/knowledge-boundaries.md39
-rw-r--r--docs/appendices/script-vm.md77
-rw-r--r--docs/baseline/current-project-audit.md83
-rw-r--r--docs/baseline/notion-reconciliation.md88
-rw-r--r--docs/baseline/vulkan-revision-plan.md158
-rw-r--r--docs/evidence/original_engine_hashes.md65
-rw-r--r--docs/evidence/stage4_capture_schema.md107
-rw-r--r--docs/index.md102
-rw-r--r--docs/reference/atmosphere.md236
-rw-r--r--docs/reference/environment-effects.md203
-rw-r--r--docs/reference/materials.md80
-rw-r--r--docs/reference/msh.md106
-rw-r--r--docs/reference/original-binaries.md33
-rw-r--r--docs/reference/render-frame.md8
-rw-r--r--docs/rendering/renderer_truth_table.md51
-rw-r--r--docs/tomes/01-guide.md253
-rw-r--r--docs/tomes/03-resources.md15
-rw-r--r--docs/tomes/05-render.md1235
-rw-r--r--docs/tomes/06-behavior.md225
-rw-r--r--docs/tomes/07-implementation.md779
-rw-r--r--docs/tomes/08-evidence.md779
-rw-r--r--fixtures/acceptance/coverage.tsv447
-rw-r--r--fixtures/acceptance/macos-gl33-triangle-capture.json17
-rw-r--r--fixtures/acceptance/stage_0_2_roadmap.md432
-rw-r--r--fixtures/acceptance/stage_0_roadmap.md69
-rw-r--r--fixtures/acceptance/stage_packages.toml48
-rw-r--r--fixtures/schemas/corpus-manifest.schema.json19
-rw-r--r--fixtures/schemas/report.schema.json10
-rw-r--r--mkdocs.yml15
-rw-r--r--parity/README.md23
-rw-r--r--parity/cases.toml27
-rw-r--r--parity/reference/.gitkeep0
-rw-r--r--renovate.config.cjs6
-rw-r--r--rust-toolchain.toml6
-rw-r--r--tools/capture-ai-init.ps1111
-rw-r--r--tools/capture-original-camera.ps1213
-rw-r--r--tools/capture-terrain-shade-cache.ps1272
-rw-r--r--tools/ghidra/ExportAiCreateSuperAi.java23
-rw-r--r--tools/ghidra/ExportAiExpressionDispatcher.java25
-rw-r--r--tools/ghidra/ExportAiFtol.java23
-rw-r--r--tools/ghidra/ExportAiGetSuperAi.java23
-rw-r--r--tools/ghidra/ExportAiScriptLoader.java23
-rw-r--r--tools/ghidra/ExportAiScriptLoaderStrings.java27
-rw-r--r--tools/ghidra/ExportAiScriptPackageReader.java23
-rw-r--r--tools/ghidra/ExportAiSuperAiConstructor.java23
-rw-r--r--tools/ghidra/ExportAiVarSetLoader.java23
-rw-r--r--tools/ghidra/ExportAiVarSetParser.java23
-rw-r--r--tools/ghidra/ExportAiVarSetU32Resolver.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler0.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler1.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler15.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler19.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler19Setter.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler19SetterCallee.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler1Callees.java25
-rw-r--r--tools/ghidra/ExportAiVmHandler2.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler2Callee.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler2Dispatch.java27
-rw-r--r--tools/ghidra/ExportAiVmHandler2SchedulerHelpers.java28
-rw-r--r--tools/ghidra/ExportAiVmHandler30.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler8.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler8Callees.java25
-rw-r--r--tools/ghidra/ExportAiVmHandler8Transitions.java25
-rw-r--r--tools/ghidra/ExportAiVmTableInstall.java23
-rw-r--r--tools/ghidra/ExportAniMeshControlCaller.java21
-rw-r--r--tools/ghidra/ExportControlFunctions.java35
-rw-r--r--tools/ghidra/ExportIron3dAiCallback.java23
-rw-r--r--tools/ghidra/ExportIron3dAiCallbackCommand1.java23
-rw-r--r--tools/ghidra/ExportIron3dAiCallbackCommand1Dispatch.java23
-rw-r--r--tools/ghidra/FindAiScriptLoaderReferences.java29
-rw-r--r--tools/ghidra/FindAiVmHandler30Callback.java27
-rw-r--r--xtask/Cargo.toml17
-rw-r--r--xtask/src/main.rs3861
180 files changed, 24970 insertions, 24854 deletions
diff --git a/.cargo/config.toml b/.cargo/config.toml
deleted file mode 100644
index 5592118..0000000
--- a/.cargo/config.toml
+++ /dev/null
@@ -1,2 +0,0 @@
-[alias]
-xtask = "run -p xtask --"
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
deleted file mode 100644
index f884a10..0000000
--- a/.devcontainer/devcontainer.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
- "image": "mcr.microsoft.com/devcontainers/rust:latest",
- "customizations": {
- "vscode": {
- "extensions": ["rust-lang.rust-analyzer"]
- }
- },
- "runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"]
-}
diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml
deleted file mode 100644
index b0132fc..0000000
--- a/.gitea/workflows/renovate.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-name: RenovateBot
-
-on:
- schedule:
- - cron: "@daily"
-
-jobs:
- renovate:
- container: ghcr.io/renovatebot/renovate:44
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout code
- uses: actions/checkout@v7
-
- - name: Run renovate
- run: |
- renovate
- env:
- GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
- LOG_LEVEL: ${{ vars.RENOVATE_LOG_LEVEL }}
- RENOVATE_CONFIG_FILE: renovate.config.cjs
- RENOVATE_LOG_LEVEL: ${{ vars.RENOVATE_LOG_LEVEL }}
- RENOVATE_REPOSITORIES: ${{ gitea.repository }}
- RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
deleted file mode 100644
index f092222..0000000
--- a/.gitea/workflows/test.yml
+++ /dev/null
@@ -1,55 +0,0 @@
-name: Test
-
-on: [push, pull_request]
-
-jobs:
- lint:
- name: Lint
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v7
- - uses: dtolnay/rust-toolchain@stable
- with:
- components: clippy
- - name: Cargo check
- run: cargo check --workspace --all-targets --all-features
- - name: Clippy (deny warnings)
- run: cargo clippy --workspace --all-targets --all-features -- -D warnings
-
- test:
- name: Test
- runs-on: ubuntu-latest
- needs: lint
- steps:
- - uses: actions/checkout@v7
- - uses: dtolnay/rust-toolchain@stable
- - name: Cargo test
- run: cargo test --workspace --all-features -- --nocapture
-
- render-parity:
- name: Render parity
- runs-on: ubuntu-latest
- needs: test
- steps:
- - uses: actions/checkout@v7
- - uses: dtolnay/rust-toolchain@stable
- - name: Install headless GL runtime
- run: |
- sudo apt-get update
- sudo apt-get install -y xvfb libgl1-mesa-dri libgles2-mesa-dev mesa-utils
- - name: Build render-demo binary
- run: cargo build -p render-demo --features demo
- - name: Run frame parity suite
- run: |
- xvfb-run -s "-screen 0 1280x720x24" cargo run -p render-parity -- \
- --manifest parity/cases.toml \
- --output-dir target/render-parity/current \
- --demo-bin target/debug/parkan-render-demo \
- --keep-going
- - name: Upload parity artifacts
- if: always()
- uses: actions/upload-artifact@v7
- with:
- name: render-parity-artifacts
- path: target/render-parity/current
- if-no-files-found: ignore
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
deleted file mode 100644
index d506622..0000000
--- a/.github/workflows/ci.yml
+++ /dev/null
@@ -1,159 +0,0 @@
-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.99.0
- - 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.99.0
- components: clippy,rustfmt
- - name: Provision macOS Vulkan runtime
- shell: bash
- run: |
- set -euo pipefail
- brew install molten-vk vulkan-loader vulkan-tools vulkan-validationlayers
- test "$(uname -m)" = "${{ matrix.runner_arch }}"
- ruby <<'RUBY'
- require "json"
-
- expected = {
- "molten-vk" => "${{ matrix.moltenvk_version }}",
- "vulkan-loader" => "${{ matrix.vulkan_sdk_version }}",
- "vulkan-tools" => "${{ matrix.vulkan_sdk_version }}",
- "vulkan-validationlayers" => "${{ matrix.vulkan_sdk_version }}",
- }
- payload = JSON.parse(`brew info --json=v2 #{expected.keys.join(" ")}`)
- actual = payload.fetch("formulae").to_h do |formula|
- [formula.fetch("name"), formula.fetch("versions").fetch("stable")]
- end
- mismatches = expected.each_with_object({}) do |(name, version), out|
- actual_version = actual[name]
- next if actual_version == version
-
- out[name] = {
- "expected" => version,
- "actual" => actual_version,
- }
- end
- unless mismatches.empty?
- warn JSON.pretty_generate(mismatches)
- abort "unexpected macOS Vulkan formula version"
- end
- RUBY
- HOMEBREW_PREFIX="$(brew --prefix)"
- VK_ICD_FILENAMES="$HOMEBREW_PREFIX/opt/molten-vk/etc/vulkan/icd.d/MoltenVK_icd.json"
- VK_LAYER_PATH="$HOMEBREW_PREFIX/opt/vulkan-validationlayers/share/vulkan/explicit_layer.d"
- DYLD_FALLBACK_LIBRARY_PATH="$HOMEBREW_PREFIX/opt/vulkan-loader/lib:$HOMEBREW_PREFIX/opt/molten-vk/lib"
- test -f "$VK_ICD_FILENAMES"
- test -d "$VK_LAYER_PATH"
- echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES" >> "$GITHUB_ENV"
- echo "VK_LAYER_PATH=$VK_LAYER_PATH" >> "$GITHUB_ENV"
- echo "DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH" >> "$GITHUB_ENV"
- - name: Install cargo-deny
- run: cargo install cargo-deny --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.99.0
- - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
- with:
- pattern: native-smoke-*
- path: target/fparkan/native-smoke-artifacts
- merge-multiple: true
- - name: Aggregate native smoke reports
- run: >
- cargo xtask native-smoke audit
- --dir target/fparkan/native-smoke-artifacts
diff --git a/.gitignore b/.gitignore
index 3391914..3aeec0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,214 +1,18 @@
-*~
-
-# temporary files which can be created if a process still has a handle open of a deleted file
-.fuse_hidden*
-
-# KDE directory preferences
-.directory
-
-# Linux trash folder which might appear on any partition or disk
-.Trash-*
-
-# .nfs files are created when an open file is removed but is still being accessed
-.nfs*
-
-# General
-.DS_Store
-.AppleDouble
-.LSOverride
-
-# Icon must end with two \r
-Icon
-
-# Thumbnails
-._*
-
-# Files that might appear in the root of a volume
-.DocumentRevisions-V100
-.fseventsd
-.Spotlight-V100
-.TemporaryItems
-.Trashes
-.VolumeIcon.icns
-.com.apple.timemachine.donotpresent
-
-# Directories potentially created on remote AFP share
-.AppleDB
-.AppleDesktop
-Network Trash Folder
-Temporary Items
-.apdisk
-
-# Windows thumbnail cache files
-Thumbs.db
-Thumbs.db:encryptable
-ehthumbs.db
-ehthumbs_vista.db
-
-# Dump file
-*.stackdump
-
-# Folder config file
-[Dd]esktop.ini
-
-# Recycle Bin used on file shares
-$RECYCLE.BIN/
-
-# Windows Installer files
-*.cab
-*.msi
-*.msix
-*.msm
-*.msp
-
-# Windows shortcuts
-*.lnk
-
-# Generated by Cargo
-# will have compiled files and executables
-debug/
-target/
-
-# These are backup files generated by rustfmt
+# Rust build output
+/target/
**/*.rs.bk
-
-# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
+Cargo.lock
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
-*$py.class
-
-# C extensions
-*.so
-
-# Distribution / packaging
-.Python
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-share/python-wheels/
-tmp/
-*.egg-info/
-.installed.cfg
-*.egg
-MANIFEST
-
-# PyInstaller
-*.manifest
-*.spec
-
-# Installer logs
-pip-log.txt
-pip-delete-this-directory.txt
-
-# Unit test / coverage reports
-htmlcov/
-.tox/
-.nox/
-.coverage
-.coverage.*
-.cache
-nosetests.xml
-coverage.xml
-*.cover
-*.py,cover
-.hypothesis/
-.pytest_cache/
-cover/
+# Local licensed game data and research output
+/testdata/*
+!/testdata/.gitignore
+/target-research/
+*.capture
+*.dump
-# Translations
-*.mo
-*.pot
-
-# Django stuff:
-*.log
-local_settings.py
-db.sqlite3
-db.sqlite3-journal
-
-# Flask stuff:
-instance/
-.webassets-cache
-
-# Scrapy stuff:
-.scrapy
-
-# Sphinx documentation
-docs/_build/
-
-# PyBuilder
-.pybuilder/
-target/
-
-# Jupyter Notebook
-.ipynb_checkpoints
-
-# IPython
-profile_default/
-ipython_config.py
-
-# pdm
-.pdm.toml
-
-# PEP 582
-__pypackages__/
-
-# Celery stuff
-celerybeat-schedule
-celerybeat.pid
-
-# SageMath parsed files
-*.sage.py
-
-# Environments
+# Local configuration and secrets
.env
-.venv
-env/
-venv/
-ENV/
-env.bak/
-venv.bak/
-
-# Spyder project settings
-.spyderproject
-.spyproject
-
-# Rope project settings
-.ropeproject
-
-# mkdocs documentation
-/site
-
-# mypy
-.mypy_cache/
-.dmypy.json
-dmypy.json
-
-# Pyre type checker
-.pyre/
-
-# pytype static type analyzer
-.pytype/
-
-# Cython debug symbols
-cython_debug/
-
-# Poetry local configuration file
-poetry.toml
-
-# ruff
-.ruff_cache/
-
-# LSP config files
-pyrightconfig.json
+.env.*
+*.pem
+*.key
diff --git a/.renovaterc b/.renovaterc
deleted file mode 100644
index bc629bb..0000000
--- a/.renovaterc
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "$schema": "https://docs.renovatebot.com/renovate-schema.json",
- "extends": [
- "config:recommended",
- ":disableDependencyDashboard"
- ],
- "assignees": [
- "valentineus"
- ],
- "labels": [
- "dependencies",
- "automated"
- ],
- "packageRules": [
- {
- "groupName": "all digest updates",
- "groupSlug": "all-digest",
- "matchUpdateTypes": [
- "minor",
- "patch",
- "pin",
- "digest"
- ],
- "matchPackageNames": [
- "*"
- ],
- "automerge": true
- }
- ]
-}
diff --git a/Cargo.lock b/Cargo.lock
deleted file mode 100644
index c2b8616..0000000
--- a/Cargo.lock
+++ /dev/null
@@ -1,1907 +0,0 @@
-# This file is automatically @generated by Cargo.
-# It is not intended for manual editing.
-version = 4
-
-[[package]]
-name = "adler2"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
-
-[[package]]
-name = "android-activity"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
-dependencies = [
- "android-properties",
- "bitflags 2.13.1",
- "cc",
- "jni",
- "libc",
- "log",
- "ndk",
- "ndk-context",
- "ndk-sys",
- "num_enum",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "android-properties"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
-
-[[package]]
-name = "ash"
-version = "0.38.0+1.3.281"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
-dependencies = [
- "libloading",
-]
-
-[[package]]
-name = "ash-window"
-version = "0.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82"
-dependencies = [
- "ash",
- "raw-window-handle",
- "raw-window-metal",
-]
-
-[[package]]
-name = "atomic-waker"
-version = "1.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
-
-[[package]]
-name = "bitflags"
-version = "1.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
-
-[[package]]
-name = "bitflags"
-version = "2.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
-
-[[package]]
-name = "block"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
-
-[[package]]
-name = "block2"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
-dependencies = [
- "objc2",
-]
-
-[[package]]
-name = "bumpalo"
-version = "3.20.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
-
-[[package]]
-name = "bytes"
-version = "1.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
-
-[[package]]
-name = "calloop"
-version = "0.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
-dependencies = [
- "bitflags 2.13.1",
- "log",
- "polling",
- "rustix 0.38.44",
- "slab",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "camino"
-version = "1.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f2d30e4173c4026932d51d31d6b0613b1fd3014bf3f9f8943d4ba139c437ba0"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
-name = "cargo-platform"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba"
-dependencies = [
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "cargo_metadata"
-version = "0.23.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9"
-dependencies = [
- "camino",
- "cargo-platform",
- "semver",
- "serde",
- "serde_json",
- "thiserror 2.0.18",
-]
-
-[[package]]
-name = "cc"
-version = "1.2.67"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
-dependencies = [
- "find-msvc-tools",
- "jobserver",
- "libc",
- "shlex",
-]
-
-[[package]]
-name = "cfg-if"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-
-[[package]]
-name = "cfg_aliases"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
-
-[[package]]
-name = "cocoa"
-version = "0.25.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c"
-dependencies = [
- "bitflags 1.3.2",
- "block",
- "cocoa-foundation",
- "core-foundation",
- "core-graphics",
- "foreign-types",
- "libc",
- "objc",
-]
-
-[[package]]
-name = "cocoa-foundation"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
-dependencies = [
- "bitflags 1.3.2",
- "block",
- "core-foundation",
- "core-graphics-types",
- "libc",
- "objc",
-]
-
-[[package]]
-name = "combine"
-version = "4.6.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
-dependencies = [
- "bytes",
- "memchr",
-]
-
-[[package]]
-name = "concurrent-queue"
-version = "2.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
-dependencies = [
- "crossbeam-utils",
-]
-
-[[package]]
-name = "core-foundation"
-version = "0.9.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
-[[package]]
-name = "core-foundation-sys"
-version = "0.8.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
-
-[[package]]
-name = "core-graphics"
-version = "0.23.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
-dependencies = [
- "bitflags 1.3.2",
- "core-foundation",
- "core-graphics-types",
- "foreign-types",
- "libc",
-]
-
-[[package]]
-name = "core-graphics-types"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
-dependencies = [
- "bitflags 1.3.2",
- "core-foundation",
- "libc",
-]
-
-[[package]]
-name = "core_detect"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48"
-
-[[package]]
-name = "crc32fast"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
-dependencies = [
- "cfg-if",
-]
-
-[[package]]
-name = "crossbeam-utils"
-version = "0.8.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
-
-[[package]]
-name = "cursor-icon"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
-
-[[package]]
-name = "dispatch"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
-
-[[package]]
-name = "dlib"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
-dependencies = [
- "libloading",
-]
-
-[[package]]
-name = "dpi"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
-
-[[package]]
-name = "encoding_rs"
-version = "0.8.42"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
-dependencies = [
- "cfg-if",
- "core_detect",
- "multiversion_no_op",
- "rustversion",
- "scopeguard",
- "simdutf8",
-]
-
-[[package]]
-name = "equivalent"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
-
-[[package]]
-name = "errno"
-version = "0.3.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
-dependencies = [
- "libc",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "find-msvc-tools"
-version = "0.1.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
-
-[[package]]
-name = "flate2"
-version = "1.1.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
-dependencies = [
- "crc32fast",
- "miniz_oxide",
-]
-
-[[package]]
-name = "foreign-types"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
-dependencies = [
- "foreign-types-macros",
- "foreign-types-shared",
-]
-
-[[package]]
-name = "foreign-types-macros"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.119",
-]
-
-[[package]]
-name = "foreign-types-shared"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
-
-[[package]]
-name = "fparkan-animation"
-version = "0.1.0"
-
-[[package]]
-name = "fparkan-assets"
-version = "0.1.0"
-dependencies = [
- "fparkan-material",
- "fparkan-mission-format",
- "fparkan-msh",
- "fparkan-nres",
- "fparkan-path",
- "fparkan-prototype",
- "fparkan-resource",
- "fparkan-terrain",
- "fparkan-terrain-format",
- "fparkan-texm",
- "fparkan-vfs",
-]
-
-[[package]]
-name = "fparkan-binary"
-version = "0.1.0"
-
-[[package]]
-name = "fparkan-cli"
-version = "0.1.0"
-dependencies = [
- "fparkan-assets",
- "fparkan-corpus",
- "fparkan-inspection",
- "fparkan-path",
- "fparkan-prototype",
- "fparkan-resource",
- "fparkan-runtime",
- "fparkan-script",
- "fparkan-vfs",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "fparkan-corpus"
-version = "0.1.0"
-dependencies = [
- "fparkan-binary",
- "fparkan-fx",
- "fparkan-material",
- "fparkan-mission-format",
- "fparkan-msh",
- "fparkan-nres",
- "fparkan-path",
- "fparkan-prototype",
- "fparkan-rsli",
- "fparkan-terrain-format",
- "fparkan-texm",
-]
-
-[[package]]
-name = "fparkan-diagnostics"
-version = "0.1.0"
-dependencies = [
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "fparkan-fx"
-version = "0.1.0"
-dependencies = [
- "fparkan-binary",
- "fparkan-nres",
-]
-
-[[package]]
-name = "fparkan-game"
-version = "0.1.0"
-dependencies = [
- "fparkan-assets",
- "fparkan-inspection",
- "fparkan-path",
- "fparkan-platform",
- "fparkan-platform-winit",
- "fparkan-render",
- "fparkan-render-vulkan",
- "fparkan-runtime",
- "fparkan-terrain",
- "fparkan-vfs",
- "fparkan-world",
- "serde",
- "serde_json",
- "winit",
-]
-
-[[package]]
-name = "fparkan-headless"
-version = "0.1.0"
-dependencies = [
- "fparkan-runtime",
- "fparkan-vfs",
- "fparkan-world",
-]
-
-[[package]]
-name = "fparkan-inspection"
-version = "0.1.0"
-dependencies = [
- "fparkan-diagnostics",
- "fparkan-material",
- "fparkan-msh",
- "fparkan-nres",
- "fparkan-path",
- "fparkan-resource",
- "fparkan-rsli",
- "fparkan-terrain-format",
- "fparkan-texm",
- "fparkan-vfs",
-]
-
-[[package]]
-name = "fparkan-material"
-version = "0.1.0"
-dependencies = [
- "encoding_rs",
- "fparkan-nres",
- "fparkan-path",
- "fparkan-resource",
- "fparkan-vfs",
-]
-
-[[package]]
-name = "fparkan-mission-format"
-version = "0.1.0"
-dependencies = [
- "encoding_rs",
- "fparkan-binary",
-]
-
-[[package]]
-name = "fparkan-msh"
-version = "0.1.0"
-dependencies = [
- "encoding_rs",
- "fparkan-animation",
- "fparkan-nres",
-]
-
-[[package]]
-name = "fparkan-nres"
-version = "0.1.0"
-dependencies = [
- "fparkan-binary",
- "fparkan-path",
-]
-
-[[package]]
-name = "fparkan-path"
-version = "0.1.0"
-
-[[package]]
-name = "fparkan-platform"
-version = "0.1.0"
-dependencies = [
- "raw-window-handle",
-]
-
-[[package]]
-name = "fparkan-platform-winit"
-version = "0.1.0"
-dependencies = [
- "fparkan-platform",
- "raw-window-handle",
- "winit",
-]
-
-[[package]]
-name = "fparkan-prototype"
-version = "0.1.0"
-dependencies = [
- "encoding_rs",
- "fparkan-binary",
- "fparkan-nres",
- "fparkan-path",
- "fparkan-resource",
- "fparkan-vfs",
-]
-
-[[package]]
-name = "fparkan-render"
-version = "0.1.0"
-dependencies = [
- "fparkan-world",
-]
-
-[[package]]
-name = "fparkan-render-vulkan"
-version = "0.1.0"
-dependencies = [
- "ash",
- "ash-window",
- "fparkan-animation",
- "fparkan-binary",
- "fparkan-msh",
- "fparkan-platform",
- "fparkan-render",
- "fparkan-terrain-format",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "fparkan-resource"
-version = "0.1.0"
-dependencies = [
- "fparkan-binary",
- "fparkan-nres",
- "fparkan-path",
- "fparkan-rsli",
- "fparkan-vfs",
-]
-
-[[package]]
-name = "fparkan-rsli"
-version = "0.1.0"
-dependencies = [
- "flate2",
- "fparkan-binary",
-]
-
-[[package]]
-name = "fparkan-runtime"
-version = "0.1.0"
-dependencies = [
- "fparkan-assets",
- "fparkan-path",
- "fparkan-platform",
- "fparkan-prototype",
- "fparkan-render",
- "fparkan-resource",
- "fparkan-script",
- "fparkan-terrain",
- "fparkan-vfs",
- "fparkan-world",
-]
-
-[[package]]
-name = "fparkan-script"
-version = "0.1.0"
-dependencies = [
- "fparkan-binary",
-]
-
-[[package]]
-name = "fparkan-terrain"
-version = "0.1.0"
-dependencies = [
- "fparkan-nres",
- "fparkan-terrain-format",
-]
-
-[[package]]
-name = "fparkan-terrain-format"
-version = "0.1.0"
-dependencies = [
- "fparkan-binary",
- "fparkan-nres",
-]
-
-[[package]]
-name = "fparkan-test-support"
-version = "0.1.0"
-dependencies = [
- "fparkan-render",
-]
-
-[[package]]
-name = "fparkan-texm"
-version = "0.1.0"
-dependencies = [
- "fparkan-nres",
-]
-
-[[package]]
-name = "fparkan-vfs"
-version = "0.1.0"
-dependencies = [
- "fparkan-binary",
- "fparkan-path",
-]
-
-[[package]]
-name = "fparkan-viewer"
-version = "0.1.0"
-dependencies = [
- "fparkan-inspection",
- "fparkan-render",
-]
-
-[[package]]
-name = "fparkan-vulkan-smoke"
-version = "0.1.0"
-dependencies = [
- "fparkan-inspection",
- "fparkan-platform",
- "fparkan-platform-winit",
- "fparkan-render-vulkan",
- "serde",
- "serde_json",
- "winit",
-]
-
-[[package]]
-name = "fparkan-world"
-version = "0.1.0"
-dependencies = [
- "fparkan-binary",
-]
-
-[[package]]
-name = "futures-core"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
-
-[[package]]
-name = "futures-task"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
-
-[[package]]
-name = "futures-util"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
-dependencies = [
- "futures-core",
- "futures-task",
- "pin-project-lite",
- "slab",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
-dependencies = [
- "cfg-if",
- "libc",
- "r-efi",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.17.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
-
-[[package]]
-name = "hermit-abi"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
-
-[[package]]
-name = "indexmap"
-version = "2.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
-dependencies = [
- "equivalent",
- "hashbrown",
-]
-
-[[package]]
-name = "itoa"
-version = "1.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
-
-[[package]]
-name = "jni"
-version = "0.22.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
-dependencies = [
- "cfg-if",
- "combine",
- "jni-macros",
- "jni-sys 0.4.1",
- "log",
- "simd_cesu8",
- "thiserror 2.0.18",
- "walkdir",
- "windows-link",
-]
-
-[[package]]
-name = "jni-macros"
-version = "0.22.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
-dependencies = [
- "proc-macro2",
- "quote",
- "rustc_version",
- "simd_cesu8",
- "syn 2.0.119",
-]
-
-[[package]]
-name = "jni-sys"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
-dependencies = [
- "jni-sys 0.4.1",
-]
-
-[[package]]
-name = "jni-sys"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
-dependencies = [
- "jni-sys-macros",
-]
-
-[[package]]
-name = "jni-sys-macros"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
-dependencies = [
- "quote",
- "syn 2.0.119",
-]
-
-[[package]]
-name = "jobserver"
-version = "0.1.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
-dependencies = [
- "getrandom",
- "libc",
-]
-
-[[package]]
-name = "js-sys"
-version = "0.3.103"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
-dependencies = [
- "cfg-if",
- "futures-util",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "libc"
-version = "0.2.186"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
-
-[[package]]
-name = "libloading"
-version = "0.8.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
-dependencies = [
- "cfg-if",
- "windows-link",
-]
-
-[[package]]
-name = "libredox"
-version = "0.1.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652"
-dependencies = [
- "bitflags 2.13.1",
- "libc",
- "plain",
- "redox_syscall 0.9.0",
-]
-
-[[package]]
-name = "linux-raw-sys"
-version = "0.4.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
-
-[[package]]
-name = "linux-raw-sys"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
-
-[[package]]
-name = "log"
-version = "0.4.33"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
-
-[[package]]
-name = "malloc_buf"
-version = "0.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "memchr"
-version = "2.8.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
-
-[[package]]
-name = "miniz_oxide"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
-dependencies = [
- "adler2",
- "simd-adler32",
-]
-
-[[package]]
-name = "multiversion_no_op"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d"
-
-[[package]]
-name = "ndk"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
-dependencies = [
- "bitflags 2.13.1",
- "jni-sys 0.3.1",
- "log",
- "ndk-sys",
- "num_enum",
- "raw-window-handle",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "ndk-context"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
-
-[[package]]
-name = "ndk-sys"
-version = "0.6.0+11769913"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
-dependencies = [
- "jni-sys 0.3.1",
-]
-
-[[package]]
-name = "num_enum"
-version = "0.7.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
-dependencies = [
- "num_enum_derive",
- "rustversion",
-]
-
-[[package]]
-name = "num_enum_derive"
-version = "0.7.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
-dependencies = [
- "proc-macro-crate",
- "proc-macro2",
- "quote",
- "syn 2.0.119",
-]
-
-[[package]]
-name = "objc"
-version = "0.2.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
-dependencies = [
- "malloc_buf",
-]
-
-[[package]]
-name = "objc-sys"
-version = "0.3.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
-
-[[package]]
-name = "objc2"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
-dependencies = [
- "objc-sys",
- "objc2-encode",
-]
-
-[[package]]
-name = "objc2-app-kit"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
-dependencies = [
- "bitflags 2.13.1",
- "block2",
- "libc",
- "objc2",
- "objc2-core-data",
- "objc2-core-image",
- "objc2-foundation",
- "objc2-quartz-core",
-]
-
-[[package]]
-name = "objc2-cloud-kit"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
-dependencies = [
- "bitflags 2.13.1",
- "block2",
- "objc2",
- "objc2-core-location",
- "objc2-foundation",
-]
-
-[[package]]
-name = "objc2-contacts"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
-dependencies = [
- "block2",
- "objc2",
- "objc2-foundation",
-]
-
-[[package]]
-name = "objc2-core-data"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
-dependencies = [
- "bitflags 2.13.1",
- "block2",
- "objc2",
- "objc2-foundation",
-]
-
-[[package]]
-name = "objc2-core-image"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
-dependencies = [
- "block2",
- "objc2",
- "objc2-foundation",
- "objc2-metal",
-]
-
-[[package]]
-name = "objc2-core-location"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
-dependencies = [
- "block2",
- "objc2",
- "objc2-contacts",
- "objc2-foundation",
-]
-
-[[package]]
-name = "objc2-encode"
-version = "4.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
-
-[[package]]
-name = "objc2-foundation"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
-dependencies = [
- "bitflags 2.13.1",
- "block2",
- "dispatch",
- "libc",
- "objc2",
-]
-
-[[package]]
-name = "objc2-link-presentation"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
-dependencies = [
- "block2",
- "objc2",
- "objc2-app-kit",
- "objc2-foundation",
-]
-
-[[package]]
-name = "objc2-metal"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
-dependencies = [
- "bitflags 2.13.1",
- "block2",
- "objc2",
- "objc2-foundation",
-]
-
-[[package]]
-name = "objc2-quartz-core"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
-dependencies = [
- "bitflags 2.13.1",
- "block2",
- "objc2",
- "objc2-foundation",
- "objc2-metal",
-]
-
-[[package]]
-name = "objc2-symbols"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
-dependencies = [
- "objc2",
- "objc2-foundation",
-]
-
-[[package]]
-name = "objc2-ui-kit"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
-dependencies = [
- "bitflags 2.13.1",
- "block2",
- "objc2",
- "objc2-cloud-kit",
- "objc2-core-data",
- "objc2-core-image",
- "objc2-core-location",
- "objc2-foundation",
- "objc2-link-presentation",
- "objc2-quartz-core",
- "objc2-symbols",
- "objc2-uniform-type-identifiers",
- "objc2-user-notifications",
-]
-
-[[package]]
-name = "objc2-uniform-type-identifiers"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
-dependencies = [
- "block2",
- "objc2",
- "objc2-foundation",
-]
-
-[[package]]
-name = "objc2-user-notifications"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
-dependencies = [
- "bitflags 2.13.1",
- "block2",
- "objc2",
- "objc2-core-location",
- "objc2-foundation",
-]
-
-[[package]]
-name = "once_cell"
-version = "1.21.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
-
-[[package]]
-name = "orbclient"
-version = "0.3.55"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747"
-dependencies = [
- "libc",
- "libredox",
-]
-
-[[package]]
-name = "pin-project"
-version = "1.1.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
-dependencies = [
- "pin-project-internal",
-]
-
-[[package]]
-name = "pin-project-internal"
-version = "1.1.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.119",
-]
-
-[[package]]
-name = "pin-project-lite"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
-
-[[package]]
-name = "plain"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
-
-[[package]]
-name = "polling"
-version = "3.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
-dependencies = [
- "cfg-if",
- "concurrent-queue",
- "hermit-abi",
- "pin-project-lite",
- "rustix 1.1.4",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "proc-macro-crate"
-version = "3.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
-dependencies = [
- "toml_edit",
-]
-
-[[package]]
-name = "proc-macro2"
-version = "1.0.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "quote"
-version = "1.0.46"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
-dependencies = [
- "proc-macro2",
-]
-
-[[package]]
-name = "r-efi"
-version = "6.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
-
-[[package]]
-name = "raw-window-handle"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
-
-[[package]]
-name = "raw-window-metal"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1"
-dependencies = [
- "cocoa",
- "core-graphics",
- "objc",
- "raw-window-handle",
-]
-
-[[package]]
-name = "redox_syscall"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
-dependencies = [
- "bitflags 1.3.2",
-]
-
-[[package]]
-name = "redox_syscall"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759"
-dependencies = [
- "bitflags 2.13.1",
-]
-
-[[package]]
-name = "rustc_version"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
-dependencies = [
- "semver",
-]
-
-[[package]]
-name = "rustix"
-version = "0.38.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
-dependencies = [
- "bitflags 2.13.1",
- "errno",
- "libc",
- "linux-raw-sys 0.4.15",
- "windows-sys 0.59.0",
-]
-
-[[package]]
-name = "rustix"
-version = "1.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
-dependencies = [
- "bitflags 2.13.1",
- "errno",
- "libc",
- "linux-raw-sys 0.12.1",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "rustversion"
-version = "1.0.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
-
-[[package]]
-name = "same-file"
-version = "1.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
-dependencies = [
- "winapi-util",
-]
-
-[[package]]
-name = "scopeguard"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
-
-[[package]]
-name = "semver"
-version = "1.0.28"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
-dependencies = [
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "serde"
-version = "1.0.229"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
-dependencies = [
- "serde_core",
- "serde_derive",
-]
-
-[[package]]
-name = "serde_core"
-version = "1.0.229"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
-dependencies = [
- "serde_derive",
-]
-
-[[package]]
-name = "serde_derive"
-version = "1.0.229"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 3.0.0",
-]
-
-[[package]]
-name = "serde_json"
-version = "1.0.152"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1741ab7a6cc54a03a89b5d563ed60075c277d9e3cfa73ad0c1f23f23974703c6"
-dependencies = [
- "itoa",
- "memchr",
- "serde",
- "serde_core",
- "zmij",
-]
-
-[[package]]
-name = "serde_spanned"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
-name = "shlex"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
-
-[[package]]
-name = "simd-adler32"
-version = "0.3.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
-
-[[package]]
-name = "simd_cesu8"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
-dependencies = [
- "rustc_version",
- "simdutf8",
-]
-
-[[package]]
-name = "simdutf8"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
-
-[[package]]
-name = "slab"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
-
-[[package]]
-name = "smol_str"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
-dependencies = [
- "serde",
-]
-
-[[package]]
-name = "syn"
-version = "2.0.119"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "syn"
-version = "3.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "thiserror"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
-dependencies = [
- "thiserror-impl 1.0.69",
-]
-
-[[package]]
-name = "thiserror"
-version = "2.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
-dependencies = [
- "thiserror-impl 2.0.18",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.119",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "2.0.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.119",
-]
-
-[[package]]
-name = "toml"
-version = "0.9.12+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
-dependencies = [
- "indexmap",
- "serde_core",
- "serde_spanned",
- "toml_datetime 0.7.5+spec-1.1.0",
- "toml_parser",
- "toml_writer",
- "winnow 0.7.15",
-]
-
-[[package]]
-name = "toml_datetime"
-version = "0.7.5+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
-name = "toml_datetime"
-version = "1.1.1+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
-dependencies = [
- "serde_core",
-]
-
-[[package]]
-name = "toml_edit"
-version = "0.25.13+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b"
-dependencies = [
- "indexmap",
- "toml_datetime 1.1.1+spec-1.1.0",
- "toml_parser",
- "winnow 1.0.4",
-]
-
-[[package]]
-name = "toml_parser"
-version = "1.1.2+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
-dependencies = [
- "winnow 1.0.4",
-]
-
-[[package]]
-name = "toml_writer"
-version = "1.1.2+spec-1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
-
-[[package]]
-name = "tracing"
-version = "0.1.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
-dependencies = [
- "pin-project-lite",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-core"
-version = "0.1.36"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
-
-[[package]]
-name = "unicode-ident"
-version = "1.0.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
-
-[[package]]
-name = "unicode-segmentation"
-version = "1.13.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
-
-[[package]]
-name = "walkdir"
-version = "2.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
-dependencies = [
- "same-file",
- "winapi-util",
-]
-
-[[package]]
-name = "wasm-bindgen"
-version = "0.2.126"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
-dependencies = [
- "cfg-if",
- "once_cell",
- "rustversion",
- "wasm-bindgen-macro",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-futures"
-version = "0.4.76"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "wasm-bindgen-macro"
-version = "0.2.126"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
-dependencies = [
- "quote",
- "wasm-bindgen-macro-support",
-]
-
-[[package]]
-name = "wasm-bindgen-macro-support"
-version = "0.2.126"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
-dependencies = [
- "bumpalo",
- "proc-macro2",
- "quote",
- "syn 2.0.119",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-shared"
-version = "0.2.126"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "web-sys"
-version = "0.3.103"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "web-time"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "winapi-util"
-version = "0.1.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
-dependencies = [
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "windows-link"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
-
-[[package]]
-name = "windows-sys"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
-dependencies = [
- "windows-targets",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.59.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
-dependencies = [
- "windows-targets",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.61.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
-dependencies = [
- "windows_aarch64_gnullvm",
- "windows_aarch64_msvc",
- "windows_i686_gnu",
- "windows_i686_gnullvm",
- "windows_i686_msvc",
- "windows_x86_64_gnu",
- "windows_x86_64_gnullvm",
- "windows_x86_64_msvc",
-]
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-
-[[package]]
-name = "winit"
-version = "0.30.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
-dependencies = [
- "android-activity",
- "atomic-waker",
- "bitflags 2.13.1",
- "block2",
- "calloop",
- "cfg_aliases",
- "concurrent-queue",
- "core-foundation",
- "core-graphics",
- "cursor-icon",
- "dpi",
- "js-sys",
- "libc",
- "ndk",
- "objc2",
- "objc2-app-kit",
- "objc2-foundation",
- "objc2-ui-kit",
- "orbclient",
- "pin-project",
- "raw-window-handle",
- "redox_syscall 0.4.1",
- "rustix 0.38.44",
- "smol_str",
- "tracing",
- "unicode-segmentation",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
- "web-time",
- "windows-sys 0.52.0",
- "xkbcommon-dl",
-]
-
-[[package]]
-name = "winnow"
-version = "0.7.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
-
-[[package]]
-name = "winnow"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "xkbcommon-dl"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
-dependencies = [
- "bitflags 2.13.1",
- "dlib",
- "log",
- "once_cell",
- "xkeysym",
-]
-
-[[package]]
-name = "xkeysym"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
-
-[[package]]
-name = "xtask"
-version = "0.1.0"
-dependencies = [
- "cargo_metadata",
- "fparkan-binary",
- "fparkan-corpus",
- "serde",
- "serde_json",
- "toml",
-]
-
-[[package]]
-name = "zmij"
-version = "1.0.23"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
index 5e9d546..847b23c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,10 +4,7 @@ members = [
"crates/fparkan-animation",
"crates/fparkan-assets",
"crates/fparkan-binary",
- "crates/fparkan-corpus",
- "crates/fparkan-diagnostics",
"crates/fparkan-fx",
- "crates/fparkan-inspection",
"crates/fparkan-material",
"crates/fparkan-mission-format",
"crates/fparkan-msh",
@@ -22,46 +19,20 @@ members = [
"crates/fparkan-runtime",
"crates/fparkan-terrain",
"crates/fparkan-terrain-format",
- "crates/fparkan-test-support",
"crates/fparkan-texm",
"crates/fparkan-vfs",
"crates/fparkan-world",
"adapters/fparkan-platform-winit",
"adapters/fparkan-render-vulkan",
- "apps/fparkan-cli",
"apps/fparkan-game",
- "apps/fparkan-headless",
- "apps/fparkan-vulkan-smoke",
- "apps/fparkan-viewer",
- "xtask",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
-rust-version = "1.97"
license = "GPL-2.0-only"
repository = "https://github.com/valentineus/fparkan"
-[workspace.lints.rust]
-unsafe_code = "forbid"
-missing_docs = "warn"
-unreachable_pub = "warn"
-unused_must_use = "deny"
-
-[workspace.lints.clippy]
-all = { level = "deny", priority = -1 }
-pedantic = { level = "warn", priority = -1 }
-unwrap_used = "deny"
-expect_used = "deny"
-panic = "deny"
-todo = "deny"
-unimplemented = "deny"
-dbg_macro = "deny"
-print_stdout = "warn"
-print_stderr = "warn"
-lossy_float_literal = "deny"
-
[profile.release]
codegen-units = 1
lto = true
diff --git a/README.md b/README.md
index a306772..1ec8d08 100644
--- a/README.md
+++ b/README.md
@@ -1,118 +1,93 @@
# FParkan
-Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»**.
-
-## Описание
-
-Проект находится в активной разработке и включает:
-
-- библиотеки для работы с форматами игровых архивов;
-- спецификации форматов и сопутствующую документацию.
-
-## Установка
-
-Проект находится в начальной стадии, подробная инструкция по установке пока отсутствует.
-
-## Документация
-
-- локально: каталог [`docs/`](docs)
-- сайт: <https://fparkan.popov.link>
-
-## Библиотеки
-
-- [crates/fparkan-nres](crates/fparkan-nres) — strict/lossless модель архивов NRes.
-- [crates/fparkan-rsli](crates/fparkan-rsli) — чтение, lookup и lossless roundtrip архивов RsLi.
-- [crates/fparkan-msh](crates/fparkan-msh) — validated static MSH geometry.
-- [crates/fparkan-runtime](crates/fparkan-runtime) — transactional mission loading и headless runtime foundation.
-- [apps/fparkan-cli](apps/fparkan-cli) — CLI для архивов, графов и acceptance-отчетов.
-- [apps/fparkan-viewer](apps/fparkan-viewer) — inspection-only CLI для archive/model/texture/map без live Vulkan draw path.
-- [apps/fparkan-headless](apps/fparkan-headless) — headless runtime composition root.
-- [apps/fparkan-game](apps/fparkan-game) — mission composition root: по умолчанию выдаёт planning report; opt-in `--backend static-vulkan` открывает native Vulkan окно и рисует terrain и все подготовленные MSH-компоненты выбранных mission roots. По умолчанию выбираются все roots; `--preview-roots N` оставляет bounded diagnostic scope.
-
-## Текущий статус рендера
-
-- `fparkan-vulkan-smoke` доказывает живой Stage 0 Vulkan triangle path с native window, swapchain и validation telemetry.
-- `VulkanPlanningBackend` и default-режим `fparkan-game` подтверждают только deterministic command planning/capture, а не draw пикселей. `fparkan-game --backend static-vulkan` — узкий mission-to-native-Vulkan bridge: он объединяет terrain и подготовленные MSH-компоненты всех выбранных roots, применяет static TMA transforms и загружает первую MAT0 diffuse texture на каждый используемый selector с preview-local remap. Legacy D3D7 camera capture поддерживается; полный corpus, material phases, dynamic ownership/visibility и pixel parity ещё не подтверждены.
-- `fparkan-viewer` пока является инспектором ассетов. `fparkan-vulkan-smoke` имеет live Stage 3 bridge для original `MSH`/`Texm`/`WEAR`/`MAT0` и geometry-only `Land.msh`; полноценный viewer, исходные terrain-material states, camera и pixel parity ещё не закрыты.
-- Truth table и evidence-артефакты вынесены в [`docs/rendering/renderer_truth_table.md`](docs/rendering/renderer_truth_table.md) и [`docs/evidence/`](docs/evidence).
-
-## Тестирование
-
-Базовое тестирование проходит на синтетических тестах из репозитория:
-
-```bash
-cargo xtask ci
-```
-
-Для дополнительного тестирования на реальных игровых ресурсах:
-
-- используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy));
-- разместите игровые каталоги в [`testdata/`](testdata);
-- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
-
-Локальный licensed gate использует некоммитимый manifest:
-
-```bash
-cat > /private/tmp/fparkan-corpora.toml <<'EOF'
-schema = 1
-
-[[corpus]]
-id = "part1-local"
-kind = "part1"
-root = "/absolute/path/to/IS"
-expected_profile = "parkan-is-part1"
-
-[[corpus]]
-id = "part2-local"
-kind = "part2"
-root = "/absolute/path/to/IS2"
-expected_profile = "parkan-is-part2"
-EOF
-
-FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \
- cargo xtask acceptance report --suite licensed --stage 5
+Открытый движок *Паркан: Железная стратегия* на Rust и Vulkan. Он читает
+ресурсы установленной оригинальной игры. Сейчас приложение показывает
+интерактивную сцену выбранной карты: terrain, все доступные MSH-компоненты
+объектов, материалы, динамические тени, небо, погоду, lightning FX и звуковые
+события. Это просмотр карты с полной графической сценой; прохождение миссии и
+игровое поведение юнитов пока не запускаются, а модели показываются в статической
+позе, заданной сохранёнными настройками узлов прототипа.
+
+## Запуск
+
+Нужны Rust и драйвер видеокарты с поддержкой Vulkan. Игровые файлы в
+репозиторий не входят.
+
+```powershell
+cargo run --release -p fparkan-game -- `
+ --root 'C:\GOG Games\Parkan - Iron Strategy' `
+ --mission 'MISSIONS\Autodemo.00\data.tma'
```
-## 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
+Окно работает в интерактивном режиме до закрытия и по умолчанию загружает все
+корневые объекты миссии (`--preview-roots N` ограничивает их число только для
+диагностики).
+`WASD` перемещают камеру относительно её направления, `E/Q` поднимают и
+опускают её, `Shift` ускоряет, `Ctrl` замедляет движение. Удержание правой
+кнопки мыши включает relative-look; движение мыши вращает yaw/pitch. При
+потере фокуса обзор отключается и нажатые клавиши сбрасываются. `Esc`
+отпускает обзор или закрывает окно.
+
+В каталоге миссии ищется `sky.ske`. Если файл найден, приложение читает её
+расписание и `sky.wea`; связанные MAT0/WEAR-материалы и
+`effects.rlb/env_lightning` загружаются из ресурсов игры. Без `sky.ske`
+окружение отключено; если файл есть, отсутствие `sky.wea`, `env_lightning` или
+связанного материала считается ошибкой. Параметр `--atmosphere-seconds N`
+задаёт начальное время; по умолчанию используется начало расписания. Когда
+окружение загружено, небо, солнце и
+луна, flares, молнии, дождь и снег, point lights, динамические тени и связанные
+звуковые события обновляются в каждом кадре.
+
+`--frames N` ограничивает запуск числом кадров для smoke/readback-проверок;
+значение `0` означает бесконечный интерактивный запуск. `--legacy-camera-capture`
+читает сохранённый захват камеры и запускает воспроизводимый режим без
+free-flight управления. `--static-animation-frame <u16>` задаёт один кадр для
+всех узлов. Если параметр не указан, приложение берёт сохранённые настройки
+узлов из явной ссылки прототипа на `.ctl`; для узлов без такой настройки
+используется кадр `0`. Статическая поза не запускает игровой Control, AI или
+полное поведение юнитов.
+`--static-material-phase <u16>` выбирает фиксированную фазу материала.
+`--readback-out target/frame.raw` сохраняет последний кадр GPU. `--validation`
+включает Vulkan validation layers и завершает запуск с ошибкой при предупреждении
+или ошибке. Readback сохраняет байты в формате surface без преобразования;
+размер — `width × height × 4`. Порядок каналов и encoding определяются значением
+`readback_format` в выводе; на текущем GPU обычно используется
+`VK_FORMAT_R8G8B8A8_UNORM`.
+
+## Разработка
+
+```text
+cargo fmt --all -- --check
+cargo test --workspace
+cargo check --workspace
```
-Поддерживается только Windows. Перед запуском убедитесь, что установлен Vulkan runtime
-от GPU vendor или LunarG Vulkan SDK и активный runtime предоставляет
-`VK_LAYER_KHRONOS_validation`. Linux, macOS/MoltenVK и их smoke-пути не входят в
-проектный scope.
-
-Для полного локального closure gate используйте:
-
-```bash
-cargo xtask ci
+Алгоритмы и форматы проверяются обычными Rust-тестами рядом с кодом.
+Тесты с оригинальными ресурсами помечены `#[ignore]`; пути к двум частям
+установки задаются через `FPARKAN_CORPUS_PART1_ROOT` и
+`FPARKAN_CORPUS_PART2_ROOT`. Зависимости разрешает Cargo, локальный
+`Cargo.lock` не хранится в Git.
+
+Готовые SPIR-V шейдеры находятся рядом с исходниками GLSL.
+Vulkan SDK нужен только для их пересборки и проверки с `--validation`.
+При доступных в PATH инструментах SDK:
+
+```powershell
+glslangValidator -V adapters/fparkan-render-vulkan/shaders/triangle.vert -o adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
+glslangValidator -V adapters/fparkan-render-vulkan/shaders/triangle.frag -o adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
+spirv-val adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
+spirv-val adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
```
-Windows native smoke — единственный platform acceptance path. Вопросы hosted CI
-и других операционных систем намеренно находятся вне scope разработки.
-
-## Contributing & Support
-
-Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
+## Книга и исходники
-- **Primary development**: [valentineus/fparkan](https://code.popov.link/valentineus/fparkan)
-- **GitHub mirror**: [valentineus/fparkan](https://github.com/valentineus/fparkan)
+[Книга об устройстве движка](docs/index.md) объясняет форматы, математику,
+загрузку мира и связь подсистем. Её можно читать прямо в репозитории;
+`mkdocs.yml` задаёт представление на [сайте проекта](https://fparkan.popov.link).
-Основная разработка ведётся в self-hosted репозитории.
+`crates/` содержит форматы и игровые алгоритмы, `adapters/` — окно и Vulkan,
+`apps/fparkan-game` — исполняемое приложение.
## Лицензия
-Проект распространяется под лицензией **[GNU GPL v2](LICENSE.txt)**.
+[GNU GPL v2](LICENSE.txt).
diff --git a/adapters/fparkan-platform-winit/Cargo.toml b/adapters/fparkan-platform-winit/Cargo.toml
index 10f89cb..3a6e591 100644
--- a/adapters/fparkan-platform-winit/Cargo.toml
+++ b/adapters/fparkan-platform-winit/Cargo.toml
@@ -6,9 +6,6 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
+fparkan-platform = { path = "../../crates/fparkan-platform" }
raw-window-handle = "0.6"
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
-
-[lints]
-workspace = true
diff --git a/adapters/fparkan-platform-winit/src/lib.rs b/adapters/fparkan-platform-winit/src/lib.rs
index 19de207..bb06eca 100644
--- a/adapters/fparkan-platform-winit/src/lib.rs
+++ b/adapters/fparkan-platform-winit/src/lib.rs
@@ -1,23 +1,4 @@
#![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::{
diff --git a/adapters/fparkan-render-vulkan/Cargo.toml b/adapters/fparkan-render-vulkan/Cargo.toml
index abd0928..eeaa46b 100644
--- a/adapters/fparkan-render-vulkan/Cargo.toml
+++ b/adapters/fparkan-render-vulkan/Cargo.toml
@@ -4,35 +4,13 @@ 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"
+fparkan-animation = { path = "../../crates/fparkan-animation" }
+fparkan-binary = { path = "../../crates/fparkan-binary" }
+fparkan-msh = { path = "../../crates/fparkan-msh" }
+fparkan-platform = { path = "../../crates/fparkan-platform" }
+fparkan-render = { path = "../../crates/fparkan-render" }
+fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
diff --git a/adapters/fparkan-render-vulkan/build.rs b/adapters/fparkan-render-vulkan/build.rs
deleted file mode 100644
index d66c022..0000000
--- a/adapters/fparkan-render-vulkan/build.rs
+++ /dev/null
@@ -1,92 +0,0 @@
-//! 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)
-}
diff --git a/adapters/fparkan-render-vulkan/shaders/manifest.json b/adapters/fparkan-render-vulkan/shaders/manifest.json
deleted file mode 100644
index 89df3ce..0000000
--- a/adapters/fparkan-render-vulkan/shaders/manifest.json
+++ /dev/null
@@ -1 +0,0 @@
-{"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"}
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag b/adapters/fparkan-render-vulkan/shaders/triangle.frag
index bcc3d43..89896cf 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag
@@ -2,18 +2,132 @@
layout(location = 0) in vec3 in_color;
layout(location = 1) in vec2 in_uv;
+layout(location = 2) in vec2 in_detail_uv;
+layout(location = 3) in float in_overlay_alpha;
+layout(location = 4) in float in_fog_factor;
+layout(location = 5) in vec3 in_base_diffuse;
+layout(location = 6) in vec3 in_base_specular;
+layout(location = 7) in vec3 in_overlay_diffuse;
+layout(location = 8) in vec3 in_overlay_specular;
layout(location = 0) out vec4 out_color;
layout(set = 0, binding = 0) uniform sampler2D base_color;
+layout(set = 0, binding = 1) uniform sampler2D base_detail;
+layout(set = 0, binding = 2) uniform sampler2D overlay_color;
+layout(set = 0, binding = 3) uniform sampler2D overlay_detail;
-layout(push_constant) uniform AlphaTestConstants {
- layout(offset = 64)
- float alpha_cutoff;
-} alpha_test;
+layout(std140, set = 0, binding = 4) uniform FrameConstants {
+ mat4 clip_from_world;
+ vec4 directional_direction[4];
+ vec4 directional_rgb[4];
+ vec4 directional_coefficients[4];
+ vec4 point_position_range;
+ vec4 point_rgb_active;
+ vec4 point_coefficients_active;
+ vec4 lighting_floor;
+ vec4 fog_color;
+ vec4 fog_distances;
+ vec4 camera_position;
+} frame;
+
+layout(push_constant) uniform MaterialConstants {
+ layout(offset = 0) float alpha_cutoff;
+ layout(offset = 4) float diffuse_alpha;
+ layout(offset = 8) float overlay_diffuse_alpha;
+ layout(offset = 12) float directional_r;
+ layout(offset = 16) float directional_g;
+ layout(offset = 20) float directional_b;
+ layout(offset = 24) float additive_r;
+ layout(offset = 28) float additive_g;
+ layout(offset = 32) float additive_b;
+ layout(offset = 36) float overlay_directional_r;
+ layout(offset = 40) float overlay_directional_g;
+ layout(offset = 44) float overlay_directional_b;
+ layout(offset = 48) float overlay_additive_r;
+ layout(offset = 52) float overlay_additive_g;
+ layout(offset = 56) float overlay_additive_b;
+ layout(offset = 60) float base_page_x;
+ layout(offset = 64) float base_page_y;
+ layout(offset = 68) float base_page_w;
+ layout(offset = 72) float base_page_h;
+ layout(offset = 76) float detail_page_x;
+ layout(offset = 80) float detail_page_y;
+ layout(offset = 84) float detail_page_w;
+ layout(offset = 88) float detail_page_h;
+ layout(offset = 92) float overlay_page_x;
+ layout(offset = 96) float overlay_page_y;
+ layout(offset = 100) float overlay_page_w;
+ layout(offset = 104) float overlay_page_h;
+ layout(offset = 108) float overlay_detail_page_x;
+ layout(offset = 112) float overlay_detail_page_y;
+ layout(offset = 116) float overlay_detail_page_w;
+ layout(offset = 120) float overlay_detail_page_h;
+ layout(offset = 124) float material_mode;
+} material;
void main() {
- out_color = texture(base_color, in_uv) * vec4(in_color, 1.0);
- if (out_color.a < alpha_test.alpha_cutoff) {
+ vec2 base_uv = vec2(material.base_page_x, material.base_page_y)
+ + in_uv * vec2(material.base_page_w, material.base_page_h);
+ vec2 detail_uv = vec2(material.detail_page_x, material.detail_page_y)
+ + in_detail_uv * vec2(material.detail_page_w, material.detail_page_h);
+ vec2 overlay_uv = vec2(material.overlay_page_x, material.overlay_page_y)
+ + in_uv * vec2(material.overlay_page_w, material.overlay_page_h);
+ vec2 overlay_detail_uv = vec2(material.overlay_detail_page_x, material.overlay_detail_page_y)
+ + in_detail_uv * vec2(material.overlay_detail_page_w, material.overlay_detail_page_h);
+ vec4 base = texture(base_color, base_uv);
+ vec4 detail = texture(base_detail, detail_uv);
+ vec4 overlay = texture(overlay_color, overlay_uv);
+ vec4 overlay_detail_sample = texture(overlay_detail, overlay_detail_uv);
+ int mode = int(round(material.material_mode));
+ int combiner_mode = mode & 3;
+
+ // Native sky mode 4 combines nebula and stars as
+ // mix(TEX0.rgb, TEX1.rgb, TEX1.a) * DIFFUSE.rgb and carries DIFFUSE.a
+ // through as the pass alpha.
+ if (combiner_mode == 2) {
+ vec3 sky_rgb = mix(base.rgb, detail.rgb, clamp(detail.a, 0.0, 1.0));
+ out_color = vec4(sky_rgb * in_color, clamp(in_overlay_alpha, 0.0, 1.0));
+ if (out_color.a < material.alpha_cutoff) {
+ discard;
+ }
+ return;
+ }
+
+ // Native terrain mode 3 combines the base texture and the lightmap
+ // without the regular terrain 2x combiner or NdotL calculation.
+ if (combiner_mode == 3) {
+ vec3 lightmap_lighting = max(
+ vec3(material.additive_r, material.additive_g, material.additive_b),
+ frame.lighting_floor.rgb
+ );
+ vec3 vertex_rgb = base.rgb * detail.rgb * lightmap_lighting * in_color;
+ vec3 final_rgb = mix(vertex_rgb, frame.fog_color.rgb, in_fog_factor);
+ out_color = vec4(final_rgb, base.a * material.diffuse_alpha);
+ if (out_color.a < material.alpha_cutoff) {
+ discard;
+ }
+ return;
+ }
+
+ float overlay_alpha = clamp(
+ in_overlay_alpha * material.overlay_diffuse_alpha,
+ 0.0,
+ 1.0
+ );
+ vec3 base_rgb = 2.0 * base.rgb * detail.rgb;
+ vec3 overlay_rgb = 2.0 * overlay.rgb * overlay_detail_sample.rgb;
+ vec3 base_lit_rgb = base_rgb * in_base_diffuse + in_base_specular;
+ vec3 overlay_lit_rgb = overlay_rgb * in_overlay_diffuse + in_overlay_specular;
+ vec3 lit_rgb = mix(base_lit_rgb, overlay_lit_rgb, overlay_alpha);
+ vec3 vertex_rgb = lit_rgb * in_color;
+ vec3 final_rgb = combiner_mode == 0
+ ? mix(vertex_rgb, frame.fog_color.rgb, in_fog_factor)
+ : vertex_rgb;
+ float final_alpha = combiner_mode == 1
+ ? base.a * material.diffuse_alpha * in_overlay_alpha
+ : base.a * material.diffuse_alpha;
+ out_color = vec4(final_rgb, final_alpha);
+ if (out_color.a < material.alpha_cutoff) {
discard;
}
}
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
index 46b1e6f..8b867bb 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.frag.spv
Binary files differ
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert b/adapters/fparkan-render-vulkan/shaders/triangle.vert
index dfcdccb..201c31a 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert
@@ -2,17 +2,273 @@
layout(location = 0) in vec3 in_position;
layout(location = 1) in vec3 in_color;
-layout(location = 2) in vec2 in_uv;
+layout(location = 2) in vec3 in_normal;
+layout(location = 3) in vec2 in_uv;
+layout(location = 4) in vec2 in_detail_uv;
+layout(location = 5) in float in_overlay_alpha;
layout(location = 0) out vec3 out_color;
layout(location = 1) out vec2 out_uv;
+layout(location = 2) out vec2 out_detail_uv;
+layout(location = 3) out float out_overlay_alpha;
+// Native D3D vertex fog is written to SPECULAR.a and interpolated by the
+// fixed-function rasterizer. Keep the existing varying slot, but carry only
+// that scalar instead of recomputing distance in the fragment stage.
+layout(location = 4) out float out_fog_factor;
+layout(location = 5) out vec3 out_base_diffuse;
+layout(location = 6) out vec3 out_base_specular;
+layout(location = 7) out vec3 out_overlay_diffuse;
+layout(location = 8) out vec3 out_overlay_specular;
-layout(push_constant) uniform CameraConstants {
+layout(std140, set = 0, binding = 4) uniform FrameConstants {
mat4 clip_from_world;
-} camera;
+ vec4 directional_direction[4];
+ vec4 directional_rgb[4];
+ vec4 directional_coefficients[4];
+ vec4 point_position_range;
+ vec4 point_rgb_active;
+ vec4 point_coefficients_active;
+ vec4 lighting_floor;
+ vec4 fog_color;
+ vec4 fog_distances;
+ vec4 camera_position;
+} frame;
+
+layout(std430, set = 0, binding = 5) readonly buffer MaterialSpecular {
+ vec4 base;
+ vec4 overlay;
+} material_specular;
+
+layout(push_constant) uniform MaterialConstants {
+ layout(offset = 0) float alpha_cutoff;
+ layout(offset = 4) float diffuse_alpha;
+ layout(offset = 8) float overlay_diffuse_alpha;
+ layout(offset = 12) float directional_r;
+ layout(offset = 16) float directional_g;
+ layout(offset = 20) float directional_b;
+ layout(offset = 24) float additive_r;
+ layout(offset = 28) float additive_g;
+ layout(offset = 32) float additive_b;
+ layout(offset = 36) float overlay_directional_r;
+ layout(offset = 40) float overlay_directional_g;
+ layout(offset = 44) float overlay_directional_b;
+ layout(offset = 48) float overlay_additive_r;
+ layout(offset = 52) float overlay_additive_g;
+ layout(offset = 56) float overlay_additive_b;
+ layout(offset = 60) float base_page_x;
+ layout(offset = 64) float base_page_y;
+ layout(offset = 68) float base_page_w;
+ layout(offset = 72) float base_page_h;
+ layout(offset = 76) float detail_page_x;
+ layout(offset = 80) float detail_page_y;
+ layout(offset = 84) float detail_page_w;
+ layout(offset = 88) float detail_page_h;
+ layout(offset = 92) float overlay_page_x;
+ layout(offset = 96) float overlay_page_y;
+ layout(offset = 100) float overlay_page_w;
+ layout(offset = 104) float overlay_page_h;
+ layout(offset = 108) float overlay_detail_page_x;
+ layout(offset = 112) float overlay_detail_page_y;
+ layout(offset = 116) float overlay_detail_page_w;
+ layout(offset = 120) float overlay_detail_page_h;
+ layout(offset = 124) float material_mode;
+} material;
+
+struct LightingTerms {
+ vec3 diffuse;
+ vec3 specular;
+};
+
+float native_range_compress(float value) {
+ if (value <= 1.0) {
+ return value;
+ }
+ if (value <= 7.0) {
+ return value / 6.0 + 5.0 / 6.0;
+ }
+ return 2.0;
+}
+
+float native_specular_curve(float value) {
+ if (value <= 1.0) {
+ return 0.8 * value;
+ }
+ if (value <= 3.0) {
+ return 0.1 * value + 0.7;
+ }
+ return 1.0;
+}
+
+// The original callback uses the reflected vector and squares the cosine for
+// power-1 iterations. A bounded loop keeps the native byte power while
+// remaining valid for every material record.
+float native_specular_power(float cosine, float power) {
+ // CShade enables specular only when the material power byte is non-zero.
+ if (power <= 0.0 || cosine <= 0.0) {
+ return 0.0;
+ }
+ float result = cosine;
+ int iterations = clamp(int(power) - 1, 0, 254);
+ for (int index = 0; index < 254; ++index) {
+ if (index >= iterations) {
+ break;
+ }
+ result *= result;
+ }
+ return result;
+}
+
+float native_fog_factor(vec3 world_position) {
+ float fog_span = frame.fog_distances.y - frame.fog_distances.x;
+ float distance_to_camera = distance(world_position, frame.camera_position.xyz);
+ if (fog_span <= 0.000001 || distance_to_camera <= frame.fog_distances.x) {
+ return 0.0;
+ }
+ return clamp((distance_to_camera - frame.fog_distances.x) / fog_span, 0.0, 1.0);
+}
+
+vec3 normalized_or(vec3 value, vec3 fallback) {
+ float length_squared = dot(value, value);
+ return length_squared > 0.0000001 ? value * inversesqrt(length_squared) : fallback;
+}
+
+vec3 specular_from_light(
+ vec3 normal,
+ vec3 view_direction,
+ vec3 light_direction,
+ vec3 light_rgb,
+ vec3 material_rgb,
+ float power,
+ float attenuation
+) {
+ float ndotl = dot(normal, light_direction);
+ if (ndotl <= 0.0 || attenuation <= 0.0) {
+ return vec3(0.0);
+ }
+ vec3 reflection = reflect(-light_direction, normal);
+ float cosine = max(dot(reflection, view_direction), 0.0);
+ return material_rgb * light_rgb * native_specular_power(cosine, power) * attenuation;
+}
+
+LightingTerms native_material_lighting(
+ vec3 world_position,
+ vec3 normal,
+ vec3 directional_rgb,
+ vec3 additive_rgb,
+ vec3 specular_rgb,
+ float specular_power
+) {
+ vec3 accumulated = additive_rgb;
+ vec3 specular = vec3(0.0);
+ vec3 view_direction = normalized_or(frame.camera_position.xyz - world_position, vec3(0.0, 0.0, 1.0));
+ for (int index = 0; index < 4; ++index) {
+ vec4 direction_record = frame.directional_direction[index];
+ vec4 rgb_record = frame.directional_rgb[index];
+ if (direction_record.w <= 0.5 || rgb_record.w <= 0.5) {
+ continue;
+ }
+ vec3 light_direction = normalized_or(-direction_record.xyz, vec3(0.0, 0.0, 1.0));
+ float ndotl = max(dot(normal, light_direction), 0.0);
+ accumulated += ndotl * directional_rgb * rgb_record.rgb;
+ specular += specular_from_light(
+ normal,
+ view_direction,
+ light_direction,
+ rgb_record.rgb,
+ specular_rgb,
+ specular_power,
+ 1.0
+ );
+ }
+ vec4 point_position_range = frame.point_position_range;
+ vec4 point_rgb_active = frame.point_rgb_active;
+ vec4 point_coefficients_active = frame.point_coefficients_active;
+ vec3 to_light = point_position_range.xyz - world_position;
+ float distance_squared = dot(to_light, to_light);
+ float range = point_position_range.w;
+ if (point_rgb_active.w > 0.5 && range > 0.0 && distance_squared <= range * range) {
+ float distance_to_light = sqrt(distance_squared);
+ if (distance_to_light > 0.000001) {
+ vec3 light_direction = to_light / distance_to_light;
+ float ndotl = dot(normal, light_direction);
+ if (ndotl > 0.0) {
+ float t = (range - distance_to_light) / range;
+ float attenuation = point_coefficients_active.x
+ + point_coefficients_active.y * t
+ + point_coefficients_active.z * t * t;
+ attenuation = max(attenuation, 0.0);
+ accumulated += ndotl * directional_rgb * point_rgb_active.rgb * attenuation;
+ specular += specular_from_light(
+ normal,
+ view_direction,
+ light_direction,
+ point_rgb_active.rgb,
+ specular_rgb,
+ specular_power,
+ attenuation
+ );
+ }
+ }
+ }
+ accumulated = max(accumulated, frame.lighting_floor.rgb);
+ vec3 compressed = vec3(
+ native_range_compress(accumulated.r),
+ native_range_compress(accumulated.g),
+ native_range_compress(accumulated.b)
+ );
+ LightingTerms terms;
+ terms.diffuse = min(compressed, vec3(1.0));
+ vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0));
+ terms.specular = vec3(
+ native_specular_curve(total_specular.r),
+ native_specular_curve(total_specular.g),
+ native_specular_curve(total_specular.b)
+ );
+ return terms;
+}
void main() {
out_color = in_color;
out_uv = in_uv;
- gl_Position = camera.clip_from_world * vec4(in_position, 1.0);
+ out_detail_uv = in_detail_uv;
+ out_overlay_alpha = in_overlay_alpha;
+ out_fog_factor = native_fog_factor(in_position);
+ int mode = int(round(material.material_mode));
+ int combiner_mode = mode & 3;
+ if (combiner_mode == 0) {
+ LightingTerms base_terms = native_material_lighting(
+ in_position,
+ in_normal,
+ vec3(material.directional_r, material.directional_g, material.directional_b),
+ vec3(material.additive_r, material.additive_g, material.additive_b),
+ material_specular.base.rgb,
+ material_specular.base.w
+ );
+ LightingTerms overlay_terms = native_material_lighting(
+ in_position,
+ in_normal,
+ vec3(material.overlay_directional_r, material.overlay_directional_g, material.overlay_directional_b),
+ vec3(material.overlay_additive_r, material.overlay_additive_g, material.overlay_additive_b),
+ material_specular.overlay.rgb,
+ material_specular.overlay.w
+ );
+ out_base_diffuse = base_terms.diffuse;
+ out_base_specular = base_terms.specular;
+ out_overlay_diffuse = overlay_terms.diffuse;
+ out_overlay_specular = overlay_terms.specular;
+ } else {
+ out_base_diffuse = vec3(1.0);
+ out_base_specular = vec3(0.0);
+ out_overlay_diffuse = vec3(1.0);
+ out_overlay_specular = vec3(0.0);
+ }
+ vec4 clip_position = frame.clip_from_world * vec4(in_position, 1.0);
+ // Sky far-depth is a Vulkan projection choice for layers authored around
+ // the camera. Keep world positions, normals, and UVs intact while placing
+ // the resulting primitive at the far depth; this does not assert native
+ // D3D depth-bit equivalence.
+ if ((mode & 4) != 0) {
+ clip_position.z = clip_position.w;
+ }
+ gl_Position = clip_position;
}
diff --git a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
index 84d1d9f..9574e1c 100644
--- a/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
+++ b/adapters/fparkan-render-vulkan/shaders/triangle.vert.spv
Binary files differ
diff --git a/adapters/fparkan-render-vulkan/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
index 79a42a4..4ce86eb 100644
--- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs
+++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs
@@ -1,16 +1,24 @@
//! Format-to-GPU geometry bridge for the initial static asset renderer.
use crate::{VulkanStaticDrawRange, VulkanStaticMesh, VulkanStaticVertex};
+use fparkan_animation::{NodePoseBuffer, Pose};
use fparkan_msh::{
draw_batches, node38_fallback_hierarchy, node38_sampled_hierarchy, selected_slot, Group, Lod,
ModelAsset, NodeId,
};
-use fparkan_render::{LegacyIron3dEulerTransform, LegacyPipelineState};
+use fparkan_render::{LegacyDepthMode, LegacyIron3dEulerTransform, LegacyPipelineState};
use fparkan_terrain_format::LandMeshDocument;
/// Legacy `Land.msh` stored-height to mission-world-height scale.
const LEGACY_LAND_HEIGHT_SCALE: f32 = 1.0 / 32.0;
+fn world_pipeline_state() -> LegacyPipelineState {
+ LegacyPipelineState {
+ depth: LegacyDepthMode::TestWrite,
+ ..LegacyPipelineState::default()
+ }
+}
+
/// Error returned when a validated MSH cannot enter the current static GPU path.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum VulkanAssetMeshError {
@@ -22,6 +30,22 @@ pub enum VulkanAssetMeshError {
DegenerateViewExtent,
/// The indexed model exceeds the current 16-bit Vulkan input contract.
IndexOutOfRange,
+ /// A caller-supplied node pose buffer has a different length than its model.
+ NodePoseCountMismatch {
+ /// Number of nodes in the model.
+ expected: usize,
+ /// Number of supplied node poses.
+ actual: usize,
+ },
+}
+
+/// Evaluated world pose of one standard Node38 node in model-local space.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct VulkanNodePose {
+ /// Translation in the model's local coordinate system.
+ pub translation: [f32; 3],
+ /// Unit quaternion in `[x, y, z, w]` order.
+ pub rotation: [f32; 4],
}
/// Shared XY frame for a deliberately top-down diagnostic static scene.
@@ -83,6 +107,10 @@ impl std::fmt::Display for VulkanAssetMeshError {
Self::NonFinitePosition => write!(f, "MSH contains a non-finite position"),
Self::DegenerateViewExtent => write!(f, "MSH has a degenerate XY view extent"),
Self::IndexOutOfRange => write!(f, "MSH index exceeds the static Vulkan u16 contract"),
+ Self::NodePoseCountMismatch { expected, actual } => write!(
+ f,
+ "node pose buffer has {actual} poses, but the MSH has {expected} nodes"
+ ),
}
}
}
@@ -153,8 +181,9 @@ pub fn project_msh_to_static_mesh_in_xy_frame(
frame.project(transformed)[1],
0.0,
],
- color: [0.82, 0.72, 0.31],
- // Iron3D stores Res5 UV0 as signed fixed point with 1/1024 units.
+ color: [1.0, 1.0, 1.0],
+ normal: model_normal(model, index),
+ // Iron3D stores Res5 UV0 as unsigned fixed point with 1/1024 units.
// Models that omit this optional stream retain the static viewer's
// XY planar fallback instead of receiving fabricated raw UV values.
uv: model
@@ -164,6 +193,8 @@ pub fn project_msh_to_static_mesh_in_xy_frame(
.map_or(frame.planar_uv(transformed), |uv| {
[f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0]
}),
+ detail_uv: model_detail_uv(model, index),
+ overlay_alpha: 0.0,
})
})
.collect::<Result<Vec<_>, _>>()?;
@@ -282,6 +313,17 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform(
transform: LegacyIron3dEulerTransform,
scale: [f32; 3],
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose(
+ model, transform, scale, None,
+ )
+}
+
+fn project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose(
+ model: &ModelAsset,
+ transform: LegacyIron3dEulerTransform,
+ scale: [f32; 3],
+ root_pose: Option<Pose>,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
if !transform
.translation
.iter()
@@ -300,12 +342,35 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform(
if !position.iter().all(|value| value.is_finite()) {
return Err(VulkanAssetMeshError::NonFinitePosition);
}
+ let local_position = root_pose.map_or(
+ Pose {
+ translation: *position,
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ |root| {
+ compose_pose(
+ root,
+ Pose {
+ translation: *position,
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ )
+ },
+ );
let position = transform
- .try_transform_scaled_point(*position, scale)
+ .try_transform_scaled_point(local_position.translation, scale)
.ok_or(VulkanAssetMeshError::NonFinitePosition)?;
Ok(VulkanStaticVertex {
position,
- color: [0.82, 0.72, 0.31],
+ color: [1.0, 1.0, 1.0],
+ normal: transform_normal(
+ transform,
+ root_pose.map_or(model_normal(model, index), |root| {
+ rotate_by_quaternion(model_normal(model, index), root.rotation)
+ }),
+ scale,
+ )
+ .ok_or(VulkanAssetMeshError::NonFinitePosition)?,
uv: model
.uv0
.as_ref()
@@ -313,6 +378,8 @@ pub fn project_msh_to_static_mesh_in_world_space_with_transform(
.map_or([0.0, 0.0], |uv| {
[f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0]
}),
+ detail_uv: model_detail_uv(model, index),
+ overlay_alpha: 0.0,
})
})
.collect::<Result<Vec<_>, _>>()?;
@@ -347,6 +414,8 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
transform,
scale,
node38_fallback_hierarchy(model),
+ None,
+ false,
)
}
@@ -372,6 +441,203 @@ pub fn project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
transform,
scale,
node38_sampled_hierarchy(model, animation_frame),
+ None,
+ false,
+ )
+}
+
+/// Projects a Node38 model using a hierarchy sampled by the caller.
+///
+/// This keeps geometry in sync with any sockets or bounds that the caller
+/// derives from the same pose buffer.
+///
+/// # Errors
+///
+/// Returns [`VulkanAssetMeshError`] when source geometry or transforms cannot
+/// be represented by the current static Vulkan input contract.
+pub fn project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
+ model: &ModelAsset,
+ transform: LegacyIron3dEulerTransform,
+ scale: [f32; 3],
+ node_poses: &NodePoseBuffer,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ project_msh_to_static_mesh_in_world_space_with_node_poses(
+ model,
+ transform,
+ scale,
+ Some(node_poses.clone()),
+ None,
+ false,
+ )
+}
+
+/// Projects a mounted Node38 model using a caller-sampled hierarchy and the
+/// parent socket pose derived from its own caller-sampled hierarchy.
+///
+/// # Errors
+///
+/// Returns [`VulkanAssetMeshError`] when source geometry or transforms cannot
+/// be represented by the current static Vulkan input contract.
+pub fn project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
+ model: &ModelAsset,
+ transform: LegacyIron3dEulerTransform,
+ scale: [f32; 3],
+ node_poses: &NodePoseBuffer,
+ mount_pose: VulkanNodePose,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ project_msh_to_static_mesh_in_world_space_with_node_poses(
+ model,
+ transform,
+ scale,
+ Some(node_poses.clone()),
+ Some(Pose {
+ translation: mount_pose.translation,
+ rotation: mount_pose.rotation,
+ }),
+ true,
+ )
+}
+
+/// Evaluates one Node38 node pose for a unit mount socket.
+#[must_use]
+pub fn node38_pose(
+ model: &ModelAsset,
+ animation_frame: Option<u16>,
+ node_index: usize,
+) -> Option<VulkanNodePose> {
+ node38_pose_with_root_replacement(model, animation_frame, node_index, false)
+}
+
+/// Evaluates one Node38 socket relative to a replacement root pose.
+///
+/// This is used when the model itself is already mounted: node zero belongs to
+/// the parent socket and must not be composed a second time.
+#[must_use]
+pub fn node38_pose_relative_to_root(
+ model: &ModelAsset,
+ animation_frame: Option<u16>,
+ node_index: usize,
+) -> Option<VulkanNodePose> {
+ node38_pose_with_root_replacement(model, animation_frame, node_index, true)
+}
+
+/// Returns one node's evaluated pose from an already sampled hierarchy.
+#[must_use]
+pub fn node38_pose_from_hierarchy(
+ hierarchy: &NodePoseBuffer,
+ node_index: usize,
+) -> Option<VulkanNodePose> {
+ node38_pose_value_from_hierarchy(hierarchy, node_index, false).map(vulkan_node_pose)
+}
+
+/// Returns a node's pose relative to a root pose that will be replaced by a
+/// parent socket.
+#[must_use]
+pub fn node38_pose_relative_to_root_from_hierarchy(
+ hierarchy: &NodePoseBuffer,
+ node_index: usize,
+) -> Option<VulkanNodePose> {
+ node38_pose_value_from_hierarchy(hierarchy, node_index, true).map(vulkan_node_pose)
+}
+
+fn node38_pose_value_from_hierarchy(
+ hierarchy: &NodePoseBuffer,
+ node_index: usize,
+ root_replaced: bool,
+) -> Option<Pose> {
+ let pose = *hierarchy.poses.get(node_index)?;
+ if !root_replaced {
+ return Some(pose);
+ }
+ if node_index == 0 {
+ return Some(Pose::default());
+ }
+ let root = *hierarchy.poses.first()?;
+ Some(compose_pose(inverse_pose(root), pose))
+}
+
+fn vulkan_node_pose(pose: Pose) -> VulkanNodePose {
+ VulkanNodePose {
+ translation: pose.translation,
+ rotation: pose.rotation,
+ }
+}
+
+fn node38_pose_with_root_replacement(
+ model: &ModelAsset,
+ animation_frame: Option<u16>,
+ node_index: usize,
+ root_replaced: bool,
+) -> Option<VulkanNodePose> {
+ let hierarchy = animation_frame.map_or_else(
+ || node38_fallback_hierarchy(model),
+ |frame| node38_sampled_hierarchy(model, frame),
+ )?;
+ let hierarchy = if root_replaced {
+ strip_node38_root_pose(hierarchy)?
+ } else {
+ hierarchy
+ };
+ let pose = *hierarchy.poses.get(node_index)?;
+ Some(VulkanNodePose {
+ translation: pose.translation,
+ rotation: pose.rotation,
+ })
+}
+
+/// Projects a mounted Node38 model while preserving its local hierarchy.
+///
+/// The native unit loader mounts a child root at the parent's selected local
+/// socket. The child root node itself is a socket placeholder and is hidden;
+/// nodes `1..` retain their evaluated local hierarchy and are composed with the
+/// parent's world socket pose. The mission transform remains applied once.
+///
+/// # Errors
+///
+/// Returns [`VulkanAssetMeshError`] when geometry or transforms cannot be
+/// represented by the static input contract.
+pub fn project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount(
+ model: &ModelAsset,
+ transform: LegacyIron3dEulerTransform,
+ scale: [f32; 3],
+ animation_frame: u16,
+ mount_pose: VulkanNodePose,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ project_msh_to_static_mesh_in_world_space_with_node_poses(
+ model,
+ transform,
+ scale,
+ node38_sampled_hierarchy(model, animation_frame),
+ Some(Pose {
+ translation: mount_pose.translation,
+ rotation: mount_pose.rotation,
+ }),
+ true,
+ )
+}
+
+/// Projects a mounted Node38 model at its decoded fallback pose.
+///
+/// # Errors
+///
+/// Returns [`VulkanAssetMeshError`] when geometry or transforms cannot be
+/// represented by the static input contract.
+pub fn project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount(
+ model: &ModelAsset,
+ transform: LegacyIron3dEulerTransform,
+ scale: [f32; 3],
+ mount_pose: VulkanNodePose,
+) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ project_msh_to_static_mesh_in_world_space_with_node_poses(
+ model,
+ transform,
+ scale,
+ node38_fallback_hierarchy(model),
+ Some(Pose {
+ translation: mount_pose.translation,
+ rotation: mount_pose.rotation,
+ }),
+ true,
)
}
@@ -380,7 +646,17 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses(
transform: LegacyIron3dEulerTransform,
scale: [f32; 3],
hierarchy: Option<fparkan_animation::NodePoseBuffer>,
+ mount_pose: Option<Pose>,
+ hide_root_node: bool,
) -> Result<VulkanStaticMesh, VulkanAssetMeshError> {
+ if let Some(hierarchy) = hierarchy.as_ref() {
+ if hierarchy.poses.len() != model.node_count {
+ return Err(VulkanAssetMeshError::NodePoseCountMismatch {
+ expected: model.node_count,
+ actual: hierarchy.poses.len(),
+ });
+ }
+ }
if !transform
.translation
.iter()
@@ -391,10 +667,19 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses(
return Err(VulkanAssetMeshError::NonFinitePosition);
}
if model.node_stride != 38 || model.node_count == 0 || model.animation.is_none() {
- return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
+ return project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose(
+ model, transform, scale, mount_pose,
+ );
}
+ let hierarchy = if mount_pose.is_some() {
+ hierarchy.and_then(strip_node38_root_pose)
+ } else {
+ hierarchy
+ };
let Some(hierarchy) = hierarchy else {
- return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
+ return project_msh_to_static_mesh_in_world_space_with_transform_and_root_pose(
+ model, transform, scale, mount_pose,
+ );
};
let mut vertices = Vec::new();
@@ -406,7 +691,12 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses(
let Some(slot) = selected_slot(model, node, Lod(0), Group(0)) else {
continue;
};
- let pose = hierarchy.poses[node_index];
+ if hide_root_node && node_index == 0 {
+ continue;
+ }
+ let pose = mount_pose.map_or(hierarchy.poses[node_index], |mount| {
+ compose_pose(mount, hierarchy.poses[node_index])
+ });
if !pose
.translation
.iter()
@@ -430,7 +720,16 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses(
}
}
if indices.is_empty() {
- return project_msh_to_static_mesh_in_world_space_with_transform(model, transform, scale);
+ // A valid Node38 hierarchy may intentionally select no drawable
+ // batches for this component (for example, a mounted attachment
+ // whose root is hidden at the chosen LOD/group). Preserve that empty
+ // result instead of falling back to every source batch and drawing
+ // geometry that the node selection excluded.
+ return Ok(VulkanStaticMesh {
+ vertices,
+ indices,
+ draw_ranges,
+ });
}
Ok(VulkanStaticMesh {
vertices,
@@ -439,7 +738,48 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses(
})
}
-#[allow(clippy::too_many_arguments)]
+/// Replaces Node38 node zero with the identity pose while retaining every
+/// descendant's pose relative to that root. Mounted components use this
+/// because the native loader substitutes their root with the parent socket.
+fn strip_node38_root_pose(
+ mut hierarchy: fparkan_animation::NodePoseBuffer,
+) -> Option<fparkan_animation::NodePoseBuffer> {
+ let root = *hierarchy.poses.first()?;
+ let inverse_root = inverse_pose(root);
+ hierarchy.poses[0] = Pose::default();
+ for pose in hierarchy.poses.iter_mut().skip(1) {
+ *pose = compose_pose(inverse_root, *pose);
+ }
+ Some(hierarchy)
+}
+
+fn inverse_pose(pose: Pose) -> Pose {
+ let [x, y, z, w] = pose.rotation;
+ let length_squared = x * x + y * y + z * z + w * w;
+ let inverse_rotation = if length_squared.is_finite() && length_squared > f32::EPSILON {
+ [
+ -x / length_squared,
+ -y / length_squared,
+ -z / length_squared,
+ w / length_squared,
+ ]
+ } else {
+ [0.0, 0.0, 0.0, 1.0]
+ };
+ let translation = rotate_by_quaternion(
+ [
+ -pose.translation[0],
+ -pose.translation[1],
+ -pose.translation[2],
+ ],
+ inverse_rotation,
+ );
+ Pose {
+ translation,
+ rotation: inverse_rotation,
+ }
+}
+
fn append_node_fallback_batch(
model: &ModelAsset,
batch: &fparkan_msh::Batch,
@@ -451,6 +791,15 @@ fn append_node_fallback_batch(
indices: &mut Vec<u32>,
draw_ranges: &mut Vec<VulkanStaticDrawRange>,
) -> Result<(), VulkanAssetMeshError> {
+ // Some authored MSH tables retain empty alternate batches. They do not
+ // describe a drawable range and would violate the renderer's contiguous
+ // non-empty draw-range contract when a full mission root set is loaded.
+ if batch.index_count == 0 {
+ return Ok(());
+ }
+ if !usize::from(batch.index_count).is_multiple_of(3) {
+ return Err(VulkanAssetMeshError::IndexOutOfRange);
+ }
let first_index =
u32::try_from(indices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?;
let start =
@@ -489,7 +838,12 @@ fn append_node_fallback_batch(
u32::try_from(vertices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?;
vertices.push(VulkanStaticVertex {
position,
- color: [0.82, 0.72, 0.31],
+ color: [1.0, 1.0, 1.0],
+ normal: {
+ let normal = rotate_by_quaternion(model_normal(model, source_index), rotation);
+ transform_normal(transform, normal, scale)
+ .ok_or(VulkanAssetMeshError::NonFinitePosition)?
+ },
uv: model
.uv0
.as_ref()
@@ -497,19 +851,46 @@ fn append_node_fallback_batch(
.map_or([0.0, 0.0], |uv| {
[f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0]
}),
+ detail_uv: model_detail_uv(model, source_index),
+ overlay_alpha: 0.0,
});
indices.push(vertex_index);
}
draw_ranges.push(VulkanStaticDrawRange {
first_index,
index_count: u32::from(batch.index_count),
- material_index: batch.material_index,
- pipeline_state: LegacyPipelineState::default(),
+ material_index: u16::from(batch.material_index),
+ lightmap_index: batch.lightmap_index,
+ batch_flags: batch.batch_flags,
+ pipeline_state: world_pipeline_state(),
alpha_test_reference: 0,
});
Ok(())
}
+fn compose_pose(parent: Pose, child: Pose) -> Pose {
+ let translation = rotate_by_quaternion(child.translation, parent.rotation);
+ Pose {
+ translation: [
+ parent.translation[0] + translation[0],
+ parent.translation[1] + translation[1],
+ parent.translation[2] + translation[2],
+ ],
+ rotation: multiply_quaternions(parent.rotation, child.rotation),
+ }
+}
+
+fn multiply_quaternions(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
+ let [lx, ly, lz, lw] = left;
+ let [rx, ry, rz, rw] = right;
+ [
+ lw * rx + lx * rw + ly * rz - lz * ry,
+ lw * ry - lx * rz + ly * rw + lz * rx,
+ lw * rz + lx * ry - ly * rx + lz * rw,
+ lw * rw - lx * rx - ly * ry - lz * rz,
+ ]
+}
+
fn rotate_by_quaternion(position: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
let [x, y, z, w] = rotation;
let tx = 2.0 * (y * position[2] - z * position[1]);
@@ -522,6 +903,104 @@ fn rotate_by_quaternion(position: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
]
}
+fn model_normal(model: &ModelAsset, index: usize) -> [f32; 3] {
+ model
+ .normals
+ .as_ref()
+ .and_then(|normals| normals.get(index))
+ .map_or([0.0, 0.0, 1.0], |raw| {
+ // Indexed MSH stores signed bytes. The native decoder divides
+ // by 127 without clamping or renormalizing, so the packed length
+ // (including -128/127) reaches the shader unchanged.
+ [
+ f32::from(raw[0]) / 127.0,
+ f32::from(raw[1]) / 127.0,
+ f32::from(raw[2]) / 127.0,
+ ]
+ })
+}
+
+/// Decodes the optional MSH type-18 secondary UV stream. Native model and
+/// terrain samplers use the same unsigned 16-bit fixed-point 1/1024 scale for
+/// both texture stages; an absent stream leaves the neutral detail coordinate.
+fn model_detail_uv(model: &ModelAsset, index: usize) -> [f32; 2] {
+ model
+ .uv1
+ .as_ref()
+ .and_then(|uv1| uv1.get(index))
+ .map_or([0.0, 0.0], |uv| {
+ [f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0]
+ })
+}
+
+fn terrain_normal(terrain: &LandMeshDocument, index: usize) -> [f32; 3] {
+ terrain.normals.get(index).map_or([0.0, 0.0, 1.0], |raw| {
+ // Ngi32's normal decoder sign-extends each source byte and multiplies
+ // it by the literal 1/128. Preserve the packed magnitude here: the
+ // shader consumes the native vector directly, and normalizing it
+ // changes the lighting response for every authored byte.
+ [
+ f32::from(raw[0]) * (1.0 / 128.0),
+ f32::from(raw[1]) * (1.0 / 128.0),
+ f32::from(raw[2]) * (1.0 / 128.0),
+ ]
+ })
+}
+
+fn transform_normal(
+ transform: LegacyIron3dEulerTransform,
+ normal: [f32; 3],
+ scale: [f32; 3],
+) -> Option<[f32; 3]> {
+ if !normal.iter().all(|value| value.is_finite())
+ || !scale
+ .iter()
+ .all(|value| value.is_finite() && value.abs() > f32::EPSILON)
+ {
+ return None;
+ }
+ // Positions use `R * (scale * point)`. Normals therefore use the
+ // inverse-transpose linear transform: inverse scale first, then R.
+ let inverse_scaled = [
+ normal[0] / scale[0],
+ normal[1] / scale[1],
+ normal[2] / scale[2],
+ ];
+ let matrix = transform.try_row_major()?;
+ let transformed = [
+ matrix[0] * inverse_scaled[0]
+ + matrix[1] * inverse_scaled[1]
+ + matrix[2] * inverse_scaled[2],
+ matrix[4] * inverse_scaled[0]
+ + matrix[5] * inverse_scaled[1]
+ + matrix[6] * inverse_scaled[2],
+ matrix[8] * inverse_scaled[0]
+ + matrix[9] * inverse_scaled[1]
+ + matrix[10] * inverse_scaled[2],
+ ];
+ let transformed_length = transformed[0].mul_add(
+ transformed[0],
+ transformed[1].mul_add(transformed[1], transformed[2] * transformed[2]),
+ );
+ if !transformed_length.is_finite() || transformed_length <= f32::EPSILON {
+ return Some([0.0; 3]);
+ }
+ let source_length = normal[0].mul_add(
+ normal[0],
+ normal[1].mul_add(normal[1], normal[2] * normal[2]),
+ );
+ if !source_length.is_finite() || source_length <= f32::EPSILON {
+ return Some([0.0; 3]);
+ }
+ let magnitude = source_length.sqrt();
+ let inverse_transformed_length = transformed_length.sqrt().recip();
+ Some([
+ transformed[0] * inverse_transformed_length * magnitude,
+ transformed[1] * inverse_transformed_length * magnitude,
+ transformed[2] * inverse_transformed_length * magnitude,
+ ])
+}
+
/// Projects validated `Land.msh` terrain geometry into the static Vulkan path.
///
/// This bridge preserves the source triangle order from `TerrainFace28` and
@@ -596,13 +1075,16 @@ pub fn project_land_msh_to_static_mesh_in_xy_frame(
frame.project(*position)[1],
0.0,
],
- color: [0.31, 0.58, 0.27],
+ color: [1.0, 1.0, 1.0],
+ normal: terrain_normal(terrain, index),
uv: terrain
.uv0
.get(index)
.map_or(frame.planar_uv(*position), |uv| {
[f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0]
}),
+ detail_uv: terrain_detail_uv(terrain, index),
+ overlay_alpha: terrain_overlay_alpha(terrain, index),
})
})
.collect::<Result<Vec<_>, _>>()?;
@@ -639,10 +1121,13 @@ pub fn project_land_msh_to_static_mesh_in_world_space(
}
Ok(VulkanStaticVertex {
position: *position,
- color: [0.31, 0.58, 0.27],
+ color: [1.0, 1.0, 1.0],
+ normal: terrain_normal(terrain, index),
uv: terrain.uv0.get(index).map_or([0.0, 0.0], |uv| {
[f32::from(uv[0]) / 1024.0, f32::from(uv[1]) / 1024.0]
}),
+ detail_uv: terrain_detail_uv(terrain, index),
+ overlay_alpha: terrain_overlay_alpha(terrain, index),
})
})
.collect::<Result<Vec<_>, _>>()?;
@@ -696,6 +1181,38 @@ fn static_terrain_indices(terrain: &LandMeshDocument) -> Result<Vec<u32>, Vulkan
Ok(indices)
}
+/// Decodes the terrain type-18 repeating detail coordinate. The original
+/// stream stores two unsigned 16-bit fixed-point coordinates with the same
+/// 1/1024 texture scale used by the texture sampler.
+fn terrain_detail_uv(terrain: &LandMeshDocument, index: usize) -> [f32; 2] {
+ terrain.aux18.get(index).map_or([0.0, 0.0], |raw| {
+ [
+ f32::from(u16::from_le_bytes([raw[0], raw[1]])) / 1024.0,
+ f32::from(u16::from_le_bytes([raw[2], raw[3]])) / 1024.0,
+ ]
+ })
+}
+
+/// Decodes the terrain type-14 overlay mask as the source float alpha.
+/// Invalid or out-of-range values are disabled at the static bridge boundary;
+/// valid map data is the continuous 0..1 mask consumed by the original shade
+/// path.
+fn terrain_overlay_alpha(terrain: &LandMeshDocument, index: usize) -> f32 {
+ terrain
+ .aux14
+ .get(index)
+ .map_or(0.0, |raw| f32::from_le_bytes(*raw))
+ .is_finite()
+ .then_some(
+ terrain
+ .aux14
+ .get(index)
+ .map_or(0.0, |raw| f32::from_le_bytes(*raw))
+ .clamp(0.0, 1.0),
+ )
+ .unwrap_or(0.0)
+}
+
fn static_terrain_draw_ranges(
terrain: &LandMeshDocument,
) -> Result<Vec<VulkanStaticDrawRange>, VulkanAssetMeshError> {
@@ -719,7 +1236,9 @@ fn static_terrain_draw_ranges(
.checked_mul(3)
.ok_or(VulkanAssetMeshError::IndexOutOfRange)?,
material_index,
- pipeline_state: LegacyPipelineState::default(),
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: world_pipeline_state(),
alpha_test_reference: 0,
});
start_face = end_face;
@@ -734,6 +1253,12 @@ fn static_model_indices_and_ranges(
let batches = static_model_preview_batches(model)?;
let mut draw_ranges = Vec::with_capacity(batches.len());
for batch in batches {
+ if batch.index_count == 0 {
+ continue;
+ }
+ if !usize::from(batch.index_count).is_multiple_of(3) {
+ return Err(VulkanAssetMeshError::IndexOutOfRange);
+ }
let first_index =
u32::try_from(indices.len()).map_err(|_| VulkanAssetMeshError::IndexOutOfRange)?;
let start = usize::try_from(batch.index_start)
@@ -755,8 +1280,10 @@ fn static_model_indices_and_ranges(
draw_ranges.push(VulkanStaticDrawRange {
first_index,
index_count: u32::from(batch.index_count),
- material_index: batch.material_index,
- pipeline_state: LegacyPipelineState::default(),
+ material_index: u16::from(batch.material_index),
+ lightmap_index: batch.lightmap_index,
+ batch_flags: batch.batch_flags,
+ pipeline_state: world_pipeline_state(),
alpha_test_reference: 0,
});
}
@@ -844,10 +1371,13 @@ mod tests {
node_stride: 0,
node_count: 0,
nodes_raw: Vec::new(),
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 0.0,
slots: Vec::new(),
positions,
normals: None,
uv0: None,
+ uv1: None,
indices,
batches,
node_names: None,
@@ -858,12 +1388,13 @@ mod tests {
fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> Batch {
Batch {
batch_flags: 0,
+ material_index_hi: 0,
material_index: 0,
- opaque4: 0,
- opaque6: 0,
+ lightmap_index: 0,
+ local_batch_index: 0,
index_count,
index_start,
- opaque14: 0,
+ vertex_count: 0,
base_vertex,
}
}
@@ -876,6 +1407,8 @@ mod tests {
node_stride: 38,
node_count: 1,
nodes_raw: node,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 0.0,
slots: vec![
Slot {
tri_start: 0,
@@ -903,6 +1436,7 @@ mod tests {
positions: Vec::new(),
normals: None,
uv0: None,
+ uv1: None,
indices: Vec::new(),
batches: vec![
batch(0, 0, 0),
@@ -941,7 +1475,9 @@ mod tests {
first_index: 0,
index_count: 3,
material_index: 0,
- pipeline_state: LegacyPipelineState::default(),
+ lightmap_index: 0,
+ batch_flags: 0,
+ pipeline_state: world_pipeline_state(),
alpha_test_reference: 0,
}]
);
@@ -957,13 +1493,13 @@ mod tests {
vec![0, 1, 2],
vec![batch(0, 3, 0)],
);
- source.uv0 = Some(vec![[1024, -512], [0, 2048], [-1024, 512]]);
+ source.uv0 = Some(vec![[1024, 65024], [0, 2048], [64512, 512]]);
let mesh = project_msh_to_static_mesh(&source).expect("representable MSH");
- assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]);
+ assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]);
assert_eq!(mesh.vertices[1].uv, [0.0, 2.0]);
- assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]);
+ assert_eq!(mesh.vertices[2].uv, [63.0, 0.5]);
}
#[test]
@@ -973,7 +1509,7 @@ mod tests {
vec![0, 1, 2],
vec![batch(0, 3, 0)],
);
- source.uv0 = Some(vec![[1024, -512], [0, 2048], [-1024, 512]]);
+ source.uv0 = Some(vec![[1024, 65024], [0, 2048], [64512, 512]]);
let mesh = project_msh_to_static_mesh_in_world_space(
&source,
@@ -985,7 +1521,7 @@ mod tests {
assert_eq!(mesh.indices, vec![0, 1, 2]);
assert_eq!(mesh.vertices[0].position, [104.0, 203.0, 302.0]);
assert_eq!(mesh.vertices[1].position, [100.0, 199.0, 301.0]);
- assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]);
+ assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]);
}
#[test]
@@ -1012,6 +1548,65 @@ mod tests {
}
#[test]
+ fn world_space_msh_transforms_normals_with_inverse_scale() {
+ let mut source = model(
+ vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
+ vec![0, 1, 2],
+ vec![batch(0, 3, 0)],
+ );
+ source.normals = Some(vec![[127, 127, 0, 0]; 3]);
+
+ let mesh = project_msh_to_static_mesh_in_world_space_with_transform(
+ &source,
+ LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0; 3],
+ },
+ [2.0, 3.0, 1.0],
+ )
+ .expect("finite transformed normals");
+
+ let expected_magnitude = 2.0_f32.sqrt() * (127.0 / 127.0);
+ assert!((mesh.vertices[0].normal[0] - 0.83205 * expected_magnitude).abs() < 0.0001);
+ assert!((mesh.vertices[0].normal[1] - 0.55470 * expected_magnitude).abs() < 0.0001);
+ assert_eq!(mesh.vertices[0].normal[2], 0.0);
+ }
+
+ #[test]
+ fn indexed_msh_normal_bytes_keep_packed_magnitude_through_uniform_scale() {
+ let mut source = model(
+ vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
+ vec![0, 1, 2],
+ vec![batch(0, 3, 0)],
+ );
+ source.normals = Some(vec![[-128, 0, 0, 0], [64, 0, 0, 0], [0, 0, 127, 0]]);
+ let mesh = project_msh_to_static_mesh_in_world_space_with_transform(
+ &source,
+ LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0, 0.0, std::f32::consts::FRAC_PI_2],
+ },
+ [2.0; 3],
+ )
+ .expect("finite packed normals");
+ let lengths = mesh
+ .vertices
+ .iter()
+ .map(|vertex| {
+ vertex.normal[0]
+ .mul_add(
+ vertex.normal[0],
+ vertex.normal[1]
+ .mul_add(vertex.normal[1], vertex.normal[2] * vertex.normal[2]),
+ )
+ .sqrt()
+ })
+ .collect::<Vec<_>>();
+ assert!((lengths[0] - 128.0 / 127.0).abs() < 0.0001);
+ assert!((lengths[1] - 64.0 / 127.0).abs() < 0.0001);
+ }
+
+ #[test]
fn xy_preview_applies_recovered_iron3d_orientation_before_framing() {
let source = model(
vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 0.0]],
@@ -1049,6 +1644,8 @@ mod tests {
node_stride: 38,
node_count: 2,
nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 0.0,
slots: vec![
Slot {
tri_start: 0,
@@ -1076,6 +1673,7 @@ mod tests {
positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
normals: None,
uv0: None,
+ uv1: None,
indices: vec![0, 1, 2, 0, 1, 2],
batches: vec![batch(0, 3, 0), batch(3, 3, 0)],
node_names: None,
@@ -1114,6 +1712,248 @@ mod tests {
}
#[test]
+ fn mounted_socket_pose_strips_only_a_replaced_parent_root() {
+ let mut nodes = vec![0_u8; 76];
+ nodes[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
+ nodes[6..8].copy_from_slice(&0_u16.to_le_bytes());
+ nodes[38 + 2..38 + 4].copy_from_slice(&0_u16.to_le_bytes());
+ nodes[38 + 6..38 + 8].copy_from_slice(&1_u16.to_le_bytes());
+ let source = ModelAsset {
+ node_stride: 38,
+ node_count: 2,
+ nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 0.0,
+ slots: Vec::new(),
+ positions: Vec::new(),
+ normals: None,
+ uv0: None,
+ uv1: None,
+ indices: Vec::new(),
+ batches: Vec::new(),
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
+ translation: [5.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
+ translation: [2.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ ],
+ frame_map: Vec::new(),
+ frame_count: 0,
+ }),
+ };
+
+ assert_eq!(
+ node38_pose(&source, None, 1)
+ .expect("full socket")
+ .translation,
+ [7.0, 0.0, 0.0]
+ );
+ assert_eq!(
+ node38_pose_relative_to_root(&source, None, 1)
+ .expect("relative socket")
+ .translation,
+ [2.0, 0.0, 0.0]
+ );
+ }
+
+ #[test]
+ fn mounted_child_uses_precomputed_hierarchies_for_socket_and_geometry() {
+ let parent_hierarchy = NodePoseBuffer {
+ poses: vec![
+ Pose {
+ translation: [20.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ Pose {
+ translation: [25.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ ],
+ };
+ let parent_socket = node38_pose_relative_to_root_from_hierarchy(&parent_hierarchy, 1)
+ .expect("sampled parent socket relative to its mounted root");
+ assert_eq!(parent_socket.translation, [5.0, 0.0, 0.0]);
+ assert_eq!(
+ node38_pose_from_hierarchy(&parent_hierarchy, 1)
+ .expect("sampled parent socket")
+ .translation,
+ [25.0, 0.0, 0.0]
+ );
+
+ let mut child_root = vec![0_u8; 38];
+ child_root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
+ child_root[6..8].copy_from_slice(&0_u16.to_le_bytes());
+ let mut child_node = vec![0_u8; 38];
+ child_node[2..4].copy_from_slice(&0_u16.to_le_bytes());
+ child_node[6..8].copy_from_slice(&1_u16.to_le_bytes());
+ child_node[8..10].copy_from_slice(&1_u16.to_le_bytes());
+ let mut nodes = child_root;
+ nodes.extend(child_node);
+ let child = ModelAsset {
+ node_stride: 38,
+ node_count: 2,
+ nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 0.0,
+ slots: vec![
+ Slot {
+ tri_start: 0,
+ tri_count: 0,
+ batch_start: 0,
+ batch_count: 1,
+ aabb_min: [0.0; 3],
+ aabb_max: [1.0; 3],
+ sphere_center: [0.0; 3],
+ sphere_radius: 1.0,
+ opaque: [0; 5],
+ },
+ Slot {
+ tri_start: 0,
+ tri_count: 0,
+ batch_start: 1,
+ batch_count: 1,
+ aabb_min: [0.0; 3],
+ aabb_max: [1.0; 3],
+ sphere_center: [0.0; 3],
+ sphere_radius: 1.0,
+ opaque: [0; 5],
+ },
+ ],
+ positions: vec![[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
+ normals: None,
+ uv0: None,
+ uv1: None,
+ indices: vec![0, 1, 2, 0, 1, 2],
+ batches: vec![batch(0, 3, 0), batch(3, 3, 0)],
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose::default(),
+ }],
+ frame_map: Vec::new(),
+ frame_count: 0,
+ }),
+ };
+ let child_hierarchy = NodePoseBuffer {
+ poses: vec![
+ Pose {
+ translation: [10.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ Pose {
+ translation: [14.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ ],
+ };
+ let child_socket = node38_pose_relative_to_root_from_hierarchy(&child_hierarchy, 1)
+ .expect("child node relative to its mounted root");
+ assert_eq!(child_socket.translation, [4.0, 0.0, 0.0]);
+
+ // The parent component's world root is at x=100, so its sampled socket
+ // at x=5 places the child root at x=105. The child geometry uses the
+ // same child hierarchy as the socket path: root x=10 is stripped and
+ // node 1 contributes x=4 before the source vertex at x=1.
+ let mesh = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
+ &child,
+ LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0; 3],
+ },
+ [1.0; 3],
+ &child_hierarchy,
+ VulkanNodePose {
+ translation: [105.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ )
+ .expect("mounted child geometry from sampled pose buffer");
+ assert_eq!(mesh.vertices[0].position, [110.0, 0.0, 0.0]);
+ assert_eq!(mesh.indices, vec![0, 1, 2]);
+ }
+
+ #[test]
+ fn supplied_node_pose_buffer_must_match_model_node_count() {
+ let source = model(Vec::new(), Vec::new(), Vec::new());
+ let error = project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
+ &source,
+ LegacyIron3dEulerTransform {
+ translation: [0.0; 3],
+ orientation_radians: [0.0; 3],
+ },
+ [1.0; 3],
+ &NodePoseBuffer {
+ poses: vec![Pose::default()],
+ },
+ )
+ .expect_err("mismatched node pose count");
+ assert_eq!(
+ error,
+ VulkanAssetMeshError::NodePoseCountMismatch {
+ expected: 0,
+ actual: 1,
+ }
+ );
+ }
+
+ #[test]
+ fn node_pose_projection_preserves_an_intentionally_empty_component() {
+ let mut node = vec![0_u8; 38];
+ node[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
+ node[6..8].copy_from_slice(&0_u16.to_le_bytes());
+ node[8..10].copy_from_slice(&u16::MAX.to_le_bytes());
+ let source = ModelAsset {
+ node_stride: 38,
+ node_count: 1,
+ nodes_raw: node,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 0.0,
+ slots: Vec::new(),
+ positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
+ normals: None,
+ uv0: None,
+ uv1: None,
+ indices: vec![0, 1, 2],
+ batches: vec![batch(0, 3, 0)],
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose::default(),
+ }],
+ frame_map: Vec::new(),
+ frame_count: 0,
+ }),
+ };
+
+ let mesh = project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
+ &source,
+ LegacyIron3dEulerTransform {
+ translation: [10.0, 20.0, 30.0],
+ orientation_radians: [0.0; 3],
+ },
+ [1.0; 3],
+ )
+ .expect("empty node component remains representable");
+ assert!(mesh.vertices.is_empty());
+ assert!(mesh.indices.is_empty());
+ assert!(mesh.draw_ranges.is_empty());
+ }
+
+ #[test]
fn rotates_point_by_unit_quaternion() {
let rotated = rotate_by_quaternion(
[1.0, 0.0, 0.0],
@@ -1152,14 +1992,18 @@ mod tests {
first_index: 0,
index_count: 3,
material_index: 0,
- pipeline_state: LegacyPipelineState::default(),
+ lightmap_index: 0,
+ batch_flags: 0,
+ pipeline_state: world_pipeline_state(),
alpha_test_reference: 0,
},
VulkanStaticDrawRange {
first_index: 3,
index_count: 3,
material_index: 7,
- pipeline_state: LegacyPipelineState::default(),
+ lightmap_index: 0,
+ batch_flags: 0,
+ pipeline_state: world_pipeline_state(),
alpha_test_reference: 0,
},
]
@@ -1182,7 +2026,7 @@ mod tests {
[2.0, 3.0, 1.0],
],
normals: Vec::new(),
- uv0: vec![[1024, -512], [0, 2048], [-1024, 512], [512, 0]],
+ uv0: vec![[1024, 65024], [0, 2048], [64512, 512], [512, 0]],
accelerator: Vec::new(),
aux14: Vec::new(),
aux18: Vec::new(),
@@ -1196,8 +2040,31 @@ mod tests {
assert_eq!(mesh.draw_ranges[0].index_count, 6);
assert_eq!(mesh.vertices[0].position, [-0.8, -0.8, 0.0]);
assert_eq!(mesh.vertices[3].position, [0.8, 0.8, 0.0]);
- assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]);
- assert_eq!(mesh.vertices[2].uv, [-1.0, 0.5]);
+ assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]);
+ assert_eq!(mesh.vertices[2].uv, [63.0, 0.5]);
+ }
+
+ #[test]
+ fn terrain_normals_keep_native_one_over_128_scale() {
+ let terrain = LandMeshDocument {
+ streams: Vec::new(),
+ nodes_raw: Vec::new(),
+ slots: TerrainSlotTable {
+ header_raw: Vec::new(),
+ slots_raw: Vec::new(),
+ },
+ positions: vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
+ normals: vec![[127, -128, 64, 0]; 3],
+ uv0: vec![[0, 0]; 3],
+ accelerator: Vec::new(),
+ aux14: Vec::new(),
+ aux18: Vec::new(),
+ faces: vec![terrain_face([0, 1, 2])],
+ };
+
+ let mesh = project_land_msh_to_static_mesh_in_world_space(&terrain)
+ .expect("representable terrain normals");
+ assert_eq!(mesh.vertices[0].normal, [127.0 / 128.0, -1.0, 0.5]);
}
#[test]
@@ -1211,7 +2078,7 @@ mod tests {
},
positions: vec![[10.0, 20.0, 30.0], [40.0, 50.0, 60.0], [70.0, 80.0, 90.0]],
normals: Vec::new(),
- uv0: vec![[1024, -512], [0, 2048], [-1024, 512]],
+ uv0: vec![[1024, 65024], [0, 2048], [64512, 512]],
accelerator: Vec::new(),
aux14: Vec::new(),
aux18: Vec::new(),
@@ -1224,7 +2091,7 @@ mod tests {
assert_eq!(mesh.indices, vec![2, 0, 1]);
assert_eq!(mesh.vertices[0].position, [10.0, 20.0, 30.0]);
assert_eq!(mesh.vertices[2].position, [70.0, 80.0, 90.0]);
- assert_eq!(mesh.vertices[0].uv, [1.0, -0.5]);
+ assert_eq!(mesh.vertices[0].uv, [1.0, 63.5]);
}
#[test]
diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs
index 79ca919..83fdbbf 100644
--- a/adapters/fparkan-render-vulkan/src/ffi.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi.rs
@@ -1,22 +1,3 @@
-#![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.
//!
@@ -40,16 +21,22 @@ 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,
+ node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
+ node38_pose_relative_to_root_from_hierarchy, 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_fallback_poses_and_mount,
+ project_msh_to_static_mesh_in_world_space_with_node_pose_buffer,
+ project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount,
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
+ project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount,
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,
+ VulkanNodePose, VulkanStaticXyFrame,
};
pub use self::capabilities::{
probe_vulkan_runtime_capabilities, probe_vulkan_runtime_capabilities_for_request,
@@ -57,42 +44,48 @@ pub use self::capabilities::{
};
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,
+ 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_frame_uniform_buffer, create_index_buffer, create_material_specular_buffer,
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,
+ create_static_texture_image, create_vertex_buffer, destroy_allocated_buffer,
+ destroy_allocated_image, destroy_depth_attachment, readback_buffer_bytes,
+ update_static_texture_image, write_frame_uniform, write_material_specular_buffer,
+ write_static_mesh_index_buffer, write_static_mesh_vertex_buffer, 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::{
+ VulkanDirectionalLight, VulkanDynamicDrawRange, VulkanFrameUniforms, VulkanPointLight,
VulkanReadbackArtifact, VulkanSmokeBootstrapProgress, VulkanSmokeBootstrapSnapshot,
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
VulkanStaticCamera, VulkanStaticDrawRange, VulkanStaticMaterial, VulkanStaticMesh,
- VulkanStaticTexture, VulkanStaticVertex, VulkanValidationReport,
+ VulkanStaticTexture, VulkanStaticTextureMip, VulkanStaticVertex, VulkanValidationReport,
+ VULKAN_DIRECTIONAL_LIGHT_COUNT, VULKAN_FRAME_UNIFORM_BYTES,
+ VULKAN_MATERIAL_SPECULAR_RECORD_BYTES,
};
#[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,
+ create_vulkan_surface_probe, plan_vulkan_surface, 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,
+ create_swapchain_resources, destroy_swapchain_resources, update_material_texture_descriptor,
+ VulkanSwapchainResources,
};
use self::validation::{create_validation_messenger, VulkanValidationMessenger};
use ash::vk;
@@ -489,6 +482,8 @@ const LEGACY_TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &[
];
const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT] {
+ assert!(bytes.len() % 4 == 0);
+ assert!(bytes.len() == WORD_COUNT * 4);
let mut words = [0_u32; WORD_COUNT];
let mut index = 0;
while index < WORD_COUNT {
@@ -504,10 +499,12 @@ const fn spirv_words<const WORD_COUNT: usize>(bytes: &[u8]) -> [u32; WORD_COUNT]
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"));
+const TRIANGLE_VERTEX_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.vert.spv");
+const TRIANGLE_FRAGMENT_SHADER_BYTES: &[u8] = include_bytes!("../shaders/triangle.frag.spv");
+static TRIANGLE_VERTEX_SHADER_DATA: [u32; TRIANGLE_VERTEX_SHADER_BYTES.len() / 4] =
+ spirv_words(TRIANGLE_VERTEX_SHADER_BYTES);
+static TRIANGLE_FRAGMENT_SHADER_DATA: [u32; TRIANGLE_FRAGMENT_SHADER_BYTES.len() / 4] =
+ spirv_words(TRIANGLE_FRAGMENT_SHADER_BYTES);
pub(crate) const TRIANGLE_VERTEX_SHADER_WORDS: &[u32] = &TRIANGLE_VERTEX_SHADER_DATA;
pub(crate) const TRIANGLE_FRAGMENT_SHADER_WORDS: &[u32] = &TRIANGLE_FRAGMENT_SHADER_DATA;
diff --git a/adapters/fparkan-render-vulkan/src/ffi/instance.rs b/adapters/fparkan-render-vulkan/src/ffi/instance.rs
index 42b9bdb..463fa03 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/instance.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/instance.rs
@@ -1,7 +1,6 @@
#![allow(unsafe_code)]
use ash::vk;
-use serde::Serialize;
use std::collections::BTreeSet;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
@@ -10,7 +9,6 @@ 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)]
@@ -236,28 +234,6 @@ pub fn create_vulkan_instance_probe(
})
}
-/// 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
@@ -288,26 +264,6 @@ 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.
diff --git a/adapters/fparkan-render-vulkan/src/ffi/resources.rs b/adapters/fparkan-render-vulkan/src/ffi/resources.rs
index 1d38503..b841588 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs
@@ -18,6 +18,9 @@ pub(super) struct VulkanAllocatedImage {
pub(super) image: vk::Image,
pub(super) memory: vk::DeviceMemory,
pub(super) view: vk::ImageView,
+ pub(super) width: u32,
+ pub(super) height: u32,
+ pub(super) mip_levels: u32,
}
/// Depth/stencil image and the exact format selected for its render pass.
@@ -26,7 +29,6 @@ pub(super) struct VulkanDepthAttachment {
pub(super) format: vk::Format,
}
-#[allow(clippy::too_many_lines)]
pub(super) fn create_depth_attachment(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
@@ -135,6 +137,9 @@ pub(super) fn create_depth_attachment(
image,
memory,
view,
+ width: extent.0,
+ height: extent.1,
+ mip_levels: 1,
},
format,
})
@@ -187,7 +192,6 @@ fn depth_aspect(format: vk::Format) -> vk::ImageAspectFlags {
pub(super) struct VulkanFrameSync {
pub(super) image_available: vk::Semaphore,
- pub(super) render_finished: vk::Semaphore,
pub(super) fence: vk::Fence,
}
@@ -211,24 +215,112 @@ pub(super) fn create_static_mesh_vertex_buffer(
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 {
+ let bytes = encode_static_vertices(&mesh.vertices)?;
+ create_host_visible_buffer(
+ instance,
+ device,
+ &bytes,
+ vk::BufferUsageFlags::VERTEX_BUFFER,
+ "static mesh vertex buffer",
+ )
+}
+
+/// Allocates a vertex buffer for a frame's retained mesh capacity.
+pub(super) fn create_vertex_buffer(
+ instance: &VulkanInstanceProbe,
+ device: &VulkanLogicalDeviceProbe,
+ vertices: &[super::VulkanStaticVertex],
+) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
+ let bytes = encode_static_vertices(vertices)?;
+ create_host_visible_buffer(
+ instance,
+ device,
+ &bytes,
+ vk::BufferUsageFlags::VERTEX_BUFFER,
+ "dynamic mesh vertex buffer",
+ )
+}
+
+/// Replaces the bytes of a host-visible static vertex allocation.
+///
+/// The live renderer waits for the device before calling this helper, so a
+/// single shared allocation can safely carry camera-relative sky and weather
+/// geometry between frames.
+pub(super) fn write_static_mesh_vertex_buffer(
+ device: &VulkanLogicalDeviceProbe,
+ buffer: &VulkanAllocatedBuffer,
+ vertices: &[super::VulkanStaticVertex],
+) -> Result<(), VulkanSmokeRendererError> {
+ let bytes = encode_static_vertices(vertices)?;
+ write_mapped_buffer(
+ device,
+ buffer,
+ &bytes,
+ "vkMapMemory(static mesh vertex update)",
+ )
+}
+
+fn encode_static_vertices(
+ vertices: &[super::VulkanStaticVertex],
+) -> Result<Vec<u8>, VulkanSmokeRendererError> {
+ let byte_len = vertices
+ .len()
+ .checked_mul(14)
+ .and_then(|count| count.checked_mul(std::mem::size_of::<f32>()))
+ .ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "static mesh vertex data size overflows address space",
+ })?;
+ let mut bytes = Vec::new();
+ bytes
+ .try_reserve_exact(byte_len)
+ .map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "static mesh vertex data exceeds addressable memory",
+ })?;
+ for vertex in vertices {
for value in vertex
.position
.into_iter()
.chain(vertex.color)
+ .chain(vertex.normal)
.chain(vertex.uv)
+ .chain(vertex.detail_uv)
+ .chain([vertex.overlay_alpha])
{
bytes.extend_from_slice(&value.to_ne_bytes());
}
}
- create_host_visible_buffer(
- instance,
- device,
- &bytes,
- vk::BufferUsageFlags::VERTEX_BUFFER,
- "static mesh vertex buffer",
- )
+ Ok(bytes)
+}
+
+fn write_mapped_buffer(
+ device: &VulkanLogicalDeviceProbe,
+ buffer: &VulkanAllocatedBuffer,
+ bytes: &[u8],
+ context: &'static str,
+) -> Result<(), VulkanSmokeRendererError> {
+ let byte_len =
+ u64::try_from(bytes.len()).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "static mesh upload exceeds address space",
+ })?;
+ if byte_len == 0 {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "static mesh upload cannot be empty",
+ });
+ }
+ // SAFETY: The caller validates that the allocation is large enough and
+ // waits for all queue work that could read it before this write.
+ let mapped = unsafe {
+ device
+ .device()
+ .map_memory(buffer.memory, 0, byte_len, vk::MemoryMapFlags::empty())
+ }
+ .map_err(|result| VulkanSmokeRendererError::VulkanOperation { context, result })?;
+ // SAFETY: `mapped` covers exactly the byte slice copied below.
+ unsafe {
+ std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
+ device.device().unmap_memory(buffer.memory);
+ }
+ Ok(())
}
pub(super) fn create_static_mesh_index_buffer(
@@ -236,30 +328,73 @@ pub(super) fn create_static_mesh_index_buffer(
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_index_buffer(instance, device, &mesh.indices)
+}
+
+/// Allocates an index buffer for a frame's retained mesh capacity.
+pub(super) fn create_index_buffer(
+ instance: &VulkanInstanceProbe,
+ device: &VulkanLogicalDeviceProbe,
+ indices: &[u32],
+) -> Result<VulkanAllocatedBuffer, VulkanSmokeRendererError> {
+ let bytes = encode_static_indices(indices)?;
create_host_visible_buffer(
instance,
device,
&bytes,
vk::BufferUsageFlags::INDEX_BUFFER,
- "static mesh index buffer",
+ "dynamic mesh index buffer",
)
}
-#[allow(clippy::too_many_lines)]
+/// Replaces the bytes of a host-visible static index allocation.
+pub(super) fn write_static_mesh_index_buffer(
+ device: &VulkanLogicalDeviceProbe,
+ buffer: &VulkanAllocatedBuffer,
+ indices: &[u32],
+) -> Result<(), VulkanSmokeRendererError> {
+ let bytes = encode_static_indices(indices)?;
+ write_mapped_buffer(
+ device,
+ buffer,
+ &bytes,
+ "vkMapMemory(static mesh index update)",
+ )
+}
+
+fn encode_static_indices(indices: &[u32]) -> Result<Vec<u8>, VulkanSmokeRendererError> {
+ let byte_len = indices
+ .len()
+ .checked_mul(std::mem::size_of::<u32>())
+ .ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "static mesh index data size overflows address space",
+ })?;
+ let mut bytes = Vec::new();
+ bytes
+ .try_reserve_exact(byte_len)
+ .map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "static mesh index data exceeds addressable memory",
+ })?;
+ for &index in indices {
+ bytes.extend_from_slice(&index.to_ne_bytes());
+ }
+ Ok(bytes)
+}
+
pub(super) fn create_static_texture_image(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
texture: &VulkanStaticTexture,
) -> Result<VulkanAllocatedImage, VulkanSmokeRendererError> {
+ texture
+ .validate()
+ .map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?;
+ let (mip_bytes, mip_levels) = build_static_texture_mip_chain(texture);
let staging = create_host_visible_buffer(
instance,
device,
- &texture.rgba8,
+ &mip_bytes,
vk::BufferUsageFlags::TRANSFER_SRC,
"static texture staging buffer",
)?;
@@ -271,7 +406,7 @@ pub(super) fn create_static_texture_image(
height: texture.height,
depth: 1,
})
- .mip_levels(1)
+ .mip_levels(mip_levels)
.array_layers(1)
.samples(vk::SampleCountFlags::TYPE_1)
.tiling(vk::ImageTiling::OPTIMAL)
@@ -338,6 +473,7 @@ pub(super) fn create_static_texture_image(
image,
texture.width,
texture.height,
+ mip_levels,
) {
// SAFETY: Both resources were created on this device and are being rolled back once.
unsafe {
@@ -352,7 +488,7 @@ pub(super) fn create_static_texture_image(
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::R8G8B8A8_UNORM)
- .subresource_range(color_subresource_range());
+ .subresource_range(color_subresource_range(mip_levels));
// 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.
@@ -373,10 +509,211 @@ pub(super) fn create_static_texture_image(
image,
memory,
view,
+ width: texture.width,
+ height: texture.height,
+ mip_levels,
})
}
-#[allow(clippy::too_many_lines)]
+/// Allocates two aligned camera slots used by the dynamic frame UBO.
+pub(super) fn create_frame_uniform_buffer(
+ instance: &VulkanInstanceProbe,
+ device: &VulkanLogicalDeviceProbe,
+) -> Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> {
+ const FRAME_SLOTS: u64 = 2;
+ const FRAME_BYTES: u64 = super::VULKAN_FRAME_UNIFORM_BYTES as u64;
+ let limits =
+ // SAFETY: The physical device belongs to this live instance and the query returns a value copy.
+ unsafe { instance.instance.get_physical_device_properties(device.physical_device()) };
+ let alignment = u64::from(limits.limits.min_uniform_buffer_offset_alignment).max(1);
+ let stride = FRAME_BYTES.checked_add(alignment - 1).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "frame uniform stride overflow",
+ },
+ )? / alignment
+ * alignment;
+ let size =
+ stride
+ .checked_mul(FRAME_SLOTS)
+ .ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "frame uniform buffer size overflow",
+ })?;
+ let bytes = vec![
+ 0_u8;
+ usize::try_from(size).map_err(|_| {
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "frame uniform buffer size exceeds address space",
+ }
+ })?
+ ];
+ let buffer = create_host_visible_buffer(
+ instance,
+ device,
+ &bytes,
+ vk::BufferUsageFlags::UNIFORM_BUFFER,
+ "frame uniform buffer",
+ )?;
+ Ok((buffer, stride))
+}
+
+/// Allocates one aligned storage-buffer record for every material descriptor.
+///
+/// The descriptor uses a dynamic storage-buffer offset. Keeping the stride
+/// aligned to the physical device limit lets the renderer select a material
+/// record with the same descriptor set for every draw without baking material
+/// indices into the pipeline layout.
+pub(super) fn create_material_specular_buffer(
+ instance: &VulkanInstanceProbe,
+ device: &VulkanLogicalDeviceProbe,
+ material_count: usize,
+) -> Result<(VulkanAllocatedBuffer, u64), VulkanSmokeRendererError> {
+ if material_count == 0 {
+ return Err(VulkanSmokeRendererError::InvariantViolation {
+ context: "material specular buffer has no material records",
+ });
+ }
+ let limits =
+ // SAFETY: The physical device belongs to this live instance and the query returns a value copy.
+ unsafe { instance.instance.get_physical_device_properties(device.physical_device()) };
+ let alignment = limits.limits.min_storage_buffer_offset_alignment.max(1);
+ let record_bytes = super::VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64;
+ let stride = record_bytes.checked_add(alignment - 1).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "material specular stride overflow",
+ },
+ )? / alignment
+ * alignment;
+ let count =
+ u64::try_from(material_count).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "material specular count exceeds address space",
+ })?;
+ let size = stride
+ .checked_mul(count)
+ .ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "material specular buffer size overflow",
+ })?;
+ let bytes =
+ vec![
+ 0_u8;
+ usize::try_from(size).map_err(|_| VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "material specular buffer size exceeds address space",
+ })?
+ ];
+ let buffer = create_host_visible_buffer(
+ instance,
+ device,
+ &bytes,
+ vk::BufferUsageFlags::STORAGE_BUFFER,
+ "material specular buffer",
+ )?;
+ Ok((buffer, stride))
+}
+
+/// Replaces all material specular records in one host-coherent upload.
+pub(super) fn write_material_specular_buffer(
+ device: &VulkanLogicalDeviceProbe,
+ buffer: &VulkanAllocatedBuffer,
+ stride: u64,
+ records: &[[[f32; 4]; 2]],
+) -> Result<(), VulkanSmokeRendererError> {
+ if records.is_empty() {
+ return Err(VulkanSmokeRendererError::InvariantViolation {
+ context: "material specular upload has no records",
+ });
+ }
+ let record_bytes = super::VULKAN_MATERIAL_SPECULAR_RECORD_BYTES;
+ let total_bytes = stride
+ .checked_mul(u64::try_from(records.len()).unwrap_or(u64::MAX))
+ .ok_or(VulkanSmokeRendererError::InvariantViolation {
+ context: "material specular upload size",
+ })?;
+ let total_bytes_usize =
+ usize::try_from(total_bytes).map_err(|_| VulkanSmokeRendererError::InvariantViolation {
+ context: "material specular upload size exceeds address space",
+ })?;
+ let mut bytes = vec![0_u8; total_bytes_usize];
+ for (record_index, record) in records.iter().enumerate() {
+ let record_offset = usize::try_from(
+ stride
+ .checked_mul(u64::try_from(record_index).unwrap_or(u64::MAX))
+ .ok_or(VulkanSmokeRendererError::InvariantViolation {
+ context: "material specular upload offset",
+ })?,
+ )
+ .map_err(|_| VulkanSmokeRendererError::InvariantViolation {
+ context: "material specular upload offset exceeds address space",
+ })?;
+ let mut offset = record_offset;
+ for vector in record {
+ for value in vector {
+ bytes[offset..offset + std::mem::size_of::<f32>()]
+ .copy_from_slice(&value.to_ne_bytes());
+ offset += std::mem::size_of::<f32>();
+ }
+ }
+ debug_assert_eq!(offset - record_offset, record_bytes);
+ }
+ // SAFETY: The storage buffer is host-visible and the caller has waited for
+ // all prior uses before replacing its records.
+ let mapped = unsafe {
+ device
+ .device()
+ .map_memory(buffer.memory, 0, total_bytes, vk::MemoryMapFlags::empty())
+ }
+ .map_err(|result| VulkanSmokeRendererError::VulkanOperation {
+ context: "vkMapMemory(material specular buffer)",
+ result,
+ })?;
+ // SAFETY: The mapped range is the complete upload and the destination is
+ // valid for the exact byte count copied below.
+ unsafe {
+ std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
+ device.device().unmap_memory(buffer.memory);
+ }
+ Ok(())
+}
+
+/// Writes one complete frame block into the slot selected by the
+/// fence-safe frame index.
+pub(super) fn write_frame_uniform(
+ device: &VulkanLogicalDeviceProbe,
+ buffer: &VulkanAllocatedBuffer,
+ stride: u64,
+ frame_index: usize,
+ bytes: &[u8; super::VULKAN_FRAME_UNIFORM_BYTES],
+) -> Result<(), VulkanSmokeRendererError> {
+ let slot =
+ u64::try_from(frame_index).map_err(|_| VulkanSmokeRendererError::InvariantViolation {
+ context: "frame uniform slot index",
+ })?;
+ let offset = stride
+ .checked_mul(slot)
+ .ok_or(VulkanSmokeRendererError::InvariantViolation {
+ context: "frame uniform slot offset",
+ })?;
+ // SAFETY: The offset is one of the two allocated aligned slots and the
+ // mapped range is exactly one complete frame block.
+ let mapped = unsafe {
+ device.device().map_memory(
+ buffer.memory,
+ offset,
+ super::VULKAN_FRAME_UNIFORM_BYTES as u64,
+ vk::MemoryMapFlags::empty(),
+ )
+ }
+ .map_err(|result| VulkanSmokeRendererError::VulkanOperation {
+ context: "vkMapMemory(frame uniform buffer)",
+ result,
+ })?;
+ // SAFETY: The destination is the mapped frame slot and the byte slice lives
+ // for this copy.
+ unsafe {
+ std::ptr::copy_nonoverlapping(bytes.as_ptr(), mapped.cast::<u8>(), bytes.len());
+ device.device().unmap_memory(buffer.memory);
+ }
+ Ok(())
+}
+
fn upload_static_texture(
device: &VulkanLogicalDeviceProbe,
command_pool: vk::CommandPool,
@@ -384,6 +721,7 @@ fn upload_static_texture(
image: vk::Image,
width: u32,
height: u32,
+ mip_levels: u32,
) -> Result<(), VulkanSmokeRendererError> {
let allocate_info = vk::CommandBufferAllocateInfo::default()
.command_pool(command_pool)
@@ -410,7 +748,7 @@ fn upload_static_texture(
result,
});
}
- let range = color_subresource_range();
+ let range = color_subresource_range(mip_levels);
let to_transfer = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::UNDEFINED)
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
@@ -418,19 +756,34 @@ fn upload_static_texture(
.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 mut regions = Vec::with_capacity(usize::try_from(mip_levels).unwrap_or(0));
+ let mut buffer_offset = 0_u64;
+ let mut mip_width = width;
+ let mut mip_height = height;
+ for mip_level in 0..mip_levels {
+ let mip_bytes = u64::from(mip_width)
+ .saturating_mul(u64::from(mip_height))
+ .saturating_mul(4);
+ regions.push(
+ vk::BufferImageCopy::default()
+ .buffer_offset(buffer_offset)
+ .image_subresource(
+ vk::ImageSubresourceLayers::default()
+ .aspect_mask(vk::ImageAspectFlags::COLOR)
+ .mip_level(mip_level)
+ .base_array_layer(0)
+ .layer_count(1),
+ )
+ .image_extent(vk::Extent3D {
+ width: mip_width,
+ height: mip_height,
+ depth: 1,
+ }),
+ );
+ buffer_offset = buffer_offset.saturating_add(mip_bytes);
+ mip_width = (mip_width / 2).max(1);
+ mip_height = (mip_height / 2).max(1);
+ }
let to_sampled = vk::ImageMemoryBarrier::default()
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
@@ -454,7 +807,7 @@ fn upload_static_texture(
staging_buffer,
image,
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
- &[region],
+ &regions,
);
device.device().cmd_pipeline_barrier(
command_buffer,
@@ -503,6 +856,246 @@ fn upload_static_texture(
})
}
+/// Replaces the contents of an already sampled texture without changing its
+/// image view or descriptor set. The caller must wait for the renderer's
+/// submitted work before invoking this function.
+pub(super) fn update_static_texture_image(
+ instance: &VulkanInstanceProbe,
+ device: &VulkanLogicalDeviceProbe,
+ command_pool: vk::CommandPool,
+ image: &VulkanAllocatedImage,
+ texture: &VulkanStaticTexture,
+) -> Result<(), VulkanSmokeRendererError> {
+ texture
+ .validate()
+ .map_err(|context| VulkanSmokeRendererError::InvalidStaticTexture { context })?;
+ let (mip_bytes, mip_levels) = build_static_texture_mip_chain(texture);
+ if texture.width != image.width
+ || texture.height != image.height
+ || mip_levels != image.mip_levels
+ {
+ return Err(VulkanSmokeRendererError::InvalidStaticTexture {
+ context: "dynamic texture extent or mip count differs from allocation",
+ });
+ }
+ let staging = create_host_visible_buffer(
+ instance,
+ device,
+ &mip_bytes,
+ vk::BufferUsageFlags::TRANSFER_SRC,
+ "dynamic texture staging buffer",
+ )?;
+ 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| {
+ destroy_allocated_buffer(device, &staging);
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkAllocateCommandBuffers(dynamic 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]);
+ };
+ destroy_allocated_buffer(device, &staging);
+ return Err(VulkanSmokeRendererError::VulkanOperation {
+ context: "vkBeginCommandBuffer(dynamic texture upload)",
+ result,
+ });
+ }
+ let range = color_subresource_range(mip_levels);
+ let to_transfer = vk::ImageMemoryBarrier::default()
+ .old_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
+ .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
+ .src_access_mask(vk::AccessFlags::SHADER_READ)
+ .dst_access_mask(vk::AccessFlags::TRANSFER_WRITE)
+ .image(image.image)
+ .subresource_range(range);
+ let mut regions = Vec::with_capacity(usize::try_from(mip_levels).unwrap_or(0));
+ let mut buffer_offset = 0_u64;
+ let mut mip_width = texture.width;
+ let mut mip_height = texture.height;
+ for mip_level in 0..mip_levels {
+ let mip_bytes = u64::from(mip_width)
+ .saturating_mul(u64::from(mip_height))
+ .saturating_mul(4);
+ regions.push(
+ vk::BufferImageCopy::default()
+ .buffer_offset(buffer_offset)
+ .image_subresource(
+ vk::ImageSubresourceLayers::default()
+ .aspect_mask(vk::ImageAspectFlags::COLOR)
+ .mip_level(mip_level)
+ .base_array_layer(0)
+ .layer_count(1),
+ )
+ .image_extent(vk::Extent3D {
+ width: mip_width,
+ height: mip_height,
+ depth: 1,
+ }),
+ );
+ buffer_offset = buffer_offset.saturating_add(mip_bytes);
+ mip_width = (mip_width / 2).max(1);
+ mip_height = (mip_height / 2).max(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.image)
+ .subresource_range(range);
+ // SAFETY: The caller has made all prior shader reads idle, and the image
+ // remains exclusively owned by this graphics queue.
+ unsafe {
+ device.device().cmd_pipeline_barrier(
+ command_buffer,
+ vk::PipelineStageFlags::FRAGMENT_SHADER,
+ vk::PipelineStageFlags::TRANSFER,
+ vk::DependencyFlags::empty(),
+ &[],
+ &[],
+ &[to_transfer],
+ );
+ device.device().cmd_copy_buffer_to_image(
+ command_buffer,
+ staging.buffer,
+ image.image,
+ vk::ImageLayout::TRANSFER_DST_OPTIMAL,
+ &regions,
+ );
+ 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]);
+ };
+ destroy_allocated_buffer(device, &staging);
+ return Err(VulkanSmokeRendererError::VulkanOperation {
+ context: "vkEndCommandBuffer(dynamic 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 remain live
+ // until this synchronous wait completes.
+ let submit_result = unsafe {
+ device
+ .device()
+ .queue_submit(device.graphics_queue(), &submit, vk::Fence::null())
+ };
+ let result = submit_result
+ .and_then(|()| unsafe { device.device().queue_wait_idle(device.graphics_queue()) });
+ // SAFETY: Submission completed or failed synchronously.
+ unsafe {
+ device
+ .device()
+ .free_command_buffers(command_pool, &[command_buffer]);
+ }
+ destroy_allocated_buffer(device, &staging);
+ result.map_err(|result| VulkanSmokeRendererError::VulkanOperation {
+ context: "vkQueueSubmit/WaitIdle(dynamic texture upload)",
+ result,
+ })
+}
+
+/// Builds an RGBA8 mip chain on the CPU so texture upload does not depend on
+/// optional linear-blit support for the device's UNORM format.
+fn build_static_texture_mip_chain(texture: &VulkanStaticTexture) -> (Vec<u8>, u32) {
+ if !texture.mip_levels.is_empty() {
+ let mut bytes = Vec::new();
+ for mip in &texture.mip_levels {
+ bytes.extend_from_slice(&mip.rgba8);
+ }
+ return (
+ bytes,
+ u32::try_from(texture.mip_levels.len()).unwrap_or(u32::MAX),
+ );
+ }
+ let mut bytes = texture.rgba8.clone();
+ let mut level = texture.rgba8.clone();
+ let mut width = texture.width;
+ let mut height = texture.height;
+ let mut mip_levels = 1_u32;
+
+ while width > 1 || height > 1 {
+ let next_width = (width / 2).max(1);
+ let next_height = (height / 2).max(1);
+ let mut next = vec![
+ 0_u8;
+ usize::try_from(next_width)
+ .unwrap_or(0)
+ .saturating_mul(usize::try_from(next_height).unwrap_or(0))
+ .saturating_mul(4)
+ ];
+ for y in 0..next_height {
+ for x in 0..next_width {
+ let dst = (usize::try_from(y).unwrap_or(0)
+ * usize::try_from(next_width).unwrap_or(0)
+ + usize::try_from(x).unwrap_or(0))
+ * 4;
+ let x0 = x.saturating_mul(2).min(width.saturating_sub(1));
+ let y0 = y.saturating_mul(2).min(height.saturating_sub(1));
+ let x1 = x0.saturating_add(1).min(width.saturating_sub(1));
+ let y1 = y0.saturating_add(1).min(height.saturating_sub(1));
+ let source_width = usize::try_from(width).unwrap_or(0);
+ let samples = [
+ (usize::try_from(y0).unwrap_or(0) * source_width
+ + usize::try_from(x0).unwrap_or(0))
+ * 4,
+ (usize::try_from(y0).unwrap_or(0) * source_width
+ + usize::try_from(x1).unwrap_or(0))
+ * 4,
+ (usize::try_from(y1).unwrap_or(0) * source_width
+ + usize::try_from(x0).unwrap_or(0))
+ * 4,
+ (usize::try_from(y1).unwrap_or(0) * source_width
+ + usize::try_from(x1).unwrap_or(0))
+ * 4,
+ ];
+ for channel in 0..4 {
+ let sum: u16 = samples
+ .iter()
+ .map(|&offset| u16::from(level[offset + channel]))
+ .sum();
+ next[dst + channel] = u8::try_from((sum + 2) / 4).unwrap_or(u8::MAX);
+ }
+ }
+ }
+ bytes.extend_from_slice(&next);
+ level = next;
+ width = next_width;
+ height = next_height;
+ mip_levels = mip_levels.saturating_add(1);
+ }
+ (bytes, mip_levels)
+}
+
pub(super) fn destroy_allocated_image(
device: &VulkanLogicalDeviceProbe,
image: &VulkanAllocatedImage,
@@ -627,36 +1220,24 @@ pub(super) fn create_frame_sync(
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.
+ let image_available =
match unsafe { device.device().create_semaphore(&semaphore_info, None) } {
- Ok(render_finished) => render_finished,
+ Ok(image_available) => image_available,
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)",
+ context: "vkCreateSemaphore(image_available)",
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);
- }
+ // 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: "vkCreateFence",
result: error,
@@ -665,7 +1246,6 @@ pub(super) fn create_frame_sync(
};
sync.push(VulkanFrameSync {
image_available,
- render_finished,
fence,
});
}
@@ -679,9 +1259,6 @@ fn destroy_frame_sync_objects(device: &VulkanLogicalDeviceProbe, sync: &[VulkanF
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);
}
}
@@ -741,11 +1318,11 @@ pub(super) fn readback_buffer_bytes(
Ok(bytes)
}
-pub(super) fn color_subresource_range() -> vk::ImageSubresourceRange {
+pub(super) fn color_subresource_range(level_count: u32) -> vk::ImageSubresourceRange {
vk::ImageSubresourceRange::default()
.aspect_mask(vk::ImageAspectFlags::COLOR)
.base_mip_level(0)
- .level_count(1)
+ .level_count(level_count)
.base_array_layer(0)
.layer_count(1)
}
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
index e41f0f9..e8e18ac 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs
@@ -1,15 +1,19 @@
#![allow(unsafe_code)]
use ash::vk;
+use fparkan_render::LegacyDepthMode;
use super::smoke_types::resolve_draw_texture_indices;
+use super::update_material_texture_descriptor;
use super::{
- create_command_pool, create_frame_sync, create_static_mesh_index_buffer,
+ create_command_pool, create_frame_sync, create_index_buffer, create_static_mesh_index_buffer,
create_static_mesh_vertex_buffer, create_static_texture_image, create_swapchain_resources,
- create_validation_messenger, create_vulkan_instance_probe,
+ create_validation_messenger, create_vertex_buffer, create_vulkan_instance_probe,
create_vulkan_logical_device_probe_for_request, create_vulkan_surface_probe,
create_vulkan_swapchain_probe_for_extent, destroy_allocated_buffer, destroy_allocated_image,
- destroy_swapchain_resources, plan_vulkan_surface, readback_buffer_bytes, VulkanAllocatedBuffer,
+ destroy_swapchain_resources, plan_vulkan_surface, readback_buffer_bytes,
+ update_static_texture_image, write_frame_uniform, write_material_specular_buffer,
+ write_static_mesh_index_buffer, write_static_mesh_vertex_buffer, VulkanAllocatedBuffer,
VulkanInstanceConfig, VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanReadbackArtifact,
VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
VulkanSmokeRendererError, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
@@ -17,7 +21,7 @@ use super::{
VulkanValidationReport,
};
use crate::policy::KHR_PORTABILITY_SUBSET_EXTENSION;
-use crate::shader_manifest::{triangle_shader_manifest, validate_shader_manifest};
+use crate::shader::validate_triangle_shaders;
#[cfg(test)]
fn take_runtime_owners_in_dependency_order<Instance, Validation, Surface, Device, Swapchain>(
@@ -103,7 +107,7 @@ impl VulkanSmokeRenderer {
///
/// Returns [`VulkanSmokeRendererError`] when Vulkan bootstrap, pipeline creation,
/// memory allocation, or synchronization resource creation fails.
- #[allow(clippy::too_many_lines)]
+
pub fn new(
create_info: &VulkanSmokeRendererCreateInfo,
) -> Result<Self, VulkanSmokeRendererError> {
@@ -120,8 +124,7 @@ impl VulkanSmokeRenderer {
resolve_draw_texture_indices(&create_info.mesh.draw_ranges, &create_info.materials)
.map_err(|context| VulkanSmokeRendererError::InvalidStaticMesh { context })?;
let bootstrap_progress = create_info.bootstrap_progress.as_ref();
- let shader_manifest = validate_shader_manifest(&triangle_shader_manifest())
- .map_err(VulkanSmokeRendererError::ShaderManifest)?;
+ validate_triangle_shaders().map_err(VulkanSmokeRendererError::Shader)?;
let surface_plan = plan_vulkan_surface(Some(create_info.native_handles))
.map_err(VulkanSmokeRendererError::Surface)?;
let mut instance_config = VulkanInstanceConfig::smoke(&create_info.application_name);
@@ -186,38 +189,225 @@ impl VulkanSmokeRenderer {
return Err(error);
}
};
- // Keep the compatibility triangle path valid: it samples a white texel
- // when no original TEXM was supplied.
+ // Keep the compatibility triangle path valid: a white base texel,
+ // neutral detail texels and a transparent white overlay make the
+ // additional terrain stages mathematically transparent.
let fallback_texture = super::VulkanStaticTexture {
width: 1,
height: 1,
rgba8: vec![255, 255, 255, 255],
+ mip_levels: Vec::new(),
+ };
+ let fallback_detail_texture = super::VulkanStaticTexture {
+ width: 1,
+ height: 1,
+ rgba8: vec![128, 128, 128, 255],
+ mip_levels: Vec::new(),
+ };
+ let fallback_overlay_texture = super::VulkanStaticTexture {
+ width: 1,
+ height: 1,
+ rgba8: vec![255, 255, 255, 0],
+ mip_levels: Vec::new(),
};
+ // Fallback stages are immutable compatibility images. Keep one
+ // allocation for each fallback kind and map every logical material
+ // slot to it. Authored images remain one-per-slot because dynamic
+ // phase uploads must never couple two materials.
let texture_sources = if create_info.materials.is_empty() {
- vec![&fallback_texture]
+ vec![
+ (&fallback_texture, Some(0_usize)),
+ (&fallback_detail_texture, Some(1_usize)),
+ (&fallback_overlay_texture, Some(2_usize)),
+ (&fallback_detail_texture, Some(1_usize)),
+ ]
+ } else {
+ let mut sources = Vec::with_capacity(create_info.materials.len() * 4);
+ for material in &create_info.materials {
+ sources.push((&material.texture, None));
+ sources.push((
+ material
+ .detail_texture
+ .as_ref()
+ .map_or(&fallback_detail_texture, |texture| texture),
+ material.detail_texture.is_none().then_some(1_usize),
+ ));
+ sources.push((
+ material
+ .overlay_texture
+ .as_ref()
+ .map_or(&fallback_overlay_texture, |texture| texture),
+ material.overlay_texture.is_none().then_some(2_usize),
+ ));
+ sources.push((
+ material
+ .overlay_detail_texture
+ .as_ref()
+ .map_or(&fallback_detail_texture, |texture| texture),
+ material.overlay_detail_texture.is_none().then_some(1_usize),
+ ));
+ }
+ sources
+ };
+ let mut textures = Vec::with_capacity(texture_sources.len());
+ let mut texture_slot_indices = Vec::with_capacity(texture_sources.len());
+ let mut fallback_image_indices = [None; 3];
+ for (texture_source, fallback_kind) in texture_sources {
+ let texture_index = if let Some(fallback_kind) = fallback_kind {
+ if let Some(texture_index) = fallback_image_indices[fallback_kind] {
+ texture_index
+ } else {
+ let image = match create_static_texture_image(
+ &instance,
+ &device,
+ command_pool,
+ texture_source,
+ ) {
+ Ok(image) => image,
+ Err(error) => {
+ for texture in &textures {
+ destroy_allocated_image(&device, texture);
+ }
+ // SAFETY: These resources belong to this live device and are rolled back before it drops.
+ unsafe { device.device().destroy_command_pool(command_pool, None) };
+ destroy_allocated_buffer(&device, &index_buffer);
+ destroy_allocated_buffer(&device, &vertex_buffer);
+ return Err(error);
+ }
+ };
+ let texture_index = textures.len();
+ textures.push(image);
+ fallback_image_indices[fallback_kind] = Some(texture_index);
+ texture_index
+ }
+ } else {
+ let image = match create_static_texture_image(
+ &instance,
+ &device,
+ command_pool,
+ texture_source,
+ ) {
+ Ok(image) => image,
+ Err(error) => {
+ for texture in &textures {
+ destroy_allocated_image(&device, texture);
+ }
+ // SAFETY: These resources belong to this live device and are rolled back before it drops.
+ unsafe { device.device().destroy_command_pool(command_pool, None) };
+ destroy_allocated_buffer(&device, &index_buffer);
+ destroy_allocated_buffer(&device, &vertex_buffer);
+ return Err(error);
+ }
+ };
+ let texture_index = textures.len();
+ textures.push(image);
+ texture_index
+ };
+ texture_slot_indices.push(texture_index);
+ }
+ let material_alphas = if create_info.materials.is_empty() {
+ vec![[1.0, 0.0]]
} else {
create_info
.materials
.iter()
- .map(|material| &material.texture)
+ .map(|material| {
+ [
+ material.diffuse_alpha,
+ material.overlay_diffuse_alpha.unwrap_or(0.0),
+ ]
+ })
+ .collect()
+ };
+ let material_lighting = if create_info.materials.is_empty() {
+ vec![[[1.0; 3], [0.0; 3], [1.0; 3], [0.0; 3]]]
+ } else {
+ create_info
+ .materials
+ .iter()
+ .map(|material| {
+ [
+ material.directional_rgb,
+ material.additive_rgb,
+ material.overlay_directional_rgb.unwrap_or([1.0; 3]),
+ material.overlay_additive_rgb.unwrap_or([0.0; 3]),
+ ]
+ })
+ .collect()
+ };
+ let material_uv_transforms = if create_info.materials.is_empty() {
+ vec![[[0.0, 0.0, 1.0, 1.0]; 4]]
+ } else {
+ create_info
+ .materials
+ .iter()
+ .map(|material| {
+ [
+ material.uv_transform,
+ material.detail_uv_transform,
+ material
+ .overlay_uv_transform
+ .unwrap_or([0.0, 0.0, 1.0, 1.0]),
+ material
+ .overlay_detail_uv_transform
+ .unwrap_or([0.0, 0.0, 1.0, 1.0]),
+ ]
+ })
+ .collect()
+ };
+ let material_unlit = if create_info.materials.is_empty() {
+ vec![0.0]
+ } else {
+ create_info
+ .materials
+ .iter()
+ .map(|material| {
+ let combiner_mode = if material.lightmap_mode {
+ 3.0
+ } else if material.sky_nebula_stars {
+ 2.0
+ } else if material.unlit {
+ 1.0
+ } else {
+ 0.0
+ };
+ combiner_mode + if material.sky_far_depth { 4.0 } else { 0.0 }
+ })
+ .collect()
+ };
+ let material_specular = if create_info.materials.is_empty() {
+ vec![[[0.0; 4]; 2]]
+ } else {
+ create_info
+ .materials
+ .iter()
+ .map(|material| {
+ [
+ [
+ material.specular_rgb[0],
+ material.specular_rgb[1],
+ material.specular_rgb[2],
+ f32::from(material.specular_power),
+ ],
+ [
+ material.overlay_specular_rgb.unwrap_or([0.0; 3])[0],
+ material.overlay_specular_rgb.unwrap_or([0.0; 3])[1],
+ material.overlay_specular_rgb.unwrap_or([0.0; 3])[2],
+ f32::from(material.overlay_specular_power.unwrap_or(0)),
+ ],
+ ]
+ })
+ .collect()
+ };
+ let material_selectors = if create_info.materials.is_empty() {
+ vec![0]
+ } else {
+ create_info
+ .materials
+ .iter()
+ .map(|material| material.material_index)
.collect()
};
- let mut textures = Vec::with_capacity(texture_sources.len());
- for texture_source in texture_sources {
- match create_static_texture_image(&instance, &device, command_pool, texture_source) {
- Ok(image) => textures.push(image),
- Err(error) => {
- for texture in &textures {
- destroy_allocated_image(&device, texture);
- }
- // SAFETY: These resources belong to this live device and are rolled back before it drops.
- unsafe { device.device().destroy_command_pool(command_pool, None) };
- destroy_allocated_buffer(&device, &index_buffer);
- destroy_allocated_buffer(&device, &vertex_buffer);
- return Err(error);
- }
- }
- }
let mut renderer = Self {
instance: Some(instance),
validation,
@@ -228,10 +418,38 @@ impl VulkanSmokeRenderer {
swapchain_resources: None,
vertex_buffer: Some(vertex_buffer),
index_buffer: Some(index_buffer),
+ texture_slot_indices,
textures,
+ vertex_count: create_info.mesh.vertices.len(),
+ index_capacity: create_info.mesh.indices.len(),
+ draw_range_capacities: create_info
+ .mesh
+ .draw_ranges
+ .iter()
+ .map(|range| range.index_count)
+ .collect(),
draw_ranges: create_info.mesh.draw_ranges.clone(),
draw_texture_indices,
+ draw_order: (0..create_info.mesh.draw_ranges.len()).collect(),
+ draw_range_sort_keys: vec![None; create_info.mesh.draw_ranges.len()],
+ draw_order_dirty: false,
+ draw_range_world_queue: vec![None; create_info.mesh.draw_ranges.len()],
+ draw_range_base_pipeline_states: create_info
+ .mesh
+ .draw_ranges
+ .iter()
+ .map(|range| range.pipeline_state)
+ .collect(),
+ material_alphas,
+ material_lighting,
+ material_uv_transforms,
+ material_unlit,
+ material_specular,
+ material_selectors,
+ material_specular_dirty: true,
+ readback_enabled: false,
camera: create_info.camera,
+ frame_uniforms: super::VulkanFrameUniforms::from_camera(create_info.camera),
frame_sync: Vec::new(),
images_in_flight: Vec::new(),
current_frame: 0,
@@ -240,7 +458,6 @@ impl VulkanSmokeRenderer {
pending_extent: None,
swapchain_recreate_count: 0,
report: VulkanSmokeRendererReport {
- shader_manifest_hash: shader_manifest.manifest_hash.clone(),
portability_enumeration: instance_config.enable_portability_enumeration,
portability_subset_enabled: false,
device_name: String::new(),
@@ -260,7 +477,6 @@ impl VulkanSmokeRenderer {
let device_ref = renderer.device_ref()?;
let swapchain_ref = renderer.swapchain_ref()?;
renderer.report = VulkanSmokeRendererReport {
- shader_manifest_hash: shader_manifest.manifest_hash,
portability_enumeration: renderer
.instance
.as_ref()
@@ -321,6 +537,12 @@ impl VulkanSmokeRenderer {
self.camera
}
+ /// Returns the frame state uploaded before the next recorded frame.
+ #[must_use]
+ pub const fn frame_uniforms(&self) -> super::VulkanFrameUniforms {
+ self.frame_uniforms
+ }
+
/// Replaces the camera for subsequent frames without recreating GPU resources.
///
/// # Errors
@@ -338,6 +560,585 @@ impl VulkanSmokeRenderer {
});
}
self.camera = camera;
+ self.frame_uniforms.clip_from_world = camera.clip_from_world;
+ Ok(())
+ }
+
+ /// Replaces lighting, fog, camera-position and matrix state for
+ /// subsequent frames without recreating GPU resources.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`VulkanSmokeRendererError::InvalidStaticCamera`] when any
+ /// submitted value is non-finite. The caller must update only between
+ /// [`Self::draw_frame`] calls on the renderer-owning thread.
+ pub fn set_frame_uniforms(
+ &mut self,
+ frame_uniforms: super::VulkanFrameUniforms,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if !frame_uniforms.is_finite() {
+ return Err(VulkanSmokeRendererError::InvalidStaticCamera {
+ context: "non-finite frame uniform",
+ });
+ }
+ self.camera.clip_from_world = frame_uniforms.clip_from_world;
+ self.frame_uniforms = frame_uniforms;
+ Ok(())
+ }
+
+ /// Enables the optional swapchain image copy used by screenshot/readback
+ /// callers. Runtime frames leave it disabled to avoid a transfer every
+ /// frame.
+ pub fn set_readback_enabled(&mut self, enabled: bool) {
+ self.readback_enabled = enabled;
+ if !enabled {
+ self.last_readback_image_index = None;
+ }
+ }
+
+ /// Replaces the shared vertex allocation between frames.
+ ///
+ /// The initial mesh owns enough vertices for the scene's dynamic slots;
+ /// callers update those slots in place as camera-relative environment
+ /// geometry moves. The device is idled before the host-visible write so
+ /// no submitted command can observe a partial update.
+ pub fn update_vertices(
+ &mut self,
+ vertices: &[super::VulkanStaticVertex],
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if vertices.len() != self.vertex_count {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "updated static mesh vertex count differs from allocation",
+ });
+ }
+ // SAFETY: The renderer owns the queue and all submitted work; waiting
+ // for idle makes the shared vertex allocation quiescent.
+ unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkDeviceWaitIdle(static mesh vertex update)",
+ result,
+ }
+ })?;
+ write_static_mesh_vertex_buffer(self.device_ref()?, self.vertex_buffer_ref()?, vertices)
+ }
+
+ /// Uploads the current shared mesh while retaining dynamic draw state.
+ ///
+ /// Environment geometry and projected shadow geometry share the original
+ /// vertex/index allocations. `geometry_ranges` carries only the ranges
+ /// whose index segment changed; material selection, blend/depth state,
+ /// transparency classification, and active counts owned by other setters
+ /// remain untouched. The allocations grow to the largest submitted frame
+ /// and never grow for a frame that fits the retained capacity.
+ ///
+ /// A range with `index_count == 0` is valid and disables that range for the
+ /// frame. The mesh itself still needs at least one vertex and one index so
+ /// Vulkan never receives a zero-sized allocation.
+ pub fn update_mesh_geometry(
+ &mut self,
+ vertices: &[super::VulkanStaticVertex],
+ indices: &[u32],
+ geometry_ranges: &[super::VulkanDynamicDrawRange],
+ ) -> Result<(), VulkanSmokeRendererError> {
+ validate_geometry_upload(
+ vertices,
+ indices,
+ &self.draw_ranges,
+ geometry_ranges,
+ self.vertex_count,
+ self.index_capacity,
+ )?;
+
+ let vertex_grew = vertices.len() > self.vertex_count;
+ let index_grew = indices.len() > self.index_capacity;
+
+ // SAFETY: The renderer owns the queue and all submitted work. Waiting
+ // before either writing or replacing a shared allocation keeps old
+ // command buffers from observing a partial upload.
+ unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkDeviceWaitIdle(dynamic mesh update)",
+ result,
+ }
+ })?;
+
+ let (new_vertex_buffer, new_index_buffer) = if vertex_grew || index_grew {
+ let instance = self.instance_ref()?;
+ let device = self.device_ref()?;
+ let new_vertex_buffer = if vertex_grew {
+ Some(create_vertex_buffer(instance, device, vertices)?)
+ } else {
+ None
+ };
+ let new_index_buffer = if index_grew {
+ match create_index_buffer(instance, device, indices) {
+ Ok(buffer) => Some(buffer),
+ Err(error) => {
+ if let Some(buffer) = new_vertex_buffer.as_ref() {
+ destroy_allocated_buffer(device, buffer);
+ }
+ return Err(error);
+ }
+ }
+ } else {
+ None
+ };
+ if !vertex_grew {
+ if let Err(error) =
+ write_static_mesh_vertex_buffer(device, self.vertex_buffer_ref()?, vertices)
+ {
+ if let Some(buffer) = new_vertex_buffer.as_ref() {
+ destroy_allocated_buffer(device, buffer);
+ }
+ if let Some(buffer) = new_index_buffer.as_ref() {
+ destroy_allocated_buffer(device, buffer);
+ }
+ return Err(error);
+ }
+ }
+ if !index_grew {
+ if let Err(error) =
+ write_static_mesh_index_buffer(device, self.index_buffer_ref()?, indices)
+ {
+ if let Some(buffer) = new_vertex_buffer.as_ref() {
+ destroy_allocated_buffer(device, buffer);
+ }
+ if let Some(buffer) = new_index_buffer.as_ref() {
+ destroy_allocated_buffer(device, buffer);
+ }
+ return Err(error);
+ }
+ }
+ (new_vertex_buffer, new_index_buffer)
+ } else {
+ let device = self.device_ref()?;
+ write_static_mesh_vertex_buffer(device, self.vertex_buffer_ref()?, vertices)?;
+ write_static_mesh_index_buffer(device, self.index_buffer_ref()?, indices)?;
+ (None, None)
+ };
+
+ if let Some(buffer) = new_vertex_buffer {
+ let old = self.vertex_buffer.replace(buffer);
+ if let Some(old) = old {
+ destroy_allocated_buffer(self.device_ref()?, &old);
+ }
+ self.vertex_count = vertices.len();
+ }
+ if let Some(buffer) = new_index_buffer {
+ let old = self.index_buffer.replace(buffer);
+ if let Some(old) = old {
+ destroy_allocated_buffer(self.device_ref()?, &old);
+ }
+ self.index_capacity = indices.len();
+ }
+
+ apply_geometry_range_updates(
+ &mut self.draw_ranges,
+ &mut self.draw_range_capacities,
+ geometry_ranges,
+ );
+ Ok(())
+ }
+
+ /// Replaces one material texture in place while retaining its descriptor
+ /// set and sampler. This is used by dynamic fixed-size atlases such as
+ /// the native 256 by 256 projected-shadow texture.
+ ///
+ /// The updated image must have the same dimensions and generated/authored
+ /// mip count as the texture allocated during renderer creation. The
+ /// renderer idles its device before the transfer, so callers may invoke
+ /// this between frame updates without taking ownership of Vulkan handles.
+ pub fn update_material_texture(
+ &mut self,
+ material_index: usize,
+ texture_slot: usize,
+ texture: &super::VulkanStaticTexture,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if texture_slot >= 4 {
+ return Err(VulkanSmokeRendererError::InvalidStaticTexture {
+ context: "dynamic material texture slot is out of bounds",
+ });
+ }
+ let logical_texture_index = material_index
+ .checked_mul(4)
+ .and_then(|index| index.checked_add(texture_slot))
+ .ok_or(VulkanSmokeRendererError::InvalidStaticTexture {
+ context: "dynamic material texture index overflows address space",
+ })?;
+ let texture_index = self
+ .texture_slot_indices
+ .get(logical_texture_index)
+ .copied()
+ .ok_or(VulkanSmokeRendererError::InvalidStaticTexture {
+ context: "dynamic material texture index is out of bounds",
+ })?;
+ if self.textures.get(texture_index).is_none() {
+ return Err(VulkanSmokeRendererError::InvalidStaticTexture {
+ context: "dynamic material texture index is out of bounds",
+ });
+ }
+ {
+ let device = self.device_ref()?;
+ // SAFETY: every submission made by this renderer uses this device; the
+ // idle wait makes all descriptor image reads quiescent before upload.
+ unsafe { device.device().device_wait_idle() }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkDeviceWaitIdle(dynamic texture update)",
+ result,
+ }
+ })?;
+ }
+ let shared_texture = self
+ .texture_slot_indices
+ .iter()
+ .filter(|mapped_index| **mapped_index == texture_index)
+ .count()
+ > 1;
+ if shared_texture {
+ // A fallback image can serve many logical slots. Detach the
+ // requested slot before uploading so this public update method
+ // keeps its one-material/one-slot semantics.
+ let image = {
+ let instance = self.instance_ref()?;
+ let device = self.device_ref()?;
+ create_static_texture_image(instance, device, self.command_pool, texture)?
+ };
+ let new_texture_index = self.textures.len();
+ self.textures.push(image);
+ self.texture_slot_indices[logical_texture_index] = new_texture_index;
+ let device = self.device_ref()?;
+ let resources = self.resources_ref()?;
+ update_material_texture_descriptor(
+ device,
+ resources,
+ material_index,
+ texture_slot,
+ &self.textures[new_texture_index],
+ )
+ } else {
+ let instance = self.instance_ref()?;
+ let device = self.device_ref()?;
+ let image = self.textures.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticTexture {
+ context: "dynamic material texture index is out of bounds",
+ },
+ )?;
+ update_static_texture_image(instance, device, self.command_pool, image, texture)
+ }
+ }
+
+ /// Changes one draw range's active index count while retaining its
+ /// pipeline and material selector. This lets a fixed-capacity weather
+ /// allocation draw only the particles emitted in the current frame.
+ pub fn set_draw_range_index_count(
+ &mut self,
+ range_index: usize,
+ index_count: u32,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if index_count % 3 != 0 {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw range index count is not a triangle count",
+ });
+ }
+ let capacity = self.draw_range_capacities.get(range_index).copied().ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw range index is out of bounds",
+ },
+ )?;
+ if index_count > capacity {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw range exceeds its index allocation",
+ });
+ }
+ let range = self.draw_ranges.get_mut(range_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw range index is out of bounds",
+ },
+ )?;
+ range.index_count = index_count;
+ Ok(())
+ }
+
+ /// Sets the native per-range alpha reference in legacy 0..=255 units.
+ ///
+ /// A value of zero selects the renderer's proven native default (one) for
+ /// alpha-test ranges. The update is dynamic and does not recreate a
+ /// graphics pipeline.
+ pub fn set_draw_range_alpha_test_reference(
+ &mut self,
+ range_index: usize,
+ alpha_test_reference: u8,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ let range = self.draw_ranges.get_mut(range_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw range index is out of bounds",
+ },
+ )?;
+ range.alpha_test_reference = alpha_test_reference;
+ Ok(())
+ }
+
+ /// Marks a draw range as belonging to the native transparent queue.
+ ///
+ /// The flag is deliberately independent of the blend category: native
+ /// materials can blend while retaining depth writes, and some alpha
+ /// materials are depth-tested opaque. When enabled, a range that uses a
+ /// depth attachment switches to depth-test/read-only. The original depth
+ /// state is retained so disabling the flag restores the caller's state.
+ ///
+ /// The transparent flag only participates in the world draw order. The
+ /// backend keeps every unmarked range in its original slot, which leaves
+ /// the fixed screen/sky/weather pass order intact.
+ pub fn set_draw_range_transparency(
+ &mut self,
+ range_index: usize,
+ transparent: bool,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ let base_state = *self
+ .draw_range_base_pipeline_states
+ .get(range_index)
+ .ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw range index is out of bounds",
+ })?;
+ let range = self.draw_ranges.get_mut(range_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw range index is out of bounds",
+ },
+ )?;
+ let depth = if transparent && base_state.depth != LegacyDepthMode::Disabled {
+ LegacyDepthMode::TestReadOnly
+ } else {
+ base_state.depth
+ };
+ let mut next_state = base_state;
+ next_state.depth = depth;
+ let pipeline_changed = range.pipeline_state != next_state;
+ range.pipeline_state = next_state;
+ // Calling this setter opts the range into the world queue. Fixed
+ // screen/sky/weather ranges remain `None` because their pass order is
+ // intentionally owned by the caller.
+ self.draw_range_world_queue[range_index] = Some(transparent);
+ self.draw_order_dirty = true;
+ if pipeline_changed {
+ self.rebuild_pipeline_resources()?;
+ }
+ Ok(())
+ }
+
+ /// Supplies the native camera-sort key returned by the draw-item camera
+ /// callback. The native callback computes Euclidean distance from the
+ /// camera to the first world vertex of the whole indexed batch.
+ ///
+ /// The recovered comparator inserts in descending floating-point camera
+ /// key order. Keeping the key separate from the range handle mirrors the
+ /// native layer's callback result and its `[item pointer, key]` entries.
+ pub fn set_draw_range_sort_key(
+ &mut self,
+ range_index: usize,
+ sort_key: f32,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if !sort_key.is_finite() {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "native draw sort key must be finite",
+ });
+ }
+ let key = self.draw_range_sort_keys.get_mut(range_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw range index is out of bounds",
+ },
+ )?;
+ *key = Some(sort_key);
+ self.draw_order_dirty = true;
+ Ok(())
+ }
+
+ /// Returns the current stable range-handle order used by command
+ /// recording. Handles are indices into the original `draw_ranges` vector.
+ #[must_use]
+ pub fn draw_order(&self) -> &[usize] {
+ &self.draw_order
+ }
+
+ /// Changes the diffuse and overlay alpha values carried by one material's
+ /// push constants. The descriptor images remain unchanged.
+ pub fn set_material_alphas(
+ &mut self,
+ material_index: usize,
+ diffuse_alpha: f32,
+ overlay_alpha: f32,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if !diffuse_alpha.is_finite()
+ || !overlay_alpha.is_finite()
+ || !(0.0..=1.0).contains(&diffuse_alpha)
+ || !(0.0..=1.0).contains(&overlay_alpha)
+ {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic material alpha must be finite and 0..1",
+ });
+ }
+ let alphas = self.material_alphas.get_mut(material_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic material index is out of bounds",
+ },
+ )?;
+ *alphas = [diffuse_alpha, overlay_alpha];
+ Ok(())
+ }
+
+ /// Changes the base directional and additive lighting coefficients of one
+ /// material while retaining its descriptor images and pipeline state.
+ pub fn set_material_lighting(
+ &mut self,
+ material_index: usize,
+ directional_rgb: [f32; 3],
+ additive_rgb: [f32; 3],
+ ) -> Result<(), VulkanSmokeRendererError> {
+ self.set_material_lighting_with_overlay(
+ material_index,
+ directional_rgb,
+ additive_rgb,
+ None,
+ None,
+ )
+ }
+
+ /// Changes base and optional overlay directional/additive coefficients
+ /// while retaining descriptor images and pipeline state.
+ pub fn set_material_lighting_with_overlay(
+ &mut self,
+ material_index: usize,
+ directional_rgb: [f32; 3],
+ additive_rgb: [f32; 3],
+ overlay_directional_rgb: Option<[f32; 3]>,
+ overlay_additive_rgb: Option<[f32; 3]>,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if directional_rgb
+ .into_iter()
+ .chain(additive_rgb)
+ .chain(overlay_directional_rgb.into_iter().flatten())
+ .chain(overlay_additive_rgb.into_iter().flatten())
+ .any(|value| !value.is_finite() || value < 0.0)
+ {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic material lighting must be finite and non-negative",
+ });
+ }
+ let lighting = self.material_lighting.get_mut(material_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic material index is out of bounds",
+ },
+ )?;
+ lighting[0] = directional_rgb;
+ lighting[1] = additive_rgb;
+ lighting[2] = overlay_directional_rgb.unwrap_or([1.0; 3]);
+ lighting[3] = overlay_additive_rgb.unwrap_or([0.0; 3]);
+ Ok(())
+ }
+
+ /// Replaces the four Page transforms carried by one material's push
+ /// constants. The transform order is base, detail, overlay, and overlay
+ /// detail, matching the material shader's texture bindings.
+ ///
+ /// The update is kept CPU-side and is consumed by the next recorded draw;
+ /// no descriptor or image resource is recreated. This is intentionally a
+ /// complete replacement so an animated MAT0 phase cannot leave one stale
+ /// Page rectangle behind when its texture set is unchanged.
+ pub fn set_material_uv_transforms(
+ &mut self,
+ material_index: usize,
+ uv_transforms: [[f32; 4]; 4],
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if uv_transforms
+ .into_iter()
+ .flatten()
+ .any(|value| !value.is_finite())
+ {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic material UV transform must be finite",
+ });
+ }
+ let transforms = self.material_uv_transforms.get_mut(material_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic material index is out of bounds",
+ },
+ )?;
+ *transforms = uv_transforms;
+ Ok(())
+ }
+
+ /// Replaces the base and overlay specular records used by the vertex
+ /// Gouraud lighting path. RGB values remain normalized; powers retain
+ /// the native MAT0 byte and are encoded as the fourth float in each GPU
+ /// record.
+ pub fn set_material_specular(
+ &mut self,
+ material_index: usize,
+ specular_rgb: [f32; 3],
+ power: u8,
+ overlay_specular_rgb: Option<[f32; 3]>,
+ overlay_power: Option<u8>,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ if specular_rgb
+ .into_iter()
+ .chain(overlay_specular_rgb.into_iter().flatten())
+ .any(|value| !value.is_finite() || value < 0.0)
+ {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic material specular RGB must be finite and non-negative",
+ });
+ }
+ let specular = self.material_specular.get_mut(material_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic material index is out of bounds",
+ },
+ )?;
+ let overlay_rgb = overlay_specular_rgb.unwrap_or([0.0; 3]);
+ let next = [
+ [
+ specular_rgb[0],
+ specular_rgb[1],
+ specular_rgb[2],
+ f32::from(power),
+ ],
+ [
+ overlay_rgb[0],
+ overlay_rgb[1],
+ overlay_rgb[2],
+ f32::from(overlay_power.unwrap_or(0)),
+ ],
+ ];
+ if *specular == next {
+ return Ok(());
+ }
+ *specular = next;
+ self.material_specular_dirty = true;
+ Ok(())
+ }
+
+ /// Rebinds one draw range to a positional material slot without
+ /// reallocating any vertex or descriptor resources.
+ pub fn set_draw_range_material(
+ &mut self,
+ range_index: usize,
+ material_index: usize,
+ ) -> Result<(), VulkanSmokeRendererError> {
+ let selector = *self.material_selectors.get(material_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic material index is out of bounds",
+ },
+ )?;
+ let range = self.draw_ranges.get_mut(range_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw range index is out of bounds",
+ },
+ )?;
+ range.material_index = selector;
+ let texture_index = self.draw_texture_indices.get_mut(range_index).ok_or(
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic draw range index is out of bounds",
+ },
+ )?;
+ *texture_index = material_index;
Ok(())
}
@@ -426,7 +1227,7 @@ impl VulkanSmokeRenderer {
///
/// Returns [`VulkanSmokeRendererError`] when synchronization, command recording,
/// submission, or presentation fails.
- #[allow(clippy::too_many_lines)]
+
pub fn draw_frame(&mut self) -> Result<VulkanSmokeFrameOutcome, VulkanSmokeRendererError> {
if let Some(extent) = self.pending_extent.take() {
if extent.0 == 0 || extent.1 == 0 {
@@ -437,9 +1238,12 @@ impl VulkanSmokeRenderer {
return Ok(VulkanSmokeFrameOutcome::Recreated);
}
- let sync = &self.frame_sync[self.current_frame];
+ let sync = self.frame_sync.get(self.current_frame).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "frame synchronization state",
+ },
+ )?;
let image_available = sync.image_available;
- let render_finished = sync.render_finished;
let in_flight_fence = sync.fence;
// SAFETY: The fence belongs to this live logical device and is waited from one thread.
unsafe {
@@ -451,6 +1255,27 @@ impl VulkanSmokeRenderer {
context: "vkWaitForFences",
result: error,
})?;
+ if self.material_specular_dirty {
+ // The storage buffer is shared by all in-flight frames. Wait for
+ // every prior use before replacing the records so a setter cannot
+ // race a vertex shader from the previous frame.
+ // SAFETY: The renderer owns the logical device and no submission
+ // can observe the host write after this idle point.
+ unsafe { self.device_ref()?.device().device_wait_idle() }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkDeviceWaitIdle(material specular update)",
+ result,
+ }
+ })?;
+ let resources = self.resources_ref()?;
+ write_material_specular_buffer(
+ self.device_ref()?,
+ &resources.material_specular_buffer,
+ resources.material_specular_stride,
+ &self.material_specular,
+ )?;
+ self.material_specular_dirty = false;
+ }
// SAFETY: The swapchain, semaphore and fence inputs are live for the duration of the acquire call.
let acquire = unsafe {
self.swapchain_ref()?.loader().acquire_next_image(
@@ -488,6 +1313,26 @@ impl VulkanSmokeRenderer {
})?;
}
self.images_in_flight[image_index_usize] = in_flight_fence;
+ let frame_bytes = self.frame_uniforms.to_ne_bytes();
+ let resources = self.resources_ref()?;
+ write_frame_uniform(
+ self.device_ref()?,
+ &resources.frame_uniform_buffer,
+ resources.frame_uniform_stride,
+ self.current_frame,
+ &frame_bytes,
+ )?;
+ let render_finished = self
+ .resources_ref()?
+ .render_finished
+ .get(image_index_usize)
+ .copied()
+ .ok_or(VulkanSmokeRendererError::InvariantViolation {
+ context: "render-finished semaphore for swapchain image",
+ })?;
+ self.record_command_buffer(image_index_usize)?;
+ // Reset only after recording succeeds. A recording error leaves the
+ // signaled fence available for the next recovery attempt.
// SAFETY: The fence belongs to this frame context and is not in use after the wait above.
unsafe { self.device_ref()?.device().reset_fences(&[in_flight_fence]) }.map_err(
|error| VulkanSmokeRendererError::VulkanOperation {
@@ -495,8 +1340,6 @@ impl VulkanSmokeRenderer {
result: error,
},
)?;
-
- self.record_command_buffer(image_index_usize)?;
let wait_semaphores = [image_available];
let wait_stages = [vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT];
let command_buffers = [self.resources_ref()?.command_buffers[image_index_usize]];
@@ -518,7 +1361,7 @@ impl VulkanSmokeRenderer {
context: "vkQueueSubmit",
result: error,
})?;
- if !self.resources_ref()?.readback_buffers.is_empty() {
+ if self.readback_enabled && !self.resources_ref()?.readback_buffers.is_empty() {
self.report.readback_copy_count = self.report.readback_copy_count.saturating_add(1);
self.last_readback_image_index = Some(image_index_usize);
}
@@ -610,6 +1453,7 @@ impl VulkanSmokeRenderer {
self.vertex_buffer_ref()?,
self.index_buffer_ref()?,
self.textures_ref()?,
+ &self.texture_slot_indices,
&self.draw_ranges,
self.depth_request,
reuse_command_pool,
@@ -624,6 +1468,7 @@ impl VulkanSmokeRenderer {
self.images_in_flight = vec![vk::Fence::null(); resources.image_views.len()];
self.frame_sync = frame_sync;
self.last_readback_image_index = None;
+ self.material_specular_dirty = true;
self.report.swapchain_extent = swapchain_extent;
self.report.swapchain_image_count = swapchain_image_count;
self.report.swapchain_image_format = self.swapchain_ref()?.report.plan.format.format;
@@ -632,11 +1477,54 @@ impl VulkanSmokeRenderer {
Ok(())
}
- #[allow(clippy::too_many_lines)]
+ /// Rebuilds only the swapchain-owned pipeline bundle after a structural
+ /// range-state change. Draw-range setters can be called while a renderer
+ /// is live, so all prior submissions are completed before handles are
+ /// destroyed and recreated.
+ fn rebuild_pipeline_resources(&mut self) -> Result<(), VulkanSmokeRendererError> {
+ if self.swapchain_resources.is_none() {
+ return Ok(());
+ }
+ let device = self.device_ref()?;
+ // SAFETY: The renderer owns every submission on this device. Waiting
+ // here makes the old pipelines, framebuffers, and command buffers
+ // quiescent before their destruction.
+ unsafe { device.device().device_wait_idle() }.map_err(|result| {
+ VulkanSmokeRendererError::VulkanOperation {
+ context: "vkDeviceWaitIdle(draw range pipeline update)",
+ result,
+ }
+ })?;
+ self.destroy_swapchain_resources();
+ self.rebuild_swapchain_resources(true)
+ }
+
+ /// Recomputes the stable command-recording order. Only ranges explicitly
+ /// marked transparent are reordered, and they remain in the slots occupied
+ /// by that queue. This preserves the caller's fixed sky/screen/weather
+ /// pass order while matching the native descending key comparator for the
+ /// world transparent queue.
+ fn rebuild_draw_order(&mut self) {
+ let range_count = self.draw_ranges.len();
+ if self.draw_order.len() != range_count {
+ self.draw_order = (0..range_count).collect();
+ }
+ self.draw_order = sorted_world_draw_order(
+ range_count,
+ &self.draw_ranges,
+ &self.draw_range_world_queue,
+ &self.draw_range_sort_keys,
+ );
+ }
+
fn record_command_buffer(
&mut self,
image_index: usize,
) -> Result<(), VulkanSmokeRendererError> {
+ if self.draw_order_dirty {
+ self.rebuild_draw_order();
+ self.draw_order_dirty = false;
+ }
let device = self.device_ref()?;
let swapchain = self.swapchain_ref()?;
let resources = self.resources_ref()?;
@@ -706,15 +1594,17 @@ impl VulkanSmokeRenderer {
0,
vk::IndexType::UINT32,
);
- let clip_from_world = matrix_bytes(self.camera.clip_from_world);
- device.device().cmd_push_constants(
- command_buffer,
- resources.pipeline_layout,
- vk::ShaderStageFlags::VERTEX,
- 0,
- &clip_from_world,
- );
- for (range, &texture_index) in self.draw_ranges.iter().zip(&self.draw_texture_indices) {
+ for &range_index in &self.draw_order {
+ let range = self.draw_ranges.get(range_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "stable draw order range handle",
+ },
+ )?;
+ let texture_index = *self.draw_texture_indices.get(range_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "stable draw texture range handle",
+ },
+ )?;
let pipeline = resources.pipelines.get(&range.pipeline_key()).ok_or(
VulkanSmokeRendererError::InvariantViolation {
context: "static draw pipeline variant",
@@ -725,6 +1615,18 @@ impl VulkanSmokeRenderer {
vk::PipelineBindPoint::GRAPHICS,
*pipeline,
);
+ let frame_offset = u32::try_from(
+ resources
+ .frame_uniform_stride
+ .saturating_mul(u64::try_from(self.current_frame).unwrap_or(u64::MAX)),
+ )
+ .unwrap_or(u32::MAX);
+ let material_offset = u32::try_from(
+ resources
+ .material_specular_stride
+ .saturating_mul(u64::try_from(texture_index).unwrap_or(u64::MAX)),
+ )
+ .unwrap_or(u32::MAX);
let descriptor_set = resources.descriptor_sets.get(texture_index).ok_or(
VulkanSmokeRendererError::InvariantViolation {
context: "static material descriptor set",
@@ -736,16 +1638,80 @@ impl VulkanSmokeRenderer {
resources.pipeline_layout,
0,
std::slice::from_ref(descriptor_set),
- &[],
+ &[frame_offset, material_offset],
);
let alpha_cutoff = range.alpha_test_cutoff().to_ne_bytes();
device.device().cmd_push_constants(
command_buffer,
resources.pipeline_layout,
- vk::ShaderStageFlags::FRAGMENT,
- 64,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 0,
&alpha_cutoff,
);
+ let material_alpha = self.material_alphas.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material alpha",
+ },
+ )?;
+ let material_alphas = [
+ material_alpha[0].to_ne_bytes(),
+ material_alpha[1].to_ne_bytes(),
+ ]
+ .concat();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 4,
+ &material_alphas,
+ );
+ let material_lighting = self.material_lighting.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material lighting",
+ },
+ )?;
+ let material_lighting = material_lighting
+ .iter()
+ .flatten()
+ .flat_map(|value| value.to_ne_bytes())
+ .collect::<Vec<_>>();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 12,
+ &material_lighting,
+ );
+ let material_uv_transforms = self.material_uv_transforms.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material UV transforms",
+ },
+ )?;
+ let material_uv_transforms = material_uv_transforms
+ .iter()
+ .flatten()
+ .flat_map(|value| value.to_ne_bytes())
+ .collect::<Vec<_>>();
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 60,
+ &material_uv_transforms,
+ );
+ let unlit = self.material_unlit.get(texture_index).ok_or(
+ VulkanSmokeRendererError::InvariantViolation {
+ context: "static material unlit flag",
+ },
+ )?;
+ let unlit = [unlit.to_ne_bytes()];
+ device.device().cmd_push_constants(
+ command_buffer,
+ resources.pipeline_layout,
+ vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT,
+ 124,
+ &unlit.concat(),
+ );
device.device().cmd_draw_indexed(
command_buffer,
range.index_count,
@@ -756,63 +1722,65 @@ impl VulkanSmokeRenderer {
);
}
device.device().cmd_end_render_pass(command_buffer);
- if let (Some(image), Some(readback)) = (
- resources.images.get(image_index),
- resources.readback_buffers.get(image_index),
- ) {
- let range = super::color_subresource_range();
- let to_transfer = vk::ImageMemoryBarrier::default()
- .old_layout(vk::ImageLayout::PRESENT_SRC_KHR)
- .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
- .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
- .dst_access_mask(vk::AccessFlags::TRANSFER_READ)
- .image(*image)
- .subresource_range(range);
- let back_to_present = vk::ImageMemoryBarrier::default()
- .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
- .new_layout(vk::ImageLayout::PRESENT_SRC_KHR)
- .src_access_mask(vk::AccessFlags::TRANSFER_READ)
- .dst_access_mask(vk::AccessFlags::empty())
- .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: swapchain.report.plan.extent.0,
- height: swapchain.report.plan.extent.1,
- depth: 1,
- });
- device.device().cmd_pipeline_barrier(
- command_buffer,
- vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT,
- vk::PipelineStageFlags::TRANSFER,
- vk::DependencyFlags::empty(),
- &[],
- &[],
- &[to_transfer],
- );
- device.device().cmd_copy_image_to_buffer(
- command_buffer,
- *image,
- vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
- readback.buffer,
- &[region],
- );
- device.device().cmd_pipeline_barrier(
- command_buffer,
- vk::PipelineStageFlags::TRANSFER,
- vk::PipelineStageFlags::BOTTOM_OF_PIPE,
- vk::DependencyFlags::empty(),
- &[],
- &[],
- &[back_to_present],
- );
+ if self.readback_enabled {
+ if let (Some(image), Some(readback)) = (
+ resources.images.get(image_index),
+ resources.readback_buffers.get(image_index),
+ ) {
+ let range = super::color_subresource_range(1);
+ let to_transfer = vk::ImageMemoryBarrier::default()
+ .old_layout(vk::ImageLayout::PRESENT_SRC_KHR)
+ .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
+ .src_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE)
+ .dst_access_mask(vk::AccessFlags::TRANSFER_READ)
+ .image(*image)
+ .subresource_range(range);
+ let back_to_present = vk::ImageMemoryBarrier::default()
+ .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL)
+ .new_layout(vk::ImageLayout::PRESENT_SRC_KHR)
+ .src_access_mask(vk::AccessFlags::TRANSFER_READ)
+ .dst_access_mask(vk::AccessFlags::empty())
+ .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: swapchain.report.plan.extent.0,
+ height: swapchain.report.plan.extent.1,
+ depth: 1,
+ });
+ device.device().cmd_pipeline_barrier(
+ command_buffer,
+ vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT,
+ vk::PipelineStageFlags::TRANSFER,
+ vk::DependencyFlags::empty(),
+ &[],
+ &[],
+ &[to_transfer],
+ );
+ device.device().cmd_copy_image_to_buffer(
+ command_buffer,
+ *image,
+ vk::ImageLayout::TRANSFER_SRC_OPTIMAL,
+ readback.buffer,
+ &[region],
+ );
+ device.device().cmd_pipeline_barrier(
+ command_buffer,
+ vk::PipelineStageFlags::TRANSFER,
+ vk::PipelineStageFlags::BOTTOM_OF_PIPE,
+ vk::DependencyFlags::empty(),
+ &[],
+ &[],
+ &[back_to_present],
+ );
+ }
}
}
@@ -836,9 +1804,6 @@ impl VulkanSmokeRenderer {
device
.device()
.destroy_semaphore(sync.image_available, None);
- device
- .device()
- .destroy_semaphore(sync.render_finished, None);
device.device().destroy_fence(sync.fence, None);
}
}
@@ -941,7 +1906,7 @@ impl VulkanSmokeRenderer {
.ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
context: "completed readback byte length",
})?;
- if resources.readback_buffers.is_empty() {
+ if !self.readback_enabled || resources.readback_buffers.is_empty() {
return Ok(None);
}
let Some(image_index) = completed_readback_buffer_index(
@@ -990,14 +1955,6 @@ fn completed_readback_buffer_index(
}
}
-fn matrix_bytes(matrix: [f32; 16]) -> [u8; 64] {
- let mut bytes = [0; 64];
- for (index, value) in matrix.into_iter().enumerate() {
- bytes[index * 4..(index + 1) * 4].copy_from_slice(&value.to_ne_bytes());
- }
- bytes
-}
-
fn fnv1a64(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
@@ -1010,12 +1967,242 @@ impl Drop for VulkanSmokeRenderer {
}
}
+fn validate_geometry_upload(
+ vertices: &[super::VulkanStaticVertex],
+ indices: &[u32],
+ current_ranges: &[super::VulkanStaticDrawRange],
+ geometry_ranges: &[super::VulkanDynamicDrawRange],
+ retained_vertex_capacity: usize,
+ retained_index_capacity: usize,
+) -> Result<(), VulkanSmokeRendererError> {
+ if vertices.is_empty() || indices.is_empty() {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh upload cannot have zero-sized geometry",
+ });
+ }
+ if !indices.len().is_multiple_of(3) {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh index upload is not a triangle list",
+ });
+ }
+ if vertices.iter().any(|vertex| {
+ !vertex
+ .position
+ .iter()
+ .chain(vertex.color.iter())
+ .chain(vertex.normal.iter())
+ .chain(vertex.uv.iter())
+ .chain(vertex.detail_uv.iter())
+ .chain(std::iter::once(&vertex.overlay_alpha))
+ .all(|value| value.is_finite())
+ || !(0.0..=1.0).contains(&vertex.overlay_alpha)
+ }) {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh vertex attributes must be finite and overlay alpha must be 0..1",
+ });
+ }
+ if indices.iter().any(|&index| {
+ usize::try_from(index)
+ .ok()
+ .is_none_or(|index| index >= vertices.len())
+ }) {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh index exceeds vertex count",
+ });
+ }
+ if retained_vertex_capacity == 0 || retained_index_capacity == 0 {
+ return Err(VulkanSmokeRendererError::InvariantViolation {
+ context: "dynamic mesh retained allocation is empty",
+ });
+ }
+
+ let mut next_ranges = current_ranges.to_vec();
+ let mut changed = vec![false; current_ranges.len()];
+ for geometry in geometry_ranges {
+ if geometry.range_index >= current_ranges.len() {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh range index is out of bounds",
+ });
+ }
+ if changed[geometry.range_index] {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh range is updated more than once",
+ });
+ }
+ changed[geometry.range_index] = true;
+ if geometry.first_index % 3 != 0 || geometry.index_count % 3 != 0 {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh range is not triangle aligned",
+ });
+ }
+ let first = usize::try_from(geometry.first_index).map_err(|_| {
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh range first index exceeds address space",
+ }
+ })?;
+ let count = usize::try_from(geometry.index_count).map_err(|_| {
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh range index count exceeds address space",
+ }
+ })?;
+ let end = first
+ .checked_add(count)
+ .ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh range exceeds address space",
+ })?;
+ if end > indices.len() {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "dynamic mesh range exceeds uploaded indices",
+ });
+ }
+ next_ranges[geometry.range_index].first_index = geometry.first_index;
+ next_ranges[geometry.range_index].index_count = geometry.index_count;
+ }
+
+ for range in next_ranges {
+ if range.first_index % 3 != 0 || range.index_count % 3 != 0 {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "retained dynamic mesh range is not triangle aligned",
+ });
+ }
+ let first = usize::try_from(range.first_index).map_err(|_| {
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "retained dynamic mesh range first index exceeds address space",
+ }
+ })?;
+ let count = usize::try_from(range.index_count).map_err(|_| {
+ VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "retained dynamic mesh range index count exceeds address space",
+ }
+ })?;
+ let end = first
+ .checked_add(count)
+ .ok_or(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "retained dynamic mesh range exceeds address space",
+ })?;
+ if end > indices.len() {
+ return Err(VulkanSmokeRendererError::InvalidStaticMesh {
+ context: "retained dynamic mesh range exceeds uploaded indices",
+ });
+ }
+ }
+ Ok(())
+}
+
+fn apply_geometry_range_updates(
+ ranges: &mut [super::VulkanStaticDrawRange],
+ capacities: &mut [u32],
+ geometry_ranges: &[super::VulkanDynamicDrawRange],
+) {
+ for geometry in geometry_ranges {
+ // `validate_geometry_upload` has already checked every handle. Keep
+ // this helper infallible so state is changed only after the Vulkan
+ // upload succeeds.
+ let range = &mut ranges[geometry.range_index];
+ range.first_index = geometry.first_index;
+ range.index_count = geometry.index_count;
+ // A moved segment no longer owns the old tail. Keeping the previous
+ // range capacity would let a later count setter read indices from a
+ // different segment. The shared GPU allocation itself remains
+ // retained; this is the logical capacity of this range's segment.
+ capacities[geometry.range_index] = geometry.index_count;
+ }
+}
+
+/// Replays the native queue construction from the original stable range
+/// order. Fixed passes stay in their original slots; world opaque ranges are
+/// emitted first, followed by world transparent ranges inserted by descending
+/// camera distance. Zero-index-count ranges are inactive and are omitted.
+fn sorted_world_draw_order(
+ range_count: usize,
+ ranges: &[super::VulkanStaticDrawRange],
+ world_queue: &[Option<bool>],
+ keys: &[Option<f32>],
+) -> Vec<usize> {
+ let mut next = (0..range_count).collect::<Vec<_>>();
+ let mut world_opaque = Vec::new();
+ let mut world_transparent = Vec::new();
+ for range_index in 0..range_count {
+ if ranges
+ .get(range_index)
+ .is_none_or(|range| range.index_count == 0)
+ {
+ continue;
+ }
+ match world_queue.get(range_index).copied().flatten() {
+ Some(false) => world_opaque.push(range_index),
+ Some(true) => world_transparent.push(range_index),
+ None => {}
+ }
+ }
+
+ // Native equal-key insertion is a midpoint insertion rather than a
+ // documented stable sort. Replay that insertion shape from original range
+ // order instead of repeatedly sorting the previous frame's order.
+ let mut ordered_transparent = Vec::with_capacity(world_transparent.len());
+ for range_index in world_transparent {
+ let new_key = keys.get(range_index).and_then(|key| *key);
+ let mut low = 0usize;
+ let mut high = ordered_transparent.len().saturating_sub(1);
+ while high > low {
+ let middle = low + (high - low) / 2;
+ let existing_key = keys.get(ordered_transparent[middle]).and_then(|key| *key);
+ match (new_key, existing_key) {
+ (Some(new_key), Some(existing_key)) if new_key > existing_key => high = middle,
+ (Some(new_key), Some(existing_key)) if new_key < existing_key => low = middle + 1,
+ (Some(_), Some(_)) => {
+ low = middle;
+ break;
+ }
+ (Some(_), None) => high = middle,
+ (None, Some(_)) => low = middle + 1,
+ (None, None) => {
+ low = middle;
+ break;
+ }
+ }
+ }
+ if !ordered_transparent.is_empty() && high == low {
+ let existing_key = keys.get(ordered_transparent[low]).and_then(|key| *key);
+ let insert_after = match (new_key, existing_key) {
+ (Some(new_key), Some(existing_key)) => new_key < existing_key,
+ (None, Some(_)) => true,
+ _ => false,
+ };
+ if insert_after {
+ low += 1;
+ }
+ }
+ ordered_transparent.insert(low, range_index);
+ }
+
+ let mut world_order = world_opaque;
+ world_order.extend(ordered_transparent);
+ let mut world_cursor = 0usize;
+ for slot in &mut next {
+ if world_queue.get(*slot).copied().flatten().is_some()
+ && ranges
+ .get(*slot)
+ .is_some_and(|range| range.index_count != 0)
+ {
+ if let Some(&replacement) = world_order.get(world_cursor) {
+ *slot = replacement;
+ world_cursor += 1;
+ }
+ }
+ }
+ next
+}
+
#[cfg(test)]
mod tests {
+ use super::super::{VulkanDynamicDrawRange, VulkanStaticDrawRange, VulkanStaticMesh};
use super::{
- completed_readback_buffer_index, take_runtime_children_with_validation_snapshot,
- take_runtime_owners_in_dependency_order, RollbackOnDrop,
+ apply_geometry_range_updates, completed_readback_buffer_index, sorted_world_draw_order,
+ take_runtime_children_with_validation_snapshot, take_runtime_owners_in_dependency_order,
+ validate_geometry_upload, RollbackOnDrop,
};
+ use fparkan_render::{LegacyBlendMode, LegacyDepthMode, LegacyPipelineState};
use std::cell::RefCell;
use std::rc::Rc;
@@ -1105,6 +2292,84 @@ mod tests {
}
#[test]
+ fn geometry_updates_preserve_other_range_runtime_state() {
+ let base_state = LegacyPipelineState {
+ blend: LegacyBlendMode::SourceAlpha,
+ depth: LegacyDepthMode::TestReadOnly,
+ ..LegacyPipelineState::default()
+ };
+ let mut ranges = vec![
+ VulkanStaticDrawRange {
+ first_index: 0,
+ index_count: 3,
+ material_index: 7,
+ lightmap_index: 4,
+ batch_flags: 0x108,
+ pipeline_state: base_state,
+ alpha_test_reference: 17,
+ },
+ VulkanStaticDrawRange {
+ first_index: 3,
+ index_count: 3,
+ material_index: 9,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: LegacyPipelineState::default(),
+ alpha_test_reference: 0,
+ },
+ ];
+ let original = ranges[0];
+ let mut capacities = vec![3, 3];
+ apply_geometry_range_updates(
+ &mut ranges,
+ &mut capacities,
+ &[VulkanDynamicDrawRange {
+ range_index: 1,
+ first_index: 3,
+ index_count: 9,
+ }],
+ );
+ assert_eq!(ranges[0], original);
+ assert_eq!(capacities, vec![3, 9]);
+ assert_eq!(ranges[1].first_index, 3);
+ assert_eq!(ranges[1].index_count, 9);
+
+ apply_geometry_range_updates(
+ &mut ranges,
+ &mut capacities,
+ &[VulkanDynamicDrawRange {
+ range_index: 1,
+ first_index: 3,
+ index_count: 3,
+ }],
+ );
+ assert_eq!(ranges[0], original);
+ assert_eq!(capacities, vec![3, 3]);
+ assert_eq!(ranges[1].index_count, 3);
+ }
+
+ #[test]
+ fn geometry_update_can_disable_empty_shadow_range_without_zero_sized_upload() {
+ let mesh = VulkanStaticMesh::smoke_triangle();
+ let geometry = [VulkanDynamicDrawRange {
+ range_index: 0,
+ first_index: 3,
+ index_count: 0,
+ }];
+ assert_eq!(
+ validate_geometry_upload(
+ &mesh.vertices,
+ &mesh.indices,
+ &mesh.draw_ranges,
+ &geometry,
+ 3,
+ 3,
+ ),
+ Ok(())
+ );
+ }
+
+ #[test]
fn runtime_owners_drop_remaining_children_after_partial_init_failures() {
let cases = [
(vec![TeardownStep::Instance], vec![TeardownStep::Instance]),
@@ -1222,4 +2487,83 @@ mod tests {
assert_eq!(log.borrow().as_slice(), &[TeardownStep::Swapchain]);
}
+
+ #[test]
+ fn native_transparent_order_is_descending_and_keeps_fixed_pass_slots() {
+ let ranges = (0..5)
+ .map(|_| super::super::VulkanStaticDrawRange {
+ first_index: 0,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: fparkan_render::LegacyPipelineState::default(),
+ alpha_test_reference: 0,
+ })
+ .collect::<Vec<_>>();
+ let order = sorted_world_draw_order(
+ 5,
+ &ranges,
+ &[None, Some(true), None, Some(true), Some(false)],
+ &[None, Some(2.0), None, Some(9.0), Some(-3.0)],
+ );
+ assert_eq!(order, vec![0, 4, 2, 3, 1]);
+ }
+
+ #[test]
+ fn inactive_world_range_is_not_reused_as_a_sort_slot() {
+ let ranges = vec![
+ super::super::VulkanStaticDrawRange {
+ first_index: 0,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: fparkan_render::LegacyPipelineState::default(),
+ alpha_test_reference: 0,
+ },
+ super::super::VulkanStaticDrawRange {
+ first_index: 3,
+ index_count: 0,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: fparkan_render::LegacyPipelineState::default(),
+ alpha_test_reference: 0,
+ },
+ super::super::VulkanStaticDrawRange {
+ first_index: 3,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: fparkan_render::LegacyPipelineState::default(),
+ alpha_test_reference: 0,
+ },
+ ];
+ let order = sorted_world_draw_order(
+ 3,
+ &ranges,
+ &[None, Some(true), Some(true)],
+ &[None, Some(100.0), Some(1.0)],
+ );
+ assert_eq!(order, vec![0, 1, 2]);
+ }
+
+ #[test]
+ fn native_equal_key_insertion_uses_inclusive_midpoint() {
+ let ranges = (0..4)
+ .map(|index| super::super::VulkanStaticDrawRange {
+ first_index: index * 3,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: fparkan_render::LegacyPipelineState::default(),
+ alpha_test_reference: 0,
+ })
+ .collect::<Vec<_>>();
+ let order = sorted_world_draw_order(4, &ranges, &[Some(true); 4], &[Some(1.0); 4]);
+ assert_eq!(order, vec![2, 3, 1, 0]);
+ }
}
diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
index 4686eaf..75877ad 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs
@@ -10,7 +10,7 @@ use super::{
VulkanSurfaceProbe, VulkanSwapchainProbe, VulkanSwapchainProbeError, VulkanSwapchainResources,
VulkanValidationMessenger,
};
-use crate::shader_manifest::VulkanShaderManifestError;
+use crate::shader::VulkanShaderError;
/// Creates a live native Vulkan renderer for the Stage 0 smoke loop.
#[derive(Clone, Debug)]
@@ -48,8 +48,20 @@ pub struct VulkanStaticVertex {
pub position: [f32; 3],
/// Linear RGB vertex color.
pub color: [f32; 3],
+ /// Unit vertex normal in world/object coordinates.
+ pub normal: [f32; 3],
/// Texture coordinate consumed by the static material bridge.
pub uv: [f32; 2],
+ /// Repeating detail texture coordinate from terrain type-18 UV data.
+ ///
+ /// Non-terrain geometry supplies `[0.0; 2]`; its neutral detail texture
+ /// keeps the additional stage mathematically transparent.
+ pub detail_uv: [f32; 2],
+ /// Interpolated terrain overlay mask from type-14 data.
+ ///
+ /// Non-terrain geometry supplies `0.0`, disabling the transparent overlay
+ /// slot while retaining one vertex contract for every static draw.
+ pub overlay_alpha: f32,
}
/// A static geometry camera represented in the shader's matrix memory order.
@@ -71,7 +83,9 @@ impl VulkanStaticCamera {
projection: LegacyD3d7Projection,
) -> Option<Self> {
let view = transform.try_direct3d7_view_row_major()?;
- let projection = projection.try_direct3d7_projection_row_major()?;
+ let mut projection = projection.try_direct3d7_projection_row_major()?;
+ // D3D7's top-down viewport and Vulkan's positive-height viewport invert NDC Y.
+ projection[5] = -projection[5];
Self::from_row_major_view_projection(view, projection)
}
@@ -108,6 +122,242 @@ impl Default for VulkanStaticCamera {
}
}
+/// Number of directional records carried by [`VulkanFrameUniforms`].
+pub const VULKAN_DIRECTIONAL_LIGHT_COUNT: usize = 4;
+
+/// One directional light in the frame lighting contract.
+///
+/// The direction is the vector from the light toward the surface. The
+/// coefficients are retained in the same three-component record as the
+/// native point callback so frame construction can copy the original light
+/// table without a lossy conversion.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct VulkanDirectionalLight {
+ /// Direction from the light toward the surface.
+ pub direction: [f32; 3],
+ /// Light RGB multiplier.
+ pub rgb: [f32; 3],
+ /// Native light coefficients. Directional callbacks normally use the
+ /// neutral `[0, 0, 1]` record; the field keeps the shared light ABI.
+ pub coefficients: [f32; 3],
+ /// Whether the light participates in this frame.
+ pub active: bool,
+}
+
+impl Default for VulkanDirectionalLight {
+ fn default() -> Self {
+ Self {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [0.0; 3],
+ coefficients: [0.0, 0.0, 1.0],
+ active: false,
+ }
+ }
+}
+
+/// One point light in the frame lighting contract.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct VulkanPointLight {
+ /// World-space position.
+ pub position: [f32; 3],
+ /// Light RGB multiplier.
+ pub rgb: [f32; 3],
+ /// Radius used by native normalized distance attenuation.
+ pub range: f32,
+ /// Native attenuation coefficients `a0`, `a1`, and `a2`.
+ pub coefficients: [f32; 3],
+ /// Whether the light participates in this frame.
+ pub active: bool,
+}
+
+impl Default for VulkanPointLight {
+ fn default() -> Self {
+ Self {
+ position: [0.0; 3],
+ rgb: [0.0; 3],
+ range: 0.0,
+ // A neutral record is explicit. In particular, this is the
+ // proven native environment contract and avoids an accidental
+ // zero polynomial when a caller enables the point slot.
+ coefficients: [0.0, 0.0, 1.0],
+ active: false,
+ }
+ }
+}
+
+/// Number of bytes occupied by one frame-uniform slot.
+///
+/// The block is deliberately made entirely from `mat4`/`vec4`-sized records
+/// so its CPU representation follows Vulkan's `std140` alignment rules on
+/// every device. Four directional lights and one point light fit in a small
+/// dynamic UBO while the push-constant range remains available for material
+/// state.
+pub const VULKAN_FRAME_UNIFORM_BYTES: usize = 368;
+
+/// Bytes occupied by one material's base and overlay specular records.
+///
+/// Each record is one `vec4`: RGB followed by the native integer power stored
+/// as an IEEE-754 float for direct shader use. The allocation stride is
+/// rounded up to the device's `minStorageBufferOffsetAlignment`.
+pub const VULKAN_MATERIAL_SPECULAR_RECORD_BYTES: usize = 2 * 4 * std::mem::size_of::<f32>();
+
+/// Per-frame data shared by the vertex and fragment stages.
+///
+/// `clip_from_world` is row-major D3D-style storage for the same reason as
+/// [`VulkanStaticCamera`]. A directional light direction points from the
+/// light toward the surface; the shader negates it when taking the
+/// surface-normal dot product. Fog distances are disabled when
+/// `fog_end <= fog_start`.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct VulkanFrameUniforms {
+ /// Row-major clip-from-world transform.
+ pub clip_from_world: [f32; 16],
+ /// Up to four native directional/celestial lights.
+ pub directional_lights: [VulkanDirectionalLight; VULKAN_DIRECTIONAL_LIGHT_COUNT],
+ /// Native point light slot.
+ pub point_light: VulkanPointLight,
+ /// Per-channel lighting floor applied before native range compression.
+ pub lighting_floor: [f32; 3],
+ /// Linear fog RGB color.
+ pub fog_color: [f32; 3],
+ /// Fog start distance.
+ pub fog_start: f32,
+ /// Fog end distance.
+ pub fog_end: f32,
+ /// World-space camera position used for fog distance.
+ pub camera_position: [f32; 3],
+}
+
+impl VulkanFrameUniforms {
+ /// Builds neutral frame state from a static camera.
+ #[must_use]
+ pub fn from_camera(camera: VulkanStaticCamera) -> Self {
+ let mut directional_lights =
+ [VulkanDirectionalLight::default(); VULKAN_DIRECTIONAL_LIGHT_COUNT];
+ directional_lights[0] = VulkanDirectionalLight {
+ direction: [0.35, 0.45, 0.82],
+ rgb: [1.0; 3],
+ coefficients: [0.0, 0.0, 1.0],
+ active: true,
+ };
+ Self {
+ clip_from_world: camera.clip_from_world,
+ directional_lights,
+ point_light: VulkanPointLight::default(),
+ lighting_floor: [0.30; 3],
+ fog_color: [0.0; 3],
+ fog_start: f32::MAX,
+ fog_end: f32::MAX,
+ camera_position: [0.0; 3],
+ }
+ }
+
+ /// Returns whether all submitted frame values are finite.
+ #[must_use]
+ pub fn is_finite(self) -> bool {
+ self.clip_from_world
+ .into_iter()
+ .chain(self.directional_lights.into_iter().flat_map(|light| {
+ light
+ .direction
+ .into_iter()
+ .chain(light.rgb)
+ .chain(light.coefficients)
+ .chain([if light.active { 1.0 } else { 0.0 }])
+ }))
+ .chain(self.point_light.position)
+ .chain(self.point_light.rgb)
+ .chain([self.point_light.range])
+ .chain(self.point_light.coefficients)
+ .chain([if self.point_light.active { 1.0 } else { 0.0 }])
+ .chain(self.lighting_floor)
+ .chain(self.fog_color)
+ .chain([self.fog_start, self.fog_end])
+ .chain(self.camera_position)
+ .all(f32::is_finite)
+ }
+
+ /// Serializes one frame slot in the exact `std140` member order.
+ #[must_use]
+ pub fn to_ne_bytes(self) -> [u8; VULKAN_FRAME_UNIFORM_BYTES] {
+ let mut values = [0.0_f32; VULKAN_FRAME_UNIFORM_BYTES / 4];
+ values[..16].copy_from_slice(&self.clip_from_world);
+ for (index, light) in self.directional_lights.into_iter().enumerate() {
+ let direction_offset = 16 + index * 4;
+ values[direction_offset..direction_offset + 4].copy_from_slice(&[
+ light.direction[0],
+ light.direction[1],
+ light.direction[2],
+ if light.active { 1.0 } else { 0.0 },
+ ]);
+ let rgb_offset = 32 + index * 4;
+ values[rgb_offset..rgb_offset + 4].copy_from_slice(&[
+ light.rgb[0],
+ light.rgb[1],
+ light.rgb[2],
+ if light.active { 1.0 } else { 0.0 },
+ ]);
+ let coefficient_offset = 48 + index * 4;
+ values[coefficient_offset..coefficient_offset + 4].copy_from_slice(&[
+ light.coefficients[0],
+ light.coefficients[1],
+ light.coefficients[2],
+ if light.active { 1.0 } else { 0.0 },
+ ]);
+ }
+ values[64..68].copy_from_slice(&[
+ self.point_light.position[0],
+ self.point_light.position[1],
+ self.point_light.position[2],
+ self.point_light.range,
+ ]);
+ values[68..72].copy_from_slice(&[
+ self.point_light.rgb[0],
+ self.point_light.rgb[1],
+ self.point_light.rgb[2],
+ if self.point_light.active { 1.0 } else { 0.0 },
+ ]);
+ values[72..76].copy_from_slice(&[
+ self.point_light.coefficients[0],
+ self.point_light.coefficients[1],
+ self.point_light.coefficients[2],
+ if self.point_light.active { 1.0 } else { 0.0 },
+ ]);
+ values[76..80].copy_from_slice(&[
+ self.lighting_floor[0],
+ self.lighting_floor[1],
+ self.lighting_floor[2],
+ 0.0,
+ ]);
+ values[80..84].copy_from_slice(&[
+ self.fog_color[0],
+ self.fog_color[1],
+ self.fog_color[2],
+ 0.0,
+ ]);
+ values[84..88].copy_from_slice(&[self.fog_start, self.fog_end, 0.0, 0.0]);
+ values[88..92].copy_from_slice(&[
+ self.camera_position[0],
+ self.camera_position[1],
+ self.camera_position[2],
+ 0.0,
+ ]);
+ let mut bytes = [0_u8; VULKAN_FRAME_UNIFORM_BYTES];
+ for (index, value) in values.into_iter().enumerate() {
+ let offset = index * std::mem::size_of::<f32>();
+ bytes[offset..offset + std::mem::size_of::<f32>()]
+ .copy_from_slice(&value.to_ne_bytes());
+ }
+ bytes
+ }
+}
+
+impl Default for VulkanFrameUniforms {
+ fn default() -> Self {
+ Self::from_camera(VulkanStaticCamera::default())
+ }
+}
+
fn multiply_row_major(left: [f32; 16], right: [f32; 16]) -> [f32; 16] {
let mut result = [0.0; 16];
for row in 0..4 {
@@ -131,6 +381,23 @@ pub struct VulkanStaticMesh {
pub draw_ranges: Vec<VulkanStaticDrawRange>,
}
+/// Geometry-only changes applied to one retained draw range.
+///
+/// The renderer owns the material, blend/depth state, active count changes,
+/// and queue classification for every range. A frame builder uses this
+/// record only when a variable-size geometry stream (such as projected
+/// shadows) moves its index segment or changes its triangle count.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct VulkanDynamicDrawRange {
+ /// Stable draw-range handle returned by the initial mesh layout.
+ pub range_index: usize,
+ /// First index in the current shared index upload.
+ pub first_index: u32,
+ /// Active triangle-list index count. Zero disables this range for the
+ /// current frame without allocating a zero-sized Vulkan buffer.
+ pub index_count: u32,
+}
+
/// One indexed triangle-list draw retained from an original mesh batch.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanStaticDrawRange {
@@ -140,6 +407,12 @@ pub struct VulkanStaticDrawRange {
pub index_count: u32,
/// Original positional `Batch20.material_index` selector.
pub material_index: u16,
+ /// Original positional `Batch20.lightmap_index` selector.
+ pub lightmap_index: u8,
+ /// Original MSH Batch20 flag word used by the native transparent queue.
+ /// Bits `0x100` and `0x008` opt a range into that queue independently of
+ /// the material blend category.
+ pub batch_flags: 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.
@@ -147,6 +420,13 @@ pub struct VulkanStaticDrawRange {
}
impl VulkanStaticDrawRange {
+ /// The value installed by the native renderer before a material-specific
+ /// alpha reference is written. The native Ngi32 initialiser stores one
+ /// in `D3DRS_ALPHAREF`; callers that do not have a material-specific value
+ /// should pass this constant explicitly. Zero remains a valid explicit
+ /// alpha reference.
+ pub const NATIVE_DEFAULT_ALPHA_TEST_REFERENCE: u8 = 1;
+
/// Returns the canonical key used for Vulkan pipeline selection.
#[must_use]
pub fn pipeline_key(self) -> PipelineKey {
@@ -165,12 +445,75 @@ impl VulkanStaticDrawRange {
}
/// One diffuse material texture keyed by an original MSH batch selector.
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, 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,
+ /// Phase parameters 4..6, normalized directional light RGB coefficient.
+ pub directional_rgb: [f32; 3],
+ /// Phase parameters 0..2, normalized additive RGB coefficient.
+ pub additive_rgb: [f32; 3],
+ /// Normalized atlas transform for base texture `[x, y, w, h]`.
+ pub uv_transform: [f32; 4],
+ /// Optional Land2 detail texture paired with the base texture.
+ pub detail_texture: Option<VulkanStaticTexture>,
+ /// Optional Land1 overlay texture selected by the face tag high byte.
+ pub overlay_texture: Option<VulkanStaticTexture>,
+ /// Optional Land2 detail texture paired with the overlay texture.
+ pub overlay_detail_texture: Option<VulkanStaticTexture>,
+ /// MAT0 phase diffuse alpha, normalized from the legacy 0..=255 byte.
+ pub diffuse_alpha: f32,
+ /// MAT0 phase alpha for the optional overlay material.
+ pub overlay_diffuse_alpha: Option<f32>,
+ /// Phase parameters 4..6 for the optional overlay material.
+ pub overlay_directional_rgb: Option<[f32; 3]>,
+ /// Phase parameters 0..2 for the optional overlay material.
+ pub overlay_additive_rgb: Option<[f32; 3]>,
+ /// Phase parameters 8..10, normalized base specular RGB coefficient.
+ ///
+ /// Specular data is kept in a storage buffer instead of push constants so
+ /// the vertex stage can evaluate the native Gouraud highlight while the
+ /// material's other dynamic values retain their existing ABI.
+ pub specular_rgb: [f32; 3],
+ /// Phase parameter 16 for the base material, carried as the native byte.
+ pub specular_power: u8,
+ /// Phase parameters 8..10 for the optional overlay material.
+ pub overlay_specular_rgb: Option<[f32; 3]>,
+ /// Phase parameter 16 for the optional overlay material.
+ pub overlay_specular_power: Option<u8>,
+ /// Normalized atlas transform for the optional detail texture.
+ pub detail_uv_transform: [f32; 4],
+ /// Normalized atlas transform for the optional overlay texture.
+ pub overlay_uv_transform: Option<[f32; 4]>,
+ /// Normalized atlas transform for the optional overlay detail texture.
+ pub overlay_detail_uv_transform: Option<[f32; 4]>,
+ /// Environment layers bypass terrain lighting and fog while retaining the
+ /// same descriptor/pipeline path.
+ pub unlit: bool,
+ /// Native sky mode 4: blend base and secondary textures by the secondary
+ /// texture alpha before multiplying by the vertex diffuse color.
+ pub sky_nebula_stars: bool,
+ /// Native terrain mode 3: combine base and secondary lightmap textures
+ /// without the regular terrain `2 * TEX0 * TEX1` combiner or NdotL.
+ pub lightmap_mode: bool,
+ /// Push material mode bit 4 and map this sky layer to the Vulkan far
+ /// plane without changing its world-space vertices or UVs. This is a
+ /// renderer projection choice; it is not a claim about the original D3D
+ /// depth-bit encoding.
+ pub sky_far_depth: bool,
+}
+
+/// One authored RGBA8 mip level accepted by the Vulkan texture upload path.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct VulkanStaticTextureMip {
+ /// Mip width in texels.
+ pub width: u32,
+ /// Mip height in texels.
+ pub height: u32,
+ /// Row-major RGBA8 pixels.
+ pub rgba8: Vec<u8>,
}
/// Decoded RGBA8 image accepted by the initial Vulkan texture upload path.
@@ -182,6 +525,10 @@ pub struct VulkanStaticTexture {
pub height: u32,
/// Row-major RGBA8 pixels.
pub rgba8: Vec<u8>,
+ /// Authored mip levels, including mip zero, when available.
+ ///
+ /// An empty vector requests deterministic CPU generation from `rgba8`.
+ pub mip_levels: Vec<VulkanStaticTextureMip>,
}
impl VulkanStaticTexture {
@@ -200,7 +547,46 @@ impl VulkanStaticTexture {
Some(expected) if expected != self.rgba8.len() => {
Err("static texture rgba8 byte count does not match extent")
}
- Some(_) => Ok(()),
+ Some(_) => {
+ if self.mip_levels.is_empty() {
+ return Ok(());
+ }
+ // TEXM files commonly stop at the authored 4x4 level. Vulkan
+ // permits a partial mip chain; only the supplied level
+ // extents and bytes need validation.
+ if self.mip_levels.len()
+ > usize::try_from(self.width.max(self.height))
+ .unwrap_or(usize::MAX)
+ .ilog2() as usize
+ + 1
+ {
+ return Err("static texture authored mip count exceeds extent");
+ }
+ let mut width = self.width;
+ let mut height = self.height;
+ for (level, mip) in self.mip_levels.iter().enumerate() {
+ if mip.width != width || mip.height != height {
+ return Err("static texture authored mip extent does not match level");
+ }
+ let expected = usize::try_from(width)
+ .ok()
+ .and_then(|width| {
+ usize::try_from(height)
+ .ok()
+ .and_then(|height| width.checked_mul(height))
+ })
+ .and_then(|pixels| pixels.checked_mul(4));
+ if expected != Some(mip.rgba8.len()) {
+ return Err("static texture authored mip byte count does not match extent");
+ }
+ if level == 0 && mip.rgba8 != self.rgba8 {
+ return Err("static texture authored mip zero does not match rgba8");
+ }
+ width = (width / 2).max(1);
+ height = (height / 2).max(1);
+ }
+ Ok(())
+ }
}
}
}
@@ -216,6 +602,60 @@ pub(super) fn resolve_draw_texture_indices(
) -> Result<Vec<usize>, &'static str> {
for (index, material) in materials.iter().enumerate() {
material.texture.validate()?;
+ if let Some(texture) = &material.detail_texture {
+ texture.validate()?;
+ }
+ if let Some(texture) = &material.overlay_texture {
+ texture.validate()?;
+ }
+ if let Some(texture) = &material.overlay_detail_texture {
+ texture.validate()?;
+ }
+ if !material.diffuse_alpha.is_finite() || !(0.0..=1.0).contains(&material.diffuse_alpha) {
+ return Err("static material diffuse alpha must be finite and 0..1");
+ }
+ if material
+ .directional_rgb
+ .into_iter()
+ .chain(material.additive_rgb)
+ .chain(material.specular_rgb)
+ .chain(material.overlay_specular_rgb.into_iter().flatten())
+ .any(|value| !value.is_finite() || value < 0.0)
+ {
+ return Err("static material lighting and specular RGB parameters must be finite and non-negative");
+ }
+ if material
+ .uv_transform
+ .into_iter()
+ .chain(material.detail_uv_transform)
+ .any(|value| !value.is_finite())
+ {
+ return Err("static material UV transforms must be finite");
+ }
+ if material
+ .overlay_diffuse_alpha
+ .is_some_and(|alpha| !alpha.is_finite() || !(0.0..=1.0).contains(&alpha))
+ {
+ return Err("static material overlay diffuse alpha must be finite and 0..1");
+ }
+ if material
+ .overlay_directional_rgb
+ .into_iter()
+ .flatten()
+ .chain(material.overlay_additive_rgb.into_iter().flatten())
+ .any(|value| !value.is_finite() || value < 0.0)
+ {
+ return Err("static material overlay RGB parameters must be finite and non-negative");
+ }
+ if material
+ .overlay_uv_transform
+ .into_iter()
+ .flatten()
+ .chain(material.overlay_detail_uv_transform.into_iter().flatten())
+ .any(|value| !value.is_finite())
+ {
+ return Err("static material overlay UV transforms must be finite");
+ }
if materials[..index]
.iter()
.any(|previous| previous.material_index == material.material_index)
@@ -247,17 +687,26 @@ impl VulkanStaticMesh {
VulkanStaticVertex {
position: [0.0, -0.55, 0.0],
color: [1.0, 0.2, 0.2],
+ normal: [0.0, 0.0, 1.0],
uv: [0.5, 0.0],
+ detail_uv: [0.0, 0.0],
+ overlay_alpha: 0.0,
},
VulkanStaticVertex {
position: [0.55, 0.55, 0.0],
color: [0.2, 1.0, 0.2],
+ normal: [0.0, 0.0, 1.0],
uv: [1.0, 1.0],
+ detail_uv: [0.0, 0.0],
+ overlay_alpha: 0.0,
},
VulkanStaticVertex {
position: [-0.55, 0.55, 0.0],
color: [0.2, 0.4, 1.0],
+ normal: [0.0, 0.0, 1.0],
uv: [0.0, 1.0],
+ detail_uv: [0.0, 0.0],
+ overlay_alpha: 0.0,
},
],
indices: vec![0, 1, 2],
@@ -265,6 +714,8 @@ impl VulkanStaticMesh {
first_index: 0,
index_count: 3,
material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
}],
@@ -275,6 +726,22 @@ impl VulkanStaticMesh {
if self.vertices.is_empty() {
return Err("static mesh has no vertices");
}
+ if self.vertices.iter().any(|vertex| {
+ !vertex
+ .position
+ .iter()
+ .chain(vertex.color.iter())
+ .chain(vertex.normal.iter())
+ .chain(vertex.uv.iter())
+ .chain(vertex.detail_uv.iter())
+ .chain(std::iter::once(&vertex.overlay_alpha))
+ .all(|value| value.is_finite())
+ || !(0.0..=1.0).contains(&vertex.overlay_alpha)
+ }) {
+ return Err(
+ "static mesh vertex attributes must be finite and overlay alpha must be 0..1",
+ );
+ }
if self.indices.is_empty() || !self.indices.len().is_multiple_of(3) {
return Err("static mesh indices must contain complete triangles");
}
@@ -312,6 +779,53 @@ mod static_mesh_tests {
use super::*;
#[test]
+ fn frame_uniforms_use_std140_sized_slots_and_preserve_dynamic_values() {
+ let frame = VulkanFrameUniforms {
+ clip_from_world: std::array::from_fn(|index| index as f32),
+ directional_lights: [
+ VulkanDirectionalLight {
+ direction: [1.0, 2.0, 3.0],
+ rgb: [4.0, 5.0, 6.0],
+ coefficients: [7.0, 8.0, 9.0],
+ active: true,
+ },
+ VulkanDirectionalLight::default(),
+ VulkanDirectionalLight::default(),
+ VulkanDirectionalLight::default(),
+ ],
+ point_light: VulkanPointLight {
+ position: [28.0, 29.0, 30.0],
+ rgb: [31.0, 32.0, 33.0],
+ range: 34.0,
+ coefficients: [35.0, 36.0, 37.0],
+ active: true,
+ },
+ lighting_floor: [7.0, 8.0, 9.0],
+ fog_color: [10.0, 11.0, 12.0],
+ fog_start: 13.0,
+ fog_end: 14.0,
+ camera_position: [15.0, 16.0, 17.0],
+ };
+ let bytes = frame.to_ne_bytes();
+ assert_eq!(bytes.len(), VULKAN_FRAME_UNIFORM_BYTES);
+ let read =
+ |offset: usize| f32::from_ne_bytes(bytes[offset..offset + 4].try_into().expect("f32"));
+ assert_eq!(read(0), 0.0);
+ assert_eq!(read(60), 15.0);
+ assert_eq!(read(64), 1.0);
+ assert_eq!(read(76), 1.0);
+ assert_eq!(read(128), 4.0);
+ assert_eq!(read(192), 7.0);
+ assert_eq!(read(256), 28.0);
+ assert_eq!(read(272), 31.0);
+ assert_eq!(read(288), 35.0);
+ assert_eq!(read(304), 7.0);
+ assert_eq!(read(320), 10.0);
+ assert_eq!(read(336), 13.0);
+ assert_eq!(read(352), 15.0);
+ }
+
+ #[test]
fn smoke_triangle_is_valid_complete_geometry() {
let mesh = VulkanStaticMesh::smoke_triangle();
@@ -358,6 +872,54 @@ mod static_mesh_tests {
}
#[test]
+ fn legacy_camera_maps_world_up_to_top_of_positive_height_vulkan_viewport() {
+ let transform = RawCameraTransform {
+ words: [
+ 1.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 1.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 0.0_f32.to_bits(),
+ 1.0_f32.to_bits(),
+ 0.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");
+ // The raw identity camera's recovered view maps world +X to depth
+ // and world +Z to camera up.
+ let world_up_at_positive_depth = [10.0, 0.0, 1.0, 1.0];
+ let clip_y = (0..4)
+ .map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 1])
+ .sum::<f32>();
+ let clip_w = (0..4)
+ .map(|row| world_up_at_positive_depth[row] * camera.clip_from_world[row * 4 + 3])
+ .sum::<f32>();
+
+ assert!(clip_w > 0.0);
+ assert!(
+ clip_y / clip_w < 0.0,
+ "world up must map to the viewport top"
+ );
+ }
+
+ #[test]
fn static_camera_accepts_finite_runtime_matrices_and_rejects_nan() {
let identity = VulkanStaticCamera::default().clip_from_world;
@@ -387,6 +949,8 @@ mod static_mesh_tests {
first_index: 0,
index_count: 2,
material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
}],
@@ -415,6 +979,8 @@ mod static_mesh_tests {
first_index: 0,
index_count: 3,
material_index: 7,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
},
@@ -422,6 +988,8 @@ mod static_mesh_tests {
first_index: 3,
index_count: 3,
material_index: 2,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
pipeline_state: LegacyPipelineState::default(),
alpha_test_reference: 0,
},
@@ -430,15 +998,58 @@ mod static_mesh_tests {
width: 1,
height: 1,
rgba8: vec![255; 4],
+ mip_levels: Vec::new(),
};
let materials = [
VulkanStaticMaterial {
material_index: 2,
texture: texture(),
+ detail_texture: None,
+ overlay_texture: None,
+ overlay_detail_texture: None,
+ diffuse_alpha: 1.0,
+ overlay_diffuse_alpha: None,
+ directional_rgb: [1.0; 3],
+ additive_rgb: [0.0; 3],
+ specular_rgb: [0.0; 3],
+ specular_power: 0,
+ overlay_specular_rgb: None,
+ overlay_specular_power: None,
+ uv_transform: [0.0, 0.0, 1.0, 1.0],
+ overlay_directional_rgb: None,
+ overlay_additive_rgb: None,
+ detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
+ overlay_uv_transform: None,
+ overlay_detail_uv_transform: None,
+ unlit: false,
+ sky_nebula_stars: false,
+ lightmap_mode: false,
+ sky_far_depth: false,
},
VulkanStaticMaterial {
material_index: 7,
texture: texture(),
+ detail_texture: None,
+ overlay_texture: None,
+ overlay_detail_texture: None,
+ diffuse_alpha: 1.0,
+ overlay_diffuse_alpha: None,
+ directional_rgb: [1.0; 3],
+ additive_rgb: [0.0; 3],
+ specular_rgb: [0.0; 3],
+ specular_power: 0,
+ overlay_specular_rgb: None,
+ overlay_specular_power: None,
+ uv_transform: [0.0, 0.0, 1.0, 1.0],
+ overlay_directional_rgb: None,
+ overlay_additive_rgb: None,
+ detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
+ overlay_uv_transform: None,
+ overlay_detail_uv_transform: None,
+ unlit: false,
+ sky_nebula_stars: false,
+ lightmap_mode: false,
+ sky_far_depth: false,
},
];
@@ -480,6 +1091,24 @@ mod static_mesh_tests {
};
assert_eq!(disabled.alpha_test_cutoff(), 0.0);
assert_eq!(enabled.alpha_test_cutoff(), 128.0 / 255.0);
+ let explicit_zero = VulkanStaticDrawRange {
+ pipeline_state: LegacyPipelineState {
+ alpha_test: true,
+ ..LegacyPipelineState::default()
+ },
+ alpha_test_reference: 0,
+ ..base
+ };
+ assert_eq!(explicit_zero.alpha_test_cutoff(), 0.0);
+ let native_default = VulkanStaticDrawRange {
+ pipeline_state: LegacyPipelineState {
+ alpha_test: true,
+ ..LegacyPipelineState::default()
+ },
+ alpha_test_reference: VulkanStaticDrawRange::NATIVE_DEFAULT_ALPHA_TEST_REFERENCE,
+ ..base
+ };
+ assert_eq!(native_default.alpha_test_cutoff(), 1.0 / 255.0);
}
}
@@ -536,7 +1165,7 @@ impl VulkanSmokeBootstrapProgress {
/// 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,
@@ -562,8 +1191,6 @@ 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`.
@@ -652,8 +1279,8 @@ pub enum VulkanSmokeRendererError {
LogicalDevice(VulkanLogicalDeviceError),
/// Swapchain bootstrap failed.
Swapchain(VulkanSwapchainProbeError),
- /// Shader manifest validation failed.
- ShaderManifest(VulkanShaderManifestError),
+ /// Checked-in shader validation failed.
+ Shader(VulkanShaderError),
/// Vulkan operation failed.
VulkanOperation {
/// Operation context.
@@ -695,7 +1322,7 @@ impl std::fmt::Display for VulkanSmokeRendererError {
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::Shader(error) => write!(f, "{error}"),
Self::VulkanOperation { context, result } => {
write!(f, "{context}: {result:?}")
}
@@ -729,10 +1356,46 @@ pub struct VulkanSmokeRenderer {
pub(super) swapchain_resources: Option<VulkanSwapchainResources>,
pub(super) vertex_buffer: Option<VulkanAllocatedBuffer>,
pub(super) index_buffer: Option<VulkanAllocatedBuffer>,
+ /// Positional material slots mapped to the owned image list. Fallback
+ /// stages may share one image without coupling independently animated
+ /// material textures.
+ pub(super) texture_slot_indices: Vec<usize>,
pub(super) textures: Vec<VulkanAllocatedImage>,
+ pub(super) vertex_count: usize,
+ /// Retained index allocation length. The upload path grows this only when
+ /// a frame actually needs more shared indices and never shrinks it.
+ pub(super) index_capacity: usize,
+ pub(super) draw_range_capacities: Vec<u32>,
pub(super) draw_ranges: Vec<VulkanStaticDrawRange>,
pub(super) draw_texture_indices: Vec<usize>,
+ /// Stable range handles used when recording the native pass order.
+ pub(super) draw_order: Vec<usize>,
+ /// Native camera-sort values returned by the draw-item camera callback.
+ pub(super) draw_range_sort_keys: Vec<Option<f32>>,
+ /// Whether the stable order must be rebuilt before the next command
+ /// buffer. Range setters are called in batches by the game loop.
+ pub(super) draw_order_dirty: bool,
+ /// World queue classification for each stable range handle. `None` keeps
+ /// a fixed screen/sky/weather pass; `Some(false)` is world opaque and
+ /// `Some(true)` is world transparent.
+ pub(super) draw_range_world_queue: Vec<Option<bool>>,
+ /// Pipeline state before transparent queue depth policy is applied.
+ pub(super) draw_range_base_pipeline_states: Vec<LegacyPipelineState>,
+ pub(super) material_alphas: Vec<[f32; 2]>,
+ pub(super) material_lighting: Vec<[[f32; 3]; 4]>,
+ pub(super) material_uv_transforms: Vec<[[f32; 4]; 4]>,
+ /// Base and overlay specular records uploaded to the dynamic SSBO.
+ pub(super) material_specular: Vec<[[f32; 4]; 2]>,
+ /// Original selector for each positional material record.
+ pub(super) material_selectors: Vec<u16>,
+ /// Whether the host-side specular records need a fence-safe GPU upload.
+ pub(super) material_specular_dirty: bool,
+ /// Encoded material mode pushed at offset 124: 0 lit, 1 unlit, 2 native
+ /// sky nebula/stars texture composite, 3 native lightmap composite.
+ pub(super) material_unlit: Vec<f32>,
+ pub(super) readback_enabled: bool,
pub(super) camera: VulkanStaticCamera,
+ pub(super) frame_uniforms: VulkanFrameUniforms,
pub(super) frame_sync: Vec<VulkanFrameSync>,
pub(super) images_in_flight: Vec<vk::Fence>,
pub(super) current_frame: usize,
diff --git a/adapters/fparkan-render-vulkan/src/ffi/surface.rs b/adapters/fparkan-render-vulkan/src/ffi/surface.rs
index 8fc9c5a..65a8b7f 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/surface.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/surface.rs
@@ -2,12 +2,10 @@
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)]
@@ -132,24 +130,6 @@ pub fn create_vulkan_surface_probe(
})
}
-/// 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) };
diff --git a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
index 7558db6..b8049d3 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs
@@ -8,44 +8,90 @@ use fparkan_render::{
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,
+ color_subresource_range, create_depth_attachment, create_material_specular_buffer,
+ destroy_depth_attachment, VulkanAllocatedBuffer, VulkanAllocatedImage, VulkanDepthAttachment,
+ VulkanInstanceProbe, VulkanLogicalDeviceProbe, VulkanSmokeRendererError, VulkanSwapchainProbe,
+ TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS, VULKAN_FRAME_UNIFORM_BYTES,
+ VULKAN_MATERIAL_SPECULAR_RECORD_BYTES,
};
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,
+ /// One depth image per swapchain image so frames can overlap safely.
+ pub(super) depth_attachments: Vec<VulkanDepthAttachment>,
+ /// A render-finished semaphore dedicated to each swapchain image.
+ pub(super) render_finished: Vec<vk::Semaphore>,
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,
+ pub(super) frame_uniform_buffer: VulkanAllocatedBuffer,
+ pub(super) frame_uniform_stride: u64,
+ pub(super) material_specular_buffer: VulkanAllocatedBuffer,
+ pub(super) material_specular_stride: u64,
/// 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>,
}
+pub(super) fn update_material_texture_descriptor(
+ device: &VulkanLogicalDeviceProbe,
+ resources: &VulkanSwapchainResources,
+ material_index: usize,
+ texture_slot: usize,
+ texture: &VulkanAllocatedImage,
+) -> Result<(), VulkanSmokeRendererError> {
+ if texture_slot >= 4 {
+ return Err(VulkanSmokeRendererError::InvalidStaticTexture {
+ context: "dynamic material texture slot is out of bounds",
+ });
+ }
+ let descriptor_set = resources
+ .descriptor_sets
+ .get(material_index)
+ .copied()
+ .ok_or(VulkanSmokeRendererError::InvalidStaticTexture {
+ context: "dynamic material descriptor set is out of bounds",
+ })?;
+ let image_info = vk::DescriptorImageInfo::default()
+ .sampler(resources.sampler)
+ .image_view(texture.view)
+ .image_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL);
+ let write = vk::WriteDescriptorSet::default()
+ .dst_set(descriptor_set)
+ .dst_binding(u32::try_from(texture_slot).unwrap_or(u32::MAX))
+ .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
+ .image_info(std::slice::from_ref(&image_info));
+ // SAFETY: the renderer idles the device before changing a descriptor that
+ // may be referenced by an in-flight command buffer.
+ unsafe { device.device().update_descriptor_sets(&[write], &[]) };
+ Ok(())
+}
+
struct PartialSwapchainResources {
image_views: Vec<vk::ImageView>,
readback_buffers: Vec<VulkanAllocatedBuffer>,
- depth_attachment: Option<VulkanDepthAttachment>,
+ depth_attachments: Vec<VulkanDepthAttachment>,
+ render_finished: Vec<vk::Semaphore>,
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>,
+ frame_uniform_buffer: Option<VulkanAllocatedBuffer>,
+ frame_uniform_stride: u64,
+ material_specular_buffer: Option<VulkanAllocatedBuffer>,
+ material_specular_stride: u64,
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,
@@ -54,6 +100,7 @@ pub(super) fn create_swapchain_resources(
vertex_buffer: &VulkanAllocatedBuffer,
index_buffer: &VulkanAllocatedBuffer,
textures: &[VulkanAllocatedImage],
+ texture_slot_indices: &[usize],
draw_ranges: &[super::VulkanStaticDrawRange],
depth_request: DepthStencilSupport,
reuse_command_pool: bool,
@@ -75,16 +122,57 @@ pub(super) fn create_swapchain_resources(
swapchain.report.plan.format.format,
)?,
readback_buffers: Vec::new(),
- depth_attachment: None,
+ depth_attachments: Vec::new(),
+ render_finished: Vec::new(),
render_pass: None,
pipeline_layout: None,
descriptor_set_layout: None,
descriptor_pool: None,
sampler: None,
+ frame_uniform_buffer: None,
+ frame_uniform_stride: 0,
+ material_specular_buffer: None,
+ material_specular_stride: 0,
pipelines: BTreeMap::new(),
framebuffers: Vec::new(),
command_buffers: Vec::new(),
};
+ let semaphore_info = vk::SemaphoreCreateInfo::default();
+ for _ in &images {
+ // SAFETY: The semaphore belongs to this live logical device and is
+ // destroyed with the swapchain resources.
+ match unsafe { device.device().create_semaphore(&semaphore_info, None) } {
+ Ok(semaphore) => partial.render_finished.push(semaphore),
+ Err(result) => {
+ destroy_partial_swapchain_resources(device, command_pool, partial);
+ return Err(VulkanSmokeRendererError::VulkanOperation {
+ context: "vkCreateSemaphore(render_finished)",
+ result,
+ });
+ }
+ }
+ }
+ let (frame_uniform_buffer, frame_uniform_stride) =
+ match super::create_frame_uniform_buffer(instance, device) {
+ Ok(buffer) => buffer,
+ Err(error) => {
+ destroy_partial_swapchain_resources(device, command_pool, partial);
+ return Err(error);
+ }
+ };
+ partial.frame_uniform_stride = frame_uniform_stride;
+ partial.frame_uniform_buffer = Some(frame_uniform_buffer);
+ let material_count = texture_slot_indices.len() / 4;
+ let (material_specular_buffer, material_specular_stride) =
+ match create_material_specular_buffer(instance, device, material_count) {
+ Ok(buffer) => buffer,
+ Err(error) => {
+ destroy_partial_swapchain_resources(device, command_pool, partial);
+ return Err(error);
+ }
+ };
+ partial.material_specular_stride = material_specular_stride;
+ partial.material_specular_buffer = Some(material_specular_buffer);
let (
depth_attachment,
render_pass,
@@ -100,8 +188,17 @@ pub(super) fn create_swapchain_resources(
swapchain.report.plan.format.format,
swapchain.report.plan.extent,
textures,
+ texture_slot_indices,
draw_ranges,
depth_request,
+ partial
+ .frame_uniform_buffer
+ .as_ref()
+ .expect("frame uniform buffer is present before descriptor creation"),
+ partial
+ .material_specular_buffer
+ .as_ref()
+ .expect("material specular buffer is present before descriptor creation"),
) {
Ok(bundle) => bundle,
Err(error) => {
@@ -109,19 +206,35 @@ pub(super) fn create_swapchain_resources(
return Err(error);
}
};
- let depth_view = depth_attachment.image.view;
- partial.depth_attachment = Some(depth_attachment);
+ // Keep every handle from the bundle in the partial owner before creating
+ // the remaining per-image depth attachments. That loop can fail after the
+ // first attachment, and its error path must destroy the whole bundle.
+ partial.depth_attachments.push(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;
+ for _ in 1..images.len() {
+ match create_depth_attachment(
+ instance,
+ device,
+ swapchain.report.plan.extent,
+ depth_request,
+ ) {
+ Ok(attachment) => partial.depth_attachments.push(attachment),
+ Err(error) => {
+ destroy_partial_swapchain_resources(device, command_pool, partial);
+ return Err(error);
+ }
+ }
+ }
let framebuffers = match create_swapchain_framebuffers(
device,
render_pass,
&partial.image_views,
- depth_view,
+ &partial.depth_attachments,
swapchain.report.plan.extent,
) {
Ok(framebuffers) => framebuffers,
@@ -169,24 +282,44 @@ pub(super) fn create_swapchain_resources(
};
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",
- })?;
+ if partial.depth_attachments.len() != images.len()
+ || partial.render_finished.len() != images.len()
+ {
+ destroy_partial_swapchain_resources(device, command_pool, partial);
+ return Err(VulkanSmokeRendererError::InvariantViolation {
+ context: "swapchain per-image resource count after 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,
+ depth_attachments: partial.depth_attachments,
+ render_finished: partial.render_finished,
+ render_pass: partial
+ .render_pass
+ .expect("render pass is present after successful swapchain setup"),
+ pipeline_layout: partial
+ .pipeline_layout
+ .expect("pipeline layout is present after successful swapchain setup"),
+ descriptor_set_layout: partial
+ .descriptor_set_layout
+ .expect("descriptor set layout is present after successful swapchain setup"),
+ descriptor_pool: partial
+ .descriptor_pool
+ .expect("descriptor pool is present after successful swapchain setup"),
descriptor_sets,
- sampler,
+ sampler: partial
+ .sampler
+ .expect("sampler is present after successful swapchain setup"),
+ frame_uniform_buffer: partial
+ .frame_uniform_buffer
+ .expect("frame uniform buffer is present after successful swapchain setup"),
+ frame_uniform_stride: partial.frame_uniform_stride,
+ material_specular_buffer: partial
+ .material_specular_buffer
+ .expect("material specular buffer is present after successful swapchain setup"),
+ material_specular_stride: partial.material_specular_stride,
pipelines: partial.pipelines,
framebuffers: partial.framebuffers,
command_buffers: partial.command_buffers,
@@ -205,15 +338,17 @@ fn create_swapchain_image_views(
Ok(image_views)
}
-#[allow(clippy::type_complexity)]
fn create_swapchain_pipeline_bundle(
instance: &VulkanInstanceProbe,
device: &VulkanLogicalDeviceProbe,
format: i32,
extent: (u32, u32),
textures: &[VulkanAllocatedImage],
+ texture_slot_indices: &[usize],
draw_ranges: &[super::VulkanStaticDrawRange],
depth_request: DepthStencilSupport,
+ frame_uniform_buffer: &VulkanAllocatedBuffer,
+ material_specular_buffer: &VulkanAllocatedBuffer,
) -> Result<
(
VulkanDepthAttachment,
@@ -236,7 +371,14 @@ fn create_swapchain_pipeline_bundle(
}
};
let (descriptor_set_layout, descriptor_pool, descriptor_sets, sampler) =
- create_texture_descriptor_bundle(device, textures).inspect_err(|_| {
+ create_texture_descriptor_bundle(
+ device,
+ textures,
+ texture_slot_indices,
+ frame_uniform_buffer,
+ material_specular_buffer,
+ )
+ .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);
@@ -291,12 +433,23 @@ fn create_swapchain_framebuffers(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
image_views: &[vk::ImageView],
- depth_view: vk::ImageView,
+ depth_attachments: &[VulkanDepthAttachment],
extent: (u32, u32),
) -> Result<Vec<vk::Framebuffer>, VulkanSmokeRendererError> {
+ if image_views.len() != depth_attachments.len() {
+ return Err(VulkanSmokeRendererError::InvariantViolation {
+ context: "swapchain color/depth attachment count",
+ });
+ }
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) {
+ for (image_view, depth_attachment) in image_views.iter().copied().zip(depth_attachments) {
+ match create_framebuffer(
+ device,
+ render_pass,
+ image_view,
+ depth_attachment.image.view,
+ extent,
+ ) {
Ok(framebuffer) => framebuffers.push(framebuffer),
Err(error) => {
// SAFETY: These framebuffers were created above on this live logical device and are destroyed on setup failure.
@@ -341,7 +494,7 @@ fn create_image_view(
.image(image)
.view_type(vk::ImageViewType::TYPE_2D)
.format(vk::Format::from_raw(format))
- .subresource_range(color_subresource_range());
+ .subresource_range(color_subresource_range(1));
// 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 {
@@ -386,10 +539,21 @@ fn create_render_pass(
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_stage_mask(
+ vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
+ | vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS
+ | vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
+ )
+ .dst_stage_mask(
+ vk::PipelineStageFlags::COLOR_ATTACHMENT_OUTPUT
+ | vk::PipelineStageFlags::EARLY_FRAGMENT_TESTS
+ | vk::PipelineStageFlags::LATE_FRAGMENT_TESTS,
+ )
.src_access_mask(vk::AccessFlags::empty())
- .dst_access_mask(vk::AccessFlags::COLOR_ATTACHMENT_WRITE);
+ .dst_access_mask(
+ vk::AccessFlags::COLOR_ATTACHMENT_WRITE
+ | vk::AccessFlags::DEPTH_STENCIL_ATTACHMENT_WRITE,
+ );
let attachments = [color_attachment, depth_attachment];
let subpasses = [subpass];
let dependencies = [dependency];
@@ -411,16 +575,16 @@ fn create_pipeline_layout(
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)),
- ];
+ // Material data occupies one 128-byte push-constant allocation shared by
+ // both stages; the vertex stage also reads its specular pair from the
+ // dynamic storage buffer at descriptor binding 5. Camera data is supplied
+ // by the dynamic frame UBO at descriptor binding 4.
+ // Expose the complete block from offset zero so drivers validate the
+ // fragment member's declared offset against the pipeline layout.
+ let push_constant_ranges = [vk::PushConstantRange::default()
+ .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT)
+ .offset(0)
+ .size(32 * 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);
@@ -433,10 +597,12 @@ fn create_pipeline_layout(
})
}
-#[allow(clippy::too_many_lines)]
fn create_texture_descriptor_bundle(
device: &VulkanLogicalDeviceProbe,
textures: &[VulkanAllocatedImage],
+ texture_slot_indices: &[usize],
+ frame_uniform_buffer: &VulkanAllocatedBuffer,
+ material_specular_buffer: &VulkanAllocatedBuffer,
) -> Result<
(
vk::DescriptorSetLayout,
@@ -446,12 +612,32 @@ fn create_texture_descriptor_bundle(
),
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];
+ const TEXTURES_PER_MATERIAL: usize = 4;
+ const FRAME_BINDING: u32 = 4;
+ const MATERIAL_SPECULAR_BINDING: u32 = 5;
+ let mut bindings = (0..TEXTURES_PER_MATERIAL)
+ .map(|binding| {
+ vk::DescriptorSetLayoutBinding::default()
+ .binding(u32::try_from(binding).unwrap_or(u32::MAX))
+ .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
+ .descriptor_count(1)
+ .stage_flags(vk::ShaderStageFlags::FRAGMENT)
+ })
+ .collect::<Vec<_>>();
+ bindings.push(
+ vk::DescriptorSetLayoutBinding::default()
+ .binding(FRAME_BINDING)
+ .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
+ .descriptor_count(1)
+ .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT),
+ );
+ bindings.push(
+ vk::DescriptorSetLayoutBinding::default()
+ .binding(MATERIAL_SPECULAR_BINDING)
+ .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC)
+ .descriptor_count(1)
+ .stage_flags(vk::ShaderStageFlags::VERTEX),
+ );
let layout_info = vk::DescriptorSetLayoutCreateInfo::default().bindings(&bindings);
// SAFETY: The layout description is stack-owned and references no external memory.
let layout = unsafe {
@@ -463,23 +649,40 @@ fn create_texture_descriptor_bundle(
context: "vkCreateDescriptorSetLayout",
result,
})?;
- let texture_count = u32::try_from(textures.len()).map_err(|_| {
+ let texture_count = u32::try_from(texture_slot_indices.len()).map_err(|_| {
VulkanSmokeRendererError::InvariantViolation {
context: "static material texture count exceeds Vulkan descriptor limit",
}
})?;
- if texture_count == 0 {
+ if texture_count == 0
+ || !texture_slot_indices
+ .len()
+ .is_multiple_of(TEXTURES_PER_MATERIAL)
+ || texture_slot_indices
+ .iter()
+ .any(|texture_index| *texture_index >= textures.len())
+ {
+ // SAFETY: The layout was created above on this device and is rolled
+ // back before reporting malformed material-slot input.
+ unsafe { device.device().destroy_descriptor_set_layout(layout, None) };
return Err(VulkanSmokeRendererError::InvariantViolation {
- context: "static material texture list is empty",
+ context: "static material texture slots are invalid",
});
}
+ let material_count = texture_count / u32::try_from(TEXTURES_PER_MATERIAL).unwrap_or(1);
let pool_size = vk::DescriptorPoolSize::default()
.ty(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
.descriptor_count(texture_count);
- let pool_sizes = [pool_size];
+ let frame_pool_size = vk::DescriptorPoolSize::default()
+ .ty(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
+ .descriptor_count(material_count);
+ let material_specular_pool_size = vk::DescriptorPoolSize::default()
+ .ty(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC)
+ .descriptor_count(material_count);
+ let pool_sizes = [pool_size, frame_pool_size, material_specular_pool_size];
let pool_info = vk::DescriptorPoolCreateInfo::default()
.pool_sizes(&pool_sizes)
- .max_sets(texture_count);
+ .max_sets(material_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| {
@@ -490,7 +693,7 @@ fn create_texture_descriptor_bundle(
result,
}
})?;
- let layouts = vec![layout; textures.len()];
+ let layouts = vec![layout; usize::try_from(material_count).unwrap_or(0)];
let allocate_info = vk::DescriptorSetAllocateInfo::default()
.descriptor_pool(pool)
.set_layouts(&layouts);
@@ -514,7 +717,9 @@ fn create_texture_descriptor_bundle(
.address_mode_u(vk::SamplerAddressMode::REPEAT)
.address_mode_v(vk::SamplerAddressMode::REPEAT)
.address_mode_w(vk::SamplerAddressMode::REPEAT)
- .max_lod(0.0);
+ // The uploaded images carry a complete CPU-generated mip chain; the
+ // sampler clamps to the largest legal level for each image.
+ .max_lod(16.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| {
@@ -528,38 +733,60 @@ fn create_texture_descriptor_bundle(
result,
}
})?;
- let image_infos = textures
+ let image_infos = texture_slot_indices
.iter()
- .map(|texture| {
+ .map(|texture_index| {
+ let texture = &textures[*texture_index];
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)| {
+ let frame_info = vk::DescriptorBufferInfo::default()
+ .buffer(frame_uniform_buffer.buffer)
+ .offset(0)
+ .range(VULKAN_FRAME_UNIFORM_BYTES as u64);
+ let material_specular_info = vk::DescriptorBufferInfo::default()
+ .buffer(material_specular_buffer.buffer)
+ .offset(0)
+ .range(VULKAN_MATERIAL_SPECULAR_RECORD_BYTES as u64);
+ let mut writes = Vec::with_capacity(descriptor_sets.len() * (TEXTURES_PER_MATERIAL + 2));
+ for (set_index, descriptor_set) in descriptor_sets.iter().copied().enumerate() {
+ for binding in 0..TEXTURES_PER_MATERIAL {
+ let image_info = &image_infos[set_index * TEXTURES_PER_MATERIAL + binding];
+ writes.push(
+ vk::WriteDescriptorSet::default()
+ .dst_set(descriptor_set)
+ .dst_binding(u32::try_from(binding).unwrap_or(u32::MAX))
+ .descriptor_type(vk::DescriptorType::COMBINED_IMAGE_SAMPLER)
+ .image_info(std::slice::from_ref(image_info)),
+ );
+ }
+ writes.push(
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<_>>();
+ .dst_binding(FRAME_BINDING)
+ .descriptor_type(vk::DescriptorType::UNIFORM_BUFFER_DYNAMIC)
+ .buffer_info(std::slice::from_ref(&frame_info)),
+ );
+ writes.push(
+ vk::WriteDescriptorSet::default()
+ .dst_set(descriptor_set)
+ .dst_binding(MATERIAL_SPECULAR_BINDING)
+ .descriptor_type(vk::DescriptorType::STORAGE_BUFFER_DYNAMIC)
+ .buffer_info(std::slice::from_ref(&material_specular_info)),
+ );
+ }
// 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,
@@ -596,7 +823,6 @@ fn create_graphics_pipeline_cache(
Ok(pipelines)
}
-#[allow(clippy::too_many_lines)]
fn create_graphics_pipeline(
device: &VulkanLogicalDeviceProbe,
render_pass: vk::RenderPass,
@@ -604,15 +830,16 @@ fn create_graphics_pipeline(
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 vertex_shader = create_shader_module(device, TRIANGLE_VERTEX_SHADER_WORDS, "vertex")?;
+ let fragment_shader =
+ match create_shader_module(device, TRIANGLE_FRAGMENT_SHADER_WORDS, "fragment") {
+ 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()
@@ -626,7 +853,7 @@ fn create_graphics_pipeline(
];
let vertex_binding = vk::VertexInputBindingDescription::default()
.binding(0)
- .stride(u32::try_from(8 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
+ .stride(u32::try_from(14 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX))
.input_rate(vk::VertexInputRate::VERTEX);
let vertex_attributes = [
vk::VertexInputAttributeDescription::default()
@@ -642,8 +869,23 @@ fn create_graphics_pipeline(
vk::VertexInputAttributeDescription::default()
.binding(0)
.location(2)
- .format(vk::Format::R32G32_SFLOAT)
+ .format(vk::Format::R32G32B32_SFLOAT)
.offset(u32::try_from(6 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
+ vk::VertexInputAttributeDescription::default()
+ .binding(0)
+ .location(3)
+ .format(vk::Format::R32G32_SFLOAT)
+ .offset(u32::try_from(9 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
+ vk::VertexInputAttributeDescription::default()
+ .binding(0)
+ .location(4)
+ .format(vk::Format::R32G32_SFLOAT)
+ .offset(u32::try_from(11 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
+ vk::VertexInputAttributeDescription::default()
+ .binding(0)
+ .location(5)
+ .format(vk::Format::R32_SFLOAT)
+ .offset(u32::try_from(13 * std::mem::size_of::<f32>()).unwrap_or(u32::MAX)),
];
let vertex_bindings = [vertex_binding];
let vertex_input_state = vk::PipelineVertexInputStateCreateInfo::default()
@@ -691,6 +933,21 @@ fn create_graphics_pipeline(
.depth_compare_op(vk::CompareOp::LESS_OR_EQUAL)
.depth_bounds_test_enable(false)
.stencil_test_enable(false);
+ let (blend_enable, src_color_blend_factor, dst_color_blend_factor) = match state.blend {
+ LegacyBlendMode::Opaque => (false, vk::BlendFactor::ONE, vk::BlendFactor::ZERO),
+ LegacyBlendMode::SourceAlpha => (
+ true,
+ vk::BlendFactor::SRC_ALPHA,
+ vk::BlendFactor::ONE_MINUS_SRC_ALPHA,
+ ),
+ LegacyBlendMode::Additive => (true, vk::BlendFactor::SRC_ALPHA, vk::BlendFactor::ONE),
+ LegacyBlendMode::ZeroSourceColor => {
+ (true, vk::BlendFactor::ZERO, vk::BlendFactor::SRC_COLOR)
+ }
+ LegacyBlendMode::DestColorSourceColor => {
+ (true, vk::BlendFactor::DST_COLOR, vk::BlendFactor::SRC_COLOR)
+ }
+ };
let color_blend_attachment = vk::PipelineColorBlendAttachmentState::default()
.color_write_mask(
vk::ColorComponentFlags::R
@@ -698,12 +955,12 @@ fn create_graphics_pipeline(
| 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)
+ .blend_enable(blend_enable)
+ .src_color_blend_factor(src_color_blend_factor)
+ .dst_color_blend_factor(dst_color_blend_factor)
.color_blend_op(vk::BlendOp::ADD)
- .src_alpha_blend_factor(vk::BlendFactor::ONE)
- .dst_alpha_blend_factor(vk::BlendFactor::ONE_MINUS_SRC_ALPHA)
+ .src_alpha_blend_factor(src_color_blend_factor)
+ .dst_alpha_blend_factor(dst_color_blend_factor)
.alpha_blend_op(vk::BlendOp::ADD);
let color_blend_attachments = [color_blend_attachment];
let color_blend_state = vk::PipelineColorBlendStateCreateInfo::default()
@@ -744,12 +1001,17 @@ fn create_graphics_pipeline(
fn create_shader_module(
device: &VulkanLogicalDeviceProbe,
words: &[u32],
+ stage: &'static str,
) -> 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",
+ context: if stage == "vertex" {
+ "vkCreateShaderModule(vertex)"
+ } else {
+ "vkCreateShaderModule(fragment)"
+ },
result: error,
}
})
@@ -827,7 +1089,9 @@ pub(super) fn destroy_swapchain_resources(
device
.device()
.destroy_render_pass(resources.render_pass, None);
- destroy_depth_attachment(device, &resources.depth_attachment);
+ for depth_attachment in resources.depth_attachments {
+ destroy_depth_attachment(device, &depth_attachment);
+ }
for image_view in resources.image_views {
device.device().destroy_image_view(image_view, None);
}
@@ -835,6 +1099,21 @@ pub(super) fn destroy_swapchain_resources(
device.device().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, None);
}
+ for semaphore in resources.render_finished {
+ device.device().destroy_semaphore(semaphore, None);
+ }
+ device
+ .device()
+ .destroy_buffer(resources.frame_uniform_buffer.buffer, None);
+ device
+ .device()
+ .free_memory(resources.frame_uniform_buffer.memory, None);
+ device
+ .device()
+ .destroy_buffer(resources.material_specular_buffer.buffer, None);
+ device
+ .device()
+ .free_memory(resources.material_specular_buffer.memory, None);
}
}
@@ -877,7 +1156,7 @@ fn destroy_partial_swapchain_resources(
if let Some(render_pass) = partial.render_pass {
device.device().destroy_render_pass(render_pass, None);
}
- if let Some(depth_attachment) = partial.depth_attachment {
+ for depth_attachment in partial.depth_attachments {
destroy_depth_attachment(device, &depth_attachment);
}
for image_view in partial.image_views {
@@ -887,5 +1166,16 @@ fn destroy_partial_swapchain_resources(
device.device().destroy_buffer(buffer.buffer, None);
device.device().free_memory(buffer.memory, None);
}
+ for semaphore in partial.render_finished {
+ device.device().destroy_semaphore(semaphore, None);
+ }
+ if let Some(buffer) = partial.frame_uniform_buffer {
+ device.device().destroy_buffer(buffer.buffer, None);
+ device.device().free_memory(buffer.memory, None);
+ }
+ if let Some(buffer) = partial.material_specular_buffer {
+ device.device().destroy_buffer(buffer.buffer, None);
+ device.device().free_memory(buffer.memory, None);
+ }
}
}
diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs
index a842c73..b8a3d71 100644
--- a/adapters/fparkan-render-vulkan/src/ffi/tests.rs
+++ b/adapters/fparkan-render-vulkan/src/ffi/tests.rs
@@ -1,114 +1,9 @@
use super::*;
+use crate::ffi::{SPIRV_MAGIC, SPIRV_VERSION_1_0};
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::shader::{validate_spirv_container, validate_triangle_shaders, VulkanShaderError};
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() {
@@ -391,38 +286,6 @@ fn capability_report_preserves_informational_sampled_formats_and_limits() {
}
#[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 {
@@ -446,9 +309,15 @@ fn instance_plan_is_sorted_deduplicated_and_portability_aware() {
enable_validation: true,
});
+ assert_eq!(plan.create_flags, 1);
+ assert!(plan.validation_requested);
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\"]}"
+ plan.enabled_extensions,
+ vec![
+ "VK_EXT_debug_utils".to_string(),
+ KHR_PORTABILITY_ENUMERATION_EXTENSION.to_string(),
+ "VK_KHR_surface".to_string(),
+ ]
);
}
@@ -510,20 +379,6 @@ fn surface_plan_requires_native_handles() {
}
#[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");
@@ -531,13 +386,13 @@ fn static_surface_extension_name_is_decoded() {
}
#[test]
-fn swapchain_plan_prefers_srgb_mailbox_and_clamps_extent() {
+fn swapchain_plan_prefers_unorm_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(),
+ format: vk::Format::R8G8B8A8_UNORM.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}
);
@@ -589,7 +444,7 @@ fn swapchain_plan_accepts_undefined_surface_format_by_picking_stage0_default() {
assert_eq!(
plan.format,
VulkanSurfaceFormat {
- format: vk::Format::B8G8R8A8_SRGB.as_raw(),
+ format: vk::Format::R8G8B8A8_UNORM.as_raw(),
color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw(),
}
);
@@ -620,128 +475,91 @@ fn swapchain_plan_rejects_missing_surface_data_and_empty_extent() {
}
#[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]}"
- );
+fn checked_in_shaders_have_valid_spirv_containers() {
+ assert_eq!(validate_triangle_shaders(), Ok(()));
}
#[test]
-fn triangle_shader_manifest_hashes_are_stable() {
- let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest");
+fn fragment_shader_dispatches_all_four_material_modes() {
+ let fragment = include_str!("../../shaders/triangle.frag");
+ assert!(fragment.contains("int mode = int(round(material.material_mode));"));
+ assert!(fragment.contains("mode == 0"));
+ assert!(fragment.contains("mode == 1"));
+ assert!(fragment.contains("mode == 2"));
+ assert!(fragment.contains("mode == 3"));
+ assert!(!fragment.contains("material_mode > 1.5"));
+ assert!(!fragment.contains("material_mode > 2.5"));
+}
- 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,
+#[test]
+fn native_specular_reference_uses_squared_power_and_total_overflow_curve() {
+ fn reference_power(cosine: f32, power: u8) -> f32 {
+ if power == 0 {
+ return 0.0;
}
- );
- assert_eq!(
- report.validator,
- VulkanShaderToolManifest {
- name: SPIRV_VALIDATOR_NAME,
- version: SPIRV_VALIDATOR_VERSION,
- binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256,
+ let mut result = cosine;
+ for _ in 0..power.saturating_sub(1) {
+ result *= result;
}
- );
- 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"
- );
-}
+ result
+ }
+ fn reference_curve(value: f32) -> f32 {
+ if value <= 1.0 {
+ 0.8 * value
+ } else if value <= 3.0 {
+ 0.1 * value + 0.7
+ } else {
+ 1.0
+ }
+ }
-#[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));
+ assert_eq!(reference_power(0.5, 0), 0.0);
+ assert!((reference_power(0.5, 1) - 0.5).abs() < 1e-6);
+ assert!((reference_power(0.5, 2) - 0.25).abs() < 1e-6);
+ assert!((reference_power(0.5, 3) - 0.0625).abs() < 1e-6);
+ let compressed_diffuse = 1.5;
+ let material_specular = 0.75;
+ assert!((reference_curve(material_specular + (compressed_diffuse - 1.0)) - 0.825).abs() < 1e-6);
+
+ let vertex = include_str!("../../shaders/triangle.vert");
+ assert!(vertex.contains("result *= result;"));
+ assert!(
+ vertex.contains("vec3 total_specular = specular + max(compressed - vec3(1.0), vec3(0.0));")
+ );
+ assert!(vertex.contains("if (power <= 0.0 || cosine <= 0.0)"));
}
#[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()
- );
+fn shader_computes_radial_fog_per_vertex_and_interpolates_it() {
+ let vertex = include_str!("../../shaders/triangle.vert");
+ let fragment = include_str!("../../shaders/triangle.frag");
+ assert!(vertex.contains("out_fog_factor = native_fog_factor(in_position);"));
+ assert!(fragment.contains("layout(location = 4) in float in_fog_factor;"));
+ assert!(!fragment.contains("distance(in_world_position"));
+ assert!(!fragment.contains("float fog_factor()"));
}
#[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];
+fn shader_container_rejects_invalid_spirv() {
assert_eq!(
- validate_shader_manifest(&[module]),
- Err(VulkanShaderManifestError::InvalidMagic {
+ validate_spirv_container("triangle.vert", &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0]),
+ Err(VulkanShaderError::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 {
+ validate_spirv_container("triangle.vert", &[SPIRV_MAGIC, 0, 0, 1, 0]),
+ Err(VulkanShaderError::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 {
+ validate_spirv_container("triangle.vert", &[SPIRV_MAGIC, SPIRV_VERSION_1_0, 0, 0, 0]),
+ Err(VulkanShaderError::InvalidBound {
name: "triangle.vert",
})
);
diff --git a/adapters/fparkan-render-vulkan/src/lib.rs b/adapters/fparkan-render-vulkan/src/lib.rs
index b039cd1..a5beafd 100644
--- a/adapters/fparkan-render-vulkan/src/lib.rs
+++ b/adapters/fparkan-render-vulkan/src/lib.rs
@@ -2,11 +2,9 @@
//! Vulkan adapter public surface.
mod ffi;
-mod planning_backend;
mod policy;
-mod shader_manifest;
+mod shader;
pub use ffi::*;
-pub use planning_backend::*;
pub use policy::*;
-pub use shader_manifest::*;
+pub use shader::VulkanShaderError;
diff --git a/adapters/fparkan-render-vulkan/src/planning_backend.rs b/adapters/fparkan-render-vulkan/src/planning_backend.rs
deleted file mode 100644
index 9a91712..0000000
--- a/adapters/fparkan-render-vulkan/src/planning_backend.rs
+++ /dev/null
@@ -1,168 +0,0 @@
-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(),
- }
-}
diff --git a/adapters/fparkan-render-vulkan/src/policy.rs b/adapters/fparkan-render-vulkan/src/policy.rs
index c79b8dc..e1f01c8 100644
--- a/adapters/fparkan-render-vulkan/src/policy.rs
+++ b/adapters/fparkan-render-vulkan/src/policy.rs
@@ -1,7 +1,5 @@
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";
@@ -147,25 +145,6 @@ pub struct VulkanSwapchainRecreationReport {
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 {
@@ -204,7 +183,7 @@ impl VulkanPhysicalDeviceRecord {
}
/// Informational device limits relevant to future Stage 0 capability growth.
-#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct VulkanDeviceLimits {
/// Maximum 2D image dimension supported by the device.
pub max_image_dimension_2d: u32,
@@ -217,7 +196,7 @@ pub struct VulkanDeviceLimits {
}
/// Informational capabilities preserved in deterministic Stage 0 reports.
-#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanInformationalCapabilities {
/// Color formats that support sampled-image usage.
pub sampled_color_formats: Vec<i32>,
@@ -253,7 +232,7 @@ pub struct VulkanCapabilityReport {
}
/// Deterministic rejection reason for an unsuitable physical device.
-#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
+#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VulkanRejectedDeviceReport {
/// Human-readable device name.
pub device_name: String,
@@ -446,136 +425,6 @@ pub const fn swapchain_recreation_report(
}
}
-/// 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 {
@@ -590,13 +439,6 @@ pub(crate) fn select_composite_alpha(
}
}
-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!(
"{}.{}.{}",
@@ -616,9 +458,24 @@ fn select_surface_format(
.iter()
.copied()
.find(|format| {
- format.format == vk::Format::B8G8R8A8_SRGB.as_raw()
+ format.format == vk::Format::R8G8B8A8_UNORM.as_raw()
&& format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw()
})
+ .or_else(|| {
+ formats.iter().copied().find(|format| {
+ format.format == vk::Format::B8G8R8A8_UNORM.as_raw()
+ && format.color_space == vk::ColorSpaceKHR::SRGB_NONLINEAR.as_raw()
+ })
+ })
+ // Keep an sRGB attachment as the final compatibility fallback. The
+ // fragment shader emits legacy byte-equivalent linear values, so an
+ // UNORM attachment is preferred whenever the surface exposes one.
+ .or_else(|| {
+ 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)
}
@@ -628,7 +485,7 @@ fn undefined_surface_format_override(
) -> Option<VulkanSurfaceFormat> {
match formats {
[format] if format.format == vk::Format::UNDEFINED.as_raw() => Some(VulkanSurfaceFormat {
- format: vk::Format::B8G8R8A8_SRGB.as_raw(),
+ format: vk::Format::R8G8B8A8_UNORM.as_raw(),
color_space: format.color_space,
}),
_ => None,
diff --git a/adapters/fparkan-render-vulkan/src/shader.rs b/adapters/fparkan-render-vulkan/src/shader.rs
new file mode 100644
index 0000000..2e3184a
--- /dev/null
+++ b/adapters/fparkan-render-vulkan/src/shader.rs
@@ -0,0 +1,82 @@
+use crate::ffi::{
+ SPIRV_MAGIC, SPIRV_VERSION_1_0, TRIANGLE_FRAGMENT_SHADER_WORDS, TRIANGLE_VERTEX_SHADER_WORDS,
+};
+
+/// Error found while checking a checked-in SPIR-V module.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum VulkanShaderError {
+ /// 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 VulkanShaderError {
+ 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 VulkanShaderError {}
+
+/// Checks the checked-in triangle shader containers before creating a pipeline.
+pub(crate) fn validate_triangle_shaders() -> Result<(), VulkanShaderError> {
+ validate_spirv_container("triangle.vert", TRIANGLE_VERTEX_SHADER_WORDS)?;
+ validate_spirv_container("triangle.frag", TRIANGLE_FRAGMENT_SHADER_WORDS)?;
+ Ok(())
+}
+
+pub(crate) fn validate_spirv_container(
+ name: &'static str,
+ words: &[u32],
+) -> Result<(), VulkanShaderError> {
+ if words.len() < 5 {
+ return Err(VulkanShaderError::TooShort { name });
+ }
+ if words[0] != SPIRV_MAGIC {
+ return Err(VulkanShaderError::InvalidMagic {
+ name,
+ found: words[0],
+ });
+ }
+ if words[1] < SPIRV_VERSION_1_0 {
+ return Err(VulkanShaderError::UnsupportedVersion {
+ name,
+ found: words[1],
+ });
+ }
+ if words[3] == 0 {
+ return Err(VulkanShaderError::InvalidBound { name });
+ }
+ Ok(())
+}
diff --git a/adapters/fparkan-render-vulkan/src/shader_manifest.rs b/adapters/fparkan-render-vulkan/src/shader_manifest.rs
deleted file mode 100644
index be86e50..0000000
--- a/adapters/fparkan-render-vulkan/src/shader_manifest.rs
+++ /dev/null
@@ -1,399 +0,0 @@
-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,
- }
-}
diff --git a/adr/ADR-0001-modular-monolith.md b/adr/ADR-0001-modular-monolith.md
deleted file mode 100644
index 82c36b9..0000000
--- a/adr/ADR-0001-modular-monolith.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# ADR-0001: Modular Monolith
-
-Status: accepted
-
-FParkan is implemented as one Cargo workspace with local crates grouped by domain. Binaries and adapters compose domain crates; domain crates do not import platform, windowing, OpenGL, GUI, or application packages.
diff --git a/adr/ADR-0002-behavior-not-abi.md b/adr/ADR-0002-behavior-not-abi.md
deleted file mode 100644
index aea3c7e..0000000
--- a/adr/ADR-0002-behavior-not-abi.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# ADR-0002: Behavior Compatibility, Not ABI Compatibility
-
-Status: accepted
-
-The project targets clean-room behavior compatibility for formats, resource lookup, loading order, deterministic runtime behavior, and presentation command semantics. It does not reproduce original DLL boundaries, exports, calling conventions, object layouts, RVAs, or native singleton access patterns.
diff --git a/adr/ADR-0003-raw-and-interpreted-data.md b/adr/ADR-0003-raw-and-interpreted-data.md
deleted file mode 100644
index 5938b83..0000000
--- a/adr/ADR-0003-raw-and-interpreted-data.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# ADR-0003: Raw And Interpreted Data
-
-Status: accepted
-
-Legacy data models keep raw bytes distinct from validated structure and interpreted domain views. Writers preserve raw data unless an explicit editing profile requests canonical rebuilding.
diff --git a/adr/ADR-0004-synthetic-and-licensed-tests.md b/adr/ADR-0004-synthetic-and-licensed-tests.md
deleted file mode 100644
index 0084ac4..0000000
--- a/adr/ADR-0004-synthetic-and-licensed-tests.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# ADR-0004: Synthetic And Licensed Tests
-
-Status: accepted
-
-Synthetic tests run everywhere and contain no proprietary data. Licensed corpus tests require an explicit local manifest and fail when requested without configuration. Reports contain metrics and fingerprints, not payload dumps or absolute game roots.
diff --git a/adr/ADR-0005-deterministic-reference-runtime.md b/adr/ADR-0005-deterministic-reference-runtime.md
deleted file mode 100644
index 5e89d30..0000000
--- a/adr/ADR-0005-deterministic-reference-runtime.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# ADR-0005: Deterministic Reference Runtime
-
-Status: accepted
-
-Stages 0-5 use a single-threaded deterministic reference profile. Stable ordering, explicit ticks, named random streams, and canonical captures are part of the contract. Wall-clock time, pointer addresses, hash iteration order, and GPU handles are not semantic inputs.
diff --git a/adr/ADR-0006-error-policy.md b/adr/ADR-0006-error-policy.md
deleted file mode 100644
index 1080497..0000000
--- a/adr/ADR-0006-error-policy.md
+++ /dev/null
@@ -1,5 +0,0 @@
-# ADR-0006: Error Policy
-
-Status: accepted
-
-Missing required resources, malformed bytes, unsupported documented branches, capability mismatches, and budget failures are structured errors. Runtime code must not silently skip mandatory objects or convert corrupted data into empty success values.
diff --git a/adr/ADR-0007-safe-sdl-opengl.md b/adr/ADR-0007-safe-sdl-opengl.md
deleted file mode 100644
index 01e35e4..0000000
--- a/adr/ADR-0007-safe-sdl-opengl.md
+++ /dev/null
@@ -1,26 +0,0 @@
-# ADR-0007: Safe SDL/OpenGL Boundary
-
-Status: provisional
-
-Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained
-external crates behind a safe project API; local Objective-C/CGL/SDL/OpenGL FFI
-inside FParkan is not an acceptable implementation strategy.
-
-The current adapter crates are safe boundary stubs. They compile the intended
-ports and deterministic command contracts, but they do not create SDL windows,
-GL contexts, GPU resources, shaders, draw calls, swapchains, or presents. They
-must not be treated as backend readiness evidence.
-
-To close the macOS backend requirement, choose and vendor/lock a maintained
-safe facade stack, then implement:
-
-- SDL event source, window creation, GL context lifecycle, drawable size and
- present;
-- GL shader compile/link, buffer/texture upload, render state, draw calls and
- diagnostics;
-- game/viewer composition roots using those adapters;
-- hidden-window/offscreen macOS smoke tests and licensed local model/terrain
- frame captures.
-
-Until those are implemented, Desktop GL evidence may document external probes
-only; it does not satisfy the permanent adapter requirement.
diff --git a/apps/fparkan-cli/Cargo.toml b/apps/fparkan-cli/Cargo.toml
deleted file mode 100644
index dcf9c12..0000000
--- a/apps/fparkan-cli/Cargo.toml
+++ /dev/null
@@ -1,22 +0,0 @@
-[package]
-name = "fparkan-cli"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
-fparkan-corpus = { path = "../../crates/fparkan-corpus", version = "0.1.0" }
-fparkan-prototype = { path = "../../crates/fparkan-prototype", version = "0.1.0" }
-fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
-fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
-fparkan-resource = { path = "../../crates/fparkan-resource", version = "0.1.0" }
-fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
-fparkan-script = { path = "../../crates/fparkan-script", version = "0.1.0" }
-fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
-
-[lints]
-workspace = true
diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs
deleted file mode 100644
index d3e1df5..0000000
--- a/apps/fparkan-cli/src/main.rs
+++ /dev/null
@@ -1,1070 +0,0 @@
-#![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` command-line tools.
-
-use fparkan_assets::{
- decode_mission_payload, extend_graph_report_with_visual_dependencies, TmaProfile,
-};
-use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
-use fparkan_inspection::{
- inspect_archive_file, inspect_land_msh_bounds_file, inspect_model_from_root,
- inspect_wear_from_root, load_land_msh_from_path, ArchiveInspection, ModelInspection,
-};
-use fparkan_path::{normalize_relative, PathPolicy};
-use fparkan_prototype::build_prototype_graph_report;
-use fparkan_resource::{resource_name, CachedResourceRepository};
-use fparkan_runtime::{
- create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
-};
-use fparkan_vfs::{DirectoryVfs, Vfs};
-use serde::Serialize;
-use std::fmt::Write as _;
-use std::path::PathBuf;
-use std::sync::Arc;
-
-const ARCHIVE_INSPECT_SCHEMA: &str = "fparkan-archive-inspect-v1";
-const PROTOTYPE_INSPECT_SCHEMA: &str = "fparkan-prototype-inspect-v2";
-const MISSION_GRAPH_SCHEMA: &str = "fparkan-mission-graph-v1";
-const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1";
-const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
-const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1";
-const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1";
-const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v2";
-
-#[derive(Serialize)]
-struct ArchiveInspectOutput<'a> {
- schema_version: &'static str,
- path: &'a str,
- kind: &'a str,
- entries: usize,
- #[serde(skip_serializing_if = "Option::is_none")]
- lookup_order_valid: Option<bool>,
-}
-
-#[derive(Serialize)]
-struct PrototypeInspectOutput {
- schema_version: &'static str,
- key: String,
- roots: usize,
- node_count: usize,
- edge_count: usize,
- unit_component_records: Vec<UnitComponentInspectOutput>,
- edges: Vec<PrototypeGraphEdgeInspectOutput>,
- prototype_requests: usize,
- resolved: usize,
- unit_references: usize,
- unit_components: usize,
- direct_references: usize,
- wear_requests: usize,
- wear: usize,
- materials: usize,
- textures: usize,
- lightmaps: usize,
- is_success: bool,
- failures: Vec<GraphFailureOutput>,
-}
-
-#[derive(Serialize)]
-struct MissionGraphOutput {
- schema_version: &'static str,
- mission: String,
- objects: usize,
- paths: usize,
- clans: usize,
- extras: usize,
- roots: usize,
- node_count: usize,
- edge_count: usize,
- direct_references: usize,
- unit_references: usize,
- unit_components: usize,
- prototype_requests: usize,
- wear_requests: usize,
- wear: usize,
- materials: usize,
- textures: usize,
- lightmaps: usize,
- is_success: bool,
- failures: usize,
-}
-
-#[derive(Serialize)]
-struct MissionInspectOutput {
- schema_version: &'static str,
- mission: String,
- objects: Vec<MissionObjectInspectOutput>,
-}
-
-#[derive(Serialize)]
-struct MissionObjectInspectOutput {
- index: usize,
- resource: String,
- position: [f32; 3],
- orientation_raw: [f32; 3],
- scale: [f32; 3],
-}
-
-#[derive(Serialize)]
-struct TerrainInspectOutput {
- schema_version: &'static str,
- path: String,
- positions: usize,
- min: [f32; 3],
- max: [f32; 3],
- faces: usize,
- slots: usize,
- material_tags: Vec<TerrainMaterialTagCount>,
- shade_pairs: usize,
- #[serde(skip_serializing_if = "Option::is_none")]
- shade_lookup_key_min: Option<u16>,
- #[serde(skip_serializing_if = "Option::is_none")]
- shade_lookup_key_max: Option<u16>,
- shade_batch_boundaries: usize,
-}
-
-#[derive(Serialize)]
-struct TerrainMaterialTagCount {
- tag: u16,
- faces: usize,
-}
-
-#[derive(Serialize)]
-struct WearInspectOutput {
- schema_version: &'static str,
- archive: String,
- resource: String,
- materials: usize,
- lightmaps: usize,
- #[serde(skip_serializing_if = "Option::is_none")]
- first_material: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- last_material: Option<String>,
-}
-
-#[derive(Serialize)]
-struct ModelInspectOutput<'a> {
- schema_version: &'static str,
- archive: &'a str,
- resource: &'a str,
- streams: usize,
- nodes: usize,
- node_stride: usize,
- slots: usize,
- positions: usize,
- indices: usize,
- batches: usize,
- #[serde(skip_serializing_if = "Option::is_none")]
- animation_keys: Option<usize>,
- #[serde(skip_serializing_if = "Option::is_none")]
- animation_frame_count: Option<u32>,
- node38: Vec<ModelNodeInspectOutput>,
-}
-
-#[derive(Serialize)]
-struct ModelNodeInspectOutput {
- index: usize,
- parent_or_link_raw: u16,
- anim_map_start: u16,
- fallback_key: u16,
- has_lod0_group0: bool,
-}
-
-#[derive(Serialize)]
-struct GraphFailureOutput {
- root_index: usize,
- edge: &'static str,
- requiredness: &'static str,
- message: String,
- #[serde(skip_serializing_if = "Option::is_none")]
- archive: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- resource: Option<String>,
-}
-
-#[derive(Serialize)]
-struct UnitComponentInspectOutput {
- root_index: usize,
- component_index: usize,
- archive_raw_hex: String,
- resource_raw_hex: String,
- kind: u32,
- parent_or_link: i32,
- description_raw_hex: String,
- tail0: u32,
- tail1: u32,
-}
-
-#[derive(Serialize)]
-struct PrototypeGraphEdgeInspectOutput {
- id: u32,
- from: u32,
- to: u32,
- kind: &'static str,
- requiredness: &'static str,
- root_index: Option<usize>,
- parent_edge: Option<u32>,
- unit_component_index: Option<usize>,
- archive: Option<String>,
- resource_raw_hex: Option<String>,
-}
-
-#[derive(Serialize)]
-struct ScriptInspectOutput<'a> {
- schema_version: &'static str,
- path: &'a str,
- opcode_handler_count: u32,
- events: usize,
- instructions: usize,
- references: usize,
- trailing_bytes: usize,
- first_header_word_candidates: Vec<ScriptHeaderWordCount>,
-}
-
-#[derive(Serialize)]
-struct ScriptHeaderWordCount {
- value: u32,
- instructions: usize,
-}
-
-#[derive(Serialize)]
-struct VarSetInspectOutput<'a> {
- schema_version: &'static str,
- path: &'a str,
- declarations: usize,
- float_defaults: usize,
- dword_defaults: usize,
- first_name: Option<&'a str>,
- last_name: Option<&'a str>,
-}
-
-fn main() {
- let args: Vec<String> = std::env::args().skip(1).collect();
- let result = run(&args);
- let code = exit_code(&result);
- if let Err(err) = result {
- eprintln!("{err}");
- }
- std::process::exit(code);
-}
-
-fn run(args: &[String]) -> Result<(), String> {
- match args {
- [domain, command, rest @ ..] if domain == "corpus" && command == "discover" => {
- let rest = strip_format_json(rest)?;
- let root = parse_root(&rest)?;
- let manifest =
- discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
- let report = report(&root, &manifest).map_err(|e| e.to_string())?;
- println!("{}", render_report_json(&report));
- Ok(())
- }
- [domain, command, rest @ ..] if domain == "corpus" && command == "validate" => {
- let rest = strip_format_json(rest)?;
- let root = parse_root(&rest)?;
- let manifest =
- discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
- let report = report(&root, &manifest).map_err(|e| e.to_string())?;
- if report.casefold_collisions > 0 {
- return Err("casefold collisions found".to_string());
- }
- if report.failures > 0 {
- return Err(format!("corpus report found {} failures", report.failures));
- }
- println!("{}", render_report_json(&report));
- Ok(())
- }
- [domain, command, rest @ ..] if domain == "archive" && command == "inspect" => {
- let rest = strip_format_json(rest)?;
- inspect_archive(&rest)
- }
- [domain, command, rest @ ..] if domain == "prototype" && command == "inspect" => {
- let rest = strip_format_json(rest)?;
- inspect_prototype(&rest)
- }
- [domain, command, rest @ ..] if domain == "mission" && command == "graph" => {
- let rest = strip_format_json(rest)?;
- graph_mission(&rest)
- }
- [domain, command, rest @ ..] if domain == "mission" && command == "inspect" => {
- let rest = strip_format_json(rest)?;
- inspect_mission(&rest)
- }
- [domain, command, rest @ ..] if domain == "terrain" && command == "inspect" => {
- let rest = strip_format_json(rest)?;
- inspect_terrain(&rest)
- }
- [domain, command, rest @ ..] if domain == "wear" && command == "inspect" => {
- let rest = strip_format_json(rest)?;
- inspect_wear(&rest)
- }
- [domain, command, rest @ ..] if domain == "model" && command == "inspect" => {
- let rest = strip_format_json(rest)?;
- inspect_model(&rest)
- }
- [domain, command, rest @ ..] if domain == "script" && command == "inspect" => {
- let rest = strip_format_json(rest)?;
- inspect_script(&rest)
- }
- [domain, command, rest @ ..] if domain == "varset" && command == "inspect" => {
- let rest = strip_format_json(rest)?;
- inspect_varset(&rest)
- }
- _ => Err(usage()),
- }
-}
-
-fn inspect_script(args: &[String]) -> Result<(), String> {
- let path = parse_file_path(args, "script inspect")?;
- let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
- let package =
- fparkan_script::decode(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
- let display_path = path.display().to_string();
- println!("{}", script_inspect_json(&display_path, &package)?);
- Ok(())
-}
-
-fn script_inspect_json(
- path: &str,
- package: &fparkan_script::ScriptPackage,
-) -> Result<String, String> {
- let instructions = package
- .events
- .iter()
- .map(|event| event.instructions.len())
- .sum();
- let references = package
- .events
- .iter()
- .flat_map(|event| &event.instructions)
- .map(|instruction| instruction.references.len())
- .sum();
- let mut candidates = std::collections::BTreeMap::<u32, usize>::new();
- for instruction in package.events.iter().flat_map(|event| &event.instructions) {
- *candidates.entry(instruction.header_words[0]).or_insert(0) += 1;
- }
- serialize_json(&ScriptInspectOutput {
- schema_version: SCRIPT_INSPECT_SCHEMA,
- path,
- opcode_handler_count: package.opcode_handler_count,
- events: package.events.len(),
- instructions,
- references,
- trailing_bytes: package.trailing_bytes.len(),
- first_header_word_candidates: candidates
- .into_iter()
- .map(|(value, instructions)| ScriptHeaderWordCount {
- value,
- instructions,
- })
- .collect(),
- })
-}
-
-fn inspect_varset(args: &[String]) -> Result<(), String> {
- let path = parse_file_path(args, "varset inspect")?;
- let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
- let varset =
- fparkan_script::parse_varset(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
- let display_path = path.display().to_string();
- println!("{}", varset_inspect_json(&display_path, &varset)?);
- Ok(())
-}
-
-fn varset_inspect_json(path: &str, varset: &fparkan_script::VarSet) -> Result<String, String> {
- let float_defaults = varset
- .declarations
- .iter()
- .filter(|declaration| declaration.type_name == fparkan_script::VarSetType::Float)
- .count();
- let dword_defaults = varset.declarations.len() - float_defaults;
- serialize_json(&VarSetInspectOutput {
- schema_version: "fparkan-varset-inspect-v1",
- path,
- declarations: varset.declarations.len(),
- float_defaults,
- dword_defaults,
- first_name: varset
- .declarations
- .first()
- .map(|declaration| declaration.name.as_str()),
- last_name: varset
- .declarations
- .last()
- .map(|declaration| declaration.name.as_str()),
- })
-}
-
-fn exit_code(result: &Result<(), String>) -> i32 {
- if result.is_ok() {
- 0
- } else {
- 2
- }
-}
-
-fn strip_format_json(args: &[String]) -> Result<Vec<String>, String> {
- let mut stripped = Vec::with_capacity(args.len());
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- if arg == "--format" {
- let value = iter
- .next()
- .ok_or_else(|| "--format requires a value".to_string())?;
- if value != "json" {
- return Err(format!("unsupported output format: {value}"));
- }
- continue;
- }
- stripped.push(arg.clone());
- }
- Ok(stripped)
-}
-
-fn parse_root(args: &[String]) -> Result<PathBuf, String> {
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- if arg == "--root" {
- return iter
- .next()
- .map(PathBuf::from)
- .ok_or_else(|| "--root requires a path".to_string());
- }
- }
- Err("missing --root".to_string())
-}
-
-fn parse_root_alias(args: &[String]) -> Result<PathBuf, String> {
- parse_option(args, &["--root", "--game-root"])
- .map(PathBuf::from)
- .ok_or_else(|| "missing --root".to_string())
-}
-
-fn parse_required(args: &[String], names: &[&str], label: &str) -> Result<String, String> {
- parse_option(args, names).ok_or_else(|| format!("missing {label}"))
-}
-
-fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- if names.iter().any(|name| arg == name) {
- return iter.next().cloned();
- }
- }
- None
-}
-
-fn inspect_prototype(args: &[String]) -> Result<(), String> {
- let root = parse_root_alias(args)?;
- let key = parse_required(args, &["--key"], "--key")?;
- let vfs = Arc::new(DirectoryVfs::new(root));
- let repository = CachedResourceRepository::new(vfs.clone());
- let roots = [resource_name(key.as_bytes())];
- let (mut graph, resolved, mut report) =
- build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
- extend_graph_report_with_visual_dependencies(&repository, &mut report, &mut graph, &resolved);
- println!(
- "{}",
- prototype_inspect_json(&key, &graph, &report).map_err(|err| err.to_string())?
- );
- Ok(())
-}
-
-fn prototype_inspect_json(
- key: &str,
- graph: &fparkan_prototype::PrototypeGraph,
- report: &fparkan_prototype::PrototypeGraphReport,
-) -> Result<String, serde_json::Error> {
- serde_json::to_string(&PrototypeInspectOutput {
- schema_version: PROTOTYPE_INSPECT_SCHEMA,
- key: key.to_string(),
- roots: report.root_count,
- node_count: graph.nodes.len(),
- edge_count: graph.edges.len(),
- unit_component_records: graph
- .root_unit_components
- .iter()
- .enumerate()
- .flat_map(|(root_index, records)| {
- records
- .iter()
- .enumerate()
- .map(
- move |(component_index, record)| UnitComponentInspectOutput {
- root_index,
- component_index,
- archive_raw_hex: hex_bytes(&record.archive_raw),
- resource_raw_hex: hex_bytes(&record.resource_raw),
- kind: record.kind,
- parent_or_link: record.parent_or_link,
- description_raw_hex: hex_bytes(&record.description_raw),
- tail0: record.tail0,
- tail1: record.tail1,
- },
- )
- })
- .collect(),
- edges: graph
- .edges
- .iter()
- .map(|edge| PrototypeGraphEdgeInspectOutput {
- id: edge.id.0,
- from: edge.from.0,
- to: edge.to.0,
- kind: prototype_graph_edge_kind_label(edge.kind),
- requiredness: prototype_graph_requiredness_label(edge.requiredness),
- root_index: edge.provenance.as_ref().map(|value| value.root_index),
- parent_edge: edge
- .provenance
- .as_ref()
- .and_then(|value| value.parent_edge.map(|parent| parent.0)),
- unit_component_index: edge
- .provenance
- .as_ref()
- .and_then(|value| value.unit_component_index),
- archive: edge
- .provenance
- .as_ref()
- .and_then(|value| value.archive.clone()),
- resource_raw_hex: edge
- .provenance
- .as_ref()
- .and_then(|value| value.resource.as_ref())
- .map(|raw| hex_bytes(raw)),
- })
- .collect(),
- prototype_requests: graph.prototype_requests.len(),
- resolved: report.resolved_count,
- unit_references: report.unit_reference_count,
- unit_components: report.unit_component_count,
- direct_references: report.direct_reference_count,
- wear_requests: report.wear_request_count,
- wear: report.wear_resolved_count,
- materials: report.material_resolved_count,
- textures: report.texture_resolved_count,
- lightmaps: report.lightmap_resolved_count,
- is_success: report.is_success(),
- failures: report
- .failures
- .iter()
- .take(16)
- .map(graph_failure_output)
- .collect(),
- })
-}
-
-fn graph_mission(args: &[String]) -> Result<(), String> {
- let root = parse_root_alias(args)?;
- let mission = parse_required(args, &["--mission"], "--mission")?;
- let services = EngineServices::new(Arc::new(DirectoryVfs::new(root)));
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- services,
- )
- .map_err(|err| err.to_string())?;
- let loaded = load_mission(
- &mut engine,
- MissionRequest {
- key: mission.clone(),
- },
- )
- .map_err(|err| err.to_string())?;
- println!(
- "{}",
- serialize_json(&MissionGraphOutput {
- schema_version: MISSION_GRAPH_SCHEMA,
- mission: mission.clone(),
- objects: loaded.object_count,
- paths: loaded.path_count,
- clans: loaded.clan_count,
- extras: loaded.extra_count,
- roots: loaded.graph_root_count,
- node_count: loaded.graph_node_count,
- edge_count: loaded.graph_edge_count,
- direct_references: loaded.graph_direct_reference_count,
- unit_references: loaded.graph_unit_reference_count,
- unit_components: loaded.graph_unit_component_count,
- prototype_requests: loaded.graph_resolved_count,
- wear_requests: loaded.graph_wear_request_count,
- wear: loaded.graph_wear_resolved_count,
- materials: loaded.graph_material_resolved_count,
- textures: loaded.graph_texture_resolved_count,
- lightmaps: loaded.graph_lightmap_resolved_count,
- is_success: loaded.graph_failure_count == 0,
- failures: loaded.graph_failure_count,
- })?
- );
- Ok(())
-}
-
-fn inspect_mission(args: &[String]) -> Result<(), String> {
- let root = parse_root_alias(args)?;
- let mission = parse_required(args, &["--mission"], "--mission")?;
- let mission_path = normalize_relative(mission.as_bytes(), PathPolicy::StrictLegacy)
- .map_err(|err| err.to_string())?;
- let vfs = DirectoryVfs::new(root);
- let bytes = vfs.read(&mission_path).map_err(|err| err.to_string())?;
- let document =
- decode_mission_payload(bytes, TmaProfile::Strict).map_err(|err| err.to_string())?;
- let objects = document
- .objects
- .iter()
- .enumerate()
- .map(|(index, object)| MissionObjectInspectOutput {
- index,
- resource: String::from_utf8_lossy(&object.resource_name.raw).into_owned(),
- position: object.position,
- orientation_raw: object.orientation,
- scale: object.scale,
- })
- .collect();
- println!(
- "{}",
- serialize_json(&MissionInspectOutput {
- schema_version: MISSION_INSPECT_SCHEMA,
- mission,
- objects,
- })?
- );
- Ok(())
-}
-
-fn inspect_archive(args: &[String]) -> Result<(), String> {
- let path = parse_archive_path(args)?;
- let inspection = inspect_archive_file(&path, 0).map_err(|err| err.clone())?;
-
- match inspection {
- ArchiveInspection::Nres {
- entries,
- lookup_order_valid,
- ..
- } => {
- println!(
- "{}",
- archive_inspect_json(
- &path.display().to_string(),
- "NRes",
- entries,
- Some(lookup_order_valid),
- )?
- );
- Ok(())
- }
- ArchiveInspection::Rsli { entries } => {
- println!(
- "{}",
- archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)?
- );
- Ok(())
- }
- ArchiveInspection::Unsupported => {
- Err(format!("{}: unsupported archive magic", path.display()))
- }
- }
-}
-
-fn inspect_terrain(args: &[String]) -> Result<(), String> {
- let path = parse_file_path(args, "terrain inspect")?;
- let bounds = inspect_land_msh_bounds_file(&path)?;
- let terrain = load_land_msh_from_path(&path)?;
- let mut material_tags = terrain
- .faces
- .iter()
- .fold(std::collections::BTreeMap::new(), |mut counts, face| {
- *counts.entry(face.material_tag).or_insert(0usize) += 1;
- counts
- })
- .into_iter()
- .map(|(tag, faces)| TerrainMaterialTagCount { tag, faces })
- .collect::<Vec<_>>();
- material_tags.sort_unstable_by_key(|entry| entry.tag);
- let shade_pairs = (0..terrain.slots.slots_raw.len())
- .filter_map(|slot_index| terrain.slot_material_pairs(slot_index))
- .flatten()
- .collect::<Vec<_>>();
- let shade_lookup_key_min = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).min();
- let shade_lookup_key_max = shade_pairs.iter().map(|pair| pair.shade_lookup_key()).max();
- let shade_batch_boundaries = shade_pairs
- .iter()
- .filter(|pair| pair.flags & 0x0010 != 0)
- .count();
- println!(
- "{}",
- serialize_json(&TerrainInspectOutput {
- schema_version: TERRAIN_INSPECT_SCHEMA,
- path: path.display().to_string(),
- positions: bounds.positions,
- min: bounds.min,
- max: bounds.max,
- faces: terrain.faces.len(),
- slots: terrain.slots.slots_raw.len(),
- material_tags,
- shade_pairs: shade_pairs.len(),
- shade_lookup_key_min,
- shade_lookup_key_max,
- shade_batch_boundaries,
- })?
- );
- Ok(())
-}
-
-fn inspect_wear(args: &[String]) -> Result<(), String> {
- let root = parse_root_alias(args)?;
- let archive = parse_required(args, &["--archive"], "--archive")?;
- let resource = parse_required(args, &["--resource"], "--resource")?;
- let inspection = inspect_wear_from_root(&root, &archive, &resource)?;
- println!(
- "{}",
- serialize_json(&WearInspectOutput {
- schema_version: WEAR_INSPECT_SCHEMA,
- archive,
- resource,
- materials: inspection.materials,
- lightmaps: inspection.lightmaps,
- first_material: inspection.first_material,
- last_material: inspection.last_material,
- })?
- );
- Ok(())
-}
-
-fn inspect_model(args: &[String]) -> Result<(), String> {
- let root = parse_root_alias(args)?;
- let archive = parse_required(args, &["--archive"], "--archive")?;
- let resource = parse_required(args, &["--resource"], "--resource")?;
- let inspection = inspect_model_from_root(&root, &archive, &resource)?;
- println!("{}", model_inspect_json(&archive, &resource, &inspection)?);
- Ok(())
-}
-
-fn model_inspect_json(
- archive: &str,
- resource: &str,
- inspection: &ModelInspection,
-) -> Result<String, String> {
- serialize_json(&ModelInspectOutput {
- schema_version: MODEL_INSPECT_SCHEMA,
- archive,
- resource,
- streams: inspection.streams,
- nodes: inspection.nodes,
- node_stride: inspection.node_stride,
- slots: inspection.slots,
- positions: inspection.positions,
- indices: inspection.indices,
- batches: inspection.batches,
- animation_keys: inspection.animation_keys,
- animation_frame_count: inspection.animation_frame_count,
- node38: inspection
- .node38
- .iter()
- .map(|node| ModelNodeInspectOutput {
- index: node.index,
- parent_or_link_raw: node.parent_or_link_raw,
- anim_map_start: node.anim_map_start,
- fallback_key: node.fallback_key,
- has_lod0_group0: node.has_lod0_group0,
- })
- .collect(),
- })
-}
-
-fn archive_inspect_json(
- path: &str,
- kind: &str,
- entries: usize,
- lookup_order_valid: Option<bool>,
-) -> Result<String, String> {
- serialize_json(&ArchiveInspectOutput {
- schema_version: ARCHIVE_INSPECT_SCHEMA,
- path,
- kind,
- entries,
- lookup_order_valid,
- })
-}
-
-fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
- parse_file_path(args, "archive inspect")
-}
-
-fn parse_file_path(args: &[String], command: &str) -> Result<PathBuf, String> {
- match args {
- [path] => Ok(PathBuf::from(path)),
- [flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
- _ => Err(format!("{command} requires <file> or --file <file>")),
- }
-}
-
-fn serialize_json<T: Serialize>(value: &T) -> Result<String, String> {
- serde_json::to_string(value).map_err(|err| err.to_string())
-}
-
-fn graph_failure_output(failure: &fparkan_prototype::PrototypeGraphFailure) -> GraphFailureOutput {
- GraphFailureOutput {
- root_index: failure.root_index,
- edge: prototype_graph_edge_label(failure.edge),
- requiredness: prototype_graph_requiredness_label(failure.requiredness),
- message: failure.message.clone(),
- archive: failure
- .provenance
- .as_ref()
- .and_then(|provenance| provenance.archive.clone()),
- resource: failure.provenance.as_ref().and_then(|provenance| {
- provenance
- .resource
- .as_ref()
- .map(|raw| String::from_utf8_lossy(raw).into_owned())
- }),
- }
-}
-
-fn hex_bytes(raw: &[u8]) -> String {
- let mut output = String::with_capacity(raw.len().saturating_mul(2));
- for byte in raw {
- #[allow(clippy::expect_used)]
- write!(&mut output, "{byte:02x}").expect("writing into String cannot fail");
- }
- output
-}
-
-fn prototype_graph_edge_kind_label(
- edge: fparkan_prototype::PrototypeGraphEdgeKind,
-) -> &'static str {
- match edge {
- fparkan_prototype::PrototypeGraphEdgeKind::MissionToRoot => "mission_to_root",
- fparkan_prototype::PrototypeGraphEdgeKind::UnitDatToComponent => "unit_dat_to_component",
- fparkan_prototype::PrototypeGraphEdgeKind::PrototypeToMesh => "prototype_to_mesh",
- fparkan_prototype::PrototypeGraphEdgeKind::MeshToWear => "mesh_to_wear",
- fparkan_prototype::PrototypeGraphEdgeKind::WearToMaterial => "wear_to_material",
- fparkan_prototype::PrototypeGraphEdgeKind::MaterialToTexture => "material_to_texture",
- fparkan_prototype::PrototypeGraphEdgeKind::WearToLightmap => "wear_to_lightmap",
- }
-}
-
-fn prototype_graph_edge_label(edge: fparkan_prototype::PrototypeGraphEdge) -> &'static str {
- match edge {
- fparkan_prototype::PrototypeGraphEdge::MissionToUnitDat => "mission_to_unit_dat",
- fparkan_prototype::PrototypeGraphEdge::MissionToObjectsRegistry => {
- "mission_to_objects_registry"
- }
- fparkan_prototype::PrototypeGraphEdge::UnitDatToComponent => "unit_dat_to_component",
- fparkan_prototype::PrototypeGraphEdge::PrototypeToMesh => "prototype_to_mesh",
- fparkan_prototype::PrototypeGraphEdge::MeshToWear => "mesh_to_wear",
- fparkan_prototype::PrototypeGraphEdge::WearToMaterial => "wear_to_material",
- fparkan_prototype::PrototypeGraphEdge::MaterialToTexture => "material_to_texture",
- fparkan_prototype::PrototypeGraphEdge::WearToLightmap => "wear_to_lightmap",
- }
-}
-
-fn prototype_graph_requiredness_label(
- requiredness: fparkan_prototype::PrototypeGraphRequiredness,
-) -> &'static str {
- match requiredness {
- fparkan_prototype::PrototypeGraphRequiredness::Required => "required",
- fparkan_prototype::PrototypeGraphRequiredness::Optional => "optional",
- fparkan_prototype::PrototypeGraphRequiredness::Fallback => "fallback",
- }
-}
-
-fn usage() -> String {
- "usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | wear inspect --root <path> --archive <archive> --resource <wear.wea> [--format json] | model inspect --root <path> --archive <archive> --resource <model.msh> [--format json] | script inspect <file> [--format json] | varset inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --root <path> --mission <path> [--format json]".to_string()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn strings(values: &[&str]) -> Vec<String> {
- values.iter().map(|value| (*value).to_string()).collect()
- }
-
- #[test]
- fn stable_exit_codes_are_mapped() {
- assert_eq!(exit_code(&Ok(())), 0);
- assert_eq!(exit_code(&Err("failure".to_string())), 2);
- }
-
- #[test]
- fn accepts_json_format_option() {
- assert_eq!(
- strip_format_json(&strings(&["--root", "testdata", "--format", "json"])),
- Ok(strings(&["--root", "testdata"]))
- );
- assert_eq!(
- strip_format_json(&strings(&["--format", "text"])),
- Err("unsupported output format: text".to_string())
- );
- }
-
- #[test]
- fn script_inspect_json_has_canonical_field_order() {
- let package =
- fparkan_script::decode(&[73, 0, 0, 0, 0, 0, 0, 0]).expect("minimal script package");
- assert_eq!(
- script_inspect_json("script.scr", &package),
- Ok("{\"schema_version\":\"fparkan-script-inspect-v2\",\"path\":\"script.scr\",\"opcode_handler_count\":73,\"events\":0,\"instructions\":0,\"references\":0,\"trailing_bytes\":0,\"first_header_word_candidates\":[]}".to_string())
- );
- }
-
- #[test]
- fn varset_inspect_json_has_canonical_field_order() {
- let varset =
- fparkan_script::parse_varset(b"VAR( float, f0, 0)\nVAR( DWORD, d0, 0xffffffff)\n")
- .expect("minimal varset");
- assert_eq!(
- varset_inspect_json("varset.var", &varset),
- Ok("{\"schema_version\":\"fparkan-varset-inspect-v1\",\"path\":\"varset.var\",\"declarations\":2,\"float_defaults\":1,\"dword_defaults\":1,\"first_name\":\"f0\",\"last_name\":\"d0\"}".to_string())
- );
- }
-
- #[test]
- fn archive_json_has_schema_version() {
- let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true))
- .expect("serialize archive inspection");
-
- assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\""));
- assert!(json.contains("\"kind\":\"NRes\""));
- assert!(json.contains("\"lookup_order_valid\":true"));
- }
-
- #[test]
- fn prototype_graph_json_has_canonical_field_order() {
- let mut graph = fparkan_prototype::PrototypeGraph::default();
- graph
- .prototype_requests
- .push(fparkan_prototype::PrototypeKey(resource_name(b"root")));
- let report = fparkan_prototype::PrototypeGraphReport {
- root_count: 1,
- direct_reference_count: 1,
- resolved_count: 1,
- ..fparkan_prototype::PrototypeGraphReport::default()
- };
-
- let json = prototype_inspect_json("root", &graph, &report).expect("serialize");
-
- assert_eq!(
- json,
- "{\"schema_version\":\"fparkan-prototype-inspect-v2\",\"key\":\"root\",\"roots\":1,\"node_count\":0,\"edge_count\":0,\"unit_component_records\":[],\"edges\":[],\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear_requests\":0,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"is_success\":true,\"failures\":[]}"
- );
- }
-
- #[test]
- fn mission_graph_json_has_canonical_field_order() {
- let json = serialize_json(&MissionGraphOutput {
- schema_version: MISSION_GRAPH_SCHEMA,
- mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
- objects: 2,
- paths: 3,
- clans: 4,
- extras: 5,
- roots: 6,
- node_count: 7,
- edge_count: 8,
- direct_references: 9,
- unit_references: 10,
- unit_components: 11,
- prototype_requests: 12,
- wear_requests: 13,
- wear: 14,
- materials: 15,
- textures: 16,
- lightmaps: 17,
- is_success: true,
- failures: 0,
- })
- .expect("serialize");
-
- assert_eq!(
- json,
- "{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":\"MISSIONS/Autodemo.00/data.tma\",\"objects\":2,\"paths\":3,\"clans\":4,\"extras\":5,\"roots\":6,\"node_count\":7,\"edge_count\":8,\"direct_references\":9,\"unit_references\":10,\"unit_components\":11,\"prototype_requests\":12,\"wear_requests\":13,\"wear\":14,\"materials\":15,\"textures\":16,\"lightmaps\":17,\"is_success\":true,\"failures\":0}"
- );
- }
-
- #[test]
- fn mission_inspect_output_retains_raw_transform_fields() {
- let json = serialize_json(&MissionInspectOutput {
- schema_version: MISSION_INSPECT_SCHEMA,
- mission: "MISSIONS/test/data.tma".to_string(),
- objects: vec![MissionObjectInspectOutput {
- index: 1,
- resource: "unit.dat".to_string(),
- position: [1.0, 2.0, 3.0],
- orientation_raw: [4.0, 5.0, 6.0],
- scale: [7.0, 8.0, 9.0],
- }],
- })
- .expect("serialize mission inspection");
-
- assert_eq!(
- json,
- "{\"schema_version\":\"fparkan-mission-inspect-v1\",\"mission\":\"MISSIONS/test/data.tma\",\"objects\":[{\"index\":1,\"resource\":\"unit.dat\",\"position\":[1.0,2.0,3.0],\"orientation_raw\":[4.0,5.0,6.0],\"scale\":[7.0,8.0,9.0]}]}"
- );
- }
-
- #[test]
- fn terrain_inspect_output_retains_axis_bounds() {
- let json = serialize_json(&TerrainInspectOutput {
- schema_version: TERRAIN_INSPECT_SCHEMA,
- path: "DATA/MAPS/AutoMAP/Land.msh".to_string(),
- positions: 3,
- min: [-1.0, -2.0, -3.0],
- max: [4.0, 5.0, 6.0],
- faces: 2,
- slots: 4,
- material_tags: vec![
- TerrainMaterialTagCount { tag: 0, faces: 1 },
- TerrainMaterialTagCount { tag: 3, faces: 1 },
- ],
- shade_pairs: 2,
- shade_lookup_key_min: Some(1),
- shade_lookup_key_max: Some(2),
- shade_batch_boundaries: 1,
- })
- .expect("serialize terrain inspection");
-
- assert_eq!(
- json,
- "{\"schema_version\":\"fparkan-terrain-inspect-v1\",\"path\":\"DATA/MAPS/AutoMAP/Land.msh\",\"positions\":3,\"min\":[-1.0,-2.0,-3.0],\"max\":[4.0,5.0,6.0],\"faces\":2,\"slots\":4,\"material_tags\":[{\"tag\":0,\"faces\":1},{\"tag\":3,\"faces\":1}],\"shade_pairs\":2,\"shade_lookup_key_min\":1,\"shade_lookup_key_max\":2,\"shade_batch_boundaries\":1}"
- );
- }
-
- #[test]
- fn wear_inspect_output_retains_material_bounds() {
- let json = serialize_json(&WearInspectOutput {
- schema_version: WEAR_INSPECT_SCHEMA,
- archive: "system.rlb".to_string(),
- resource: "SHADE.WEA".to_string(),
- materials: 1,
- lightmaps: 0,
- first_material: Some("LIGHT1".to_string()),
- last_material: Some("LIGHT1".to_string()),
- })
- .expect("serialize wear inspection");
-
- assert_eq!(
- json,
- "{\"schema_version\":\"fparkan-wear-inspect-v1\",\"archive\":\"system.rlb\",\"resource\":\"SHADE.WEA\",\"materials\":1,\"lightmaps\":0,\"first_material\":\"LIGHT1\",\"last_material\":\"LIGHT1\"}"
- );
- }
-}
diff --git a/apps/fparkan-game/Cargo.toml b/apps/fparkan-game/Cargo.toml
index 49e2cad..69535f3 100644
--- a/apps/fparkan-game/Cargo.toml
+++ b/apps/fparkan-game/Cargo.toml
@@ -6,20 +6,21 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-assets = { path = "../../crates/fparkan-assets", version = "0.1.0" }
-fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
-fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
-fparkan-platform = { path = "../../crates/fparkan-platform", version = "0.1.0" }
-fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
-fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit", version = "0.1.0" }
-fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan", version = "0.1.0" }
-fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
-fparkan-terrain = { path = "../../crates/fparkan-terrain", version = "0.1.0" }
-fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
-fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" }
+fparkan-animation = { path = "../../crates/fparkan-animation" }
+fparkan-assets = { path = "../../crates/fparkan-assets" }
+fparkan-fx = { path = "../../crates/fparkan-fx" }
+fparkan-path = { path = "../../crates/fparkan-path" }
+fparkan-resource = { path = "../../crates/fparkan-resource" }
+fparkan-render = { path = "../../crates/fparkan-render" }
+fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
+fparkan-msh = { path = "../../crates/fparkan-msh" }
+fparkan-prototype = { path = "../../crates/fparkan-prototype" }
+fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
+fparkan-runtime = { path = "../../crates/fparkan-runtime" }
+fparkan-terrain = { path = "../../crates/fparkan-terrain" }
+fparkan-terrain-format = { path = "../../crates/fparkan-terrain-format" }
+fparkan-vfs = { path = "../../crates/fparkan-vfs" }
+rodio = { version = "0.22.2", default-features = false, features = ["playback", "vorbis", "wav", "symphonia-adpcm"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
winit = { version = "0.30", default-features = false, features = ["rwh_06"] }
-
-[lints]
-workspace = true
diff --git a/apps/fparkan-game/src/audio.rs b/apps/fparkan-game/src/audio.rs
new file mode 100644
index 0000000..2de6464
--- /dev/null
+++ b/apps/fparkan-game/src/audio.rs
@@ -0,0 +1,1798 @@
+//! Audio playback for a running mission.
+//!
+//! The game keeps this module deliberately small. Mission ambience is read
+//! from the names in mission.cfg, samples are resolved through the same VFS
+//! and resource archive formats as the rest of the game, and rodio owns the
+//! output thread. A sample is decoded once to validate it before it is
+//! queued, then its bytes are retained so a new source can be created when a
+//! loop or one-shot starts.
+
+use fparkan_fx::environment::{SoundAction, SoundEvent};
+use fparkan_path::{normalize_relative, NormalizedPath, PathPolicy};
+use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
+use fparkan_vfs::Vfs;
+use rodio::mixer::Mixer;
+use rodio::source::{ChannelVolume, SeekError};
+use rodio::{
+ ChannelCount, Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, SampleRate, Source,
+};
+use std::collections::BTreeMap;
+use std::io::Cursor;
+use std::sync::{Arc, Mutex};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+const SWAV_TYPE: u32 = u32::from_le_bytes(*b"SWAV");
+const DEFAULT_MUSIC_TRACK: u8 = 2;
+const FIRST_MUSIC_TRACK: u8 = 2;
+const LAST_MUSIC_TRACK: u8 = 10;
+const EAR_HALF_WIDTH: f32 = 0.15;
+const VALIDATION_SAMPLES: usize = 64;
+const AMBIENT_NIGHT_LENGTH: f32 = 1.1;
+const AMBIENT_MIN_DELAY_SECONDS: f32 = 10.0;
+const AMBIENT_DELAY_VARIANTS: u32 = 10;
+
+/// Position and orientation of the listener in world coordinates.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub(crate) struct Listener {
+ /// Listener position.
+ pub position: [f32; 3],
+ /// Direction in which the camera looks.
+ pub forward: [f32; 3],
+ /// Camera up direction.
+ pub up: [f32; 3],
+}
+
+impl Default for Listener {
+ fn default() -> Self {
+ Self {
+ position: [0.0; 3],
+ forward: [0.0, 1.0, 0.0],
+ up: [0.0, 0.0, 1.0],
+ }
+ }
+}
+
+/// Audio loading/playback failure.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) enum AudioError {
+ /// Mission path or archive path is not a valid relative resource path.
+ InvalidPath { path: String, message: String },
+ /// Mission configuration could not be read.
+ MissionConfigRead { path: String, message: String },
+ /// Mission configuration does not contain the required audio object.
+ MissionConfig { path: String, message: String },
+ /// A declared archive could not be opened.
+ Archive { path: String, message: String },
+ /// A declared archive entry is absent.
+ MissingEntry { archive: String, name: String },
+ /// A declared entry exists but contains no payload.
+ EmptyEntry { archive: String, name: String },
+ /// A payload is not the expected SWAV entry type.
+ WrongEntryType {
+ archive: String,
+ name: String,
+ type_id: Option<u32>,
+ },
+ /// The payload has a valid container but cannot be decoded by rodio.
+ Decode { resource: String, message: String },
+ /// A decoded container produced no samples.
+ NoSamples { resource: String },
+ /// A direct VFS file such as a music track is missing or unreadable.
+ FileRead { path: String, message: String },
+ /// Track selection is outside the names shipped by the original game.
+ UnsupportedMusicTrack(u8),
+ /// The operating system has no usable output device.
+ DeviceUnavailable { message: String },
+ /// A listener direction is non-finite or has zero length.
+ InvalidListener { field: &'static str },
+ /// A listener or emitter position is non-finite.
+ InvalidPosition { field: &'static str },
+}
+
+impl std::fmt::Display for AudioError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::InvalidPath { path, message } => {
+ write!(f, "invalid audio path {path}: {message}")
+ }
+ Self::MissionConfigRead { path, message } => {
+ write!(f, "failed to read mission audio config {path}: {message}")
+ }
+ Self::MissionConfig { path, message } => {
+ write!(f, "invalid mission audio config {path}: {message}")
+ }
+ Self::Archive { path, message } => {
+ write!(f, "failed to open audio archive {path}: {message}")
+ }
+ Self::MissingEntry { archive, name } => {
+ write!(
+ f,
+ "declared audio resource {name} is missing from {archive}"
+ )
+ }
+ Self::EmptyEntry { archive, name } => {
+ write!(f, "declared audio resource {name} in {archive} is empty")
+ }
+ Self::WrongEntryType {
+ archive,
+ name,
+ type_id,
+ } => write!(
+ f,
+ "audio resource {name} in {archive} has type {type_id:?}, expected SWAV"
+ ),
+ Self::Decode { resource, message } => {
+ write!(f, "failed to decode audio resource {resource}: {message}")
+ }
+ Self::NoSamples { resource } => {
+ write!(f, "audio resource {resource} contains no samples")
+ }
+ Self::FileRead { path, message } => {
+ write!(f, "failed to read audio file {path}: {message}")
+ }
+ Self::UnsupportedMusicTrack(track) => {
+ write!(
+ f,
+ "music track Track{track:02}.ogg is outside Track02..Track10"
+ )
+ }
+ Self::DeviceUnavailable { message } => {
+ write!(f, "audio output device is unavailable: {message}")
+ }
+ Self::InvalidListener { field } => {
+ write!(
+ f,
+ "audio listener direction {field} must be finite and non-zero"
+ )
+ }
+ Self::InvalidPosition { field } => {
+ write!(f, "audio {field} position must be finite")
+ }
+ }
+ }
+}
+
+impl std::error::Error for AudioError {}
+
+/// Parsed names from the two mission audio objects.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub(crate) struct MissionAudioConfig {
+ /// Archive named by ambient_music_loop.
+ pub library: String,
+ /// Archive named by ambient_music_variation, or the loop archive when it
+ /// is omitted.
+ pub variation_library: String,
+ /// Theme loop name from THEME.
+ pub theme: String,
+ /// Ambient default variation names in numeric key order.
+ pub default_variations: Vec<String>,
+ /// Ambient day variation names in numeric key order.
+ pub day_variations: Vec<String>,
+ /// Ambient night variation names in numeric key order.
+ pub night_variations: Vec<String>,
+}
+
+#[derive(Clone, Debug)]
+struct SoundAsset {
+ archive: String,
+ name: String,
+ bytes: Arc<[u8]>,
+}
+
+impl SoundAsset {
+ fn resource_name(&self) -> String {
+ format!("{}:{}", self.archive, self.name)
+ }
+}
+
+struct AudioLibrary {
+ vfs: Arc<dyn Vfs>,
+ repository: Arc<CachedResourceRepository>,
+ theme: SoundAsset,
+ default_variations: Vec<SoundAsset>,
+ day_variations: Vec<SoundAsset>,
+ night_variations: Vec<SoundAsset>,
+ music: BTreeMap<u8, Arc<[u8]>>,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum VariationPool {
+ Default,
+ Day,
+ Night,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct VariationSelection {
+ pool: VariationPool,
+ index: Option<usize>,
+}
+
+/// The two 16-bit states used by the original ambient variation selector.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct VariationRng {
+ a: u16,
+ b: u16,
+}
+
+impl VariationRng {
+ fn from_clock(clock: u32) -> Self {
+ Self {
+ a: clock as u16,
+ b: (clock >> 16) as u16,
+ }
+ }
+
+ fn step(&mut self) {
+ self.a = self.a.wrapping_shl(1) ^ self.b;
+ self.b = self.b.wrapping_shr(1) ^ self.a;
+ }
+
+ fn choose(&mut self, length: usize, last_index: &mut i32) -> Option<usize> {
+ if length == 0 {
+ *last_index = -1;
+ return None;
+ }
+ if length == 1 {
+ *last_index = 0;
+ return Some(0);
+ }
+ for _ in 0..=u16::MAX {
+ self.step();
+ let candidate = usize::from(self.b) % length;
+ if candidate as i32 != *last_index {
+ *last_index = candidate as i32;
+ return Some(candidate);
+ }
+ }
+ // A zero state is not produced by the native clock seed, but keeping
+ // this fallback makes malformed or deterministic test seeds harmless.
+ let candidate = usize::from(self.b) % length;
+ *last_index = candidate as i32;
+ Some(candidate)
+ }
+}
+
+/// CRT-style delay generator kept separate from the ambient pool selector.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct CrtRand {
+ state: u32,
+}
+
+impl CrtRand {
+ fn new(state: u32) -> Self {
+ Self { state }
+ }
+
+ fn next_u15(&mut self) -> u32 {
+ self.state = self.state.wrapping_mul(214_013).wrapping_add(2_531_011);
+ (self.state >> 16) & 0x7fff
+ }
+
+ fn next_delay_seconds(&mut self) -> f32 {
+ AMBIENT_MIN_DELAY_SECONDS + (self.next_u15() % AMBIENT_DELAY_VARIANTS) as f32
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct AmbienceScheduler {
+ elapsed_seconds: f32,
+ next_delay_seconds: f32,
+ pool: VariationPool,
+ variation_rng: VariationRng,
+ delay_rng: CrtRand,
+ last_index: i32,
+}
+
+impl AmbienceScheduler {
+ fn new(clock_seed: u32, delay_seed: u32) -> Self {
+ Self {
+ elapsed_seconds: 0.0,
+ next_delay_seconds: 0.0,
+ pool: VariationPool::Default,
+ variation_rng: VariationRng::from_clock(clock_seed),
+ delay_rng: CrtRand::new(delay_seed),
+ last_index: -1,
+ }
+ }
+
+ fn update(
+ &mut self,
+ dt_seconds: f32,
+ primary_rgb: [f32; 3],
+ default_len: usize,
+ day_len: usize,
+ night_len: usize,
+ ) -> Option<VariationSelection> {
+ self.pool = selected_pool(primary_rgb, day_len, night_len);
+ self.elapsed_seconds += finite_non_negative(dt_seconds);
+ if self.elapsed_seconds <= self.next_delay_seconds {
+ return None;
+ }
+ self.elapsed_seconds = 0.0;
+ let length = match self.pool {
+ VariationPool::Default => default_len,
+ VariationPool::Day => day_len,
+ VariationPool::Night => night_len,
+ };
+ let index = self.variation_rng.choose(length, &mut self.last_index);
+ self.next_delay_seconds = self.delay_rng.next_delay_seconds();
+ Some(VariationSelection {
+ pool: self.pool,
+ index,
+ })
+ }
+}
+
+fn finite_non_negative(value: f32) -> f32 {
+ if value.is_finite() {
+ value.max(0.0)
+ } else {
+ 0.0
+ }
+}
+
+fn random_seed() -> u32 {
+ let duration = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default();
+ (duration.as_nanos() as u64 as u32) ^ duration.subsec_nanos().rotate_left(13)
+}
+
+fn selected_pool(primary_rgb: [f32; 3], day_len: usize, night_len: usize) -> VariationPool {
+ if day_len == 0 && night_len == 0 {
+ return VariationPool::Default;
+ }
+ let length = primary_rgb
+ .iter()
+ .map(|value| value * value)
+ .sum::<f32>()
+ .sqrt();
+ if length.is_finite() && length <= AMBIENT_NIGHT_LENGTH {
+ VariationPool::Night
+ } else {
+ VariationPool::Day
+ }
+}
+
+struct SpatialOneShot {
+ player: NativeSpatialPlayer,
+ volume: f32,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct NativeSpatialState {
+ emitter_position: [f32; 3],
+ left_ear: [f32; 3],
+ right_ear: [f32; 3],
+ volume: f32,
+ min_distance: f32,
+ max_distance: f32,
+}
+
+/// A spatial player with the native DirectSound distance curve.
+///
+/// `rodio::SpatialPlayer` applies an inverse-square curve unconditionally.
+/// Native FX sounds instead use the DirectSound min/max distance curve, so
+/// this small wrapper keeps rodio's mono and stereo panning behavior while
+/// supplying the native per-channel gains itself.
+struct NativeSpatialPlayer {
+ player: Player,
+ state: Arc<Mutex<NativeSpatialState>>,
+}
+
+impl NativeSpatialPlayer {
+ fn connect_new(
+ mixer: &Mixer,
+ emitter_position: [f32; 3],
+ left_ear: [f32; 3],
+ right_ear: [f32; 3],
+ ) -> Self {
+ Self {
+ player: Player::connect_new(mixer),
+ state: Arc::new(Mutex::new(NativeSpatialState {
+ emitter_position,
+ left_ear,
+ right_ear,
+ volume: 1.0,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ })),
+ }
+ }
+
+ fn set_emitter_position(&self, position: [f32; 3]) {
+ self.state
+ .lock()
+ .expect("audio state poisoned")
+ .emitter_position = position;
+ }
+
+ fn set_left_ear_position(&self, position: [f32; 3]) {
+ self.state.lock().expect("audio state poisoned").left_ear = position;
+ }
+
+ fn set_right_ear_position(&self, position: [f32; 3]) {
+ self.state.lock().expect("audio state poisoned").right_ear = position;
+ }
+
+ fn set_sound_parameters(&self, min_distance: f32, max_distance: f32) {
+ let mut state = self.state.lock().expect("audio state poisoned");
+ state.min_distance = min_distance;
+ state.max_distance = max_distance;
+ }
+
+ fn append<S>(&self, source: S)
+ where
+ S: Source + Send + 'static,
+ {
+ self.player
+ .append(NativeSpatialSource::new(source, Arc::clone(&self.state)));
+ }
+
+ #[cfg(test)]
+ fn volume(&self) -> f32 {
+ self.state.lock().expect("audio state poisoned").volume
+ }
+
+ fn set_volume(&self, volume: f32) {
+ self.state.lock().expect("audio state poisoned").volume = volume;
+ }
+
+ fn set_speed(&self, speed: f32) {
+ self.player.set_speed(speed);
+ }
+
+ fn play(&self) {
+ self.player.play();
+ }
+
+ fn pause(&self) {
+ self.player.pause();
+ }
+
+ #[cfg(test)]
+ fn is_paused(&self) -> bool {
+ self.player.is_paused()
+ }
+
+ fn clear(&self) {
+ self.player.clear();
+ }
+
+ fn stop(&self) {
+ self.player.stop();
+ }
+
+ fn empty(&self) -> bool {
+ self.player.empty()
+ }
+}
+
+struct NativeSpatialSource<I>
+where
+ I: Source,
+{
+ input: ChannelVolume<I>,
+ state: Arc<Mutex<NativeSpatialState>>,
+ channel_gains: [f32; 2],
+ refresh_remaining: usize,
+}
+
+const NATIVE_SPATIAL_REFRESH_SAMPLES: usize = 256;
+
+impl<I> NativeSpatialSource<I>
+where
+ I: Source,
+{
+ fn new(input: I, state: Arc<Mutex<NativeSpatialState>>) -> Self {
+ Self {
+ input: ChannelVolume::new(input, vec![0.0, 0.0]),
+ state,
+ channel_gains: [0.0, 0.0],
+ refresh_remaining: 0,
+ }
+ }
+}
+
+impl<I> Iterator for NativeSpatialSource<I>
+where
+ I: Source,
+{
+ type Item = f32;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ if self.refresh_remaining == 0 {
+ if let Ok(state) = self.state.lock() {
+ self.channel_gains = native_spatial_channel_gains(*state);
+ }
+ self.refresh_remaining = NATIVE_SPATIAL_REFRESH_SAMPLES;
+ }
+ self.refresh_remaining -= 1;
+ self.input.set_volume(0, self.channel_gains[0]);
+ self.input.set_volume(1, self.channel_gains[1]);
+ self.input.next()
+ }
+}
+
+impl<I> Source for NativeSpatialSource<I>
+where
+ I: Source,
+{
+ fn current_span_len(&self) -> Option<usize> {
+ self.input.current_span_len()
+ }
+
+ fn channels(&self) -> ChannelCount {
+ self.input.channels()
+ }
+
+ fn sample_rate(&self) -> SampleRate {
+ self.input.sample_rate()
+ }
+
+ fn total_duration(&self) -> Option<std::time::Duration> {
+ self.input.total_duration()
+ }
+
+ fn try_seek(&mut self, position: std::time::Duration) -> Result<(), SeekError> {
+ self.input.try_seek(position)
+ }
+}
+
+impl AudioLibrary {
+ fn load(vfs: Arc<dyn Vfs>, mission_cfg: &NormalizedPath) -> Result<Self, AudioError> {
+ let repository = Arc::new(CachedResourceRepository::new(Arc::clone(&vfs)));
+ let cfg_bytes = vfs
+ .read(mission_cfg)
+ .map_err(|source| AudioError::MissionConfigRead {
+ path: mission_cfg.as_str().to_string(),
+ message: source.to_string(),
+ })?;
+ let config = parse_mission_audio_config(&cfg_bytes).map_err(|message| {
+ AudioError::MissionConfig {
+ path: mission_cfg.as_str().to_string(),
+ message,
+ }
+ })?;
+ let archive =
+ archive_path(config.library.as_bytes()).map_err(|source| AudioError::InvalidPath {
+ path: config.library.clone(),
+ message: source.to_string(),
+ })?;
+ let variation_archive =
+ archive_path(config.variation_library.as_bytes()).map_err(|source| {
+ AudioError::InvalidPath {
+ path: config.variation_library.clone(),
+ message: source.to_string(),
+ }
+ })?;
+
+ let theme = load_swav(&repository, &archive, &config.theme)?;
+ let default_variations = config
+ .default_variations
+ .iter()
+ .map(|name| load_swav(&repository, &variation_archive, name))
+ .collect::<Result<Vec<_>, _>>()?;
+ let day_variations = config
+ .day_variations
+ .iter()
+ .map(|name| load_swav(&repository, &variation_archive, name))
+ .collect::<Result<Vec<_>, _>>()?;
+ let night_variations = config
+ .night_variations
+ .iter()
+ .map(|name| load_swav(&repository, &variation_archive, name))
+ .collect::<Result<Vec<_>, _>>()?;
+ Ok(Self {
+ vfs,
+ repository,
+ theme,
+ default_variations,
+ day_variations,
+ night_variations,
+ music: BTreeMap::new(),
+ })
+ }
+
+ fn load_fx_sound(&self, archive: &str, name: &str) -> Result<SoundAsset, AudioError> {
+ let archive =
+ archive_path(archive.as_bytes()).map_err(|source| AudioError::InvalidPath {
+ path: archive.to_string(),
+ message: source.to_string(),
+ })?;
+ load_swav(&self.repository, &archive, name)
+ }
+
+ fn music_bytes(&mut self, track: u8) -> Result<Arc<[u8]>, AudioError> {
+ let path = music_path(track)?;
+ if let Some(bytes) = self.music.get(&track) {
+ return Ok(Arc::clone(bytes));
+ }
+ let bytes = self
+ .vfs
+ .read(&path)
+ .map_err(|source| AudioError::FileRead {
+ path: path.as_str().to_string(),
+ message: source.to_string(),
+ })?;
+ let bytes = validate_audio_bytes(path.as_str(), &bytes)?;
+ self.music.insert(track, Arc::clone(&bytes));
+ Ok(bytes)
+ }
+}
+
+/// Runtime audio state for one loaded mission.
+pub(crate) struct GameAudio {
+ stream: MixerDeviceSink,
+ ambient: Player,
+ music: Player,
+ variations: Player,
+ weather_loop: NativeSpatialPlayer,
+ one_shots: Vec<SpatialOneShot>,
+ library: AudioLibrary,
+ ambience: AmbienceScheduler,
+ listener: Listener,
+ paused: bool,
+ dynamic_sounds: BTreeMap<String, SoundAsset>,
+ weather_volume: f32,
+ weather_position: [f32; 3],
+}
+
+impl GameAudio {
+ /// Opens and validates mission audio using the caller's VFS.
+ ///
+ /// mission is the same relative path passed to the runtime, usually
+ /// MISSIONS/Autodemo.00/data.tma. Its neighbouring mission.cfg supplies
+ /// the ambient names. Only the declared samples and selected music tracks
+ /// are read.
+ ///
+ /// Returns a resource/configuration error for missing or malformed audio,
+ /// or DeviceUnavailable when no output device can be opened. The caller
+ /// may report the latter and continue without audio.
+ pub(crate) fn new(vfs: Arc<dyn Vfs>, mission: &str) -> Result<Self, AudioError> {
+ let mission_cfg = mission_cfg_path(mission)?;
+ let library = AudioLibrary::load(vfs, &mission_cfg)?;
+ let mut stream = DeviceSinkBuilder::open_default_sink().map_err(|source| {
+ AudioError::DeviceUnavailable {
+ message: source.to_string(),
+ }
+ })?;
+ stream.log_on_drop(false);
+ let ambient = Player::connect_new(stream.mixer());
+ let music = Player::connect_new(stream.mixer());
+ let variations = Player::connect_new(stream.mixer());
+ let listener = Listener::default();
+ let (left, right) = ear_positions(listener)?;
+ let weather_loop =
+ NativeSpatialPlayer::connect_new(stream.mixer(), listener.position, left, right);
+ let mut audio = Self {
+ stream,
+ ambient,
+ music,
+ variations,
+ weather_loop,
+ one_shots: Vec::new(),
+ library,
+ ambience: AmbienceScheduler::new(random_seed(), random_seed()),
+ listener,
+ paused: false,
+ dynamic_sounds: BTreeMap::new(),
+ weather_volume: 1.0,
+ weather_position: listener.position,
+ };
+ audio.start_theme()?;
+ audio.play_music_track(DEFAULT_MUSIC_TRACK)?;
+ Ok(audio)
+ }
+
+ /// Updates rodio's left/right ears from the current free camera.
+ pub(crate) fn update_listener(
+ &mut self,
+ position: [f32; 3],
+ forward: [f32; 3],
+ up: [f32; 3],
+ ) -> Result<(), AudioError> {
+ let listener = Listener {
+ position,
+ forward,
+ up,
+ };
+ let (left, right) = ear_positions(listener)?;
+ self.listener = listener;
+ self.weather_loop.set_left_ear_position(left);
+ self.weather_loop.set_right_ear_position(right);
+ self.weather_loop
+ .set_emitter_position(self.weather_position);
+ self.retain_active_one_shots();
+ for one_shot in &self.one_shots {
+ one_shot.player.set_left_ear_position(left);
+ one_shot.player.set_right_ear_position(right);
+ one_shot.player.set_volume(one_shot.volume);
+ }
+ self.refresh_weather_volume();
+ Ok(())
+ }
+
+ /// Advances the native ambient variation timer and starts a selected
+ /// sample when its strict elapsed-time boundary is crossed.
+ ///
+ /// `dt_seconds` is the non-negative delta for this frame, not an
+ /// absolute mission clock.
+ ///
+ /// `primary_rgb` is the first active celestial light's base color before
+ /// any camera-dependent sun glare is applied. The original atmosphere
+ /// treats a color length at or below 1.1 as night.
+ pub(crate) fn update_ambience(
+ &mut self,
+ dt_seconds: f32,
+ primary_rgb: [f32; 3],
+ ) -> Result<(), AudioError> {
+ if self.paused {
+ return Ok(());
+ }
+ let selection = self.ambience.update(
+ dt_seconds,
+ primary_rgb,
+ self.library.default_variations.len(),
+ self.library.day_variations.len(),
+ self.library.night_variations.len(),
+ );
+ let Some(selection) = selection else {
+ return Ok(());
+ };
+ let Some(index) = selection.index else {
+ return Ok(());
+ };
+ let asset = match selection.pool {
+ VariationPool::Default => self.library.default_variations.get(index),
+ VariationPool::Day => self.library.day_variations.get(index),
+ VariationPool::Night => self.library.night_variations.get(index),
+ }
+ .cloned()
+ .ok_or_else(|| AudioError::NoSamples {
+ resource: format!("ambient variation {:?}[{index}]", selection.pool),
+ })?;
+ self.variations.clear();
+ self.variations.append(decoder_for(&asset)?);
+ if self.paused {
+ self.variations.pause();
+ } else {
+ self.variations.play();
+ }
+ Ok(())
+ }
+
+ /// Applies one sound event emitted by the environment simulation.
+ ///
+ /// Resource names are resolved lazily from the archive named by the event;
+ /// this keeps mission `sky.ske`/FX references data-driven and avoids
+ /// requiring every possible weather sample during mission startup.
+ pub(crate) fn handle_sound_event(&mut self, event: &SoundEvent) -> Result<(), AudioError> {
+ validate_point(event.position, "emitter")?;
+ let volume = if event.volume.is_finite() {
+ event.volume.max(0.0)
+ } else {
+ 0.0
+ };
+ let min_distance = finite_non_negative(event.min_distance);
+ let max_distance = if event.max_distance.is_finite() {
+ event.max_distance.max(0.0)
+ } else {
+ f32::INFINITY
+ };
+ let frequency_ratio = if event.frequency_ratio.is_finite() && event.frequency_ratio > 0.0 {
+ event.frequency_ratio
+ } else {
+ 1.0
+ };
+ match event.action {
+ SoundAction::StartLoop => {
+ let asset = self.dynamic_sound(&event.archive, &event.name)?;
+ self.weather_loop.clear();
+ self.weather_loop.set_emitter_position(event.position);
+ self.weather_position = event.position;
+ self.weather_volume = volume;
+ self.weather_loop
+ .set_sound_parameters(min_distance, max_distance);
+ self.weather_loop.set_volume(volume);
+ self.weather_loop.set_speed(frequency_ratio);
+ self.weather_loop
+ .append(decoder_for(&asset)?.repeat_infinite());
+ if self.paused {
+ self.weather_loop.pause();
+ } else {
+ self.weather_loop.play();
+ }
+ }
+ SoundAction::SetLoopVolume => {
+ self.weather_loop.set_emitter_position(event.position);
+ self.weather_position = event.position;
+ self.weather_volume = volume;
+ self.weather_loop
+ .set_sound_parameters(min_distance, max_distance);
+ self.weather_loop.set_volume(volume);
+ self.weather_loop.set_speed(frequency_ratio);
+ }
+ SoundAction::StopLoop => {
+ self.weather_loop.clear();
+ self.weather_loop.set_volume(1.0);
+ self.weather_volume = 1.0;
+ self.weather_position = self.listener.position;
+ self.weather_loop.set_sound_parameters(0.0, f32::INFINITY);
+ self.weather_loop.set_speed(1.0);
+ }
+ SoundAction::OneShot => {
+ self.retain_active_one_shots();
+ let asset = self.dynamic_sound(&event.archive, &event.name)?;
+ self.one_shots.push(self.new_spatial_one_shot(
+ &asset,
+ event.position,
+ volume,
+ min_distance,
+ max_distance,
+ frequency_ratio,
+ )?);
+ }
+ }
+ Ok(())
+ }
+
+ /// Selects one of the original MUSIC/Track02..10.ogg files and loops it.
+ pub(crate) fn play_music_track(&mut self, track: u8) -> Result<(), AudioError> {
+ let bytes = self.library.music_bytes(track)?;
+ self.music.clear();
+ self.music.append(
+ decoder_for_bytes(&format!("MUSIC/Track{track:02}.ogg"), &bytes)?.repeat_infinite(),
+ );
+ if self.paused {
+ self.music.pause();
+ } else {
+ self.music.play();
+ }
+ Ok(())
+ }
+
+ /// Pauses or resumes every live audio source without changing selection.
+ pub(crate) fn set_paused(&mut self, paused: bool) {
+ self.paused = paused;
+ for player in [&self.ambient, &self.music, &self.variations] {
+ if paused {
+ player.pause();
+ } else {
+ player.play();
+ }
+ }
+ if paused {
+ self.weather_loop.pause();
+ } else {
+ self.weather_loop.play();
+ }
+ for one_shot in &self.one_shots {
+ if paused {
+ one_shot.player.pause();
+ } else {
+ one_shot.player.play();
+ }
+ }
+ }
+
+ /// Focus callback used by the window event loop.
+ pub(crate) fn on_focus_changed(&mut self, focused: bool) {
+ self.set_paused(!focused);
+ }
+
+ /// Stops all sources before the window/event loop exits.
+ pub(crate) fn shutdown(&mut self) {
+ self.ambient.stop();
+ self.music.stop();
+ self.variations.stop();
+ self.weather_loop.stop();
+ for one_shot in &self.one_shots {
+ one_shot.player.stop();
+ }
+ }
+
+ fn start_theme(&self) -> Result<(), AudioError> {
+ self.ambient
+ .append(decoder_for(&self.library.theme)?.repeat_infinite());
+ if self.paused {
+ self.ambient.pause();
+ } else {
+ self.ambient.play();
+ }
+ Ok(())
+ }
+
+ fn new_spatial_one_shot(
+ &self,
+ asset: &SoundAsset,
+ emitter: [f32; 3],
+ volume: f32,
+ min_distance: f32,
+ max_distance: f32,
+ frequency_ratio: f32,
+ ) -> Result<SpatialOneShot, AudioError> {
+ validate_point(emitter, "emitter")?;
+ let (left, right) = ear_positions(self.listener)?;
+ let player = NativeSpatialPlayer::connect_new(self.stream.mixer(), emitter, left, right);
+ player.set_sound_parameters(min_distance, max_distance);
+ player.set_volume(volume);
+ player.set_speed(frequency_ratio);
+ player.append(decoder_for(asset)?);
+ if self.paused {
+ player.pause();
+ } else {
+ player.play();
+ }
+ Ok(SpatialOneShot { player, volume })
+ }
+
+ fn refresh_weather_volume(&self) {
+ self.weather_loop.set_volume(self.weather_volume);
+ }
+
+ fn dynamic_sound(&mut self, archive: &str, name: &str) -> Result<SoundAsset, AudioError> {
+ let archive = if archive.is_empty() {
+ self.library.theme.archive.as_str()
+ } else {
+ archive
+ };
+ let key = format!(
+ "{}:{}",
+ archive.to_ascii_lowercase(),
+ name.to_ascii_lowercase()
+ );
+ if let Some(asset) = self.dynamic_sounds.get(&key) {
+ return Ok(asset.clone());
+ }
+ let asset = self.library.load_fx_sound(archive, name)?;
+ self.dynamic_sounds.insert(key, asset.clone());
+ Ok(asset)
+ }
+
+ fn retain_active_one_shots(&mut self) {
+ self.one_shots.retain(|one_shot| !one_shot.player.empty());
+ }
+}
+
+impl Drop for GameAudio {
+ fn drop(&mut self) {
+ self.shutdown();
+ }
+}
+
+type AudioDecoder = Decoder<Cursor<Arc<[u8]>>>;
+
+fn decoder_for(asset: &SoundAsset) -> Result<AudioDecoder, AudioError> {
+ decoder_for_bytes(&asset.resource_name(), &asset.bytes)
+}
+
+fn decoder_for_bytes(resource: &str, bytes: &Arc<[u8]>) -> Result<AudioDecoder, AudioError> {
+ Decoder::try_from(Cursor::new(Arc::clone(bytes))).map_err(|source| AudioError::Decode {
+ resource: resource.to_string(),
+ message: source.to_string(),
+ })
+}
+
+fn validate_audio_bytes(resource: &str, bytes: &Arc<[u8]>) -> Result<Arc<[u8]>, AudioError> {
+ if bytes.is_empty() {
+ return Err(AudioError::EmptyEntry {
+ archive: "VFS".to_string(),
+ name: resource.to_string(),
+ });
+ }
+ let decoder = decoder_for_bytes(resource, bytes)?;
+ if decoder.take(VALIDATION_SAMPLES).count() == 0 {
+ return Err(AudioError::NoSamples {
+ resource: resource.to_string(),
+ });
+ }
+ Ok(Arc::clone(bytes))
+}
+
+fn load_swav(
+ repository: &CachedResourceRepository,
+ archive: &NormalizedPath,
+ name: &str,
+) -> Result<SoundAsset, AudioError> {
+ let display_archive = archive.as_str().to_string();
+ let resource = resource_name(name.as_bytes());
+ let archive_id = repository
+ .open_archive_unchanged(archive)
+ .map_err(|source| AudioError::Archive {
+ path: display_archive.clone(),
+ message: source.to_string(),
+ })?;
+ let entry = repository
+ .find(archive_id, &resource)
+ .map_err(|source| AudioError::Archive {
+ path: display_archive.clone(),
+ message: source.to_string(),
+ })?
+ .ok_or_else(|| AudioError::MissingEntry {
+ archive: display_archive.clone(),
+ name: name.to_string(),
+ })?;
+ let info = repository
+ .entry_info(entry)
+ .map_err(|source| AudioError::Archive {
+ path: display_archive.clone(),
+ message: source.to_string(),
+ })?;
+ if info.key.type_id != Some(SWAV_TYPE) {
+ return Err(AudioError::WrongEntryType {
+ archive: display_archive,
+ name: name.to_string(),
+ type_id: info.key.type_id,
+ });
+ }
+ let bytes = repository
+ .read(entry)
+ .map_err(|source| AudioError::Archive {
+ path: archive.as_str().to_string(),
+ message: source.to_string(),
+ })?;
+ if bytes.is_empty() {
+ return Err(AudioError::EmptyEntry {
+ archive: archive.as_str().to_string(),
+ name: name.to_string(),
+ });
+ }
+ let bytes = Arc::<[u8]>::from(bytes.as_slice());
+ let bytes = validate_audio_bytes(&format!("{}:{name}", archive.as_str()), &bytes)?;
+ Ok(SoundAsset {
+ archive: archive.as_str().to_string(),
+ name: name.to_string(),
+ bytes,
+ })
+}
+
+fn music_path(track: u8) -> Result<NormalizedPath, AudioError> {
+ if !(FIRST_MUSIC_TRACK..=LAST_MUSIC_TRACK).contains(&track) {
+ return Err(AudioError::UnsupportedMusicTrack(track));
+ }
+ let display = format!("MUSIC/Track{track:02}.ogg");
+ normalize_relative(display.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
+ AudioError::InvalidPath {
+ path: display,
+ message: source.to_string(),
+ }
+ })
+}
+
+/// Derives the sibling mission.cfg path from a mission TMA path.
+pub(crate) fn mission_cfg_path(mission: &str) -> Result<NormalizedPath, AudioError> {
+ let mission =
+ normalize_relative(mission.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
+ AudioError::InvalidPath {
+ path: mission.to_string(),
+ message: source.to_string(),
+ }
+ })?;
+ let cfg = mission.as_str().rsplit_once('/').map_or_else(
+ || "mission.cfg".to_string(),
+ |(parent, _)| format!("{parent}/mission.cfg"),
+ );
+ normalize_relative(cfg.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
+ AudioError::InvalidPath {
+ path: cfg,
+ message: source.to_string(),
+ }
+ })
+}
+
+fn ear_positions(listener: Listener) -> Result<([f32; 3], [f32; 3]), AudioError> {
+ validate_point(listener.position, "position")?;
+ let forward = unit(listener.forward, "forward")?;
+ let up = unit(listener.up, "up")?;
+ let right = unit(cross(forward, up), "forward/up")?;
+ let left_ear = sub(listener.position, scale(right, EAR_HALF_WIDTH));
+ let right_ear = add(listener.position, scale(right, EAR_HALF_WIDTH));
+ validate_point(left_ear, "position")?;
+ validate_point(right_ear, "position")?;
+ Ok((left_ear, right_ear))
+}
+
+const DIRECTSOUND_DB_COEFFICIENT: f32 = 3_321.927_978_515_625;
+const DIRECTSOUND_MIN_VOLUME_DB: f32 = -10_000.0;
+const DIRECTSOUND_PAN_UNITS: f32 = 4_000.0;
+
+/// Converts a DirectSound distance range into a linear sample multiplier.
+/// The native listener uses rolloff 1.0 and stores volume in hundredths of a
+/// decibel. Its max-distance branch is strict (`distance > max`).
+fn native_distance_gain(distance: f32, min_distance: f32, max_distance: f32) -> f32 {
+ if !distance.is_finite()
+ || !min_distance.is_finite()
+ || min_distance <= 0.0
+ || !max_distance.is_finite()
+ || max_distance <= min_distance
+ {
+ return 1.0;
+ }
+ if distance <= min_distance {
+ return 1.0;
+ }
+ if distance > max_distance {
+ return 10.0_f32.powf(DIRECTSOUND_MIN_VOLUME_DB / 2_000.0);
+ }
+ let ratio = min_distance / (min_distance + (distance - min_distance));
+ if !ratio.is_finite() || ratio <= 0.0 {
+ return 10.0_f32.powf(DIRECTSOUND_MIN_VOLUME_DB / 2_000.0);
+ }
+ let decibels = (ratio.log10() * DIRECTSOUND_DB_COEFFICIENT)
+ .round()
+ .max(DIRECTSOUND_MIN_VOLUME_DB);
+ 10.0_f32.powf(decibels / 2_000.0)
+}
+
+fn native_spatial_channel_gains(state: NativeSpatialState) -> [f32; 2] {
+ let listener_position = midpoint(state.left_ear, state.right_ear);
+ let distance = squared_distance(state.emitter_position, listener_position).sqrt();
+ let attenuation = native_distance_gain(distance, state.min_distance, state.max_distance);
+ // Effect.dll computes software pan from the listener's right vector and
+ // the normalized emitter direction, then passes +/-4000 to DirectSound.
+ // The engine's ear positions already encode that right vector. Do not use
+ // the distance to each ear: that heuristic reverses the native pan when a
+ // source is placed to the listener's right.
+ let ear_axis = sub(state.right_ear, state.left_ear);
+ let ear_axis_length = squared_distance(ear_axis, [0.0; 3]).sqrt();
+ let emitter_direction = sub(state.emitter_position, listener_position);
+ let emitter_length = squared_distance(emitter_direction, [0.0; 3]).sqrt();
+ let pan = if ear_axis_length.is_finite()
+ && ear_axis_length > f32::EPSILON
+ && emitter_length.is_finite()
+ && emitter_length > f32::EPSILON
+ {
+ let right = scale(ear_axis, 1.0 / ear_axis_length);
+ let direction = scale(emitter_direction, 1.0 / emitter_length);
+ (DIRECTSOUND_PAN_UNITS * dot(right, direction)).round()
+ } else {
+ 0.0
+ };
+ let (left_pan, right_pan) = if pan > 0.0 {
+ (10.0_f32.powf(-pan / 2_000.0), 1.0)
+ } else if pan < 0.0 {
+ (1.0, 10.0_f32.powf(pan / 2_000.0))
+ } else {
+ (1.0, 1.0)
+ };
+ let volume = if state.volume.is_finite() {
+ state.volume.max(0.0)
+ } else {
+ 0.0
+ } * attenuation;
+ [volume * left_pan, volume * right_pan]
+}
+
+fn dot(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2]))
+}
+
+fn squared_distance(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left.into_iter()
+ .zip(right)
+ .map(|(left, right)| (left - right) * (left - right))
+ .sum()
+}
+
+fn midpoint(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [
+ (left[0] + right[0]) * 0.5,
+ (left[1] + right[1]) * 0.5,
+ (left[2] + right[2]) * 0.5,
+ ]
+}
+
+fn validate_point(point: [f32; 3], field: &'static str) -> Result<(), AudioError> {
+ if point.iter().all(|value| value.is_finite()) {
+ Ok(())
+ } else {
+ Err(AudioError::InvalidPosition { field })
+ }
+}
+
+fn unit(vector: [f32; 3], field: &'static str) -> Result<[f32; 3], AudioError> {
+ if !vector.iter().all(|value| value.is_finite()) {
+ return Err(AudioError::InvalidListener { field });
+ }
+ let length = vector[0]
+ .mul_add(
+ vector[0],
+ vector[1].mul_add(vector[1], vector[2] * vector[2]),
+ )
+ .sqrt();
+ if !length.is_finite() || length <= f32::EPSILON {
+ return Err(AudioError::InvalidListener { field });
+ }
+ Ok(vector.map(|value| value / length))
+}
+
+fn cross(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [
+ left[1] * right[2] - left[2] * right[1],
+ left[2] * right[0] - left[0] * right[2],
+ left[0] * right[1] - left[1] * right[0],
+ ]
+}
+
+fn add(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] + right[0], left[1] + right[1], left[2] + right[2]]
+}
+
+fn sub(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
+}
+
+fn scale(vector: [f32; 3], factor: f32) -> [f32; 3] {
+ [vector[0] * factor, vector[1] * factor, vector[2] * factor]
+}
+
+fn parse_mission_audio_config(bytes: &[u8]) -> Result<MissionAudioConfig, String> {
+ #[derive(Default)]
+ struct Object {
+ library: Option<String>,
+ theme: Option<String>,
+ default: BTreeMap<usize, String>,
+ day: BTreeMap<usize, String>,
+ night: BTreeMap<usize, String>,
+ }
+
+ let text = String::from_utf8_lossy(bytes);
+ let mut loop_object = Object::default();
+ let mut variation_object = Object::default();
+ let mut current = None;
+ for raw_line in text.lines() {
+ let line = raw_line.split('#').next().unwrap_or_default().trim();
+ if line.is_empty() {
+ continue;
+ }
+ let mut tokens = line.split_whitespace();
+ if tokens
+ .next()
+ .is_some_and(|token| token.eq_ignore_ascii_case("object"))
+ {
+ current = tokens.next().map(|name| name.to_ascii_lowercase());
+ continue;
+ }
+ if line.eq_ignore_ascii_case("end") {
+ current = None;
+ continue;
+ }
+ let Some((key, value)) = line.split_once('=') else {
+ continue;
+ };
+ let key = key.trim().to_ascii_uppercase();
+ let value = parse_cfg_value(value.trim())?;
+ match current.as_deref() {
+ Some("ambient_music_loop") => {
+ if key == "LIBRARY" {
+ loop_object.library = Some(value);
+ } else if key == "THEME" {
+ loop_object.theme = Some(value);
+ }
+ }
+ Some("ambient_music_variation") => {
+ if key == "LIBRARY" {
+ variation_object.library = Some(value);
+ } else if let Some(index) = key.strip_prefix("DEFAULT_VARIATION") {
+ let index = index
+ .parse::<usize>()
+ .map_err(|_| format!("invalid default variation key {key}"))?;
+ if index == 0 {
+ return Err(format!("zero default variation key {key}"));
+ }
+ variation_object.default.insert(index, value);
+ } else if let Some(index) = key.strip_prefix("DAY_VARIATION") {
+ let index = index
+ .parse::<usize>()
+ .map_err(|_| format!("invalid day variation key {key}"))?;
+ if index == 0 {
+ return Err(format!("zero day variation key {key}"));
+ }
+ variation_object.day.insert(index, value);
+ } else if let Some(index) = key.strip_prefix("NIGHT_VARIATION") {
+ let index = index
+ .parse::<usize>()
+ .map_err(|_| format!("invalid night variation key {key}"))?;
+ if index == 0 {
+ return Err(format!("zero night variation key {key}"));
+ }
+ variation_object.night.insert(index, value);
+ }
+ }
+ _ => {}
+ }
+ }
+ let library = loop_object
+ .library
+ .ok_or_else(|| "ambient_music_loop.library is missing".to_string())?;
+ let theme = loop_object
+ .theme
+ .ok_or_else(|| "ambient_music_loop.THEME is missing".to_string())?;
+ let variation_library = variation_object.library.unwrap_or_else(|| library.clone());
+ Ok(MissionAudioConfig {
+ library,
+ variation_library,
+ theme,
+ default_variations: variation_object.default.into_values().collect(),
+ day_variations: variation_object.day.into_values().collect(),
+ night_variations: variation_object.night.into_values().collect(),
+ })
+}
+
+fn parse_cfg_value(value: &str) -> Result<String, String> {
+ if let Some(rest) = value.strip_prefix('"') {
+ let end = rest
+ .find('"')
+ .ok_or_else(|| "unterminated quoted value".to_string())?;
+ return Ok(rest[..end].to_string());
+ }
+ Ok(value
+ .split_whitespace()
+ .next()
+ .unwrap_or_default()
+ .to_string())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn parses_original_ambient_names_in_numeric_order() {
+ let config = parse_mission_audio_config(
+ br#"
+ object ambient_music_loop
+ library = "sounds.lib"
+ THEME = "atm_c1_lp.wav"
+ end
+ object ambient_music_variation
+ library = "ambient.lib"
+ DEFAULT_VARIATION2 = "atm_default2.wav"
+ DEFAULT_VARIATION1 = "atm_default1.wav"
+ DAY_VARIATION2 = "atm_bird2.wav"
+ DAY_VARIATION1 = "atm_bees.wav"
+ NIGHT_VARIATION2 = "atm_frog2.wav"
+ NIGHT_VARIATION1 = "atm_frog1.wav"
+ end
+ "#,
+ )
+ .expect("config");
+ assert_eq!(config.library, "sounds.lib");
+ assert_eq!(config.variation_library, "ambient.lib");
+ assert_eq!(config.theme, "atm_c1_lp.wav");
+ assert_eq!(
+ config.default_variations,
+ ["atm_default1.wav", "atm_default2.wav"]
+ );
+ assert_eq!(config.day_variations, ["atm_bees.wav", "atm_bird2.wav"]);
+ assert_eq!(config.night_variations, ["atm_frog1.wav", "atm_frog2.wav"]);
+ }
+
+ #[test]
+ fn omitted_variation_library_uses_loop_library() {
+ let config = parse_mission_audio_config(
+ br#"
+ object ambient_music_loop
+ library = "sounds.lib"
+ THEME = "atm_c1_lp.wav"
+ end
+ object ambient_music_variation
+ DEFAULT_VARIATION1 = "atm_c1_1.wav"
+ end
+ "#,
+ )
+ .expect("config");
+ assert_eq!(config.variation_library, "sounds.lib");
+ assert_eq!(config.default_variations, ["atm_c1_1.wav"]);
+ }
+
+ #[test]
+ fn duplicate_native_variation_key_keeps_last_definition() {
+ let config = parse_mission_audio_config(
+ br#"
+ object ambient_music_loop
+ library = "sounds.lib"
+ THEME = "atm_c1_lp.wav"
+ end
+ object ambient_music_variation
+ DAY_VARIATION1 = "old.wav"
+ DAY_VARIATION1 = "native-last.wav"
+ end
+ "#,
+ )
+ .expect("config");
+ assert_eq!(config.day_variations, ["native-last.wav"]);
+ }
+
+ #[test]
+ fn variation_rng_matches_native_two_state_step() {
+ let mut rng = VariationRng {
+ a: 0x1234,
+ b: 0xabcd,
+ };
+ rng.step();
+ assert_eq!((rng.a, rng.b), (0x8fa5, 0xda43));
+ rng.step();
+ assert_eq!((rng.a, rng.b), (0xc509, 0xa828));
+ rng.step();
+ assert_eq!((rng.a, rng.b), (0x223a, 0x762e));
+ }
+
+ #[test]
+ fn variation_rng_does_not_repeat_last_index_when_pool_has_choices() {
+ let mut rng = VariationRng {
+ a: 0x1234,
+ b: 0xabcd,
+ };
+ let mut last = -1;
+ let mut previous = None;
+ for _ in 0..64 {
+ let selected = rng.choose(4, &mut last).expect("non-empty pool");
+ assert_ne!(Some(selected), previous);
+ previous = Some(selected);
+ }
+ }
+
+ #[test]
+ fn variation_rng_clears_last_index_for_empty_pool() {
+ let mut rng = VariationRng {
+ a: 0x1234,
+ b: 0xabcd,
+ };
+ let mut last = 2;
+ assert_eq!(rng.choose(0, &mut last), None);
+ assert_eq!(last, -1);
+ assert_eq!(rng.choose(3, &mut last), Some(0));
+ assert_eq!(last, 0);
+ }
+
+ #[test]
+ fn crt_delay_uses_native_multiplier_and_ten_to_nineteen_seconds() {
+ let mut rng = CrtRand::new(1);
+ assert_eq!(rng.next_u15(), 41);
+ assert_eq!(rng.next_delay_seconds(), 17.0);
+ assert!((10.0..20.0).contains(&rng.next_delay_seconds()));
+ }
+
+ #[test]
+ fn ambience_timer_uses_strict_boundary_and_initial_positive_tick() {
+ let mut scheduler = AmbienceScheduler {
+ elapsed_seconds: 0.0,
+ next_delay_seconds: 0.0,
+ pool: VariationPool::Default,
+ variation_rng: VariationRng {
+ a: 0x1234,
+ b: 0xabcd,
+ },
+ delay_rng: CrtRand::new(16),
+ last_index: -1,
+ };
+ assert_eq!(scheduler.update(0.0, [2.0, 0.0, 0.0], 0, 1, 1), None);
+ let initial = scheduler
+ .update(0.001, [2.0, 0.0, 0.0], 0, 1, 1)
+ .expect("first positive tick selects a variation");
+ assert_eq!(initial.pool, VariationPool::Day);
+ assert_eq!(initial.index, Some(0));
+ assert_eq!(scheduler.next_delay_seconds, 10.0);
+ assert_eq!(scheduler.update(10.0, [2.0, 0.0, 0.0], 0, 1, 1), None);
+ assert!(scheduler.update(0.0001, [2.0, 0.0, 0.0], 0, 1, 1).is_some());
+ }
+
+ #[test]
+ fn ambience_pool_selection_has_no_cross_day_fallback() {
+ let mut scheduler = AmbienceScheduler::new(1, 1);
+ let selected = scheduler
+ .update(0.01, [0.0, 0.0, 0.0], 2, 1, 0)
+ .expect("night selection attempt");
+ assert_eq!(selected.pool, VariationPool::Night);
+ assert_eq!(selected.index, None);
+
+ let mut scheduler = AmbienceScheduler::new(1, 1);
+ let selected = scheduler
+ .update(0.01, [2.0, 0.0, 0.0], 2, 0, 0)
+ .expect("default selection");
+ assert_eq!(selected.pool, VariationPool::Default);
+ assert_eq!(selected.index, Some(0));
+ }
+
+ #[test]
+ fn night_threshold_is_native_primary_rgb_length() {
+ assert_eq!(selected_pool([1.1, 0.0, 0.0], 1, 1), VariationPool::Night);
+ assert_eq!(selected_pool([1.1001, 0.0, 0.0], 1, 1), VariationPool::Day);
+ assert_eq!(selected_pool([0.0, 0.0, 0.0], 0, 0), VariationPool::Default);
+ }
+
+ #[test]
+ fn derives_sibling_mission_config_path() {
+ assert_eq!(
+ mission_cfg_path("MISSIONS/Single.01/data.tma")
+ .expect("mission path")
+ .as_str(),
+ "MISSIONS/Single.01/mission.cfg"
+ );
+ }
+
+ #[test]
+ fn listener_ears_are_spaced_across_camera_right() {
+ let (left, right) = ear_positions(Listener::default()).expect("ears");
+ assert_eq!(left, [-EAR_HALF_WIDTH, 0.0, 0.0]);
+ assert_eq!(right, [EAR_HALF_WIDTH, 0.0, 0.0]);
+ }
+
+ #[test]
+ fn native_pan_uses_listener_right_axis_and_directsound_units() {
+ let state = NativeSpatialState {
+ emitter_position: [0.0, 0.0, 0.0],
+ left_ear: [-EAR_HALF_WIDTH, 0.0, 0.0],
+ right_ear: [EAR_HALF_WIDTH, 0.0, 0.0],
+ volume: 1.0,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ };
+ assert_eq!(native_spatial_channel_gains(state), [1.0, 1.0]);
+
+ let right = NativeSpatialState {
+ emitter_position: [1.0, 0.0, 0.0],
+ ..state
+ };
+ let right_gains = native_spatial_channel_gains(right);
+ assert!((right_gains[0] - 0.01).abs() < 1.0e-6);
+ assert_eq!(right_gains[1], 1.0);
+
+ let left = NativeSpatialState {
+ emitter_position: [-1.0, 0.0, 0.0],
+ ..state
+ };
+ let left_gains = native_spatial_channel_gains(left);
+ assert_eq!(left_gains[0], 1.0);
+ assert!((left_gains[1] - 0.01).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn native_distance_gain_keeps_min_and_max_boundaries_native() {
+ let below_min = native_distance_gain(99.0, 100.0, 1_500.0);
+ let at_min = native_distance_gain(100.0, 100.0, 1_500.0);
+ let at_max = native_distance_gain(1_500.0, 100.0, 1_500.0);
+ let past_max = native_distance_gain(1_500.001, 100.0, 1_500.0);
+
+ assert_eq!(below_min, 1.0);
+ assert_eq!(at_min, 1.0);
+ assert!(at_max > past_max);
+ assert!(at_max < 1.0);
+ assert!((past_max - 1.0e-5).abs() < 1.0e-8);
+ }
+
+ #[test]
+ fn rejects_non_finite_or_unbounded_listener_inputs() {
+ assert!(ear_positions(Listener {
+ position: [f32::NAN, 0.0, 0.0],
+ ..Listener::default()
+ })
+ .is_err());
+ assert!(ear_positions(Listener {
+ forward: [f32::MAX, f32::MAX, f32::MAX],
+ ..Listener::default()
+ })
+ .is_err());
+ }
+
+ #[test]
+ #[ignore = "requires the installed GOG game files; set FPARKAN_GAME_ROOT"]
+ fn decodes_gog_pcm_adpcm_and_ogg_assets() {
+ let root = std::env::var_os("FPARKAN_GAME_ROOT")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ assert!(
+ root.is_dir(),
+ "game root does not exist: {}",
+ root.display()
+ );
+ let vfs: Arc<dyn Vfs> = Arc::new(fparkan_vfs::DirectoryVfs::new(&root));
+ let mission_cfg = normalize_relative(
+ b"MISSIONS/Autodemo.00/mission.cfg",
+ PathPolicy::StrictLegacy,
+ )
+ .expect("mission cfg path");
+ let mut library = AudioLibrary::load(Arc::clone(&vfs), &mission_cfg).expect("audio assets");
+ assert_eq!(library.theme.name, "atm_c1_lp.wav");
+ assert!(!library.day_variations.is_empty());
+ assert!(!library.night_variations.is_empty());
+ assert_eq!(wav_format_tag(&library.theme.bytes), Some(1));
+ assert_decodes_samples(&library.theme.resource_name(), &library.theme.bytes, 256);
+
+ let adpcm = library
+ .day_variations
+ .iter()
+ .find(|asset| wav_format_tag(&asset.bytes) == Some(2))
+ .expect("an original day variation must use Microsoft ADPCM");
+ assert_eq!(wav_format_tag(&adpcm.bytes), Some(2));
+ assert_decodes_samples(&adpcm.resource_name(), &adpcm.bytes, 256);
+
+ let music = library
+ .music_bytes(DEFAULT_MUSIC_TRACK)
+ .expect("original Track02 OGG");
+ assert!(music.starts_with(b"OggS"));
+ assert_decodes_samples("MUSIC/Track02.ogg", &music, 256);
+ assert_eq!(library.music.len(), 1, "music must load lazily");
+ }
+
+ #[test]
+ #[ignore = "requires the installed GOG game files; set FPARKAN_GAME_ROOT"]
+ fn loads_every_gog_mission_variation_pool() {
+ let root = std::env::var_os("FPARKAN_GAME_ROOT")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ assert!(
+ root.is_dir(),
+ "game root does not exist: {}",
+ root.display()
+ );
+ let mut paths = Vec::new();
+ collect_mission_configs(&root, &mut paths).expect("enumerate mission configs");
+ assert!(!paths.is_empty(), "GOG install has no mission.cfg files");
+ let vfs: Arc<dyn Vfs> = Arc::new(fparkan_vfs::DirectoryVfs::new(&root));
+ let mut default_pools = 0;
+ let mut day_pools = 0;
+ let mut night_pools = 0;
+ for path in paths {
+ let relative = path
+ .strip_prefix(&root)
+ .expect("mission config under game root")
+ .to_string_lossy()
+ .replace('\\', "/");
+ let mission_cfg = normalize_relative(relative.as_bytes(), PathPolicy::StrictLegacy)
+ .expect("mission cfg path");
+ let library = AudioLibrary::load(Arc::clone(&vfs), &mission_cfg)
+ .unwrap_or_else(|error| panic!("load {}: {error}", mission_cfg.as_str()));
+ default_pools += usize::from(!library.default_variations.is_empty());
+ day_pools += usize::from(!library.day_variations.is_empty());
+ night_pools += usize::from(!library.night_variations.is_empty());
+ }
+ assert!(default_pools > 0, "GOG missions must exercise DEFAULT pool");
+ assert!(day_pools > 0, "GOG missions must exercise DAY pool");
+ assert!(night_pools > 0, "GOG missions must exercise NIGHT pool");
+ }
+
+ #[test]
+ #[ignore = "requires installed GOG assets and a usable OS audio output"]
+ fn starts_pauses_and_drops_gog_audio() {
+ let root = std::env::var_os("FPARKAN_GAME_ROOT")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|| std::path::PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ assert!(
+ root.is_dir(),
+ "game root does not exist: {}",
+ root.display()
+ );
+ let vfs: Arc<dyn Vfs> = Arc::new(fparkan_vfs::DirectoryVfs::new(&root));
+ let mut audio =
+ GameAudio::new(vfs, "MISSIONS/Autodemo.00/data.tma").expect("open original game audio");
+ audio
+ .handle_sound_event(&SoundEvent {
+ archive: "sounds.lib".to_string(),
+ name: "atm_rain1.wav".to_string(),
+ position: [0.0; 3],
+ volume: 0.0,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ action: SoundAction::StartLoop,
+ })
+ .expect("start original rain loop");
+ assert_eq!(audio.weather_loop.volume(), 0.0);
+ audio
+ .handle_sound_event(&SoundEvent {
+ archive: "sounds.lib".to_string(),
+ name: "atm_rain1.wav".to_string(),
+ position: [1.0, 2.0, 3.0],
+ volume: 0.5,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ action: SoundAction::SetLoopVolume,
+ })
+ .expect("update original rain loop volume");
+ assert_eq!(audio.weather_loop.volume(), 0.5);
+ audio
+ .update_listener([10.0, 20.0, 30.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0])
+ .expect("move listener");
+ audio
+ .handle_sound_event(&SoundEvent {
+ archive: "sounds.lib".to_string(),
+ name: "atm_light1.wav".to_string(),
+ position: [3.0, 4.0, 5.0],
+ volume: 0.75,
+ min_distance: 100.0,
+ max_distance: 1500.0,
+ frequency_ratio: 1.0,
+ action: SoundAction::OneShot,
+ })
+ .expect("play original lightning sample");
+ assert_eq!(
+ audio
+ .one_shots
+ .last()
+ .unwrap()
+ .player
+ .state
+ .lock()
+ .unwrap()
+ .emitter_position,
+ [3.0, 4.0, 5.0]
+ );
+ audio
+ .handle_sound_event(&SoundEvent {
+ archive: "sounds.lib".to_string(),
+ name: "atm_rain1.wav".to_string(),
+ position: [10.0, 20.0, 30.0],
+ volume: 0.0,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ action: SoundAction::StopLoop,
+ })
+ .expect("stop original rain loop");
+ assert!(audio.weather_loop.empty());
+ audio.set_paused(true);
+ assert!(audio.ambient.is_paused());
+ assert!(audio.music.is_paused());
+ assert!(audio.weather_loop.is_paused());
+ audio.set_paused(false);
+ assert!(!audio.ambient.is_paused());
+ assert!(!audio.music.is_paused());
+ audio.shutdown();
+ }
+
+ fn assert_decodes_samples(resource: &str, bytes: &Arc<[u8]>, minimum: usize) {
+ let decoded = decoder_for_bytes(resource, bytes)
+ .expect("decoder construction")
+ .take(minimum)
+ .count();
+ assert_eq!(decoded, minimum, "{resource} decoded too few samples");
+ }
+
+ fn wav_format_tag(bytes: &[u8]) -> Option<u16> {
+ if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
+ return None;
+ }
+ let mut offset = 12usize;
+ while offset.checked_add(8)? <= bytes.len() {
+ let chunk_len = usize::try_from(u32::from_le_bytes(
+ bytes[offset + 4..offset + 8].try_into().ok()?,
+ ))
+ .ok()?;
+ let data_start = offset.checked_add(8)?;
+ let data_end = data_start.checked_add(chunk_len)?;
+ if data_end > bytes.len() {
+ return None;
+ }
+ if &bytes[offset..offset + 4] == b"fmt " && chunk_len >= 2 {
+ return Some(u16::from_le_bytes(
+ bytes[data_start..data_start + 2].try_into().ok()?,
+ ));
+ }
+ offset = data_end.checked_add(chunk_len & 1)?;
+ }
+ None
+ }
+
+ fn collect_mission_configs(
+ directory: &std::path::Path,
+ output: &mut Vec<std::path::PathBuf>,
+ ) -> std::io::Result<()> {
+ for entry in std::fs::read_dir(directory)? {
+ let path = entry?.path();
+ if path.is_dir() {
+ collect_mission_configs(&path, output)?;
+ } else if path
+ .file_name()
+ .is_some_and(|name| name.eq_ignore_ascii_case("mission.cfg"))
+ {
+ output.push(path);
+ }
+ }
+ Ok(())
+ }
+}
diff --git a/apps/fparkan-game/src/main.rs b/apps/fparkan-game/src/main.rs
index af7fe1d..7405458 100644
--- a/apps/fparkan-game/src/main.rs
+++ b/apps/fparkan-game/src/main.rs
@@ -1,69 +1,76 @@
#![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` render-planning composition root.
-use fparkan_assets::{PreparedTextureUsage, PreparedVisual};
-use fparkan_inspection::load_standalone_wear_material_texture_mip0_rgba8_from_root;
+use fparkan_animation::{AnimationTime, NodePoseBuffer};
+use fparkan_assets::{
+ load_material_name_texture_rgba8_and_phase_with_document_from_root,
+ load_resource_entry_bytes_from_root,
+ load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root,
+ page_uv_transform, sample_material_phase, Mat0Document, MaterialPhase, MaterialPhaseSample,
+ PreparedControlNodeBinding, PreparedMaterialPhase, PreparedTexture, PreparedTextureUsage,
+ PreparedVisual, RgbaImage, StandaloneWearMaterialLoader,
+};
+use fparkan_fx::atmosphere::TypedAtmosphere;
+use fparkan_fx::environment::{
+ decode_env_lightning_fxid, Camera as FxCamera, EnvironmentFrame, EnvironmentPrimitive,
+ EnvironmentSystem, PrecipitationKind, ScreenBillboard,
+};
+use fparkan_fx::shadow::{
+ ShadowCamera, ShadowCaster, ShadowFrame, ShadowLight, ShadowLodSettings, ShadowPageCache,
+ ShadowScene, ShadowSphere, ShadowTriangle, SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+};
+use fparkan_fx::sky::{
+ sprite_half_size_pixels, SkyFrame, SkyMaterials, SkyMesh, SkyPassKind, SkySpriteFrame,
+ SkySystem, SunVisibility, SKY_FLARE_ROWS,
+};
+use fparkan_msh::{
+ node38_fallback_hierarchy, node38_sampled_hierarchy, node38_sampled_hierarchy_at_times,
+ ModelAsset,
+};
use fparkan_path::ResourceName;
-use fparkan_platform::WindowPort;
use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan};
+use fparkan_prototype::{unit_component_tree, PROTOTYPE_TYPE_EXTO};
use fparkan_render::{
- build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange,
- LegacyD3d7Projection, LegacyIron3dEulerTransform, RawCameraTransform, RenderBackend,
- RenderCommand, RenderCommandList, RenderPhase, RenderProfile, RenderSnapshot,
- RenderSnapshotDraw,
+ LegacyBlendMode, LegacyD3d7Projection, LegacyDepthMode, LegacyIron3dEulerTransform,
+ LegacyPipelineState, RawCameraTransform,
};
use fparkan_render_vulkan::{
+ node38_pose, node38_pose_from_hierarchy, node38_pose_relative_to_root,
+ node38_pose_relative_to_root_from_hierarchy,
project_land_msh_to_static_mesh_in_legacy_world_space,
- project_land_msh_to_static_mesh_in_xy_frame,
project_msh_to_static_mesh_in_world_space_with_node_fallback_poses,
+ project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount,
+ project_msh_to_static_mesh_in_world_space_with_node_pose_buffer,
+ project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount,
project_msh_to_static_mesh_in_world_space_with_node_sampled_poses,
- project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses,
- project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses, VulkanPlanningBackend,
- VulkanSmokeFrameOutcome, VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo,
- VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
- VulkanStaticXyFrame,
+ project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount,
+ VulkanDynamicDrawRange, VulkanNodePose, VulkanPointLight, VulkanSmokeFrameOutcome,
+ VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticDrawRange,
+ VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture, VulkanStaticTextureMip,
+ VulkanStaticVertex,
};
use fparkan_runtime::{
- create, frame, load_mission, load_mission_static_preview, load_mission_static_preview_roots,
- load_mission_static_preview_roots_with_progress, load_mission_with_progress,
- loaded_mission_assets, loaded_mission_object_drafts, loaded_terrain, EngineConfig, EngineMode,
- EngineServices, MissionAssets, MissionLoadPhase, MissionObjectDraft, MissionRequest,
+ create, load_mission_static_preview, load_mission_static_preview_roots, loaded_mission_assets,
+ loaded_mission_object_drafts, loaded_terrain, EngineServices, MissionAssets,
+ MissionObjectDraft, MissionRequest,
};
-use fparkan_terrain::TerrainMaterialLayers;
-use fparkan_terrain::TerrainWorld;
+use fparkan_terrain::{FullSurfaceMask, SurfaceQuery, TerrainMaterialLayers, TerrainWorld};
use fparkan_vfs::DirectoryVfs;
-#[cfg(test)]
-use fparkan_world::OriginalObjectId;
-use fparkan_world::{TransformState, WorldSnapshot};
use serde::Deserialize;
+use std::collections::{HashMap, HashSet};
use std::num::NonZeroUsize;
-use std::path::PathBuf;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
+use std::time::{Duration, Instant};
use winit::application::ApplicationHandler;
use winit::dpi::PhysicalSize as WinitPhysicalSize;
-use winit::event::WindowEvent;
+use winit::event::{DeviceEvent, DeviceId, ElementState, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
-use winit::window::{Window, WindowId};
+use winit::keyboard::{KeyCode, PhysicalKey};
+use winit::window::{CursorGrabMode, Window, WindowId};
+
+mod audio;
fn main() {
let raw_args = std::env::args().skip(1).collect::<Vec<_>>();
@@ -83,91 +90,70 @@ fn main() {
fn run(args: &[String]) -> Result<String, String> {
let args = Args::parse(args)?;
let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root)));
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Rendered,
- },
- services,
- )
- .map_err(|err| err.to_string())?;
+ let mut engine = create(services).map_err(|err| err.to_string())?;
+ let mission_assets_started = Instant::now();
let loaded = load_requested_mission(&mut engine, &args)?;
+ let mission_assets_duration = mission_assets_started.elapsed();
- if args.backend == RenderBackendMode::StaticVulkan {
- let mission_assets = loaded_mission_assets(&engine)
- .ok_or_else(|| "mission assets are unavailable after loading".to_string())?;
- let terrain = loaded_terrain(&engine)
- .ok_or_else(|| "mission terrain is unavailable after loading".to_string())?;
- let roots = loaded_mission_object_drafts(&engine)
- .map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())])
- .filter(|roots| !roots.is_empty())
- .ok_or_else(|| {
- "selected mission object drafts are unavailable after loading".to_string()
- })?;
- let camera = args
- .legacy_camera_capture
- .as_deref()
- .map(load_legacy_camera_capture)
- .transpose()?;
- let preview = static_preview_mesh_and_materials(
- mission_assets,
- terrain,
- roots,
- camera,
- args.static_animation_frame,
- args.static_material_phase,
- &args.root,
- &loaded.land_msh_path,
- )?;
- return run_static_vulkan_mode(
- preview,
- roots.len(),
- args.frames,
- &args.mission,
- loaded.object_count,
- args.readback_out.as_deref(),
- );
- }
-
- let mut backend = VulkanPlanningBackend::new();
- let _request = WinitWindow::default_render_request();
- let window = WinitWindow::synthetic(1280, 720);
- let _ = window.drawable_size();
- let _ = window.handle();
- let mut last_draw_count = 0usize;
- let mut last_tick = 0u64;
- let mut last_hash = [0u8; 32];
- for _ in 0..args.frames {
- let result = frame(&mut engine).map_err(|err| err.to_string())?;
- last_tick = result.snapshot.tick.0;
- last_hash = result.snapshot.hash.0;
- let mission_assets = loaded_mission_assets(&engine);
- let mission_drafts = fparkan_runtime::loaded_mission_object_drafts(&engine);
- let commands =
- render_snapshot_commands_with_assets(&result.snapshot, mission_assets, mission_drafts)
- .map_err(|err| format!("render snapshot: {err}"))?;
- last_draw_count = commands
- .commands
- .iter()
- .filter(|command| matches!(command, RenderCommand::Draw(_)))
- .count();
- backend
- .execute(&commands)
- .map_err(|err| format!("render backend: {err}"))?;
- }
-
- let capture_report = backend.report();
-
- Ok(format!(
- "{{\"report_kind\":\"render-planning\",\"backend\":\"vulkan-planning\",\"window\":\"synthetic\",\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"submission_plans\":{},\"last_command_capture_bytes\":{},\"hash\":{}}}",
- json_string(&args.mission),
- loaded.object_count,
+ let mission_assets = loaded_mission_assets(&engine)
+ .ok_or_else(|| "mission assets are unavailable after loading".to_string())?;
+ let terrain = loaded_terrain(&engine)
+ .ok_or_else(|| "mission terrain is unavailable after loading".to_string())?;
+ let roots = loaded_mission_object_drafts(&engine)
+ .map(|drafts| &drafts[..args.preview_roots.get().min(drafts.len())])
+ .filter(|roots| !roots.is_empty())
+ .ok_or_else(|| {
+ "selected mission object drafts are unavailable after loading".to_string()
+ })?;
+ let camera = args
+ .legacy_camera_capture
+ .as_deref()
+ .map(load_legacy_camera_capture)
+ .transpose()?;
+ let terrain_materials_duration_started = Instant::now();
+ let preview = static_preview_mesh_and_materials(
+ mission_assets,
+ terrain,
+ roots,
+ camera,
+ args.static_animation_frame,
+ args.static_material_phase,
+ &args.root,
+ &loaded.land_msh_path,
+ )?;
+ let terrain_materials_duration = terrain_materials_duration_started.elapsed();
+ // Atmosphere files are mission-local assets. Resolving them from the
+ // entire install tree would silently attach the first unrelated mission's
+ // sky to the selected map (there are many `sky.ske` files in the campaign
+ // tree).
+ let sky_started = Instant::now();
+ let environment = load_environment(&args.root, &args.mission, args.atmosphere_seconds)?;
+ let sky_duration = sky_started.elapsed();
+ let audio = match audio::GameAudio::new(Arc::new(DirectoryVfs::new(&args.root)), &args.mission)
+ {
+ Ok(audio) => Some(audio),
+ Err(audio::AudioError::DeviceUnavailable { message }) => {
+ eprintln!("audio output unavailable; continuing without sound: {message}");
+ None
+ }
+ Err(error) => return Err(format!("load mission audio: {error}")),
+ };
+ run_static_vulkan_mode(
+ preview,
+ environment,
+ audio,
args.frames,
- last_tick,
- last_draw_count,
- capture_report.execution.submission_plans,
- capture_report.execution.last_capture_size,
- json_hash(&last_hash)
- ))
+ &args.mission,
+ loaded.object_count,
+ args.readback_out.as_deref(),
+ args.validation,
+ StartupTimings {
+ mission_assets: mission_assets_duration,
+ terrain_materials: terrain_materials_duration,
+ sky: sky_duration,
+ gpu_initialization: Duration::ZERO,
+ },
+ )
}
fn load_requested_mission(
@@ -177,89 +163,3358 @@ fn load_requested_mission(
let request = MissionRequest {
key: args.mission.clone(),
};
- if let Some(progress_path) = args.load_progress.as_ref() {
- prepare_load_progress_path(progress_path)?;
- let progress_started = std::time::Instant::now();
- let mut write_error = None;
- let loaded = if args.backend == RenderBackendMode::StaticVulkan {
- load_mission_static_preview_roots_with_progress(
- engine,
- request,
- args.preview_roots,
- |phase| {
- if write_error.is_none() {
- if let Err(err) =
- write_load_progress(progress_path, progress_started.elapsed(), phase)
- {
- write_error = Some(err);
- }
- }
- },
- )
+ if args.preview_roots.get() == 1 {
+ load_mission_static_preview(engine, request)
+ } else {
+ load_mission_static_preview_roots(engine, request, args.preview_roots)
+ }
+ .map_err(|err| err.to_string())
+}
+
+/// Camera state used by the interactive world-space preview.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct FreeFlightCamera {
+ position: [f32; 3],
+ yaw: f32,
+ pitch: f32,
+ vertical_fov: f32,
+ near_plane: f32,
+ far_plane: f32,
+ move_speed: f32,
+}
+
+impl FreeFlightCamera {
+ fn from_mesh(mesh: &VulkanStaticMesh) -> Result<Self, String> {
+ let mut min = [f32::INFINITY; 3];
+ let mut max = [f32::NEG_INFINITY; 3];
+ for vertex in &mesh.vertices {
+ for axis in 0..3 {
+ let value = vertex.position[axis];
+ if !value.is_finite() {
+ return Err("world preview contains a non-finite vertex".to_string());
+ }
+ min[axis] = min[axis].min(value);
+ max[axis] = max[axis].max(value);
+ }
+ }
+ if !min.iter().chain(max.iter()).all(|value| value.is_finite()) {
+ return Err("world preview has no finite bounds".to_string());
+ }
+ Self::from_bounds(min, max)
+ }
+
+ fn from_bounds(min: [f32; 3], max: [f32; 3]) -> Result<Self, String> {
+ if !min.iter().chain(max.iter()).all(|value| value.is_finite()) {
+ return Err("free-flight bounds must be finite".to_string());
+ }
+ let center = [
+ (min[0] + max[0]) * 0.5,
+ (min[1] + max[1]) * 0.5,
+ (min[2] + max[2]) * 0.5,
+ ];
+ let radius = (0..3)
+ .map(|axis| (max[axis] - min[axis]).abs())
+ .fold(1.0_f32, f32::max);
+ if !radius.is_finite() || radius <= f32::EPSILON {
+ return Err("free-flight bounds are degenerate".to_string());
+ }
+ let position = [
+ center[0],
+ center[1] - radius * 2.5,
+ center[2] + radius * 1.25,
+ ];
+ let direction = sub3(center, position);
+ let distance = length3(direction)
+ .ok_or_else(|| "free-flight initial camera has no direction to the map".to_string())?;
+ let yaw = direction[1].atan2(direction[0]);
+ let pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin();
+ Ok(Self {
+ position,
+ yaw,
+ pitch,
+ vertical_fov: std::f32::consts::FRAC_PI_3,
+ near_plane: (radius * 0.01).clamp(0.05, 0.1),
+ far_plane: (radius * 32.0).max(100.0),
+ // Keep the default traversal speed comfortable on a full mission
+ // map; Shift/Ctrl still provide the coarse speed controls.
+ move_speed: (radius * 0.03).clamp(4.0, 40.0),
+ })
+ }
+
+ fn from_mesh_and_terrain(
+ mesh: &VulkanStaticMesh,
+ terrain: &TerrainWorld,
+ ) -> Result<Self, String> {
+ let mut camera = Self::from_mesh(mesh)?;
+ let positions = terrain
+ .source_positions()
+ .ok_or_else(|| "free-flight terrain has no source positions".to_string())?;
+ let mut min = [f32::INFINITY; 3];
+ let mut max = [f32::NEG_INFINITY; 3];
+ for position in positions {
+ if !position.iter().all(|value| value.is_finite()) {
+ return Err("free-flight terrain contains a non-finite position".to_string());
+ }
+ for axis in 0..3 {
+ min[axis] = min[axis].min(position[axis]);
+ max[axis] = max[axis].max(position[axis]);
+ }
+ }
+ if !min.iter().chain(max.iter()).all(|value| value.is_finite()) {
+ return Err("free-flight terrain has no finite bounds".to_string());
+ }
+ let extent = (max[0] - min[0]).max(max[1] - min[1]);
+ if !extent.is_finite() || extent <= f32::EPSILON {
+ return Err("free-flight terrain has degenerate XY bounds".to_string());
+ }
+ let center = [(min[0] + max[0]) * 0.5, (min[1] + max[1]) * 0.5];
+ let offset = extent * 0.18;
+ let requested_spawn = [
+ (center[0] - offset).clamp(min[0], max[0]),
+ (center[1] - offset).clamp(min[1], max[1]),
+ ];
+ let sample_offsets = [
+ [0.0, 0.0],
+ [0.18, 0.18],
+ [-0.18, 0.18],
+ [0.18, -0.18],
+ [-0.18, -0.18],
+ [0.0, 0.18],
+ [0.18, 0.0],
+ [-0.18, 0.0],
+ [0.0, -0.18],
+ ];
+ let height_world = |xy: [f32; 2]| {
+ terrain
+ .height_at(xy)
+ .ok()
+ .flatten()
+ .filter(|height| height.is_finite())
+ .map(|height| height / 32.0)
+ };
+ let (spawn_xy, spawn_ground) = sample_offsets
+ .into_iter()
+ .map(|sample| {
+ [
+ (requested_spawn[0] + sample[0] * extent).clamp(min[0], max[0]),
+ (requested_spawn[1] + sample[1] * extent).clamp(min[1], max[1]),
+ ]
+ })
+ .find_map(|xy| height_world(xy).map(|height| (xy, height)))
+ .ok_or_else(|| "free-flight terrain has no queryable spawn surface".to_string())?;
+ let target_ground = height_world(center).unwrap_or(spawn_ground);
+ let clearance = (extent * 0.005).clamp(2.0, 8.0);
+ camera.position = [spawn_xy[0], spawn_xy[1], spawn_ground + clearance];
+ let target = [center[0], center[1], target_ground + 1.5];
+ let direction = sub3(target, camera.position);
+ let distance = length3(direction)
+ .ok_or_else(|| "free-flight terrain spawn has no view direction".to_string())?;
+ camera.yaw = direction[1].atan2(direction[0]);
+ camera.pitch = (direction[2] / distance).clamp(-1.0, 1.0).asin();
+ Ok(camera)
+ }
+
+ fn forward(self) -> [f32; 3] {
+ let (sin_yaw, cos_yaw) = self.yaw.sin_cos();
+ let (sin_pitch, cos_pitch) = self.pitch.sin_cos();
+ [cos_pitch * cos_yaw, cos_pitch * sin_yaw, sin_pitch]
+ }
+
+ fn right(self) -> [f32; 3] {
+ normalize3(cross3(self.forward(), [0.0, 0.0, 1.0])).unwrap_or([1.0, 0.0, 0.0])
+ }
+
+ fn up(self) -> [f32; 3] {
+ normalize3(cross3(self.right(), self.forward())).unwrap_or([0.0, 0.0, 1.0])
+ }
+
+ fn look_delta(&mut self, dx: f64, dy: f64) {
+ const SENSITIVITY: f32 = 0.0025;
+ let dx = dx as f32;
+ let dy = dy as f32;
+ if dx.is_finite() && dy.is_finite() {
+ // Moving the mouse right turns the view to the right.
+ self.yaw -= dx * SENSITIVITY;
+ self.pitch = (self.pitch - dy * SENSITIVITY).clamp(-1.5, 1.5);
+ }
+ }
+
+ fn advance(&mut self, keys: &HashSet<KeyCode>, seconds: f32) {
+ if !seconds.is_finite() || seconds <= 0.0 {
+ return;
+ }
+ let mut movement = [0.0; 3];
+ let forward = self.forward();
+ let right = self.right();
+ if keys.contains(&KeyCode::KeyW) {
+ add3_in_place(&mut movement, forward);
+ }
+ if keys.contains(&KeyCode::KeyS) {
+ add3_in_place(&mut movement, scale3(forward, -1.0));
+ }
+ if keys.contains(&KeyCode::KeyD) {
+ add3_in_place(&mut movement, right);
+ }
+ if keys.contains(&KeyCode::KeyA) {
+ add3_in_place(&mut movement, scale3(right, -1.0));
+ }
+ if keys.contains(&KeyCode::KeyE) {
+ add3_in_place(&mut movement, [0.0, 0.0, 1.0]);
+ }
+ if keys.contains(&KeyCode::KeyQ) {
+ add3_in_place(&mut movement, [0.0, 0.0, -1.0]);
+ }
+ let Some(direction) = normalize3(movement) else {
+ return;
+ };
+ let mut speed = self.move_speed;
+ if keys.contains(&KeyCode::ShiftLeft) || keys.contains(&KeyCode::ShiftRight) {
+ speed *= 4.0;
+ }
+ if keys.contains(&KeyCode::ControlLeft) || keys.contains(&KeyCode::ControlRight) {
+ speed *= 0.25;
+ }
+ self.position = add3(self.position, scale3(direction, speed * seconds));
+ }
+
+ fn vulkan_camera(self, aspect: f32) -> VulkanStaticCamera {
+ let aspect = if aspect.is_finite() && aspect > f32::EPSILON {
+ aspect
} else {
- load_mission_with_progress(engine, request, |phase| {
- if write_error.is_none() {
- if let Err(err) =
- write_load_progress(progress_path, progress_started.elapsed(), phase)
+ 16.0 / 9.0
+ };
+ let forward = self.forward();
+ let right = self.right();
+ let up = self.up();
+ // VulkanStaticCamera carries row-major D3D-style data. GLSL interprets
+ // those bytes as a column-major mat4, so shader multiplication is the
+ // transpose of this row-vector transform: world * V * P. The camera
+ // basis is right-handed with +Z-up world coordinates and +Z depth.
+ let view = [
+ right[0],
+ up[0],
+ forward[0],
+ 0.0,
+ right[1],
+ up[1],
+ forward[1],
+ 0.0,
+ right[2],
+ up[2],
+ forward[2],
+ 0.0,
+ -dot3(right, self.position),
+ -dot3(up, self.position),
+ -dot3(forward, self.position),
+ 1.0,
+ ];
+ let half_fov = (self.vertical_fov * 0.5).clamp(0.05, 1.5);
+ let focal = half_fov.tan().recip();
+ let near_plane = self.near_plane.max(0.001);
+ let far_plane = self.far_plane.max(near_plane + 0.001);
+ let projection = [
+ focal / aspect,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ -focal,
+ 0.0,
+ 0.0,
+ 0.0,
+ 0.0,
+ far_plane / (far_plane - near_plane),
+ 1.0,
+ 0.0,
+ 0.0,
+ -(near_plane * far_plane) / (far_plane - near_plane),
+ 0.0,
+ ];
+ VulkanStaticCamera {
+ clip_from_world: multiply_row_major(view, projection),
+ }
+ }
+}
+
+const ENVIRONMENT_SPRITE_ROWS: [usize; 2] = [3, 4];
+const ENVIRONMENT_FLARE_SLOTS: usize = 12;
+const ENVIRONMENT_PARTICLE_SLOTS: usize = 1_000;
+
+#[derive(Clone, Copy, Debug)]
+struct EnvironmentGpuRange {
+ range_index: usize,
+ vertex_start: usize,
+ vertex_capacity: usize,
+ material_index: usize,
+}
+
+#[derive(Clone, Debug)]
+struct EnvironmentMaterialAsset {
+ texture: VulkanStaticTexture,
+ phase: PreparedMaterialPhase,
+ document: Mat0Document,
+ phase_textures: Vec<VulkanStaticTexture>,
+ phases: Vec<PreparedMaterialPhase>,
+}
+
+/// One source MAT0 document retained for frame-time sampling. The renderer
+/// receives the selected coefficients through its dynamic material state;
+/// keeping this binding beside the prepared scene avoids rebuilding assets or
+/// reopening archives on every redraw.
+#[derive(Clone, Debug)]
+struct MaterialPhaseBinding {
+ document: Mat0Document,
+ animation_block_index: usize,
+ wear_row_start_ms: u32,
+ random_state: u32,
+ frozen_phase_index: Option<usize>,
+ phase_uv_transforms: Vec<[f32; 4]>,
+}
+
+/// Texture phases for the material stages that do not select the draw
+/// variant. Terrain MAT0 rows are independent: Land1 base, Land2 detail,
+/// Land1 overlay, and Land2 overlay-detail can each have their own animation
+/// timeline. Keeping these handles beside the binding lets the renderer
+/// update the three descriptor images in place without materialising every
+/// Cartesian product of phase indices.
+#[derive(Clone, Debug)]
+struct MaterialStageTextures {
+ detail: Option<Vec<VulkanStaticTexture>>,
+ overlay: Option<Vec<VulkanStaticTexture>>,
+ overlay_detail: Option<Vec<VulkanStaticTexture>>,
+ applied_material_phases: HashMap<usize, [usize; 3]>,
+}
+
+impl MaterialPhaseBinding {
+ fn sample(
+ &mut self,
+ material_index: usize,
+ clock_ms: u32,
+ ) -> Result<MaterialPhaseSample, String> {
+ if let Some(phase_index) = self.frozen_phase_index {
+ let phase_index = phase_index.min(self.document.phases.len().saturating_sub(1));
+ let phase = self.document.phases.get(phase_index).ok_or_else(|| {
+ format!(
+ "sample MAT0 material {material_index} at clock {clock_ms}ms: no material phases"
+ )
+ })?;
+ return Ok(MaterialPhaseSample {
+ phase_index,
+ coefficients: phase.coefficients(),
+ texture_raw: phase.texture_raw,
+ });
+ }
+
+ // GetPhase's random mode asks the CRT generator for one fresh value
+ // per query. Animated loop/ping-pong/clamp modes do not consume the
+ // RNG and receive the sampler's ignored value.
+ let block = self
+ .document
+ .animation_blocks
+ .get(self.animation_block_index)
+ .or_else(|| self.document.animation_blocks.first());
+ let random_value = if block.is_some_and(|block| block.header_raw & 7 == 3) {
+ self.random_state = self
+ .random_state
+ .wrapping_mul(214_013)
+ .wrapping_add(2_531_011);
+ (self.random_state >> 16) & 0x7fff
+ } else {
+ 0
+ };
+ sample_material_phase(
+ &self.document,
+ self.animation_block_index,
+ clock_ms,
+ self.wear_row_start_ms,
+ random_value,
+ )
+ .map_err(|error| {
+ format!(
+ "sample MAT0 material {} at clock {clock_ms}ms: {error}",
+ material_index
+ )
+ })
+ }
+
+ fn page_uv_transform(&self, phase_index: usize) -> [f32; 4] {
+ self.phase_uv_transforms
+ .get(phase_index)
+ .copied()
+ .or_else(|| self.phase_uv_transforms.first().copied())
+ .unwrap_or([0.0, 0.0, 1.0, 1.0])
+ }
+
+ fn checked_page_uv_transform(&self, phase_index: usize) -> Result<[f32; 4], String> {
+ self.phase_uv_transforms
+ .get(phase_index)
+ .copied()
+ .ok_or_else(|| {
+ format!(
+ "MAT0 Page transform is missing phase {phase_index} of {}",
+ self.phase_uv_transforms.len()
+ )
+ })
+ }
+}
+
+#[derive(Clone, Debug)]
+struct MaterialVariantSet {
+ phase_material_indices: Vec<usize>,
+ range_indices: Vec<usize>,
+}
+
+#[derive(Clone, Debug)]
+struct MaterialAnimationBinding {
+ initial_phase_index: usize,
+ variants: Vec<MaterialVariantSet>,
+ base: MaterialPhaseBinding,
+ detail: Option<MaterialPhaseBinding>,
+ overlay: Option<MaterialPhaseBinding>,
+ overlay_detail: Option<MaterialPhaseBinding>,
+ stage_textures: Option<MaterialStageTextures>,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct ActiveMaterialState {
+ material_index: usize,
+ diffuse_alpha: f32,
+}
+
+fn update_material_animations(
+ bindings: &mut [MaterialAnimationBinding],
+ materials: &[VulkanStaticMaterial],
+ renderer: &mut VulkanSmokeRenderer,
+ clock_ms: u32,
+) -> Result<HashMap<usize, ActiveMaterialState>, String> {
+ // Keep the resolved selector beside the draw range for this frame. The
+ // renderer's material arrays are keyed by selector, so any later
+ // environment pass that writes alpha or lighting must target the phase
+ // variant selected above rather than the material that happened to be
+ // active when the range was allocated.
+ let mut active_materials = HashMap::new();
+ for binding in bindings {
+ let base_sample = binding.base.sample(binding.initial_phase_index, clock_ms)?;
+ let detail_sample = binding
+ .detail
+ .as_mut()
+ .map(|detail| detail.sample(binding.initial_phase_index, clock_ms))
+ .transpose()?;
+ let overlay_sample = binding
+ .overlay
+ .as_mut()
+ .map(|overlay| overlay.sample(binding.initial_phase_index, clock_ms))
+ .transpose()?;
+ let overlay_detail_sample = binding
+ .overlay_detail
+ .as_mut()
+ .map(|overlay_detail| overlay_detail.sample(binding.initial_phase_index, clock_ms))
+ .transpose()?;
+ let mut updated_materials = HashSet::new();
+ for variant in &binding.variants {
+ let material_index = selected_material_variant_index(variant, base_sample.phase_index)
+ .ok_or_else(|| "MAT0 material animation has no texture variant".to_string())?;
+ for &range_index in &variant.range_indices {
+ active_materials.insert(
+ range_index,
+ ActiveMaterialState {
+ material_index,
+ diffuse_alpha: base_sample.coefficients.opacity,
+ },
+ );
+ renderer
+ .set_draw_range_material(range_index, material_index)
+ .map_err(|error| format!("update MAT0 material draw selector: {error}"))?;
+ }
+ if !updated_materials.insert(material_index) {
+ continue;
+ }
+ let material = materials.get(material_index).ok_or_else(|| {
+ format!("MAT0 material animation selector {material_index} is out of bounds")
+ })?;
+ let base_coefficients = base_sample.coefficients;
+ let overlay_coefficients = overlay_sample.map(|sample| sample.coefficients);
+ let (base_directional, base_additive, overlay_directional, overlay_additive) =
+ if material.lightmap_mode {
+ // Terrain mode 3 samples the lightmap in the secondary
+ // stage and uses the authored directional RGB as its
+ // multiplicative lightmap coefficient. It has no normal
+ // lighting or overlay stage.
+ ([0.0; 3], base_coefficients.directional_rgb, None, None)
+ } else {
+ (
+ base_coefficients.directional_rgb,
+ base_coefficients.additive_rgb,
+ overlay_coefficients.map(|coefficients| coefficients.directional_rgb),
+ overlay_coefficients.map(|coefficients| coefficients.additive_rgb),
+ )
+ };
+ renderer
+ .set_material_alphas(
+ material_index,
+ base_coefficients.opacity.clamp(0.0, 1.0),
+ overlay_coefficients
+ .map_or(0.0, |coefficients| coefficients.opacity.clamp(0.0, 1.0)),
+ )
+ .map_err(|error| format!("update MAT0 material alpha: {error}"))?;
+ renderer
+ .set_material_lighting_with_overlay(
+ material_index,
+ base_directional,
+ base_additive,
+ overlay_directional,
+ overlay_additive,
+ )
+ .map_err(|error| format!("update MAT0 material lighting: {error}"))?;
+ renderer
+ .set_material_specular(
+ material_index,
+ base_coefficients.specular_rgb,
+ base_coefficients.power,
+ overlay_coefficients.map(|coefficients| coefficients.specular_rgb),
+ overlay_coefficients.map(|coefficients| coefficients.power),
+ )
+ .map_err(|error| format!("update MAT0 material specular: {error}"))?;
+
+ // Detail and overlay textures have independent MAT0 phase
+ // selectors. Their descriptor images are updated only when a
+ // selected base material sees a new stage tuple; this keeps the
+ // steady-state frame path from idling Vulkan for every draw.
+ if !material.lightmap_mode {
+ if let Some(stage_textures) = binding.stage_textures.as_mut() {
+ let stage_phases = [
+ detail_sample.map_or(0, |sample| sample.phase_index),
+ overlay_sample.map_or(0, |sample| sample.phase_index),
+ overlay_detail_sample.map_or(0, |sample| sample.phase_index),
+ ];
+ if stage_textures
+ .applied_material_phases
+ .get(&material_index)
+ .copied()
+ != Some(stage_phases)
{
- write_error = Some(err);
+ let detail_texture = stage_textures
+ .detail
+ .as_ref()
+ .and_then(|textures| textures.get(stage_phases[0]))
+ .cloned();
+ let overlay_texture = stage_textures
+ .overlay
+ .as_ref()
+ .and_then(|textures| textures.get(stage_phases[1]))
+ .cloned();
+ let overlay_detail_texture = stage_textures
+ .overlay_detail
+ .as_ref()
+ .and_then(|textures| textures.get(stage_phases[2]))
+ .cloned();
+ if let Some(texture) = detail_texture.as_ref() {
+ renderer
+ .update_material_texture(material_index, 1, texture)
+ .map_err(|error| format!("update MAT0 detail texture: {error}"))?;
+ }
+ if let Some(texture) = overlay_texture.as_ref() {
+ renderer
+ .update_material_texture(material_index, 2, texture)
+ .map_err(|error| format!("update MAT0 overlay texture: {error}"))?;
+ }
+ if let Some(texture) = overlay_detail_texture.as_ref() {
+ renderer
+ .update_material_texture(material_index, 3, texture)
+ .map_err(|error| {
+ format!("update MAT0 overlay detail texture: {error}")
+ })?;
+ }
+ stage_textures
+ .applied_material_phases
+ .insert(material_index, stage_phases);
}
}
+ }
+ let overlay_uv = overlay_sample
+ .map(|sample| {
+ binding
+ .overlay
+ .as_ref()
+ .map_or([0.0, 0.0, 1.0, 1.0], |overlay| {
+ overlay.page_uv_transform(sample.phase_index)
+ })
+ })
+ .or(material.overlay_uv_transform)
+ .unwrap_or([0.0, 0.0, 1.0, 1.0]);
+ let detail_uv = if material.lightmap_mode {
+ material.detail_uv_transform
+ } else {
+ detail_sample
+ .map(|sample| {
+ binding
+ .detail
+ .as_ref()
+ .map_or(material.detail_uv_transform, |detail| {
+ detail.page_uv_transform(sample.phase_index)
+ })
+ })
+ .or(Some(material.detail_uv_transform))
+ .unwrap_or([0.0, 0.0, 1.0, 1.0])
+ };
+ let overlay_detail_uv = if material.lightmap_mode {
+ material
+ .overlay_detail_uv_transform
+ .unwrap_or([0.0, 0.0, 1.0, 1.0])
+ } else {
+ overlay_detail_sample
+ .map(|sample| {
+ binding.overlay_detail.as_ref().map_or(
+ material
+ .overlay_detail_uv_transform
+ .unwrap_or([0.0, 0.0, 1.0, 1.0]),
+ |overlay_detail| overlay_detail.page_uv_transform(sample.phase_index),
+ )
+ })
+ .or(material.overlay_detail_uv_transform)
+ .unwrap_or([0.0, 0.0, 1.0, 1.0])
+ };
+ let base_uv = binding
+ .base
+ .checked_page_uv_transform(base_sample.phase_index)?;
+ renderer
+ .set_material_uv_transforms(
+ material_index,
+ [base_uv, detail_uv, overlay_uv, overlay_detail_uv],
+ )
+ .map_err(|error| format!("update MAT0 material Page transform: {error}"))?;
+ }
+ }
+ Ok(active_materials)
+}
+
+fn native_world_transparent(range: &VulkanStaticDrawRange, diffuse_alpha: f32) -> bool {
+ diffuse_alpha < 1.0 || (range.batch_flags & 0x108) != 0
+}
+
+fn register_material_range(
+ bindings: &mut [MaterialAnimationBinding],
+ material_index: usize,
+ range_index: usize,
+) {
+ for binding in bindings {
+ if let Some(variant) = binding.variants.iter_mut().find(|variant| {
+ variant
+ .phase_material_indices
+ .get(binding.initial_phase_index)
+ .copied()
+ == Some(material_index)
+ }) {
+ variant.range_indices.push(range_index);
+ return;
+ }
+ }
+}
+
+fn find_material_animation_variant(
+ bindings: &[MaterialAnimationBinding],
+ material_index: usize,
+) -> Option<(usize, Vec<usize>)> {
+ bindings
+ .iter()
+ .enumerate()
+ .find_map(|(binding_index, binding)| {
+ binding.variants.iter().find_map(|variant| {
+ (variant
+ .phase_material_indices
+ .get(binding.initial_phase_index)
+ .copied()
+ == Some(material_index))
+ .then(|| (binding_index, variant.phase_material_indices.clone()))
})
+ })
+}
+
+fn selected_material_variant_index(
+ variant: &MaterialVariantSet,
+ phase_index: usize,
+) -> Option<usize> {
+ variant.phase_material_indices.get(phase_index).copied()
+}
+
+fn remap_material_animation_ranges(
+ bindings: &mut [MaterialAnimationBinding],
+ remap: &[usize],
+) -> Result<(), String> {
+ for binding in bindings {
+ for variant in &mut binding.variants {
+ for range_index in &mut variant.range_indices {
+ *range_index = *remap
+ .get(*range_index)
+ .ok_or_else(|| "MAT0 draw range remap is out of bounds".to_string())?;
+ }
}
- .map_err(|err| err.to_string())?;
- if let Some(err) = write_error {
- return Err(err);
- }
- std::fs::OpenOptions::new()
- .append(true)
- .open(progress_path)
- .and_then(|mut file| {
- use std::io::Write;
- writeln!(
- file,
- "Complete\telapsed_ms={}",
- progress_started.elapsed().as_millis()
+ }
+ Ok(())
+}
+
+fn phase_texture_name(phase: &MaterialPhase) -> Option<ResourceName> {
+ let length = phase
+ .texture_raw
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(phase.texture_raw.len());
+ (length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec()))
+}
+
+fn phase_uv_transforms_from_assets(
+ document: &Mat0Document,
+ assets: &MissionAssets,
+) -> Vec<[f32; 4]> {
+ document
+ .phases
+ .iter()
+ .map(|phase| {
+ phase_texture_name(phase)
+ .and_then(|name| {
+ assets
+ .textures
+ .iter()
+ .find(|texture| {
+ texture.usage == PreparedTextureUsage::Diffuse
+ && texture.source.name == name
+ })
+ .and_then(|texture| {
+ page_uv_transform(
+ texture.texm.width(),
+ texture.texm.height(),
+ &texture.texm.page_rects(),
+ i8::from_ne_bytes([phase.parameters[17]]),
+ )
+ .ok()
+ })
+ })
+ .unwrap_or([0.0, 0.0, 1.0, 1.0])
+ })
+ .collect()
+}
+
+fn phase_uv_transforms_from_standalone(
+ loader: &mut StandaloneWearMaterialLoader,
+ wear_path: &Path,
+ material_index: u16,
+ document: &Mat0Document,
+) -> Result<Vec<[f32; 4]>, String> {
+ document
+ .phases
+ .iter()
+ .enumerate()
+ .map(|(phase_index, phase)| {
+ if phase_texture_name(phase).is_none() {
+ return Ok([0.0, 0.0, 1.0, 1.0]);
+ }
+ loader
+ .load(
+ wear_path,
+ material_index,
+ u16::try_from(phase_index).unwrap_or(u16::MAX),
)
+ .map(|(_, prepared)| prepared.page_uv_transform)
+ })
+ .collect()
+}
+
+#[derive(Clone, Copy, Debug)]
+struct EnvironmentSkyLayer {
+ kind: SkyPassKind,
+ stage: Option<usize>,
+ secondary_stage: Option<usize>,
+ material_index: usize,
+ diffuse_alpha: f32,
+ static_directional_rgb: [f32; 3],
+ static_additive_rgb: [f32; 3],
+ translation_z: f32,
+ range: EnvironmentGpuRange,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct EnvironmentSpriteLayer {
+ row: usize,
+ material_index: usize,
+ diffuse_alpha: f32,
+ flare_index: Option<usize>,
+ range: EnvironmentGpuRange,
+}
+
+/// Fixed-capacity GPU allocations for the CPU-owned sky and environment
+/// output. The Vulkan renderer only receives one shared vertex update and
+/// smaller draw counts each frame; descriptor images and index allocations
+/// stay stable while the camera and weather move.
+#[derive(Clone, Debug)]
+struct EnvironmentGpuScene {
+ material_animations: Vec<MaterialAnimationBinding>,
+ sky_layers: Vec<EnvironmentSkyLayer>,
+ sprites: Vec<EnvironmentSpriteLayer>,
+ rain: EnvironmentGpuRange,
+ snow: EnvironmentGpuRange,
+ lightning: EnvironmentGpuRange,
+ lightning_material_index: usize,
+}
+
+fn world_pipeline_state(blend: LegacyBlendMode, depth: LegacyDepthMode) -> LegacyPipelineState {
+ LegacyPipelineState {
+ blend,
+ depth,
+ ..LegacyPipelineState::default()
+ }
+}
+
+fn environment_vertex(position: [f32; 3], color: [f32; 3], uv: [f32; 2]) -> VulkanStaticVertex {
+ environment_vertex_with_alpha(position, color, 1.0, uv)
+}
+
+fn environment_vertex_with_alpha(
+ position: [f32; 3],
+ color: [f32; 3],
+ alpha: f32,
+ uv: [f32; 2],
+) -> VulkanStaticVertex {
+ environment_vertex_with_alpha_normal(position, color, alpha, uv, [0.0, 0.0, 1.0])
+}
+
+fn environment_vertex_with_alpha_normal(
+ position: [f32; 3],
+ color: [f32; 3],
+ alpha: f32,
+ uv: [f32; 2],
+ normal: [f32; 3],
+) -> VulkanStaticVertex {
+ VulkanStaticVertex {
+ position,
+ color,
+ normal,
+ uv,
+ detail_uv: [0.0, 0.0],
+ overlay_alpha: alpha.clamp(0.0, 1.0),
+ }
+}
+
+fn sky_normal(raw: [i8; 3]) -> [f32; 3] {
+ // Ngi32's cloud-light decode uses the signed-byte scale 1/128 and keeps
+ // the authored vector length. Renormalizing (or using 1/127) changes the
+ // edge values and shifts the native cloud shading.
+ [
+ f32::from(raw[0]) / 128.0,
+ f32::from(raw[1]) / 128.0,
+ f32::from(raw[2]) / 128.0,
+ ]
+}
+
+fn solid_environment_texture(rgba: [u8; 4]) -> VulkanStaticTexture {
+ let mip = VulkanStaticTextureMip {
+ width: 1,
+ height: 1,
+ rgba8: rgba.to_vec(),
+ };
+ VulkanStaticTexture {
+ width: 1,
+ height: 1,
+ rgba8: mip.rgba8.clone(),
+ mip_levels: vec![mip],
+ }
+}
+
+fn vulkan_texture_from_rgba_mips(
+ mips: Vec<RgbaImage>,
+ label: &str,
+) -> Result<VulkanStaticTexture, String> {
+ let first = mips
+ .first()
+ .ok_or_else(|| format!("{label} has no mip zero"))?;
+ Ok(VulkanStaticTexture {
+ width: first.width,
+ height: first.height,
+ rgba8: first.rgba8.clone(),
+ mip_levels: mips
+ .into_iter()
+ .map(|mip| VulkanStaticTextureMip {
+ width: mip.width,
+ height: mip.height,
+ rgba8: mip.rgba8,
+ })
+ .collect(),
+ })
+}
+
+fn append_environment_material(
+ materials: &mut Vec<VulkanStaticMaterial>,
+ material_names: &mut HashMap<String, usize>,
+ material_assets: &HashMap<String, EnvironmentMaterialAsset>,
+ material_animations: &mut Vec<MaterialAnimationBinding>,
+ name: &str,
+ fallback_row: usize,
+) -> Result<usize, String> {
+ if let Some(index) = material_names.get(name).copied() {
+ return Ok(index);
+ }
+ let asset = material_assets.get(name).ok_or_else(|| {
+ format!("environment material {name:?} is missing from resolved material.lib/MAT0 assets")
+ })?;
+ let phases = if asset.phases.is_empty() {
+ vec![asset.phase]
+ } else {
+ asset.phases.clone()
+ };
+ let first_material_index = materials.len();
+ let mut phase_material_indices = Vec::with_capacity(phases.len());
+ for (phase_index, phase) in phases.iter().enumerate() {
+ let index = materials.len();
+ let material_index = u16::try_from(index).map_err(|_| {
+ "environment exceeds the available 16-bit material selector space".to_string()
+ })?;
+ materials.push(VulkanStaticMaterial {
+ material_index,
+ texture: asset
+ .phase_textures
+ .get(phase_index)
+ .cloned()
+ .unwrap_or_else(|| asset.texture.clone()),
+ directional_rgb: phase.directional_rgb,
+ additive_rgb: phase.additive_rgb,
+ uv_transform: phase.page_uv_transform,
+ detail_texture: Some(solid_environment_texture([128, 128, 128, 255])),
+ overlay_texture: None,
+ overlay_detail_texture: None,
+ diffuse_alpha: phase.diffuse_alpha.clamp(0.0, 1.0),
+ overlay_diffuse_alpha: None,
+ overlay_directional_rgb: None,
+ overlay_additive_rgb: None,
+ specular_rgb: phase.specular_rgb,
+ specular_power: phase.power,
+ overlay_specular_rgb: None,
+ overlay_specular_power: None,
+ detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
+ overlay_uv_transform: None,
+ overlay_detail_uv_transform: None,
+ // Native cloud draws carry shade/fog flags. The remaining sky rows
+ // are unlit overlays; all rows still retain their decoded normals.
+ unlit: fallback_row != 2,
+ sky_nebula_stars: false,
+ lightmap_mode: false,
+ sky_far_depth: matches!(fallback_row, 0..=2),
+ });
+ phase_material_indices.push(index);
+ }
+ let document = asset.document.clone();
+ material_animations.push(MaterialAnimationBinding {
+ initial_phase_index: 0,
+ variants: vec![MaterialVariantSet {
+ phase_material_indices,
+ range_indices: Vec::new(),
+ }],
+ base: MaterialPhaseBinding {
+ document,
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: first_material_index as u32,
+ frozen_phase_index: None,
+ phase_uv_transforms: phases.iter().map(|phase| phase.page_uv_transform).collect(),
+ },
+ detail: None,
+ overlay: None,
+ overlay_detail: None,
+ stage_textures: None,
+ });
+ material_names.insert(name.to_owned(), first_material_index);
+ Ok(first_material_index)
+}
+
+fn append_environment_gradient_material(
+ materials: &mut Vec<VulkanStaticMaterial>,
+) -> Result<usize, String> {
+ let index = materials.len();
+ let material_index = u16::try_from(index).map_err(|_| {
+ "environment exceeds the available 16-bit material selector space".to_string()
+ })?;
+ materials.push(VulkanStaticMaterial {
+ material_index,
+ texture: solid_environment_texture([255, 255, 255, 255]),
+ directional_rgb: [1.0; 3],
+ additive_rgb: [0.0; 3],
+ uv_transform: [0.0, 0.0, 1.0, 1.0],
+ detail_texture: Some(solid_environment_texture([128, 128, 128, 255])),
+ overlay_texture: None,
+ overlay_detail_texture: None,
+ diffuse_alpha: 1.0,
+ overlay_diffuse_alpha: None,
+ overlay_directional_rgb: None,
+ overlay_additive_rgb: None,
+ specular_rgb: [0.0; 3],
+ specular_power: 0,
+ overlay_specular_rgb: None,
+ overlay_specular_power: None,
+ detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
+ overlay_uv_transform: None,
+ overlay_detail_uv_transform: None,
+ unlit: true,
+ sky_nebula_stars: false,
+ lightmap_mode: false,
+ sky_far_depth: true,
+ });
+ Ok(index)
+}
+
+fn shadow_atlas_texture() -> VulkanStaticTexture {
+ let rgba8 = vec![0_u8; 256 * 256 * 4];
+ VulkanStaticTexture {
+ width: 256,
+ height: 256,
+ rgba8: rgba8.clone(),
+ mip_levels: vec![VulkanStaticTextureMip {
+ width: 256,
+ height: 256,
+ rgba8,
+ }],
+ }
+}
+
+fn append_shadow_material(materials: &mut Vec<VulkanStaticMaterial>) -> Result<usize, String> {
+ let material_index = u16::try_from(materials.len()).map_err(|_| {
+ "shadow material selector exceeds the available 16-bit material selector space".to_string()
+ })?;
+ let index = materials.len();
+ materials.push(VulkanStaticMaterial {
+ material_index,
+ texture: shadow_atlas_texture(),
+ directional_rgb: [1.0; 3],
+ additive_rgb: [0.0; 3],
+ uv_transform: [0.0, 0.0, 1.0, 1.0],
+ detail_texture: Some(solid_environment_texture([255, 255, 255, 255])),
+ overlay_texture: None,
+ overlay_detail_texture: None,
+ diffuse_alpha: 1.0,
+ overlay_diffuse_alpha: None,
+ overlay_directional_rgb: None,
+ overlay_additive_rgb: None,
+ specular_rgb: [0.0; 3],
+ specular_power: 0,
+ overlay_specular_rgb: None,
+ overlay_specular_power: None,
+ detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
+ overlay_uv_transform: None,
+ overlay_detail_uv_transform: None,
+ // Combiner mode 1 keeps the atlas alpha and the projected receiver's
+ // per-vertex alpha while bypassing world lighting/fog.
+ unlit: true,
+ sky_nebula_stars: false,
+ lightmap_mode: false,
+ sky_far_depth: false,
+ });
+ Ok(index)
+}
+
+fn shadow_placeholder_vertices() -> Vec<VulkanStaticVertex> {
+ [
+ environment_vertex_with_alpha([0.0, 0.0, 0.0], [1.0; 3], 0.0, [0.0, 0.0]),
+ environment_vertex_with_alpha([1.0, 0.0, 0.0], [1.0; 3], 0.0, [1.0, 0.0]),
+ environment_vertex_with_alpha([0.0, 1.0, 0.0], [1.0; 3], 0.0, [0.0, 1.0]),
+ environment_vertex_with_alpha([1.0, 1.0, 0.0], [1.0; 3], 0.0, [1.0, 1.0]),
+ ]
+ .to_vec()
+}
+
+fn shadow_triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle {
+ let normal = normalize3(cross3(
+ sub3(positions[1], positions[0]),
+ sub3(positions[2], positions[0]),
+ ))
+ .unwrap_or([0.0, 0.0, 1.0]);
+ ShadowTriangle {
+ positions,
+ normal,
+ mask,
+ }
+}
+
+fn terrain_shadow_receivers(
+ terrain: &fparkan_terrain_format::LandMeshDocument,
+) -> Result<Vec<ShadowTriangle>, String> {
+ terrain
+ .faces
+ .iter()
+ .map(|face| {
+ let positions = face.vertices.map(|index| {
+ let source = terrain
+ .positions
+ .get(usize::from(index))
+ .copied()
+ .unwrap_or([0.0; 3]);
+ [source[0], source[1], source[2] / 32.0]
+ });
+ if !positions.iter().flatten().all(|value| value.is_finite()) {
+ return Err("terrain shadow receiver contains a non-finite position".to_string());
+ }
+ Ok(shadow_triangle(positions, face.flags.0))
+ })
+ .collect()
+}
+
+fn static_mesh_shadow_triangles(mesh: &VulkanStaticMesh) -> Vec<ShadowTriangle> {
+ mesh.indices
+ .chunks_exact(3)
+ .filter_map(|triangle| {
+ let positions = [
+ mesh.vertices
+ .get(usize::try_from(triangle[0]).ok()?)?
+ .position,
+ mesh.vertices
+ .get(usize::try_from(triangle[1]).ok()?)?
+ .position,
+ mesh.vertices
+ .get(usize::try_from(triangle[2]).ok()?)?
+ .position,
+ ];
+ positions
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ .then_some(shadow_triangle(positions, 0))
+ })
+ .collect()
+}
+
+#[derive(Clone, Copy, Debug)]
+struct ShadowComponentSphere {
+ center: [f32; 3],
+ radius: f32,
+}
+
+fn mounted_shadow_sphere(
+ center: [f32; 3],
+ radius: f32,
+ mount_pose: Option<VulkanNodePose>,
+ node1_pose: Option<VulkanNodePose>,
+) -> Option<ShadowComponentSphere> {
+ if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius <= 0.0 {
+ return None;
+ }
+ let center = mount_pose.map_or(center, |parent_pose| {
+ // The native mounted-unit sphere query keeps node 1's accumulated
+ // rotation but replaces its translation with the accumulated parent
+ // translation. This is deliberately different from the render mount
+ // composition, which also carries node 1's translation.
+ let rotation = node1_pose.map_or(parent_pose.rotation, |node_pose| {
+ multiply_unit_quaternions(parent_pose.rotation, node_pose.rotation)
+ });
+ add3(
+ parent_pose.translation,
+ rotate_unit_vector(center, rotation),
+ )
+ });
+ center
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(ShadowComponentSphere { center, radius })
+}
+
+fn aggregate_shadow_caster(
+ id: u32,
+ native_kind: u32,
+ transform: LegacyIron3dEulerTransform,
+ scale: [f32; 3],
+ triangles: Vec<ShadowTriangle>,
+ component_spheres: &[ShadowComponentSphere],
+) -> Option<ShadowCaster> {
+ if triangles.is_empty() {
+ return None;
+ }
+ let weight = component_spheres
+ .iter()
+ .map(|sphere| sphere.radius)
+ .sum::<f32>();
+ if !weight.is_finite() || weight <= f32::EPSILON {
+ return None;
+ }
+ // Native 9510 aggregates visible root/EXTO spheres by radius-weighted
+ // center, then encloses every component sphere around that center.
+ let aggregate_center = component_spheres.iter().fold([0.0; 3], |sum, sphere| {
+ add3(sum, scale3(sphere.center, sphere.radius))
+ });
+ let aggregate_center = scale3(aggregate_center, weight.recip());
+ let radius = component_spheres
+ .iter()
+ .map(|sphere| length3(sub3(sphere.center, aggregate_center)).unwrap_or(0.0) + sphere.radius)
+ .fold(0.0, f32::max);
+ let max_scale = scale.iter().copied().fold(f32::NEG_INFINITY, f32::max);
+ if !aggregate_center.iter().all(|value| value.is_finite())
+ || !radius.is_finite()
+ || radius <= f32::EPSILON
+ || !max_scale.is_finite()
+ || max_scale <= 0.0
+ {
+ return None;
+ }
+ // The native scale flag applies to the aggregate exactly once. The
+ // mission Euler transform then rotates/translates that scaled sphere.
+ let scaled_center = [
+ aggregate_center[0] * scale[0],
+ aggregate_center[1] * scale[1],
+ aggregate_center[2] * scale[2],
+ ];
+ let center = transform.try_transform_scaled_point(scaled_center, [1.0; 3])?;
+ let radius = radius * max_scale;
+ if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() {
+ return None;
+ }
+ let world_axes = transform
+ .try_row_major()
+ .map(|matrix| {
+ [
+ [matrix[0], matrix[4], matrix[8]],
+ [matrix[1], matrix[5], matrix[9]],
+ [matrix[2], matrix[6], matrix[10]],
+ ]
+ })
+ .unwrap_or([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]);
+ Some(ShadowCaster {
+ id,
+ world_axes,
+ sphere: ShadowSphere { center, radius },
+ native_kind,
+ triangles,
+ })
+}
+
+fn shadow_lights(
+ sky_frame: Option<&SkyFrame<'_>>,
+ environment_frame: Option<&EnvironmentFrame>,
+ renderer: &VulkanSmokeRenderer,
+) -> Vec<ShadowLight> {
+ let mut lights = Vec::new();
+ if let Some(sky_frame) = sky_frame {
+ lights.extend(sky_frame.directional_lights.into_iter().enumerate().map(
+ |(index, light)| {
+ ShadowLight::Directional {
+ direction: light.direction,
+ rgb: light.color,
+ active: light.active,
+ // CSun's constructor marks both the sun (slot 0) and
+ // moon (slot 2) as primary. ShadowReference selects the
+ // first one for kind 3 and accumulates both active
+ // primary directions for ordinary objects (kind 4).
+ flags: (index == 0 || index == 2)
+ .then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG)
+ .unwrap_or(0),
+ }
+ },
+ ));
+ } else {
+ let uniforms = renderer.frame_uniforms();
+ lights.extend(
+ uniforms
+ .directional_lights
+ .into_iter()
+ .enumerate()
+ .map(|(index, light)| ShadowLight::Directional {
+ direction: light.direction,
+ rgb: light.rgb,
+ active: light.active,
+ flags: (index == 0 || index == 2)
+ .then_some(SHADOW_PRIMARY_DIRECTIONAL_FLAG)
+ .unwrap_or(0),
+ }),
+ );
+ }
+ if let Some(environment_frame) = environment_frame {
+ lights.extend(
+ environment_frame
+ .point_lights
+ .iter()
+ .map(|light| ShadowLight::Point {
+ position: light.position,
+ rgb: [light.color[0], light.color[1], light.color[2]],
+ range: light.range,
+ coefficients: light.attenuation,
+ active: light.active,
+ flags: 0,
+ }),
+ );
+ } else {
+ let point = renderer.frame_uniforms().point_light;
+ lights.push(ShadowLight::Point {
+ position: point.position,
+ rgb: point.rgb,
+ range: point.range,
+ coefficients: point.coefficients,
+ active: point.active,
+ flags: 0,
+ });
+ }
+ lights
+}
+
+/// Native `Terrain30737` expands each caster sphere by 100 world units before
+/// testing it against the six planes in the current world frustum. Filtering
+/// here matters because `CShade` only accepts its first twenty candidates;
+/// off-screen objects must not consume that budget.
+const SHADOW_FRUSTUM_RADIUS_MARGIN: f32 = 100.0;
+
+fn native_shadow_caster_visible(caster: &ShadowCaster, clip_from_world: [f32; 16]) -> bool {
+ let radius = caster.sphere.radius + SHADOW_FRUSTUM_RADIUS_MARGIN;
+ if !radius.is_finite() || radius < 0.0 || !caster.sphere.center.iter().all(|v| v.is_finite()) {
+ return false;
+ }
+
+ // The camera matrix is row-major D3D-style storage. A world point is
+ // transformed by p * M, so each clip coordinate uses one matrix column.
+ // The six Vulkan clip half-spaces are x+w, -x+w, y+w, -y+w, z, and w-z.
+ let planes = [
+ [
+ clip_from_world[0] + clip_from_world[3],
+ clip_from_world[4] + clip_from_world[7],
+ clip_from_world[8] + clip_from_world[11],
+ clip_from_world[12] + clip_from_world[15],
+ ],
+ [
+ -clip_from_world[0] + clip_from_world[3],
+ -clip_from_world[4] + clip_from_world[7],
+ -clip_from_world[8] + clip_from_world[11],
+ -clip_from_world[12] + clip_from_world[15],
+ ],
+ [
+ clip_from_world[1] + clip_from_world[3],
+ clip_from_world[5] + clip_from_world[7],
+ clip_from_world[9] + clip_from_world[11],
+ clip_from_world[13] + clip_from_world[15],
+ ],
+ [
+ -clip_from_world[1] + clip_from_world[3],
+ -clip_from_world[5] + clip_from_world[7],
+ -clip_from_world[9] + clip_from_world[11],
+ -clip_from_world[13] + clip_from_world[15],
+ ],
+ [
+ clip_from_world[2],
+ clip_from_world[6],
+ clip_from_world[10],
+ clip_from_world[14],
+ ],
+ [
+ clip_from_world[3] - clip_from_world[2],
+ clip_from_world[7] - clip_from_world[6],
+ clip_from_world[11] - clip_from_world[10],
+ clip_from_world[15] - clip_from_world[14],
+ ],
+ ];
+ planes.into_iter().all(|plane| {
+ let normal_length =
+ (plane[0].mul_add(plane[0], plane[1].mul_add(plane[1], plane[2] * plane[2]))).sqrt();
+ if !normal_length.is_finite() || normal_length <= f32::EPSILON {
+ return false;
+ }
+ let distance = plane[0].mul_add(
+ caster.sphere.center[0],
+ plane[1].mul_add(
+ caster.sphere.center[1],
+ plane[2].mul_add(caster.sphere.center[2], plane[3]),
+ ),
+ ) / normal_length;
+ distance.is_finite() && distance > -radius
+ })
+}
+
+fn native_visible_shadow_casters(
+ casters: &[ShadowCaster],
+ clip_from_world: [f32; 16],
+) -> Vec<ShadowCaster> {
+ casters
+ .iter()
+ .filter(|caster| native_shadow_caster_visible(caster, clip_from_world))
+ .cloned()
+ .collect()
+}
+
+fn append_shadow_frame_geometry(
+ mesh: &mut VulkanStaticMesh,
+ shadow_range: EnvironmentGpuRange,
+ shadow_base_vertex: usize,
+ shadow_base_index: usize,
+ frame: &ShadowFrame,
+) -> Result<VulkanDynamicDrawRange, String> {
+ if shadow_base_vertex > mesh.vertices.len() || shadow_base_index > mesh.indices.len() {
+ return Err("shadow dynamic base is outside the shared mesh".to_string());
+ }
+ mesh.vertices.truncate(shadow_base_vertex);
+ mesh.indices.truncate(shadow_base_index);
+ let vertex_base = u32::try_from(mesh.vertices.len())
+ .map_err(|_| "shadow vertex base exceeds u32".to_string())?;
+ let first_index = u32::try_from(mesh.indices.len())
+ .map_err(|_| "shadow index base exceeds u32".to_string())?;
+ if frame.indices.iter().any(|&index| {
+ usize::try_from(index)
+ .ok()
+ .is_none_or(|index| index >= frame.vertices.len())
+ }) {
+ return Err("shadow frame index exceeds its vertex stream".to_string());
+ }
+ mesh.vertices.extend(frame.vertices.iter().map(|vertex| {
+ environment_vertex_with_alpha_normal(
+ vertex.position,
+ [1.0; 3],
+ vertex.alpha.clamp(0.0, 1.0),
+ vertex.uv,
+ vertex.normal,
+ )
+ }));
+ mesh.indices.extend(
+ frame
+ .indices
+ .iter()
+ .map(|&index| {
+ vertex_base
+ .checked_add(index)
+ .ok_or_else(|| "shadow index exceeds u32".to_string())
})
- .map_err(|err| format!("{}: {err}", progress_path.display()))?;
- return Ok(loaded);
+ .collect::<Result<Vec<_>, _>>()?,
+ );
+ let index_count = u32::try_from(frame.indices.len())
+ .map_err(|_| "shadow index count exceeds u32".to_string())?;
+ if let Some(range) = mesh.draw_ranges.get_mut(shadow_range.range_index) {
+ range.first_index = first_index;
+ range.index_count = index_count;
+ }
+ Ok(VulkanDynamicDrawRange {
+ range_index: shadow_range.range_index,
+ first_index,
+ index_count,
+ })
+}
+
+fn shadow_atlas_texture_from_frame(frame: &ShadowFrame) -> VulkanStaticTexture {
+ VulkanStaticTexture {
+ width: 256,
+ height: 256,
+ rgba8: frame.atlas_rgba8.clone(),
+ mip_levels: vec![VulkanStaticTextureMip {
+ width: 256,
+ height: 256,
+ rgba8: frame.atlas_rgba8.clone(),
+ }],
+ }
+}
+
+fn append_environment_range(
+ mesh: &mut VulkanStaticMesh,
+ material_index: usize,
+ vertices: Vec<VulkanStaticVertex>,
+ local_indices: Vec<u32>,
+ pipeline_state: LegacyPipelineState,
+) -> Result<EnvironmentGpuRange, String> {
+ if vertices.is_empty() || local_indices.is_empty() || !local_indices.len().is_multiple_of(3) {
+ return Err("environment geometry range is empty or not triangle-aligned".to_string());
+ }
+ let vertex_start = mesh.vertices.len();
+ let first_index = mesh.indices.len();
+ let material_index = u16::try_from(material_index)
+ .map_err(|_| "environment material selector exceeds u16".to_string())?;
+ let vertex_start_u32 = u32::try_from(vertex_start)
+ .map_err(|_| "environment vertex allocation exceeds u32".to_string())?;
+ for index in local_indices {
+ mesh.indices.push(
+ vertex_start_u32
+ .checked_add(index)
+ .ok_or_else(|| "environment index exceeds u32".to_string())?,
+ );
+ }
+ let index_count = u32::try_from(mesh.indices.len() - first_index)
+ .map_err(|_| "environment index allocation exceeds u32".to_string())?;
+ let vertex_capacity = vertices.len();
+ mesh.vertices.extend(vertices);
+ let first_index = u32::try_from(first_index)
+ .map_err(|_| "environment first index exceeds u32".to_string())?;
+ let range_index = mesh.draw_ranges.len();
+ mesh.draw_ranges
+ .push(fparkan_render_vulkan::VulkanStaticDrawRange {
+ first_index,
+ index_count,
+ material_index,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state,
+ alpha_test_reference: 0,
+ });
+ Ok(EnvironmentGpuRange {
+ range_index,
+ vertex_start,
+ vertex_capacity,
+ material_index: usize::from(material_index),
+ })
+}
+
+/// Reorders indexed draw segments without breaking the static mesh contract.
+///
+/// Draw ranges are required to cover one contiguous index buffer. Changing
+/// only their vector order therefore is insufficient when a background pass
+/// must run before mission geometry: the corresponding index segments have to
+/// move with them and every range receives a new `first_index`.
+fn reorder_draw_ranges(
+ mesh: &mut VulkanStaticMesh,
+ old_order: &[usize],
+) -> Result<Vec<usize>, String> {
+ if old_order.len() != mesh.draw_ranges.len() {
+ return Err("draw-range reorder does not cover the whole mesh".to_string());
+ }
+ let mut seen = vec![false; mesh.draw_ranges.len()];
+ let old_ranges = mesh.draw_ranges.clone();
+ let old_indices = mesh.indices.clone();
+ let mut indices = Vec::with_capacity(old_indices.len());
+ let mut ranges = Vec::with_capacity(old_ranges.len());
+ let mut remap = vec![usize::MAX; old_ranges.len()];
+ for (new_index, &old_index) in old_order.iter().enumerate() {
+ if old_index >= old_ranges.len() || seen[old_index] {
+ return Err("draw-range reorder contains a duplicate or invalid range".to_string());
+ }
+ seen[old_index] = true;
+ let old_range = old_ranges[old_index];
+ let first = usize::try_from(old_range.first_index)
+ .map_err(|_| "draw-range first index exceeds addressable memory".to_string())?;
+ let count = usize::try_from(old_range.index_count)
+ .map_err(|_| "draw-range index count exceeds addressable memory".to_string())?;
+ let end = first
+ .checked_add(count)
+ .ok_or_else(|| "draw-range index segment overflows address space".to_string())?;
+ let segment = old_indices
+ .get(first..end)
+ .ok_or_else(|| "draw-range index segment is outside the mesh".to_string())?;
+ let first_index = u32::try_from(indices.len())
+ .map_err(|_| "reordered index buffer exceeds u32".to_string())?;
+ indices.extend_from_slice(segment);
+ let mut range = old_range;
+ range.first_index = first_index;
+ ranges.push(range);
+ remap[old_index] = new_index;
+ }
+ if seen.iter().any(|seen| !seen) || indices.len() != old_indices.len() {
+ return Err("draw-range reorder does not cover the whole index buffer".to_string());
}
- if args.backend == RenderBackendMode::StaticVulkan {
- if args.preview_roots.get() == 1 {
- load_mission_static_preview(engine, request)
+ mesh.indices = indices;
+ mesh.draw_ranges = ranges;
+ Ok(remap)
+}
+
+fn quad_indices(slot_count: usize) -> Result<Vec<u32>, String> {
+ let mut indices = Vec::with_capacity(
+ slot_count
+ .checked_mul(6)
+ .ok_or_else(|| "environment quad index count overflow".to_string())?,
+ );
+ for slot in 0..slot_count {
+ let base = u32::try_from(
+ slot.checked_mul(4)
+ .ok_or_else(|| "environment quad vertex count overflow".to_string())?,
+ )
+ .map_err(|_| "environment quad vertex count exceeds u32".to_string())?;
+ indices.extend_from_slice(&[base, base + 1, base + 2, base + 2, base + 1, base + 3]);
+ }
+ Ok(indices)
+}
+
+fn placeholder_quad_vertices(
+ camera: &FreeFlightCamera,
+ slot_count: usize,
+ size: f32,
+) -> Vec<VulkanStaticVertex> {
+ let center = add3(
+ camera.position,
+ scale3(camera.forward(), camera.far_plane * 0.5),
+ );
+ let right = scale3(camera.right(), size);
+ let up = scale3(camera.up(), size);
+ (0..slot_count)
+ .flat_map(|_| {
+ [
+ environment_vertex(sub3(sub3(center, right), up), [1.0; 3], [0.0, 1.0]),
+ environment_vertex(add3(sub3(center, up), right), [1.0; 3], [1.0, 1.0]),
+ environment_vertex(add3(sub3(center, right), up), [1.0; 3], [0.0, 0.0]),
+ environment_vertex(add3(add3(center, right), up), [1.0; 3], [1.0, 0.0]),
+ ]
+ })
+ .collect()
+}
+
+fn screen_gradient_quad_vertices(
+ camera: &FreeFlightCamera,
+ viewport: [f32; 2],
+ color: [f32; 3],
+) -> [VulkanStaticVertex; 4] {
+ let width = viewport[0].max(1.0);
+ let height = viewport[1].max(1.0);
+ let aspect = width / height;
+ let fov = camera.vertical_fov.clamp(0.05, 3.0);
+ // The quad is placed close to the camera and drawn with depth disabled.
+ // It therefore covers the complete projected viewport while the later
+ // dome, terrain, and object ranges provide their own depth-tested detail.
+ let distance = 1.0_f32;
+ let half_height = distance * (fov * 0.5).tan() * 1.05;
+ let half_width = half_height * aspect;
+ let center = add3(camera.position, scale3(camera.forward(), distance));
+ let right = scale3(camera.right(), half_width);
+ let up = scale3(camera.up(), half_height);
+ [
+ environment_vertex(sub3(sub3(center, right), up), color, [0.0, 1.0]),
+ environment_vertex(add3(sub3(center, up), right), color, [1.0, 1.0]),
+ environment_vertex(add3(sub3(center, right), up), color, [0.0, 0.0]),
+ environment_vertex(add3(add3(center, right), up), color, [1.0, 0.0]),
+ ]
+}
+
+impl EnvironmentGpuScene {
+ fn fixed_range_indices(&self) -> HashSet<usize> {
+ self.sky_layers
+ .iter()
+ .map(|layer| layer.range.range_index)
+ .chain(self.sprites.iter().map(|sprite| sprite.range.range_index))
+ .chain([
+ self.rain.range_index,
+ self.snow.range_index,
+ self.lightning.range_index,
+ ])
+ .collect()
+ }
+
+ fn new(
+ mesh: &mut VulkanStaticMesh,
+ materials: &mut Vec<VulkanStaticMaterial>,
+ preview_material_animations: &mut Vec<MaterialAnimationBinding>,
+ sun_occlusion_range_indices: &mut Vec<usize>,
+ environment_materials: &SkyMaterials,
+ material_assets: &HashMap<String, EnvironmentMaterialAsset>,
+ sky_mesh: &SkyMesh,
+ camera: &FreeFlightCamera,
+ shadow_range_index: &mut usize,
+ ) -> Result<Self, String> {
+ let mut names = HashMap::new();
+ let mut material_animations = Vec::new();
+ let mut material_indices = [None; 9];
+ let fallback_names = SkyMaterials::default();
+ for row in 0..9 {
+ let name = environment_materials
+ .material_name(row)
+ .or_else(|| fallback_names.material_name(row))
+ .unwrap_or("ENVIRONMENT");
+ let index = append_environment_material(
+ materials,
+ &mut names,
+ material_assets,
+ &mut material_animations,
+ name,
+ row,
+ )?;
+ material_indices[row] = Some(index);
+ }
+ let gradient_material_index = append_environment_gradient_material(materials)?;
+ let nebula_material_index = material_indices[0]
+ .ok_or_else(|| "nebula material selector is unavailable".to_string())?;
+ let stars_material_index = material_indices[1]
+ .ok_or_else(|| "stars material selector is unavailable".to_string())?;
+ // Native sky mode 4 uses one descriptor set: TEX0 is the nebula and
+ // TEX1 is the independently animated stars texture whose alpha chooses
+ // the contribution. Keep the stars MAT0 binding as the nebula binding's
+ // detail stage; do not form a Cartesian product of the two phase rows.
+ let (nebula_binding_index, nebula_phase_materials) =
+ find_material_animation_variant(&material_animations, nebula_material_index)
+ .ok_or_else(|| {
+ format!("MAT0 sky material {nebula_material_index} has no phase variants")
+ })?;
+ let (stars_binding_index, stars_phase_materials) =
+ find_material_animation_variant(&material_animations, stars_material_index)
+ .ok_or_else(|| {
+ format!("MAT0 sky material {stars_material_index} has no phase variants")
+ })?;
+ if stars_phase_materials.is_empty() {
+ return Err("stars material has no phase variants".to_string());
+ }
+ let stars_descriptors = stars_phase_materials
+ .iter()
+ .map(|&material_index| {
+ let material = materials.get(material_index).ok_or_else(|| {
+ format!("stars material descriptor {material_index} is unavailable")
+ })?;
+ Ok((material.texture.clone(), material.uv_transform))
+ })
+ .collect::<Result<Vec<_>, String>>()?;
+ let stars_binding = material_animations
+ .get(stars_binding_index)
+ .ok_or_else(|| "stars material animation binding is unavailable".to_string())?
+ .base
+ .clone();
+ let stars_stage_textures = stars_descriptors
+ .iter()
+ .map(|(texture, _)| texture.clone())
+ .collect();
+ let stars_initial_uv = stars_descriptors
+ .first()
+ .map(|(_, uv_transform)| *uv_transform)
+ .unwrap_or([0.0, 0.0, 1.0, 1.0]);
+ for &material_index in &nebula_phase_materials {
+ let stars_texture = stars_descriptors
+ .first()
+ .map(|(texture, _)| texture.clone())
+ .ok_or_else(|| "stars material descriptor is unavailable".to_string())?;
+ let nebula = materials.get_mut(material_index).ok_or_else(|| {
+ format!("nebula material descriptor {material_index} is unavailable")
+ })?;
+ nebula.detail_texture = Some(stars_texture);
+ nebula.detail_uv_transform = stars_initial_uv;
+ nebula.sky_nebula_stars = true;
+ }
+ let nebula_binding = material_animations
+ .get_mut(nebula_binding_index)
+ .ok_or_else(|| "nebula material animation binding is unavailable".to_string())?;
+ nebula_binding.detail = Some(stars_binding);
+ nebula_binding.stage_textures = Some(MaterialStageTextures {
+ detail: Some(stars_stage_textures),
+ overlay: None,
+ overlay_detail: None,
+ // Force TEX1 to the independently sampled stars phase on the first
+ // frame, including when the initial star descriptor is already set.
+ applied_material_phases: HashMap::new(),
+ });
+ let lightning_material_index = append_environment_material(
+ materials,
+ &mut names,
+ material_assets,
+ &mut material_animations,
+ "env_lightning",
+ 3,
+ )?;
+
+ let mut scene = Self {
+ material_animations,
+ sky_layers: Vec::with_capacity(4),
+ sprites: Vec::with_capacity(ENVIRONMENT_SPRITE_ROWS.len() + ENVIRONMENT_FLARE_SLOTS),
+ rain: EnvironmentGpuRange {
+ range_index: 0,
+ vertex_start: 0,
+ vertex_capacity: 0,
+ material_index: material_indices[8].unwrap_or(0),
+ },
+ snow: EnvironmentGpuRange {
+ range_index: 0,
+ vertex_start: 0,
+ vertex_capacity: 0,
+ material_index: material_indices[7].unwrap_or(0),
+ },
+ lightning: EnvironmentGpuRange {
+ range_index: 0,
+ vertex_start: 0,
+ vertex_capacity: 0,
+ material_index: lightning_material_index,
+ },
+ lightning_material_index,
+ };
+
+ let screen_range = append_environment_range(
+ mesh,
+ gradient_material_index,
+ screen_gradient_quad_vertices(camera, [16.0, 9.0], [1.0; 3]).to_vec(),
+ quad_indices(1)?,
+ world_pipeline_state(LegacyBlendMode::Opaque, LegacyDepthMode::Disabled),
+ )?;
+ scene.sky_layers.push(EnvironmentSkyLayer {
+ kind: SkyPassKind::ScreenGradient,
+ stage: None,
+ secondary_stage: None,
+ material_index: gradient_material_index,
+ diffuse_alpha: 1.0,
+ static_directional_rgb: [1.0; 3],
+ static_additive_rgb: [0.0; 3],
+ translation_z: 0.0,
+ range: screen_range,
+ });
+
+ let nebula_material = materials
+ .get(nebula_material_index)
+ .ok_or_else(|| "nebula material descriptor is unavailable".to_string())?;
+ let nebula_vertices = sky_mesh
+ .vertices
+ .iter()
+ .map(|vertex| {
+ let color = vertex.color_rgba();
+ environment_vertex_with_alpha_normal(
+ add3(vertex.position, camera.position),
+ [color[0], color[1], color[2]],
+ color[3],
+ vertex.uv[0],
+ sky_normal(vertex.normal),
+ )
+ })
+ .collect();
+ let nebula_range = append_environment_range(
+ mesh,
+ nebula_material_index,
+ nebula_vertices,
+ sky_mesh.indices.clone(),
+ world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
+ )?;
+ register_material_range(
+ &mut scene.material_animations,
+ nebula_material_index,
+ nebula_range.range_index,
+ );
+ scene.sky_layers.push(EnvironmentSkyLayer {
+ kind: SkyPassKind::NebulaStars,
+ stage: Some(0),
+ secondary_stage: Some(1),
+ material_index: nebula_material_index,
+ diffuse_alpha: nebula_material.diffuse_alpha,
+ static_directional_rgb: nebula_material.directional_rgb,
+ static_additive_rgb: nebula_material.additive_rgb,
+ translation_z: 0.0,
+ range: nebula_range,
+ });
+
+ let dome_vertices = sky_mesh
+ .vertices
+ .iter()
+ .map(|vertex| {
+ let color = vertex.color_rgba();
+ environment_vertex_with_alpha_normal(
+ add3(vertex.position, camera.position),
+ [color[0], color[1], color[2]],
+ color[3],
+ [0.0, 0.0],
+ sky_normal(vertex.normal),
+ )
+ })
+ .collect();
+ let dome_range = append_environment_range(
+ mesh,
+ gradient_material_index,
+ dome_vertices,
+ sky_mesh.indices.clone(),
+ world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
+ )?;
+ scene.sky_layers.push(EnvironmentSkyLayer {
+ kind: SkyPassKind::DomeGradient,
+ stage: None,
+ secondary_stage: None,
+ material_index: gradient_material_index,
+ diffuse_alpha: 1.0,
+ static_directional_rgb: [1.0; 3],
+ static_additive_rgb: [0.0; 3],
+ translation_z: 0.0,
+ range: dome_range,
+ });
+
+ let clouds_material_index = material_indices[2]
+ .ok_or_else(|| "cloud material selector is unavailable".to_string())?;
+ let clouds_material = materials
+ .get(clouds_material_index)
+ .ok_or_else(|| "cloud material descriptor is unavailable".to_string())?;
+ let clouds_vertices = sky_mesh
+ .vertices
+ .iter()
+ .map(|vertex| {
+ let color = vertex.color_rgba();
+ environment_vertex_with_alpha_normal(
+ add3(add3(vertex.position, camera.position), [0.0, 0.0, -5000.0]),
+ [color[0], color[1], color[2]],
+ color[3],
+ vertex.uv[2],
+ sky_normal(vertex.normal),
+ )
+ })
+ .collect();
+ let clouds_range = append_environment_range(
+ mesh,
+ clouds_material_index,
+ clouds_vertices,
+ sky_mesh.indices.clone(),
+ world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
+ )?;
+ register_material_range(
+ &mut scene.material_animations,
+ clouds_material_index,
+ clouds_range.range_index,
+ );
+ scene.sky_layers.push(EnvironmentSkyLayer {
+ kind: SkyPassKind::Clouds,
+ stage: Some(2),
+ secondary_stage: None,
+ material_index: clouds_material_index,
+ diffuse_alpha: clouds_material.diffuse_alpha,
+ static_directional_rgb: clouds_material.directional_rgb,
+ static_additive_rgb: clouds_material.additive_rgb,
+ translation_z: -5000.0,
+ range: clouds_range,
+ });
+
+ let sprite_size = (camera.far_plane * 0.01).max(1.0);
+ for row in ENVIRONMENT_SPRITE_ROWS {
+ let material_index = material_indices[row]
+ .ok_or_else(|| format!("sky sprite row {row} has no material selector"))?;
+ let range = append_environment_range(
+ mesh,
+ material_index,
+ placeholder_quad_vertices(camera, 1, sprite_size),
+ quad_indices(1)?,
+ world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
+ )?;
+ register_material_range(
+ &mut scene.material_animations,
+ material_index,
+ range.range_index,
+ );
+ scene.sprites.push(EnvironmentSpriteLayer {
+ row,
+ material_index,
+ diffuse_alpha: materials
+ .get(material_index)
+ .map_or(1.0, |material| material.diffuse_alpha),
+ flare_index: None,
+ range,
+ });
+ }
+
+ for flare_index in 0..ENVIRONMENT_FLARE_SLOTS {
+ let row = SKY_FLARE_ROWS[flare_index];
+ let material_index = material_indices[row]
+ .ok_or_else(|| format!("sky flare row {row} has no material selector"))?;
+ let range = append_environment_range(
+ mesh,
+ material_index,
+ placeholder_quad_vertices(camera, 1, sprite_size),
+ quad_indices(1)?,
+ world_pipeline_state(LegacyBlendMode::Additive, LegacyDepthMode::TestReadOnly),
+ )?;
+ register_material_range(
+ &mut scene.material_animations,
+ material_index,
+ range.range_index,
+ );
+ scene.sprites.push(EnvironmentSpriteLayer {
+ row,
+ material_index,
+ diffuse_alpha: materials
+ .get(material_index)
+ .map_or(1.0, |material| material.diffuse_alpha),
+ flare_index: Some(flare_index),
+ range,
+ });
+ }
+
+ let particle_indices = quad_indices(ENVIRONMENT_PARTICLE_SLOTS)?;
+ scene.rain = append_environment_range(
+ mesh,
+ material_indices[8]
+ .ok_or_else(|| "rain material selector is unavailable".to_string())?,
+ placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25),
+ particle_indices.clone(),
+ world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
+ )?;
+ register_material_range(
+ &mut scene.material_animations,
+ scene.rain.material_index,
+ scene.rain.range_index,
+ );
+ scene.snow = append_environment_range(
+ mesh,
+ material_indices[7]
+ .ok_or_else(|| "snow material selector is unavailable".to_string())?,
+ placeholder_quad_vertices(camera, ENVIRONMENT_PARTICLE_SLOTS, 0.25),
+ particle_indices,
+ world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
+ )?;
+ register_material_range(
+ &mut scene.material_animations,
+ scene.snow.material_index,
+ scene.snow.range_index,
+ );
+ scene.lightning = append_environment_range(
+ mesh,
+ lightning_material_index,
+ placeholder_quad_vertices(camera, 1, sprite_size),
+ quad_indices(1)?,
+ world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
+ )?;
+ register_material_range(
+ &mut scene.material_animations,
+ scene.lightning.material_index,
+ scene.lightning.range_index,
+ );
+
+ // Preserve the native sky order while keeping the indexed mesh
+ // contiguous. The screen gradient must be the first draw, and the
+ // cloud dome follows all directional sprites. Reordering the range
+ // metadata alone would leave first_index values pointing into the old
+ // segment order, so move each index segment together with its range.
+ let range_for_kind = |kind: SkyPassKind| {
+ scene
+ .sky_layers
+ .iter()
+ .find(|layer| layer.kind == kind)
+ .map(|layer| layer.range.range_index)
+ };
+ let screen_index = range_for_kind(SkyPassKind::ScreenGradient)
+ .ok_or_else(|| "screen gradient range was not allocated".to_string())?;
+ let nebula_index = range_for_kind(SkyPassKind::NebulaStars)
+ .ok_or_else(|| "nebula sky range was not allocated".to_string())?;
+ let dome_index = range_for_kind(SkyPassKind::DomeGradient)
+ .ok_or_else(|| "dome gradient range was not allocated".to_string())?;
+ let cloud_index = range_for_kind(SkyPassKind::Clouds)
+ .ok_or_else(|| "cloud sky range was not allocated".to_string())?;
+ let sprite_indices = scene
+ .sprites
+ .iter()
+ .map(|sprite| sprite.range.range_index)
+ .collect::<Vec<_>>();
+ if sprite_indices.is_empty() {
+ return Err("sky sprite ranges were not allocated".to_string());
+ }
+ let rain_index = scene.rain.range_index;
+ let snow_index = scene.snow.range_index;
+ let lightning_index = scene.lightning.range_index;
+ let environment_indices = [
+ screen_index,
+ nebula_index,
+ dome_index,
+ cloud_index,
+ rain_index,
+ snow_index,
+ lightning_index,
+ *shadow_range_index,
+ ]
+ .into_iter()
+ .chain(sprite_indices.iter().copied())
+ .collect::<HashSet<_>>();
+ let old_range_count = mesh.draw_ranges.len();
+ let mut old_order = Vec::with_capacity(old_range_count);
+ old_order.push(screen_index);
+ old_order.extend((0..old_range_count).filter(|index| {
+ !environment_indices.contains(index)
+ && *index != screen_index
+ && *index != nebula_index
+ && *index != dome_index
+ && *index != cloud_index
+ }));
+ old_order.push(*shadow_range_index);
+ old_order.extend([nebula_index, dome_index]);
+ old_order.extend(sprite_indices.iter().copied());
+ old_order.extend([cloud_index, rain_index, snow_index, lightning_index]);
+ let remap = reorder_draw_ranges(mesh, &old_order)?;
+ for layer in &mut scene.sky_layers {
+ layer.range.range_index = *remap
+ .get(layer.range.range_index)
+ .ok_or_else(|| "sky draw range remap is out of bounds".to_string())?;
+ }
+ for sprite in &mut scene.sprites {
+ sprite.range.range_index = *remap
+ .get(sprite.range.range_index)
+ .ok_or_else(|| "sky sprite draw range remap is out of bounds".to_string())?;
+ }
+ scene.rain.range_index = *remap
+ .get(scene.rain.range_index)
+ .ok_or_else(|| "rain draw range remap is out of bounds".to_string())?;
+ scene.snow.range_index = *remap
+ .get(scene.snow.range_index)
+ .ok_or_else(|| "snow draw range remap is out of bounds".to_string())?;
+ scene.lightning.range_index = *remap
+ .get(scene.lightning.range_index)
+ .ok_or_else(|| "lightning draw range remap is out of bounds".to_string())?;
+ remap_material_animation_ranges(&mut scene.material_animations, &remap)?;
+ remap_material_animation_ranges(preview_material_animations, &remap)?;
+ for range_index in sun_occlusion_range_indices {
+ *range_index = *remap
+ .get(*range_index)
+ .ok_or_else(|| "sun occlusion range remap is out of bounds".to_string())?;
+ }
+ *shadow_range_index = *remap
+ .get(*shadow_range_index)
+ .ok_or_else(|| "shadow draw range remap is out of bounds".to_string())?;
+ Ok(scene)
+ }
+
+ fn update(
+ &mut self,
+ mesh: &mut VulkanStaticMesh,
+ materials: &[VulkanStaticMaterial],
+ renderer: &mut VulkanSmokeRenderer,
+ sky_frame: &SkyFrame,
+ sky_mesh: &SkyMesh,
+ environment_frame: &EnvironmentFrame,
+ camera: &FreeFlightCamera,
+ viewport: [f32; 2],
+ material_clock_ms: u32,
+ terrain: &TerrainWorld,
+ sun_occlusion_range_indices: &[usize],
+ ) -> Result<(), String> {
+ let active_materials = update_material_animations(
+ &mut self.material_animations,
+ materials,
+ renderer,
+ material_clock_ms,
+ )?;
+ // Resolve the native glare once for the frame. CSky feeds the
+ // boosted primary color back into the light manager and uses the
+ // positive boost delta for both the screen-gradient horizon floor and
+ // the dome palette clamp.
+ let projected_sun =
+ project_direction_pixels(camera, sky_frame.sun.geometry_direction(), viewport)
+ .unwrap_or([f32::NAN; 2]);
+ let optics = sky_frame
+ .sun_optics(SunVisibility {
+ projected_sun,
+ viewport,
+ view_forward: camera.forward(),
+ // Flares use the same terrain/object ray query as the native
+ // visibility gate. The sun sprite itself deliberately stays
+ // independent of this test, matching the native layer's
+ // `requires_occlusion` split.
+ unoccluded: native_sun_unoccluded_with_world(
+ terrain,
+ mesh,
+ sun_occlusion_range_indices,
+ camera,
+ sky_frame.sun.geometry_direction(),
+ ),
+ })
+ .ok();
+ let glare_rgb_delta = optics.map_or([0.0; 3], |optics| {
+ std::array::from_fn(|index| {
+ (optics.primary_color[index] - sky_frame.sun.color[index]).max(0.0)
+ })
+ });
+ let gradient = sky_frame.screen_gradient(camera.yaw, glare_rgb_delta);
+ if let Some(optics) = optics {
+ let mut uniforms = renderer.frame_uniforms();
+ if sky_frame.sun.active {
+ uniforms.directional_lights[0].rgb = optics.primary_color;
+ }
+ renderer
+ .set_frame_uniforms(uniforms)
+ .map_err(|error| format!("update glare light uniforms: {error}"))?;
+ }
+ let gradient_rgba = gradient.color.rgba();
+ for layer in &self.sky_layers {
+ let range = layer.range;
+ let destination = mesh
+ .vertices
+ .get_mut(range.vertex_start..range.vertex_start + range.vertex_capacity)
+ .ok_or_else(|| "sky vertex allocation is out of bounds".to_string())?;
+ let pass_frame = sky_frame
+ .passes
+ .iter()
+ .find(|pass| pass.kind == layer.kind)
+ .ok_or_else(|| format!("sky frame is missing {:?} pass", layer.kind))?;
+ match layer.kind {
+ SkyPassKind::ScreenGradient => {
+ if destination.len() != 4 {
+ return Err("screen gradient vertex allocation changed".to_string());
+ }
+ destination.copy_from_slice(&screen_gradient_quad_vertices(
+ camera,
+ viewport,
+ [gradient_rgba[0], gradient_rgba[1], gradient_rgba[2]],
+ ));
+ }
+ SkyPassKind::NebulaStars | SkyPassKind::DomeGradient | SkyPassKind::Clouds => {
+ if destination.len() != sky_mesh.vertices.len() {
+ return Err(
+ "sky mesh vertex count changed after GPU allocation".to_string()
+ );
+ }
+ for (target, source) in destination.iter_mut().zip(&sky_mesh.vertices) {
+ let source_color = match layer.kind {
+ // Native mode 4 receives a white DIFFUSE and
+ // chooses nebula/stars through TEX1 alpha.
+ SkyPassKind::NebulaStars | SkyPassKind::Clouds => [1.0; 4],
+ SkyPassKind::DomeGradient => {
+ gradient.clamp_dome_color(source.color).rgba()
+ }
+ SkyPassKind::ScreenGradient => unreachable!(),
+ };
+ target.position = add3(
+ add3(source.position, camera.position),
+ [0.0, 0.0, layer.translation_z],
+ );
+ target.color = [source_color[0], source_color[1], source_color[2]];
+ target.uv = layer
+ .stage
+ .and_then(|stage| source.uv.get(stage).copied())
+ .unwrap_or([0.0, 0.0]);
+ target.detail_uv = layer
+ .secondary_stage
+ .and_then(|stage| source.uv.get(stage).copied())
+ .unwrap_or([0.0, 0.0]);
+ target.normal = sky_normal(source.normal);
+ target.overlay_alpha = source_color[3];
+ }
+ }
+ }
+ let material_index = active_materials
+ .get(&range.range_index)
+ .map(|state| state.material_index)
+ .unwrap_or(layer.material_index);
+ if !active_materials.contains_key(&range.range_index) {
+ renderer
+ .set_material_alphas(material_index, layer.diffuse_alpha.clamp(0.0, 1.0), 0.0)
+ .map_err(|error| format!("update sky material alpha: {error}"))?;
+ }
+ if layer.kind == SkyPassKind::Clouds {
+ let directional_rgb = pass_frame
+ .material_color
+ .unwrap_or(layer.static_directional_rgb);
+ renderer
+ .set_material_lighting(
+ material_index,
+ directional_rgb,
+ layer.static_additive_rgb,
+ )
+ .map_err(|error| format!("update cloud material lighting: {error}"))?;
+ }
+ renderer
+ .set_draw_range_index_count(
+ range.range_index,
+ if layer.kind == SkyPassKind::ScreenGradient {
+ 6
+ } else {
+ u32::try_from(sky_mesh.indices.len())
+ .map_err(|_| "sky index count exceeds u32".to_string())?
+ },
+ )
+ .map_err(|error| format!("update sky draw range: {error}"))?;
+ }
+
+ // Flares share their source material row, so their packed alpha lives
+ // in each vertex. Upload the shared phase alpha once before emitting
+ // the twelve fixed-capacity ranges.
+ let mut flare_materials = HashMap::new();
+ for sprite in &self.sprites {
+ if sprite.flare_index.is_some()
+ && !active_materials.contains_key(&sprite.range.range_index)
+ {
+ flare_materials.insert(sprite.material_index, sprite.diffuse_alpha);
+ }
+ }
+ for (material_index, diffuse_alpha) in flare_materials {
+ renderer
+ .set_material_alphas(material_index, diffuse_alpha.clamp(0.0, 1.0), 0.0)
+ .map_err(|error| format!("update sky flare alpha: {error}"))?;
+ }
+ for sprite in &self.sprites {
+ let mut active = false;
+ let mut vertices = None;
+ let mut material_alpha = 0.0;
+ if let Some(flare_index) = sprite.flare_index {
+ if let Some(flare) =
+ optics.and_then(|optics| optics.flares.get(flare_index).copied())
+ {
+ let direction = screen_ray_from_pixels(camera, flare.center_pixels, viewport);
+ active = flare.active && direction.is_some() && flare.color[3] > 0.0;
+ if let Some(direction) = direction {
+ vertices = Some(sprite_quad_vertices_pixels(
+ camera,
+ direction,
+ (camera.far_plane * 0.5).max(camera.near_plane * 2.0),
+ flare.half_size_pixels,
+ flare.half_size_pixels,
+ viewport,
+ [flare.color[0], flare.color[1], flare.color[2]],
+ flare.color[3],
+ flare.uv,
+ ));
+ }
+ }
+ } else if let Some(frame) = sprite_frame_for_row(sky_frame, sprite.row) {
+ let projected = project_direction_pixels(camera, frame.direction, viewport);
+ active = frame.active
+ && frame.intensity > 0.0
+ && projected.is_some_and(|center| {
+ center[0] >= 0.0
+ && center[0] <= viewport[0]
+ && center[1] >= 0.0
+ && center[1] <= viewport[1]
+ });
+ if active {
+ if let Ok([half_width, half_height]) = sprite_half_size_pixels(
+ frame.size_values,
+ viewport,
+ horizontal_fov(camera, viewport[0] / viewport[1].max(1.0)),
+ ) {
+ vertices = Some(sprite_quad_vertices_pixels(
+ camera,
+ frame.direction,
+ (camera.far_plane * 0.5).max(camera.near_plane * 2.0),
+ half_width,
+ half_height,
+ viewport,
+ frame.color,
+ frame.alpha,
+ frame.uv,
+ ));
+ } else {
+ active = false;
+ }
+ }
+ material_alpha = if active {
+ // The packed sprite alpha is carried by the vertex. The
+ // MAT0 phase alpha remains the material multiplier;
+ // intensity controls lifecycle visibility, not a second
+ // alpha fade.
+ sprite.diffuse_alpha
+ } else {
+ 0.0
+ };
+ }
+ if active {
+ if let Some(vertices) = vertices {
+ let destination = mesh
+ .vertices
+ .get_mut(sprite.range.vertex_start..sprite.range.vertex_start + 4)
+ .ok_or_else(|| {
+ "sky sprite vertex allocation is out of bounds".to_string()
+ })?;
+ destination.copy_from_slice(&vertices);
+ } else {
+ active = false;
+ }
+ }
+ if sprite.flare_index.is_none() {
+ let material_index = active_materials
+ .get(&sprite.range.range_index)
+ .map(|state| state.material_index)
+ .unwrap_or(sprite.material_index);
+ // The animation update above has already restored the
+ // sampled phase alpha for an active animated material. A
+ // hidden sprite still needs an explicit zero, while a static
+ // sprite uses its authored material alpha when visible.
+ let should_write_alpha =
+ !active || !active_materials.contains_key(&sprite.range.range_index);
+ if should_write_alpha {
+ renderer
+ .set_material_alphas(material_index, material_alpha.clamp(0.0, 1.0), 0.0)
+ .map_err(|error| format!("update sky sprite alpha: {error}"))?;
+ }
+ }
+ renderer
+ .set_draw_range_index_count(sprite.range.range_index, if active { 6 } else { 0 })
+ .map_err(|error| format!("update sky sprite draw range: {error}"))?;
+ }
+
+ let mut rain_count = 0usize;
+ let mut snow_count = 0usize;
+ let mut lightning = None;
+ for primitive in &environment_frame.primitives {
+ match primitive {
+ EnvironmentPrimitive::Particle {
+ kind,
+ color,
+ screen,
+ ..
+ } => {
+ let (range, slot) = match kind {
+ PrecipitationKind::Rain if rain_count < ENVIRONMENT_PARTICLE_SLOTS => {
+ let slot = rain_count;
+ rain_count += 1;
+ (self.rain, slot)
+ }
+ PrecipitationKind::Snow if snow_count < ENVIRONMENT_PARTICLE_SLOTS => {
+ let slot = snow_count;
+ snow_count += 1;
+ (self.snow, slot)
+ }
+ _ => continue,
+ };
+ let vertices =
+ precipitation_quad_vertices(*kind, screen, *color, camera, viewport);
+ let start = range.vertex_start + slot * 4;
+ let destination = mesh.vertices.get_mut(start..start + 4).ok_or_else(|| {
+ "precipitation vertex allocation is out of bounds".to_string()
+ })?;
+ destination.copy_from_slice(&vertices);
+ }
+ EnvironmentPrimitive::Lightning(bolt) if lightning.is_none() => {
+ lightning = Some(bolt);
+ }
+ EnvironmentPrimitive::Lightning(_) => {}
+ }
+ }
+ renderer
+ .set_draw_range_index_count(
+ self.rain.range_index,
+ u32::try_from(rain_count * 6)
+ .map_err(|_| "rain draw count exceeds u32".to_string())?,
+ )
+ .map_err(|error| format!("update rain draw range: {error}"))?;
+ renderer
+ .set_draw_range_index_count(
+ self.snow.range_index,
+ u32::try_from(snow_count * 6)
+ .map_err(|_| "snow draw count exceeds u32".to_string())?,
+ )
+ .map_err(|error| format!("update snow draw range: {error}"))?;
+
+ let lightning_alpha = if let Some(bolt) = lightning {
+ let vertices = lightning_quad_vertices(bolt);
+ let destination = mesh
+ .vertices
+ .get_mut(self.lightning.vertex_start..self.lightning.vertex_start + 4)
+ .ok_or_else(|| "lightning vertex allocation is out of bounds".to_string())?;
+ destination.copy_from_slice(&vertices);
+ bolt.intensity.clamp(0.0, 1.0)
} else {
- load_mission_static_preview_roots(engine, request, args.preview_roots)
+ 0.0
+ };
+ let lightning_material_index = active_materials
+ .get(&self.lightning.range_index)
+ .map(|state| state.material_index)
+ .unwrap_or(self.lightning_material_index);
+ renderer
+ .set_material_alphas(lightning_material_index, lightning_alpha, 0.0)
+ .map_err(|error| format!("update lightning material alpha: {error}"))?;
+ renderer
+ .set_draw_range_index_count(
+ self.lightning.range_index,
+ if lightning.is_some() { 6 } else { 0 },
+ )
+ .map_err(|error| format!("update lightning draw range: {error}"))?;
+ Ok(())
+ }
+}
+
+fn sprite_frame_for_row<'frame, 'materials>(
+ sky_frame: &'frame SkyFrame<'materials>,
+ row: usize,
+) -> Option<SkySpriteFrame<'materials>> {
+ sky_frame
+ .sprites
+ .iter()
+ .copied()
+ .find(|sprite| sprite.row == row)
+}
+
+fn horizontal_fov(camera: &FreeFlightCamera, aspect: f32) -> f32 {
+ let aspect = if aspect.is_finite() && aspect > 0.0 {
+ aspect
+ } else {
+ 16.0 / 9.0
+ };
+ 2.0 * ((camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan() * aspect).atan()
+}
+
+/// Tests the camera-to-sun segment against the validated mission terrain.
+///
+/// The native CSun caller starts a half-unit in front of the camera along the
+/// normalized geometry direction, then queries the finite segment ending at
+/// the sun center. `Land.msh` stores Z in the native 32x height unit while the
+/// renderer's world projection divides it by 32, so only the Z components are
+/// scaled before the terrain query. The ray parameter remains normalized to
+/// the finite segment and is therefore independent of world distance.
+#[cfg(test)]
+fn native_sun_unoccluded(
+ terrain: &TerrainWorld,
+ camera: &FreeFlightCamera,
+ direction: [f32; 3],
+) -> bool {
+ let empty_mesh = VulkanStaticMesh {
+ vertices: Vec::new(),
+ indices: Vec::new(),
+ draw_ranges: Vec::new(),
+ };
+ native_sun_unoccluded_with_world(terrain, &empty_mesh, &[], camera, direction)
+}
+
+fn native_sun_unoccluded_with_world(
+ terrain: &TerrainWorld,
+ world_mesh: &VulkanStaticMesh,
+ sun_occlusion_range_indices: &[usize],
+ camera: &FreeFlightCamera,
+ direction: [f32; 3],
+) -> bool {
+ // Native CSun requests object kinds 1, 3, 4, and 10 (the aggregate
+ // object mask is 0x41A) and excludes full surface flag 0x20. The preview
+ // range list is built from roots whose raw mission kind is one of those
+ // four eligible kinds; fixed atmosphere ranges are remapped out before
+ // this call.
+ let Some(direction) = normalize3(direction) else {
+ return false;
+ };
+ let sun_center = add3(
+ camera.position,
+ scale3(
+ direction,
+ (camera.far_plane * 0.5).max(camera.near_plane * 2.0),
+ ),
+ );
+ let start = add3(camera.position, scale3(direction, 0.5));
+ let segment = sub3(sun_center, start);
+ if !sun_center
+ .iter()
+ .chain(start.iter())
+ .chain(segment.iter())
+ .all(|value| value.is_finite())
+ {
+ return false;
+ }
+ if dot3(segment, segment) <= f32::EPSILON {
+ return true;
+ }
+ let origin = [start[0], start[1], start[2] * 32.0];
+ let terrain_segment = [segment[0], segment[1], segment[2] * 32.0];
+ if let Ok(Some(hit)) = terrain.raycast_excluding(
+ origin,
+ terrain_segment,
+ FullSurfaceMask(0),
+ FullSurfaceMask(0x0000_0020),
+ ) {
+ if hit.distance.is_finite() && hit.distance <= 1.0 {
+ return false;
}
+ }
+ !world_mesh_sun_occluded(world_mesh, sun_occlusion_range_indices, start, segment)
+}
+
+fn world_mesh_sun_occluded(
+ mesh: &VulkanStaticMesh,
+ range_indices: &[usize],
+ origin: [f32; 3],
+ direction: [f32; 3],
+) -> bool {
+ for &range_index in range_indices {
+ let Some(range) = mesh.draw_ranges.get(range_index).copied() else {
+ continue;
+ };
+ // The native world query receives the compact exclusion bit 0x8,
+ // which is the world-space representation of full surface flag 0x20.
+ if range.batch_flags & 0x0008 != 0 {
+ continue;
+ }
+ let Ok(first_index) = usize::try_from(range.first_index) else {
+ continue;
+ };
+ let Ok(index_count) = usize::try_from(range.index_count) else {
+ continue;
+ };
+ let Some(indices) = mesh
+ .indices
+ .get(first_index..first_index.saturating_add(index_count))
+ else {
+ continue;
+ };
+ for triangle in indices.chunks_exact(3) {
+ let Ok(a) = usize::try_from(triangle[0]) else {
+ continue;
+ };
+ let Ok(b) = usize::try_from(triangle[1]) else {
+ continue;
+ };
+ let Ok(c) = usize::try_from(triangle[2]) else {
+ continue;
+ };
+ let (Some(&a), Some(&b), Some(&c)) = (
+ mesh.vertices.get(a),
+ mesh.vertices.get(b),
+ mesh.vertices.get(c),
+ ) else {
+ continue;
+ };
+ if segment_intersects_triangle(origin, direction, a.position, b.position, c.position) {
+ return true;
+ }
+ }
+ }
+ false
+}
+
+fn segment_intersects_triangle(
+ origin: [f32; 3],
+ direction: [f32; 3],
+ a: [f32; 3],
+ b: [f32; 3],
+ c: [f32; 3],
+) -> bool {
+ let edge1 = sub3(b, a);
+ let edge2 = sub3(c, a);
+ let pvec = cross3(direction, edge2);
+ let determinant = dot3(edge1, pvec);
+ if !determinant.is_finite() || determinant.abs() <= f32::EPSILON {
+ return false;
+ }
+ let inverse = determinant.recip();
+ let tvec = sub3(origin, a);
+ let u = dot3(tvec, pvec) * inverse;
+ if !u.is_finite() || !(0.0..=1.0).contains(&u) {
+ return false;
+ }
+ let qvec = cross3(tvec, edge1);
+ let v = dot3(direction, qvec) * inverse;
+ if !v.is_finite() || v < 0.0 || u + v > 1.0 {
+ return false;
+ }
+ let distance = dot3(edge2, qvec) * inverse;
+ distance.is_finite() && (0.0..=1.0).contains(&distance)
+}
+
+/// Replays the native draw-item callback: one whole indexed batch is keyed by
+/// the camera distance to its first indexed vertex.
+fn first_indexed_vertex_distance(
+ mesh: &VulkanStaticMesh,
+ range_index: usize,
+ camera_position: [f32; 3],
+) -> Result<f32, String> {
+ let range: VulkanStaticDrawRange = *mesh
+ .draw_ranges
+ .get(range_index)
+ .ok_or_else(|| "world draw range index is out of bounds".to_string())?;
+ let first_index = usize::try_from(range.first_index)
+ .map_err(|_| "world draw range first index is not addressable".to_string())?;
+ let vertex_index =
+ usize::try_from(*mesh.indices.get(first_index).ok_or_else(|| {
+ "world draw range first index is outside the index buffer".to_string()
+ })?)
+ .map_err(|_| "world draw range vertex index is not addressable".to_string())?;
+ let vertex = mesh
+ .vertices
+ .get(vertex_index)
+ .ok_or_else(|| "world draw range vertex index is outside the vertex buffer".to_string())?;
+ let delta = sub3(vertex.position, camera_position);
+ let distance = dot3(delta, delta).sqrt();
+ distance
+ .is_finite()
+ .then_some(distance)
+ .ok_or_else(|| "world draw range sort distance is non-finite".to_string())
+}
+
+fn project_direction_pixels(
+ camera: &FreeFlightCamera,
+ direction: [f32; 3],
+ viewport: [f32; 2],
+) -> Option<[f32; 2]> {
+ let direction = normalize3(direction)?;
+ let width = viewport[0];
+ let height = viewport[1];
+ if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 {
+ return None;
+ }
+ let depth = dot3(camera.forward(), direction);
+ if !depth.is_finite() || depth <= camera.near_plane.max(0.001) {
+ return None;
+ }
+ let aspect = width / height;
+ let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan();
+ let half_horizontal = half_vertical * aspect;
+ let ndc_x = dot3(camera.right(), direction) / (depth * half_horizontal);
+ let ndc_y = dot3(camera.up(), direction) / (depth * half_vertical);
+ let pixels = [(ndc_x * 0.5 + 0.5) * width, (0.5 - ndc_y * 0.5) * height];
+ pixels
+ .iter()
+ .all(|value| value.is_finite())
+ .then_some(pixels)
+}
+
+fn screen_ray_from_pixels(
+ camera: &FreeFlightCamera,
+ pixels: [f32; 2],
+ viewport: [f32; 2],
+) -> Option<[f32; 3]> {
+ if !pixels.iter().all(|value| value.is_finite())
+ || !viewport.iter().all(|value| value.is_finite())
+ || viewport[0] <= 0.0
+ || viewport[1] <= 0.0
+ {
+ return None;
+ }
+ let aspect = viewport[0] / viewport[1];
+ let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan();
+ let half_horizontal = half_vertical * aspect;
+ let ndc_x = pixels[0] / viewport[0] * 2.0 - 1.0;
+ let ndc_y = 1.0 - pixels[1] / viewport[1] * 2.0;
+ normalize3(add3(
+ add3(
+ camera.forward(),
+ scale3(camera.right(), ndc_x * half_horizontal),
+ ),
+ scale3(camera.up(), ndc_y * half_vertical),
+ ))
+}
+
+fn sprite_quad_vertices_pixels(
+ camera: &FreeFlightCamera,
+ direction: [f32; 3],
+ distance: f32,
+ half_width_pixels: f32,
+ half_height_pixels: f32,
+ viewport: [f32; 2],
+ color: [f32; 3],
+ alpha: f32,
+ uv: [[f32; 2]; 4],
+) -> [VulkanStaticVertex; 4] {
+ let direction = normalize3(direction).unwrap_or(camera.forward());
+ let center = add3(camera.position, scale3(direction, distance));
+ let depth = dot3(camera.forward(), direction)
+ .mul_add(distance, 0.0)
+ .max(camera.near_plane.max(0.001));
+ let width = viewport[0].max(1.0);
+ let height = viewport[1].max(1.0);
+ let aspect = width / height;
+ let vertical_fov = camera.vertical_fov.clamp(0.05, 3.0);
+ let horizontal_fov_radians = horizontal_fov(camera, aspect);
+ let world_per_pixel_x = 2.0 * depth * (horizontal_fov_radians * 0.5).tan() / width;
+ let world_per_pixel_y = 2.0 * depth * (vertical_fov * 0.5).tan() / height;
+ let right = scale3(
+ camera.right(),
+ half_width_pixels.max(0.0) * world_per_pixel_x,
+ );
+ let up = scale3(camera.up(), half_height_pixels.max(0.0) * world_per_pixel_y);
+ // Native sun and flare UVs are ordered left-top, left-bottom, right-bottom,
+ // right-top. The shared index order is bottom-left, bottom-right, top-left,
+ // top-right.
+ let uv = [uv[1], uv[2], uv[0], uv[3]];
+ [
+ environment_vertex_with_alpha(sub3(sub3(center, right), up), color, alpha, uv[0]),
+ environment_vertex_with_alpha(add3(sub3(center, up), right), color, alpha, uv[1]),
+ environment_vertex_with_alpha(add3(sub3(center, right), up), color, alpha, uv[2]),
+ environment_vertex_with_alpha(add3(add3(center, right), up), color, alpha, uv[3]),
+ ]
+}
+
+fn precipitation_quad_vertices(
+ kind: PrecipitationKind,
+ screen: &ScreenBillboard,
+ color: [f32; 4],
+ camera: &FreeFlightCamera,
+ viewport: [f32; 2],
+) -> [VulkanStaticVertex; 4] {
+ let rgb = [color[0], color[1], color[2]];
+ let depths = match kind {
+ PrecipitationKind::Rain => [
+ screen.tail_depth,
+ screen.tail_depth,
+ screen.head_depth,
+ screen.head_depth,
+ ],
+ PrecipitationKind::Snow => [screen.head_depth; 4],
+ };
+ std::array::from_fn(|index| {
+ environment_vertex_with_alpha(
+ world_from_ndc(camera, screen.corners[index], depths[index], viewport),
+ rgb,
+ color[3],
+ screen.uv[index],
+ )
+ })
+}
+
+fn world_from_ndc(
+ camera: &FreeFlightCamera,
+ ndc: [f32; 2],
+ depth: f32,
+ viewport: [f32; 2],
+) -> [f32; 3] {
+ let depth = if depth.is_finite() {
+ depth.max(camera.near_plane.max(0.001))
} else {
- load_mission(engine, request)
+ camera.near_plane.max(0.001)
+ };
+ let aspect = viewport[0].max(1.0) / viewport[1].max(1.0);
+ let half_vertical = (camera.vertical_fov.clamp(0.05, 3.0) * 0.5).tan();
+ let half_horizontal = half_vertical * aspect;
+ let offset = add3(
+ scale3(camera.right(), ndc[0] * half_horizontal),
+ scale3(camera.up(), ndc[1] * half_vertical),
+ );
+ add3(
+ camera.position,
+ scale3(add3(camera.forward(), offset), depth),
+ )
+}
+
+fn lightning_quad_vertices(
+ bolt: &fparkan_fx::environment::LightningBolt,
+) -> [VulkanStaticVertex; 4] {
+ std::array::from_fn(|index| {
+ environment_vertex(bolt.quad.vertices[index], [1.0; 3], bolt.quad.uv[index])
+ })
+}
+
+/// CPU atmosphere state shared by the interactive preview and the renderer's
+/// per-frame light/fog block. The sky mesh and weather primitives stay on the
+/// same update clock, so camera movement cannot leave environment audio or
+/// lighting one frame behind.
+struct DynamicEnvironment {
+ schedule: TypedAtmosphere,
+ sky: SkySystem,
+ environment: EnvironmentSystem,
+ materials: SkyMaterials,
+ material_assets: HashMap<String, EnvironmentMaterialAsset>,
+ schedule_offset_seconds: f32,
+ time_seconds: f32,
+}
+
+impl DynamicEnvironment {
+ fn update(
+ &mut self,
+ dt_seconds: f32,
+ camera: &FreeFlightCamera,
+ aspect: f32,
+ viewport: [f32; 2],
+ renderer: &mut VulkanSmokeRenderer,
+ mut audio: Option<&mut audio::GameAudio>,
+ ) -> Result<(SkyFrame<'_>, SkyMesh, EnvironmentFrame), String> {
+ let dt_seconds = if dt_seconds.is_finite() {
+ dt_seconds.max(0.0)
+ } else {
+ 0.0
+ };
+ self.time_seconds = if self.time_seconds.is_finite() {
+ self.time_seconds + dt_seconds
+ } else {
+ 0.0
+ };
+ let absolute_time_seconds = self.schedule_offset_seconds + self.time_seconds;
+ let atmosphere = self.schedule.sample(absolute_time_seconds);
+ let mut sky_mesh = self.sky.mesh().clone();
+ let sky_frame =
+ SkyFrame::from_atmosphere(&atmosphere, &self.materials, absolute_time_seconds);
+ sky_mesh.update_colors(&sky_frame.sky);
+ if let Some(audio) = audio.as_mut() {
+ let primary_rgb = if sky_frame.sun.active {
+ sky_frame.sun.color
+ } else if sky_frame.moon.active {
+ sky_frame.moon.color
+ } else {
+ [0.0; 3]
+ };
+ audio
+ .update_ambience(dt_seconds, primary_rgb)
+ .map_err(|error| format!("update ambient variation: {error}"))?;
+ }
+
+ let mut uniforms = renderer.frame_uniforms();
+ uniforms.clip_from_world = camera.vulkan_camera(aspect).clip_from_world;
+ for (target, source) in uniforms
+ .directional_lights
+ .iter_mut()
+ .zip(sky_frame.directional_lights)
+ {
+ target.direction = source.direction;
+ target.rgb = source.color;
+ target.coefficients = [0.0, 0.0, 1.0];
+ target.active = source.active;
+ }
+ uniforms.lighting_floor = sky_frame.lighting_floor;
+ uniforms.fog_color = sky_frame.fog.color;
+ uniforms.fog_start = sky_frame.fog.start.max(0.0);
+ uniforms.fog_end = sky_frame.fog.end.max(uniforms.fog_start);
+ uniforms.camera_position = camera.position;
+ renderer
+ .set_frame_uniforms(uniforms)
+ .map_err(|error| format!("update sky frame uniforms: {error}"))?;
+
+ let fx_camera = FxCamera::with_projection(
+ camera.position,
+ camera.forward(),
+ camera.right(),
+ camera.up(),
+ camera.vertical_fov,
+ aspect,
+ )
+ .with_viewport(viewport);
+ let environment_frame = self.environment.update_atmosphere_with_materials(
+ dt_seconds,
+ &atmosphere,
+ &self.materials,
+ fx_camera,
+ );
+ let mut uniforms = renderer.frame_uniforms();
+ uniforms.point_light = environment_frame
+ .point_lights
+ .iter()
+ .find(|light| light.active)
+ .map(|light| VulkanPointLight {
+ position: light.position,
+ rgb: [light.color[0], light.color[1], light.color[2]],
+ range: light.range,
+ coefficients: light.attenuation,
+ active: true,
+ })
+ .unwrap_or_default();
+ renderer
+ .set_frame_uniforms(uniforms)
+ .map_err(|error| format!("update environment point-light uniforms: {error}"))?;
+ if let Some(audio) = audio.as_mut() {
+ for sound in &environment_frame.sounds {
+ audio
+ .handle_sound_event(sound)
+ .map_err(|error| format!("play environment sound: {error}"))?;
+ }
+ }
+ Ok((sky_frame, sky_mesh, environment_frame))
}
- .map_err(|err| err.to_string())
}
-/// Static geometry and descriptors belonging to the selected preview roots.
+fn load_environment(
+ root: &Path,
+ mission: &str,
+ atmosphere_seconds: Option<f32>,
+) -> Result<Option<DynamicEnvironment>, String> {
+ let mission_dir = mission_asset_directory(root, mission)?;
+ let Some(sky_path) = find_mission_file(&mission_dir, "sky.ske")? else {
+ return Ok(None);
+ };
+ let sky_bytes = std::fs::read(&sky_path)
+ .map_err(|error| format!("read atmosphere {}: {error}", sky_path.display()))?;
+ let schedule = TypedAtmosphere::parse(&sky_bytes)
+ .map_err(|error| format!("parse atmosphere {}: {error}", sky_path.display()))?;
+
+ let sky_wea_path = match find_sibling_file(&sky_path, "sky.wea") {
+ Some(path) => Some(path),
+ None => find_mission_file(&mission_dir, "sky.wea")?,
+ }
+ .ok_or_else(|| format!("selected atmosphere {} has no sky.wea", sky_path.display()))?;
+ let materials = SkyMaterials::parse(
+ &std::fs::read(&sky_wea_path)
+ .map_err(|error| format!("read sky materials {}: {error}", sky_wea_path.display()))?,
+ )
+ .map_err(|error| format!("parse sky materials {}: {error}", sky_wea_path.display()))?;
+ let mut sky_material_loader = StandaloneWearMaterialLoader::new(root);
+ let mut material_assets =
+ load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root(
+ root,
+ &sky_wea_path,
+ 0,
+ )
+ .map_err(|error| {
+ format!(
+ "resolve sky material WEAR {}: {error}",
+ sky_wea_path.display()
+ )
+ })?
+ .into_iter()
+ .enumerate()
+ .map(
+ |(material_index, (name, mips, phase, document))| -> Result<_, String> {
+ let initial_phase = phase;
+ let mut phase_textures = Vec::with_capacity(document.phases.len());
+ let mut phases = Vec::with_capacity(document.phases.len());
+ for phase_index in 0..document.phases.len() {
+ let phase_index_u16 = u16::try_from(phase_index).unwrap_or(u16::MAX);
+ let (phase_mips, sampled_phase) = if phase_index == 0 {
+ (mips.clone(), initial_phase)
+ } else {
+ sky_material_loader
+ .load(
+ &sky_wea_path,
+ u16::try_from(material_index).unwrap_or(u16::MAX),
+ phase_index_u16,
+ )
+ .map_err(|error| {
+ format!("resolve sky material phase {phase_index}: {error}")
+ })?
+ };
+ phase_textures.push(vulkan_texture_from_rgba_mips(phase_mips, "sky material")?);
+ phases.push(sampled_phase);
+ }
+ let texture = phase_textures
+ .first()
+ .cloned()
+ .ok_or_else(|| "sky material has no MAT0 phases".to_string())?;
+ Ok((
+ String::from_utf8_lossy(&name).into_owned(),
+ EnvironmentMaterialAsset {
+ texture,
+ phase: initial_phase,
+ document,
+ phase_textures,
+ phases,
+ },
+ ))
+ },
+ )
+ .collect::<Result<HashMap<_, _>, String>>()?;
+ let effect_bytes = load_resource_entry_bytes_from_root(root, "effects.rlb", "env_lightning")
+ .map_err(|error| format!("load effects.rlb/env_lightning: {error}"))?;
+ let lightning_effect = decode_env_lightning_fxid(effect_bytes)
+ .map_err(|error| format!("decode effects.rlb/env_lightning: {error}"))?;
+ if !lightning_effect
+ .visual
+ .archive
+ .eq_ignore_ascii_case("material.lib")
+ {
+ return Err(format!(
+ "env_lightning visual archive must be material.lib, got {:?}",
+ lightning_effect.visual.archive
+ ));
+ }
+ let (lightning_mips, lightning_phase, lightning_document) =
+ load_material_name_texture_rgba8_and_phase_with_document_from_root(
+ root,
+ &lightning_effect.visual.name,
+ 0,
+ )
+ .map_err(|error| {
+ format!(
+ "resolve env_lightning MAT0 {}: {error}",
+ lightning_effect.visual.name
+ )
+ })?;
+ let mut lightning_phase_textures = vec![vulkan_texture_from_rgba_mips(
+ lightning_mips,
+ "env_lightning material",
+ )?];
+ let mut lightning_phases = vec![lightning_phase];
+ for phase_index in 1..lightning_document.phases.len() {
+ let (phase_mips, phase, _document) =
+ load_material_name_texture_rgba8_and_phase_with_document_from_root(
+ root,
+ &lightning_effect.visual.name,
+ u16::try_from(phase_index).unwrap_or(u16::MAX),
+ )
+ .map_err(|error| {
+ format!(
+ "resolve env_lightning MAT0 phase {phase_index} {}: {error}",
+ lightning_effect.visual.name
+ )
+ })?;
+ lightning_phase_textures.push(vulkan_texture_from_rgba_mips(
+ phase_mips,
+ "env_lightning material",
+ )?);
+ lightning_phases.push(phase);
+ }
+ let lightning_texture = lightning_phase_textures
+ .first()
+ .cloned()
+ .ok_or_else(|| "env_lightning MAT0 has no phases".to_string())?;
+ material_assets.insert(
+ lightning_effect.visual.name.clone(),
+ EnvironmentMaterialAsset {
+ texture: lightning_texture,
+ phase: lightning_phase,
+ document: lightning_document,
+ phase_textures: lightning_phase_textures,
+ phases: lightning_phases,
+ },
+ );
+ let initial_offset_seconds = schedule.initial_offset_seconds() as f32;
+ let sky = SkySystem::new(Default::default(), materials.clone())
+ .map_err(|error| format!("build sky geometry: {error}"))?;
+ let time_seconds = atmosphere_seconds
+ .map(|seconds| seconds - initial_offset_seconds)
+ .unwrap_or(0.0);
+ let mut environment = EnvironmentSystem::new(0x4650_4152_4B41_4E_u64);
+ environment.set_lightning_effect(lightning_effect);
+ Ok(Some(DynamicEnvironment {
+ schedule,
+ sky,
+ environment,
+ materials,
+ material_assets,
+ schedule_offset_seconds: initial_offset_seconds,
+ time_seconds,
+ }))
+}
+
+/// Returns the host directory containing the selected mission's assets.
+/// Mission keys are the same relative paths accepted by the runtime VFS; the
+/// host root is only used here for the small set of loose mission-local files
+/// that are not archive entries (`sky.ske` and `sky.wea`).
+fn mission_asset_directory(root: &Path, mission: &str) -> Result<PathBuf, String> {
+ let mission_path = Path::new(mission);
+ if mission_path.is_absolute() {
+ return Err("mission environment path must be relative to --root".to_string());
+ }
+ let directory = mission_path
+ .parent()
+ .filter(|parent| !parent.as_os_str().is_empty())
+ .unwrap_or_else(|| Path::new("."));
+ let directory = root.join(directory);
+ if !directory.is_dir() {
+ return Err(format!(
+ "selected mission asset directory does not exist: {}",
+ directory.display()
+ ));
+ }
+ Ok(directory)
+}
+
+/// Finds one mission-local loose environment file. A direct sibling wins;
+/// nested `env`, `environment`, or `planet` layouts are accepted only when
+/// they contain a single matching file, so a second unrelated candidate never
+/// changes the selected mission silently.
+fn find_mission_file(root: &Path, name: &str) -> Result<Option<PathBuf>, String> {
+ let direct = root.join(name);
+ if direct.is_file() {
+ return Ok(Some(direct));
+ }
+ let mut stack = vec![root.to_path_buf()];
+ let mut matches = Vec::new();
+ while let Some(directory) = stack.pop() {
+ let Ok(entries) = std::fs::read_dir(&directory) else {
+ continue;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ let Ok(file_type) = entry.file_type() else {
+ continue;
+ };
+ if file_type.is_symlink() {
+ continue;
+ }
+ if file_type.is_dir() {
+ stack.push(path);
+ } else if file_type.is_file()
+ && path
+ .file_name()
+ .is_some_and(|file_name| file_name.eq_ignore_ascii_case(name))
+ {
+ matches.push(path);
+ }
+ }
+ }
+ matches.sort();
+ match matches.as_slice() {
+ [] => Ok(None),
+ [path] => Ok(Some(path.clone())),
+ _ => Err(format!(
+ "selected mission has multiple {name} files: {}",
+ matches
+ .iter()
+ .map(|path| path.display().to_string())
+ .collect::<Vec<_>>()
+ .join(", ")
+ )),
+ }
+}
+
+fn find_sibling_file(source: &Path, name: &str) -> Option<PathBuf> {
+ source
+ .parent()
+ .map(|parent| parent.join(name))
+ .filter(|path| path.is_file())
+}
+
+fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] + right[0], left[1] + right[1], left[2] + right[2]]
+}
+
+fn add3_in_place(target: &mut [f32; 3], value: [f32; 3]) {
+ *target = add3(*target, value);
+}
+
+fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
+}
+
+fn scale3(value: [f32; 3], factor: f32) -> [f32; 3] {
+ [value[0] * factor, value[1] * factor, value[2] * factor]
+}
+
+fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2]))
+}
+
+fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [
+ left[1] * right[2] - left[2] * right[1],
+ left[2] * right[0] - left[0] * right[2],
+ left[0] * right[1] - left[1] * right[0],
+ ]
+}
+
+fn length3(value: [f32; 3]) -> Option<f32> {
+ let length = dot3(value, value).sqrt();
+ length
+ .is_finite()
+ .then_some(length)
+ .filter(|length| *length > f32::EPSILON)
+}
+
+fn normalize3(value: [f32; 3]) -> Option<[f32; 3]> {
+ let inverse = length3(value)?.recip();
+ Some(scale3(value, inverse))
+}
+
+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
+}
+
struct StaticPreviewScene {
mesh: VulkanStaticMesh,
materials: Vec<VulkanStaticMaterial>,
+ material_animations: Vec<MaterialAnimationBinding>,
+ sun_occlusion_range_indices: Vec<usize>,
+ shadow_casters: Vec<ShadowCaster>,
+ shadow_receivers: Vec<ShadowTriangle>,
+ shadow_material_index: usize,
+ terrain: Arc<TerrainWorld>,
camera: VulkanStaticCamera,
+ free_camera: Option<FreeFlightCamera>,
camera_mode: &'static str,
- clip_visible_vertices: usize,
mesh_components: usize,
terrain_components: usize,
- animation_frame: Option<u16>,
- material_phase: Option<u16>,
}
-/// Projects the mission terrain plus every MSH component of the explicitly
-/// bounded static-preview roots into one diagnostic XY frame.
+#[derive(Clone, Copy, Debug, Default)]
+struct StartupTimings {
+ mission_assets: Duration,
+ terrain_materials: Duration,
+ sky: Duration,
+ gpu_initialization: Duration,
+}
+
+#[derive(Clone, Copy, Debug, Default)]
+struct ShadowFrameEvidence {
+ visible_casters: usize,
+ projected_indices: usize,
+}
+
+fn elapsed_ms(duration: Duration) -> f64 {
+ duration.as_secs_f64() * 1_000.0
+}
+
+fn unit_component_is_visible(index: usize, source_type: Option<u32>) -> bool {
+ index == 0 || source_type == Some(PROTOTYPE_TYPE_EXTO)
+}
+
+fn native_sun_object_kind_eligible(source_type: Option<u32>) -> bool {
+ native_shadow_kind(source_type).is_some()
+}
+
+const PROTOTYPE_TYPE_FORT: u32 = u32::from_le_bytes(*b"FORT");
+const PROTOTYPE_TYPE_BTLU: u32 = u32::from_le_bytes(*b"BTLU");
+const PROTOTYPE_TYPE_STAT: u32 = u32::from_le_bytes(*b"STAT");
+
+/// Maps the resolved registry type to the native object-kind selector used by
+/// CSun/CShade. The mission object's raw kind is an unrelated TMA field; the
+/// renderer receives the registry provenance from the prepared visual.
+fn native_shadow_kind(source_type: Option<u32>) -> Option<u32> {
+ match source_type {
+ Some(PROTOTYPE_TYPE_FORT) => Some(3),
+ Some(PROTOTYPE_TYPE_BTLU) => Some(4),
+ Some(PROTOTYPE_TYPE_STAT) => Some(10),
+ _ => None,
+ }
+}
+
+fn compose_unit_pose(parent: VulkanNodePose, child: VulkanNodePose) -> VulkanNodePose {
+ let rotated = rotate_unit_vector(child.translation, parent.rotation);
+ VulkanNodePose {
+ translation: [
+ parent.translation[0] + rotated[0],
+ parent.translation[1] + rotated[1],
+ parent.translation[2] + rotated[2],
+ ],
+ rotation: multiply_unit_quaternions(parent.rotation, child.rotation),
+ }
+}
+
+fn rotate_unit_vector(value: [f32; 3], rotation: [f32; 4]) -> [f32; 3] {
+ let [x, y, z, w] = rotation;
+ let tx = 2.0 * (y * value[2] - z * value[1]);
+ let ty = 2.0 * (z * value[0] - x * value[2]);
+ let tz = 2.0 * (x * value[1] - y * value[0]);
+ [
+ value[0] + w * tx + (y * tz - z * ty),
+ value[1] + w * ty + (z * tx - x * tz),
+ value[2] + w * tz + (x * ty - y * tx),
+ ]
+}
+
+fn multiply_unit_quaternions(left: [f32; 4], right: [f32; 4]) -> [f32; 4] {
+ let [lx, ly, lz, lw] = left;
+ let [rx, ry, rz, rw] = right;
+ [
+ lw * rx + lx * rw + ly * rz - lz * ry,
+ lw * ry - lx * rz + ly * rw + lz * rx,
+ lw * rz + lx * ry - ly * rx + lz * rw,
+ lw * rw - lx * rx - ly * ry - lz * rz,
+ ]
+}
+
+/// Projects the mission terrain plus every MSH component of the selected roots
+/// into one shared world-space mesh.
///
-/// This intentionally takes only the first MAT0 texture request for each MSH
-/// batch selector. Terrain maps its proven low-byte packed tag channel through
-/// the map-local `Land2.wea` table. The high-byte `Land1.wea` channel is
-/// retained on draw ranges but awaits a recovered blend equation; orientation,
-/// later material phases, animation, lightmaps and gameplay visibility remain
-/// outside this bridge.
-#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
+/// Terrain resolves its packed low-byte base and high-byte overlay through
+/// map-local `Land1.wea`, then pairs each layer with its `Land2.wea`
+/// microtexture and type-18 UV stream. Type-14 alpha remains a continuous
+/// interpolated overlay mask. MSH component hierarchy, animation sampling,
+/// lightmaps, and gameplay visibility are handled by the preview bridge below.
+
+fn static_preview_node_times(
+ node_count: usize,
+ bindings: &[PreparedControlNodeBinding],
+) -> Result<Vec<Option<AnimationTime>>, String> {
+ let mut node_times = vec![Some(AnimationTime(0.0)); node_count];
+ for binding in bindings {
+ // Disabled rows are filtered while loading CTLD, but preserve the
+ // native guard here in case a caller constructs MissionAssets itself.
+ if binding.flags & 4 != 0 {
+ continue;
+ }
+ if binding.node_index >= node_count {
+ return Err(format!(
+ "control binding node {} is outside model node count {node_count}",
+ binding.node_index
+ ));
+ }
+ if !binding.frame_a.is_finite()
+ || !binding.frame_b.is_finite()
+ || !binding.initial_blend.is_finite()
+ || binding.frame_a < 0.0
+ || binding.frame_b < 0.0
+ {
+ return Err(format!(
+ "control binding for node {} contains an invalid frame or blend",
+ binding.node_index
+ ));
+ }
+
+ let mut blend = binding.initial_blend;
+ if binding.flags & 1 != 0 {
+ if blend > 1.0 {
+ blend -= 1.0;
+ }
+ if blend < 0.0 {
+ blend += 1.0;
+ }
+ } else {
+ blend = blend.clamp(0.0, 1.0);
+ }
+ if binding.flags & 2 != 0 {
+ blend = 1.0 - blend;
+ }
+ let sample_time = (1.0 - blend) * binding.frame_a + blend * binding.frame_b;
+ if !sample_time.is_finite() || sample_time < 0.0 {
+ return Err(format!(
+ "control binding for node {} produced a negative or non-finite sample time unsupported by the static sampler",
+ binding.node_index
+ ));
+ }
+ // Source order is significant: native initialization writes each
+ // enabled row in turn, so a later row for the same node wins.
+ node_times[binding.node_index] = Some(AnimationTime(sample_time));
+ }
+ Ok(node_times)
+}
+
+fn static_preview_component_pose_buffer(
+ model: &ModelAsset,
+ bindings: &[PreparedControlNodeBinding],
+ static_animation_frame: Option<u16>,
+) -> Result<Option<NodePoseBuffer>, String> {
+ if let Some(frame) = static_animation_frame {
+ return Ok(node38_sampled_hierarchy(model, frame));
+ }
+ let node_times = static_preview_node_times(model.node_count, bindings)?;
+ Ok(node38_sampled_hierarchy_at_times(model, &node_times)
+ .or_else(|| node38_fallback_hierarchy(model)))
+}
+
fn static_preview_mesh_and_materials(
assets: &MissionAssets,
terrain: &TerrainWorld,
@@ -273,240 +3528,373 @@ fn static_preview_mesh_and_materials(
let terrain_mesh = terrain
.source_mesh()
.ok_or_else(|| "runtime terrain does not retain its validated source mesh".to_string())?;
- let xy_frame = legacy_camera
- .is_none()
- .then(|| static_preview_xy_frame(assets, terrain, roots))
- .transpose()?;
let mut mesh = VulkanStaticMesh {
vertices: Vec::new(),
indices: Vec::new(),
draw_ranges: Vec::new(),
};
let mut materials = Vec::new();
- let terrain_component = if legacy_camera.is_some() {
- project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh)
- } else {
- let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?;
- project_land_msh_to_static_mesh_in_xy_frame(terrain_mesh, frame)
- }
- .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
+ let mut material_animations = Vec::new();
+ let mut sun_occlusion_range_indices = Vec::new();
+ let mut shadow_casters = Vec::new();
+ let shadow_receivers = terrain_shadow_receivers(terrain_mesh)?;
+ let mut terrain_material_loader = StandaloneWearMaterialLoader::new(root);
+ let terrain_component = project_land_msh_to_static_mesh_in_legacy_world_space(terrain_mesh)
+ .map_err(|err| format!("project mission terrain for Vulkan: {err}"))?;
let terrain_materials = static_preview_terrain_base_materials(
+ &mut terrain_material_loader,
root,
land_msh_path,
&terrain_component,
&mut materials,
+ &mut material_animations,
+ static_material_phase,
+ )?;
+ append_static_preview_component(
+ &mut mesh,
+ terrain_component,
+ &terrain_materials,
+ Some(&mut material_animations),
)?;
- append_static_preview_component(&mut mesh, terrain_component, &terrain_materials)?;
let mut mesh_components = 0;
- for (object_index, root) in roots.iter().enumerate() {
- for visual_id in assets.visuals_for_object(object_index) {
- let visual = assets.visual_by_id(*visual_id).ok_or_else(|| {
- format!(
- "static preview root {object_index} references unknown visual {visual_id:?}"
- )
- })?;
- let Some(model_id) = visual.model_id else {
+ for (object_index, mission_root) in roots.iter().enumerate() {
+ let visual_ids = assets.visuals_for_object(object_index);
+ let records = &mission_root.unit_components;
+ let tree = if records.is_empty() {
+ None
+ } else {
+ Some(unit_component_tree(records).map_err(|error| {
+ format!("decode unit component tree for mission object {object_index}: {error}")
+ })?)
+ };
+ if let Some(tree) = tree.as_ref() {
+ if tree.len() != visual_ids.len() {
+ return Err(format!(
+ "mission object {object_index} unit tree has {} records but {} visuals",
+ tree.len(),
+ visual_ids.len()
+ ));
+ }
+ }
+ let component_refs = visual_ids
+ .iter()
+ .map(|visual_id| {
+ let visual = assets.visual_by_id(*visual_id).ok_or_else(|| {
+ format!(
+ "static preview root {object_index} references unknown visual {visual_id:?}"
+ )
+ })?;
+ let model = visual
+ .model_id
+ .map(|model_id| {
+ assets.model_by_id(model_id).ok_or_else(|| {
+ format!(
+ "static preview visual {visual_id:?} references unknown model {model_id:?}"
+ )
+ })
+ })
+ .transpose()?;
+ Ok((visual, model))
+ })
+ .collect::<Result<Vec<_>, String>>()?;
+ let component_controls = assets.object_component_controls.get(object_index);
+ if component_controls.is_some_and(|controls| controls.len() != component_refs.len()) {
+ return Err(format!(
+ "mission object {object_index} has {} visuals but {} component controls",
+ component_refs.len(),
+ component_controls.map_or(0, Vec::len)
+ ));
+ }
+ let root_native_kind = component_refs
+ .first()
+ .and_then(|(visual, _)| native_shadow_kind(visual.source_type));
+ let root_sun_eligible = native_sun_object_kind_eligible(
+ component_refs
+ .first()
+ .and_then(|(visual, _)| visual.source_type),
+ );
+ let mut root_shadow_triangles = Vec::new();
+ let mut root_shadow_spheres = Vec::new();
+ let mut component_mount_poses = vec![None; component_refs.len()];
+ let mut component_pose_buffers = vec![None; component_refs.len()];
+ for (component_index, (visual, model)) in component_refs.iter().enumerate() {
+ let Some(model) = *model else {
continue;
};
- let model = assets.model_by_id(model_id).ok_or_else(|| {
- format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
- })?;
- let transform = LegacyIron3dEulerTransform {
- translation: root.position,
- orientation_radians: root.orientation_raw,
- };
- let component = if legacy_camera.is_some() {
- if let Some(frame) = static_animation_frame {
- project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
- &model.validated,
- transform,
- root.scale,
- frame,
- )
+ let component_bindings = component_controls
+ .and_then(|controls| controls.get(component_index))
+ .and_then(Option::as_ref)
+ .map_or(&[][..], |control| control.bindings.as_slice());
+ component_pose_buffers[component_index] = static_preview_component_pose_buffer(
+ &model.validated,
+ component_bindings,
+ static_animation_frame,
+ )?;
+ let should_render = tree.as_ref().map_or(true, |_| {
+ unit_component_is_visible(component_index, visual.source_type)
+ });
+ // Native AniMesh only resolves global MSH node sockets for EXTO
+ // geometry. Internal INTO components use controller/equipment
+ // slots, whose `parent_or_link` values are not MSH node indices.
+ // Do not ask the MSH hierarchy to resolve hidden internal parts.
+ let mount_pose = if should_render {
+ if let Some(tree) = tree.as_ref() {
+ let record = records.get(component_index).ok_or_else(|| {
+ format!("unit component {component_index} is outside decoded records")
+ })?;
+ let parent_index = tree[component_index].parent_index;
+ match parent_index {
+ None => None,
+ Some(parent_index) => {
+ let parent_visible = component_refs.get(parent_index).is_some_and(
+ |(parent_visual, _)| {
+ unit_component_is_visible(
+ parent_index,
+ parent_visual.source_type,
+ )
+ },
+ );
+ if !parent_visible {
+ None
+ } else {
+ let parent_model = component_refs
+ .get(parent_index)
+ .and_then(|(_, model)| *model)
+ .ok_or_else(|| {
+ format!(
+ "unit component {component_index} parent {parent_index} has no model for mount socket"
+ )
+ })?;
+ let parent_hierarchy = component_pose_buffers
+ .get(parent_index)
+ .and_then(Option::as_ref);
+ let parent_is_mounted =
+ component_mount_poses[parent_index].is_some();
+ let socket =
+ usize::try_from(record.parent_or_link).map_err(|_| {
+ format!(
+ "unit component {component_index} has negative mount socket {}",
+ record.parent_or_link
+ )
+ })?;
+ let parent_socket = match parent_hierarchy {
+ Some(hierarchy) if parent_is_mounted => {
+ node38_pose_relative_to_root_from_hierarchy(
+ hierarchy, socket,
+ )
+ }
+ Some(hierarchy) => {
+ node38_pose_from_hierarchy(hierarchy, socket)
+ }
+ None if parent_is_mounted => node38_pose_relative_to_root(
+ &parent_model.validated,
+ static_animation_frame,
+ socket,
+ ),
+ None => node38_pose(
+ &parent_model.validated,
+ static_animation_frame,
+ socket,
+ ),
+ }
+ .ok_or_else(|| {
+ format!(
+ "unit component {component_index} mount socket {socket} is absent from parent {parent_index} (parent source={:?}, node_count={}, slots={}, animation={})",
+ parent_model.source,
+ parent_model.validated.node_count,
+ parent_model.validated.slots.len(),
+ parent_model.validated.animation.is_some(),
+ )
+ })?;
+ let parent_root_pose = component_mount_poses[parent_index]
+ .unwrap_or(VulkanNodePose {
+ translation: [0.0; 3],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ });
+ Some(compose_unit_pose(parent_root_pose, parent_socket))
+ }
+ }
+ }
} else {
- project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
- &model.validated,
- transform,
- root.scale,
- )
+ None
}
} else {
- let frame = xy_frame.ok_or_else(|| "missing diagnostic XY frame".to_string())?;
- if let Some(animation_frame) = static_animation_frame {
- project_msh_to_static_mesh_in_xy_frame_with_node_sampled_poses(
- &model.validated,
- frame,
- transform,
- root.scale,
- animation_frame,
- )
- } else {
- project_msh_to_static_mesh_in_xy_frame_with_node_fallback_poses(
- &model.validated,
- frame,
- transform,
- root.scale,
- )
+ None
+ };
+ if let Some(mount_pose) = mount_pose {
+ component_mount_poses[component_index] = Some(mount_pose);
+ }
+
+ let transform = LegacyIron3dEulerTransform {
+ translation: mission_root.position,
+ orientation_radians: mission_root.orientation_raw,
+ };
+ if should_render {
+ let pose_buffer = component_pose_buffers[component_index].as_ref();
+ let component = match (pose_buffer, mount_pose, static_animation_frame) {
+ (Some(poses), Some(mount), _) => {
+ project_msh_to_static_mesh_in_world_space_with_node_pose_buffer_and_mount(
+ &model.validated,
+ transform,
+ mission_root.scale,
+ poses,
+ mount,
+ )
+ }
+ (Some(poses), None, _) => {
+ project_msh_to_static_mesh_in_world_space_with_node_pose_buffer(
+ &model.validated,
+ transform,
+ mission_root.scale,
+ poses,
+ )
+ }
+ (None, Some(mount), Some(frame)) => {
+ project_msh_to_static_mesh_in_world_space_with_node_sampled_poses_and_mount(
+ &model.validated,
+ transform,
+ mission_root.scale,
+ frame,
+ mount,
+ )
+ }
+ (None, Some(mount), None) => {
+ project_msh_to_static_mesh_in_world_space_with_node_fallback_poses_and_mount(
+ &model.validated,
+ transform,
+ mission_root.scale,
+ mount,
+ )
+ }
+ (None, None, Some(frame)) => {
+ project_msh_to_static_mesh_in_world_space_with_node_sampled_poses(
+ &model.validated,
+ transform,
+ mission_root.scale,
+ frame,
+ )
+ }
+ (None, None, None) => {
+ project_msh_to_static_mesh_in_world_space_with_node_fallback_poses(
+ &model.validated,
+ transform,
+ mission_root.scale,
+ )
+ }
+ }
+ .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
+ let component_shadow_triangles = static_mesh_shadow_triangles(&component);
+ if root_native_kind.is_some() {
+ root_shadow_triangles.extend(component_shadow_triangles);
+ let node1_pose = mount_pose.and_then(|_| match pose_buffer {
+ Some(poses) => node38_pose_relative_to_root_from_hierarchy(poses, 1),
+ None => node38_pose_relative_to_root(
+ &model.validated,
+ static_animation_frame,
+ 1,
+ ),
+ });
+ if let Some(sphere) = mounted_shadow_sphere(
+ model.validated.bounding_sphere_center,
+ model.validated.bounding_sphere_radius,
+ mount_pose,
+ node1_pose,
+ ) {
+ root_shadow_spheres.push(sphere);
+ }
+ }
+ let selector_remap = static_preview_component_materials(
+ assets,
+ visual,
+ &component,
+ static_material_phase,
+ &mut materials,
+ &mut material_animations,
+ )?;
+ let lightmap_textures = static_preview_lightmap_textures(assets, visual)?;
+ let range_start = mesh.draw_ranges.len();
+ append_static_preview_component_with_lightmaps(
+ &mut mesh,
+ component,
+ &selector_remap,
+ Some(&mut materials),
+ &lightmap_textures,
+ Some(&mut material_animations),
+ )?;
+ if root_sun_eligible {
+ sun_occlusion_range_indices.extend(range_start..mesh.draw_ranges.len());
}
+ mesh_components += 1;
+ }
+ }
+ if let Some(native_kind) = root_native_kind {
+ let caster_id = u32::try_from(shadow_casters.len())
+ .map_err(|_| "shadow caster id exceeds u32".to_string())?;
+ if let Some(caster) = aggregate_shadow_caster(
+ caster_id,
+ native_kind,
+ LegacyIron3dEulerTransform {
+ translation: mission_root.position,
+ orientation_radians: mission_root.orientation_raw,
+ },
+ mission_root.scale,
+ root_shadow_triangles,
+ &root_shadow_spheres,
+ ) {
+ shadow_casters.push(caster);
}
- .map_err(|err| format!("project mission MSH for Vulkan: {err}"))?;
- let selector_remap = static_preview_component_materials(
- assets,
- visual,
- &component,
- static_material_phase,
- &mut materials,
- )?;
- append_static_preview_component(&mut mesh, component, &selector_remap)?;
- mesh_components += 1;
}
}
if mesh_components == 0 {
return Err("selected static preview roots have no mesh-backed visual".to_string());
}
- let camera = legacy_camera.unwrap_or_default();
+ let free_camera = if legacy_camera.is_none() {
+ Some(FreeFlightCamera::from_mesh_and_terrain(&mesh, terrain)?)
+ } else {
+ None
+ };
+ let camera = legacy_camera.unwrap_or_else(|| {
+ free_camera
+ .map(|camera| camera.vulkan_camera(16.0 / 9.0))
+ .unwrap_or_default()
+ });
+ let shadow_material_index = append_shadow_material(&mut materials)?;
Ok(StaticPreviewScene {
- clip_visible_vertices: static_preview_clip_visible_vertices(&mesh, camera),
mesh,
materials,
+ material_animations,
+ sun_occlusion_range_indices,
+ shadow_casters,
+ shadow_receivers,
+ shadow_material_index,
+ terrain: Arc::new(terrain.clone()),
camera,
+ free_camera,
camera_mode: if legacy_camera.is_some() {
"legacy-d3d7-capture"
} else {
- "diagnostic-xy"
+ "free-flight"
},
mesh_components,
terrain_components: 1,
- animation_frame: static_animation_frame,
- material_phase: static_material_phase,
})
}
-/// Counts source vertices inside the Vulkan clip volume for a static preview.
-///
-/// The static vertex shader receives row-major D3D7 matrix data as a GLSL
-/// column-major `mat4`; its multiplication is consequently equivalent to this
-/// row-vector calculation. This remains CPU-only diagnostic evidence and does
-/// not change the renderer's clipping rules.
-fn static_preview_clip_visible_vertices(
- mesh: &VulkanStaticMesh,
- camera: VulkanStaticCamera,
-) -> usize {
- mesh.vertices
- .iter()
- .filter(|vertex| {
- let [x, y, z] = vertex.position;
- let matrix = camera.clip_from_world;
- let clip_x = x.mul_add(
- matrix[0],
- y.mul_add(matrix[4], z.mul_add(matrix[8], matrix[12])),
- );
- let clip_y = x.mul_add(
- matrix[1],
- y.mul_add(matrix[5], z.mul_add(matrix[9], matrix[13])),
- );
- let clip_z = x.mul_add(
- matrix[2],
- y.mul_add(matrix[6], z.mul_add(matrix[10], matrix[14])),
- );
- let clip_w = x.mul_add(
- matrix[3],
- y.mul_add(matrix[7], z.mul_add(matrix[11], matrix[15])),
- );
- clip_w.is_finite()
- && clip_w > 0.0
- && (-clip_w..=clip_w).contains(&clip_x)
- && (-clip_w..=clip_w).contains(&clip_y)
- && (0.0..=clip_w).contains(&clip_z)
- })
- .count()
-}
-
-fn static_preview_xy_frame(
- assets: &MissionAssets,
- terrain: &TerrainWorld,
- roots: &[MissionObjectDraft],
-) -> Result<VulkanStaticXyFrame, String> {
- let mut min_x = f32::INFINITY;
- let mut max_x = f32::NEG_INFINITY;
- let mut min_y = f32::INFINITY;
- let mut max_y = f32::NEG_INFINITY;
- let terrain_positions = terrain
- .source_positions()
- .ok_or_else(|| "runtime terrain does not retain source positions".to_string())?;
- for position in terrain_positions {
- extend_static_preview_xy_bounds(*position, &mut min_x, &mut max_x, &mut min_y, &mut max_y)?;
- }
- for (object_index, root) in roots.iter().enumerate() {
- if !root
- .position
- .iter()
- .chain(root.scale.iter())
- .all(|value| value.is_finite())
- {
- return Err(format!(
- "static preview root {object_index} has a non-finite position or scale"
- ));
- }
- for visual_id in assets.visuals_for_object(object_index) {
- let visual = assets.visual_by_id(*visual_id).ok_or_else(|| {
- format!(
- "static preview root {object_index} references unknown visual {visual_id:?}"
- )
- })?;
- let Some(model_id) = visual.model_id else {
- continue;
- };
- let model = assets.model_by_id(model_id).ok_or_else(|| {
- format!("static preview visual {visual_id:?} references unknown model {model_id:?}")
- })?;
- for position in &model.validated.positions {
- let position = LegacyIron3dEulerTransform {
- translation: root.position,
- orientation_radians: root.orientation_raw,
- }
- .try_transform_scaled_point(*position, root.scale)
- .ok_or_else(|| {
- "static preview contains a non-finite transformed XY position".to_string()
- })?;
- extend_static_preview_xy_bounds(
- position, &mut min_x, &mut max_x, &mut min_y, &mut max_y,
- )?;
- }
- }
- }
- VulkanStaticXyFrame::from_bounds(min_x, max_x, min_y, max_y)
- .map_err(|err| format!("build static preview XY frame: {err}"))
-}
-
-fn extend_static_preview_xy_bounds(
- position: [f32; 3],
- min_x: &mut f32,
- max_x: &mut f32,
- min_y: &mut f32,
- max_y: &mut f32,
-) -> Result<(), String> {
- if !position.iter().all(|value| value.is_finite()) {
- return Err("static preview contains a non-finite XY position".to_string());
- }
- *min_x = min_x.min(position[0]);
- *max_x = max_x.max(position[0]);
- *min_y = min_y.min(position[1]);
- *max_y = max_y.max(position[1]);
- Ok(())
-}
-
fn static_preview_terrain_base_materials(
+ loader: &mut StandaloneWearMaterialLoader,
root: &std::path::Path,
land_msh_path: &str,
mesh: &VulkanStaticMesh,
materials: &mut Vec<VulkanStaticMaterial>,
+ material_animations: &mut Vec<MaterialAnimationBinding>,
+ static_material_phase: Option<u16>,
) -> Result<Vec<(u16, u16)>, String> {
let land_path = root.join(land_msh_path);
- let wear_path = land_path
+ let land_parent = land_path
.parent()
- .ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))?
- .join("Land2.wea");
+ .ok_or_else(|| format!("terrain mesh has no parent path: {}", land_path.display()))?;
+ let land1_path = land_parent.join("Land1.wea");
+ let land2_path = land_parent.join("Land2.wea");
let mut packed_tags = mesh
.draw_ranges
.iter()
@@ -517,29 +3905,231 @@ fn static_preview_terrain_base_materials(
packed_tags
.into_iter()
.map(|packed_tag| {
- let selection = TerrainMaterialLayers::from_packed_tag(packed_tag).land2_selection();
- let selected = load_standalone_wear_material_texture_mip0_rgba8_from_root(
- root,
- &wear_path,
- selection.material_index,
- )
- .map_err(|err| {
- format!(
- "resolve terrain base material tag 0x{packed_tag:04x} through {}: {err}",
- wear_path.display()
- )
- })?;
- let preview_selector = u16::try_from(materials.len()).map_err(|_| {
- "static preview exceeds the available 16-bit material selector space".to_string()
- })?;
- materials.push(VulkanStaticMaterial {
- material_index: preview_selector,
- texture: VulkanStaticTexture {
- width: selected.image.width,
- height: selected.image.height,
- rgba8: selected.image.rgba8,
+ let layers = TerrainMaterialLayers::from_packed_tag(packed_tag);
+ let base_document = loader
+ .material_document(&land1_path, layers.base_selection().material_index)
+ .map_err(|err| format!("resolve terrain base MAT0 document: {err}"))?;
+ let detail_document = loader
+ .material_document(&land2_path, layers.detail_base_selection().material_index)
+ .map_err(|err| format!("resolve terrain detail MAT0 document: {err}"))?;
+ let overlay_selection = layers.overlay_selection();
+ let overlay_detail_selection = layers.detail_overlay_selection();
+ let overlay_document = overlay_selection
+ .map(|selection| loader.material_document(&land1_path, selection.material_index))
+ .transpose()
+ .map_err(|err| format!("resolve terrain overlay MAT0 document: {err}"))?;
+ let overlay_detail_document = overlay_detail_selection
+ .map(|selection| loader.material_document(&land2_path, selection.material_index))
+ .transpose()
+ .map_err(|err| format!("resolve terrain overlay detail MAT0 document: {err}"))?;
+ let base_phases = load_standalone_phase_set(
+ loader,
+ &land1_path,
+ layers.base_selection().material_index,
+ &base_document,
+ )?;
+ let detail_phases = load_standalone_phase_set(
+ loader,
+ &land2_path,
+ layers.detail_base_selection().material_index,
+ &detail_document,
+ )?;
+ let overlay_phases = overlay_selection
+ .map(|selection| {
+ load_standalone_phase_set(
+ loader,
+ &land1_path,
+ selection.material_index,
+ overlay_document.as_ref().expect("overlay document"),
+ )
+ })
+ .transpose()?;
+ let overlay_detail_phases = overlay_detail_selection
+ .map(|selection| {
+ load_standalone_phase_set(
+ loader,
+ &land2_path,
+ selection.material_index,
+ overlay_detail_document
+ .as_ref()
+ .expect("overlay detail document"),
+ )
+ })
+ .transpose()?;
+ let phase_count = base_phases.len();
+ if phase_count == 0 || detail_phases.is_empty() {
+ return Err(format!(
+ "terrain material tag 0x{packed_tag:04x} has incomplete phase tables"
+ ));
+ }
+ if overlay_phases.as_ref().is_some_and(Vec::is_empty)
+ || overlay_detail_phases.as_ref().is_some_and(Vec::is_empty)
+ {
+ return Err(format!(
+ "terrain material tag 0x{packed_tag:04x} has an empty optional phase table"
+ ));
+ }
+ let initial_phase_index =
+ usize::from(static_material_phase.unwrap_or(0)).min(phase_count.saturating_sub(1));
+ let initial_detail_phase_index = usize::from(static_material_phase.unwrap_or(0))
+ .min(detail_phases.len().saturating_sub(1));
+ let initial_overlay_phase_index = overlay_phases.as_ref().map(|phases| {
+ usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1))
+ });
+ let initial_overlay_detail_phase_index = overlay_detail_phases.as_ref().map(|phases| {
+ usize::from(static_material_phase.unwrap_or(0)).min(phases.len().saturating_sub(1))
+ });
+ let mut phase_material_indices = Vec::with_capacity(phase_count);
+ for phase_index in 0..phase_count {
+ let (base, base_phase) = &base_phases[phase_index];
+ // The base draw selector follows the base MAT0 timeline. The
+ // other descriptor stages start at their own initial phase;
+ // frame-time sampling below can then advance them separately.
+ let (detail, detail_phase) = &detail_phases[initial_detail_phase_index];
+ let overlay = overlay_phases
+ .as_ref()
+ .zip(initial_overlay_phase_index)
+ .map(|(phases, phase_index)| &phases[phase_index]);
+ let overlay_detail = overlay_detail_phases
+ .as_ref()
+ .zip(initial_overlay_detail_phase_index)
+ .map(|(phases, phase_index)| &phases[phase_index]);
+ let preview_selector = u16::try_from(materials.len()).map_err(|_| {
+ "static preview exceeds the available 16-bit material selector space"
+ .to_string()
+ })?;
+ materials.push(VulkanStaticMaterial {
+ material_index: preview_selector,
+ texture: base.clone(),
+ detail_texture: Some(detail.clone()),
+ overlay_texture: overlay.map(|(texture, _)| texture.clone()),
+ overlay_detail_texture: overlay_detail.map(|(texture, _)| texture.clone()),
+ diffuse_alpha: base_phase.diffuse_alpha,
+ overlay_diffuse_alpha: overlay.map(|(_, phase)| phase.diffuse_alpha),
+ directional_rgb: base_phase.directional_rgb,
+ additive_rgb: base_phase.additive_rgb,
+ uv_transform: base_phase.page_uv_transform,
+ overlay_directional_rgb: overlay.map(|(_, phase)| phase.directional_rgb),
+ overlay_additive_rgb: overlay.map(|(_, phase)| phase.additive_rgb),
+ specular_rgb: base_phase.specular_rgb,
+ specular_power: base_phase.power,
+ overlay_specular_rgb: overlay.map(|(_, phase)| phase.specular_rgb),
+ overlay_specular_power: overlay.map(|(_, phase)| phase.power),
+ detail_uv_transform: detail_phase.page_uv_transform,
+ overlay_uv_transform: overlay.map(|(_, phase)| phase.page_uv_transform),
+ overlay_detail_uv_transform: overlay_detail
+ .map(|(_, phase)| phase.page_uv_transform),
+ unlit: false,
+ sky_nebula_stars: false,
+ lightmap_mode: false,
+ sky_far_depth: false,
+ });
+ phase_material_indices.push(usize::from(preview_selector));
+ }
+ let base_uvs = phase_uv_transforms_from_standalone(
+ loader,
+ &land1_path,
+ layers.base_selection().material_index,
+ &base_document,
+ )?;
+ let detail_uvs = phase_uv_transforms_from_standalone(
+ loader,
+ &land2_path,
+ layers.detail_base_selection().material_index,
+ &detail_document,
+ )?;
+ let overlay_uvs = match (&overlay_document, layers.overlay_selection()) {
+ (Some(document), Some(selection)) => phase_uv_transforms_from_standalone(
+ loader,
+ &land1_path,
+ selection.material_index,
+ document,
+ )?,
+ _ => Vec::new(),
+ };
+ let overlay_detail_uvs =
+ match (&overlay_detail_document, layers.detail_overlay_selection()) {
+ (Some(document), Some(selection)) => phase_uv_transforms_from_standalone(
+ loader,
+ &land2_path,
+ selection.material_index,
+ document,
+ )?,
+ _ => Vec::new(),
+ };
+ let stage_textures = MaterialStageTextures {
+ detail: Some(
+ detail_phases
+ .iter()
+ .map(|(texture, _)| texture.clone())
+ .collect(),
+ ),
+ overlay: overlay_phases
+ .as_ref()
+ .map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()),
+ overlay_detail: overlay_detail_phases
+ .as_ref()
+ .map(|phases| phases.iter().map(|(texture, _)| texture.clone()).collect()),
+ applied_material_phases: phase_material_indices
+ .iter()
+ .copied()
+ .map(|material_index| {
+ (
+ material_index,
+ [
+ initial_detail_phase_index,
+ initial_overlay_phase_index.unwrap_or(0),
+ initial_overlay_detail_phase_index.unwrap_or(0),
+ ],
+ )
+ })
+ .collect(),
+ };
+ material_animations.push(MaterialAnimationBinding {
+ initial_phase_index: static_material_phase
+ .map_or(0, usize::from)
+ .min(base_document.phases.len().saturating_sub(1)),
+ variants: vec![MaterialVariantSet {
+ phase_material_indices,
+ range_indices: Vec::new(),
+ }],
+ base: MaterialPhaseBinding {
+ document: base_document,
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: packed_tag as u32,
+ frozen_phase_index: static_material_phase.map(usize::from),
+ phase_uv_transforms: base_uvs,
},
+ detail: Some(MaterialPhaseBinding {
+ document: detail_document,
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: packed_tag as u32 ^ 0x5A5A_A5A5,
+ frozen_phase_index: static_material_phase.map(usize::from),
+ phase_uv_transforms: detail_uvs,
+ }),
+ overlay: overlay_document.map(|document| MaterialPhaseBinding {
+ document,
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: packed_tag as u32 ^ 0xA5A5_5A5A,
+ frozen_phase_index: static_material_phase.map(usize::from),
+ phase_uv_transforms: overlay_uvs,
+ }),
+ overlay_detail: overlay_detail_document.map(|document| MaterialPhaseBinding {
+ document,
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: packed_tag as u32 ^ 0xC3C3_3C3C,
+ frozen_phase_index: static_material_phase.map(usize::from),
+ phase_uv_transforms: overlay_detail_uvs,
+ }),
+ stage_textures: Some(stage_textures),
});
+ let preview_selector =
+ u16::try_from(materials.len() - phase_count + initial_phase_index)
+ .map_err(|_| "terrain material selector exceeds u16".to_string())?;
Ok((packed_tag, preview_selector))
})
.collect()
@@ -551,6 +4141,7 @@ fn static_preview_component_materials(
mesh: &VulkanStaticMesh,
static_material_phase: Option<u16>,
materials: &mut Vec<VulkanStaticMaterial>,
+ material_animations: &mut Vec<MaterialAnimationBinding>,
) -> Result<Vec<(u16, u16)>, String> {
let mut source_selectors = mesh
.draw_ranges
@@ -573,44 +4164,143 @@ fn static_preview_component_materials(
let material = assets.material_by_id(*material_id).ok_or_else(|| {
format!("static preview prepared material {source_selector} is unavailable")
})?;
- let texture_name = static_material_phase
- .and_then(|phase_index| {
- let phase = material.mat0.phases.get(usize::from(phase_index))?;
- let length = phase.texture_raw.iter().position(|byte| *byte == 0).unwrap_or(phase.texture_raw.len());
- (length != 0).then(|| ResourceName(phase.texture_raw[..length].to_vec()))
- })
- .or_else(|| material.texture_requests.first().cloned())
- .ok_or_else(|| {
- format!("static preview material {source_selector} has no MAT0 diffuse texture")
+ let initial_phase_index = usize::from(static_material_phase.unwrap_or(0))
+ .min(material.mat0.phases.len().saturating_sub(1));
+ if material.mat0.phases.is_empty() {
+ return Err(format!(
+ "static preview material {source_selector} has no MAT0 phases"
+ ));
+ }
+ let mut phase_material_indices = Vec::with_capacity(material.mat0.phases.len());
+ for phase in &material.mat0.phases {
+ let texture_name = phase_texture_name(phase);
+ let prepared_texture = texture_name.as_ref().and_then(|name| {
+ assets.textures.iter().find(|texture| {
+ texture.usage == PreparedTextureUsage::Diffuse
+ && texture.source.name == *name
+ })
+ });
+ // Empty MAT0 texture names remain valid untextured materials.
+ let texture = prepared_texture
+ .map(|texture| {
+ prepared_texture_to_vulkan(texture, "static preview diffuse texture")
+ })
+ .transpose()?
+ .unwrap_or_else(|| solid_environment_texture([255, 255, 255, 255]));
+ let coefficients = phase.coefficients();
+ let uv_transform = if let Some(texture) = prepared_texture {
+ page_uv_transform(
+ texture.texm.width(),
+ texture.texm.height(),
+ &texture.texm.page_rects(),
+ coefficients.page_index,
+ )?
+ } else {
+ [0.0, 0.0, 1.0, 1.0]
+ };
+ let preview_selector = u16::try_from(materials.len()).map_err(|_| {
+ "static preview exceeds the available 16-bit material selector space"
+ .to_string()
})?;
+ materials.push(VulkanStaticMaterial {
+ material_index: preview_selector,
+ texture,
+ detail_texture: None,
+ overlay_texture: None,
+ overlay_detail_texture: None,
+ diffuse_alpha: coefficients.opacity,
+ overlay_diffuse_alpha: None,
+ directional_rgb: coefficients.directional_rgb,
+ additive_rgb: coefficients.additive_rgb,
+ uv_transform,
+ overlay_directional_rgb: None,
+ overlay_additive_rgb: None,
+ specular_rgb: coefficients.specular_rgb,
+ specular_power: coefficients.power,
+ overlay_specular_rgb: None,
+ overlay_specular_power: None,
+ detail_uv_transform: [0.0, 0.0, 1.0, 1.0],
+ overlay_uv_transform: None,
+ overlay_detail_uv_transform: None,
+ unlit: false,
+ sky_nebula_stars: false,
+ lightmap_mode: false,
+ sky_far_depth: false,
+ });
+ phase_material_indices.push(usize::from(preview_selector));
+ }
+ let preview_selector = u16::try_from(
+ *phase_material_indices
+ .get(initial_phase_index)
+ .ok_or_else(|| "MAT0 phase variant table is empty".to_string())?,
+ )
+ .map_err(|_| "MAT0 phase variant selector exceeds u16".to_string())?;
+ material_animations.push(MaterialAnimationBinding {
+ initial_phase_index,
+ variants: vec![MaterialVariantSet {
+ phase_material_indices,
+ range_indices: Vec::new(),
+ }],
+ base: MaterialPhaseBinding {
+ document: material.mat0.clone(),
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: material_id.raw() as u32,
+ frozen_phase_index: static_material_phase.map(usize::from),
+ phase_uv_transforms: phase_uv_transforms_from_assets(&material.mat0, assets),
+ },
+ detail: None,
+ overlay: None,
+ overlay_detail: None,
+ stage_textures: None,
+ });
+ Ok((source_selector, preview_selector))
+ })
+ .collect()
+}
+
+fn prepared_texture_to_vulkan(
+ texture: &PreparedTexture,
+ label: &str,
+) -> Result<VulkanStaticTexture, String> {
+ let mip_images = (0..texture.texm.mip_count())
+ .map(|level| texture.decode_mip_rgba8(u32::try_from(level).unwrap_or(u32::MAX)))
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|err| format!("decode {label} {:?}: {err}", texture.source.name))?;
+ let image = mip_images
+ .first()
+ .ok_or_else(|| format!("{label} {:?} has no mip zero", texture.source.name))?;
+ Ok(VulkanStaticTexture {
+ width: image.width,
+ height: image.height,
+ rgba8: image.rgba8.clone(),
+ mip_levels: mip_images
+ .into_iter()
+ .map(|image| VulkanStaticTextureMip {
+ width: image.width,
+ height: image.height,
+ rgba8: image.rgba8,
+ })
+ .collect(),
+ })
+}
+
+fn static_preview_lightmap_textures(
+ assets: &MissionAssets,
+ visual: &PreparedVisual,
+) -> Result<Vec<VulkanStaticTexture>, String> {
+ visual
+ .lightmap_ids
+ .iter()
+ .map(|id| {
let texture = assets
.textures
.iter()
.find(|texture| {
- texture.usage == PreparedTextureUsage::Diffuse && texture.source.name == texture_name
+ texture.id == *id && texture.usage == PreparedTextureUsage::Lightmap
})
- .ok_or_else(|| {
- format!(
- "static preview diffuse texture {texture_name:?} for material {source_selector} is unavailable"
- )
- })?;
- let image = texture.decode_mip_rgba8(0).map_err(|err| {
- format!(
- "decode static preview diffuse texture {texture_name:?} for material {source_selector}: {err}"
- )
- })?;
- let preview_selector = u16::try_from(materials.len()).map_err(|_| {
- "static preview exceeds the available 16-bit material selector space".to_string()
- })?;
- materials.push(VulkanStaticMaterial {
- material_index: preview_selector,
- texture: VulkanStaticTexture {
- width: image.width,
- height: image.height,
- rgba8: image.rgba8,
- },
- });
- Ok((source_selector, preview_selector))
+ .ok_or_else(|| format!("static preview lightmap {id:?} is unavailable"))?;
+ prepared_texture_to_vulkan(texture, "static preview lightmap")
})
.collect()
}
@@ -619,6 +4309,25 @@ fn append_static_preview_component(
target: &mut VulkanStaticMesh,
component: VulkanStaticMesh,
selector_remap: &[(u16, u16)],
+ material_animations: Option<&mut Vec<MaterialAnimationBinding>>,
+) -> Result<(), String> {
+ append_static_preview_component_with_lightmaps(
+ target,
+ component,
+ selector_remap,
+ None,
+ &[],
+ material_animations,
+ )
+}
+
+fn append_static_preview_component_with_lightmaps(
+ target: &mut VulkanStaticMesh,
+ component: VulkanStaticMesh,
+ selector_remap: &[(u16, u16)],
+ mut materials: Option<&mut Vec<VulkanStaticMaterial>>,
+ lightmap_textures: &[VulkanStaticTexture],
+ mut material_animations: Option<&mut Vec<MaterialAnimationBinding>>,
) -> Result<(), String> {
let vertex_base = u32::try_from(target.vertices.len())
.map_err(|_| "static preview vertex count exceeds u32".to_string())?;
@@ -641,8 +4350,14 @@ fn append_static_preview_component(
})
.collect::<Result<Vec<_>, _>>()?,
);
+ // A lightmapped range must keep the lightmap material family when MAT0
+ // advances to another phase. The range's original selector identifies
+ // the base binding, while the lightmap selector identifies the secondary
+ // texture. Keep the generated phase family and the binding/variant it
+ // belongs to so update_material_animations can rebind every frame.
+ let mut lightmap_materials: HashMap<(usize, u8), (Vec<usize>, usize, usize)> = HashMap::new();
for range in component.draw_ranges {
- let material_index = selector_remap
+ let source_material_index = selector_remap
.iter()
.find_map(|(source, preview)| (*source == range.material_index).then_some(*preview))
.ok_or_else(|| {
@@ -651,6 +4366,95 @@ fn append_static_preview_component(
range.material_index
)
})?;
+ let (material_index, lightmap_variant) = if lightmap_textures
+ .get(usize::from(range.lightmap_index))
+ .is_some()
+ {
+ let key = (usize::from(source_material_index), range.lightmap_index);
+ let (phase_material_indices, binding_index, variant_index) = if let Some(existing) =
+ lightmap_materials.get(&key)
+ {
+ existing.clone()
+ } else {
+ let bindings = material_animations.as_deref().ok_or_else(|| {
+ "static preview lightmap animation storage is unavailable".to_string()
+ })?;
+ let (binding_index, source_phase_indices) =
+ find_material_animation_variant(bindings, usize::from(source_material_index))
+ .ok_or_else(|| {
+ format!(
+ "static preview material selector {source_material_index} has no MAT0 animation binding"
+ )
+ })?;
+ let lightmap = lightmap_textures
+ .get(usize::from(range.lightmap_index))
+ .expect("lightmap presence checked above");
+ let materials = materials.as_mut().ok_or_else(|| {
+ "static preview lightmap material storage is unavailable".to_string()
+ })?;
+ let mut phase_material_indices = Vec::with_capacity(source_phase_indices.len());
+ for source_phase_index in source_phase_indices {
+ let mut lightmap_material = materials
+ .get(source_phase_index)
+ .cloned()
+ .ok_or_else(|| {
+ format!(
+ "static preview material selector {source_phase_index} is unavailable"
+ )
+ })?;
+ let original_directional = lightmap_material.directional_rgb;
+ lightmap_material.directional_rgb = [0.0; 3];
+ lightmap_material.additive_rgb = original_directional;
+ lightmap_material.detail_texture = Some(lightmap.clone());
+ lightmap_material.detail_uv_transform = [0.0, 0.0, 1.0, 1.0];
+ lightmap_material.overlay_texture = None;
+ lightmap_material.overlay_detail_texture = None;
+ lightmap_material.overlay_diffuse_alpha = None;
+ lightmap_material.overlay_directional_rgb = None;
+ lightmap_material.overlay_additive_rgb = None;
+ lightmap_material.overlay_uv_transform = None;
+ lightmap_material.overlay_detail_uv_transform = None;
+ lightmap_material.unlit = false;
+ lightmap_material.sky_nebula_stars = false;
+ lightmap_material.lightmap_mode = true;
+ let index = u16::try_from(materials.len()).map_err(|_| {
+ "static preview exceeds the available 16-bit material selector space"
+ .to_string()
+ })?;
+ lightmap_material.material_index = index;
+ materials.push(lightmap_material);
+ phase_material_indices.push(usize::from(index));
+ }
+ let bindings = material_animations.as_deref_mut().ok_or_else(|| {
+ "static preview lightmap animation storage is unavailable".to_string()
+ })?;
+ let variant_index = bindings[binding_index].variants.len();
+ bindings[binding_index].variants.push(MaterialVariantSet {
+ phase_material_indices: phase_material_indices.clone(),
+ range_indices: Vec::new(),
+ });
+ let value = (phase_material_indices, binding_index, variant_index);
+ lightmap_materials.insert(key, value.clone());
+ value
+ };
+ let initial_material = *phase_material_indices
+ .get(
+ material_animations
+ .as_deref()
+ .and_then(|bindings| bindings.get(binding_index))
+ .map_or(0, |binding| binding.initial_phase_index),
+ )
+ .ok_or_else(|| "static preview lightmap phase table is empty".to_string())?;
+ (
+ u16::try_from(initial_material).map_err(|_| {
+ "static preview lightmap material selector exceeds u16".to_string()
+ })?,
+ Some((binding_index, variant_index)),
+ )
+ } else {
+ (source_material_index, None)
+ };
+ let target_range_index = target.draw_ranges.len();
target
.draw_ranges
.push(fparkan_render_vulkan::VulkanStaticDrawRange {
@@ -660,50 +4464,146 @@ fn append_static_preview_component(
material_index,
..range
});
+ if let Some(bindings) = material_animations.as_deref_mut() {
+ if let Some((binding_index, variant_index)) = lightmap_variant {
+ bindings
+ .get_mut(binding_index)
+ .and_then(|binding| binding.variants.get_mut(variant_index))
+ .ok_or_else(|| {
+ "static preview lightmap animation variant is missing".to_string()
+ })?
+ .range_indices
+ .push(target_range_index);
+ } else {
+ register_material_range(
+ bindings,
+ usize::from(source_material_index),
+ target_range_index,
+ );
+ }
+ }
}
Ok(())
}
-fn prepare_load_progress_path(path: &std::path::Path) -> Result<(), String> {
- if let Some(parent) = path.parent() {
- std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?;
- }
- std::fs::write(path, "Starting\telapsed_ms=0\n")
- .map_err(|err| format!("{}: {err}", path.display()))
+fn load_standalone_vulkan_texture(
+ loader: &mut StandaloneWearMaterialLoader,
+ wear_path: &Path,
+ material_index: u16,
+ phase_index: u16,
+) -> Result<(VulkanStaticTexture, PreparedMaterialPhase), String> {
+ let (mips, phase) = loader.load(wear_path, material_index, phase_index)?;
+ let first = mips
+ .first()
+ .ok_or_else(|| "standalone TEXM mip loader returned no mip zero".to_string())?;
+ Ok((
+ VulkanStaticTexture {
+ width: first.width,
+ height: first.height,
+ rgba8: first.rgba8.clone(),
+ mip_levels: mips
+ .into_iter()
+ .map(|image| VulkanStaticTextureMip {
+ width: image.width,
+ height: image.height,
+ rgba8: image.rgba8,
+ })
+ .collect(),
+ },
+ phase,
+ ))
}
-fn write_load_progress(
- path: &std::path::Path,
- elapsed: std::time::Duration,
- phase: MissionLoadPhase,
-) -> Result<(), String> {
- use std::io::Write;
-
- let mut file = std::fs::OpenOptions::new()
- .append(true)
- .open(path)
- .map_err(|err| format!("{}: {err}", path.display()))?;
- writeln!(file, "{phase:?}\telapsed_ms={}", elapsed.as_millis())
- .map_err(|err| format!("{}: {err}", path.display()))
+fn load_standalone_phase_set(
+ loader: &mut StandaloneWearMaterialLoader,
+ wear_path: &Path,
+ material_index: u16,
+ document: &Mat0Document,
+) -> Result<Vec<(VulkanStaticTexture, PreparedMaterialPhase)>, String> {
+ (0..document.phases.len())
+ .map(|phase_index| {
+ load_standalone_vulkan_texture(
+ loader,
+ wear_path,
+ material_index,
+ u16::try_from(phase_index)
+ .map_err(|_| "MAT0 phase index exceeds u16".to_string())?,
+ )
+ })
+ .collect()
}
fn run_static_vulkan_mode(
preview: StaticPreviewScene,
- preview_roots: usize,
+ environment: Option<DynamicEnvironment>,
+ audio: Option<audio::GameAudio>,
target_frames: u64,
mission: &str,
object_count: usize,
readback_out: Option<&std::path::Path>,
+ enable_validation: bool,
+ startup_timings: StartupTimings,
) -> Result<String, String> {
let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?;
event_loop.set_control_flow(ControlFlow::Poll);
+ let mut preview = preview;
+ let mut environment = environment;
+ // Reserve the projected-shadow draw range before the environment builder
+ // reorders its fixed passes. The range itself remains a stable draw slot;
+ // frame geometry is appended after every static environment allocation.
+ let mut shadow_range = append_environment_range(
+ &mut preview.mesh,
+ preview.shadow_material_index,
+ shadow_placeholder_vertices(),
+ quad_indices(1)?,
+ world_pipeline_state(LegacyBlendMode::SourceAlpha, LegacyDepthMode::TestReadOnly),
+ )?;
+ let environment_gpu = match environment.as_mut() {
+ Some(environment) => {
+ let camera = preview.free_camera.unwrap_or_else(|| {
+ FreeFlightCamera::from_mesh(&preview.mesh).unwrap_or(FreeFlightCamera {
+ position: [0.0; 3],
+ yaw: 0.0,
+ pitch: 0.0,
+ vertical_fov: std::f32::consts::FRAC_PI_3,
+ near_plane: 0.1,
+ far_plane: 100_000.0,
+ move_speed: 1.0,
+ })
+ });
+ let environment_materials = environment.materials.clone();
+ let material_assets = environment.material_assets.clone();
+ let sky_mesh = environment.sky.mesh().clone();
+ Some(EnvironmentGpuScene::new(
+ &mut preview.mesh,
+ &mut preview.materials,
+ &mut preview.material_animations,
+ &mut preview.sun_occlusion_range_indices,
+ &environment_materials,
+ &material_assets,
+ &sky_mesh,
+ &camera,
+ &mut shadow_range.range_index,
+ )?)
+ }
+ None => None,
+ };
+ let shadow_base_vertex = preview.mesh.vertices.len();
+ let shadow_base_index = preview.mesh.indices.len();
let mut app = StaticVulkanApp::new(
preview,
- preview_roots,
+ environment,
+ environment_gpu,
+ shadow_range,
+ shadow_base_vertex,
+ shadow_base_index,
+ audio,
target_frames,
mission,
object_count,
readback_out.map(std::path::Path::to_path_buf),
+ enable_validation,
+ startup_timings,
);
if let Err(err) = event_loop.run_app(&mut app) {
app.error = Some(format!("winit event loop: {err}"));
@@ -714,18 +4614,41 @@ fn run_static_vulkan_mode(
struct StaticVulkanApp {
mesh: VulkanStaticMesh,
materials: Vec<VulkanStaticMaterial>,
+ material_animations: Vec<MaterialAnimationBinding>,
+ sun_occlusion_range_indices: Vec<usize>,
+ terrain: Arc<TerrainWorld>,
+ world_range_indices: Vec<usize>,
+ world_range_index_counts: HashMap<usize, u32>,
camera: VulkanStaticCamera,
+ free_camera: Option<FreeFlightCamera>,
+ environment: Option<DynamicEnvironment>,
+ environment_gpu: Option<EnvironmentGpuScene>,
+ shadow_range: EnvironmentGpuRange,
+ shadow_base_vertex: usize,
+ shadow_base_index: usize,
+ shadow_material_index: usize,
+ shadow_casters: Vec<ShadowCaster>,
+ shadow_receivers: Vec<ShadowTriangle>,
+ shadow_cache: ShadowPageCache,
+ shadow_last_evidence: ShadowFrameEvidence,
+ environment_last_tick: Instant,
+ material_last_tick: Instant,
+ material_elapsed: Duration,
+ material_time_ms: u32,
+ environment_primitive_count: usize,
+ pressed_keys: HashSet<KeyCode>,
+ last_tick: Instant,
+ mouse_look: bool,
+ audio: Option<audio::GameAudio>,
camera_mode: &'static str,
- clip_visible_vertices: usize,
mesh_components: usize,
terrain_components: usize,
- animation_frame: Option<u16>,
- material_phase: Option<u16>,
- preview_roots: usize,
target_frames: u64,
mission: String,
object_count: usize,
readback_out: Option<PathBuf>,
+ enable_validation: bool,
+ startup_timings: StartupTimings,
window_id: Option<WindowId>,
window: Option<Window>,
renderer: Option<VulkanSmokeRenderer>,
@@ -737,27 +4660,73 @@ struct StaticVulkanApp {
impl StaticVulkanApp {
fn new(
preview: StaticPreviewScene,
- preview_roots: usize,
+ environment: Option<DynamicEnvironment>,
+ environment_gpu: Option<EnvironmentGpuScene>,
+ shadow_range: EnvironmentGpuRange,
+ shadow_base_vertex: usize,
+ shadow_base_index: usize,
+ audio: Option<audio::GameAudio>,
target_frames: u64,
mission: &str,
object_count: usize,
readback_out: Option<PathBuf>,
+ enable_validation: bool,
+ startup_timings: StartupTimings,
) -> Self {
+ let mut fixed_range_indices = environment_gpu
+ .as_ref()
+ .map(EnvironmentGpuScene::fixed_range_indices)
+ .unwrap_or_default();
+ fixed_range_indices.insert(shadow_range.range_index);
+ let world_range_indices = (0..preview.mesh.draw_ranges.len())
+ .filter(|range_index| !fixed_range_indices.contains(range_index))
+ .collect();
+ let world_range_index_counts = preview
+ .mesh
+ .draw_ranges
+ .iter()
+ .enumerate()
+ .filter(|(range_index, _)| !fixed_range_indices.contains(range_index))
+ .map(|(range_index, range)| (range_index, range.index_count))
+ .collect();
Self {
mesh: preview.mesh,
materials: preview.materials,
+ material_animations: preview.material_animations,
+ sun_occlusion_range_indices: preview.sun_occlusion_range_indices,
+ terrain: preview.terrain,
+ world_range_indices,
+ world_range_index_counts,
camera: preview.camera,
+ free_camera: preview.free_camera,
+ environment,
+ environment_gpu,
+ shadow_base_vertex,
+ shadow_base_index,
+ shadow_material_index: preview.shadow_material_index,
+ shadow_range,
+ shadow_casters: preview.shadow_casters,
+ shadow_receivers: preview.shadow_receivers,
+ shadow_cache: ShadowPageCache::default(),
+ shadow_last_evidence: ShadowFrameEvidence::default(),
+ environment_last_tick: Instant::now(),
+ material_last_tick: Instant::now(),
+ material_elapsed: Duration::ZERO,
+ material_time_ms: 0,
+ environment_primitive_count: 0,
+ pressed_keys: HashSet::new(),
+ last_tick: Instant::now(),
+ mouse_look: false,
+ audio,
camera_mode: preview.camera_mode,
- clip_visible_vertices: preview.clip_visible_vertices,
mesh_components: preview.mesh_components,
terrain_components: preview.terrain_components,
- animation_frame: preview.animation_frame,
- material_phase: preview.material_phase,
- preview_roots,
target_frames,
mission: mission.to_string(),
object_count,
readback_out,
+ enable_validation,
+ startup_timings,
window_id: None,
window: None,
renderer: None,
@@ -767,6 +4736,313 @@ impl StaticVulkanApp {
}
}
+ fn configure_world_draw_ranges(
+ &self,
+ renderer: &mut VulkanSmokeRenderer,
+ ) -> Result<(), String> {
+ for &range_index in &self.world_range_indices {
+ let range = self
+ .mesh
+ .draw_ranges
+ .get(range_index)
+ .ok_or_else(|| "world draw range index is out of bounds".to_string())?;
+ let alpha = self
+ .materials
+ .get(usize::from(range.material_index))
+ .map_or(1.0, |material| material.diffuse_alpha);
+ let transparent = native_world_transparent(range, alpha);
+ renderer
+ .set_draw_range_transparency(range_index, transparent)
+ .map_err(|error| format!("configure world draw range {range_index}: {error}"))?;
+ renderer
+ .set_draw_range_index_count(
+ range_index,
+ if alpha > 0.0 { range.index_count } else { 0 },
+ )
+ .map_err(|error| {
+ format!("configure world draw range {range_index} count: {error}")
+ })?;
+ }
+ Ok(())
+ }
+
+ fn update_world_transparency(
+ mesh: &VulkanStaticMesh,
+ materials: &[VulkanStaticMaterial],
+ world_range_indices: &[usize],
+ world_range_index_counts: &HashMap<usize, u32>,
+ active_materials: &HashMap<usize, ActiveMaterialState>,
+ renderer: &mut VulkanSmokeRenderer,
+ ) -> Result<(), String> {
+ for &range_index in world_range_indices {
+ let range = mesh
+ .draw_ranges
+ .get(range_index)
+ .ok_or_else(|| "world draw range index is out of bounds".to_string())?;
+ let state = active_materials.get(&range_index);
+ let alpha = state.map_or_else(
+ || {
+ materials
+ .get(usize::from(range.material_index))
+ .map_or(1.0, |material| material.diffuse_alpha)
+ },
+ |state| state.diffuse_alpha,
+ );
+ renderer
+ .set_draw_range_transparency(range_index, native_world_transparent(range, alpha))
+ .map_err(|error| format!("update world draw range {range_index}: {error}"))?;
+ let count = if alpha > 0.0 {
+ *world_range_index_counts
+ .get(&range_index)
+ .ok_or_else(|| "world draw range count is missing".to_string())?
+ } else {
+ 0
+ };
+ renderer
+ .set_draw_range_index_count(range_index, count)
+ .map_err(|error| format!("update world draw range {range_index} count: {error}"))?;
+ }
+ Ok(())
+ }
+
+ fn world_draw_sort_keys(&self, camera_position: [f32; 3]) -> Result<Vec<(usize, f32)>, String> {
+ let mut keys = Vec::with_capacity(self.world_range_indices.len());
+ for &range_index in &self.world_range_indices {
+ let sort_key = first_indexed_vertex_distance(&self.mesh, range_index, camera_position)?;
+ keys.push((range_index, sort_key));
+ }
+ Ok(keys)
+ }
+
+ fn update_free_camera(&mut self) -> Result<(), String> {
+ if self.free_camera.is_none() {
+ return Ok(());
+ }
+ let now = Instant::now();
+ let elapsed = now
+ .saturating_duration_since(self.last_tick)
+ .min(Duration::from_millis(100));
+ self.last_tick = now;
+ let Some(window) = self.window.as_ref() else {
+ return Ok(());
+ };
+ let size = window.inner_size();
+ let aspect = if size.height == 0 {
+ 16.0 / 9.0
+ } else {
+ size.width as f32 / size.height as f32
+ };
+ let (camera, listener_position, listener_forward, listener_up) = {
+ let free_camera = self
+ .free_camera
+ .as_mut()
+ .ok_or_else(|| "free-flight camera disappeared".to_string())?;
+ free_camera.advance(&self.pressed_keys, elapsed.as_secs_f32());
+ (
+ free_camera.vulkan_camera(aspect),
+ free_camera.position,
+ free_camera.forward(),
+ free_camera.up(),
+ )
+ };
+ self.camera = camera;
+ let sort_keys = self.world_draw_sort_keys(listener_position)?;
+ if let Some(renderer) = self.renderer.as_mut() {
+ renderer
+ .set_camera(camera)
+ .map_err(|error| format!("update free-flight camera: {error}"))?;
+ for (range_index, sort_key) in sort_keys {
+ renderer
+ .set_draw_range_sort_key(range_index, sort_key)
+ .map_err(|error| {
+ format!("update world draw range {range_index} sort key: {error}")
+ })?;
+ }
+ }
+ if let Some(audio) = self.audio.as_mut() {
+ audio
+ .update_listener(listener_position, listener_forward, listener_up)
+ .map_err(|error| format!("update audio listener: {error}"))?;
+ }
+ Ok(())
+ }
+
+ fn update_material_clock(&mut self) {
+ let now = Instant::now();
+ let elapsed = now
+ .saturating_duration_since(self.material_last_tick)
+ .min(Duration::from_millis(100));
+ self.material_last_tick = now;
+ self.material_elapsed = self.material_elapsed.saturating_add(elapsed);
+ // Keep the native u32 millisecond clock while flooring only the
+ // accumulated duration. This preserves sub-millisecond frame deltas
+ // instead of dropping them once per redraw.
+ self.material_time_ms = self.material_elapsed.as_millis() as u32;
+ }
+
+ fn update_shadow(
+ mesh: &mut VulkanStaticMesh,
+ shadow_cache: &mut ShadowPageCache,
+ shadow_casters: &[ShadowCaster],
+ shadow_receivers: &[ShadowTriangle],
+ shadow_range: EnvironmentGpuRange,
+ shadow_base_vertex: usize,
+ shadow_base_index: usize,
+ shadow_material_index: usize,
+ renderer: &mut VulkanSmokeRenderer,
+ sky_frame: Option<&SkyFrame<'_>>,
+ environment_frame: Option<&EnvironmentFrame>,
+ shadow_camera: Option<ShadowCamera>,
+ ) -> Result<ShadowFrameEvidence, String> {
+ let lights = shadow_lights(sky_frame, environment_frame, renderer);
+ let visible_casters = native_visible_shadow_casters(
+ shadow_casters,
+ renderer.frame_uniforms().clip_from_world,
+ );
+ let frame = shadow_cache
+ .build(ShadowScene {
+ lights: &lights,
+ casters: &visible_casters,
+ receivers: shadow_receivers,
+ fade: 1.0,
+ smooth_passes: 1,
+ camera: shadow_camera,
+ lod: ShadowLodSettings::default(),
+ })
+ .map_err(|error| format!("build projected shadow frame: {error}"))?;
+ let geometry = append_shadow_frame_geometry(
+ mesh,
+ shadow_range,
+ shadow_base_vertex,
+ shadow_base_index,
+ &frame,
+ )?;
+ let atlas = shadow_atlas_texture_from_frame(&frame);
+ renderer
+ .update_material_texture(shadow_material_index, 0, &atlas)
+ .map_err(|error| format!("upload projected shadow atlas: {error}"))?;
+ renderer
+ .update_mesh_geometry(&mesh.vertices, &mesh.indices, &[geometry])
+ .map_err(|error| format!("upload environment and shadow geometry: {error}"))?;
+ Ok(ShadowFrameEvidence {
+ visible_casters: visible_casters.len(),
+ projected_indices: frame.indices.len(),
+ })
+ }
+
+ fn update_environment(&mut self) -> Result<(), String> {
+ let Some(renderer) = self.renderer.as_mut() else {
+ return Ok(());
+ };
+ let viewport = self
+ .window
+ .as_ref()
+ .map(|window| {
+ let size = window.inner_size();
+ [size.width.max(1) as f32, size.height.max(1) as f32]
+ })
+ .unwrap_or([1280.0, 720.0]);
+ let aspect = viewport[0] / viewport[1].max(1.0);
+ let now = Instant::now();
+ let dt = now
+ .saturating_duration_since(self.environment_last_tick)
+ .min(Duration::from_millis(100))
+ .as_secs_f32();
+ self.environment_last_tick = now;
+ let active_materials = update_material_animations(
+ &mut self.material_animations,
+ &self.materials,
+ renderer,
+ self.material_time_ms,
+ )?;
+ Self::update_world_transparency(
+ &self.mesh,
+ &self.materials,
+ &self.world_range_indices,
+ &self.world_range_index_counts,
+ &active_materials,
+ renderer,
+ )?;
+ let free_camera = self.free_camera;
+ if let (Some(camera), Some(environment)) = (free_camera, self.environment.as_mut()) {
+ let (sky_frame, sky_mesh, environment_frame) =
+ environment.update(dt, &camera, aspect, viewport, renderer, self.audio.as_mut())?;
+ self.environment_primitive_count = environment_frame.primitives.len();
+ if let Some(environment_gpu) = self.environment_gpu.as_mut() {
+ environment_gpu.update(
+ &mut self.mesh,
+ &self.materials,
+ renderer,
+ &sky_frame,
+ &sky_mesh,
+ &environment_frame,
+ &camera,
+ viewport,
+ self.material_time_ms,
+ &self.terrain,
+ &self.sun_occlusion_range_indices,
+ )?;
+ }
+ let shadow_camera = ShadowCamera {
+ position: camera.position,
+ viewport_width: viewport[0],
+ horizontal_fov: horizontal_fov(&camera, aspect),
+ };
+ self.shadow_last_evidence = Self::update_shadow(
+ &mut self.mesh,
+ &mut self.shadow_cache,
+ &self.shadow_casters,
+ &self.shadow_receivers,
+ self.shadow_range,
+ self.shadow_base_vertex,
+ self.shadow_base_index,
+ self.shadow_material_index,
+ renderer,
+ Some(&sky_frame),
+ Some(&environment_frame),
+ Some(shadow_camera),
+ )?;
+ } else {
+ let shadow_camera = free_camera.map(|camera| ShadowCamera {
+ position: camera.position,
+ viewport_width: viewport[0],
+ horizontal_fov: horizontal_fov(&camera, aspect),
+ });
+ self.shadow_last_evidence = Self::update_shadow(
+ &mut self.mesh,
+ &mut self.shadow_cache,
+ &self.shadow_casters,
+ &self.shadow_receivers,
+ self.shadow_range,
+ self.shadow_base_vertex,
+ self.shadow_base_index,
+ self.shadow_material_index,
+ renderer,
+ None,
+ None,
+ shadow_camera,
+ )?;
+ }
+ Ok(())
+ }
+
+ fn set_mouse_look(&mut self, active: bool) {
+ self.mouse_look = active;
+ if let Some(window) = self.window.as_ref() {
+ if active {
+ // Relative device motion keeps the camera usable at the edge
+ // of the window. Locked grab is unavailable on a few window
+ // backends, so confined grab is a safe fallback.
+ if window.set_cursor_grab(CursorGrabMode::Locked).is_err() {
+ let _ = window.set_cursor_grab(CursorGrabMode::Confined);
+ }
+ } else {
+ let _ = window.set_cursor_grab(CursorGrabMode::None);
+ }
+ window.set_cursor_visible(!active);
+ }
+ }
+
fn schedule_next_redraw(&self) {
if let Some(window) = self.window.as_ref() {
window.request_redraw();
@@ -774,6 +5050,9 @@ impl StaticVulkanApp {
}
fn complete(&mut self, event_loop: &ActiveEventLoop) {
+ if let Some(audio) = self.audio.as_mut() {
+ audio.shutdown();
+ }
let Some(renderer) = self.renderer.take() else {
self.error = Some("native Vulkan renderer was not initialized".to_string());
event_loop.exit();
@@ -788,14 +5067,23 @@ impl StaticVulkanApp {
}
};
self.window.take();
- if report.validation.warning_count != 0 || report.validation.error_count != 0 {
+ if self.enable_validation
+ && (report.validation.warning_count != 0 || report.validation.error_count != 0)
+ {
self.error = Some(format!(
- "native Vulkan validation must stay clean (warnings={}, errors={})",
- report.validation.warning_count, report.validation.error_count
+ "native Vulkan validation must stay clean (warnings={}, errors={}, vuids={:?})",
+ report.validation.warning_count,
+ report.validation.error_count,
+ report.validation.vuids,
));
event_loop.exit();
return;
}
+ let validation_status = if self.enable_validation {
+ "clean"
+ } else {
+ "disabled"
+ };
let readback_path = match (&self.readback_out, &report.readback_artifact) {
(Some(path), Some(artifact)) => {
if let Some(parent) = path.parent() {
@@ -820,28 +5108,33 @@ impl StaticVulkanApp {
}
(None, _) => None,
};
+ let readback_format = report
+ .readback_artifact
+ .as_ref()
+ .map_or(report.renderer_report.swapchain_image_format, |artifact| {
+ artifact.format
+ });
self.output = Some(format!(
- "{{\"report_kind\":\"rendered-static-mission\",\"backend\":\"vulkan-static\",\"camera_mode\":{},\"window\":\"native\",\"mission\":{},\"objects\":{},\"frames\":{},\"preview_roots\":{},\"animation_frame\":{},\"material_phase\":{},\"mesh_components\":{},\"terrain_components\":{},\"clip_visible_vertices\":{},\"material_descriptors\":{},\"swapchain\":[{},{}],\"swapchain_images\":{},\"validation_warnings\":{},\"validation_errors\":{},\"readback_format\":{},\"readback_bytes\":{},\"readback_hash\":{},\"readback_path\":{}}}",
- json_string(self.camera_mode),
- json_string(&self.mission),
- self.object_count,
+ "rendered mission {}: {} frames, {} objects, {} mesh components, {} terrain components, camera={}, materials={}, environment_primitives={}, shadow_casters={}, shadow_indices={}, swapchain_recreates={}, validation={}, swapchain_format={}, readback_format={}, readback={}, startup_ms={{mission_assets:{:.1},terrain_materials:{:.1},sky:{:.1},gpu_initialization:{:.1}}}",
+ self.mission,
self.frames_presented,
- self.preview_roots,
- self.animation_frame.map_or_else(|| "null".to_string(), |frame| frame.to_string()),
- self.material_phase.map_or_else(|| "null".to_string(), |phase| phase.to_string()),
+ self.object_count,
self.mesh_components,
self.terrain_components,
- self.clip_visible_vertices,
+ self.camera_mode,
self.materials.len(),
- report.renderer_report.swapchain_extent.0,
- report.renderer_report.swapchain_extent.1,
- report.renderer_report.swapchain_image_count,
- report.validation.warning_count,
- report.validation.error_count,
- report.readback_artifact.as_ref().map_or(0, |artifact| artifact.format),
- report.renderer_report.readback_byte_count,
- report.renderer_report.readback_fnv1a64,
- readback_path.as_deref().map_or_else(|| "null".to_string(), json_string),
+ self.environment_primitive_count,
+ self.shadow_last_evidence.visible_casters,
+ self.shadow_last_evidence.projected_indices,
+ report.swapchain_recreate_count,
+ validation_status,
+ report.renderer_report.swapchain_image_format,
+ readback_format,
+ readback_path.as_deref().unwrap_or("none"),
+ elapsed_ms(self.startup_timings.mission_assets),
+ elapsed_ms(self.startup_timings.terrain_materials),
+ elapsed_ms(self.startup_timings.sky),
+ elapsed_ms(self.startup_timings.gpu_initialization),
));
event_loop.exit();
}
@@ -856,6 +5149,21 @@ impl StaticVulkanApp {
}
impl ApplicationHandler for StaticVulkanApp {
+ fn device_event(
+ &mut self,
+ _event_loop: &ActiveEventLoop,
+ _device_id: DeviceId,
+ event: DeviceEvent,
+ ) {
+ if self.mouse_look {
+ if let DeviceEvent::MouseMotion { delta } = event {
+ if let Some(camera) = self.free_camera.as_mut() {
+ camera.look_delta(delta.0, delta.1);
+ }
+ }
+ }
+ }
+
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() {
return;
@@ -869,7 +5177,7 @@ impl ApplicationHandler for StaticVulkanApp {
}
};
let attributes = Window::default_attributes()
- .with_title("FParkan static mission Vulkan")
+ .with_title("FParkan mission")
.with_inner_size(WinitPhysicalSize::new(plan.width, plan.height));
let window = match event_loop.create_window(attributes) {
Ok(window) => window,
@@ -885,12 +5193,13 @@ impl ApplicationHandler for StaticVulkanApp {
return;
};
let size = window.inner_size();
- let renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo {
+ let gpu_initialization_started = Instant::now();
+ let mut renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo {
application_name: "fparkan-game".to_string(),
native_handles,
drawable_extent: (size.width.max(1), size.height.max(1)),
render_request: WinitWindow::default_render_request(),
- enable_validation: true,
+ enable_validation: self.enable_validation,
mesh: self.mesh.clone(),
camera: self.camera,
materials: self.materials.clone(),
@@ -903,9 +5212,47 @@ impl ApplicationHandler for StaticVulkanApp {
return;
}
};
+ if let Err(error) = self.configure_world_draw_ranges(&mut renderer) {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
+ }
+ if let Some(free_camera) = self.free_camera {
+ match self.world_draw_sort_keys(free_camera.position) {
+ Ok(sort_keys) => {
+ for (range_index, sort_key) in sort_keys {
+ if let Err(error) = renderer.set_draw_range_sort_key(range_index, sort_key)
+ {
+ self.error = Some(format!(
+ "update world draw range {range_index} sort key: {error}"
+ ));
+ event_loop.exit();
+ return;
+ }
+ }
+ }
+ Err(error) => {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
+ }
+ }
+ }
+ self.startup_timings.gpu_initialization = gpu_initialization_started.elapsed();
+ renderer.set_readback_enabled(self.readback_out.is_some());
self.window_id = Some(window.id());
self.renderer = Some(renderer);
self.window = Some(window);
+ self.last_tick = Instant::now();
+ self.environment_last_tick = Instant::now();
+ self.material_last_tick = Instant::now();
+ self.material_elapsed = Duration::ZERO;
+ self.material_time_ms = 0;
+ if let Err(error) = self.update_free_camera() {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
+ }
self.schedule_next_redraw();
}
@@ -920,15 +5267,55 @@ impl ApplicationHandler for StaticVulkanApp {
}
match event {
WindowEvent::CloseRequested => {
- self.error = Some("native Vulkan window closed before completion".to_string());
- event_loop.exit();
+ self.complete(event_loop);
}
+ WindowEvent::Focused(focused) => {
+ if !focused {
+ self.pressed_keys.clear();
+ self.set_mouse_look(false);
+ }
+ if let Some(audio) = self.audio.as_mut() {
+ audio.on_focus_changed(focused);
+ }
+ }
+ WindowEvent::KeyboardInput { event, .. } => {
+ if let PhysicalKey::Code(code) = event.physical_key {
+ if code == KeyCode::Escape && event.state == ElementState::Pressed {
+ if self.mouse_look {
+ self.set_mouse_look(false);
+ } else {
+ self.complete(event_loop);
+ }
+ } else if event.state == ElementState::Pressed {
+ self.pressed_keys.insert(code);
+ } else {
+ self.pressed_keys.remove(&code);
+ }
+ }
+ }
+ WindowEvent::MouseInput { state, button, .. } => {
+ if button == MouseButton::Right {
+ self.set_mouse_look(state == ElementState::Pressed);
+ }
+ }
+ WindowEvent::CursorMoved { .. } => {}
WindowEvent::Resized(size) => {
if let Some(renderer) = self.renderer.as_mut() {
renderer.request_resize((size.width, size.height));
}
}
WindowEvent::RedrawRequested => {
+ if let Err(error) = self.update_free_camera() {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
+ }
+ self.update_material_clock();
+ if let Err(error) = self.update_environment() {
+ self.error = Some(error);
+ event_loop.exit();
+ return;
+ }
let Some(renderer) = self.renderer.as_mut() else {
self.error = Some("native Vulkan renderer was not initialized".to_string());
event_loop.exit();
@@ -947,7 +5334,7 @@ impl ApplicationHandler for StaticVulkanApp {
return;
}
}
- if self.frames_presented >= self.target_frames {
+ if self.target_frames != 0 && self.frames_presented >= self.target_frames {
self.complete(event_loop);
} else {
self.schedule_next_redraw();
@@ -964,157 +5351,13 @@ impl ApplicationHandler for StaticVulkanApp {
}
}
-#[cfg(test)]
-fn render_snapshot_commands(snapshot: &WorldSnapshot) -> Result<RenderCommandList, String> {
- render_snapshot_commands_with_assets(snapshot, None, None)
-}
-
-fn render_snapshot_commands_with_assets(
- snapshot: &WorldSnapshot,
- mission_assets: Option<&MissionAssets>,
- mission_drafts: Option<&[MissionObjectDraft]>,
-) -> Result<RenderCommandList, String> {
- let render_snapshot = render_snapshot_with_assets(snapshot, mission_assets, mission_drafts);
- build_commands(&render_snapshot, RenderProfile::default()).map_err(|err| err.to_string())
-}
-
-fn render_snapshot_with_assets(
- snapshot: &WorldSnapshot,
- mission_assets: Option<&MissionAssets>,
- mission_drafts: Option<&[MissionObjectDraft]>,
-) -> RenderSnapshot {
- let mut draws = Vec::with_capacity(snapshot.objects.len());
- for (index, handle) in snapshot.objects.iter().enumerate() {
- let visuals = mission_assets.map_or(&[][..], |assets| assets.visuals_for_object(index));
- let visual_count = visuals.len().max(1);
- for visual_index in 0..visual_count {
- let prepared = mission_assets.and_then(|assets| {
- visuals
- .get(visual_index)
- .and_then(|visual_id| assets.visual_by_id(*visual_id))
- });
- let mesh = prepared.map_or_else(
- || GpuMeshId(u64::from(handle.slot) + 1),
- |visual| GpuMeshId(visual.id.raw()),
- );
- let material = prepared
- .and_then(PreparedVisual::primary_material_id)
- .map_or(GpuMaterialId(1), |material_id| {
- GpuMaterialId(material_id.raw())
- });
- let stable_order = if visual_count == 1 {
- u64::from(handle.slot)
- } else {
- u64::from(handle.slot)
- .wrapping_mul(1_000_003)
- .wrapping_add(u64::try_from(visual_index).unwrap_or(u64::MAX))
- };
- let draw_id = snapshot
- .tick
- .0
- .wrapping_mul(1_000_003)
- .wrapping_add(stable_order);
- draws.push(RenderSnapshotDraw {
- id: DrawId(draw_id),
- phase: RenderPhase::Opaque,
- object_id: mission_drafts
- .and_then(|drafts| drafts.get(index))
- .and_then(|draft| draft.original_id),
- mesh,
- material_slots: vec![material],
- material_index: 0,
- pipeline_state: fparkan_render::LegacyPipelineState::default(),
- transform: snapshot_transform(snapshot, *handle).unwrap_or_else(|| {
- mission_drafts
- .and_then(|drafts| drafts.get(index))
- .map_or_else(
- || identity_transform(index_to_f32(index)),
- mission_position_scale_transform,
- )
- }),
- range: IndexRange { start: 0, count: 3 },
- stable_order,
- });
- }
- }
- RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws,
- }
-}
-
-fn snapshot_transform(
- snapshot: &WorldSnapshot,
- handle: fparkan_world::ObjectHandle,
-) -> Option<[f32; 16]> {
- snapshot
- .transforms
- .iter()
- .find_map(|(candidate, transform)| (*candidate == handle).then_some(*transform))
- .map(transform_state_position_scale_transform)
-}
-
-fn transform_state_position_scale_transform(transform: TransformState) -> [f32; 16] {
- let position = transform.position.map(f32::from_bits);
- let scale = transform.scale.map(f32::from_bits);
- [
- scale[0],
- 0.0,
- 0.0,
- position[0],
- 0.0,
- scale[1],
- 0.0,
- position[1],
- 0.0,
- 0.0,
- scale[2],
- position[2],
- 0.0,
- 0.0,
- 0.0,
- 1.0,
- ]
-}
-
-fn mission_position_scale_transform(draft: &MissionObjectDraft) -> [f32; 16] {
- [
- draft.scale[0],
- 0.0,
- 0.0,
- draft.position[0],
- 0.0,
- draft.scale[1],
- 0.0,
- draft.position[1],
- 0.0,
- 0.0,
- draft.scale[2],
- draft.position[2],
- 0.0,
- 0.0,
- 0.0,
- 1.0,
- ]
-}
-
-fn identity_transform(x: f32) -> [f32; 16] {
- [
- 1.0, 0.0, 0.0, x, 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 index_to_f32(index: usize) -> f32 {
- u16::try_from(index).map_or(f32::from(u16::MAX), f32::from)
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
+#[derive(Clone, Debug, PartialEq)]
struct Args {
root: PathBuf,
mission: String,
frames: u64,
- backend: RenderBackendMode,
- load_progress: Option<PathBuf>,
+ atmosphere_seconds: Option<f32>,
+ validation: bool,
readback_out: Option<PathBuf>,
preview_roots: NonZeroUsize,
legacy_camera_capture: Option<PathBuf>,
@@ -1122,20 +5365,16 @@ struct Args {
static_material_phase: Option<u16>,
}
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-enum RenderBackendMode {
- Planning,
- StaticVulkan,
-}
-
impl Args {
- #[allow(clippy::too_many_lines)]
fn parse(args: &[String]) -> Result<Self, String> {
let mut root = None;
let mut mission = None;
- let mut frames = 1;
- let mut backend = RenderBackendMode::Planning;
- let mut load_progress = None;
+ // Zero means keep the interactive preview open until the user closes
+ // it. A bounded `--frames N` remains available for smoke/readback
+ // runs and validation scripts.
+ let mut frames = 0;
+ let mut atmosphere_seconds = None;
+ let mut validation = false;
let mut readback_out = None;
// A native static-Vulkan invocation is the usable mission preview, so
// its default scope covers every root. `PreviewRoots` is clamped by the
@@ -1169,23 +5408,21 @@ impl Args {
.parse()
.map_err(|_| "--frames must be an integer".to_string())?;
}
- "--backend" => {
- backend = match iter
+ "--atmosphere-seconds" => {
+ let value = iter
.next()
- .ok_or_else(|| "--backend requires a value".to_string())?
- .as_str()
- {
- "planning" => RenderBackendMode::Planning,
- "static-vulkan" => RenderBackendMode::StaticVulkan,
- _ => return Err("--backend must be planning or static-vulkan".to_string()),
- };
+ .ok_or_else(|| "--atmosphere-seconds requires a value".to_string())?
+ .parse::<f32>()
+ .map_err(|_| "--atmosphere-seconds must be a number".to_string())?;
+ if !value.is_finite() || value < 0.0 {
+ return Err(
+ "--atmosphere-seconds must be finite and non-negative".to_string()
+ );
+ }
+ atmosphere_seconds = Some(value);
}
- "--load-progress" => {
- load_progress = Some(
- iter.next()
- .map(PathBuf::from)
- .ok_or_else(|| "--load-progress requires a path".to_string())?,
- );
+ "--validation" => {
+ validation = true;
}
"--readback-out" => {
readback_out = Some(
@@ -1232,27 +5469,12 @@ impl Args {
}
let root = root.ok_or_else(|| "missing --root".to_string())?;
let mission = mission.ok_or_else(|| "missing --mission".to_string())?;
- if frames == 0 {
- return Err("--frames must be greater than zero".to_string());
- }
- if legacy_camera_capture.is_some() && backend != RenderBackendMode::StaticVulkan {
- return Err("--legacy-camera-capture requires --backend static-vulkan".to_string());
- }
- if readback_out.is_some() && backend != RenderBackendMode::StaticVulkan {
- return Err("--readback-out requires --backend static-vulkan".to_string());
- }
- if static_animation_frame.is_some() && backend != RenderBackendMode::StaticVulkan {
- return Err("--static-animation-frame requires --backend static-vulkan".to_string());
- }
- if static_material_phase.is_some() && backend != RenderBackendMode::StaticVulkan {
- return Err("--static-material-phase requires --backend static-vulkan".to_string());
- }
Ok(Self {
root,
mission,
frames,
- backend,
- load_progress,
+ atmosphere_seconds,
+ validation,
readback_out,
preview_roots,
legacy_camera_capture,
@@ -1339,87 +5561,327 @@ fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result<String, String> {
}
}
-fn json_string(value: &str) -> String {
- let mut out = String::with_capacity(value.len() + 2);
- out.push('"');
- for ch in value.chars() {
- match ch {
- '"' => out.push_str("\\\""),
- '\\' => out.push_str("\\\\"),
- '\n' => out.push_str("\\n"),
- '\r' => out.push_str("\\r"),
- '\t' => out.push_str("\\t"),
- c if c.is_control() => {
- use std::fmt::Write as _;
- let _ = write!(out, "\\u{:04x}", c as u32);
- }
- c => out.push(c),
- }
- }
- out.push('"');
- out
-}
-
-fn json_hash(hash: &[u8; 32]) -> String {
- let mut out = String::from("\"");
- for byte in hash {
- use std::fmt::Write as _;
- let _ = write!(out, "{byte:02x}");
- }
- out.push('"');
- out
-}
-
fn usage() -> String {
- "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--backend <planning|static-vulkan>] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>] [--load-progress <path>]".to_string()
+ "usage: fparkan-game --root <path> --mission <path> [--frames <n>] [--atmosphere-seconds <seconds>] [--validation] [--preview-roots <non-zero n>] [--legacy-camera-capture <path>] [--static-animation-frame <u16>] [--static-material-phase <u16>] [--readback-out <path>]\nstatic preview uses saved per-component CTLD defaults, with uncontrolled nodes at frame 0; --static-animation-frame overrides all components globally.".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
- use fparkan_assets::AssetId;
- use fparkan_world::{ObjectHandle, StateHash, Tick};
- use std::path::Path;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
- fn static_preview_clip_diagnostic_matches_vulkan_clip_bounds() {
- let mesh = VulkanStaticMesh {
- vertices: vec![
- fparkan_render_vulkan::VulkanStaticVertex {
- position: [0.0, 0.0, 0.0],
- color: [0.0; 3],
- uv: [0.0; 2],
- },
- fparkan_render_vulkan::VulkanStaticVertex {
- position: [1.0, -1.0, 1.0],
- color: [0.0; 3],
- uv: [0.0; 2],
- },
- fparkan_render_vulkan::VulkanStaticVertex {
- position: [1.01, 0.0, 0.0],
- color: [0.0; 3],
- uv: [0.0; 2],
- },
- fparkan_render_vulkan::VulkanStaticVertex {
- position: [0.0, 0.0, -0.01],
- color: [0.0; 3],
- uv: [0.0; 2],
+ #[ignore = "requires licensed corpus"]
+ fn licensed_corpus_part1_prepares_every_mission() {
+ prepare_licensed_corpus("FPARKAN_CORPUS_PART1_ROOT", 29);
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_corpus_part2_prepares_every_mission() {
+ prepare_licensed_corpus("FPARKAN_CORPUS_PART2_ROOT", 31);
+ }
+
+ fn prepare_licensed_corpus(variable: &str, expected_missions: usize) {
+ let root = std::env::var_os(variable)
+ .map(PathBuf::from)
+ .unwrap_or_else(|| panic!("{variable} is required"));
+ assert!(
+ root.is_dir(),
+ "licensed corpus root is missing: {}",
+ root.display()
+ );
+
+ let mut missions = Vec::new();
+ let mut directories = vec![root.clone()];
+ while let Some(directory) = directories.pop() {
+ let entries = std::fs::read_dir(&directory)
+ .unwrap_or_else(|error| panic!("read {}: {error}", directory.display()));
+ for entry in entries {
+ let path = entry
+ .unwrap_or_else(|error| {
+ panic!("read entry in {}: {error}", directory.display())
+ })
+ .path();
+ if path.is_dir() {
+ directories.push(path);
+ } else if path
+ .file_name()
+ .is_some_and(|name| name.eq_ignore_ascii_case("data.tma"))
+ {
+ let relative = path
+ .strip_prefix(&root)
+ .expect("mission path is under corpus root")
+ .to_str()
+ .expect("mission path is UTF-8")
+ .replace('\\', "/");
+ missions.push(relative);
+ }
+ }
+ }
+ missions.sort();
+ assert_eq!(
+ missions.len(),
+ expected_missions,
+ "installed mission count for {variable}"
+ );
+
+ let vfs: Arc<dyn fparkan_vfs::Vfs> = Arc::new(DirectoryVfs::new(&root));
+ let mut prepared = 0usize;
+ let mut environment_missions = 0usize;
+ for mission in missions {
+ let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
+ let loaded = load_mission_static_preview_roots(
+ &mut engine,
+ MissionRequest {
+ key: mission.clone(),
},
- ],
- indices: Vec::new(),
- draw_ranges: Vec::new(),
+ NonZeroUsize::MAX,
+ )
+ .unwrap_or_else(|error| panic!("prepare runtime mission {mission}: {error}"));
+ let assets = loaded_mission_assets(&engine).expect("mission assets");
+ let terrain = loaded_terrain(&engine).expect("mission terrain");
+ let roots = loaded_mission_object_drafts(&engine).expect("mission object drafts");
+ let preview = static_preview_mesh_and_materials(
+ assets,
+ terrain,
+ roots,
+ None,
+ None,
+ None,
+ &root,
+ &loaded.land_msh_path,
+ )
+ .unwrap_or_else(|error| panic!("prepare renderer mission {mission}: {error}"));
+ assert!(!preview.mesh.vertices.is_empty(), "{mission}: empty mesh");
+ assert!(
+ !preview.materials.is_empty(),
+ "{mission}: empty material set"
+ );
+
+ if load_environment(&root, &mission, None)
+ .unwrap_or_else(|error| panic!("prepare environment mission {mission}: {error}"))
+ .is_some()
+ {
+ environment_missions += 1;
+ }
+
+ // `GameAudio::new` resolves and decodes every sample declared by
+ // mission.cfg before opening the OS device. A headless CI host is
+ // therefore still a useful resource check: only the final device
+ // step may be unavailable.
+ match audio::GameAudio::new(vfs.clone(), &mission) {
+ Ok(mut audio) => audio.shutdown(),
+ Err(audio::AudioError::DeviceUnavailable { .. }) => {}
+ Err(error) => panic!("prepare audio mission {mission}: {error}"),
+ }
+ prepared += 1;
+ }
+
+ assert_eq!(prepared, expected_missions);
+ assert_eq!(environment_missions, expected_missions);
+ }
+
+ #[test]
+ fn draw_range_reorder_remaps_all_material_animation_bindings() {
+ let range = |first_index, material_index| VulkanStaticDrawRange {
+ first_index,
+ index_count: 3,
+ material_index,
+ lightmap_index: u8::MAX,
+ batch_flags: 0,
+ pipeline_state: world_pipeline_state(
+ LegacyBlendMode::Opaque,
+ LegacyDepthMode::TestReadOnly,
+ ),
+ alpha_test_reference: 0,
+ };
+ let mut mesh = VulkanStaticMesh {
+ vertices: Vec::new(),
+ indices: (0..9).collect(),
+ draw_ranges: vec![range(0, 10), range(3, 20), range(6, 30)],
+ };
+ let empty_binding = || MaterialPhaseBinding {
+ document: Mat0Document {
+ version: 0,
+ animation_block_count: 0,
+ phases: Vec::new(),
+ prefix: Vec::new(),
+ header_opaque: [0; 2],
+ animation_blocks: Vec::new(),
+ },
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: 0,
+ frozen_phase_index: Some(0),
+ phase_uv_transforms: Vec::new(),
};
+ let mut bindings = vec![MaterialAnimationBinding {
+ initial_phase_index: 0,
+ variants: vec![MaterialVariantSet {
+ phase_material_indices: vec![10],
+ range_indices: vec![0, 2],
+ }],
+ base: empty_binding(),
+ detail: None,
+ overlay: None,
+ overlay_detail: None,
+ stage_textures: None,
+ }];
+
+ let remap = reorder_draw_ranges(&mut mesh, &[2, 0, 1]).expect("reorder");
+ remap_material_animation_ranges(&mut bindings, &remap).expect("binding remap");
+
assert_eq!(
- static_preview_clip_visible_vertices(&mesh, VulkanStaticCamera::default()),
- 2
+ mesh.indices,
+ (6..9).chain(0..3).chain(3..6).collect::<Vec<_>>()
+ );
+ assert_eq!(mesh.draw_ranges[0].material_index, 30);
+ assert_eq!(mesh.draw_ranges[1].material_index, 10);
+ assert_eq!(mesh.draw_ranges[2].material_index, 20);
+ assert_eq!(bindings[0].variants[0].range_indices, vec![1, 0]);
+ }
+
+ #[test]
+ fn native_world_transparency_uses_sampled_alpha_and_native_batch_flags() {
+ let range = |blend, batch_flags| VulkanStaticDrawRange {
+ first_index: 0,
+ index_count: 3,
+ material_index: 0,
+ lightmap_index: u8::MAX,
+ batch_flags,
+ pipeline_state: world_pipeline_state(blend, LegacyDepthMode::TestReadOnly),
+ alpha_test_reference: 0,
+ };
+
+ // Transparency is driven by the native predicate. The pipeline blend
+ // enum is an independent rendering detail and does not qualify an
+ // opaque range on its own.
+ assert!(!native_world_transparent(
+ &range(LegacyBlendMode::SourceAlpha, 0),
+ 1.0
+ ));
+ assert!(native_world_transparent(
+ &range(LegacyBlendMode::Opaque, 0),
+ 0.999
+ ));
+ assert!(native_world_transparent(
+ &range(LegacyBlendMode::Opaque, 0x100),
+ 1.0
+ ));
+ assert!(native_world_transparent(
+ &range(LegacyBlendMode::Opaque, 0x8),
+ 1.0
+ ));
+ }
+
+ #[test]
+ fn native_sun_object_source_types_map_to_world_kinds() {
+ assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_FORT)), Some(3));
+ assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_BTLU)), Some(4));
+ assert_eq!(native_shadow_kind(Some(PROTOTYPE_TYPE_STAT)), Some(10));
+ assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_FORT)));
+ assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_BTLU)));
+ assert!(native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_STAT)));
+ assert!(!native_sun_object_kind_eligible(Some(PROTOTYPE_TYPE_EXTO)));
+ assert!(!native_sun_object_kind_eligible(None));
+ }
+
+ fn test_shadow_caster(id: u32, center: [f32; 3]) -> ShadowCaster {
+ ShadowCaster {
+ id,
+ world_axes: [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
+ sphere: ShadowSphere {
+ center,
+ radius: 0.0,
+ },
+ native_kind: 4,
+ triangles: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn shadow_frustum_filters_before_the_native_twenty_caster_budget() {
+ let casters = [
+ test_shadow_caster(1, [202.0, 0.0, 0.0]),
+ test_shadow_caster(2, [0.0, 0.0, 0.0]),
+ ];
+ let visible =
+ native_visible_shadow_casters(&casters, VulkanStaticCamera::default().clip_from_world);
+ assert_eq!(
+ visible.iter().map(|caster| caster.id).collect::<Vec<_>>(),
+ [2]
);
}
#[test]
+ fn shadow_frustum_keeps_the_native_hundred_unit_sphere_margin() {
+ let matrix = VulkanStaticCamera::default().clip_from_world;
+ assert!(native_shadow_caster_visible(
+ &test_shadow_caster(1, [100.99, 0.0, 0.0]),
+ matrix
+ ));
+ assert!(!native_shadow_caster_visible(
+ &test_shadow_caster(2, [101.0, 0.0, 0.0]),
+ matrix
+ ));
+ }
+
+ #[test]
+ fn mounted_shadow_bounds_ignore_child_translation_and_scale_aggregate_once() {
+ let parent = VulkanNodePose {
+ translation: [10.0, 20.0, 30.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ };
+ let node1 = VulkanNodePose {
+ // Native bounds replace this translation with the accumulated
+ // parent translation while retaining node 1's rotation.
+ translation: [99.0, -77.0, 42.0],
+ rotation: [0.0, 0.0, (0.5_f32).sqrt(), (0.5_f32).sqrt()],
+ };
+ let first = mounted_shadow_sphere([2.0, 0.0, 0.0], 2.0, Some(parent), Some(node1))
+ .expect("finite mounted sphere");
+ assert!((first.center[0] - 10.0).abs() < 1.0e-5);
+ assert!((first.center[1] - 22.0).abs() < 1.0e-5);
+ assert!((first.center[2] - 30.0).abs() < 1.0e-5);
+
+ let second = ShadowComponentSphere {
+ center: [12.0, 22.0, 30.0],
+ radius: 1.0,
+ };
+ let caster = aggregate_shadow_caster(
+ 7,
+ 4,
+ LegacyIron3dEulerTransform {
+ translation: [1.0, 2.0, 3.0],
+ orientation_radians: [0.0; 3],
+ },
+ [2.0, 3.0, 4.0],
+ vec![shadow_triangle(
+ [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]],
+ 0,
+ )],
+ &[first, second],
+ )
+ .expect("aggregate caster");
+ let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-4);
+ nearly_equal(caster.sphere.center[0], 22.333_334);
+ nearly_equal(caster.sphere.center[1], 68.0);
+ nearly_equal(caster.sphere.center[2], 123.0);
+ nearly_equal(caster.sphere.radius, 10.666_667);
+ }
+
+ #[test]
+ fn internal_components_do_not_enter_msh_socket_mount_path() {
+ assert!(unit_component_is_visible(0, None));
+ assert!(!unit_component_is_visible(1, None));
+ assert!(unit_component_is_visible(1, Some(PROTOTYPE_TYPE_EXTO)));
+ }
+
+ #[test]
fn parses_required_args() {
assert_eq!(
Args::parse(&strings(&[
@@ -1434,8 +5896,8 @@ mod tests {
root: PathBuf::from("testdata/IS"),
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
frames: 3,
- backend: RenderBackendMode::Planning,
- load_progress: None,
+ atmosphere_seconds: None,
+ validation: false,
readback_out: None,
preview_roots: NonZeroUsize::MAX,
legacy_camera_capture: None,
@@ -1446,31 +5908,313 @@ mod tests {
}
#[test]
- fn parses_static_vulkan_backend() {
+ fn defaults_to_interactive_frame_loop() {
+ let parsed = Args::parse(&strings(&[
+ "--root",
+ "testdata/IS",
+ "--mission",
+ "MISSIONS/Autodemo.00/data.tma",
+ ]))
+ .expect("default interactive arguments");
+ assert_eq!(parsed.frames, 0);
+ assert_eq!(parsed.preview_roots, NonZeroUsize::MAX);
+ assert_eq!(parsed.static_animation_frame, None);
+ }
+
+ #[test]
+ fn lightmap_ranges_stay_on_the_decorated_variant_when_phase_changes() {
+ let empty_mat0 = Mat0Document {
+ version: 0,
+ animation_block_count: 0,
+ phases: Vec::new(),
+ prefix: Vec::new(),
+ header_opaque: [0; 2],
+ animation_blocks: Vec::new(),
+ };
+ let mut bindings = vec![MaterialAnimationBinding {
+ initial_phase_index: 0,
+ variants: vec![MaterialVariantSet {
+ phase_material_indices: vec![10, 11],
+ range_indices: Vec::new(),
+ }],
+ base: MaterialPhaseBinding {
+ document: empty_mat0,
+ animation_block_index: 0,
+ wear_row_start_ms: 0,
+ random_state: 0,
+ frozen_phase_index: Some(0),
+ phase_uv_transforms: Vec::new(),
+ },
+ detail: None,
+ overlay: None,
+ overlay_detail: None,
+ stage_textures: None,
+ }];
+ let (binding_index, source_phases) =
+ find_material_animation_variant(&bindings, 10).expect("base material variant");
+ assert_eq!(source_phases, vec![10, 11]);
+ bindings[binding_index].variants.push(MaterialVariantSet {
+ phase_material_indices: vec![20, 21],
+ range_indices: vec![7],
+ });
+
+ let lightmap_variant = &bindings[binding_index].variants[1];
+ assert_eq!(lightmap_variant.range_indices, vec![7]);
assert_eq!(
- Args::parse(&strings(&[
- "--root",
- "testdata/IS",
- "--mission",
- "MISSIONS/Autodemo.00/data.tma",
- "--backend",
- "static-vulkan",
- ])),
- Ok(Args {
- root: PathBuf::from("testdata/IS"),
- mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
- frames: 1,
- backend: RenderBackendMode::StaticVulkan,
- load_progress: None,
- readback_out: None,
- preview_roots: NonZeroUsize::MAX,
- legacy_camera_capture: None,
- static_animation_frame: None,
- static_material_phase: None,
- })
+ selected_material_variant_index(lightmap_variant, 0),
+ Some(20)
+ );
+ assert_eq!(
+ selected_material_variant_index(lightmap_variant, 1),
+ Some(21)
);
}
+ fn row_vector_transform(point: [f32; 4], matrix: [f32; 16]) -> [f32; 4] {
+ let mut result = [0.0; 4];
+ for column in 0..4 {
+ result[column] = (0..4)
+ .map(|row| point[row] * matrix[row * 4 + column])
+ .sum();
+ }
+ result
+ }
+
+ #[test]
+ fn free_flight_camera_uses_right_handed_z_up_basis_and_positive_depth() {
+ let camera = FreeFlightCamera {
+ position: [0.0, 0.0, 0.0],
+ yaw: 0.0,
+ pitch: 0.0,
+ vertical_fov: std::f32::consts::FRAC_PI_2,
+ near_plane: 1.0,
+ far_plane: 11.0,
+ move_speed: 1.0,
+ };
+
+ let nearly_equal = |left: f32, right: f32| assert!((left - right).abs() < 1.0e-5);
+ assert_eq!(camera.forward(), [1.0, 0.0, 0.0]);
+ assert_eq!(camera.right(), [0.0, -1.0, 0.0]);
+ assert_eq!(camera.up(), [0.0, 0.0, 1.0]);
+ let matrix = camera.vulkan_camera(1.0).clip_from_world;
+ let near = row_vector_transform([1.0, 0.0, 0.0, 1.0], matrix);
+ let far = row_vector_transform([11.0, 0.0, 0.0, 1.0], matrix);
+ let camera_right = row_vector_transform([1.0, -1.0, 0.0, 1.0], matrix);
+ let world_up = row_vector_transform([1.0, 0.0, 1.0, 1.0], matrix);
+ nearly_equal(near[0], 0.0);
+ nearly_equal(near[1], 0.0);
+ nearly_equal(near[2], 0.0);
+ nearly_equal(near[3], 1.0);
+ assert!(camera_right[0] / camera_right[3] > 0.0);
+ assert!(world_up[1] / world_up[3] < 0.0);
+ nearly_equal(far[2] / far[3], 1.0);
+ }
+
+ #[test]
+ fn sky_normals_keep_native_signed_byte_scale_without_renormalizing() {
+ assert_eq!(sky_normal([-128, 127, -64]), [-1.0, 127.0 / 128.0, -0.5]);
+ }
+
+ #[test]
+ fn sun_visibility_uses_a_valid_ray_and_fails_open_without_surfaces() {
+ let camera = FreeFlightCamera {
+ position: [10.0, 20.0, 30.0],
+ yaw: 0.0,
+ pitch: 0.0,
+ vertical_fov: 1.0,
+ near_plane: 0.1,
+ far_plane: 100.0,
+ move_speed: 1.0,
+ };
+ let terrain = TerrainWorld::default();
+ assert!(native_sun_unoccluded(&terrain, &camera, [1.0, 0.0, 0.0]));
+ assert!(!native_sun_unoccluded(&terrain, &camera, [0.0, 0.0, 0.0]));
+
+ let terrain = TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument {
+ streams: Vec::new(),
+ nodes_raw: Vec::new(),
+ slots: fparkan_terrain_format::TerrainSlotTable {
+ header_raw: Vec::new(),
+ slots_raw: Vec::new(),
+ },
+ // The native Land.msh height is 32; renderer world Z is 1.
+ positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]],
+ normals: Vec::new(),
+ uv0: Vec::new(),
+ accelerator: Vec::new(),
+ aux14: Vec::new(),
+ aux18: Vec::new(),
+ faces: vec![fparkan_terrain_format::TerrainFace28 {
+ flags: FullSurfaceMask(1),
+ material_tag: 0,
+ aux_tag: 0,
+ vertices: [0, 1, 2],
+ neighbors: [None, None, None],
+ tail_raw: [0; 8],
+ raw: [0; 28],
+ }],
+ })
+ .expect("synthetic sun-occluder terrain");
+ let blocked_camera = FreeFlightCamera {
+ position: [0.25, 0.25, 2.0],
+ yaw: 0.0,
+ pitch: 0.0,
+ vertical_fov: 1.0,
+ near_plane: 0.1,
+ far_plane: 100.0,
+ move_speed: 1.0,
+ };
+ assert!(!native_sun_unoccluded(
+ &terrain,
+ &blocked_camera,
+ [0.0, 0.0, -1.0]
+ ));
+
+ // The same hit is beyond a short native sun segment, so it must not
+ // occlude the light even though the terrain contains a surface.
+ let short_segment_camera = FreeFlightCamera {
+ far_plane: 1.0,
+ ..blocked_camera
+ };
+ assert!(native_sun_unoccluded(
+ &terrain,
+ &short_segment_camera,
+ [0.0, 0.0, -1.0]
+ ));
+
+ let world_mesh = VulkanStaticMesh::smoke_triangle();
+ assert!(!native_sun_unoccluded_with_world(
+ &TerrainWorld::default(),
+ &world_mesh,
+ &[0],
+ &blocked_camera,
+ [0.0, 0.0, -1.0]
+ ));
+ assert!(native_sun_unoccluded_with_world(
+ &TerrainWorld::default(),
+ &world_mesh,
+ &[],
+ &blocked_camera,
+ [0.0, 0.0, -1.0]
+ ));
+
+ let excluded_terrain =
+ TerrainWorld::from_land_msh(&fparkan_terrain_format::LandMeshDocument {
+ streams: Vec::new(),
+ nodes_raw: Vec::new(),
+ slots: fparkan_terrain_format::TerrainSlotTable {
+ header_raw: Vec::new(),
+ slots_raw: Vec::new(),
+ },
+ positions: vec![[0.0, 0.0, 32.0], [1.0, 0.0, 32.0], [0.0, 1.0, 32.0]],
+ normals: Vec::new(),
+ uv0: Vec::new(),
+ accelerator: Vec::new(),
+ aux14: Vec::new(),
+ aux18: Vec::new(),
+ faces: vec![fparkan_terrain_format::TerrainFace28 {
+ flags: FullSurfaceMask(0x20),
+ material_tag: 0,
+ aux_tag: 0,
+ vertices: [0, 1, 2],
+ neighbors: [None, None, None],
+ tail_raw: [0; 8],
+ raw: [0; 28],
+ }],
+ })
+ .expect("excluded terrain");
+ assert!(native_sun_unoccluded_with_world(
+ &excluded_terrain,
+ &world_mesh,
+ &[],
+ &blocked_camera,
+ [0.0, 0.0, -1.0]
+ ));
+
+ let mut excluded_world_mesh = world_mesh.clone();
+ excluded_world_mesh.draw_ranges[0].batch_flags = 0x0008;
+ assert!(native_sun_unoccluded_with_world(
+ &TerrainWorld::default(),
+ &excluded_world_mesh,
+ &[0],
+ &blocked_camera,
+ [0.0, 0.0, -1.0]
+ ));
+ }
+
+ #[test]
+ fn world_sort_key_uses_the_first_indexed_vertex() {
+ let mesh = VulkanStaticMesh::smoke_triangle();
+ let distance = first_indexed_vertex_distance(&mesh, 0, [0.0, -0.55, -3.0])
+ .expect("first indexed vertex distance");
+ assert!((distance - 3.0).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn free_flight_camera_advances_wasd_and_vertical_controls() {
+ let mut camera = FreeFlightCamera {
+ position: [0.0, 0.0, 0.0],
+ yaw: 0.0,
+ pitch: 0.0,
+ vertical_fov: 1.0,
+ near_plane: 0.1,
+ far_plane: 100.0,
+ move_speed: 2.0,
+ };
+ let mut keys = HashSet::new();
+ keys.insert(KeyCode::KeyW);
+ keys.insert(KeyCode::KeyD);
+ camera.advance(&keys, 1.0);
+ let diagonal = 2.0 / 2.0_f32.sqrt();
+ assert!((camera.position[0] - diagonal).abs() < 1.0e-5);
+ assert!((camera.position[1] + diagonal).abs() < 1.0e-5);
+ assert_eq!(camera.position[2], 0.0);
+
+ keys.clear();
+ keys.insert(KeyCode::KeyE);
+ keys.insert(KeyCode::ShiftLeft);
+ camera.advance(&keys, 0.5);
+ assert!((camera.position[2] - 4.0).abs() < 1.0e-5);
+ }
+
+ #[test]
+ fn free_flight_mouse_right_turns_toward_previous_right_and_vertical_is_world_z() {
+ let mut camera = FreeFlightCamera {
+ position: [0.0, 0.0, 0.0],
+ yaw: 0.0,
+ pitch: 0.6,
+ vertical_fov: 1.0,
+ near_plane: 0.1,
+ far_plane: 100.0,
+ move_speed: 2.0,
+ };
+ let old_right = camera.right();
+ let old_forward = camera.forward();
+ camera.look_delta(100.0, 0.0);
+ assert!(dot3(camera.forward(), old_right) > 0.0);
+ assert!(dot3(camera.forward(), old_forward) < 1.0);
+
+ let mut keys = HashSet::new();
+ keys.insert(KeyCode::KeyE);
+ camera.advance(&keys, 0.5);
+ assert!((camera.position[0]).abs() < 1.0e-5);
+ assert!((camera.position[1]).abs() < 1.0e-5);
+ assert!((camera.position[2] - 1.0).abs() < 1.0e-5);
+ }
+
+ #[test]
+ fn parses_validation_flag() {
+ let parsed = Args::parse(&strings(&[
+ "--root",
+ "testdata/IS",
+ "--mission",
+ "MISSIONS/Autodemo.00/data.tma",
+ "--validation",
+ ]))
+ .expect("valid validation flag");
+ assert!(parsed.validation);
+ }
+
#[test]
fn parses_nonzero_static_preview_root_count() {
let parsed = Args::parse(&strings(&[
@@ -1478,8 +6222,6 @@ mod tests {
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
- "--backend",
- "static-vulkan",
"--preview-roots",
"2",
]))
@@ -1491,26 +6233,12 @@ mod tests {
}
#[test]
- fn readback_output_requires_static_vulkan_and_is_retained() {
- let common = [
- "--root",
- "testdata/IS",
- "--mission",
- "MISSIONS/Autodemo.00/data.tma",
- "--readback-out",
- "target/frame.raw",
- ];
- assert_eq!(
- Args::parse(&strings(&common)),
- Err("--readback-out requires --backend static-vulkan".to_string())
- );
+ fn parses_readback_output() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
- "--backend",
- "static-vulkan",
"--readback-out",
"target/frame.raw",
]))
@@ -1519,26 +6247,12 @@ mod tests {
}
#[test]
- fn static_animation_frame_requires_static_vulkan_and_is_retained() {
- let common = [
- "--root",
- "testdata/IS",
- "--mission",
- "MISSIONS/Autodemo.00/data.tma",
- "--static-animation-frame",
- "12",
- ];
- assert_eq!(
- Args::parse(&strings(&common)),
- Err("--static-animation-frame requires --backend static-vulkan".to_string())
- );
+ fn parses_static_animation_frame() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
- "--backend",
- "static-vulkan",
"--static-animation-frame",
"12",
]))
@@ -1547,26 +6261,67 @@ mod tests {
}
#[test]
- fn static_material_phase_requires_static_vulkan_and_is_retained() {
- let common = [
- "--root",
- "testdata/IS",
- "--mission",
- "MISSIONS/Autodemo.00/data.tma",
- "--static-material-phase",
- "2",
- ];
+ fn static_preview_control_defaults_apply_native_flags_and_last_row_wins() {
+ let binding =
+ |node_index, frame_a, frame_b, initial_blend, flags| PreparedControlNodeBinding {
+ node_index,
+ frame_a,
+ frame_b,
+ initial_blend,
+ flags,
+ };
+ let times = static_preview_node_times(
+ 6,
+ &[
+ binding(1, 1.0, 3.0, 0.0, 0),
+ binding(2, 1.0, 3.0, 1.25, 1),
+ binding(3, 1.0, 3.0, -0.5, 1),
+ binding(4, 1.0, 3.0, -0.5, 0),
+ binding(1, 1.0, 3.0, 0.25, 2),
+ binding(usize::MAX, f32::NAN, f32::INFINITY, f32::NAN, 4),
+ ],
+ )
+ .expect("valid static CTLD defaults");
+ let values = times
+ .iter()
+ .map(|time| time.map(|time| time.0))
+ .collect::<Vec<_>>();
assert_eq!(
- Args::parse(&strings(&common)),
- Err("--static-material-phase requires --backend static-vulkan".to_string())
+ values,
+ vec![
+ Some(0.0),
+ Some(2.5),
+ Some(1.5),
+ Some(2.0),
+ Some(1.0),
+ Some(0.0)
+ ]
);
+ }
+
+ #[test]
+ fn static_preview_rejects_negative_derived_control_sample_time() {
+ let error = static_preview_node_times(
+ 1,
+ &[PreparedControlNodeBinding {
+ node_index: 0,
+ frame_a: 0.0,
+ frame_b: 2.0,
+ initial_blend: -2.0,
+ flags: 1,
+ }],
+ )
+ .expect_err("one wrap still leaves an unsupported negative sample time");
+ assert!(error.contains("negative or non-finite sample time"));
+ }
+
+ #[test]
+ fn parses_static_material_phase() {
let parsed = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
- "--backend",
- "static-vulkan",
"--static-material-phase",
"2",
]))
@@ -1591,52 +6346,12 @@ mod tests {
}
#[test]
- fn parses_load_progress_path() {
- assert_eq!(
- Args::parse(&strings(&[
- "--root",
- "testdata/IS",
- "--mission",
- "MISSIONS/Autodemo.00/data.tma",
- "--load-progress",
- "target/probe.txt",
- ])),
- Ok(Args {
- root: PathBuf::from("testdata/IS"),
- mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
- frames: 1,
- backend: RenderBackendMode::Planning,
- load_progress: Some(PathBuf::from("target/probe.txt")),
- readback_out: None,
- preview_roots: NonZeroUsize::MAX,
- legacy_camera_capture: None,
- static_animation_frame: None,
- static_material_phase: None,
- })
- );
- }
-
- #[test]
- fn legacy_camera_capture_requires_static_vulkan_and_is_retained() {
- let common = [
- "--root",
- "testdata/IS",
- "--mission",
- "MISSIONS/Autodemo.00/data.tma",
- "--legacy-camera-capture",
- "target/camera.json",
- ];
- assert_eq!(
- Args::parse(&strings(&common)),
- Err("--legacy-camera-capture requires --backend static-vulkan".to_string())
- );
+ fn parses_legacy_camera_capture_path() {
let valid = Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
- "--backend",
- "static-vulkan",
"--legacy-camera-capture",
"target/camera.json",
]))
@@ -1691,38 +6406,6 @@ mod tests {
}
#[test]
- fn load_progress_retains_prior_milestones() -> Result<(), String> {
- let path =
- std::env::temp_dir().join(format!("fparkan-game-progress-{}.txt", std::process::id()));
- prepare_load_progress_path(&path)?;
- write_load_progress(
- &path,
- std::time::Duration::from_millis(12),
- MissionLoadPhase::GraphVisualMaterials,
- )?;
- write_load_progress(
- &path,
- std::time::Duration::from_millis(34),
- MissionLoadPhase::GraphVisualMaterialRequests {
- request_count: 64,
- graph_node_count: 128,
- graph_edge_count: 192,
- wear_cache_entries: 8,
- material_cache_entries: 16,
- texture_cache_entries: 32,
- },
- )?;
- let progress = std::fs::read_to_string(&path).map_err(|err| err.to_string())?;
- std::fs::remove_file(&path).map_err(|err| err.to_string())?;
-
- assert_eq!(
- progress,
- "Starting\telapsed_ms=0\nGraphVisualMaterials\telapsed_ms=12\nGraphVisualMaterialRequests { request_count: 64, graph_node_count: 128, graph_edge_count: 192, wear_cache_entries: 8, material_cache_entries: 16, texture_cache_entries: 32 }\telapsed_ms=34\n"
- );
- Ok(())
- }
-
- #[test]
fn static_preview_component_merge_offsets_indices_and_remaps_local_selectors(
) -> Result<(), String> {
let mut merged = VulkanStaticMesh {
@@ -1734,11 +6417,13 @@ mod tests {
&mut merged,
VulkanStaticMesh::smoke_triangle(),
&[(0, 4)],
+ None,
)?;
append_static_preview_component(
&mut merged,
VulkanStaticMesh::smoke_triangle(),
&[(0, 9)],
+ None,
)?;
assert_eq!(merged.vertices.len(), 6);
@@ -1756,7 +6441,10 @@ mod tests {
let vertex = fparkan_render_vulkan::VulkanStaticVertex {
position: [0.0, 0.0, 0.0],
color: [1.0, 1.0, 1.0],
+ normal: [0.0, 0.0, 1.0],
uv: [0.0, 0.0],
+ detail_uv: [0.0, 0.0],
+ overlay_alpha: 0.0,
};
let mut merged = VulkanStaticMesh {
vertices: vec![vertex; usize::from(u16::MAX) + 1],
@@ -1768,6 +6456,7 @@ mod tests {
&mut merged,
VulkanStaticMesh::smoke_triangle(),
&[(0, 0)],
+ None,
)?;
assert_eq!(merged.indices, vec![65_536, 65_537, 65_538]);
@@ -1775,265 +6464,81 @@ mod tests {
}
#[test]
- fn render_commands_follow_snapshot_order() -> Result<(), String> {
- let snapshot = WorldSnapshot {
- tick: Tick(7),
- objects: vec![
- ObjectHandle {
- generation: 1,
- slot: 2,
- },
- ObjectHandle {
- generation: 1,
- slot: 5,
- },
- ],
- transforms: Vec::new(),
- events: Vec::new(),
- hash: StateHash([0; 32]),
- };
-
- let commands = render_snapshot_commands(&snapshot)?;
-
- assert_eq!(commands.commands.len(), 4);
- assert!(matches!(
- commands.commands[0],
- fparkan_render::RenderCommand::BeginFrame
- ));
- assert!(matches!(
- commands.commands[3],
- fparkan_render::RenderCommand::EndFrame
- ));
- let fparkan_render::RenderCommand::Draw(first) = &commands.commands[1] else {
- return Err("expected draw".to_string());
- };
- assert_eq!(first.mesh, GpuMeshId(3));
- assert_eq!(first.stable_order, 2);
- Ok(())
- }
-
- #[test]
- fn render_snapshot_retains_every_prepared_visual_for_an_object() -> Result<(), String> {
- let snapshot = WorldSnapshot {
- tick: Tick(3),
- objects: vec![ObjectHandle {
- generation: 1,
- slot: 7,
- }],
- transforms: Vec::new(),
- events: Vec::new(),
- hash: StateHash([0; 32]),
- };
- let first = prepared_visual(101, 201);
- let second = prepared_visual(102, 202);
- let assets = MissionAssets {
- visuals: vec![first, second],
- object_visuals: vec![vec![AssetId::new(101), AssetId::new(102)]],
- ..MissionAssets::default()
- };
- let commands = render_snapshot_commands_with_assets(&snapshot, Some(&assets), None)?;
- let draws: Vec<_> = commands
- .commands
- .iter()
- .filter_map(|command| match command {
- RenderCommand::Draw(draw) => Some(draw),
- _ => None,
- })
- .collect();
- assert_eq!(draws.len(), 2);
- assert_eq!(draws[0].mesh, GpuMeshId(101));
- assert_eq!(draws[1].mesh, GpuMeshId(102));
- assert_eq!(draws[0].material, GpuMaterialId(201));
- assert_eq!(draws[1].material, GpuMaterialId(202));
- assert_ne!(draws[0].stable_order, draws[1].stable_order);
- Ok(())
- }
-
- #[test]
- fn render_snapshot_uses_mission_position_and_scale_without_interpreting_orientation() {
- let draft = MissionObjectDraft {
- original_id: None,
- resource_name_raw: Vec::new(),
- identity_or_clan_raw: 0,
+ fn precipitation_upload_reconstructs_native_ndc_depths_and_uvs() {
+ let camera = FreeFlightCamera {
position: [10.0, 20.0, 30.0],
- orientation_raw: [1.0, 2.0, 3.0],
- scale: [2.0, 3.0, 4.0],
- visual_ids: Vec::new(),
- properties: Vec::new(),
- unit_components: Vec::new(),
+ yaw: 0.0,
+ pitch: 0.0,
+ vertical_fov: std::f32::consts::FRAC_PI_2,
+ near_plane: 0.1,
+ far_plane: 100.0,
+ move_speed: 1.0,
};
- assert_eq!(
- mission_position_scale_transform(&draft),
- [2.0, 0.0, 0.0, 10.0, 0.0, 3.0, 0.0, 20.0, 0.0, 0.0, 4.0, 30.0, 0.0, 0.0, 0.0, 1.0,]
- );
- }
-
- #[test]
- fn render_snapshot_prefers_world_transform_over_mission_draft() -> Result<(), String> {
- let handle = ObjectHandle {
- generation: 1,
- slot: 0,
- };
- let snapshot = WorldSnapshot {
- tick: Tick(1),
- objects: vec![handle],
- transforms: vec![(
- handle,
- TransformState {
- position: [
- 100.0_f32.to_bits(),
- 200.0_f32.to_bits(),
- 300.0_f32.to_bits(),
- ],
- orientation: [0; 3],
- scale: [4.0_f32.to_bits(), 5.0_f32.to_bits(), 6.0_f32.to_bits()],
- },
- )],
- events: Vec::new(),
- hash: StateHash([0; 32]),
- };
- let drafts = vec![MissionObjectDraft {
- original_id: None,
- resource_name_raw: Vec::new(),
- identity_or_clan_raw: 0,
- position: [1.0; 3],
- orientation_raw: [0.0; 3],
- scale: [1.0; 3],
- visual_ids: Vec::new(),
- properties: Vec::new(),
- unit_components: Vec::new(),
- }];
-
- let commands = render_snapshot_commands_with_assets(&snapshot, None, Some(&drafts))?;
- let RenderCommand::Draw(draw) = &commands.commands[1] else {
- return Err("expected draw".to_string());
+ let screen = ScreenBillboard {
+ world_head: [0.0; 3],
+ world_tail: [0.0; 3],
+ head: [0.0, 0.0],
+ tail: [0.0, 0.0],
+ corners: [[-0.25, -0.5], [0.25, -0.5], [-0.25, 0.5], [0.25, 0.5]],
+ uv: [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6], [0.7, 0.8]],
+ half_size: 12.0,
+ head_depth: 8.0,
+ tail_depth: 4.0,
};
- assert_eq!(
- draw.transform,
- [
- 4.0, 0.0, 0.0, 100.0, 0.0, 5.0, 0.0, 200.0, 0.0, 0.0, 6.0, 300.0, 0.0, 0.0, 0.0,
- 1.0
- ]
+ let vertices = precipitation_quad_vertices(
+ PrecipitationKind::Rain,
+ &screen,
+ [1.0, 0.5, 0.25, 0.75],
+ &camera,
+ [100.0, 100.0],
);
- Ok(())
+ assert_eq!(vertices[0].uv, screen.uv[0]);
+ assert_eq!(vertices[3].uv, screen.uv[3]);
+ assert!(
+ (dot3(
+ sub3(vertices[0].position, camera.position),
+ camera.forward()
+ ) - 4.0)
+ .abs()
+ < 1.0e-5
+ );
+ assert!(
+ (dot3(
+ sub3(vertices[3].position, camera.position),
+ camera.forward()
+ ) - 8.0)
+ .abs()
+ < 1.0e-5
+ );
+ assert_eq!(vertices[0].overlay_alpha, 0.75);
}
#[test]
- fn render_snapshot_preserves_mission_original_object_id() -> Result<(), String> {
- let snapshot = WorldSnapshot {
- tick: Tick(1),
- objects: vec![ObjectHandle {
- generation: 1,
- slot: 0,
- }],
- transforms: Vec::new(),
- events: Vec::new(),
- hash: StateHash([0; 32]),
- };
- let drafts = vec![MissionObjectDraft {
- original_id: Some(OriginalObjectId(42)),
- resource_name_raw: Vec::new(),
- identity_or_clan_raw: 0,
+ fn sprite_upload_remaps_native_left_top_uv_order_to_quad_positions() {
+ let camera = FreeFlightCamera {
position: [0.0; 3],
- orientation_raw: [0.0; 3],
- scale: [1.0; 3],
- visual_ids: Vec::new(),
- properties: Vec::new(),
- unit_components: Vec::new(),
- }];
-
- let commands = render_snapshot_commands_with_assets(&snapshot, None, Some(&drafts))?;
- let RenderCommand::Draw(draw) = &commands.commands[1] else {
- return Err("expected draw".to_string());
+ yaw: 0.0,
+ pitch: 0.0,
+ vertical_fov: 1.0,
+ near_plane: 0.1,
+ far_plane: 100.0,
+ move_speed: 1.0,
};
- assert_eq!(draw.object_id, Some(OriginalObjectId(42)));
- Ok(())
- }
-
- fn prepared_visual(id: u64, material: u64) -> PreparedVisual {
- PreparedVisual {
- id: AssetId::new(id),
- mesh: None,
- model_id: None,
- wear_id: None,
- model_nodes: 0,
- model_slots: 0,
- model_batches: 0,
- material_count: 1,
- material_ids: vec![AssetId::new(material)],
- texture_ids: Vec::new(),
- lightmap_ids: Vec::new(),
- texture_count: 0,
- lightmap_count: 0,
- }
- }
-
- #[test]
- #[ignore = "requires licensed corpus"]
- fn selected_is_and_is2_missions_produce_approved_render_captures() {
- for case in [
- RenderCase {
- root: "IS",
- mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
- expected: "{\"report_kind\":\"render-planning\",\"backend\":\"vulkan-planning\",\"window\":\"synthetic\",\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"submission_plans\":1,\"last_command_capture_bytes\":2008,\"hash\":\"ca17cc76e55c45e83c1c9c1c088e84bf1a698be91a7730943210fe27596af841\"}",
- },
- RenderCase {
- root: "IS2",
- mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
- expected: "{\"report_kind\":\"render-planning\",\"backend\":\"vulkan-planning\",\"window\":\"synthetic\",\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"submission_plans\":1,\"last_command_capture_bytes\":605,\"hash\":\"5d720b3ab690076a398a79a404850bbeaee2e33811b5bb570ec8a96d4a7a2fc4\"}",
- },
- ] {
- assert_eq!(
- run(&render_args(&licensed_root(case.root), case.mission)),
- Ok(case.expected.to_string())
- );
- }
- }
-
- #[test]
- fn json_hash_is_hex() {
- let mut hash = [0; 32];
- hash[0] = 0xab;
- hash[31] = 0xcd;
-
- assert_eq!(
- json_hash(&hash),
- "\"ab000000000000000000000000000000000000000000000000000000000000cd\""
+ let uv = [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0], [7.0, 8.0]];
+ let vertices = sprite_quad_vertices_pixels(
+ &camera,
+ camera.forward(),
+ 10.0,
+ 1.0,
+ 1.0,
+ [100.0, 100.0],
+ [1.0; 3],
+ 1.0,
+ uv,
);
- }
-
- #[derive(Clone, Copy)]
- struct RenderCase {
- root: &'static str,
- mission: &'static str,
- expected: &'static str,
- }
-
- fn render_args(root: &Path, mission: &str) -> Vec<String> {
- vec![
- "--root".to_string(),
- root.to_str().expect("utf8 root").to_string(),
- "--mission".to_string(),
- mission.to_string(),
- "--frames".to_string(),
- "1".to_string(),
- ]
- }
-
- fn licensed_root(name: &str) -> PathBuf {
- let variable = match name {
- "IS" => "FPARKAN_CORPUS_PART1_ROOT",
- "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
- _ => panic!("unknown licensed corpus part: {name}"),
- };
- let root = std::env::var_os(variable)
- .map(PathBuf::from)
- .unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
- assert!(
- root.is_dir(),
- "licensed corpus root is missing: {}",
- root.display()
+ assert_eq!(
+ vertices.map(|vertex| vertex.uv),
+ [uv[1], uv[2], uv[0], uv[3]]
);
- root
}
}
diff --git a/apps/fparkan-headless/Cargo.toml b/apps/fparkan-headless/Cargo.toml
deleted file mode 100644
index 483438b..0000000
--- a/apps/fparkan-headless/Cargo.toml
+++ /dev/null
@@ -1,14 +0,0 @@
-[package]
-name = "fparkan-headless"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
-fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
-fparkan-world = { path = "../../crates/fparkan-world", version = "0.1.0" }
-
-[lints]
-workspace = true
diff --git a/apps/fparkan-headless/src/main.rs b/apps/fparkan-headless/src/main.rs
deleted file mode 100644
index 9c5ab52..0000000
--- a/apps/fparkan-headless/src/main.rs
+++ /dev/null
@@ -1,267 +0,0 @@
-#![forbid(unsafe_code)]
-#![cfg_attr(
- test,
- allow(
- clippy::cast_possible_truncation,
- clippy::cast_possible_wrap,
- clippy::cast_precision_loss,
- clippy::expect_used,
- clippy::float_cmp,
- clippy::identity_op,
- clippy::too_many_lines,
- clippy::uninlined_format_args,
- clippy::map_unwrap_or,
- clippy::needless_raw_string_hashes,
- clippy::semicolon_if_nothing_returned,
- clippy::type_complexity,
- clippy::panic,
- clippy::unwrap_used
- )
-)]
-#![allow(clippy::print_stderr, clippy::print_stdout)]
-//! `FParkan` headless runtime entrypoint.
-
-use fparkan_runtime::{
- advance_reference_movement, create, load_mission, step_headless, EngineConfig, EngineMode,
- EngineServices, MissionRequest,
-};
-use fparkan_vfs::DirectoryVfs;
-use fparkan_world::{InputSnapshot, OriginalObjectId};
-use std::path::PathBuf;
-use std::sync::Arc;
-
-fn main() {
- if let Err(err) = run() {
- eprintln!("{err}");
- std::process::exit(2);
- }
-}
-
-fn run() -> Result<(), String> {
- let raw_args: Vec<String> = std::env::args().skip(1).collect();
- let args = Args::parse(&raw_args)?;
- let services = if let Some(root) = &args.root {
- EngineServices::new(Arc::new(DirectoryVfs::new(root)))
- } else {
- EngineServices::default()
- };
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- services,
- )
- .map_err(|err| format!("{err}"))?;
- if let Some(mission) = args.mission {
- let loaded = load_mission(&mut engine, MissionRequest { key: mission })
- .map_err(|err| format!("{err}"))?;
- println!(
- "mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} scripts={} script_events={} script_varset_declarations={} script_init_states={} script_varset_states={} graph_failures={}",
- loaded.object_count,
- loaded.areal_count,
- loaded.surface_count,
- loaded.graph_root_count,
- loaded.graph_unit_component_count,
- loaded.graph_wear_resolved_count,
- loaded.graph_material_resolved_count,
- loaded.graph_texture_resolved_count,
- loaded.graph_lightmap_resolved_count,
- loaded.script_bundle_count,
- loaded.script_event_count,
- loaded.script_varset_declaration_count,
- loaded.script_init_state_count,
- loaded.script_varset_state_count,
- loaded.graph_failure_count
- );
- }
- if let Some(movement) = args.reference_movement {
- let reached = advance_reference_movement(
- &mut engine,
- OriginalObjectId(movement.original_id),
- movement.target_xy,
- movement.max_step,
- )
- .map_err(|err| format!("{err}"))?;
- println!(
- "reference_movement original_id={} target_xy=[{},{}] max_step={} reached={reached}",
- movement.original_id, movement.target_xy[0], movement.target_xy[1], movement.max_step,
- );
- }
- let mut last = None;
- for _ in 0..args.ticks {
- last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
- }
- if let Some(frame) = last {
- println!(
- "tick={} hash={:02x?}",
- frame.snapshot.tick.0, frame.snapshot.hash.0
- );
- }
- Ok(())
-}
-
-#[derive(Debug)]
-struct Args {
- root: Option<PathBuf>,
- mission: Option<String>,
- ticks: u64,
- reference_movement: Option<ReferenceMovement>,
-}
-
-#[derive(Clone, Copy, Debug, PartialEq)]
-struct ReferenceMovement {
- original_id: u32,
- target_xy: [f32; 2],
- max_step: f32,
-}
-
-impl Args {
- fn parse(args: &[String]) -> Result<Self, String> {
- let mut parsed = Self {
- root: None,
- mission: None,
- ticks: 1,
- reference_movement: None,
- };
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- match arg.as_str() {
- "--root" => {
- parsed.root = Some(
- iter.next()
- .map(PathBuf::from)
- .ok_or_else(|| "--root requires a path".to_string())?,
- );
- }
- "--mission" => {
- parsed.mission = Some(
- iter.next()
- .cloned()
- .ok_or_else(|| "--mission requires a path".to_string())?,
- );
- }
- "--ticks" => {
- parsed.ticks = iter
- .next()
- .ok_or_else(|| "--ticks requires a value".to_string())?
- .parse()
- .map_err(|_| "--ticks must be an integer".to_string())?;
- }
- "--move-object" => {
- if parsed.reference_movement.is_some() {
- return Err("--move-object may be specified once".to_string());
- }
- let original_id = iter
- .next()
- .ok_or_else(|| "--move-object requires an original object id".to_string())?
- .parse()
- .map_err(|_| {
- "--move-object object id must be an unsigned integer".to_string()
- })?;
- let x = parse_finite_argument(
- iter.next(),
- "--move-object requires a finite X target",
- )?;
- let y = parse_finite_argument(
- iter.next(),
- "--move-object requires a finite Y target",
- )?;
- let max_step = parse_finite_argument(
- iter.next(),
- "--move-object requires a finite positive maximum step",
- )?;
- if max_step <= 0.0 {
- return Err("--move-object maximum step must be positive".to_string());
- }
- parsed.reference_movement = Some(ReferenceMovement {
- original_id,
- target_xy: [x, y],
- max_step,
- });
- }
- _ => return Err(usage()),
- }
- }
- if parsed.mission.is_some() && parsed.root.is_none() {
- return Err("--mission requires --root".to_string());
- }
- if parsed.reference_movement.is_some() && parsed.mission.is_none() {
- return Err("--move-object requires --mission".to_string());
- }
- Ok(parsed)
- }
-}
-
-fn parse_finite_argument(value: Option<&String>, error: &str) -> Result<f32, String> {
- let value: f32 = value
- .ok_or_else(|| error.to_string())?
- .parse()
- .map_err(|_| error.to_string())?;
- value
- .is_finite()
- .then_some(value)
- .ok_or_else(|| error.to_string())
-}
-
-fn usage() -> String {
- "usage: fparkan-headless [--root <path> --mission <path>] [--move-object <original-id> <x> <y> <max-step>] [--ticks <n>]".to_string()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn args(values: &[&str]) -> Vec<String> {
- values.iter().map(ToString::to_string).collect()
- }
-
- #[test]
- fn move_object_parses_a_single_finite_reference_command() {
- let parsed = Args::parse(&args(&[
- "--root",
- "C:/game",
- "--mission",
- "MISSIONS/Autodemo.00/data.tma",
- "--move-object",
- "7",
- "12.5",
- "-3",
- "0.25",
- "--ticks",
- "2",
- ]))
- .expect("args");
- assert_eq!(parsed.ticks, 2);
- assert_eq!(
- parsed.reference_movement,
- Some(ReferenceMovement {
- original_id: 7,
- target_xy: [12.5, -3.0],
- max_step: 0.25,
- })
- );
- }
-
- #[test]
- fn move_object_requires_a_loaded_mission_and_valid_step() {
- assert_eq!(
- Args::parse(&args(&["--move-object", "7", "1", "2", "1"])).expect_err("mission"),
- "--move-object requires --mission"
- );
- assert_eq!(
- Args::parse(&args(&[
- "--root",
- "C:/game",
- "--mission",
- "M/data.tma",
- "--move-object",
- "7",
- "1",
- "2",
- "0",
- ]))
- .expect_err("step"),
- "--move-object maximum step must be positive"
- );
- }
-}
diff --git a/apps/fparkan-viewer/Cargo.toml b/apps/fparkan-viewer/Cargo.toml
deleted file mode 100644
index 0ad6167..0000000
--- a/apps/fparkan-viewer/Cargo.toml
+++ /dev/null
@@ -1,13 +0,0 @@
-[package]
-name = "fparkan-viewer"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.0" }
-fparkan-render = { path = "../../crates/fparkan-render", version = "0.1.0" }
-
-[lints]
-workspace = true
diff --git a/apps/fparkan-viewer/src/main.rs b/apps/fparkan-viewer/src/main.rs
deleted file mode 100644
index 5b4e26e..0000000
--- a/apps/fparkan-viewer/src/main.rs
+++ /dev/null
@@ -1,350 +0,0 @@
-#![forbid(unsafe_code)]
-#![cfg_attr(
- test,
- allow(
- clippy::cast_possible_truncation,
- clippy::cast_possible_wrap,
- clippy::cast_precision_loss,
- clippy::expect_used,
- clippy::float_cmp,
- clippy::identity_op,
- clippy::too_many_lines,
- clippy::uninlined_format_args,
- clippy::map_unwrap_or,
- clippy::needless_raw_string_hashes,
- clippy::semicolon_if_nothing_returned,
- clippy::type_complexity,
- clippy::panic,
- clippy::unwrap_used
- )
-)]
-#![allow(clippy::print_stderr, clippy::print_stdout)]
-//! `FParkan` asset inspection composition root.
-
-use fparkan_inspection::{
- inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
- LandFileKind, MapInspection, NresEntrySummary,
-};
-use fparkan_render::{
- build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
- RenderProfile, RenderSnapshot, RenderSnapshotDraw,
-};
-use std::fmt::Write;
-use std::path::PathBuf;
-
-fn main() {
- let args = std::env::args().skip(1).collect::<Vec<_>>();
- let code = match run(&args) {
- Ok(json) => {
- println!("{json}");
- 0
- }
- Err(err) => {
- eprintln!("{err}");
- 2
- }
- };
- std::process::exit(code);
-}
-
-fn run(args: &[String]) -> Result<String, String> {
- match args {
- [domain, rest @ ..] if domain == "archive" => inspect_archive(rest),
- [domain, rest @ ..] if domain == "model" => inspect_model(rest),
- [domain, rest @ ..] if domain == "texture" => inspect_texture(rest),
- [domain, rest @ ..] if domain == "map" => inspect_map(rest),
- _ => Err(usage()),
- }
-}
-
-fn inspect_archive(args: &[String]) -> Result<String, String> {
- let file = parse_file(args)?;
- let limit = parse_limit(args)?;
- let inspection = fparkan_inspection::inspect_archive_file(&file, limit)?;
-
- match inspection {
- ArchiveInspection::Nres {
- entries,
- lookup_order_valid,
- sample,
- } => Ok(format!(
- "{{\"report_kind\":\"archive-inspection\",\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
- json_string(&file.display().to_string()),
- entries,
- lookup_order_valid,
- render_nres_entries(&sample)
- )),
- ArchiveInspection::Rsli { entries } => Ok(format!(
- "{{\"report_kind\":\"archive-inspection\",\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
- json_string(&file.display().to_string()),
- entries
- )),
- ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", file.display())),
- }
-}
-
-fn inspect_model(args: &[String]) -> Result<String, String> {
- if let Some(fixture) = parse_option(args, &["--fixture"]) {
- return ViewerModelService::inspect_synthetic_model(&fixture);
- }
-
- let query = parse_resource_query(args)?;
- let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
-
- Ok(format!(
- "{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
- json_string(&query.archive),
- json_string(&query.name),
- inspection.streams,
- inspection.nodes,
- inspection.slots,
- inspection.positions,
- inspection.indices,
- inspection.batches
- ))
-}
-
-#[derive(Clone, Debug)]
-struct ViewerModelService;
-
-impl ViewerModelService {
- fn inspect_synthetic_model(fixture: &str) -> Result<String, String> {
- if fixture != "synthetic/model-basic" {
- return Err(format!("unknown model fixture: {fixture}"));
- }
-
- let snapshot = RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![RenderSnapshotDraw {
- id: DrawId(1),
- phase: RenderPhase::Opaque,
- object_id: None,
- mesh: GpuMeshId(1),
- material_slots: vec![GpuMaterialId(7)],
- material_index: 0,
- pipeline_state: fparkan_render::LegacyPipelineState::default(),
- transform: identity_transform(),
- range: IndexRange { start: 0, count: 3 },
- stable_order: 0,
- }],
- };
- let commands = build_commands(&snapshot, RenderProfile::default())
- .map_err(|err| format!("render command generation: {err}"))?;
- let draw_commands = commands
- .commands
- .iter()
- .filter(|command| matches!(command, fparkan_render::RenderCommand::Draw(_)))
- .count();
-
- Ok(format!(
- "{{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model-inspection\",\"draw_commands\":{draw_commands}}}",
- json_string(fixture)
- ))
- }
-}
-
-fn inspect_texture(args: &[String]) -> Result<String, String> {
- let query = parse_resource_query(args)?;
- let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
-
- Ok(format!(
- "{{\"report_kind\":\"texture-inspection\",\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
- json_string(&query.archive),
- json_string(&query.name),
- inspection.width,
- inspection.height,
- json_string(&inspection.format),
- inspection.mips,
- inspection.pages
- ))
-}
-
-fn inspect_map(args: &[String]) -> Result<String, String> {
- let file = parse_file(args)?;
- let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?;
- let inspection = inspect_land_file(
- &file,
- match kind.as_str() {
- "land-msh" => LandFileKind::LandMsh,
- "land-map" => LandFileKind::LandMap,
- _ => return Err(format!("unknown map kind: {kind}")),
- },
- )?;
-
- Ok(render_map_inspection_json(
- &file.display().to_string(),
- &kind,
- &inspection,
- ))
-}
-
-fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
- match kind {
- "land-msh" => format!(
- "{{\"report_kind\":\"map-inspection\",\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
- json_string(path),
- inspection.streams,
- inspection.positions,
- inspection.faces,
- inspection.slots
- ),
- "land-map" => format!(
- "{{\"report_kind\":\"map-inspection\",\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
- json_string(path),
- inspection.areals,
- inspection.declared_areals,
- inspection.grid_width,
- inspection.grid_height
- ),
- _ => unreachable!("invalid land kind: {kind}"),
- }
-}
-
-struct ResourceQuery {
- root: PathBuf,
- archive: String,
- name: String,
-}
-
-fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> {
- Ok(ResourceQuery {
- root: parse_path_option(args, &["--root", "--game-root"], "--root")?,
- archive: parse_option(args, &["--archive"])
- .ok_or_else(|| "missing --archive".to_string())?,
- name: parse_option(args, &["--name"]).ok_or_else(|| "missing --name".to_string())?,
- })
-}
-
-fn parse_file(args: &[String]) -> Result<PathBuf, String> {
- parse_path_option(args, &["--file"], "--file")
-}
-
-fn parse_limit(args: &[String]) -> Result<usize, String> {
- parse_option(args, &["--limit"])
- .map(|value| {
- value
- .parse::<usize>()
- .map_err(|_| format!("invalid --limit: {value}"))
- })
- .transpose()
- .map(|value| value.unwrap_or(0))
-}
-
-fn render_nres_entries(entries: &[NresEntrySummary]) -> String {
- let mut out = String::new();
- for (index, entry) in entries.iter().enumerate() {
- if index > 0 {
- out.push(',');
- }
- let name = &entry.name;
- let _ = write!(
- out,
- "{{\"name\":{},\"type\":{},\"size\":{}}}",
- json_string(name),
- entry.type_id,
- entry.data_size
- );
- }
- out
-}
-
-fn parse_path_option(args: &[String], names: &[&str], label: &str) -> Result<PathBuf, String> {
- parse_option(args, names)
- .map(PathBuf::from)
- .ok_or_else(|| format!("missing {label}"))
-}
-
-fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- if names.iter().any(|name| arg == name) {
- return iter.next().cloned();
- }
- }
- None
-}
-
-fn json_string(value: &str) -> String {
- let mut out = String::with_capacity(value.len() + 2);
- out.push('"');
- for ch in value.chars() {
- match ch {
- '"' => out.push_str("\\\""),
- '\\' => out.push_str("\\\\"),
- '\n' => out.push_str("\\n"),
- '\r' => out.push_str("\\r"),
- '\t' => out.push_str("\\t"),
- c if c.is_control() => {
- let _ = write!(out, "\\u{:04x}", c as u32);
- }
- c => out.push(c),
- }
- }
- out.push('"');
- out
-}
-
-fn identity_transform() -> [f32; 16] {
- [
- 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
- ]
-}
-
-fn usage() -> String {
- "usage: fparkan-viewer archive --file <archive> [--limit N] | model --root <game-root> --archive <archive> --name <msh> | model --fixture synthetic/model-basic | texture --root <game-root> --archive <archive> --name <texm> | map --file <Land.msh|Land.map> --kind land-msh|land-map".to_string()
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn strings(values: &[&str]) -> Vec<String> {
- values.iter().map(|value| (*value).to_string()).collect()
- }
-
- #[test]
- fn parses_resource_query() -> Result<(), String> {
- let query = parse_resource_query(&strings(&[
- "--root",
- "testdata/IS",
- "--archive",
- "textures.lib",
- "--name",
- "grass.tex",
- ]))?;
-
- assert_eq!(query.root, PathBuf::from("testdata/IS"));
- assert_eq!(query.archive, "textures.lib");
- assert_eq!(query.name, "grass.tex");
- Ok(())
- }
-
- #[test]
- fn json_string_escapes_controls() {
- assert_eq!(json_string("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
- }
-
- #[test]
- fn usage_rejects_empty_args() {
- assert_eq!(run(&[]), Err(usage()));
- }
-
- #[test]
- fn parses_limit() {
- assert_eq!(parse_limit(&strings(&["--limit", "2"])), Ok(2));
- assert_eq!(parse_limit(&[]), Ok(0));
- assert_eq!(
- parse_limit(&strings(&["--limit", "x"])),
- Err("invalid --limit: x".to_string())
- );
- }
-
- #[test]
- fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> {
- assert_eq!(
- run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?,
- "{\"report_kind\":\"model-inspection\",\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model-inspection\",\"draw_commands\":1}"
- );
- Ok(())
- }
-}
diff --git a/apps/fparkan-vulkan-smoke/Cargo.toml b/apps/fparkan-vulkan-smoke/Cargo.toml
deleted file mode 100644
index 4492c66..0000000
--- a/apps/fparkan-vulkan-smoke/Cargo.toml
+++ /dev/null
@@ -1,19 +0,0 @@
-[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
diff --git a/apps/fparkan-vulkan-smoke/build.rs b/apps/fparkan-vulkan-smoke/build.rs
deleted file mode 100644
index 24da219..0000000
--- a/apps/fparkan-vulkan-smoke/build.rs
+++ /dev/null
@@ -1,134 +0,0 @@
-//! 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)
- })
-}
diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs
deleted file mode 100644
index ec2d19c..0000000
--- a/apps/fparkan-vulkan-smoke/src/main.rs
+++ /dev/null
@@ -1,1973 +0,0 @@
-#![forbid(unsafe_code)]
-#![cfg_attr(
- test,
- allow(
- clippy::expect_used,
- clippy::needless_raw_string_hashes,
- clippy::panic,
- clippy::unwrap_used
- )
-)]
-#![allow(clippy::print_stderr, clippy::print_stdout)]
-//! Native Vulkan smoke runner entrypoint.
-
-use fparkan_platform::RenderRequest;
-use fparkan_platform_winit::{window_native_handles, WinitWindowPlan};
-use fparkan_render_vulkan::{
- project_land_msh_to_static_mesh, project_msh_to_static_mesh, VulkanReadbackArtifact,
- VulkanSmokeBootstrapProgress, VulkanSmokeFrameOutcome, VulkanSmokeRenderer,
- VulkanSmokeRendererCreateInfo, VulkanSmokeRendererReport, VulkanSmokeShutdownReport,
- VulkanStaticCamera, VulkanStaticMaterial, VulkanStaticMesh, VulkanStaticTexture,
- VulkanValidationReport,
-};
-use serde::Serialize;
-use std::path::PathBuf;
-use std::process::Command;
-use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
-use std::sync::Arc;
-use std::time::{Duration, Instant};
-use winit::application::ApplicationHandler;
-use winit::dpi::PhysicalSize;
-use winit::event::StartCause;
-use winit::event::WindowEvent;
-use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
-use winit::window::{Window, WindowId};
-
-const SCHEMA_VERSION: &str = "fparkan-native-smoke-v1";
-const DEFAULT_TARGET_FRAMES: u32 = 300;
-const DEFAULT_RESIZE_FRAME: u32 = 120;
-const DEFAULT_RESIZE_WIDTH: u32 = 960;
-const DEFAULT_RESIZE_HEIGHT: u32 = 540;
-const DEFAULT_TIMEOUT_SECONDS: u64 = 120;
-
-fn main() {
- let args = std::env::args().skip(1).collect::<Vec<_>>();
- let code = match run(&args) {
- Ok(output) => {
- println!("{output}");
- 0
- }
- Err(err) => {
- eprintln!("{err}");
- 2
- }
- };
- std::process::exit(code);
-}
-
-fn run(args: &[String]) -> Result<String, String> {
- let options = SmokeOptions::parse(args)?;
- let mesh = options.load_mesh()?;
- let materials = options.load_materials(&mesh)?;
- remove_stale_output(&options)?;
- let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?;
- event_loop.set_control_flow(ControlFlow::Poll);
- let completed = Arc::new(AtomicBool::new(false));
- let progress = Arc::new(SharedSmokeProgress::default());
- spawn_timeout_watchdog(
- options.clone(),
- Arc::clone(&completed),
- Arc::clone(&progress),
- );
- let mut app = SmokeApp::new(options, mesh, materials, completed, progress);
- if let Err(err) = event_loop.run_app(&mut app) {
- app.error = Some(format!("winit event loop: {err}"));
- }
- app.finish()
-}
-
-fn spawn_timeout_watchdog(
- options: SmokeOptions,
- completed: Arc<AtomicBool>,
- progress: Arc<SharedSmokeProgress>,
-) {
- std::thread::spawn(move || {
- std::thread::sleep(Duration::from_secs(options.timeout_seconds));
- if completed.load(Ordering::SeqCst) {
- return;
- }
- let failure_reason = format!(
- "native smoke timed out after {} seconds",
- options.timeout_seconds
- );
- if let Ok(report) = render_timeout_failure_report(&options, &failure_reason, &progress) {
- if let Some(parent) = options.out.parent() {
- let _ = std::fs::create_dir_all(parent);
- }
- let _ = std::fs::write(&options.out, report);
- }
- eprintln!("{failure_reason}");
- std::process::exit(2);
- });
-}
-
-fn remove_stale_output(options: &SmokeOptions) -> Result<(), String> {
- if !options.out.exists() {
- return Ok(());
- }
- std::fs::remove_file(&options.out).map_err(|err| format!("{}: {err}", options.out.display()))
-}
-
-fn compare_readback_files(
- expected: &std::path::Path,
- actual: &std::path::Path,
-) -> Result<(), String> {
- let expected_format = readback_format_from_path(expected)?;
- let actual_format = readback_format_from_path(actual)?;
- if expected_format != actual_format {
- return Err(format!(
- "pixel readback format mismatch: expected={expected_format} actual={actual_format}"
- ));
- }
- let expected_bytes =
- std::fs::read(expected).map_err(|err| format!("{}: {err}", expected.display()))?;
- let actual_bytes =
- std::fs::read(actual).map_err(|err| format!("{}: {err}", actual.display()))?;
- if expected_bytes.len() != actual_bytes.len() {
- return Err(format!(
- "pixel readback length mismatch: expected={} actual={}",
- expected_bytes.len(),
- actual_bytes.len()
- ));
- }
- if let Some((offset, (expected_byte, actual_byte))) = expected_bytes
- .iter()
- .zip(&actual_bytes)
- .enumerate()
- .find(|(_, (expected_byte, actual_byte))| expected_byte != actual_byte)
- {
- return Err(format!(
- "pixel readback mismatch at byte {offset}: expected={expected_byte} actual={actual_byte}"
- ));
- }
- Ok(())
-}
-
-fn readback_format_from_path(path: &std::path::Path) -> Result<i32, String> {
- let name = path
- .file_name()
- .and_then(std::ffi::OsStr::to_str)
- .ok_or_else(|| {
- format!(
- "pixel readback artifact has no UTF-8 file name: {}",
- path.display()
- )
- })?;
- let prefix = ".readback-vkformat-";
- let raw = name
- .strip_suffix(".raw")
- .and_then(|stem| stem.rsplit_once(prefix).map(|(_, raw)| raw))
- .ok_or_else(|| {
- format!(
- "pixel readback artifact lacks format identity: {}",
- path.display()
- )
- })?;
- raw.parse::<i32>().map_err(|_| {
- format!(
- "pixel readback artifact has invalid format identity: {}",
- path.display()
- )
- })
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct SmokeOptions {
- out: PathBuf,
- frames: u32,
- resize_frame: u32,
- timeout_seconds: u64,
- expected_readback: Option<PathBuf>,
- mesh_input: MeshInput,
- texture_input: Option<TextureInput>,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct ResourceInput {
- root: PathBuf,
- archive: String,
- name: String,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct WearMaterialInput {
- root: PathBuf,
- archive: String,
- name: String,
- material_index: u16,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct WearInput {
- root: PathBuf,
- archive: String,
- name: String,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-enum TextureInput {
- Direct(ResourceInput),
- WearMaterial(WearMaterialInput),
- WearBatchMaterials(WearInput),
-}
-
-impl TextureInput {
- fn source(&self) -> &'static str {
- match self {
- Self::Direct(_) => "original-texm",
- Self::WearMaterial(_) => "original-wear-mat0-texm",
- Self::WearBatchMaterials(_) => "original-wear-mat0-texm-batch",
- }
- }
-
- fn archive(&self) -> &str {
- match self {
- Self::Direct(input) => &input.archive,
- Self::WearMaterial(_) | Self::WearBatchMaterials(_) => "Textures.lib",
- }
- }
-
- fn requested_name(&self) -> &str {
- match self {
- Self::Direct(input) => &input.name,
- // The selected name comes from MAT0 at load time and is not a CLI request.
- Self::WearMaterial(_) | Self::WearBatchMaterials(_) => "",
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-enum MeshInput {
- SmokeTriangle,
- Msh {
- root: PathBuf,
- archive: String,
- name: String,
- },
- LandMsh {
- path: PathBuf,
- },
-}
-
-impl MeshInput {
- fn kind(&self) -> &'static str {
- match self {
- Self::SmokeTriangle => "synthetic-smoke-triangle",
- Self::Msh { .. } => "original-msh",
- Self::LandMsh { .. } => "original-land-msh",
- }
- }
-
- fn archive(&self) -> &str {
- match self {
- Self::Msh { archive, .. } => archive,
- Self::SmokeTriangle | Self::LandMsh { .. } => "",
- }
- }
-
- fn name(&self) -> &str {
- match self {
- Self::SmokeTriangle => "",
- Self::Msh { name, .. } => name,
- Self::LandMsh { .. } => "Land.msh",
- }
- }
-}
-
-impl SmokeOptions {
- #[allow(clippy::too_many_lines)]
- fn parse(args: &[String]) -> Result<Self, String> {
- let mut out = None;
- let mut frames = DEFAULT_TARGET_FRAMES;
- let mut resize_frame = DEFAULT_RESIZE_FRAME;
- let mut timeout_seconds = DEFAULT_TIMEOUT_SECONDS;
- let mut expected_readback = None;
- let mut model_root = None;
- let mut model_archive = None;
- let mut model_name = None;
- let mut terrain_path = None;
- let mut texture_root = None;
- let mut texture_archive = None;
- let mut texture_name = None;
- let mut wear_root = None;
- let mut wear_archive = None;
- let mut wear_name = None;
- let mut material_index = None;
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- match arg.as_str() {
- "--out" => {
- out = Some(
- iter.next()
- .map(PathBuf::from)
- .ok_or_else(|| "--out requires a path".to_string())?,
- );
- }
- "--frames" => {
- frames = iter
- .next()
- .ok_or_else(|| "--frames requires a value".to_string())?
- .parse()
- .map_err(|_| "--frames must be an integer".to_string())?;
- }
- "--resize-frame" => {
- resize_frame = iter
- .next()
- .ok_or_else(|| "--resize-frame requires a value".to_string())?
- .parse()
- .map_err(|_| "--resize-frame must be an integer".to_string())?;
- }
- "--timeout-seconds" => {
- timeout_seconds = iter
- .next()
- .ok_or_else(|| "--timeout-seconds requires a value".to_string())?
- .parse()
- .map_err(|_| "--timeout-seconds must be an integer".to_string())?;
- }
- "--expected-readback" => {
- expected_readback = Some(
- iter.next()
- .map(PathBuf::from)
- .ok_or_else(|| "--expected-readback requires a path".to_string())?,
- );
- }
- "--model-root" => {
- model_root = Some(
- iter.next()
- .map(PathBuf::from)
- .ok_or_else(|| "--model-root requires a path".to_string())?,
- );
- }
- "--model-archive" => {
- model_archive = Some(
- iter.next()
- .cloned()
- .ok_or_else(|| "--model-archive requires a value".to_string())?,
- );
- }
- "--model-name" => {
- model_name = Some(
- iter.next()
- .cloned()
- .ok_or_else(|| "--model-name requires a value".to_string())?,
- );
- }
- "--terrain-path" => {
- terrain_path = Some(
- iter.next()
- .map(PathBuf::from)
- .ok_or_else(|| "--terrain-path requires a path".to_string())?,
- );
- }
- "--texture-root" => {
- texture_root = Some(
- iter.next()
- .map(PathBuf::from)
- .ok_or_else(|| "--texture-root requires a path".to_string())?,
- );
- }
- "--texture-archive" => {
- texture_archive = Some(
- iter.next()
- .cloned()
- .ok_or_else(|| "--texture-archive requires a value".to_string())?,
- );
- }
- "--texture-name" => {
- texture_name = Some(
- iter.next()
- .cloned()
- .ok_or_else(|| "--texture-name requires a value".to_string())?,
- );
- }
- "--wear-root" => {
- wear_root = Some(
- iter.next()
- .map(PathBuf::from)
- .ok_or_else(|| "--wear-root requires a path".to_string())?,
- );
- }
- "--wear-archive" => {
- wear_archive = Some(
- iter.next()
- .cloned()
- .ok_or_else(|| "--wear-archive requires a value".to_string())?,
- );
- }
- "--wear-name" => {
- wear_name = Some(
- iter.next()
- .cloned()
- .ok_or_else(|| "--wear-name requires a value".to_string())?,
- );
- }
- "--material-index" => {
- material_index = Some(
- iter.next()
- .ok_or_else(|| "--material-index requires a value".to_string())?
- .parse()
- .map_err(|_| "--material-index must be a u16".to_string())?,
- );
- }
- _ => return Err(format!("unknown native smoke option: {arg}")),
- }
- }
- let out = out.ok_or_else(|| "missing --out".to_string())?;
- if frames < DEFAULT_TARGET_FRAMES {
- return Err(format!(
- "native smoke requires --frames >= {DEFAULT_TARGET_FRAMES}"
- ));
- }
- if timeout_seconds == 0 {
- return Err("native smoke requires --timeout-seconds >= 1".to_string());
- }
- let mesh_input = match (terrain_path, model_root, model_archive, model_name) {
- (Some(path), None, None, None) => MeshInput::LandMsh { path },
- (None, None, None, None) => MeshInput::SmokeTriangle,
- (None, Some(root), Some(archive), Some(name)) => MeshInput::Msh {
- root,
- archive,
- name,
- },
- (None, _, _, _) => {
- return Err(
- "--model-root, --model-archive and --model-name must be supplied together"
- .to_string(),
- );
- }
- _ => return Err("--terrain-path cannot be combined with --model-*".to_string()),
- };
- let texture_input = match (texture_root, texture_archive, texture_name) {
- (None, None, None) => None,
- (Some(root), Some(archive), Some(name)) => Some(TextureInput::Direct(ResourceInput {
- root,
- archive,
- name,
- })),
- _ => {
- return Err(
- "--texture-root, --texture-archive and --texture-name must be supplied together"
- .to_string(),
- );
- }
- };
- let wear_material_input = match (wear_root, wear_archive, wear_name, material_index) {
- (None, None, None, None) => None,
- (Some(root), Some(archive), Some(name), Some(material_index)) => {
- Some(TextureInput::WearMaterial(WearMaterialInput {
- root,
- archive,
- name,
- material_index,
- }))
- }
- (Some(root), Some(archive), Some(name), None) => {
- Some(TextureInput::WearBatchMaterials(WearInput {
- root,
- archive,
- name,
- }))
- }
- _ => {
- return Err(
- "--wear-root, --wear-archive and --wear-name must be supplied together; --material-index is optional"
- .to_string(),
- );
- }
- };
- if texture_input.is_some() && wear_material_input.is_some() {
- return Err(
- "manual --texture-* input cannot be combined with --wear-* material input"
- .to_string(),
- );
- }
- Ok(Self {
- out,
- frames,
- resize_frame,
- timeout_seconds,
- expected_readback,
- mesh_input,
- texture_input: texture_input.or(wear_material_input),
- })
- }
-
- fn load_mesh(&self) -> Result<VulkanStaticMesh, String> {
- match &self.mesh_input {
- MeshInput::SmokeTriangle => Ok(VulkanStaticMesh::smoke_triangle()),
- MeshInput::Msh {
- root,
- archive,
- name,
- } => {
- let model = fparkan_inspection::load_model_from_root(root, archive, name)?;
- project_msh_to_static_mesh(&model).map_err(|err| err.to_string())
- }
- MeshInput::LandMsh { path } => {
- let terrain = fparkan_inspection::load_land_msh_from_path(path)?;
- project_land_msh_to_static_mesh(&terrain).map_err(|err| err.to_string())
- }
- }
- }
-
- fn load_materials(&self, mesh: &VulkanStaticMesh) -> Result<Vec<LoadedTexture>, String> {
- let Some(input) = self.texture_input.as_ref() else {
- return Ok(Vec::new());
- };
- match input {
- TextureInput::Direct(input) => Ok(vec![Self::load_material(
- 0,
- {
- let image = fparkan_inspection::load_texture_mip0_rgba8_from_root(
- &input.root,
- &input.archive,
- &input.name,
- )?;
- (image.width, image.height, image.rgba8)
- },
- input.name.clone(),
- )]),
- TextureInput::WearMaterial(input) => {
- let selected = fparkan_inspection::load_wear_material_texture_mip0_rgba8_from_root(
- &input.root,
- &input.archive,
- &input.name,
- input.material_index,
- )?;
- Ok(vec![Self::load_material(
- input.material_index,
- (
- selected.image.width,
- selected.image.height,
- selected.image.rgba8,
- ),
- selected.texture_name,
- )])
- }
- TextureInput::WearBatchMaterials(input) => {
- let mut selectors = mesh
- .draw_ranges
- .iter()
- .map(|range| range.material_index)
- .collect::<Vec<_>>();
- selectors.sort_unstable();
- selectors.dedup();
- selectors
- .into_iter()
- .map(|material_index| {
- let selected =
- fparkan_inspection::load_wear_material_texture_mip0_rgba8_from_root(
- &input.root,
- &input.archive,
- &input.name,
- material_index,
- )?;
- Ok(Self::load_material(
- material_index,
- (
- selected.image.width,
- selected.image.height,
- selected.image.rgba8,
- ),
- selected.texture_name,
- ))
- })
- .collect()
- }
- }
- }
-
- fn load_material(
- material_index: u16,
- image: (u32, u32, Vec<u8>),
- selected_name: String,
- ) -> LoadedTexture {
- LoadedTexture {
- material_index,
- texture: VulkanStaticTexture {
- width: image.0,
- height: image.1,
- rgba8: image.2,
- },
- selected_name,
- }
- }
-}
-
-#[derive(Clone, Debug)]
-struct LoadedTexture {
- material_index: u16,
- texture: VulkanStaticTexture,
- selected_name: String,
-}
-
-struct SmokeApp {
- options: SmokeOptions,
- mesh: VulkanStaticMesh,
- materials: Vec<LoadedTexture>,
- completed: Arc<AtomicBool>,
- progress: Arc<SharedSmokeProgress>,
- window_id: Option<WindowId>,
- window: Option<Window>,
- renderer: Option<VulkanSmokeRenderer>,
- final_renderer: Option<RendererSnapshot>,
- error: Option<String>,
- output: Option<String>,
- frames_presented: u32,
- resize_count: u32,
- resize_requested: bool,
- last_size: Option<(u32, u32)>,
- started_at: Instant,
-}
-
-fn drop_renderer_before_window<Renderer, WindowLike>(
- renderer: &mut Option<Renderer>,
- window: &mut Option<WindowLike>,
-) {
- drop(renderer.take());
- drop(window.take());
-}
-
-#[derive(Clone, Debug)]
-struct RendererSnapshot {
- report: VulkanSmokeRendererReport,
- readback_artifact: Option<VulkanReadbackArtifact>,
- swapchain_recreate_count: u32,
- validation: VulkanValidationReport,
-}
-
-impl From<VulkanSmokeShutdownReport> for RendererSnapshot {
- fn from(report: VulkanSmokeShutdownReport) -> Self {
- Self {
- report: report.renderer_report,
- readback_artifact: report.readback_artifact,
- swapchain_recreate_count: report.swapchain_recreate_count,
- validation: report.validation,
- }
- }
-}
-
-impl SmokeApp {
- fn new(
- options: SmokeOptions,
- mesh: VulkanStaticMesh,
- materials: Vec<LoadedTexture>,
- completed: Arc<AtomicBool>,
- progress: Arc<SharedSmokeProgress>,
- ) -> Self {
- Self {
- options,
- mesh,
- materials,
- completed,
- progress,
- window_id: None,
- window: None,
- renderer: None,
- final_renderer: None,
- error: None,
- output: None,
- frames_presented: 0,
- resize_count: 0,
- resize_requested: false,
- last_size: None,
- started_at: Instant::now(),
- }
- }
-
- fn finish(mut self) -> Result<String, String> {
- if let Some(output) = self.output.take() {
- return Ok(output);
- }
- self.completed.store(true, Ordering::SeqCst);
- let error = self
- .error
- .clone()
- .unwrap_or_else(|| "native smoke exited before producing a report".to_string());
- self.write_failure_report(&error)?;
- Err(error)
- }
-
- fn schedule_next_redraw(&self) {
- if let Some(window) = self.window.as_ref() {
- window.request_redraw();
- }
- }
-
- fn write_report(&self, report: &str) -> Result<(), String> {
- if let Some(parent) = self.options.out.parent() {
- std::fs::create_dir_all(parent)
- .map_err(|err| format!("{}: {err}", parent.display()))?;
- }
- std::fs::write(&self.options.out, report)
- .map_err(|err| format!("{}: {err}", self.options.out.display()))
- }
-
- fn readback_artifact_path(&self, artifact: &VulkanReadbackArtifact) -> PathBuf {
- let stem = self
- .options
- .out
- .file_stem()
- .and_then(std::ffi::OsStr::to_str)
- .unwrap_or("smoke");
- self.options
- .out
- .with_file_name(format!("{stem}.readback-vkformat-{}.raw", artifact.format))
- }
-
- fn write_readback_artifact(
- &self,
- artifact: &VulkanReadbackArtifact,
- ) -> Result<PathBuf, String> {
- let path = self.readback_artifact_path(artifact);
- std::fs::write(&path, &artifact.bytes)
- .map_err(|err| format!("{}: {err}", path.display()))
- .map(|()| path)
- }
-
- fn live_renderer_snapshot(&self) -> Option<RendererSnapshot> {
- self.renderer.as_ref().map(|renderer| RendererSnapshot {
- report: renderer.report().clone(),
- readback_artifact: None,
- swapchain_recreate_count: renderer.swapchain_recreate_count(),
- validation: renderer.validation_report(),
- })
- }
-
- fn renderer_snapshot(&self) -> Option<RendererSnapshot> {
- self.final_renderer
- .clone()
- .or_else(|| self.live_renderer_snapshot())
- }
-
- #[allow(clippy::too_many_lines)]
- fn render_report(
- &self,
- status: &'static str,
- failure_reason: Option<&str>,
- ) -> Result<String, String> {
- let renderer = self.renderer_snapshot();
- let validation = renderer
- .as_ref()
- .map_or_else(VulkanValidationReport::default, |snapshot| {
- snapshot.validation.clone()
- });
- let smoke_report = SmokeReport {
- schema_version: SCHEMA_VERSION,
- commit_sha: compiled_commit_sha(),
- git_dirty: compiled_git_dirty(),
- runner_identity: measured_runner_identity(),
- runner_architecture: actual_architecture(),
- rust_toolchain: compiled_rust_toolchain(),
- target_triple: compiled_target_triple(),
- platform: actual_platform(),
- status,
- failure_reason,
- frames: self.frames_presented,
- resize_count: self.resize_count,
- swapchain_recreate_count: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.swapchain_recreate_count),
- validation_warning_count: validation.warning_count,
- validation_error_count: validation.error_count,
- validation_vuids: &validation.vuids,
- requested_frames: self.options.frames,
- timeout_seconds: self.options.timeout_seconds,
- mesh_source: self.options.mesh_input.kind(),
- mesh_archive: self.options.mesh_input.archive(),
- mesh_name: self.options.mesh_input.name(),
- mesh_vertex_count: self.mesh.vertices.len(),
- mesh_index_count: self.mesh.indices.len(),
- mesh_draw_count: self.mesh.draw_ranges.len(),
- texture_source: self
- .options
- .texture_input
- .as_ref()
- .map_or("none", TextureInput::source),
- texture_archive: self
- .options
- .texture_input
- .as_ref()
- .map_or("", TextureInput::archive),
- texture_name: match self.materials.as_slice() {
- [] => self
- .options
- .texture_input
- .as_ref()
- .map_or("", TextureInput::requested_name),
- [material] => material.selected_name.as_str(),
- _ => "multiple",
- },
- texture_width: self
- .materials
- .first()
- .map_or(0, |material| material.texture.width),
- texture_height: self
- .materials
- .first()
- .map_or(0, |material| material.texture.height),
- material_binding_count: self.materials.len(),
- mesh_material_indices: self
- .mesh
- .draw_ranges
- .iter()
- .map(|range| range.material_index)
- .collect(),
- material_texture_names: self
- .materials
- .iter()
- .map(|material| material.selected_name.clone())
- .collect(),
- shader_manifest_hash: renderer
- .as_ref()
- .map_or("", |snapshot| snapshot.report.shader_manifest_hash.as_str()),
- vulkan_loader_status: if renderer.is_some() {
- "available"
- } else {
- "failed"
- },
- vulkan_instance_status: if renderer.is_some() {
- "created"
- } else {
- "failed"
- },
- window_status: if self.window.is_some() {
- "created"
- } else {
- "failed"
- },
- vulkan_surface_status: if renderer.is_some() {
- "created"
- } else {
- "failed"
- },
- vulkan_device_status: if renderer.is_some() {
- "selected"
- } else {
- "failed"
- },
- vulkan_device_name: renderer
- .as_ref()
- .map_or("", |snapshot| snapshot.report.device_name.as_str()),
- vulkan_logical_device_status: if renderer.is_some() {
- "created"
- } else {
- "failed"
- },
- vulkan_logical_device_graphics_queue_family: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.graphics_queue_family),
- vulkan_logical_device_present_queue_family: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.present_queue_family),
- vulkan_logical_device_enabled_extension_count: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.enabled_extension_count),
- vulkan_swapchain_status: if renderer.is_some() {
- "created"
- } else {
- "failed"
- },
- vulkan_swapchain_width: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.swapchain_extent.0),
- vulkan_swapchain_height: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.swapchain_extent.1),
- vulkan_swapchain_image_count: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.swapchain_image_count),
- vulkan_swapchain_image_format: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.swapchain_image_format),
- vulkan_swapchain_image_usage: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.swapchain_image_usage),
- vulkan_readback_copy_count: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.readback_copy_count),
- vulkan_readback_byte_count: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.readback_byte_count),
- vulkan_readback_fnv1a64: renderer
- .as_ref()
- .map_or(0, |snapshot| snapshot.report.readback_fnv1a64),
- vulkan_portability_enumeration: renderer
- .as_ref()
- .is_some_and(|snapshot| snapshot.report.portability_enumeration),
- vulkan_portability_subset_enabled: renderer
- .as_ref()
- .is_some_and(|snapshot| snapshot.report.portability_subset_enabled),
- };
- serde_json::to_string_pretty(&smoke_report)
- .map(|json| format!("{json}\n"))
- .map_err(|err| format!("native smoke report serialization failed: {err}"))
- }
-
- fn write_failure_report(&self, failure_reason: &str) -> Result<(), String> {
- let report = self.render_report("failed", Some(failure_reason))?;
- self.write_report(&report)
- }
-
- fn abort_if_timed_out(&mut self, event_loop: &ActiveEventLoop) -> bool {
- if self.output.is_some() || self.error.is_some() {
- return false;
- }
- if self.started_at.elapsed() <= Duration::from_secs(self.options.timeout_seconds) {
- return false;
- }
- self.error = Some(format!(
- "native smoke timed out after {} seconds",
- self.options.timeout_seconds
- ));
- event_loop.exit();
- true
- }
-
- fn complete(&mut self, event_loop: &ActiveEventLoop) {
- let Some(renderer) = self.renderer.as_ref() else {
- self.error = Some("native smoke renderer was not initialized".to_string());
- event_loop.exit();
- return;
- };
- if self.frames_presented < self.options.frames {
- self.error = Some("native smoke did not reach the required frame count".to_string());
- event_loop.exit();
- return;
- }
- if self.resize_count == 0 || renderer.swapchain_recreate_count() == 0 {
- self.error = Some(
- "native smoke requires at least one measured resize and swapchain recreation"
- .to_string(),
- );
- event_loop.exit();
- return;
- }
- let Some(renderer) = self.renderer.take() else {
- self.error = Some("native smoke renderer was not initialized".to_string());
- event_loop.exit();
- return;
- };
- let final_renderer = match renderer.shutdown() {
- Ok(report) => RendererSnapshot::from(report),
- Err(err) => {
- self.error = Some(err.to_string());
- event_loop.exit();
- return;
- }
- };
- if final_renderer.validation.warning_count != 0
- || final_renderer.validation.error_count != 0
- {
- self.final_renderer = Some(final_renderer.clone());
- self.error = Some(format!(
- "native smoke validation must stay clean (warnings={}, errors={})",
- final_renderer.validation.warning_count, final_renderer.validation.error_count
- ));
- event_loop.exit();
- return;
- }
- self.final_renderer = Some(final_renderer);
- if let Some(artifact) = self
- .final_renderer
- .as_ref()
- .and_then(|snapshot| snapshot.readback_artifact.as_ref())
- {
- let path = match self.write_readback_artifact(artifact) {
- Ok(path) => path,
- Err(err) => {
- self.error = Some(err);
- event_loop.exit();
- return;
- }
- };
- if let Some(expected) = self.options.expected_readback.as_ref() {
- if let Err(err) = compare_readback_files(expected, &path) {
- self.error = Some(err);
- event_loop.exit();
- return;
- }
- }
- }
- let report = match self.render_report("passed", None) {
- Ok(report) => report,
- Err(err) => {
- self.error = Some(err);
- event_loop.exit();
- return;
- }
- };
- self.completed.store(true, Ordering::SeqCst);
- if let Err(err) = self.write_report(&report) {
- self.error = Some(err);
- event_loop.exit();
- return;
- }
- self.output = Some(report);
- event_loop.exit();
- }
-
- fn request_controlled_resize(&mut self) {
- if self.resize_requested {
- return;
- }
- let Some(window) = self.window.as_ref() else {
- return;
- };
- self.resize_requested = true;
- let requested = PhysicalSize::new(DEFAULT_RESIZE_WIDTH, DEFAULT_RESIZE_HEIGHT);
- let _ = window.request_inner_size(requested);
- }
-}
-
-impl Drop for SmokeApp {
- fn drop(&mut self) {
- // Keep the native window alive until the Vulkan renderer finishes
- // destroying swapchain and surface state that still references it.
- drop_renderer_before_window(&mut self.renderer, &mut self.window);
- }
-}
-
-fn render_timeout_failure_report(
- options: &SmokeOptions,
- failure_reason: &str,
- progress: &SharedSmokeProgress,
-) -> Result<String, String> {
- let bootstrap = progress.bootstrap.snapshot();
- let smoke_report = SmokeReport {
- schema_version: SCHEMA_VERSION,
- commit_sha: compiled_commit_sha(),
- git_dirty: compiled_git_dirty(),
- runner_identity: measured_runner_identity(),
- runner_architecture: actual_architecture(),
- rust_toolchain: compiled_rust_toolchain(),
- target_triple: compiled_target_triple(),
- platform: actual_platform(),
- status: "failed",
- failure_reason: Some(failure_reason),
- frames: progress.frames_presented.load(Ordering::SeqCst),
- resize_count: progress.resize_count.load(Ordering::SeqCst),
- swapchain_recreate_count: progress.swapchain_recreate_count.load(Ordering::SeqCst),
- validation_warning_count: 0,
- validation_error_count: 0,
- validation_vuids: &[],
- requested_frames: options.frames,
- timeout_seconds: options.timeout_seconds,
- mesh_source: options.mesh_input.kind(),
- mesh_archive: options.mesh_input.archive(),
- mesh_name: options.mesh_input.name(),
- mesh_vertex_count: 0,
- mesh_index_count: 0,
- mesh_draw_count: 0,
- texture_source: options
- .texture_input
- .as_ref()
- .map_or("none", TextureInput::source),
- texture_archive: options
- .texture_input
- .as_ref()
- .map_or("", TextureInput::archive),
- texture_name: options
- .texture_input
- .as_ref()
- .map_or("", TextureInput::requested_name),
- texture_width: 0,
- texture_height: 0,
- material_binding_count: 0,
- mesh_material_indices: Vec::new(),
- material_texture_names: Vec::new(),
- shader_manifest_hash: "",
- vulkan_loader_status: if bootstrap.loader_available {
- "available"
- } else {
- "failed"
- },
- vulkan_instance_status: if bootstrap.instance_created {
- "created"
- } else {
- "failed"
- },
- window_status: progress.window_phase.status(),
- vulkan_surface_status: if bootstrap.surface_created {
- "created"
- } else {
- "failed"
- },
- vulkan_device_status: if bootstrap.device_selected {
- "selected"
- } else {
- "failed"
- },
- vulkan_device_name: "",
- vulkan_logical_device_status: if bootstrap.logical_device_created {
- "created"
- } else {
- "failed"
- },
- vulkan_logical_device_graphics_queue_family: 0,
- vulkan_logical_device_present_queue_family: 0,
- vulkan_logical_device_enabled_extension_count: 0,
- vulkan_swapchain_status: if bootstrap.swapchain_created {
- "created"
- } else {
- "failed"
- },
- vulkan_swapchain_width: 0,
- vulkan_swapchain_height: 0,
- vulkan_swapchain_image_count: 0,
- vulkan_swapchain_image_format: 0,
- vulkan_swapchain_image_usage: 0,
- vulkan_readback_copy_count: 0,
- vulkan_readback_byte_count: 0,
- vulkan_readback_fnv1a64: 0,
- vulkan_portability_enumeration: false,
- vulkan_portability_subset_enabled: false,
- };
- serde_json::to_string_pretty(&smoke_report)
- .map(|json| format!("{json}\n"))
- .map_err(|err| format!("native smoke report serialization failed: {err}"))
-}
-
-impl ApplicationHandler for SmokeApp {
- fn new_events(&mut self, event_loop: &ActiveEventLoop, _cause: StartCause) {
- let _ = self.abort_if_timed_out(event_loop);
- }
-
- fn resumed(&mut self, event_loop: &ActiveEventLoop) {
- if self.abort_if_timed_out(event_loop) {
- return;
- }
- self.progress.window_phase.store(WindowPhase::Resumed);
- if self.window.is_some() {
- return;
- }
- let plan = match WinitWindowPlan::smoke().validate() {
- Ok(plan) => plan,
- Err(err) => {
- self.error = Some(err.to_string());
- event_loop.exit();
- return;
- }
- };
- self.progress
- .window_phase
- .store(WindowPhase::CreatingWindow);
- let attributes = Window::default_attributes()
- .with_title("FParkan Vulkan smoke")
- .with_inner_size(PhysicalSize::new(plan.width, plan.height));
- let window = match event_loop.create_window(attributes) {
- Ok(window) => window,
- Err(err) => {
- self.error = Some(format!("winit window: {err}"));
- event_loop.exit();
- return;
- }
- };
- let Some(native_handles) = window_native_handles(&window) else {
- self.error = Some("winit window does not expose native handles".to_string());
- event_loop.exit();
- return;
- };
- let size = window.inner_size();
- let renderer = match VulkanSmokeRenderer::new(&VulkanSmokeRendererCreateInfo {
- application_name: "fparkan-vulkan-smoke".to_string(),
- native_handles,
- drawable_extent: (size.width.max(1), size.height.max(1)),
- render_request: RenderRequest::conservative(),
- enable_validation: true,
- mesh: self.mesh.clone(),
- camera: VulkanStaticCamera::default(),
- materials: self
- .materials
- .iter()
- .map(|material| VulkanStaticMaterial {
- material_index: material.material_index,
- texture: material.texture.clone(),
- })
- .collect(),
- bootstrap_progress: Some(Arc::clone(&self.progress.bootstrap)),
- }) {
- Ok(renderer) => renderer,
- Err(err) => {
- self.error = Some(err.to_string());
- event_loop.exit();
- return;
- }
- };
- self.last_size = Some((size.width, size.height));
- self.window_id = Some(window.id());
- self.progress.window_phase.store(WindowPhase::Created);
- self.renderer = Some(renderer);
- self.window = Some(window);
- self.schedule_next_redraw();
- }
-
- fn window_event(
- &mut self,
- event_loop: &ActiveEventLoop,
- window_id: WindowId,
- event: WindowEvent,
- ) {
- if self.abort_if_timed_out(event_loop) {
- return;
- }
- if Some(window_id) != self.window_id {
- return;
- }
- match event {
- WindowEvent::CloseRequested => {
- if self.output.is_none() {
- self.error = Some("native smoke window closed before completion".to_string());
- }
- event_loop.exit();
- }
- WindowEvent::Resized(size) => {
- if self
- .last_size
- .is_some_and(|last| last != (size.width, size.height))
- {
- self.resize_count = self.resize_count.saturating_add(1);
- self.progress
- .resize_count
- .store(self.resize_count, Ordering::SeqCst);
- }
- self.last_size = Some((size.width, size.height));
- if let Some(renderer) = self.renderer.as_mut() {
- renderer.request_resize((size.width, size.height));
- }
- }
- WindowEvent::RedrawRequested => {
- let Some(renderer) = self.renderer.as_mut() else {
- self.error = Some("native smoke renderer was not initialized".to_string());
- event_loop.exit();
- return;
- };
- if let Err(error) = renderer.set_camera(VulkanStaticCamera::default()) {
- self.error = Some(error.to_string());
- event_loop.exit();
- return;
- }
- match renderer.draw_frame() {
- Ok(VulkanSmokeFrameOutcome::Presented) => {
- self.frames_presented = self.frames_presented.saturating_add(1);
- self.progress
- .frames_presented
- .store(self.frames_presented, Ordering::SeqCst);
- }
- Ok(
- VulkanSmokeFrameOutcome::Recreated | VulkanSmokeFrameOutcome::ZeroExtent,
- ) => {}
- Err(err) => {
- self.error = Some(err.to_string());
- event_loop.exit();
- return;
- }
- }
- let recreate_count = renderer.swapchain_recreate_count();
- self.progress
- .swapchain_recreate_count
- .store(recreate_count, Ordering::SeqCst);
- let should_request_resize =
- !self.resize_requested && self.frames_presented >= self.options.resize_frame;
- let should_complete = self.frames_presented >= self.options.frames
- && self.resize_count > 0
- && recreate_count > 0;
- let _ = renderer;
- if should_request_resize {
- self.request_controlled_resize();
- }
- if should_complete {
- self.complete(event_loop);
- } else {
- self.schedule_next_redraw();
- }
- }
- _ => {}
- }
- }
-
- fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
- if self.abort_if_timed_out(event_loop) {
- return;
- }
- if self.output.is_none() && self.error.is_none() {
- self.schedule_next_redraw();
- }
- }
-}
-
-#[derive(Serialize)]
-struct SmokeReport<'a> {
- schema_version: &'static str,
- commit_sha: String,
- git_dirty: bool,
- runner_identity: String,
- runner_architecture: &'static str,
- rust_toolchain: String,
- target_triple: String,
- platform: &'static str,
- status: &'a str,
- #[serde(skip_serializing_if = "Option::is_none")]
- failure_reason: Option<&'a str>,
- frames: u32,
- resize_count: u32,
- swapchain_recreate_count: u32,
- validation_warning_count: u32,
- validation_error_count: u32,
- validation_vuids: &'a [String],
- requested_frames: u32,
- timeout_seconds: u64,
- mesh_source: &'a str,
- mesh_archive: &'a str,
- mesh_name: &'a str,
- mesh_vertex_count: usize,
- mesh_index_count: usize,
- mesh_draw_count: usize,
- texture_source: &'a str,
- texture_archive: &'a str,
- texture_name: &'a str,
- texture_width: u32,
- texture_height: u32,
- material_binding_count: usize,
- mesh_material_indices: Vec<u16>,
- material_texture_names: Vec<String>,
- shader_manifest_hash: &'a str,
- vulkan_loader_status: &'a str,
- vulkan_instance_status: &'a str,
- window_status: &'a str,
- vulkan_surface_status: &'a str,
- vulkan_device_status: &'a str,
- vulkan_device_name: &'a str,
- vulkan_logical_device_status: &'a str,
- vulkan_logical_device_graphics_queue_family: u32,
- vulkan_logical_device_present_queue_family: u32,
- vulkan_logical_device_enabled_extension_count: u32,
- vulkan_swapchain_status: &'a str,
- vulkan_swapchain_width: u32,
- vulkan_swapchain_height: u32,
- vulkan_swapchain_image_count: u32,
- vulkan_swapchain_image_format: i32,
- vulkan_swapchain_image_usage: u32,
- vulkan_readback_copy_count: u64,
- vulkan_readback_byte_count: u64,
- vulkan_readback_fnv1a64: u64,
- vulkan_portability_enumeration: bool,
- vulkan_portability_subset_enabled: bool,
-}
-
-#[derive(Debug, Default)]
-struct SharedSmokeProgress {
- bootstrap: Arc<VulkanSmokeBootstrapProgress>,
- window_phase: AtomicWindowPhase,
- frames_presented: AtomicU32,
- resize_count: AtomicU32,
- swapchain_recreate_count: AtomicU32,
-}
-
-#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
-enum WindowPhase {
- #[default]
- NotStarted,
- Resumed,
- CreatingWindow,
- Created,
-}
-
-impl WindowPhase {
- const fn as_u8(self) -> u8 {
- match self {
- Self::NotStarted => 0,
- Self::Resumed => 1,
- Self::CreatingWindow => 2,
- Self::Created => 3,
- }
- }
-
- const fn from_u8(value: u8) -> Self {
- match value {
- 1 => Self::Resumed,
- 2 => Self::CreatingWindow,
- 3 => Self::Created,
- _ => Self::NotStarted,
- }
- }
-
- const fn status(self) -> &'static str {
- match self {
- Self::NotStarted => "failed",
- Self::Resumed => "resumed",
- Self::CreatingWindow => "creating",
- Self::Created => "created",
- }
- }
-}
-
-#[derive(Debug, Default)]
-struct AtomicWindowPhase(AtomicU32);
-
-impl AtomicWindowPhase {
- fn store(&self, phase: WindowPhase) {
- self.0.store(u32::from(phase.as_u8()), Ordering::SeqCst);
- }
-
- fn status(&self) -> &'static str {
- match u8::try_from(self.0.load(Ordering::SeqCst)) {
- Ok(value) => WindowPhase::from_u8(value),
- Err(_) => WindowPhase::NotStarted,
- }
- .status()
- }
-}
-
-fn actual_platform() -> &'static str {
- match std::env::consts::OS {
- "windows" => "windows",
- other => other,
- }
-}
-
-fn actual_architecture() -> &'static str {
- match std::env::consts::ARCH {
- "arm64" => "aarch64",
- other => other,
- }
-}
-
-fn compiled_commit_sha() -> String {
- option_env!("FPARKAN_BUILD_COMMIT_SHA")
- .filter(|value| is_commit_sha(value))
- .map_or_else(runtime_git_commit_sha, ToString::to_string)
-}
-
-fn runtime_git_commit_sha() -> String {
- Command::new("git")
- .args(["rev-parse", "HEAD"])
- .output()
- .ok()
- .filter(|output| output.status.success())
- .and_then(|output| String::from_utf8(output.stdout).ok())
- .map(|value| value.trim().to_string())
- .filter(|value| is_commit_sha(value))
- .unwrap_or_else(|| "unknown".to_string())
-}
-
-fn compiled_git_dirty() -> bool {
- option_env!("FPARKAN_BUILD_GIT_DIRTY")
- .and_then(parse_bool_env)
- .unwrap_or_else(runtime_git_dirty)
-}
-
-fn runtime_git_dirty() -> bool {
- Command::new("git")
- .args(["status", "--short"])
- .output()
- .ok()
- .filter(|output| output.status.success())
- .and_then(|output| String::from_utf8(output.stdout).ok())
- .is_some_and(|output| !output.trim().is_empty())
-}
-
-fn compiled_rust_toolchain() -> String {
- option_env!("FPARKAN_BUILD_RUST_TOOLCHAIN")
- .filter(|value| !value.trim().is_empty())
- .map_or_else(current_rustc_release, ToString::to_string)
-}
-
-fn current_rustc_release() -> String {
- Command::new("rustc")
- .arg("-Vv")
- .output()
- .ok()
- .filter(|output| output.status.success())
- .and_then(|output| String::from_utf8(output.stdout).ok())
- .and_then(|output| {
- output
- .lines()
- .find_map(|line| line.strip_prefix("release: ").map(ToString::to_string))
- })
- .unwrap_or_else(|| "unknown".to_string())
-}
-
-fn compiled_target_triple() -> String {
- option_env!("FPARKAN_BUILD_TARGET_TRIPLE")
- .filter(|value| !value.trim().is_empty())
- .map_or_else(current_rustc_host_triple, ToString::to_string)
-}
-
-fn measured_runner_identity() -> String {
- if std::env::var_os("GITHUB_ACTIONS").is_some() {
- let run_id = std::env::var("GITHUB_RUN_ID").unwrap_or_else(|_| "unknown-run".to_string());
- let job = std::env::var("GITHUB_JOB").unwrap_or_else(|_| "unknown-job".to_string());
- format!("github-actions/{run_id}/{job}")
- } else if std::env::var_os("CI").is_some() {
- let job = std::env::var("CI_JOB_NAME")
- .or_else(|_| std::env::var("BUILD_ID"))
- .unwrap_or_else(|_| "generic-ci".to_string());
- format!("ci/{job}")
- } else {
- format!("local/{}", std::env::consts::OS)
- }
-}
-
-fn current_rustc_host_triple() -> String {
- Command::new("rustc")
- .arg("-Vv")
- .output()
- .ok()
- .filter(|output| output.status.success())
- .and_then(|output| String::from_utf8(output.stdout).ok())
- .and_then(|output| {
- output
- .lines()
- .find_map(|line| line.strip_prefix("host: ").map(ToString::to_string))
- })
- .unwrap_or_else(|| "unknown".to_string())
-}
-
-fn is_commit_sha(value: &str) -> bool {
- value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
-}
-
-fn parse_bool_env(value: &str) -> Option<bool> {
- match value {
- "true" => Some(true),
- "false" => Some(false),
- _ => None,
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::cell::RefCell;
- use std::rc::Rc;
-
- #[derive(Clone, Copy, Debug, Eq, PartialEq)]
- enum DropStep {
- Renderer,
- Window,
- }
-
- struct DropTracker {
- step: DropStep,
- log: Rc<RefCell<Vec<DropStep>>>,
- }
-
- impl Drop for DropTracker {
- fn drop(&mut self) {
- self.log.borrow_mut().push(self.step);
- }
- }
-
- fn tracker(step: DropStep, log: &Rc<RefCell<Vec<DropStep>>>) -> DropTracker {
- DropTracker {
- step,
- log: Rc::clone(log),
- }
- }
-
- #[test]
- fn parses_required_args() {
- let parsed = SmokeOptions::parse(&["--out".to_string(), "target/report.json".to_string()]);
-
- assert_eq!(
- parsed,
- Ok(SmokeOptions {
- out: PathBuf::from("target/report.json"),
- frames: DEFAULT_TARGET_FRAMES,
- resize_frame: DEFAULT_RESIZE_FRAME,
- timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
- expected_readback: None,
- mesh_input: MeshInput::SmokeTriangle,
- texture_input: None,
- })
- );
- }
-
- #[test]
- fn rejects_too_few_frames() {
- let parsed = SmokeOptions::parse(&[
- "--out".to_string(),
- "target/report.json".to_string(),
- "--frames".to_string(),
- "299".to_string(),
- ]);
-
- assert_eq!(
- parsed,
- Err("native smoke requires --frames >= 300".to_string())
- );
- }
-
- #[test]
- fn parses_timeout_seconds() {
- let parsed = SmokeOptions::parse(&[
- "--out".to_string(),
- "target/report.json".to_string(),
- "--timeout-seconds".to_string(),
- "45".to_string(),
- ]);
-
- assert_eq!(
- parsed,
- Ok(SmokeOptions {
- out: PathBuf::from("target/report.json"),
- frames: DEFAULT_TARGET_FRAMES,
- resize_frame: DEFAULT_RESIZE_FRAME,
- timeout_seconds: 45,
- expected_readback: None,
- mesh_input: MeshInput::SmokeTriangle,
- texture_input: None,
- })
- );
- }
-
- #[test]
- fn rejects_zero_timeout_seconds() {
- let parsed = SmokeOptions::parse(&[
- "--out".to_string(),
- "target/report.json".to_string(),
- "--timeout-seconds".to_string(),
- "0".to_string(),
- ]);
-
- assert_eq!(
- parsed,
- Err("native smoke requires --timeout-seconds >= 1".to_string())
- );
- }
-
- #[test]
- fn parses_original_msh_input_as_one_atomic_request() {
- let parsed = SmokeOptions::parse(&[
- "--out".to_string(),
- "target/report.json".to_string(),
- "--model-root".to_string(),
- "C:/GOG".to_string(),
- "--model-archive".to_string(),
- "system.rlb".to_string(),
- "--model-name".to_string(),
- "MTCHECK.MSH".to_string(),
- ]);
-
- assert!(matches!(
- parsed,
- Ok(SmokeOptions {
- mesh_input: MeshInput::Msh { archive, name, .. },
- ..
- }) if archive == "system.rlb" && name == "MTCHECK.MSH"
- ));
- }
-
- #[test]
- fn parses_original_land_msh_as_direct_terrain_input() {
- let parsed = SmokeOptions::parse(&[
- "--out".to_string(),
- "target/report.json".to_string(),
- "--terrain-path".to_string(),
- "C:/GOG/DATA/MAPS/11/Land.msh".to_string(),
- ]);
-
- assert!(matches!(
- parsed,
- Ok(SmokeOptions {
- mesh_input: MeshInput::LandMsh { path },
- ..
- }) if path == std::path::Path::new("C:/GOG/DATA/MAPS/11/Land.msh")
- ));
- }
-
- #[test]
- fn rejects_partial_original_msh_input() {
- let parsed = SmokeOptions::parse(&[
- "--out".to_string(),
- "target/report.json".to_string(),
- "--model-root".to_string(),
- "C:/GOG".to_string(),
- ]);
-
- assert_eq!(
- parsed,
- Err(
- "--model-root, --model-archive and --model-name must be supplied together"
- .to_string()
- )
- );
- }
-
- #[test]
- fn parses_original_texm_input_as_one_atomic_request() {
- let parsed = SmokeOptions::parse(&[
- "--out".to_string(),
- "target/report.json".to_string(),
- "--texture-root".to_string(),
- "C:/GOG".to_string(),
- "--texture-archive".to_string(),
- "Textures.lib".to_string(),
- "--texture-name".to_string(),
- "DEFAULT.0".to_string(),
- ]);
-
- assert!(matches!(
- parsed,
- Ok(SmokeOptions {
- texture_input: Some(TextureInput::Direct(ResourceInput { archive, name, .. })),
- ..
- }) if archive == "Textures.lib" && name == "DEFAULT.0"
- ));
- }
-
- #[test]
- fn parses_original_wear_material_input_as_one_atomic_request() {
- let parsed = SmokeOptions::parse(&[
- "--out".to_string(),
- "target/report.json".to_string(),
- "--wear-root".to_string(),
- "C:/GOG".to_string(),
- "--wear-archive".to_string(),
- "system.rlb".to_string(),
- "--wear-name".to_string(),
- "MODEL.WEA".to_string(),
- "--material-index".to_string(),
- "7".to_string(),
- ]);
-
- assert!(matches!(
- parsed,
- Ok(SmokeOptions {
- texture_input: Some(TextureInput::WearMaterial(WearMaterialInput {
- archive,
- name,
- material_index,
- ..
- })),
- ..
- }) if archive == "system.rlb" && name == "MODEL.WEA" && material_index == 7
- ));
- }
-
- #[test]
- fn parses_original_wear_batch_material_input_without_override() {
- let parsed = SmokeOptions::parse(&[
- "--out".to_string(),
- "target/report.json".to_string(),
- "--wear-root".to_string(),
- "C:/GOG".to_string(),
- "--wear-archive".to_string(),
- "system.rlb".to_string(),
- "--wear-name".to_string(),
- "MODEL.WEA".to_string(),
- ]);
-
- assert!(matches!(
- parsed,
- Ok(SmokeOptions {
- texture_input: Some(TextureInput::WearBatchMaterials(WearInput { archive, name, .. })),
- ..
- }) if archive == "system.rlb" && name == "MODEL.WEA"
- ));
- }
-
- #[test]
- fn rejects_deprecated_self_assertion_flags() {
- for flag in [
- "--status",
- "--platform",
- "--validation-error-count",
- "--resize-count",
- "--swapchain-recreate-count",
- ] {
- let parsed = SmokeOptions::parse(&[
- "--out".to_string(),
- "target/report.json".to_string(),
- flag.to_string(),
- "value".to_string(),
- ]);
-
- assert_eq!(parsed, Err(format!("unknown native smoke option: {flag}")));
- }
- }
-
- #[test]
- fn commit_sha_validation_accepts_hex_head() {
- assert!(is_commit_sha("0123456789abcdef0123456789abcdef01234567"));
- }
-
- #[test]
- fn commit_sha_validation_rejects_non_hex_or_wrong_length() {
- assert!(!is_commit_sha("0123456789abcdef0123456789abcdef0123456"));
- assert!(!is_commit_sha("zz23456789abcdef0123456789abcdef01234567"));
- }
-
- #[test]
- fn parses_bool_env_values() {
- assert_eq!(parse_bool_env("true"), Some(true));
- assert_eq!(parse_bool_env("false"), Some(false));
- assert_eq!(parse_bool_env("1"), None);
- }
-
- #[test]
- fn smoke_report_json_contains_expected_fields() {
- let json = serde_json::to_string_pretty(&SmokeReport {
- schema_version: SCHEMA_VERSION,
- commit_sha: "0123456789abcdef0123456789abcdef01234567".to_string(),
- git_dirty: false,
- runner_identity: "windows-local/stage0".to_string(),
- runner_architecture: "x86_64",
- rust_toolchain: "1.97.1".to_string(),
- target_triple: "x86_64-pc-windows-msvc".to_string(),
- platform: "windows",
- status: "passed",
- failure_reason: None,
- frames: 300,
- resize_count: 1,
- swapchain_recreate_count: 1,
- validation_warning_count: 0,
- validation_error_count: 0,
- validation_vuids: &["VUID-A".to_string(), "VUID-B".to_string()],
- requested_frames: 300,
- timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
- mesh_source: "original-msh",
- mesh_archive: "system.rlb",
- mesh_name: "MTCHECK.MSH",
- mesh_vertex_count: 128,
- mesh_index_count: 252,
- mesh_draw_count: 1,
- texture_source: "original-texm",
- texture_archive: "Textures.lib",
- texture_name: "DEFAULT.0",
- texture_width: 16,
- texture_height: 16,
- material_binding_count: 1,
- mesh_material_indices: vec![0],
- material_texture_names: vec!["DEFAULT.0".to_string()],
- shader_manifest_hash: "deadbeef",
- vulkan_loader_status: "available",
- vulkan_instance_status: "created",
- window_status: "created",
- vulkan_surface_status: "created",
- vulkan_device_status: "selected",
- vulkan_device_name: "Windows test GPU",
- vulkan_logical_device_status: "created",
- vulkan_logical_device_graphics_queue_family: 0,
- vulkan_logical_device_present_queue_family: 0,
- vulkan_logical_device_enabled_extension_count: 2,
- vulkan_swapchain_status: "created",
- vulkan_swapchain_width: 960,
- vulkan_swapchain_height: 540,
- vulkan_swapchain_image_count: 3,
- vulkan_swapchain_image_format: 50,
- vulkan_swapchain_image_usage: 17,
- vulkan_readback_copy_count: 300,
- vulkan_readback_byte_count: 4_147_200,
- vulkan_readback_fnv1a64: 0x1234_5678_90ab_cdef,
- vulkan_portability_enumeration: false,
- vulkan_portability_subset_enabled: false,
- })
- .expect("smoke report should serialize");
-
- assert!(json.contains("\"schema_version\": \"fparkan-native-smoke-v1\""));
- assert!(json.contains("\"validation_vuids\": ["));
- assert!(json.contains("\"vulkan_device_name\": \"Windows test GPU\""));
- assert!(json.contains("\"runner_architecture\": \"x86_64\""));
- assert!(json.contains("\"mesh_source\": \"original-msh\""));
- assert!(json.contains("\"mesh_draw_count\": 1"));
- assert!(json.contains("\"texture_source\": \"original-texm\""));
- assert!(json.contains("\"vulkan_readback_copy_count\": 300"));
- assert!(json.contains("\"vulkan_readback_byte_count\": 4147200"));
- }
-
- #[test]
- fn finish_writes_failure_artifact() {
- let root = std::env::temp_dir().join(format!(
- "fparkan-native-smoke-{}-{}",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .expect("time")
- .as_nanos()
- ));
- std::fs::create_dir_all(&root).expect("temp dir");
- let out = root.join("report.json");
-
- let result = SmokeApp {
- options: SmokeOptions {
- out: out.clone(),
- frames: DEFAULT_TARGET_FRAMES,
- resize_frame: DEFAULT_RESIZE_FRAME,
- timeout_seconds: 7,
- expected_readback: None,
- mesh_input: MeshInput::SmokeTriangle,
- texture_input: None,
- },
- mesh: VulkanStaticMesh::smoke_triangle(),
- materials: Vec::new(),
- completed: Arc::new(AtomicBool::new(false)),
- progress: Arc::new(SharedSmokeProgress::default()),
- window_id: None,
- window: None,
- renderer: None,
- final_renderer: None,
- error: Some("native smoke timed out after 7 seconds".to_string()),
- output: None,
- frames_presented: 42,
- resize_count: 0,
- resize_requested: false,
- last_size: None,
- started_at: Instant::now(),
- }
- .finish();
-
- assert_eq!(
- result,
- Err("native smoke timed out after 7 seconds".to_string())
- );
-
- let json = std::fs::read_to_string(&out).expect("failure report");
- assert!(json.contains("\"status\": \"failed\""));
- assert!(json.contains("\"failure_reason\": \"native smoke timed out after 7 seconds\""));
- assert!(json.contains("\"timeout_seconds\": 7"));
-
- std::fs::remove_file(out).expect("cleanup report");
- std::fs::remove_dir(root).expect("cleanup dir");
- }
-
- #[test]
- fn readback_comparator_accepts_exact_bytes_and_reports_first_difference() {
- let root = std::env::temp_dir().join(format!("fparkan-readback-{}", std::process::id()));
- std::fs::create_dir_all(&root).expect("temp dir");
- let expected = root.join("expected.readback-vkformat-50.raw");
- let actual = root.join("actual.readback-vkformat-50.raw");
- std::fs::write(&expected, [1_u8, 2, 3]).expect("expected");
- std::fs::write(&actual, [1_u8, 2, 3]).expect("actual");
- assert_eq!(compare_readback_files(&expected, &actual), Ok(()));
- std::fs::write(&actual, [1_u8, 9, 3]).expect("different actual");
- assert_eq!(
- compare_readback_files(&expected, &actual),
- Err("pixel readback mismatch at byte 1: expected=2 actual=9".to_string())
- );
- let incompatible = root.join("incompatible.readback-vkformat-37.raw");
- std::fs::write(&incompatible, [1_u8, 2, 3]).expect("incompatible actual");
- assert_eq!(
- compare_readback_files(&expected, &incompatible),
- Err("pixel readback format mismatch: expected=50 actual=37".to_string())
- );
- std::fs::remove_dir_all(root).expect("cleanup");
- }
-
- #[test]
- fn renderer_is_dropped_before_window() {
- let log = Rc::new(RefCell::new(Vec::new()));
- let mut renderer = Some(tracker(DropStep::Renderer, &log));
- let mut window = Some(tracker(DropStep::Window, &log));
-
- drop_renderer_before_window(&mut renderer, &mut window);
-
- assert!(renderer.is_none());
- assert!(window.is_none());
- assert_eq!(
- Rc::into_inner(log).expect("trackers released").into_inner(),
- vec![DropStep::Renderer, DropStep::Window]
- );
- }
-}
diff --git a/crates/fparkan-animation/Cargo.toml b/crates/fparkan-animation/Cargo.toml
index a3e5d9e..153f9f2 100644
--- a/crates/fparkan-animation/Cargo.toml
+++ b/crates/fparkan-animation/Cargo.toml
@@ -6,6 +6,3 @@ license.workspace = true
repository.workspace = true
[dependencies]
-
-[lints]
-workspace = true
diff --git a/crates/fparkan-animation/src/lib.rs b/crates/fparkan-animation/src/lib.rs
index 34a3fa7..ab606ad 100644
--- a/crates/fparkan-animation/src/lib.rs
+++ b/crates/fparkan-animation/src/lib.rs
@@ -1,23 +1,5 @@
#![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.
//!
//! The current sampler is a portable reference path. Compatibility profiles
@@ -272,12 +254,16 @@ impl AnimKey24 {
];
let time = AnimationTime(read_f32(bytes, 12)?);
validate_time(time)?;
- let raw_rotation = [
+ let disk_wxyz = [
f32::from(read_i16(bytes, 16)?) / 32767.0,
f32::from(read_i16(bytes, 18)?) / 32767.0,
f32::from(read_i16(bytes, 20)?) / 32767.0,
f32::from(read_i16(bytes, 22)?) / 32767.0,
];
+ // Native AniMesh stores the scalar first (WXYZ). The portable pose
+ // contract is XYZW, matching the rotate/multiply helpers below. Keep
+ // the decoded values unnormalized; sampling_pose owns normalization.
+ let raw_rotation = [disk_wxyz[1], disk_wxyz[2], disk_wxyz[3], disk_wxyz[0]];
Ok(Self {
time,
pose: Pose {
@@ -888,9 +874,47 @@ mod tests {
assert_eq!(key.time, AnimationTime(12.5));
assert_eq!(key.pose.translation, [-1.0, 2.0, 0.0]);
- assert!(key.pose.rotation[1] < 0.0);
- assert!((key.pose.rotation[1] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
- assert!((key.pose.rotation[3] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
+ assert!(key.pose.rotation[0] < 0.0);
+ assert!((key.pose.rotation[0] + std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
+ assert!((key.pose.rotation[2] - std::f32::consts::FRAC_1_SQRT_2).abs() < 0.000_05);
+ }
+
+ #[test]
+ fn anim_key24_preserves_disk_wxyz_as_internal_xyzw() {
+ let mut bytes = [0_u8; 24];
+ bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes());
+ bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes());
+ bytes[18..20].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes());
+
+ let key = AnimKey24::decode(&bytes).expect("bunker key");
+ let expected = [0.0, 0.0, 3211.0 / 32767.0, 32609.0 / 32767.0];
+
+ assert_eq!(key.pose.rotation, expected);
+ }
+
+ #[test]
+ fn bunker_fallback_pose_rotates_a_point_with_native_matrix_contract() {
+ let mut bytes = [0_u8; 24];
+ bytes[12..16].copy_from_slice(&0.0_f32.to_bits().to_le_bytes());
+ bytes[16..18].copy_from_slice(&32609_i16.to_le_bytes());
+ bytes[18..20].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[20..22].copy_from_slice(&0_i16.to_le_bytes());
+ bytes[22..24].copy_from_slice(&3211_i16.to_le_bytes());
+
+ let pose = AnimKey24::decode(&bytes)
+ .expect("bunker key")
+ .sampling_pose();
+ let rotated = rotate_point(pose.rotation, [0.0, 1.0, 0.0]);
+
+ assert!((pose.rotation[0] - 0.0).abs() < 0.000_001);
+ assert!((pose.rotation[1] - 0.0).abs() < 0.000_001);
+ assert!((pose.rotation[2] - 0.097_995_8).abs() < 0.000_01);
+ assert!((pose.rotation[3] - 0.995_186_8).abs() < 0.000_01);
+ assert!((rotated[0] + 0.195_048_3).abs() < 0.000_01);
+ assert!((rotated[1] - 0.980_793_6).abs() < 0.000_01);
+ assert!(rotated[2].abs() < 0.000_01);
}
#[test]
diff --git a/crates/fparkan-assets/Cargo.toml b/crates/fparkan-assets/Cargo.toml
index 468698b..ce3cb61 100644
--- a/crates/fparkan-assets/Cargo.toml
+++ b/crates/fparkan-assets/Cargo.toml
@@ -6,19 +6,14 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
-fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
-fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
-fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
-fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" }
-fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
-fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
-fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
-fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
-fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
-
-[dev-dependencies]
-fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-material = { path = "../fparkan-material" }
+fparkan-msh = { path = "../fparkan-msh" }
+fparkan-nres = { path = "../fparkan-nres" }
+fparkan-path = { path = "../fparkan-path" }
+fparkan-mission-format = { path = "../fparkan-mission-format" }
+fparkan-prototype = { path = "../fparkan-prototype" }
+fparkan-resource = { path = "../fparkan-resource" }
+fparkan-texm = { path = "../fparkan-texm" }
+fparkan-terrain = { path = "../fparkan-terrain" }
+fparkan-terrain-format = { path = "../fparkan-terrain-format" }
+fparkan-vfs = { path = "../fparkan-vfs" }
diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs
index 708993f..8f45c8e 100644
--- a/crates/fparkan-assets/src/lib.rs
+++ b/crates/fparkan-assets/src/lib.rs
@@ -1,29 +1,14 @@
#![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
- )
-)]
//! Asset manager ports and transactional preparation models.
use fparkan_material::{
- decode_wear, resolve_material, Mat0Document, MaterialError, ResolvedMaterial, WearTable,
+ decode_mat0, decode_wear, resolve_material, MaterialFallback, ResolvedMaterial, WearTable,
MAT0_KIND, WEAR_KIND,
};
+pub use fparkan_material::{
+ sample_material_phase, Mat0Document, MaterialCoefficients, MaterialError, MaterialPhase,
+ MaterialPhaseSample,
+};
use fparkan_mission_format::{decode_tma, decode_tma_land_path, ClanBody};
pub use fparkan_mission_format::{LpString, MissionDocument, MissionError, TmaProfile};
use fparkan_msh::{decode_msh, validate_msh, ModelAsset, MshError};
@@ -35,24 +20,62 @@ use fparkan_prototype::{
PrototypeGraphFailure, PrototypeGraphNodeKind, PrototypeGraphProvenance, PrototypeGraphReport,
PrototypeGraphRequiredness,
};
-use fparkan_resource::{ResourceError, ResourceKey, ResourceRepository};
+use fparkan_resource::{CachedResourceRepository, ResourceError, ResourceKey, ResourceRepository};
pub use fparkan_terrain::{TerrainError, TerrainWorld};
use fparkan_terrain_format::{decode_build_dat, decode_land_map, decode_land_msh};
pub use fparkan_terrain_format::{BuildCategory, TerrainFormatError};
-use fparkan_texm::{decode_mip_rgba8, decode_texm, RgbaImage, TexmDocument, TexmError};
-use std::collections::{hash_map::Entry, HashMap, HashSet};
+use fparkan_texm::{decode_mip_rgba8, decode_texm, TexmDocument, TexmError};
+pub use fparkan_texm::{PageRect, RgbaImage};
+use fparkan_vfs::DirectoryVfs;
+use std::collections::{hash_map::Entry, HashMap};
use std::fmt;
use std::hash::{Hash, Hasher};
use std::marker::PhantomData;
+use std::path::{Path, PathBuf};
use std::sync::Arc;
const TEXTURES_ARCHIVE: &str = "textures.lib";
const LIGHTMAP_ARCHIVE: &str = "lightmap.lib";
-const VISUAL_DEPENDENCY_PROGRESS_INTERVAL: usize = 64;
+
+/// Material coefficients decoded from one selected MAT0 phase.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PreparedMaterialPhase {
+ /// Raw NRes MAT0 entry flags retained from the archive directory.
+ pub attr1: u32,
+ /// Native material pipeline category `(attr1 >> 2) & 0xF`.
+ pub pipeline_category: u8,
+ /// Native material feature bit `attr1 & 0x40`.
+ pub native_flag_40: bool,
+ /// Phase parameters 4..6, normalized directional light RGB coefficient.
+ pub directional_rgb: [f32; 3],
+ /// Phase parameters 0..2, normalized additive RGB coefficient.
+ pub additive_rgb: [f32; 3],
+ /// Phase parameter 3, stored as a percentage.
+ pub diffuse_alpha: f32,
+ /// Phase parameters 8..10, normalized specular RGB coefficient.
+ pub specular_rgb: [f32; 3],
+ /// Phase parameter 16, integer specular power.
+ pub power: u8,
+ /// Signed phase parameter 17 selecting a TEXM Page rectangle, or -1.
+ pub page_index: i8,
+ /// Normalized atlas transform `[offset_x, offset_y, scale_x, scale_y]`.
+ pub page_uv_transform: [f32; 4],
+}
type WearValidationCache =
HashMap<(NormalizedPath, Vec<u8>), Result<fparkan_material::WearTable, String>>;
-type TextureValidationCache = HashMap<Vec<u8>, Result<(), String>>;
+
+/// Extracts the native pipeline category from a MAT0 NRes entry's `attr1`.
+#[must_use]
+pub const fn material_pipeline_category(attr1: u32) -> u8 {
+ ((attr1 >> 2) & 0x0F) as u8
+}
+
+/// Returns whether a MAT0 NRes entry carries native feature bit `0x40`.
+#[must_use]
+pub const fn material_native_flag_40(attr1: u32) -> bool {
+ (attr1 & 0x40) != 0
+}
/// Canonical terrain archive paths derived from a mission land reference.
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -188,6 +211,589 @@ pub fn decode_nres_payload(
decode_nres(bytes, ReadProfile::Compatible)
}
+/// Loads every authored RGBA8 mip and the selected phase coefficients from a
+/// map-local standalone WEAR table.
+pub fn load_standalone_wear_material_texture_rgba8_and_phase_from_root(
+ root: &Path,
+ wear_path: &Path,
+ material_index: u16,
+) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase), String> {
+ load_standalone_wear_material_texture_rgba8_and_phase_at_index_from_root(
+ root,
+ wear_path,
+ material_index,
+ 0,
+ )
+}
+
+/// Loads an authored TEXM mip chain and MAT0 coefficients for one phase.
+/// Empty phase texture names are treated as intentionally untextured.
+pub fn load_standalone_wear_material_texture_rgba8_and_phase_at_index_from_root(
+ root: &Path,
+ wear_path: &Path,
+ material_index: u16,
+ phase_index: u16,
+) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase), String> {
+ let mut loader = StandaloneWearMaterialLoader::new(root);
+ loader.load(wear_path, material_index, phase_index)
+}
+
+/// Reusable standalone WEAR/MAT0/TEXM loader for map-local preview paths.
+///
+/// The native preview can request many rows from `Land1.wea` and `Land2.wea`.
+/// Keeping one repository and one decoded table per path avoids repeating the
+/// archive fingerprint, WEAR parse, and material lookup work for every row.
+pub struct StandaloneWearMaterialLoader {
+ repository: CachedResourceRepository,
+ wears: HashMap<PathBuf, WearTable>,
+ materials: HashMap<(PathBuf, u16), ResolvedMaterial>,
+ loaded: HashMap<(PathBuf, u16, u16), (Vec<RgbaImage>, PreparedMaterialPhase)>,
+}
+
+impl StandaloneWearMaterialLoader {
+ /// Creates a loader over an immutable install root.
+ #[must_use]
+ pub fn new(root: &Path) -> Self {
+ Self {
+ repository: CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root))),
+ wears: HashMap::new(),
+ materials: HashMap::new(),
+ loaded: HashMap::new(),
+ }
+ }
+
+ /// Loads one material row and phase from a map-local WEAR table.
+ ///
+ /// # Errors
+ ///
+ /// Returns a formatted error when the table, material, or texture cannot
+ /// be decoded.
+ pub fn load(
+ &mut self,
+ wear_path: &Path,
+ material_index: u16,
+ phase_index: u16,
+ ) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase), String> {
+ let cache_key = wear_path.to_path_buf();
+ if !self.wears.contains_key(&cache_key) {
+ let wear = decode_wear(
+ &std::fs::read(wear_path)
+ .map_err(|err| format!("{}: {err}", wear_path.display()))?,
+ )
+ .map_err(|err| err.to_string())?;
+ self.wears.insert(cache_key.clone(), wear);
+ }
+ let material = self.resolve_material(&cache_key, material_index)?;
+ let loaded_key = (cache_key.clone(), material_index, phase_index);
+ if let Some(result) = self.loaded.get(&loaded_key) {
+ return Ok(result.clone());
+ }
+ let result = load_resolved_material_texture_rgba8_and_phase(
+ &self.repository,
+ &material,
+ phase_index,
+ )?;
+ self.loaded.insert(loaded_key, result.clone());
+ Ok(result)
+ }
+
+ /// Returns the decoded MAT0 document for one map-local WEAR row.
+ ///
+ /// The document is cloned at this boundary so callers can retain a
+ /// frame-sampling binding while the loader continues to cache resources.
+ ///
+ /// # Errors
+ ///
+ /// Returns a formatted error when the WEAR table or material cannot be
+ /// resolved.
+ pub fn material_document(
+ &mut self,
+ wear_path: &Path,
+ material_index: u16,
+ ) -> Result<Mat0Document, String> {
+ let cache_key = wear_path.to_path_buf();
+ self.ensure_wear(&cache_key)?;
+ self.resolve_material(&cache_key, material_index)
+ .map(|material| material.document)
+ }
+
+ fn ensure_wear(&mut self, cache_key: &Path) -> Result<(), String> {
+ if self.wears.contains_key(cache_key) {
+ return Ok(());
+ }
+ let wear = decode_wear(
+ &std::fs::read(cache_key).map_err(|err| format!("{}: {err}", cache_key.display()))?,
+ )
+ .map_err(|err| err.to_string())?;
+ self.wears.insert(cache_key.to_path_buf(), wear);
+ Ok(())
+ }
+
+ fn resolve_material(
+ &mut self,
+ cache_key: &Path,
+ material_index: u16,
+ ) -> Result<ResolvedMaterial, String> {
+ let material_key = (cache_key.to_path_buf(), material_index);
+ if let Some(material) = self.materials.get(&material_key) {
+ return Ok(material.clone());
+ }
+ let wear = self
+ .wears
+ .get(cache_key)
+ .ok_or_else(|| "standalone WEAR cache insertion failed".to_string())?;
+ let material = resolve_material(&self.repository, wear, material_index)
+ .map_err(|err| err.to_string())?;
+ self.materials.insert(material_key, material.clone());
+ Ok(material)
+ }
+}
+
+/// Loads named map-local materials together with their complete MAT0
+/// documents. This is the runtime form used by sky/environment bindings;
+/// the older helper remains available for callers that only need phase zero.
+pub fn load_standalone_wear_named_material_textures_rgba8_and_phases_with_documents_from_root(
+ root: &Path,
+ wear_path: &Path,
+ phase_index: u16,
+) -> Result<Vec<(Vec<u8>, Vec<RgbaImage>, PreparedMaterialPhase, Mat0Document)>, String> {
+ let wear = decode_wear(
+ &std::fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?,
+ )
+ .map_err(|err| err.to_string())?;
+ let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
+ wear.entries
+ .iter()
+ .enumerate()
+ .map(|(index, entry)| {
+ let material_index =
+ u16::try_from(index).map_err(|_| "WEAR material index exceeds u16".to_string())?;
+ let material = resolve_material(&repository, &wear, material_index)
+ .map_err(|err| err.to_string())?;
+ let (mips, phase) = load_resolved_material_texture_rgba8_and_phase(
+ &repository,
+ &material,
+ phase_index,
+ )?;
+ Ok((entry.material.0.clone(), mips, phase, material.document))
+ })
+ .collect()
+}
+
+/// Loads every named MAT0 entry in a map-local WEAR table through one cached
+/// resource repository. Sky material rows use this path: their names are
+/// material.lib entries, not direct textures.lib TEXM names.
+pub fn load_standalone_wear_named_material_textures_rgba8_and_phases_from_root(
+ root: &Path,
+ wear_path: &Path,
+ phase_index: u16,
+) -> Result<Vec<(Vec<u8>, Vec<RgbaImage>, PreparedMaterialPhase)>, String> {
+ let wear = decode_wear(
+ &std::fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?,
+ )
+ .map_err(|err| err.to_string())?;
+ let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
+ wear.entries
+ .iter()
+ .enumerate()
+ .map(|(index, entry)| {
+ let material_index =
+ u16::try_from(index).map_err(|_| "WEAR material index exceeds u16".to_string())?;
+ let material = resolve_material(&repository, &wear, material_index)
+ .map_err(|err| err.to_string())?;
+ let (mips, phase) = load_resolved_material_texture_rgba8_and_phase(
+ &repository,
+ &material,
+ phase_index,
+ )?;
+ Ok((entry.material.0.clone(), mips, phase))
+ })
+ .collect()
+}
+
+fn load_resolved_material_texture_rgba8_and_phase<R: ResourceRepository>(
+ repository: &R,
+ material: &ResolvedMaterial,
+ phase_index: u16,
+) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase), String> {
+ let phase = material
+ .document
+ .phases
+ .get(usize::from(phase_index))
+ .or_else(|| material.document.phases.first())
+ .ok_or_else(|| "MAT0 declares no material phases".to_string())?;
+ let texture_bytes = phase
+ .texture_raw
+ .iter()
+ .copied()
+ .take_while(|byte| *byte != 0)
+ .collect::<Vec<_>>();
+ let document = if texture_bytes.is_empty() {
+ None
+ } else {
+ let texture = ResourceName(texture_bytes);
+ let key = ResourceKey {
+ archive: parse_path(TEXTURES_ARCHIVE).map_err(|err| err.to_string())?,
+ name: texture.clone(),
+ type_id: None,
+ };
+ let bytes = read_optional_key(repository, &key, Some("texture"))
+ .map_err(|err| err.to_string())?
+ .ok_or_else(|| format!("missing texture {texture:?}"))?;
+ Some(decode_texm(bytes).map_err(|err| err.to_string())?)
+ };
+ let page_index = i8::from_ne_bytes([phase.parameters[17]]);
+ let page_uv = match document.as_ref() {
+ Some(document) => page_uv_transform(
+ document.width(),
+ document.height(),
+ &document.page_rects(),
+ page_index,
+ )?,
+ None => [0.0, 0.0, 1.0, 1.0],
+ };
+ let phase = PreparedMaterialPhase {
+ attr1: material.attr1,
+ pipeline_category: material_pipeline_category(material.attr1),
+ native_flag_40: material_native_flag_40(material.attr1),
+ directional_rgb: [
+ f32::from(phase.parameters[4]) / 255.0,
+ f32::from(phase.parameters[5]) / 255.0,
+ f32::from(phase.parameters[6]) / 255.0,
+ ],
+ additive_rgb: [
+ f32::from(phase.parameters[0]) / 255.0,
+ f32::from(phase.parameters[1]) / 255.0,
+ f32::from(phase.parameters[2]) / 255.0,
+ ],
+ diffuse_alpha: f32::from(phase.parameters[3]) * 0.01,
+ specular_rgb: [
+ f32::from(phase.parameters[8]) / 255.0,
+ f32::from(phase.parameters[9]) / 255.0,
+ f32::from(phase.parameters[10]) / 255.0,
+ ],
+ power: phase.parameters[16],
+ page_index,
+ page_uv_transform: page_uv,
+ };
+ let mips = match document {
+ Some(document) => (0..document.mip_count())
+ .map(|level| decode_mip_rgba8(&document, u32::try_from(level).unwrap_or(u32::MAX)))
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|err| err.to_string())?,
+ None => vec![RgbaImage {
+ width: 1,
+ height: 1,
+ rgba8: vec![255, 255, 255, 255],
+ }],
+ };
+ Ok((mips, phase))
+}
+
+/// Loads a named `material.lib` MAT0 entry and its selected texture phase.
+/// This is used by FX resources whose opcode carries a direct material
+/// reference instead of a WEAR-table index.
+pub fn load_material_name_texture_rgba8_and_phase_from_root(
+ root: &Path,
+ material_name: &str,
+ phase_index: u16,
+) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase), String> {
+ load_material_name_texture_rgba8_and_phase_with_document_from_root(
+ root,
+ material_name,
+ phase_index,
+ )
+ .map(|(mips, phase, _document)| (mips, phase))
+}
+
+/// Loads a named `material.lib` entry, its selected texture phase, and the
+/// complete MAT0 document used by the runtime animation sampler.
+pub fn load_material_name_texture_rgba8_and_phase_with_document_from_root(
+ root: &Path,
+ material_name: &str,
+ phase_index: u16,
+) -> Result<(Vec<RgbaImage>, PreparedMaterialPhase, Mat0Document), String> {
+ if material_name.is_empty() {
+ return Err("material name is empty".to_string());
+ }
+ let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
+ let archive = parse_path("material.lib").map_err(|err| err.to_string())?;
+ let archive_id = repository
+ .open_archive(&archive)
+ .map_err(|err| err.to_string())?;
+ let name = ResourceName(material_name.as_bytes().to_vec());
+ let entry = repository
+ .find(archive_id, &name)
+ .map_err(|err| err.to_string())?
+ .ok_or_else(|| format!("missing material {material_name:?}"))?;
+ let info = repository
+ .entry_info(entry)
+ .map_err(|err| err.to_string())?;
+ if info.key.type_id != Some(MAT0_KIND) {
+ return Err(format!("material {material_name:?} is not a MAT0 entry"));
+ }
+ let document = decode_mat0(
+ repository
+ .read(entry)
+ .map_err(|err| err.to_string())?
+ .as_slice(),
+ info.attr2,
+ )
+ .map_err(|err| err.to_string())?;
+ let material = ResolvedMaterial {
+ name: info.key.name,
+ fallback: MaterialFallback::Exact,
+ attr1: info.attr1,
+ document,
+ };
+ let document = material.document.clone();
+ let (mips, phase) =
+ load_resolved_material_texture_rgba8_and_phase(&repository, &material, phase_index)?;
+ Ok((mips, phase, document))
+}
+
+/// Reads one named entry from a legacy resource archive through the same
+/// cached repository used by the material loaders.
+pub fn load_resource_entry_bytes_from_root(
+ root: &Path,
+ archive_name: &str,
+ entry_name: &str,
+) -> Result<Arc<[u8]>, String> {
+ if archive_name.is_empty() || entry_name.is_empty() {
+ return Err("resource archive and entry names must be non-empty".to_string());
+ }
+ let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
+ let archive = parse_path(archive_name).map_err(|err| err.to_string())?;
+ let archive_id = repository
+ .open_archive(&archive)
+ .map_err(|err| err.to_string())?;
+ let entry = repository
+ .find(archive_id, &ResourceName(entry_name.as_bytes().to_vec()))
+ .map_err(|err| err.to_string())?
+ .ok_or_else(|| format!("missing resource {archive_name}/{entry_name}"))?;
+ Ok(Arc::from(
+ repository
+ .read(entry)
+ .map_err(|err| err.to_string())?
+ .into_owned()
+ .into_boxed_slice(),
+ ))
+}
+
+/// Loads every authored mip of a named entry from the normal texture archive.
+///
+/// Environment material tables (`sky.wea`) contain direct texture entry names
+/// rather than WEAR indirections, so they use this small archive helper.
+pub fn load_texture_rgba8_mips_from_root(
+ root: &Path,
+ texture_name: &str,
+) -> Result<Vec<RgbaImage>, String> {
+ if texture_name.is_empty() {
+ return Err("texture name is empty".to_string());
+ }
+ let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
+ let archive = ResourceKey {
+ archive: parse_path(TEXTURES_ARCHIVE).map_err(|err| err.to_string())?,
+ name: ResourceName(texture_name.as_bytes().to_vec()),
+ type_id: None,
+ };
+ let archive_id = repository
+ .open_archive(&archive.archive)
+ .map_err(|err| err.to_string())?;
+ let entry = repository
+ .find(archive_id, &archive.name)
+ .map_err(|err| err.to_string())?
+ .ok_or_else(|| format!("missing texture {texture_name:?}"))?;
+ let bytes = repository.read(entry).map_err(|err| err.to_string())?;
+ let document = decode_texm(Arc::from(bytes.as_slice().to_vec().into_boxed_slice()))
+ .map_err(|err| err.to_string())?;
+ (0..document.mip_count())
+ .map(|level| decode_mip_rgba8(&document, u32::try_from(level).unwrap_or(u32::MAX)))
+ .collect::<Result<Vec<_>, _>>()
+ .map_err(|err| err.to_string())
+}
+
+/// Returns the native normalized atlas transform for a signed MAT0 page index.
+///
+/// The transform is `[offset_x, offset_y, scale_x, scale_y]` and is applied as
+/// `atlas_uv = offset + page_local_uv * scale`. A negative selector addresses
+/// the complete texture.
+pub fn page_uv_transform(
+ width: u32,
+ height: u32,
+ page_rects: &[PageRect],
+ page_index: i8,
+) -> Result<[f32; 4], String> {
+ if width == 0 || height == 0 {
+ return Err("TEXM page transform has zero texture extent".to_string());
+ }
+ if page_index < 0 {
+ return Ok([0.0, 0.0, 1.0, 1.0]);
+ }
+ let rect = page_rects
+ .get(usize::from(page_index as u8))
+ .ok_or_else(|| {
+ format!(
+ "MAT0 page selector {page_index} exceeds TEXM Page rect count {}",
+ page_rects.len()
+ )
+ })?;
+ if rect.x < 0 || rect.y < 0 || rect.w <= 0 || rect.h <= 0 {
+ return Err(format!(
+ "TEXM Page rect is invalid: x={} y={} w={} h={}",
+ rect.x, rect.y, rect.w, rect.h
+ ));
+ }
+ if i64::from(rect.x) + i64::from(rect.w) > i64::from(width)
+ || i64::from(rect.y) + i64::from(rect.h) > i64::from(height)
+ {
+ return Err(format!(
+ "TEXM Page rect exceeds base extent {width}x{height}: x={} y={} w={} h={}",
+ rect.x, rect.y, rect.w, rect.h
+ ));
+ }
+ Ok([
+ f32::from(rect.x) / width as f32,
+ f32::from(rect.y) / height as f32,
+ f32::from(rect.w) / width as f32,
+ f32::from(rect.h) / height as f32,
+ ])
+}
+
+/// Crops decoded TEXM mips to a signed MAT0 page selector.
+///
+/// A selector of `-1` means that the texture has no page selection and is
+/// returned unchanged. Non-negative selectors address the TEXM `Page` table;
+/// rectangles are scaled with floor origin and ceil end for each mip level.
+/// The returned images use page-local 0..1 UVs, matching the native atlas
+/// sampling contract.
+pub fn crop_rgba8_mips_to_page(
+ mips: Vec<RgbaImage>,
+ page_rects: &[PageRect],
+ page_index: i8,
+) -> Result<Vec<RgbaImage>, String> {
+ if page_index < 0 {
+ return Ok(mips);
+ }
+ let (base_width_u32, base_height_u32) = mips
+ .first()
+ .map(|base| (base.width, base.height))
+ .ok_or_else(|| "cannot select a page from an empty TEXM mip chain".to_string())?;
+ let rect = page_rects
+ .get(usize::from(page_index as u8))
+ .ok_or_else(|| {
+ format!(
+ "MAT0 page selector {page_index} exceeds TEXM Page rect count {}",
+ page_rects.len()
+ )
+ })?;
+ if rect.x < 0 || rect.y < 0 || rect.w <= 0 || rect.h <= 0 {
+ return Err(format!(
+ "TEXM Page rect is invalid: x={} y={} w={} h={}",
+ rect.x, rect.y, rect.w, rect.h
+ ));
+ }
+ let base_x_end = i64::from(rect.x) + i64::from(rect.w);
+ let base_y_end = i64::from(rect.y) + i64::from(rect.h);
+ if base_x_end > i64::from(base_width_u32) || base_y_end > i64::from(base_height_u32) {
+ return Err(format!(
+ "TEXM Page rect exceeds base extent {}x{}: x={} y={} w={} h={}",
+ base_width_u32, base_height_u32, rect.x, rect.y, rect.w, rect.h
+ ));
+ }
+ mips.into_iter()
+ .enumerate()
+ .map(|(level, image)| {
+ let scale_width = i64::from(image.width);
+ let scale_height = i64::from(image.height);
+ let base_width = i64::from(base_width_u32);
+ let base_height = i64::from(base_height_u32);
+ let x0 = floor_mul_div(i64::from(rect.x), scale_width, base_width);
+ let x1 = ceil_mul_div(base_x_end, scale_width, base_width);
+ let y0 = floor_mul_div(i64::from(rect.y), scale_height, base_height);
+ let y1 = ceil_mul_div(base_y_end, scale_height, base_height);
+ let x0 = u32::try_from(x0)
+ .map_err(|_| format!("TEXM Page mip {level} x origin is invalid"))?;
+ let y0 = u32::try_from(y0)
+ .map_err(|_| format!("TEXM Page mip {level} y origin is invalid"))?;
+ let x1 =
+ u32::try_from(x1).map_err(|_| format!("TEXM Page mip {level} x end is invalid"))?;
+ let y1 =
+ u32::try_from(y1).map_err(|_| format!("TEXM Page mip {level} y end is invalid"))?;
+ if x0 >= x1 || y0 >= y1 || x1 > image.width || y1 > image.height {
+ return Err(format!(
+ "TEXM Page mip {level} scaled rect is outside {}x{}: {x0},{y0}..{x1},{y1}",
+ image.width, image.height
+ ));
+ }
+ let width = x1 - x0;
+ let height = y1 - y0;
+ let row_bytes = usize::try_from(width)
+ .ok()
+ .and_then(|width| width.checked_mul(4))
+ .ok_or_else(|| format!("TEXM Page mip {level} row size overflows"))?;
+ let capacity = usize::try_from(width)
+ .ok()
+ .and_then(|width| {
+ usize::try_from(height)
+ .ok()
+ .and_then(|height| width.checked_mul(height))
+ })
+ .and_then(|pixels| pixels.checked_mul(4))
+ .ok_or_else(|| format!("TEXM Page mip {level} size overflows"))?;
+ let mut rgba8 = Vec::with_capacity(capacity);
+ let source_stride = usize::try_from(image.width)
+ .ok()
+ .and_then(|width| width.checked_mul(4))
+ .ok_or_else(|| format!("TEXM Page mip {level} source stride overflows"))?;
+ let start_x = usize::try_from(x0)
+ .ok()
+ .and_then(|x| x.checked_mul(4))
+ .ok_or_else(|| format!("TEXM Page mip {level} x offset overflows"))?;
+ for row in y0..y1 {
+ let row_start = usize::try_from(row)
+ .ok()
+ .and_then(|row| row.checked_mul(source_stride))
+ .and_then(|offset| offset.checked_add(start_x))
+ .ok_or_else(|| format!("TEXM Page mip {level} row offset overflows"))?;
+ let row_end = row_start
+ .checked_add(row_bytes)
+ .ok_or_else(|| format!("TEXM Page mip {level} row end overflows"))?;
+ let source = image
+ .rgba8
+ .get(row_start..row_end)
+ .ok_or_else(|| format!("TEXM Page mip {level} row exceeds decoded pixels"))?;
+ rgba8.extend_from_slice(source);
+ }
+ Ok(RgbaImage {
+ width,
+ height,
+ rgba8,
+ })
+ })
+ .collect()
+}
+
+fn floor_mul_div(value: i64, scale: i64, base: i64) -> i64 {
+ let product = value.saturating_mul(scale);
+ let quotient = product / base;
+ if product < 0 && product % base != 0 {
+ quotient - 1
+ } else {
+ quotient
+ }
+}
+
+fn ceil_mul_div(value: i64, scale: i64, base: i64) -> i64 {
+ let product = value.saturating_mul(scale);
+ let quotient = product / base;
+ if product > 0 && product % base != 0 {
+ quotient + 1
+ } else {
+ quotient
+ }
+}
+
/// Decodes terrain documents and builds immutable terrain state.
///
/// # Errors
@@ -259,6 +865,8 @@ pub struct PreparedVisual {
pub id: AssetId<PreparedVisual>,
/// Optional mesh resource backing the visual.
pub mesh: Option<ResourceKey>,
+ /// Source registry entry type (`EXTO`/`INTO`/etc.) when preserved.
+ pub source_type: Option<u32>,
/// Prepared model backing the visual, when geometry is present.
pub model_id: Option<AssetId<PreparedModel>>,
/// Prepared WEAR table backing the visual, when geometry is present.
@@ -283,6 +891,35 @@ pub struct PreparedVisual {
pub lightmap_count: usize,
}
+/// Component-local animation bindings loaded from an explicitly referenced
+/// CTLD resource.
+///
+/// This data is kept beside mission component instances rather than on the
+/// shared visual/model cache, because two instances of one mesh may use
+/// different control resources.
+#[derive(Clone, Debug, PartialEq)]
+pub struct PreparedComponentControl {
+ /// CTLD resource that supplied these bindings.
+ pub source: ResourceKey,
+ /// Enabled source rows in their original order, including duplicate nodes.
+ pub bindings: Vec<PreparedControlNodeBinding>,
+}
+
+/// One enabled CTLD row targeting a node in a component model.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PreparedControlNodeBinding {
+ /// Target node index in the component model.
+ pub node_index: usize,
+ /// First source animation endpoint, kept as an f32 value.
+ pub frame_a: f32,
+ /// Second source animation endpoint, kept as an f32 value.
+ pub frame_b: f32,
+ /// Initial source blend value.
+ pub initial_blend: f32,
+ /// Raw native row flags.
+ pub flags: u32,
+}
+
/// CPU-side validated model ready for a renderer upload path.
#[derive(Clone, Debug, PartialEq)]
pub struct PreparedModel {
@@ -316,6 +953,12 @@ pub struct PreparedMaterial {
pub source: ResourceKey,
/// Parsed material key retained for compatibility with older callers.
pub name: ResourceName,
+ /// Raw NRes MAT0 entry flags retained for runtime pipeline selection.
+ pub attr1: u32,
+ /// Native material pipeline category `(attr1 >> 2) & 0xF`.
+ pub pipeline_category: u8,
+ /// Native material feature bit `attr1 & 0x40`.
+ pub native_flag_40: bool,
/// Decoded MAT0 payload.
pub mat0: Mat0Document,
/// Texture requests declared by MAT0 phases.
@@ -381,6 +1024,10 @@ pub struct MissionAssets {
pub visuals: Vec<PreparedVisual>,
/// Visual ids available for each mission object index.
pub object_visuals: Vec<Vec<AssetId<PreparedVisual>>>,
+ /// Optional component-local controls aligned with each object's visual
+ /// component list. `None` means no CTLD pose override is available because
+ /// there is no explicit reference or its referenced archive/entry is absent.
+ pub object_component_controls: Vec<Vec<Option<PreparedComponentControl>>>,
}
impl MissionAssets {
@@ -494,56 +1141,6 @@ pub struct AssetPreparationReport {
pub texture_pixels: u64,
}
-/// Asset preparation checkpoint emitted while resolving visual dependencies.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum AssetPreparationPhase {
- /// Decode and validate an MSH model.
- ModelMesh,
- /// Decode the corresponding WEAR table.
- WearTable,
- /// Resolve MAT0 material documents referenced by a WEAR table.
- Materials,
- /// Decode diffuse textures and baked lightmaps.
- Textures,
-}
-
-/// Visual dependency class currently being expanded into a prototype graph.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum VisualDependencyPhase {
- /// Resolve a mesh's WEAR table.
- Wear,
- /// Resolve MAT0 material documents selected by WEAR.
- Material,
- /// Validate diffuse TEXM textures and lightmaps.
- Texture,
-}
-
-/// Validation-cache entries retained while expanding visual dependencies.
-#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
-pub struct VisualDependencyCacheCounts {
- /// Distinct WEAR archive/name keys resolved so far.
- pub wear_entries: usize,
- /// Distinct MAT0 raw names resolved so far.
- pub material_entries: usize,
- /// Distinct TEXM archive/name keys resolved so far.
- pub texture_entries: usize,
-}
-
-/// Throttled visual-dependency expansion progress.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct VisualDependencyProgress {
- /// Dependency class currently being expanded.
- pub phase: VisualDependencyPhase,
- /// Number of requests reached in this class during the current expansion.
- pub request_count: usize,
- /// Resource graph nodes materialized before this request is resolved.
- pub graph_node_count: usize,
- /// Resource graph edges materialized before this request is resolved.
- pub graph_edge_count: usize,
- /// Validation-cache entries retained before this request is resolved.
- pub cache_entries: VisualDependencyCacheCounts,
-}
-
/// Errors raised while preparing CPU-side assets.
#[derive(Debug)]
pub enum AssetError {
@@ -660,25 +1257,6 @@ impl<R: ResourceRepository> AssetManager<R> {
prepare_mission_assets_with_repository(&self.repository, root_prototype_spans, prototypes)
}
- /// Builds mission assets and reports the active dependency class.
- ///
- /// # Errors
- ///
- /// Returns [`AssetError`] if any visual dependency is missing or malformed.
- pub fn prepare_mission_assets_with_progress(
- &self,
- root_prototype_spans: &[std::ops::Range<usize>],
- prototypes: &[EffectivePrototype],
- on_phase: impl FnMut(AssetPreparationPhase),
- ) -> Result<MissionAssets, AssetError> {
- prepare_mission_assets_with_repository_with_progress(
- &self.repository,
- root_prototype_spans,
- prototypes,
- on_phase,
- )
- }
-
/// Builds mission assets together with a bounded preparation report.
///
/// # Errors
@@ -764,28 +1342,6 @@ pub fn prepare_mission_assets_with_repository<R: ResourceRepository>(
.0)
}
-/// Builds mission assets and reports the active dependency class.
-///
-/// # Errors
-///
-/// Returns [`AssetError`] if any visual dependency is missing or malformed.
-pub fn prepare_mission_assets_with_repository_with_progress<R: ResourceRepository>(
- repository: &R,
- root_prototype_spans: &[std::ops::Range<usize>],
- prototypes: &[EffectivePrototype],
- mut on_phase: impl FnMut(AssetPreparationPhase),
-) -> Result<MissionAssets, AssetError> {
- Ok(prepare_mission_assets_with_repository_internal(
- repository,
- root_prototype_spans,
- prototypes,
- AssetIdentityPolicy::default(),
- AssetPreparationLimits::default(),
- &mut on_phase,
- )?
- .0)
-}
-
/// Builds mission assets while enforcing explicit preparation limits.
///
/// # Errors
@@ -798,27 +1354,24 @@ pub fn prepare_mission_assets_profiled_with_repository<R: ResourceRepository>(
prototypes: &[EffectivePrototype],
limits: AssetPreparationLimits,
) -> Result<(MissionAssets, AssetPreparationReport), AssetError> {
- let mut ignore_phase = |_| {};
prepare_mission_assets_with_repository_internal(
repository,
root_prototype_spans,
prototypes,
AssetIdentityPolicy::default(),
limits,
- &mut ignore_phase,
)
}
// This orchestration intentionally keeps preparation, deduplication, and limit enforcement
// together so all fallible asset construction remains transactional.
-#[allow(clippy::too_many_lines)]
+
fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
repository: &R,
root_prototype_spans: &[std::ops::Range<usize>],
prototypes: &[EffectivePrototype],
identity_policy: AssetIdentityPolicy,
limits: AssetPreparationLimits,
- on_phase: &mut dyn FnMut(AssetPreparationPhase),
) -> Result<(MissionAssets, AssetPreparationReport), AssetError> {
if prototypes.is_empty() {
return Ok((MissionAssets::default(), AssetPreparationReport::default()));
@@ -858,7 +1411,6 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
proto,
identity_policy,
&mut preparation_cache,
- on_phase,
)?;
if bundle.visual.id != visual_id {
// Defensive check. stable IDs are deterministic for the same inputs.
@@ -912,7 +1464,38 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
prototype_visual_ids.push(visual_id);
}
+ let mut prototype_component_controls = Vec::with_capacity(prototypes.len());
+ for (prototype_index, prototype) in prototypes.iter().enumerate() {
+ let Some(source) = prototype.control_resource.clone() else {
+ prototype_component_controls.push(None);
+ continue;
+ };
+ let PrototypeGeometry::Mesh(mesh_key) = &prototype.geometry else {
+ prototype_component_controls.push(None);
+ continue;
+ };
+ let model = models
+ .iter()
+ .find(|model| model.source == *mesh_key)
+ .ok_or_else(|| {
+ AssetError::InvalidPrototype(format!(
+ "prototype {} references control resource {}:{} but its prepared model is missing",
+ String::from_utf8_lossy(&prototype.key.0.0),
+ source.archive.as_str(),
+ String::from_utf8_lossy(&source.name.0),
+ ))
+ })?;
+ let control = prepare_component_control_from_resource(
+ repository,
+ source,
+ model.validated.node_count,
+ prototype_index,
+ )?;
+ prototype_component_controls.push(control);
+ }
+
let mut object_visuals = Vec::with_capacity(root_prototype_spans.len());
+ let mut object_component_controls = Vec::with_capacity(root_prototype_spans.len());
for (root_index, span) in root_prototype_spans.iter().enumerate() {
if span.start > span.end || span.end > prototype_visual_ids.len() {
return Err(AssetError::InvalidPrototype(format!(
@@ -920,14 +1503,17 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
)));
}
let mut ids = Vec::new();
- let mut dedup = HashSet::new();
+ let mut controls = Vec::new();
for index in span.clone() {
let visual_id = prototype_visual_ids[index];
- if dedup.insert(visual_id) {
- ids.push(visual_id);
- }
+ // A unit DAT is an instance tree. Reusing one prepared visual
+ // must not collapse two component instances (for example the two
+ // identical cannons on w_m_wlk2).
+ ids.push(visual_id);
+ controls.push(prototype_component_controls[index].clone());
}
object_visuals.push(ids);
+ object_component_controls.push(controls);
}
let assets = MissionAssets {
@@ -937,6 +1523,7 @@ fn prepare_mission_assets_with_repository_internal<R: ResourceRepository>(
textures,
visuals,
object_visuals,
+ object_component_controls,
};
let report = AssetPreparationReport {
model_count: assets.models.len(),
@@ -959,9 +1546,11 @@ enum PreparedVisualSignature {
archive: Vec<u8>,
name: Vec<u8>,
type_id: Option<u32>,
+ source_type: Option<u32>,
dependency_count: usize,
},
NonGeometric {
+ source_type: Option<u32>,
dependency_count: usize,
},
}
@@ -1012,6 +1601,9 @@ struct PreparedWearSignature {
#[derive(Clone, Debug, Eq, PartialEq)]
struct PreparedMaterialSignature {
source: ResourceSignature,
+ attr1: u32,
+ pipeline_category: u8,
+ native_flag_40: bool,
texture_requests: Vec<Vec<u8>>,
lightmap_requests: Vec<Vec<u8>>,
}
@@ -1031,9 +1623,11 @@ fn prepared_visual_signature(proto: &EffectivePrototype) -> PreparedVisualSignat
archive: key.archive.identity_bytes().to_vec(),
name: key.name.0.clone(),
type_id: key.type_id,
+ source_type: proto.source_type,
dependency_count: proto.dependencies.len(),
},
PrototypeGeometry::NonGeometric => PreparedVisualSignature::NonGeometric {
+ source_type: proto.source_type,
dependency_count: proto.dependencies.len(),
},
}
@@ -1067,6 +1661,9 @@ fn prepared_wear_signature(wear: &PreparedWear) -> PreparedWearSignature {
fn prepared_material_signature(material: &PreparedMaterial) -> PreparedMaterialSignature {
PreparedMaterialSignature {
source: resource_signature(&material.source),
+ attr1: material.attr1,
+ pipeline_category: material.pipeline_category,
+ native_flag_40: material.native_flag_40,
texture_requests: material
.texture_requests
.iter()
@@ -1173,73 +1770,17 @@ fn enforce_asset_preparation_limits(
/// the path policy, which indicates a programming error in this module.
// The graph expansion is deliberately a single transactional traversal so every generated
// edge receives the correct parent provenance and node/edge sequence.
-#[allow(clippy::too_many_lines)]
-pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
- repository: &R,
- report: &mut PrototypeGraphReport,
- graph: &mut PrototypeGraph,
- prototypes: &[EffectivePrototype],
-) {
- extend_graph_report_with_visual_dependencies_and_progress(
- repository,
- report,
- graph,
- prototypes,
- |_| {},
- );
-}
-
-/// Extends a prototype dependency report while reporting each visual dependency class.
-///
-/// # Panics
-///
-/// Panics only if the hard-coded, host-compatible `material.lib` path stops
-/// satisfying the path policy, which indicates a programming error in this module.
-#[allow(clippy::too_many_lines)]
-pub fn extend_graph_report_with_visual_dependencies_and_progress<R: ResourceRepository>(
- repository: &R,
- report: &mut PrototypeGraphReport,
- graph: &mut PrototypeGraph,
- prototypes: &[EffectivePrototype],
- mut on_phase: impl FnMut(VisualDependencyPhase),
-) {
- let mut last_phase = None;
- extend_graph_report_with_visual_dependencies_with_progress(
- repository,
- report,
- graph,
- prototypes,
- |progress| {
- if last_phase != Some(progress.phase) {
- on_phase(progress.phase);
- last_phase = Some(progress.phase);
- }
- },
- );
-}
-/// Extends a prototype dependency report with throttled request progress.
-///
-/// The callback receives each dependency-class entrance, its first request,
-/// and every subsequent multiple of 64 requests. Graph traversal, edge order
-/// and validation remain unchanged.
-///
-/// # Panics
-///
-/// Panics only if the hard-coded, host-compatible `material.lib` path stops
-/// satisfying the path policy, which indicates a programming error in this module.
-#[allow(clippy::too_many_lines)]
-pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRepository>(
+pub fn extend_graph_report_with_visual_dependencies<R: ResourceRepository>(
repository: &R,
report: &mut PrototypeGraphReport,
graph: &mut PrototypeGraph,
prototypes: &[EffectivePrototype],
- mut on_progress: impl FnMut(VisualDependencyProgress),
) {
if graph.visual_dependencies_expanded {
return;
}
- #[allow(clippy::expect_used)]
+
let material_archive = parse_path("material.lib")
.expect("static material archive path must satisfy host-compatible normalization");
let mut next_node = graph
@@ -1258,9 +1799,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
let mut lightmap_texture_validation = HashMap::new();
let mut material_validation = HashMap::new();
let mut wear_validation = HashMap::new();
- let mut wear_requests = 0usize;
- let mut material_requests = 0usize;
- let mut texture_requests = 0usize;
// Capture all base-graph anchors before this traversal appends visual
// resources and edges. Looking them up again from the growing graph would
// make each later prototype scan an ever larger collection.
@@ -1288,22 +1826,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
let unit_component_index =
mesh_parent_edge.and_then(|edge| unit_component_index_for_edge(graph, edge));
- wear_requests = wear_requests.saturating_add(1);
- report_visual_dependency_progress(
- &mut on_progress,
- VisualDependencyProgress {
- phase: VisualDependencyPhase::Wear,
- request_count: wear_requests,
- graph_node_count: graph.nodes.len(),
- graph_edge_count: graph.edges.len(),
- cache_entries: visual_dependency_cache_counts(
- &wear_validation,
- &material_validation,
- &diffuse_texture_validation,
- &lightmap_texture_validation,
- ),
- },
- );
match resolve_wear_table_cached(repository, mesh, &mut wear_validation) {
Ok(table) => {
report.wear_resolved_count += 1;
@@ -1343,35 +1865,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
&mut next_edge,
);
report.material_slot_count += table.entries.len();
- on_progress(VisualDependencyProgress {
- phase: VisualDependencyPhase::Material,
- request_count: material_requests,
- graph_node_count: graph.nodes.len(),
- graph_edge_count: graph.edges.len(),
- cache_entries: visual_dependency_cache_counts(
- &wear_validation,
- &material_validation,
- &diffuse_texture_validation,
- &lightmap_texture_validation,
- ),
- });
for (material_index, _entry) in table.entries.iter().enumerate() {
- material_requests = material_requests.saturating_add(1);
- report_visual_dependency_progress(
- &mut on_progress,
- VisualDependencyProgress {
- phase: VisualDependencyPhase::Material,
- request_count: material_requests,
- graph_node_count: graph.nodes.len(),
- graph_edge_count: graph.edges.len(),
- cache_entries: visual_dependency_cache_counts(
- &wear_validation,
- &material_validation,
- &diffuse_texture_validation,
- &lightmap_texture_validation,
- ),
- },
- );
let Ok(material_index) = u16::try_from(material_index) else {
push_visual_failure(
report,
@@ -1418,22 +1912,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
&mut next_edge,
);
for texture in material.document.texture_requests() {
- texture_requests = texture_requests.saturating_add(1);
- report_visual_dependency_progress(
- &mut on_progress,
- VisualDependencyProgress {
- phase: VisualDependencyPhase::Texture,
- request_count: texture_requests,
- graph_node_count: graph.nodes.len(),
- graph_edge_count: graph.edges.len(),
- cache_entries: visual_dependency_cache_counts(
- &wear_validation,
- &material_validation,
- &diffuse_texture_validation,
- &lightmap_texture_validation,
- ),
- },
- );
report.texture_request_count += 1;
match resolve_texm_validation_cached(
repository,
@@ -1495,22 +1973,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
}
}
for lightmap in &table.lightmaps {
- texture_requests = texture_requests.saturating_add(1);
- report_visual_dependency_progress(
- &mut on_progress,
- VisualDependencyProgress {
- phase: VisualDependencyPhase::Texture,
- request_count: texture_requests,
- graph_node_count: graph.nodes.len(),
- graph_edge_count: graph.edges.len(),
- cache_entries: visual_dependency_cache_counts(
- &wear_validation,
- &material_validation,
- &diffuse_texture_validation,
- &lightmap_texture_validation,
- ),
- },
- );
report.lightmap_request_count += 1;
match resolve_texm_validation_cached(
repository,
@@ -1574,34 +2036,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress<R: ResourceRep
graph.visual_dependencies_expanded = true;
}
-fn report_visual_dependency_progress(
- on_progress: &mut impl FnMut(VisualDependencyProgress),
- progress: VisualDependencyProgress,
-) {
- if progress.request_count == 1
- || progress
- .request_count
- .is_multiple_of(VISUAL_DEPENDENCY_PROGRESS_INTERVAL)
- {
- on_progress(progress);
- }
-}
-
-fn visual_dependency_cache_counts(
- wear_validation: &WearValidationCache,
- material_validation: &HashMap<Vec<u8>, Result<ResolvedMaterial, String>>,
- diffuse_texture_validation: &TextureValidationCache,
- lightmap_texture_validation: &TextureValidationCache,
-) -> VisualDependencyCacheCounts {
- VisualDependencyCacheCounts {
- wear_entries: wear_validation.len(),
- material_entries: material_validation.len(),
- texture_entries: diffuse_texture_validation
- .len()
- .saturating_add(lightmap_texture_validation.len()),
- }
-}
-
fn push_graph_resource_node(
graph: &mut PrototypeGraph,
kind: PrototypeGraphNodeKind,
@@ -1708,6 +2142,7 @@ pub fn prepare_visual(proto: &EffectivePrototype) -> Result<PreparedVisual, Asse
Ok(PreparedVisual {
id: AssetId::new(id),
mesh,
+ source_type: proto.source_type,
model_id: None,
wear_id: None,
model_nodes: 0,
@@ -1738,7 +2173,6 @@ pub fn prepare_visual_with_repository<R: ResourceRepository>(
proto,
AssetIdentityPolicy::default(),
&mut preparation_cache,
- &mut |_| {},
)?
.visual)
}
@@ -1761,13 +2195,12 @@ struct PreparationCache {
}
// Keeps the complete model -> WEAR -> material -> texture transaction in one fallible boundary.
-#[allow(clippy::too_many_lines)]
+
fn prepare_visual_with_repository_internal<R: ResourceRepository>(
repository: &R,
proto: &EffectivePrototype,
identity_policy: AssetIdentityPolicy,
preparation_cache: &mut PreparationCache,
- on_phase: &mut dyn FnMut(AssetPreparationPhase),
) -> Result<PreparedVisualBundle, AssetError> {
let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else {
return Ok(PreparedVisualBundle {
@@ -1779,7 +2212,6 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
});
};
- on_phase(AssetPreparationPhase::ModelMesh);
let model = prepare_model_cached(repository, mesh_key, preparation_cache)?;
let model_id = AssetId::new((identity_policy.model)(proto));
let prepared_model = PreparedModel {
@@ -1795,7 +2227,6 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
name: wear_name,
type_id: Some(WEAR_KIND),
};
- on_phase(AssetPreparationPhase::WearTable);
let wear = prepare_wear_cached(repository, &wear_key, preparation_cache)?;
let wear_id = AssetId::new((identity_policy.wear)(proto));
let prepared_wear = PreparedWear {
@@ -1817,7 +2248,6 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
.iter()
.map(|lightmap| lightmap.lightmap.clone())
.collect();
- on_phase(AssetPreparationPhase::Materials);
for material_index in 0..wear.entries.len() {
let material_index = u16::try_from(material_index).map_err(|_| {
AssetError::InvalidPrototype("material index does not fit archive format".to_string())
@@ -1841,13 +2271,15 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
id: material_id,
source: material_key,
name: material.name.clone(),
+ attr1: material.attr1,
+ pipeline_category: material_pipeline_category(material.attr1),
+ native_flag_40: material_native_flag_40(material.attr1),
mat0: material.document.clone(),
texture_requests: texture_requests.clone(),
lightmap_requests: lightmap_requests.clone(),
});
for texture in texture_requests {
- on_phase(AssetPreparationPhase::Textures);
let prepared_texture = prepare_texture_cached(
repository,
&texture,
@@ -1862,7 +2294,6 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
}
for lightmap in &wear.lightmaps {
- on_phase(AssetPreparationPhase::Textures);
let prepared_lightmap = prepare_texture_cached(
repository,
&lightmap.lightmap,
@@ -1879,6 +2310,7 @@ fn prepare_visual_with_repository_internal<R: ResourceRepository>(
visual: PreparedVisual {
id: AssetId::new((identity_policy.visual)(proto)),
mesh: Some(mesh_key.clone()),
+ source_type: proto.source_type,
model_id: Some(model_id),
wear_id: Some(wear_id),
model_nodes: model.node_count,
@@ -2350,6 +2782,123 @@ fn read_optional_key<R: ResourceRepository>(
Ok(Some(Arc::from(bytes.into_owned())))
}
+fn prepare_component_control_from_resource<R: ResourceRepository>(
+ repository: &R,
+ source: ResourceKey,
+ model_node_count: usize,
+ prototype_index: usize,
+) -> Result<Option<PreparedComponentControl>, AssetError> {
+ let Some(payload) = read_optional_key(repository, &source, Some("component control"))? else {
+ return Ok(None);
+ };
+ decode_component_control_payload(&payload, source, model_node_count, prototype_index).map(Some)
+}
+
+fn decode_component_control_payload(
+ payload: &[u8],
+ source: ResourceKey,
+ model_node_count: usize,
+ prototype_index: usize,
+) -> Result<PreparedComponentControl, AssetError> {
+ const HEADER_BYTES: usize = 5 * 4;
+ const FIXED_SECTION_BYTES: usize = 108;
+ const SECTION_FIXED_BYTES: usize = 156;
+ const SECTION_NODE_BYTES: usize = 16;
+ const ROW_BYTES: usize = 36;
+
+ let invalid = |message: &str| {
+ AssetError::InvalidPrototype(format!(
+ "prototype {prototype_index} control {}:{}: {message}",
+ source.archive.as_str(),
+ String::from_utf8_lossy(&source.name.0),
+ ))
+ };
+ if payload.len() < HEADER_BYTES {
+ return Err(invalid("payload is shorter than its five-word header"));
+ }
+ let mut header = [0_u32; 5];
+ for (index, word) in header.iter_mut().enumerate() {
+ let offset = index * 4;
+ let bytes: [u8; 4] = payload[offset..offset + 4]
+ .try_into()
+ .expect("header slice is exactly four bytes");
+ *word = u32::from_le_bytes(bytes);
+ }
+
+ let section_count =
+ usize::try_from(header[0]).map_err(|_| invalid("section count overflows usize"))?;
+ let per_section_nodes =
+ usize::try_from(header[1]).map_err(|_| invalid("section node count overflows usize"))?;
+ let row_count =
+ usize::try_from(header[2]).map_err(|_| invalid("binding count overflows usize"))?;
+ let per_section_node_bytes = SECTION_NODE_BYTES
+ .checked_mul(per_section_nodes)
+ .ok_or_else(|| invalid("section node bytes overflow"))?;
+ let section_bytes = section_count
+ .checked_mul(
+ SECTION_FIXED_BYTES
+ .checked_add(per_section_node_bytes)
+ .ok_or_else(|| invalid("section size overflows"))?,
+ )
+ .ok_or_else(|| invalid("section table bytes overflow"))?;
+ let matrix_bytes = section_count
+ .checked_mul(section_count)
+ .and_then(|count| count.checked_mul(4))
+ .ok_or_else(|| invalid("section matrix bytes overflow"))?;
+ let row_start = HEADER_BYTES
+ .checked_add(FIXED_SECTION_BYTES)
+ .and_then(|offset| offset.checked_add(section_bytes))
+ .and_then(|offset| offset.checked_add(matrix_bytes))
+ .ok_or_else(|| invalid("binding table offset overflows"))?;
+ let row_bytes = row_count
+ .checked_mul(ROW_BYTES)
+ .ok_or_else(|| invalid("binding table bytes overflow"))?;
+ let row_end = row_start
+ .checked_add(row_bytes)
+ .ok_or_else(|| invalid("binding table end overflows"))?;
+ if row_end > payload.len() {
+ return Err(invalid("complete binding table extends past the payload"));
+ }
+
+ let mut bindings = Vec::with_capacity(row_count);
+ for row_index in 0..row_count {
+ let row_start = row_start + row_index * ROW_BYTES;
+ let row = &payload[row_start..row_start + ROW_BYTES];
+ let flags = u32::from_le_bytes(row[0x20..0x24].try_into().expect("row flags"));
+ if flags & 4 != 0 {
+ continue;
+ }
+ let node_index = u32::from_le_bytes(row[0..4].try_into().expect("row node index")) as usize;
+ if node_index >= model_node_count {
+ return Err(invalid("enabled binding node index is outside the model"));
+ }
+ let frame_a = f32::from_bits(u32::from_le_bytes(row[4..8].try_into().expect("frame A")));
+ let frame_b = f32::from_bits(u32::from_le_bytes(row[8..12].try_into().expect("frame B")));
+ let initial_blend = f32::from_bits(u32::from_le_bytes(
+ row[12..16].try_into().expect("initial blend"),
+ ));
+ if !frame_a.is_finite()
+ || !frame_b.is_finite()
+ || !initial_blend.is_finite()
+ || frame_a < 0.0
+ || frame_b < 0.0
+ {
+ return Err(invalid(
+ "enabled binding contains a non-finite or negative value",
+ ));
+ }
+ bindings.push(PreparedControlNodeBinding {
+ node_index,
+ frame_a,
+ frame_b,
+ initial_blend,
+ flags,
+ });
+ }
+
+ Ok(PreparedComponentControl { source, bindings })
+}
+
fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, AssetError> {
let dot = key
.name
@@ -2367,6 +2916,7 @@ fn sibling_name(key: &ResourceKey, extension: &str) -> Result<ResourceName, Asse
fn stable_visual_id(proto: &EffectivePrototype) -> u64 {
let mut hasher = StableHasher::default();
+ proto.source_type.hash(&mut hasher);
match &proto.geometry {
PrototypeGeometry::Mesh(key) => {
1_u8.hash(&mut hasher);
@@ -2451,63 +3001,102 @@ mod tests {
use std::path::PathBuf;
#[test]
- fn visual_dependency_progress_is_throttled_at_fixed_request_intervals() {
- let mut progress = Vec::new();
- for request_count in 1..=129 {
- report_visual_dependency_progress(
- &mut |event| progress.push(event),
- VisualDependencyProgress {
- phase: VisualDependencyPhase::Texture,
- request_count,
- graph_node_count: 37,
- graph_edge_count: 41,
- cache_entries: VisualDependencyCacheCounts {
- wear_entries: 3,
- material_entries: 5,
- texture_entries: 7,
- },
- },
- );
+ fn component_control_rows_preserve_f32_values_and_source_order() {
+ let source = ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(b"parts.ctl"),
+ type_id: None,
+ };
+ let payload = component_control_payload(&[
+ (99, f32::NAN, -1.0, -1.0, 4),
+ (1, 1.25, 2.5, 0.0, 0),
+ (1, 2.0, 0.0, 0.75, 1),
+ ]);
+
+ let control =
+ decode_component_control_payload(&payload, source, 2, 7).expect("valid enabled rows");
+
+ assert_eq!(control.bindings.len(), 2);
+ assert_eq!(control.bindings[0].node_index, 1);
+ assert_eq!(control.bindings[0].frame_a, 1.25);
+ assert_eq!(control.bindings[0].frame_b, 2.5);
+ assert_eq!(control.bindings[0].initial_blend, 0.0);
+ assert_eq!(control.bindings[1].node_index, 1);
+ assert_eq!(control.bindings[1].frame_a, 2.0);
+ assert_eq!(control.bindings[1].flags, 1);
+ }
+
+ #[test]
+ fn component_control_rejects_bad_bounds_but_keeps_sampler_inputs_raw() {
+ let source = ResourceKey {
+ archive: parse_path("controls.rlb").expect("archive"),
+ name: resource_name(b"parts.ctl"),
+ type_id: None,
+ };
+ let mut overflow = vec![0; 20];
+ overflow[0..4].copy_from_slice(&u32::MAX.to_le_bytes());
+ assert!(decode_component_control_payload(&overflow, source.clone(), 2, 0).is_err());
+
+ let out_of_range_node = component_control_payload(&[(2, 0.0, 1.0, 0.0, 0)]);
+ assert!(
+ decode_component_control_payload(&out_of_range_node, source.clone(), 2, 0,).is_err()
+ );
+
+ let fallback_input = component_control_payload(&[(1, 0.0, 4.0, -0.5, 0)]);
+ let control = decode_component_control_payload(&fallback_input, source.clone(), 2, 0)
+ .expect("sampler decides out-of-map fallback and blend normalization");
+ assert_eq!(control.bindings[0].frame_b, 4.0);
+ assert_eq!(control.bindings[0].initial_blend, -0.5);
+
+ let non_finite = component_control_payload(&[(1, f32::NAN, 1.0, 0.0, 0)]);
+ assert!(decode_component_control_payload(&non_finite, source, 2, 0).is_err());
+ }
+
+ fn component_control_payload(rows: &[(u32, f32, f32, f32, u32)]) -> Vec<u8> {
+ const ROW_START: usize = 5 * 4 + 108;
+ const ROW_BYTES: usize = 36;
+ let mut payload = vec![0; ROW_START + rows.len() * ROW_BYTES];
+ payload[8..12].copy_from_slice(&u32::try_from(rows.len()).expect("count").to_le_bytes());
+ for (index, (node, frame_a, frame_b, blend, flags)) in rows.iter().enumerate() {
+ let row = ROW_START + index * ROW_BYTES;
+ payload[row..row + 4].copy_from_slice(&node.to_le_bytes());
+ payload[row + 4..row + 8].copy_from_slice(&frame_a.to_bits().to_le_bytes());
+ payload[row + 8..row + 12].copy_from_slice(&frame_b.to_bits().to_le_bytes());
+ payload[row + 12..row + 16].copy_from_slice(&blend.to_bits().to_le_bytes());
+ payload[row + 0x20..row + 0x24].copy_from_slice(&flags.to_le_bytes());
}
+ payload
+ }
+ #[test]
+ fn page_uv_transform_matches_normalized_native_atlas_record() {
assert_eq!(
- progress,
- vec![
- VisualDependencyProgress {
- phase: VisualDependencyPhase::Texture,
- request_count: 1,
- graph_node_count: 37,
- graph_edge_count: 41,
- cache_entries: VisualDependencyCacheCounts {
- wear_entries: 3,
- material_entries: 5,
- texture_entries: 7,
- },
- },
- VisualDependencyProgress {
- phase: VisualDependencyPhase::Texture,
- request_count: 64,
- graph_node_count: 37,
- graph_edge_count: 41,
- cache_entries: VisualDependencyCacheCounts {
- wear_entries: 3,
- material_entries: 5,
- texture_entries: 7,
- },
- },
- VisualDependencyProgress {
- phase: VisualDependencyPhase::Texture,
- request_count: 128,
- graph_node_count: 37,
- graph_edge_count: 41,
- cache_entries: VisualDependencyCacheCounts {
- wear_entries: 3,
- material_entries: 5,
- texture_entries: 7,
- },
- },
- ]
+ page_uv_transform(
+ 8,
+ 4,
+ &[PageRect {
+ x: 2,
+ w: 4,
+ y: 1,
+ h: 2,
+ }],
+ 0,
+ ),
+ Ok([0.25, 0.25, 0.5, 0.5])
);
+ assert_eq!(page_uv_transform(8, 4, &[], -1), Ok([0.0, 0.0, 1.0, 1.0]));
+ }
+
+ #[test]
+ fn page_uv_transform_rejects_missing_or_out_of_bounds_page() {
+ let rects = [PageRect {
+ x: 7,
+ w: 2,
+ y: 0,
+ h: 1,
+ }];
+ assert!(page_uv_transform(8, 4, &[], 0).is_err());
+ assert!(page_uv_transform(8, 4, &rects, 0).is_err());
}
#[test]
@@ -2592,7 +3181,9 @@ mod tests {
type_id: Some(0x4853_454D),
}),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
};
let second = EffectivePrototype {
key: fparkan_prototype::PrototypeKey(resource_name(b"mesh")),
@@ -2603,7 +3194,9 @@ mod tests {
type_id: Some(0x4853_454D),
}),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
};
assert_ne!(stable_visual_id(&first), stable_visual_id(&second));
@@ -2665,7 +3258,9 @@ mod tests {
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: vec![mesh_key.clone()],
+ control_resource: None,
};
let mut graph = prototype_graph_for_mesh(&prototype);
let mut report = PrototypeGraphReport {
@@ -2778,7 +3373,9 @@ mod tests {
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
geometry: PrototypeGeometry::Mesh(mesh_key),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
};
let mut graph = prototype_graph_for_mesh(&prototype);
graph
@@ -2929,7 +3526,9 @@ mod tests {
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
geometry: PrototypeGeometry::Mesh(mesh_key),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
};
let mut graph = prototype_graph_for_mesh(&prototype);
let mut report = PrototypeGraphReport {
@@ -2962,16 +3561,20 @@ mod tests {
}
#[test]
- fn prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads() {
+ fn prepare_mission_assets_keep_instance_controls_beside_deduplicated_visuals() {
let mesh_key = ResourceKey {
archive: parse_path("static.rlb").expect("archive"),
name: resource_name(b"tree.msh"),
type_id: Some(0x4853_454D),
};
- let msh = minimal_model_archive();
+ let mut node = vec![0; 38];
+ node[8..].fill(0xff);
+ let msh = minimal_model_archive_with_nodes(&node);
let mat0 = mat0_with_texture(b"TEX_A");
let texm = texm_payload();
let lightmap_texm = texm_payload();
+ let first_control = component_control_payload(&[(0, 1.25, 2.5, 0.25, 0)]);
+ let second_control = component_control_payload(&[(0, 2.5, 3.75, 0.75, 0)]);
let repo = repository_with_archives_meta(&[
(
"static.rlb",
@@ -3017,27 +3620,122 @@ mod tests {
attr2: 0,
}],
),
+ (
+ "controls.rlb",
+ &[
+ TestNresEntry {
+ name: b"first.ctl",
+ payload: &first_control,
+ type_id: 0,
+ attr2: 0,
+ },
+ TestNresEntry {
+ name: b"second.ctl",
+ payload: &second_control,
+ type_id: 0,
+ attr2: 0,
+ },
+ ],
+ ),
]);
- let prototype = EffectivePrototype {
- key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
- geometry: PrototypeGeometry::Mesh(mesh_key),
- source: fparkan_prototype::PrototypeSource::DirectArchive,
- dependencies: Vec::new(),
- };
+ let prototypes = [
+ EffectivePrototype {
+ key: fparkan_prototype::PrototypeKey(resource_name(b"tree-a")),
+ geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
+ source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
+ dependencies: vec![mesh_key.clone()],
+ control_resource: Some(ResourceKey {
+ archive: parse_path("controls.rlb").expect("control archive"),
+ name: resource_name(b"first.ctl"),
+ type_id: None,
+ }),
+ },
+ EffectivePrototype {
+ key: fparkan_prototype::PrototypeKey(resource_name(b"tree-b")),
+ geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
+ source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
+ dependencies: vec![mesh_key.clone()],
+ control_resource: Some(ResourceKey {
+ archive: parse_path("controls.rlb").expect("control archive"),
+ name: resource_name(b"second.ctl"),
+ type_id: None,
+ }),
+ },
+ EffectivePrototype {
+ key: fparkan_prototype::PrototypeKey(resource_name(b"tree-c")),
+ geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
+ source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
+ dependencies: vec![mesh_key],
+ control_resource: Some(ResourceKey {
+ archive: parse_path("controls.rlb").expect("control archive"),
+ name: resource_name(b"missing.ctl"),
+ type_id: None,
+ }),
+ },
+ ];
- let assets = prepare_mission_assets_with_repository(
- &repo,
- std::slice::from_ref(&(0..1)),
- std::slice::from_ref(&prototype),
- )
- .expect("prepared mission assets");
+ let assets = prepare_mission_assets_with_repository(&repo, &[0..3], &prototypes)
+ .expect("prepared mission assets");
assert_eq!(assets.models.len(), 1);
assert_eq!(assets.wears.len(), 1);
assert_eq!(assets.materials.len(), 1);
assert_eq!(assets.textures.len(), 2);
assert_eq!(assets.visuals.len(), 1);
- assert_eq!(assets.object_visuals, vec![vec![assets.visuals[0].id]]);
+ assert_eq!(
+ assets.object_visuals,
+ vec![vec![
+ assets.visuals[0].id,
+ assets.visuals[0].id,
+ assets.visuals[0].id
+ ]]
+ );
+ assert_eq!(assets.object_component_controls.len(), 1);
+ let controls = &assets.object_component_controls[0];
+ assert_eq!(controls.len(), 3);
+ assert_eq!(
+ controls[0]
+ .as_ref()
+ .expect("first component control")
+ .source
+ .name,
+ resource_name(b"first.ctl")
+ );
+ assert_eq!(
+ controls[1]
+ .as_ref()
+ .expect("second component control")
+ .source
+ .name,
+ resource_name(b"second.ctl")
+ );
+ assert!(
+ controls[2].is_none(),
+ "missing explicit CTL means no override"
+ );
+ assert_eq!(
+ controls[0].as_ref().expect("first control").bindings,
+ vec![PreparedControlNodeBinding {
+ node_index: 0,
+ frame_a: 1.25,
+ frame_b: 2.5,
+ initial_blend: 0.25,
+ flags: 0,
+ }]
+ );
+ assert_eq!(
+ controls[1].as_ref().expect("second control").bindings,
+ vec![PreparedControlNodeBinding {
+ node_index: 0,
+ frame_a: 2.5,
+ frame_b: 3.75,
+ initial_blend: 0.75,
+ flags: 0,
+ }]
+ );
let visual = &assets.visuals[0];
assert_eq!(visual.model_id, Some(assets.models[0].id));
@@ -3048,6 +3746,29 @@ mod tests {
}
#[test]
+ fn non_geometric_explicit_control_does_not_break_visual_preparation() {
+ let repository = repository_with_archives(&[]);
+ let prototype = EffectivePrototype {
+ key: fparkan_prototype::PrototypeKey(resource_name(b"marker")),
+ geometry: PrototypeGeometry::NonGeometric,
+ source: fparkan_prototype::PrototypeSource::ObjectsRegistry,
+ source_type: None,
+ dependencies: Vec::new(),
+ control_resource: Some(ResourceKey {
+ archive: parse_path("controls.rlb").expect("control archive"),
+ name: resource_name(b"marker.ctl"),
+ type_id: None,
+ }),
+ };
+
+ let assets = prepare_mission_assets_with_repository(&repository, &[0..1], &[prototype])
+ .expect("non-geometric visual remains valid");
+
+ assert_eq!(assets.object_visuals.len(), 1);
+ assert_eq!(assets.object_component_controls, vec![vec![None]]);
+ }
+
+ #[test]
fn texture_preparation_cache_reuses_exact_request_within_one_mission() {
let texm = texm_payload();
let repo = repository_with_archives_meta(&[(
@@ -3230,7 +3951,9 @@ mod tests {
key: fparkan_prototype::PrototypeKey(resource_name(b"tree")),
geometry: PrototypeGeometry::Mesh(mesh_key.clone()),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: vec![mesh_key.clone()],
+ control_resource: None,
};
let mut graph = prototype_graph_for_mesh(&prototype);
let mut report = PrototypeGraphReport {
@@ -3315,7 +4038,6 @@ mod tests {
&prototypes,
policy,
AssetPreparationLimits::default(),
- &mut |_| {},
)
.expect_err("collision should fail");
@@ -3438,7 +4160,9 @@ mod tests {
type_id: Some(0x4853_454D),
}),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
},
EffectivePrototype {
key: fparkan_prototype::PrototypeKey(resource_name(b"tree_b")),
@@ -3448,7 +4172,9 @@ mod tests {
type_id: Some(0x4853_454D),
}),
source: fparkan_prototype::PrototypeSource::DirectArchive,
+ source_type: None,
dependencies: Vec::new(),
+ control_resource: None,
},
];
(repo, prototypes)
@@ -3510,6 +4236,10 @@ mod tests {
}
fn minimal_model_archive() -> Vec<u8> {
+ minimal_model_archive_with_nodes(&[])
+ }
+
+ fn minimal_model_archive_with_nodes(nodes: &[u8]) -> Vec<u8> {
struct MshEntry<'a> {
type_id: u32,
attr3: u32,
@@ -3522,7 +4252,7 @@ mod tests {
type_id: 1,
attr3: 38,
name: b"Res1",
- payload: &[],
+ payload: nodes,
},
MshEntry {
type_id: 2,
diff --git a/crates/fparkan-binary/Cargo.toml b/crates/fparkan-binary/Cargo.toml
index 2bab5be..cec2aa3 100644
--- a/crates/fparkan-binary/Cargo.toml
+++ b/crates/fparkan-binary/Cargo.toml
@@ -6,6 +6,3 @@ license.workspace = true
repository.workspace = true
[dependencies]
-
-[lints]
-workspace = true
diff --git a/crates/fparkan-binary/src/lib.rs b/crates/fparkan-binary/src/lib.rs
index be9e8d9..bb9f118 100644
--- a/crates/fparkan-binary/src/lib.rs
+++ b/crates/fparkan-binary/src/lib.rs
@@ -1,23 +1,4 @@
#![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;
@@ -399,7 +380,6 @@ pub fn sha256_hex(digest: &Sha256Digest) -> String {
out
}
-#[allow(clippy::many_single_char_names)]
fn compress_sha256_chunk(h: &mut [u32; 8], chunk: &[u8], k: &[u32; 64]) {
let mut w = [0u32; 64];
for (idx, word) in w.iter_mut().take(16).enumerate() {
diff --git a/crates/fparkan-corpus/Cargo.toml b/crates/fparkan-corpus/Cargo.toml
deleted file mode 100644
index 50bf798..0000000
--- a/crates/fparkan-corpus/Cargo.toml
+++ /dev/null
@@ -1,22 +0,0 @@
-[package]
-name = "fparkan-corpus"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
-fparkan-fx = { path = "../fparkan-fx", version = "0.1.0" }
-fparkan-material = { path = "../fparkan-material", version = "0.1.0" }
-fparkan-msh = { path = "../fparkan-msh", version = "0.1.0" }
-fparkan-mission-format = { path = "../fparkan-mission-format", version = "0.1.0" }
-fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
-fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
-fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
-fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
-fparkan-texm = { path = "../fparkan-texm", version = "0.1.0" }
-fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
-
-[lints]
-workspace = true
diff --git a/crates/fparkan-corpus/src/lib.rs b/crates/fparkan-corpus/src/lib.rs
deleted file mode 100644
index 48eaf9e..0000000
--- a/crates/fparkan-corpus/src/lib.rs
+++ /dev/null
@@ -1,1239 +0,0 @@
-#![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 {
- /// Demo corpus.
- Demo,
- /// Part 1 full game.
- Part1,
- /// Part 2 full game.
- Part2,
- /// Unknown local directory.
- Unknown,
-}
-
-/// Corpus root.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct CorpusRoot(pub PathBuf);
-
-/// Discovery options.
-#[derive(Clone, Copy, Debug, Default)]
-pub struct DiscoverOptions {
- /// Whether symlinks may be traversed.
- pub follow_symlinks: bool,
-}
-
-/// File manifest entry.
-#[derive(Clone, Debug, Eq, PartialEq)]
-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.
- pub hash: Sha256Digest,
-}
-
-/// Corpus manifest.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct CorpusManifest {
- /// Kind.
- pub kind: CorpusKind,
- /// Sorted files.
- pub files: Vec<ManifestEntry>,
- /// Casefold collisions.
- pub casefold_collisions: Vec<Vec<String>>,
-}
-
-/// Aggregate report.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct CorpusReport {
- /// Schema version.
- pub schema: u32,
- /// Kind.
- pub kind: CorpusKind,
- /// Total files.
- pub files: usize,
- /// Total bytes.
- pub bytes: u64,
- /// Metrics.
- pub metrics: BTreeMap<String, u64>,
- /// Casefold collision count.
- pub casefold_collisions: usize,
- /// Manifest fingerprint.
- pub fingerprint: Sha256Digest,
- /// Per-file status records.
- pub records: Vec<CorpusFileRecord>,
- /// Number of files with report errors.
- pub failures: usize,
-}
-
-/// Per-file report status.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum CorpusFileStatus {
- /// File was inspected successfully.
- Ok,
- /// File was inspected but produced a non-fatal warning.
- Warning,
- /// File could not be inspected.
- Error,
-}
-
-/// Per-file report record.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct CorpusFileRecord {
- /// Normalized relative path.
- pub path: String,
- /// Inspection status.
- pub status: CorpusFileStatus,
- /// Detected file variant.
- pub variant: String,
- /// Optional status message.
- pub message: Option<String>,
-}
-
-/// Corpus error.
-#[derive(Debug)]
-pub enum CorpusError {
- /// I/O failure.
- Io {
- /// Path where I/O failed.
- path: PathBuf,
- /// Source error.
- source: std::io::Error,
- },
- /// Invalid root.
- InvalidRoot(PathBuf),
- /// Invalid path.
- InvalidPath(String),
- /// Aggregate report failure.
- Report {
- /// Path where reporting failed.
- path: String,
- /// Failure message.
- message: String,
- },
-}
-
-impl fmt::Display for CorpusError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::Io { path, source } => write!(f, "{}: {source}", path.display()),
- Self::InvalidRoot(path) => write!(f, "invalid corpus root: {}", path.display()),
- Self::InvalidPath(path) => write!(f, "invalid corpus path: {path}"),
- Self::Report { path, message } => write!(f, "{path}: {message}"),
- }
- }
-}
-
-impl std::error::Error for CorpusError {
- fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
- match self {
- Self::Io { source, .. } => Some(source),
- Self::InvalidRoot(_) | Self::InvalidPath(_) | Self::Report { .. } => None,
- }
- }
-}
-
-/// Discovers a corpus under a root directory.
-///
-/// # Errors
-///
-/// Returns [`CorpusError`] if the root is invalid, traversal encounters an I/O
-/// error, or a discovered path cannot be represented by the legacy path policy.
-pub fn discover(root: &Path, options: DiscoverOptions) -> Result<CorpusManifest, CorpusError> {
- if !root.is_dir() {
- return Err(CorpusError::InvalidRoot(root.to_path_buf()));
- }
- let mut files = Vec::new();
- walk(root, root, options, &mut files)?;
- 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);
- Ok(CorpusManifest {
- kind,
- files,
- casefold_collisions,
- })
-}
-
-fn walk(
- root: &Path,
- dir: &Path,
- options: DiscoverOptions,
- out: &mut Vec<ManifestEntry>,
-) -> Result<(), CorpusError> {
- let read_dir = fs::read_dir(dir).map_err(|source| CorpusError::Io {
- path: dir.to_path_buf(),
- source,
- })?;
- let mut entries = Vec::new();
- for entry in read_dir {
- let entry = entry.map_err(|source| CorpusError::Io {
- path: dir.to_path_buf(),
- source,
- })?;
- entries.push(entry.path());
- }
- entries.sort();
- for path in entries {
- if path
- .file_name()
- .and_then(|name| name.to_str())
- .is_some_and(|name| name.starts_with('.'))
- {
- continue;
- }
- let metadata = fs::symlink_metadata(&path).map_err(|source| CorpusError::Io {
- path: path.clone(),
- source,
- })?;
- if metadata.file_type().is_symlink() && !options.follow_symlinks {
- continue;
- }
- if metadata.is_dir() {
- walk(root, &path, options, out)?;
- continue;
- }
- if !metadata.is_file() {
- continue;
- }
- let rel = path
- .strip_prefix(root)
- .map_err(|_| CorpusError::InvalidPath(path.display().to_string()))?;
- #[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()))?
- .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.display_lossy().to_string(),
- host_rel_path: rel.to_path_buf(),
- size: metadata.len(),
- hash: sha256(&bytes),
- });
- }
- Ok(())
-}
-
-fn classify(root: &Path, files: &[ManifestEntry]) -> CorpusKind {
- let name = root
- .file_name()
- .and_then(|v| v.to_str())
- .unwrap_or_default()
- .to_ascii_uppercase();
- if name == "IS" {
- CorpusKind::Part1
- } else if name == "IS2" {
- CorpusKind::Part2
- } else if files
- .iter()
- .any(|f| f.path.eq_ignore_ascii_case("iron_3d.exe"))
- {
- CorpusKind::Part1
- } else {
- CorpusKind::Unknown
- }
-}
-
-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(path_identity_bytes(&file.host_rel_path)).0)
- .or_default()
- .insert(file.path.clone());
- }
- grouped
- .into_values()
- .filter(|paths| paths.len() > 1)
- .map(|paths| paths.into_iter().collect())
- .collect()
-}
-
-/// Builds aggregate report.
-///
-/// # Errors
-///
-/// Returns [`CorpusError`] when the aggregate report cannot be constructed.
-/// Per-file inspection failures are represented in [`CorpusReport::records`]
-/// and counted in [`CorpusReport::failures`].
-pub fn report(root: &Path, manifest: &CorpusManifest) -> Result<CorpusReport, CorpusError> {
- let mut metrics = empty_report_metrics();
- let mut records = Vec::with_capacity(manifest.files.len());
- let mut failures = 0usize;
-
- for entry in &manifest.files {
- let record = inspect_report_file(root, entry, &mut metrics);
- if record.status == CorpusFileStatus::Error {
- failures = failures.saturating_add(1);
- }
- records.push(record);
- }
-
- Ok(CorpusReport {
- schema: 1,
- kind: manifest.kind,
- files: manifest.files.len(),
- bytes: manifest.files.iter().map(|f| f.size).sum(),
- metrics,
- casefold_collisions: manifest.casefold_collisions.len(),
- fingerprint: fingerprint(manifest),
- records,
- failures,
- })
-}
-
-fn empty_report_metrics() -> BTreeMap<String, u64> {
- let mut metrics = BTreeMap::new();
- metrics.insert("nres_files".to_string(), 0);
- metrics.insert("nres_entries".to_string(), 0);
- metrics.insert("rsli_files".to_string(), 0);
- metrics.insert("tma_files".to_string(), 0);
- metrics.insert("land_msh_files".to_string(), 0);
- metrics.insert("land_map_files".to_string(), 0);
- metrics.insert("unit_dat_files".to_string(), 0);
- metrics.insert("msh_entries".to_string(), 0);
- metrics.insert("mat0_entries".to_string(), 0);
- metrics.insert("texm_entries".to_string(), 0);
- metrics.insert("fxid_entries".to_string(), 0);
- metrics.insert("wear_entries".to_string(), 0);
- metrics
-}
-
-fn inspect_report_file(
- root: &Path,
- entry: &ManifestEntry,
- metrics: &mut BTreeMap<String, u64>,
-) -> CorpusFileRecord {
- let lower = entry.path.to_ascii_lowercase();
- let mut variant = inspect_path_metrics(&lower, metrics);
- let path = root.join(&entry.host_rel_path);
- let bytes = match fs::read(&path) {
- Ok(bytes) => bytes,
- Err(source) => {
- return CorpusFileRecord {
- path: entry.path.clone(),
- status: CorpusFileStatus::Error,
- variant,
- message: Some(source.to_string()),
- };
- }
- };
- if bytes.starts_with(b"NRes") {
- if variant == "file" {
- variant = "nres".to_string();
- }
- bump(metrics, "nres_files", 1);
- if let Err(message) = inspect_nres_metrics(&bytes, metrics) {
- return CorpusFileRecord {
- path: entry.path.clone(),
- status: CorpusFileStatus::Error,
- variant,
- 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(),
- status: CorpusFileStatus::Ok,
- variant,
- message: None,
- }
-}
-
-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") {
- bump(metrics, "tma_files", 1);
- variant = "tma";
- }
- if lower.ends_with("land.msh") {
- bump(metrics, "land_msh_files", 1);
- variant = "land_msh";
- }
- if lower.ends_with("land.map") {
- bump(metrics, "land_map_files", 1);
- variant = "land_map";
- }
- if has_extension(lower, "dat") && (lower.starts_with("units/") || lower.contains("/units/")) {
- bump(metrics, "unit_dat_files", 1);
- variant = "unit_dat";
- }
- variant.to_string()
-}
-
-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 {
- MAT0_KIND => {
- bump(metrics, "mat0_entries", 1);
- validate_nres_mat0_payload(&document, entry)?;
- }
- TEXM_KIND => {
- bump(metrics, "texm_entries", 1);
- validate_nres_texm_payload(&document, entry)?;
- }
- FXID_KIND => {
- bump(metrics, "fxid_entries", 1);
- validate_nres_fxid_payload(&document, entry)?;
- }
- WEAR_KIND => {
- bump(metrics, "wear_entries", 1);
- validate_nres_wear_payload(&document, entry)?;
- }
- _ => {}
- }
- }
- 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);
- }
-}
-
-fn has_extension(path: &str, expected: &str) -> bool {
- Path::new(path)
- .extension()
- .is_some_and(|extension| extension.eq_ignore_ascii_case(expected))
-}
-
-/// Computes stable manifest fingerprint.
-#[must_use]
-pub fn fingerprint(manifest: &CorpusManifest) -> Sha256Digest {
- let mut bytes = Vec::new();
- for file in &manifest.files {
- bytes.extend_from_slice(file.path.as_bytes());
- bytes.push(0);
- bytes.extend_from_slice(&file.size.to_le_bytes());
- bytes.extend_from_slice(&file.hash);
- }
- sha256(&bytes)
-}
-
-/// Writes report atomically.
-///
-/// # Errors
-///
-/// Returns [`CorpusError`] if the parent directory, temporary file, write, or
-/// final rename operation fails.
-pub fn write_report_atomic(path: &Path, report: &CorpusReport) -> Result<(), CorpusError> {
- let tmp = path.with_extension("tmp");
- if let Some(parent) = path.parent() {
- fs::create_dir_all(parent).map_err(|source| CorpusError::Io {
- path: parent.to_path_buf(),
- source,
- })?;
- }
- let mut file = fs::File::create(&tmp).map_err(|source| CorpusError::Io {
- path: tmp.clone(),
- source,
- })?;
- file.write_all(render_report_json(report).as_bytes())
- .map_err(|source| CorpusError::Io {
- path: tmp.clone(),
- source,
- })?;
- file.sync_all().map_err(|source| CorpusError::Io {
- path: tmp.clone(),
- source,
- })?;
- fs::rename(&tmp, path).map_err(|source| CorpusError::Io {
- path: path.to_path_buf(),
- source,
- })?;
- Ok(())
-}
-
-/// Renders report JSON.
-#[must_use]
-pub fn render_report_json(report: &CorpusReport) -> String {
- let mut out = format!(
- "{{\"schema_version\":\"fparkan-corpus-report-v1\",\"schema\":{},\"kind\":\"{:?}\",\"files\":{},\"bytes\":{},\"casefold_collisions\":{},\"fingerprint\":\"{}\",\"failures\":{},\"record_count\":{},\"metrics\":{{",
- report.schema,
- report.kind,
- report.files,
- report.bytes,
- report.casefold_collisions,
- sha256_hex(&report.fingerprint),
- report.failures,
- report.records.len()
- );
- for (idx, (key, value)) in report.metrics.iter().enumerate() {
- if idx > 0 {
- out.push(',');
- }
- out.push('"');
- out.push_str(key);
- out.push_str("\":");
- out.push_str(&value.to_string());
- }
- out.push_str("}}");
- out.push('}');
- out
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use fparkan_path::join_under;
- use std::time::{SystemTime, UNIX_EPOCH};
-
- #[test]
- #[ignore = "requires licensed corpus"]
- fn report_for_testdata_roots() {
- let root = licensed_root("IS");
- let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
- let report = report(&root, &manifest).expect("report");
- assert!(report.files > 0);
- assert!(report.metrics["nres_files"] > 0);
- }
-
- #[test]
- #[ignore = "requires licensed corpus"]
- fn licensed_part1_manifest_profile_and_counts_match_baseline() {
- let root = testdata_root("IS");
- let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
- let report = report(&root, &manifest).expect("report");
-
- assert_eq!(manifest.kind, CorpusKind::Part1);
- assert_eq!(report.files, 1_017);
- assert_eq!(report.metrics["nres_files"], 120);
- assert_eq!(report.metrics["rsli_files"], 2);
- assert_eq!(report.metrics["tma_files"], 29);
- assert_eq!(report.metrics["land_msh_files"], 33);
- assert_eq!(report.metrics["land_map_files"], 33);
- assert_eq!(report.metrics["unit_dat_files"], 425);
- }
-
- #[test]
- #[ignore = "requires licensed corpus"]
- fn licensed_part2_manifest_profile_and_counts_match_baseline() {
- let root = testdata_root("IS2");
- let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
- let report = report(&root, &manifest).expect("report");
-
- assert_eq!(manifest.kind, CorpusKind::Part2);
- assert_eq!(report.files, 1_302);
- assert_eq!(report.metrics["nres_files"], 134);
- assert_eq!(report.metrics["rsli_files"], 2);
- assert_eq!(report.metrics["tma_files"], 31);
- assert_eq!(report.metrics["land_msh_files"], 32);
- assert_eq!(report.metrics["land_map_files"], 32);
- assert_eq!(report.metrics["unit_dat_files"], 676);
- }
-
- #[test]
- #[ignore = "requires licensed corpus"]
- fn licensed_part1_has_no_casefold_relative_path_collisions() {
- let root = testdata_root("IS");
- let manifest = discover(&root, DiscoverOptions::default()).expect("part 1 manifest");
-
- assert!(manifest.casefold_collisions.is_empty());
- }
-
- #[test]
- #[ignore = "requires licensed corpus"]
- fn licensed_part2_has_no_casefold_relative_path_collisions() {
- let root = testdata_root("IS2");
- let manifest = discover(&root, DiscoverOptions::default()).expect("part 2 manifest");
-
- assert!(manifest.casefold_collisions.is_empty());
- }
-
- #[test]
- #[ignore = "requires licensed corpus"]
- fn licensed_part1_paths_stay_under_root() {
- assert_discovered_paths_stay_under_root("IS");
- }
-
- #[test]
- #[ignore = "requires licensed corpus"]
- fn licensed_part2_paths_stay_under_root() {
- assert_discovered_paths_stay_under_root("IS2");
- }
-
- #[test]
- fn report_json_contains_metrics_and_hashes_not_paths_or_payloads() {
- let manifest = CorpusManifest {
- kind: CorpusKind::Part1,
- files: vec![manifest_entry("secret/payload.bin", 4, sha256(b"DATA"))],
- casefold_collisions: Vec::new(),
- };
- let report = report(Path::new("."), &manifest).expect("report");
- let json = render_report_json(&report);
-
- assert!(json.contains("\"schema_version\":\"fparkan-corpus-report-v1\""));
- assert!(json.contains("\"fingerprint\":"));
- assert!(json.contains("\"failures\":1"));
- assert!(json.contains("\"record_count\":1"));
- assert!(json.contains("\"metrics\":"));
- assert!(!json.contains("secret/payload.bin"));
- assert!(!json.contains("DATA"));
- }
-
- #[test]
- fn report_records_missing_manifest_files_as_failures() {
- let root = temp_dir("report-missing");
- let manifest = CorpusManifest {
- kind: CorpusKind::Unknown,
- files: vec![manifest_entry("missing.lib", 1, sha256(b"missing"))],
- 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].path, "missing.lib");
- assert_eq!(report.records[0].status, CorpusFileStatus::Error);
- let _ = fs::remove_dir_all(root);
- }
-
- #[test]
- fn report_records_malformed_nres_as_failure() {
- let root = temp_dir("report-bad-nres");
- fs::write(root.join("bad.lib"), b"NRes").expect("bad nres");
- let manifest = CorpusManifest {
- kind: CorpusKind::Unknown,
- files: vec![manifest_entry("bad.lib", 4, sha256(b"NRes"))],
- 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, "nres");
- assert!(report.records[0]
- .message
- .as_deref()
- .is_some_and(|message| message.contains("NRes")));
- let _ = fs::remove_dir_all(root);
- }
-
- #[test]
- fn report_uses_production_nres_parser_for_entry_metrics() {
- let root = temp_dir("report-nres");
- let archive = build_nres(&[
- TestNresEntry {
- name: "mesh.msh",
- type_id: 0,
- payload: b"mesh",
- },
- TestNresEntry {
- name: "mat.bin",
- type_id: 0x3054_414D,
- payload: b"mat0",
- },
- TestNresEntry {
- name: "texture.bin",
- type_id: 0x6D78_6554,
- payload: b"texm",
- },
- ]);
- fs::write(root.join("archive.lib"), &archive).expect("archive");
- let manifest = CorpusManifest {
- kind: CorpusKind::Unknown,
- 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[0].status, CorpusFileStatus::Ok);
- 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);
- }
-
- #[test]
- fn deterministic_traversal_is_creation_order_independent() {
- let first = temp_dir("order-first");
- let second = temp_dir("order-second");
- fs::create_dir_all(first.join("nested")).expect("first nested");
- fs::create_dir_all(second.join("nested")).expect("second nested");
-
- fs::write(first.join("b.bin"), b"b").expect("first b");
- fs::write(first.join("nested").join("a.bin"), b"a").expect("first a");
- fs::write(second.join("nested").join("a.bin"), b"a").expect("second a");
- fs::write(second.join("b.bin"), b"b").expect("second b");
-
- let first_manifest = discover(&first, DiscoverOptions::default()).expect("first manifest");
- let second_manifest =
- discover(&second, DiscoverOptions::default()).expect("second manifest");
-
- assert_eq!(first_manifest.files, second_manifest.files);
- let _ = fs::remove_dir_all(first);
- let _ = fs::remove_dir_all(second);
- }
-
- #[cfg(unix)]
- #[test]
- fn unreadable_directory_produces_error() {
- use std::os::unix::fs::PermissionsExt;
-
- let root = temp_dir("unreadable");
- let child = root.join("locked");
- fs::create_dir_all(&child).expect("locked dir");
- fs::set_permissions(&child, fs::Permissions::from_mode(0o000)).expect("lock dir");
-
- let result = discover(&root, DiscoverOptions::default());
-
- fs::set_permissions(&child, fs::Permissions::from_mode(0o700)).expect("unlock dir");
- let _ = fs::remove_dir_all(root);
- assert!(matches!(result, Err(CorpusError::Io { path, .. }) if path.ends_with("locked")));
- }
-
- #[cfg(unix)]
- #[test]
- fn symlink_loop_is_not_traversed_by_default() {
- use std::os::unix::fs::symlink;
-
- let root = temp_dir("symlink-loop");
- fs::write(root.join("real.bin"), b"real").expect("real file");
- symlink(&root, root.join("loop")).expect("loop symlink");
-
- let manifest = discover(&root, DiscoverOptions::default()).expect("manifest");
-
- assert_eq!(manifest.files.len(), 1);
- assert_eq!(manifest.files[0].path, "real.bin");
- let _ = fs::remove_dir_all(root);
- }
-
- #[test]
- fn casefold_collisions_are_registered() {
- let manifest = CorpusManifest {
- kind: CorpusKind::Unknown,
- files: vec![
- manifest_entry("Textures/Foo.TEX", 1, sha256(b"first")),
- manifest_entry("textures/foo.tex", 1, sha256(b"second")),
- ],
- casefold_collisions: Vec::new(),
- };
-
- let collisions = detect_casefold_collisions(&manifest.files);
-
- assert_eq!(
- collisions,
- vec![vec![
- "Textures/Foo.TEX".to_string(),
- "textures/foo.tex".to_string()
- ]]
- );
- }
-
- #[test]
- fn fingerprint_changes() {
- let mut manifest = CorpusManifest {
- kind: CorpusKind::Unknown,
- files: vec![manifest_entry("a", 1, sha256(b"before"))],
- casefold_collisions: Vec::new(),
- };
- let a = fingerprint(&manifest);
- manifest.files[0].hash = sha256(b"after");
- assert_ne!(a, fingerprint(&manifest));
- }
-
- #[test]
- fn atomic_report_write() {
- let tmp = std::env::temp_dir().join(format!(
- "fparkan-report-{}.json",
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("clock")
- .as_nanos()
- ));
- let report = CorpusReport {
- schema: 1,
- kind: CorpusKind::Unknown,
- files: 0,
- bytes: 0,
- metrics: BTreeMap::new(),
- casefold_collisions: 0,
- fingerprint: sha256(b"empty-report"),
- records: Vec::new(),
- failures: 0,
- };
- write_report_atomic(&tmp, &report).expect("write");
- assert!(tmp.is_file());
- 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,
- payload: &'a [u8],
- }
-
- fn build_nres(entries: &[TestNresEntry<'_>]) -> Vec<u8> {
- let mut out = vec![0; 16];
- let mut offsets = Vec::with_capacity(entries.len());
- for entry in entries {
- offsets.push(u32::try_from(out.len()).expect("offset"));
- out.extend_from_slice(entry.payload);
- let padding = (8 - (out.len() % 8)) % 8;
- out.resize(out.len() + padding, 0);
- }
- let mut order: Vec<usize> = (0..entries.len()).collect();
- order.sort_by(|left, right| {
- entries[*left]
- .name
- .as_bytes()
- .cmp(entries[*right].name.as_bytes())
- });
- for (index, entry) in entries.iter().enumerate() {
- push_u32(&mut out, entry.type_id);
- push_u32(&mut out, 0);
- push_u32(&mut out, 0);
- push_u32(
- &mut out,
- u32::try_from(entry.payload.len()).expect("payload size"),
- );
- push_u32(&mut out, 0);
- let mut name = [0; 36];
- let name_bytes = entry.name.as_bytes();
- name[..name_bytes.len()].copy_from_slice(name_bytes);
- out.extend_from_slice(&name);
- push_u32(&mut out, offsets[index]);
- push_u32(&mut out, u32::try_from(order[index]).expect("sort index"));
- }
- out[0..4].copy_from_slice(b"NRes");
- out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
- out[8..12].copy_from_slice(&u32::try_from(entries.len()).expect("count").to_le_bytes());
- let total_size = u32::try_from(out.len()).expect("total size");
- out[12..16].copy_from_slice(&total_size.to_le_bytes());
- 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());
- }
-
- fn temp_dir(name: &str) -> PathBuf {
- let path = std::env::temp_dir().join(format!(
- "fparkan-corpus-{name}-{}",
- SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .expect("clock")
- .as_nanos()
- ));
- fs::create_dir_all(&path).expect("temp dir");
- path
- }
-
- fn testdata_root(part: &str) -> PathBuf {
- licensed_root(part)
- }
-
- fn licensed_root(part: &str) -> PathBuf {
- let variable = match part {
- "IS" => "FPARKAN_CORPUS_PART1_ROOT",
- "IS2" => "FPARKAN_CORPUS_PART2_ROOT",
- _ => panic!("unknown licensed corpus part: {part}"),
- };
- let root = std::env::var_os(variable)
- .map(PathBuf::from)
- .unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
- assert!(
- root.is_dir(),
- "licensed corpus root is missing: {}",
- root.display()
- );
- root
- }
-
- fn assert_discovered_paths_stay_under_root(part: &str) {
- let root = testdata_root(part);
- let manifest = discover(&root, DiscoverOptions::default()).expect("licensed manifest");
-
- for entry in &manifest.files {
- let normalized = normalize_relative(entry.path.as_bytes(), PathPolicy::HostCompatible)
- .expect("discovered path should re-normalize");
- let joined = join_under(&root, &normalized).expect("discovered path should join");
- assert!(
- joined.starts_with(&root),
- "discovered path escaped root: {}",
- entry.path
- );
- }
- }
-}
diff --git a/crates/fparkan-diagnostics/Cargo.toml b/crates/fparkan-diagnostics/Cargo.toml
deleted file mode 100644
index 59b8273..0000000
--- a/crates/fparkan-diagnostics/Cargo.toml
+++ /dev/null
@@ -1,13 +0,0 @@
-[package]
-name = "fparkan-diagnostics"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
-
-[lints]
-workspace = true
diff --git a/crates/fparkan-diagnostics/src/lib.rs b/crates/fparkan-diagnostics/src/lib.rs
deleted file mode 100644
index f0228ec..0000000
--- a/crates/fparkan-diagnostics/src/lib.rs
+++ /dev/null
@@ -1,249 +0,0 @@
-#![forbid(unsafe_code)]
-#![cfg_attr(
- test,
- allow(
- clippy::cast_possible_truncation,
- clippy::cast_possible_wrap,
- clippy::cast_precision_loss,
- clippy::expect_used,
- clippy::float_cmp,
- clippy::identity_op,
- clippy::too_many_lines,
- clippy::uninlined_format_args,
- clippy::map_unwrap_or,
- clippy::needless_raw_string_hashes,
- clippy::semicolon_if_nothing_returned,
- clippy::type_complexity,
- clippy::panic,
- clippy::unwrap_used
- )
-)]
-//! Structured diagnostics shared by `FParkan` crates.
-
-use serde::Serialize;
-
-/// Diagnostic severity.
-#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub enum Severity {
- /// Informational note.
- Info,
- /// Recoverable warning.
- Warning,
- /// Error for the current operation.
- Error,
- /// Fatal error for the current run.
- Fatal,
-}
-
-/// Evidence level for a contract or interpretation.
-#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub enum EvidenceStatus {
- /// Described by project documentation.
- Documented,
- /// Verified by synthetic fixtures.
- SyntheticVerified,
- /// Verified against the licensed corpus.
- CorpusVerified,
- /// Verified by runtime capture.
- RuntimeCaptured,
- /// Working hypothesis; not a runtime contract.
- Hypothesis,
-}
-
-/// Operation phase where a diagnostic was produced.
-#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
-#[serde(rename_all = "lowercase")]
-pub enum Phase {
- /// Discovery.
- Discover,
- /// Read.
- Read,
- /// Parse.
- Parse,
- /// Validate.
- Validate,
- /// Resolve.
- Resolve,
- /// Prepare.
- Prepare,
- /// Construct.
- Construct,
- /// Register.
- Register,
- /// Simulate.
- Simulate,
- /// Render.
- Render,
-}
-
-/// Byte span in an input source.
-#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
-pub struct SourceSpan {
- /// Start offset.
- pub offset: u64,
- /// Length in bytes.
- pub length: u64,
-}
-
-/// Stable diagnostic code.
-#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
-pub struct DiagnosticCode(pub &'static str);
-
-/// Context attached to a diagnostic.
-#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
-pub struct DiagnosticContext {
- /// Phase.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub phase: Option<Phase>,
- /// Redacted or logical path.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub path: Option<String>,
- /// Archive entry name.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub archive_entry: Option<String>,
- /// Object/prototype key.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub object_key: Option<String>,
- /// Input span.
- #[serde(skip_serializing_if = "Option::is_none")]
- pub span: Option<SourceSpan>,
-}
-
-/// Structured diagnostic with cause chain.
-#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
-pub struct Diagnostic {
- /// Stable code.
- pub code: DiagnosticCode,
- /// Severity.
- pub severity: Severity,
- /// Human message.
- pub message: String,
- /// Context.
- pub context: DiagnosticContext,
- /// Causes.
- pub causes: Vec<Diagnostic>,
-}
-
-/// Creates a diagnostic with default error severity.
-#[must_use]
-pub fn diagnostic(code: DiagnosticCode, message: impl Into<String>) -> Diagnostic {
- Diagnostic {
- code,
- severity: Severity::Error,
- message: message.into(),
- context: DiagnosticContext::default(),
- causes: Vec::new(),
- }
-}
-
-impl Diagnostic {
- /// Returns a copy with severity changed.
- #[must_use]
- pub fn with_severity(mut self, severity: Severity) -> Self {
- self.severity = severity;
- self
- }
-
- /// Returns a copy with context changed.
- #[must_use]
- pub fn with_context(mut self, context: DiagnosticContext) -> Self {
- self.context = context;
- self
- }
-
- /// Adds a cause.
- pub fn push_cause(&mut self, cause: Diagnostic) {
- self.causes.push(cause);
- }
-}
-
-/// Renders a compact human-readable diagnostic.
-#[must_use]
-pub fn render_human(diagnostic: &Diagnostic) -> String {
- let mut out = format!(
- "{:?} {}: {}",
- diagnostic.severity, diagnostic.code.0, diagnostic.message
- );
- if let Some(path) = &diagnostic.context.path {
- out.push_str(" [");
- out.push_str(path);
- out.push(']');
- }
- out
-}
-
-/// Renders deterministic JSON using the typed diagnostic schema.
-#[must_use]
-pub fn render_json(diagnostic: &Diagnostic) -> String {
- match serde_json::to_string(diagnostic) {
- Ok(json) => json,
- Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn json_is_stable() {
- let d = diagnostic(DiagnosticCode("S0-DIAG-001"), "keeps context").with_context(
- DiagnosticContext {
- phase: Some(Phase::Parse),
- ..DiagnosticContext::default()
- },
- );
- assert_eq!(
- render_json(&d),
- "{\"code\":\"S0-DIAG-001\",\"severity\":\"error\",\"message\":\"keeps context\",\"context\":{\"phase\":\"parse\"},\"causes\":[]}"
- );
- }
-
- #[test]
- fn diagnostic_chain_preserves_context() {
- let mut root = diagnostic(DiagnosticCode("ROOT"), "root").with_context(DiagnosticContext {
- phase: Some(Phase::Resolve),
- path: Some("archives/material.lib".to_string()),
- archive_entry: Some("MATERIAL.MAT0".to_string()),
- object_key: Some("unit/tank".to_string()),
- span: Some(SourceSpan {
- offset: 12,
- length: 4,
- }),
- });
- root.push_cause(diagnostic(DiagnosticCode("CAUSE"), "cause").with_context(
- DiagnosticContext {
- phase: Some(Phase::Parse),
- path: Some("archives/material.lib".to_string()),
- span: Some(SourceSpan {
- offset: 16,
- length: 8,
- }),
- ..DiagnosticContext::default()
- },
- ));
-
- let json = render_json(&root);
-
- assert!(json.contains("\"code\":\"ROOT\""));
- assert!(json.contains("\"phase\":\"resolve\""));
- assert!(json.contains("\"path\":\"archives/material.lib\""));
- assert!(json.contains("\"archive_entry\":\"MATERIAL.MAT0\""));
- assert!(json.contains("\"object_key\":\"unit/tank\""));
- assert!(json.contains("\"span\":{\"offset\":12,\"length\":4}"));
- assert!(json.contains("\"code\":\"CAUSE\""));
- assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
- }
-
- #[test]
- fn json_escapes_all_control_characters() {
- let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
- let json = render_json(&value);
- assert!(json.contains("\\u0000"));
- assert!(json.contains("\\t"));
- assert!(!json.contains('\t'));
- assert!(!json.contains('\r'));
- }
-}
diff --git a/crates/fparkan-fx/Cargo.toml b/crates/fparkan-fx/Cargo.toml
index a8cf963..89672ec 100644
--- a/crates/fparkan-fx/Cargo.toml
+++ b/crates/fparkan-fx/Cargo.toml
@@ -6,10 +6,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
+fparkan-binary = { path = "../fparkan-binary" }
[dev-dependencies]
-fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-nres = { path = "../fparkan-nres" }
diff --git a/crates/fparkan-fx/src/atmosphere.rs b/crates/fparkan-fx/src/atmosphere.rs
new file mode 100644
index 0000000..53ae81b
--- /dev/null
+++ b/crates/fparkan-fx/src/atmosphere.rs
@@ -0,0 +1,1756 @@
+//! Parser and sampler for the original sky.ske atmosphere schedule.
+//!
+//! The game stores a schedule rather than a list of named weather presets.
+//! A schedule contains one or more compressed in-game days. Track durations
+//! are real seconds, while key dates are hours, minutes, and seconds in the
+//! in-game day. Sampling selects tracks in file order and interpolates the
+//! two surrounding keys on the complete cyclic schedule, including adjacent
+//! track boundaries.
+//!
+//! Fields not consumed by the original atmosphere renderer keep neutral names
+//! such as raw, header_values, and parameters. They are retained so a later
+//! renderer can use them without changing the file parser.
+
+use fparkan_binary::{read_lp_bytes, Cursor, DecodeError};
+use std::fmt;
+
+/// sky.ske header marker.
+pub const SKE_HEADER_MARKER: i32 = -1;
+/// Supported sky.ske version.
+pub const SKE_VERSION: u32 = 5;
+/// Number of color parameters emitted by the sky sampler.
+pub const SKY_COLOR_COUNT: usize = 13;
+/// Number of packed colors in every on-disk key palette.
+pub const KEY_COLOR_COUNT: usize = 15;
+/// Number of fixed length-prefixed strings in every key.
+pub const KEY_STRING_COUNT: usize = 6;
+/// Number of words in an on-disk date.
+pub const DATE_WORD_COUNT: usize = 8;
+
+const MAX_TRACKS: u32 = 256;
+const MAX_KEYS: u32 = 16_384;
+const MAX_STRING_BYTES: u32 = 64 * 1024;
+const MAX_REFERENCES: u32 = 256;
+const DAY_SECONDS: f32 = 86_400.0;
+const COLOR_MIN_COMPONENT: f32 = 80.0 / 255.0;
+const WEATHER_ALPHA: f32 = 150.0 / 255.0;
+
+/// A decoded atmosphere error.
+#[derive(Clone, Debug, PartialEq)]
+pub enum AtmosphereError {
+ /// A bounded binary read failed.
+ Decode(DecodeError),
+ /// The file marker or version is not supported.
+ InvalidHeader {
+ /// Header marker read from the file.
+ marker: i32,
+ /// Header version read from the file.
+ version: u32,
+ },
+ /// A track has a version other than one.
+ InvalidTrackVersion {
+ /// Track number.
+ track_index: usize,
+ /// Version read from the file.
+ version: u32,
+ },
+ /// A key has a version other than three.
+ InvalidKeyVersion {
+ /// Track number.
+ track_index: usize,
+ /// Key number.
+ key_index: usize,
+ /// Version read from the file.
+ version: u32,
+ },
+ /// A date contains an impossible time of day.
+ InvalidDate {
+ /// Context in which the date appeared.
+ context: &'static str,
+ /// Hour word.
+ hours: u32,
+ /// Minute word.
+ minutes: u32,
+ /// Second word.
+ seconds: u32,
+ },
+ /// A key date is earlier than the previous key date.
+ NonMonotonicKey {
+ /// Track number.
+ track_index: usize,
+ /// Key number that is out of order.
+ key_index: usize,
+ /// Previous key time in seconds.
+ previous_seconds: u32,
+ /// Current key time in seconds.
+ current_seconds: u32,
+ },
+ /// A track contains no key and therefore cannot be sampled.
+ EmptyTrack {
+ /// Track number.
+ track_index: usize,
+ },
+ /// A floating point value in a key is NaN or infinite.
+ NonFiniteValue {
+ /// Track number.
+ track_index: usize,
+ /// Key number.
+ key_index: usize,
+ /// Neutral field name.
+ field: &'static str,
+ /// Array element.
+ index: usize,
+ },
+ /// A real duration could not be represented.
+ InvalidDuration {
+ /// Track number.
+ track_index: usize,
+ },
+}
+
+impl From<DecodeError> for AtmosphereError {
+ fn from(value: DecodeError) -> Self {
+ Self::Decode(value)
+ }
+}
+
+impl fmt::Display for AtmosphereError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Decode(source) => write!(f, "{source}"),
+ Self::InvalidHeader { marker, version } => {
+ write!(f, "unsupported sky.ske header marker={marker} version={version}")
+ }
+ Self::InvalidTrackVersion {
+ track_index,
+ version,
+ } => write!(f, "unsupported sky.ske track {track_index} version {version}"),
+ Self::InvalidKeyVersion {
+ track_index,
+ key_index,
+ version,
+ } => write!(
+ f,
+ "unsupported sky.ske track {track_index} key {key_index} version {version}"
+ ),
+ Self::InvalidDate {
+ context,
+ hours,
+ minutes,
+ seconds,
+ } => write!(
+ f,
+ "invalid sky.ske {context} time {hours:02}:{minutes:02}:{seconds:02}"
+ ),
+ Self::NonMonotonicKey {
+ track_index,
+ key_index,
+ previous_seconds,
+ current_seconds,
+ } => write!(
+ f,
+ "sky.ske track {track_index} key {key_index} goes backward from {previous_seconds}s to {current_seconds}s"
+ ),
+ Self::EmptyTrack { track_index } => {
+ write!(f, "sky.ske track {track_index} has no keys")
+ }
+ Self::NonFiniteValue {
+ track_index,
+ key_index,
+ field,
+ index,
+ } => write!(
+ f,
+ "sky.ske track {track_index} key {key_index} has non-finite {field}[{index}]"
+ ),
+ Self::InvalidDuration { track_index } => {
+ write!(f, "sky.ske track {track_index} has an invalid duration")
+ }
+ }
+ }
+}
+
+impl std::error::Error for AtmosphereError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Decode(source) => Some(source),
+ Self::InvalidHeader { .. }
+ | Self::InvalidTrackVersion { .. }
+ | Self::InvalidKeyVersion { .. }
+ | Self::InvalidDate { .. }
+ | Self::NonMonotonicKey { .. }
+ | Self::EmptyTrack { .. }
+ | Self::NonFiniteValue { .. }
+ | Self::InvalidDuration { .. } => None,
+ }
+ }
+}
+
+/// The eight words copied by the original date reader.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct SkeDate {
+ /// Raw date words, preserved in file order.
+ pub words: [u32; DATE_WORD_COUNT],
+}
+
+impl SkeDate {
+ /// Constructs a date from its raw words.
+ #[must_use]
+ pub const fn from_words(words: [u32; DATE_WORD_COUNT]) -> Self {
+ Self { words }
+ }
+
+ /// In-game hour word.
+ #[must_use]
+ pub const fn hours(self) -> u32 {
+ self.words[3]
+ }
+
+ /// In-game minute word.
+ #[must_use]
+ pub const fn minutes(self) -> u32 {
+ self.words[4]
+ }
+
+ /// In-game second word.
+ #[must_use]
+ pub const fn seconds(self) -> u32 {
+ self.words[5]
+ }
+
+ /// Returns the time of day in seconds.
+ #[must_use]
+ pub const fn seconds_of_day(self) -> u32 {
+ self.hours() * 3_600 + self.minutes() * 60 + self.seconds()
+ }
+
+ /// Returns the hour/minute position used by the original atmosphere
+ /// scheduler. The serialized seconds word is deliberately ignored by the
+ /// scheduler and is retained only in [`Self::words`].
+ #[must_use]
+ pub const fn hour_minute_seconds(self) -> u32 {
+ (self.hours() * 60 + self.minutes()) * 60
+ }
+
+ /// Returns the hour and minute duration marker used by the original track
+ /// constructor.
+ #[must_use]
+ pub const fn real_duration_marker(self) -> u32 {
+ self.hour_minute_seconds()
+ }
+}
+
+/// A packed original ARGB color (0xAARRGGBB).
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct PackedColor(pub u32);
+
+impl PackedColor {
+ /// Returns the raw ARGB word.
+ #[must_use]
+ pub const fn raw(self) -> u32 {
+ self.0
+ }
+
+ /// Decodes the word to normalized RGBA components.
+ #[must_use]
+ pub fn rgba(self) -> [f32; 4] {
+ [
+ f32::from((self.0 >> 16) as u8) / 255.0,
+ f32::from((self.0 >> 8) as u8) / 255.0,
+ f32::from(self.0 as u8) / 255.0,
+ f32::from((self.0 >> 24) as u8) / 255.0,
+ ]
+ }
+
+ /// Interpolates two packed colors in normalized channel space and packs
+ /// the result back to ARGB.
+ #[must_use]
+ pub fn lerp(self, other: Self, factor: f32) -> Self {
+ let a = self.rgba();
+ let b = other.rgba();
+ Self::from_rgba([
+ lerp(a[0], b[0], factor),
+ lerp(a[1], b[1], factor),
+ lerp(a[2], b[2], factor),
+ lerp(a[3], b[3], factor),
+ ])
+ }
+
+ /// Packs normalized RGBA components as ARGB.
+ #[must_use]
+ pub fn from_rgba(rgba: [f32; 4]) -> Self {
+ let channel = |value: f32| -> u32 { (value.clamp(0.0, 1.0) * 255.0).round() as u32 };
+ Self(
+ (channel(rgba[3]) << 24)
+ | (channel(rgba[0]) << 16)
+ | (channel(rgba[1]) << 8)
+ | channel(rgba[2]),
+ )
+ }
+}
+
+/// A length-prefixed string from a key.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AtmosphereString {
+ /// Original bytes without a terminating NUL.
+ pub raw: Vec<u8>,
+ /// Lossy UTF-8 view. Resource names in the original files are ASCII.
+ pub text: String,
+}
+
+impl AtmosphereString {
+ /// Returns the decoded text.
+ #[must_use]
+ pub fn as_str(&self) -> &str {
+ &self.text
+ }
+
+ /// Whether the length-prefixed value is empty.
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.raw.is_empty()
+ }
+}
+
+/// A key kind from the original atmosphere dispatcher.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AtmosphereKind {
+ /// Starts or updates the sun object.
+ SunStart,
+ /// Stops the sun object.
+ SunStop,
+ /// An unclassified state key with raw kind two.
+ State2,
+ /// Starts rain.
+ RainStart,
+ /// Stops rain.
+ RainStop,
+ /// Starts snow.
+ SnowStart,
+ /// Stops snow.
+ SnowStop,
+ /// An interpolated state key with raw kind seven.
+ State7,
+ /// Starts lightning.
+ LightningStart,
+ /// Stops lightning.
+ LightningStop,
+ /// A kind not present in the known original dispatcher.
+ Unknown(u32),
+}
+
+impl AtmosphereKind {
+ /// Converts the raw kind word without discarding unknown values.
+ #[must_use]
+ pub const fn from_raw(raw: u32) -> Self {
+ match raw {
+ 0 => Self::SunStart,
+ 1 => Self::SunStop,
+ 2 => Self::State2,
+ 3 => Self::RainStart,
+ 4 => Self::RainStop,
+ 5 => Self::SnowStart,
+ 6 => Self::SnowStop,
+ 7 => Self::State7,
+ 8 => Self::LightningStart,
+ 9 => Self::LightningStop,
+ other => Self::Unknown(other),
+ }
+ }
+
+ /// Returns the original kind word.
+ #[must_use]
+ pub const fn raw(self) -> u32 {
+ match self {
+ Self::SunStart => 0,
+ Self::SunStop => 1,
+ Self::State2 => 2,
+ Self::RainStart => 3,
+ Self::RainStop => 4,
+ Self::SnowStart => 5,
+ Self::SnowStop => 6,
+ Self::State7 => 7,
+ Self::LightningStart => 8,
+ Self::LightningStop => 9,
+ Self::Unknown(raw) => raw,
+ }
+ }
+}
+
+/// One exact key record from a version-five sky.ske.
+#[derive(Clone, Debug, PartialEq)]
+pub struct AtmosphereKey {
+ /// Key record version (three in the original files).
+ pub version: u32,
+ /// In-game date of this key.
+ pub date: SkeDate,
+ /// Decoded key kind.
+ pub kind: AtmosphereKind,
+ /// Original kind word.
+ pub kind_raw: u32,
+ /// Opaque key word following the kind.
+ pub raw: u32,
+ /// Four packed colors at the start of the key payload.
+ pub header_colors: [PackedColor; 4],
+ /// Two floating point values following header_colors.
+ pub header_values: [f32; 2],
+ /// Fifteen packed colors in the key palette.
+ pub colors: [PackedColor; KEY_COLOR_COUNT],
+ /// Six fixed length-prefixed strings.
+ pub strings: Vec<AtmosphereString>,
+ /// Four trailing floating point values.
+ pub parameters: [f32; 4],
+ /// Length-prefixed resource references.
+ pub references: Vec<AtmosphereString>,
+}
+
+impl AtmosphereKey {
+ /// Returns non-empty resource references in file order.
+ #[must_use]
+ pub fn resource_references(&self) -> Vec<&AtmosphereString> {
+ self.references
+ .iter()
+ .filter(|reference| !reference.is_empty())
+ .collect()
+ }
+}
+
+/// One atmosphere track.
+#[derive(Clone, Debug, PartialEq)]
+pub struct AtmosphereTrack {
+ /// Track version (one in the original files).
+ pub version: u32,
+ /// Track start date copied from disk.
+ pub start: SkeDate,
+ /// Track end/duration date copied from disk.
+ pub end: SkeDate,
+ /// Keys in file order.
+ pub keys: Vec<AtmosphereKey>,
+ /// Real seconds assigned to this track by the original constructor.
+ pub duration_seconds: u32,
+}
+
+impl AtmosphereTrack {
+ /// Returns a key's in-game time in seconds.
+ #[must_use]
+ pub fn key_seconds(&self, index: usize) -> Option<u32> {
+ self.keys.get(index).map(|key| key.date.seconds_of_day())
+ }
+}
+
+/// The final forty bytes of a sky.ske.
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+pub struct AtmosphereTrailer {
+ /// Trailer date copied from disk.
+ pub date: SkeDate,
+ /// Two opaque trailer words.
+ pub values: [u32; 2],
+}
+
+/// A timeline record emitted by TypedAtmosphere::timeline and
+/// TypedAtmosphere::events_between.
+#[derive(Clone, Debug, PartialEq)]
+pub struct AtmosphereEvent {
+ /// Track containing the key.
+ pub track_index: usize,
+ /// Key containing the event.
+ pub key_index: usize,
+ /// In-game time of the event in seconds.
+ pub day_seconds: f32,
+ /// Real time from the beginning of the repeating schedule.
+ pub real_seconds: f32,
+ /// Decoded event kind.
+ pub kind: AtmosphereKind,
+ /// Opaque key word.
+ pub raw: u32,
+ /// Non-empty resource references attached to the key.
+ pub resources: Vec<AtmosphereString>,
+}
+
+/// A sky sample with the thirteen color parameters used by the original
+/// CSky sampler.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkySample {
+ /// Colors zero through eleven: four header colors, then two reordered
+ /// groups of four palette colors.
+ pub colors: [PackedColor; SKY_COLOR_COUNT],
+ /// The two floating point values copied from the key header values.
+ pub values: [f32; 2],
+ /// The two remaining packed color parameters.
+ pub packed: [PackedColor; 2],
+}
+
+/// Birth payload generated by the native atmosphere dispatcher for a named
+/// celestial object.
+///
+/// The dispatcher does not serialize this payload in `sky.ske`. For a kind
+/// zero key it chooses the angle pair from the fixed string in slot zero and
+/// computes the lifetime from the following matching kind one key. Keeping
+/// the generated values beside the sample lets the renderer reproduce the
+/// native object without inventing a start time on its first active frame.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SunBirth {
+ /// Absolute real schedule time at which this interval began.
+ pub start_seconds: f32,
+ /// Real schedule duration until the matching stop key.
+ pub lifetime_seconds: f32,
+ /// Native birth angles in degrees.
+ pub initial_angles_degrees: [f32; 2],
+ /// WEA material row selected by the dispatcher.
+ pub material_row: usize,
+}
+
+/// A sampled sun parameter set. The same payload shape is used for the moon
+/// because the original dispatcher creates both from the same object class.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SunSample {
+ /// Whether the latest SunStart/SunStop lifecycle interval is active.
+ pub active: bool,
+ /// RGB color multiplied by the third trailing key parameter.
+ pub rgb: [f32; 3],
+ /// First two trailing key parameters, kept with neutral names.
+ pub values: [f32; 2],
+ /// Two packed parameters consumed by the original sun object.
+ pub packed: [PackedColor; 2],
+ /// The third trailing parameter used for RGB intensity.
+ pub intensity: f32,
+ /// Generated birth payload for the current named interval, if active.
+ pub birth: Option<SunBirth>,
+}
+
+/// A sampled moon parameter set with the native sun-object layout.
+pub type MoonSample = SunSample;
+
+/// A sampled precipitation or lightning state.
+#[derive(Clone, Debug, PartialEq)]
+pub struct WeatherSample {
+ /// Whether the corresponding start/stop state is active.
+ pub active: bool,
+ /// Interpolated fourth trailing key parameter.
+ pub intensity: f32,
+ /// Effective color. Active weather follows the original 80/255 RGB floor
+ /// and 150/255 alpha used by the particle objects.
+ pub color: [f32; 4],
+ /// Interpolated source color before the particle floor is applied.
+ pub raw_color: [f32; 4],
+ /// Resource references for the currently active interval.
+ pub resources: Vec<AtmosphereString>,
+}
+
+/// A complete atmosphere sample.
+#[derive(Clone, Debug, PartialEq)]
+pub struct AtmosphereFrame {
+ /// Requested real schedule time after finite-value sanitization.
+ pub real_seconds: f32,
+ /// Selected track.
+ pub track_index: usize,
+ /// Real seconds within the selected track.
+ pub track_seconds: f32,
+ /// In-game seconds within the selected day.
+ pub day_seconds: f32,
+ /// Interpolated sky parameters.
+ pub sky: SkySample,
+ /// Interpolated sun parameters.
+ pub sun: SunSample,
+ /// Interpolated moon parameters and its independent lifecycle.
+ pub moon: MoonSample,
+ /// Rain state.
+ pub rain: WeatherSample,
+ /// Snow state.
+ pub snow: WeatherSample,
+ /// Lightning state.
+ pub lightning: WeatherSample,
+}
+
+/// A parsed, repeating version-five atmosphere schedule.
+#[derive(Clone, Debug, PartialEq)]
+pub struct TypedAtmosphere {
+ /// Header marker.
+ pub marker: i32,
+ /// Header version.
+ pub version: u32,
+ /// Tracks in file order.
+ pub tracks: Vec<AtmosphereTrack>,
+ /// Trailer copied from disk.
+ pub trailer: AtmosphereTrailer,
+}
+
+impl TypedAtmosphere {
+ /// Parses a complete version-five sky.ske byte slice.
+ ///
+ /// The parser consumes every byte, bounds all declared counts and strings,
+ /// checks every floating point field, and preserves unknown kinds and
+ /// payload words.
+ ///
+ /// Returns an error for unsupported versions, malformed dates,
+ /// non-finite values, truncation, or trailing bytes.
+ pub fn parse(bytes: &[u8]) -> Result<Self, AtmosphereError> {
+ let mut cursor = Cursor::new(bytes);
+ let marker = cursor.read_i32_le()?;
+ let version = cursor.read_u32_le()?;
+ let track_count = cursor.read_u32_le()?;
+ if marker != SKE_HEADER_MARKER || version != SKE_VERSION {
+ return Err(AtmosphereError::InvalidHeader { marker, version });
+ }
+ checked_count(track_count, MAX_TRACKS)?;
+
+ let mut tracks = Vec::with_capacity(track_count as usize);
+ for track_index in 0..track_count as usize {
+ let track_version = cursor.read_u32_le()?;
+ if track_version != 1 {
+ return Err(AtmosphereError::InvalidTrackVersion {
+ track_index,
+ version: track_version,
+ });
+ }
+ let key_count = cursor.read_u32_le()?;
+ checked_count(key_count, MAX_KEYS)?;
+ let start = read_date(&mut cursor, "track start")?;
+ let end = read_date(&mut cursor, "track end")?;
+ let duration_seconds = end.real_duration_marker();
+ if duration_seconds == 0 {
+ return Err(AtmosphereError::InvalidDuration { track_index });
+ }
+ if key_count == 0 {
+ return Err(AtmosphereError::EmptyTrack { track_index });
+ }
+
+ let mut keys = Vec::with_capacity(key_count as usize);
+ let mut previous_seconds = None;
+ for key_index in 0..key_count as usize {
+ let key = read_key(&mut cursor, track_index, key_index)?;
+ let current_seconds = key.date.hour_minute_seconds();
+ if let Some(previous_seconds) = previous_seconds {
+ if current_seconds < previous_seconds {
+ return Err(AtmosphereError::NonMonotonicKey {
+ track_index,
+ key_index,
+ previous_seconds,
+ current_seconds,
+ });
+ }
+ }
+ previous_seconds = Some(current_seconds);
+ keys.push(key);
+ }
+ tracks.push(AtmosphereTrack {
+ version: track_version,
+ start,
+ end,
+ keys,
+ duration_seconds,
+ });
+ }
+
+ let trailer = AtmosphereTrailer {
+ date: read_date(&mut cursor, "trailer")?,
+ values: [cursor.read_u32_le()?, cursor.read_u32_le()?],
+ };
+ cursor.require_eof()?;
+ Ok(Self {
+ marker,
+ version,
+ tracks,
+ trailer,
+ })
+ }
+
+ /// Returns the duration of one complete repeating schedule.
+ #[must_use]
+ pub fn total_duration_seconds(&self) -> f32 {
+ self.tracks
+ .iter()
+ .map(|track| track.duration_seconds as f32)
+ .sum()
+ }
+
+ /// Returns the integer real-time offset selected by the trailer for the
+ /// initial atmosphere clock. The trailer's first word selects a track;
+ /// its hour/minute words are scaled by that track's duration and floored
+ /// exactly as in the original constructor.
+ #[must_use]
+ pub fn initial_offset_seconds(&self) -> u32 {
+ let track_index = usize::try_from(self.trailer.date.words[0]).ok();
+ let Some(track) = track_index.and_then(|index| self.tracks.get(index)) else {
+ return 0;
+ };
+ let scaled = u64::from(track.duration_seconds)
+ .saturating_mul(u64::from(self.trailer.date.hour_minute_seconds()))
+ / DAY_SECONDS_U64;
+ let track_offset = track_index
+ .map(|index| self.track_offset_seconds(index))
+ .unwrap_or(0);
+ u32::try_from(track_offset.saturating_add(scaled)).unwrap_or(u32::MAX)
+ }
+
+ /// Samples at real seconds from the beginning of the repeating schedule.
+ ///
+ /// Each track occupies its original real duration. Its in-game day is
+ /// then traversed from 00:00 through 24:00. Non-finite input is treated
+ /// as zero so a renderer cannot poison a frame with NaN coordinates.
+ #[must_use]
+ pub fn sample(&self, real_seconds: f32) -> AtmosphereFrame {
+ let real_seconds = if real_seconds.is_finite() {
+ real_seconds
+ } else {
+ 0.0
+ };
+ if self.tracks.is_empty() {
+ return empty_frame(real_seconds);
+ }
+ let total = self.total_duration_seconds();
+ let wrapped = real_seconds.rem_euclid(total);
+ let (track_index, track_seconds) = self.locate_track(wrapped);
+ let track = &self.tracks[track_index];
+ let day_seconds = track_seconds / track.duration_seconds as f32 * DAY_SECONDS;
+ let points = self.schedule_points();
+ self.sample_track(
+ real_seconds,
+ wrapped,
+ real_seconds,
+ track_index,
+ track_seconds,
+ day_seconds,
+ &points,
+ )
+ }
+
+ /// Samples a selected track directly in in-game seconds.
+ ///
+ /// This is useful for deterministic tests and tools that already have the
+ /// original game clock. The day wraps at 24 hours.
+ #[must_use]
+ pub fn sample_day(&self, track_index: usize, day_seconds: f32) -> AtmosphereFrame {
+ if self.tracks.is_empty() {
+ return empty_frame(0.0);
+ }
+ let track_index = track_index.min(self.tracks.len() - 1);
+ let track = &self.tracks[track_index];
+ let day_seconds = if day_seconds.is_finite() {
+ day_seconds.rem_euclid(DAY_SECONDS)
+ } else {
+ 0.0
+ };
+ let track_seconds = day_seconds / DAY_SECONDS * track.duration_seconds as f32;
+ let schedule_seconds = self.track_offset_seconds(track_index) as f32 + track_seconds;
+ let wrapped = schedule_seconds.rem_euclid(self.total_duration_seconds());
+ let points = self.schedule_points();
+ self.sample_track(
+ schedule_seconds,
+ wrapped,
+ schedule_seconds,
+ track_index,
+ track_seconds,
+ day_seconds,
+ &points,
+ )
+ }
+
+ /// Returns every key in file order with real schedule offsets.
+ #[must_use]
+ pub fn timeline(&self) -> Vec<AtmosphereEvent> {
+ self.schedule_points()
+ .into_iter()
+ .map(|point| {
+ event_for_key(
+ point.track_index,
+ point.key_index,
+ point.key,
+ point.real_seconds as f32,
+ )
+ })
+ .collect()
+ }
+
+ /// Returns timeline records crossed by (start, end], including schedule
+ /// wraparound and track boundaries.
+ #[must_use]
+ pub fn events_between(&self, start: f32, end: f32) -> Vec<AtmosphereEvent> {
+ if self.tracks.is_empty() || !start.is_finite() || !end.is_finite() || end <= start {
+ return Vec::new();
+ }
+ let total = self.total_duration_seconds();
+ if total <= 0.0 || !total.is_finite() {
+ return Vec::new();
+ }
+ let base = self.timeline();
+ if base.is_empty() {
+ return Vec::new();
+ }
+ let first_cycle = (start / total).floor() as i64 - 1;
+ let last_cycle = (end / total).ceil() as i64 + 1;
+ let mut out = Vec::new();
+ for cycle in first_cycle..=last_cycle {
+ let offset = cycle as f32 * total;
+ for event in &base {
+ let event_time = event.real_seconds + offset;
+ if event_time > start && event_time <= end {
+ let mut event = event.clone();
+ event.real_seconds = event_time;
+ out.push(event);
+ }
+ }
+ }
+ out.sort_by(|a, b| {
+ a.real_seconds
+ .partial_cmp(&b.real_seconds)
+ .unwrap_or(std::cmp::Ordering::Equal)
+ });
+ out
+ }
+
+ fn locate_track(&self, wrapped_seconds: f32) -> (usize, f32) {
+ let mut offset = 0.0;
+ for (index, track) in self.tracks.iter().enumerate() {
+ let duration = track.duration_seconds as f32;
+ if wrapped_seconds < offset + duration || index + 1 == self.tracks.len() {
+ return (index, (wrapped_seconds - offset).clamp(0.0, duration));
+ }
+ offset += duration;
+ }
+ (self.tracks.len() - 1, 0.0)
+ }
+
+ fn track_offset_seconds(&self, track_index: usize) -> u64 {
+ self.tracks
+ .iter()
+ .take(track_index)
+ .map(|track| u64::from(track.duration_seconds))
+ .sum()
+ }
+
+ fn schedule_points(&self) -> Vec<SchedulePoint<'_>> {
+ let mut points = Vec::new();
+ let mut track_offset = 0_u64;
+ for (track_index, track) in self.tracks.iter().enumerate() {
+ for (key_index, key) in track.keys.iter().enumerate() {
+ let key_offset = u64::from(track.duration_seconds)
+ .saturating_mul(u64::from(key.date.hour_minute_seconds()))
+ / DAY_SECONDS_U64;
+ points.push(SchedulePoint {
+ track_index,
+ key_index,
+ key,
+ real_seconds: track_offset.saturating_add(key_offset),
+ });
+ }
+ track_offset = track_offset.saturating_add(u64::from(track.duration_seconds));
+ }
+ points
+ }
+
+ fn sample_track(
+ &self,
+ real_seconds: f32,
+ sample_seconds: f32,
+ birth_seconds: f32,
+ track_index: usize,
+ track_seconds: f32,
+ day_seconds: f32,
+ points: &[SchedulePoint<'_>],
+ ) -> AtmosphereFrame {
+ let interpolation =
+ interpolate_schedule(points, sample_seconds, self.total_duration_seconds());
+ let sky = sky_sample(&interpolation);
+ let total_seconds = self.total_duration_seconds();
+ let sun_birth = celestial_birth(points, birth_seconds, total_seconds, b"sun");
+ let moon_birth = celestial_birth(points, birth_seconds, total_seconds, b"moon");
+ let sun = sun_sample(&interpolation, sun_birth);
+ let moon = sun_sample(&interpolation, moon_birth);
+ let rain = weather_sample(
+ points,
+ sample_seconds,
+ self.total_duration_seconds(),
+ &[AtmosphereKind::RainStart, AtmosphereKind::RainStop],
+ );
+ let snow = weather_sample(
+ points,
+ sample_seconds,
+ self.total_duration_seconds(),
+ &[AtmosphereKind::SnowStart, AtmosphereKind::SnowStop],
+ );
+ let lightning = weather_sample(
+ points,
+ sample_seconds,
+ self.total_duration_seconds(),
+ &[
+ AtmosphereKind::LightningStart,
+ AtmosphereKind::LightningStop,
+ ],
+ );
+ AtmosphereFrame {
+ real_seconds,
+ track_index,
+ track_seconds,
+ day_seconds,
+ sky,
+ sun,
+ moon,
+ rain,
+ snow,
+ lightning,
+ }
+ }
+}
+
+const DAY_SECONDS_U64: u64 = 86_400;
+
+#[derive(Clone, Copy, Debug)]
+struct SchedulePoint<'a> {
+ track_index: usize,
+ key_index: usize,
+ key: &'a AtmosphereKey,
+ real_seconds: u64,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct InterpolatedKey {
+ header_colors: [PackedColor; 4],
+ header_values: [f32; 2],
+ colors: [PackedColor; KEY_COLOR_COUNT],
+ parameters: [f32; 4],
+}
+
+fn read_date(cursor: &mut Cursor<'_>, context: &'static str) -> Result<SkeDate, AtmosphereError> {
+ let mut words = [0_u32; DATE_WORD_COUNT];
+ for word in &mut words {
+ *word = cursor.read_u32_le()?;
+ }
+ let date = SkeDate::from_words(words);
+ if date.hours() > 24
+ || date.minutes() > 59
+ || date.seconds() > 59
+ || (date.hours() == 24 && (date.minutes() != 0 || date.seconds() != 0))
+ {
+ return Err(AtmosphereError::InvalidDate {
+ context,
+ hours: date.hours(),
+ minutes: date.minutes(),
+ seconds: date.seconds(),
+ });
+ }
+ Ok(date)
+}
+
+fn read_key(
+ cursor: &mut Cursor<'_>,
+ track_index: usize,
+ key_index: usize,
+) -> Result<AtmosphereKey, AtmosphereError> {
+ let version = cursor.read_u32_le()?;
+ if version != 3 {
+ return Err(AtmosphereError::InvalidKeyVersion {
+ track_index,
+ key_index,
+ version,
+ });
+ }
+ let date = read_date(cursor, "key")?;
+ let kind_raw = cursor.read_u32_le()?;
+ let raw = cursor.read_u32_le()?;
+ let mut header_colors = [PackedColor::default(); 4];
+ for color in &mut header_colors {
+ *color = PackedColor(cursor.read_u32_le()?);
+ }
+ let header_values = [cursor.read_f32_le()?, cursor.read_f32_le()?];
+ let mut colors = [PackedColor::default(); KEY_COLOR_COUNT];
+ for color in &mut colors {
+ *color = PackedColor(cursor.read_u32_le()?);
+ }
+ let mut strings = Vec::with_capacity(KEY_STRING_COUNT);
+ for _ in 0..KEY_STRING_COUNT {
+ strings.push(read_string(cursor)?);
+ }
+ let parameters = [
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ cursor.read_f32_le()?,
+ ];
+ for (index, value) in header_values.iter().enumerate() {
+ if !value.is_finite() {
+ return Err(AtmosphereError::NonFiniteValue {
+ track_index,
+ key_index,
+ field: "header_values",
+ index,
+ });
+ }
+ }
+ for (index, value) in parameters.iter().enumerate() {
+ if !value.is_finite() {
+ return Err(AtmosphereError::NonFiniteValue {
+ track_index,
+ key_index,
+ field: "parameters",
+ index,
+ });
+ }
+ }
+ let reference_count = cursor.read_u32_le()?;
+ checked_count(reference_count, MAX_REFERENCES)?;
+ let mut references = Vec::with_capacity(reference_count as usize);
+ for _ in 0..reference_count {
+ references.push(read_string(cursor)?);
+ }
+ Ok(AtmosphereKey {
+ version,
+ date,
+ kind: AtmosphereKind::from_raw(kind_raw),
+ kind_raw,
+ raw,
+ header_colors,
+ header_values,
+ colors,
+ strings,
+ parameters,
+ references,
+ })
+}
+
+fn read_string(cursor: &mut Cursor<'_>) -> Result<AtmosphereString, AtmosphereError> {
+ let raw = read_lp_bytes(cursor, MAX_STRING_BYTES)?;
+ let text = String::from_utf8_lossy(&raw).into_owned();
+ Ok(AtmosphereString { raw, text })
+}
+
+fn checked_count(count: u32, limit: u32) -> Result<(), AtmosphereError> {
+ if count > limit {
+ return Err(AtmosphereError::Decode(DecodeError::LimitExceeded {
+ count: u64::from(count),
+ limit: u64::from(limit),
+ }));
+ }
+ Ok(())
+}
+
+fn lerp(a: f32, b: f32, factor: f32) -> f32 {
+ a + (b - a) * factor.clamp(0.0, 1.0)
+}
+
+fn interpolate_schedule(
+ points: &[SchedulePoint<'_>],
+ schedule_seconds: f32,
+ total_seconds: f32,
+) -> InterpolatedKey {
+ let (before, after, factor) = surrounding_points(points, schedule_seconds, total_seconds);
+ InterpolatedKey {
+ header_colors: std::array::from_fn(|index| {
+ before.key.header_colors[index].lerp(after.key.header_colors[index], factor)
+ }),
+ header_values: std::array::from_fn(|index| {
+ lerp(
+ before.key.header_values[index],
+ after.key.header_values[index],
+ factor,
+ )
+ }),
+ colors: std::array::from_fn(|index| {
+ before.key.colors[index].lerp(after.key.colors[index], factor)
+ }),
+ parameters: std::array::from_fn(|index| {
+ lerp(
+ before.key.parameters[index],
+ after.key.parameters[index],
+ factor,
+ )
+ }),
+ }
+}
+
+fn surrounding_points<'a>(
+ points: &'a [SchedulePoint<'a>],
+ schedule_seconds: f32,
+ total_seconds: f32,
+) -> (&'a SchedulePoint<'a>, &'a SchedulePoint<'a>, f32) {
+ debug_assert!(!points.is_empty());
+ if points.len() == 1 {
+ return (&points[0], &points[0], 0.0);
+ }
+ let sample_seconds = schedule_seconds.rem_euclid(total_seconds);
+
+ // The native previous-key helper is called with current time + 1 and a
+ // strict comparison, which makes an integer key at the current time the
+ // previous key. `<=` expresses that behavior without a frame epsilon.
+ let mut previous_index = None;
+ let mut next_index = None;
+ for (index, point) in points.iter().enumerate() {
+ let point_seconds = point.real_seconds as f32;
+ if point_seconds <= sample_seconds {
+ previous_index = Some(index);
+ }
+ if next_index.is_none() && point_seconds >= sample_seconds {
+ // Prefer the last key when several track boundaries share the
+ // same real timestamp. This selects the newly entered track.
+ let mut end = index;
+ while end + 1 < points.len() && points[end + 1].real_seconds == point.real_seconds {
+ end += 1;
+ }
+ next_index = Some(end);
+ }
+ }
+
+ let previous_index = previous_index.unwrap_or(points.len() - 1);
+ let next_index = next_index.unwrap_or(0);
+ let previous_wrap = if points[previous_index].real_seconds as f32 > sample_seconds {
+ total_seconds
+ } else {
+ 0.0
+ };
+ let next_wrap = if (points[next_index].real_seconds as f32) < sample_seconds {
+ total_seconds
+ } else {
+ 0.0
+ };
+ let previous_seconds = points[previous_index].real_seconds as f32 - previous_wrap;
+ let next_seconds = points[next_index].real_seconds as f32 + next_wrap;
+ let span = (next_seconds - previous_seconds).max(0.0);
+ let factor = if span > 0.0 {
+ ((sample_seconds - previous_seconds) / span).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ (&points[previous_index], &points[next_index], factor)
+}
+
+fn sky_sample(key: &InterpolatedKey) -> SkySample {
+ let colors = [
+ key.header_colors[1],
+ key.header_colors[2],
+ key.header_colors[0],
+ key.header_colors[3],
+ key.colors[0],
+ key.colors[3],
+ key.colors[1],
+ key.colors[2],
+ key.colors[4],
+ key.colors[7],
+ key.colors[5],
+ key.colors[6],
+ key.colors[8],
+ ];
+ SkySample {
+ colors,
+ values: key.header_values,
+ packed: [key.colors[11], key.colors[13]],
+ }
+}
+
+fn sun_sample(key: &InterpolatedKey, birth: Option<SunBirth>) -> SunSample {
+ let source = key.colors[12].rgba();
+ let intensity = key.parameters[2];
+ SunSample {
+ active: birth.is_some(),
+ rgb: [
+ source[0] * intensity,
+ source[1] * intensity,
+ source[2] * intensity,
+ ],
+ values: [key.parameters[0], key.parameters[1]],
+ packed: [key.colors[10], key.colors[14]],
+ intensity,
+ birth,
+ }
+}
+
+fn weather_sample(
+ points: &[SchedulePoint<'_>],
+ schedule_seconds: f32,
+ total_seconds: f32,
+ markers: &[AtmosphereKind; 2],
+) -> WeatherSample {
+ let (active, resources) = active_interval(points, schedule_seconds, total_seconds, markers);
+ let interpolation = interpolate_schedule(points, schedule_seconds, total_seconds);
+ let raw_color = interpolation.colors[12].rgba();
+ let intensity = if active {
+ interpolation.parameters[3].max(0.0)
+ } else {
+ 0.0
+ };
+ let color = if active {
+ [
+ raw_color[0].max(COLOR_MIN_COMPONENT),
+ raw_color[1].max(COLOR_MIN_COMPONENT),
+ raw_color[2].max(COLOR_MIN_COMPONENT),
+ WEATHER_ALPHA,
+ ]
+ } else {
+ [0.0; 4]
+ };
+ WeatherSample {
+ active,
+ intensity,
+ color,
+ raw_color,
+ resources,
+ }
+}
+
+fn active_interval(
+ points: &[SchedulePoint<'_>],
+ schedule_seconds: f32,
+ total_seconds: f32,
+ markers: &[AtmosphereKind; 2],
+) -> (bool, Vec<AtmosphereString>) {
+ let sample_seconds = schedule_seconds.rem_euclid(total_seconds);
+ let mut latest = None;
+ for point in points {
+ if point.key.kind != markers[0] && point.key.kind != markers[1] {
+ continue;
+ }
+ if point.real_seconds as f32 <= sample_seconds {
+ latest = Some(point);
+ }
+ }
+ let Some(latest) = latest.or_else(|| {
+ points
+ .iter()
+ .rev()
+ .find(|point| point.key.kind == markers[0] || point.key.kind == markers[1])
+ }) else {
+ return (false, Vec::new());
+ };
+ if latest.key.kind == markers[0] {
+ (
+ true,
+ latest
+ .key
+ .resource_references()
+ .into_iter()
+ .cloned()
+ .collect(),
+ )
+ } else {
+ (false, Vec::new())
+ }
+}
+
+/// Reconstructs the native birth payload for one named celestial object.
+///
+/// The dispatcher compares the first fixed key string with `"sun"`; every
+/// other celestial name uses the moon angle pair. It then scans forward for
+/// the next matching stop event and stores the real-time difference as the
+/// lifetime. Schedule points already contain the native integer floor, so no
+/// fractional day-time conversion is introduced here.
+fn celestial_birth(
+ points: &[SchedulePoint<'_>],
+ schedule_seconds: f32,
+ total_seconds: f32,
+ name: &[u8],
+) -> Option<SunBirth> {
+ if points.is_empty() || !total_seconds.is_finite() || total_seconds <= 0.0 {
+ return None;
+ }
+ let schedule_seconds = if schedule_seconds.is_finite() {
+ schedule_seconds
+ } else {
+ 0.0
+ };
+ let cycle = (schedule_seconds / total_seconds).floor();
+ let sample_absolute = cycle * total_seconds + schedule_seconds.rem_euclid(total_seconds);
+
+ let mut latest: Option<(f32, usize, AtmosphereKind)> = None;
+ for (index, point) in points.iter().enumerate() {
+ if !matches!(
+ point.key.kind,
+ AtmosphereKind::SunStart | AtmosphereKind::SunStop
+ ) || point
+ .key
+ .strings
+ .first()
+ .map(|string| string.raw.as_slice())
+ != Some(name)
+ {
+ continue;
+ }
+ let base = point.real_seconds as f32;
+ // Only the surrounding cycles can contain the latest event. Keeping
+ // all three also handles sample_day values at either schedule edge.
+ for cycle_delta in -1..=1 {
+ let time = base + (cycle + cycle_delta as f32) * total_seconds;
+ if time > sample_absolute {
+ continue;
+ }
+ let replace = latest
+ .map(|(old_time, old_index, _)| {
+ time > old_time || (time == old_time && index >= old_index)
+ })
+ .unwrap_or(true);
+ if replace {
+ latest = Some((time, index, point.key.kind));
+ }
+ }
+ }
+ let (start_seconds, start_index, kind) = latest?;
+ if kind != AtmosphereKind::SunStart {
+ return None;
+ }
+
+ let mut stop_seconds = None;
+ for point in points {
+ if point.key.kind != AtmosphereKind::SunStop
+ || point
+ .key
+ .strings
+ .first()
+ .map(|string| string.raw.as_slice())
+ != Some(name)
+ {
+ continue;
+ }
+ let base = point.real_seconds as f32;
+ for cycle_delta in -1..=2 {
+ let time = base + (cycle + cycle_delta as f32) * total_seconds;
+ if time <= start_seconds {
+ continue;
+ }
+ if stop_seconds.map(|old| time < old).unwrap_or(true) {
+ stop_seconds = Some(time);
+ }
+ }
+ }
+ let lifetime_seconds = stop_seconds
+ .map(|stop| stop - start_seconds)
+ .filter(|lifetime| lifetime.is_finite() && *lifetime > 0.0)?;
+ let (initial_angles_degrees, material_row) = if name == b"sun" {
+ ([90.0, 30.0], 3)
+ } else {
+ ([0.0, 50.0], 4)
+ };
+ let _ = start_index;
+ Some(SunBirth {
+ start_seconds,
+ lifetime_seconds,
+ initial_angles_degrees,
+ material_row,
+ })
+}
+
+fn event_for_key(
+ track_index: usize,
+ key_index: usize,
+ key: &AtmosphereKey,
+ real_seconds: f32,
+) -> AtmosphereEvent {
+ AtmosphereEvent {
+ track_index,
+ key_index,
+ day_seconds: key.date.hour_minute_seconds() as f32,
+ real_seconds,
+ kind: key.kind,
+ raw: key.raw,
+ resources: key.resource_references().into_iter().cloned().collect(),
+ }
+}
+
+fn empty_frame(real_seconds: f32) -> AtmosphereFrame {
+ AtmosphereFrame {
+ real_seconds,
+ track_index: 0,
+ track_seconds: 0.0,
+ day_seconds: 0.0,
+ sky: SkySample {
+ colors: [PackedColor::default(); SKY_COLOR_COUNT],
+ values: [0.0; 2],
+ packed: [PackedColor::default(); 2],
+ },
+ sun: SunSample {
+ active: false,
+ rgb: [0.0; 3],
+ values: [0.0; 2],
+ packed: [PackedColor::default(); 2],
+ intensity: 0.0,
+ birth: None,
+ },
+ moon: SunSample {
+ active: false,
+ rgb: [0.0; 3],
+ values: [0.0; 2],
+ packed: [PackedColor::default(); 2],
+ intensity: 0.0,
+ birth: None,
+ },
+ rain: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ snow: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ lightning: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::{Path, PathBuf};
+
+ fn date(hours: u32, minutes: u32, seconds: u32) -> [u32; DATE_WORD_COUNT] {
+ [0, 0, 0, hours, minutes, seconds, 0, 0]
+ }
+
+ fn lp(bytes: &[u8], out: &mut Vec<u8>) {
+ out.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
+ out.extend_from_slice(bytes);
+ }
+
+ fn key(
+ out: &mut Vec<u8>,
+ hours: u32,
+ minutes: u32,
+ kind: u32,
+ palette: [u32; KEY_COLOR_COUNT],
+ tail: [f32; 4],
+ references: &[&[u8]],
+ ) {
+ key_with_seconds(out, hours, minutes, 0, kind, palette, tail, references);
+ }
+
+ fn key_with_seconds(
+ out: &mut Vec<u8>,
+ hours: u32,
+ minutes: u32,
+ seconds: u32,
+ kind: u32,
+ palette: [u32; KEY_COLOR_COUNT],
+ tail: [f32; 4],
+ references: &[&[u8]],
+ ) {
+ out.extend_from_slice(&3_u32.to_le_bytes());
+ for word in date(hours, minutes, seconds) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ out.extend_from_slice(&kind.to_le_bytes());
+ out.extend_from_slice(&0xAABB_CCDD_u32.to_le_bytes());
+ for value in [0xFF11_2233_u32, 0xFF44_5566, 0xFF77_8899, 0xFFAA_BBCC] {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+ for value in [0.25_f32, 0.75_f32] {
+ out.extend_from_slice(&value.to_bits().to_le_bytes());
+ }
+ for value in palette {
+ out.extend_from_slice(&value.to_le_bytes());
+ }
+ for _ in 0..KEY_STRING_COUNT {
+ lp(b"", out);
+ }
+ for value in tail {
+ out.extend_from_slice(&value.to_bits().to_le_bytes());
+ }
+ out.extend_from_slice(&(references.len() as u32).to_le_bytes());
+ for reference in references {
+ lp(reference, out);
+ }
+ }
+
+ fn document(key_count: u32, keys: impl FnOnce(&mut Vec<u8>)) -> Vec<u8> {
+ let mut out = Vec::new();
+ out.extend_from_slice(&SKE_HEADER_MARKER.to_le_bytes());
+ out.extend_from_slice(&SKE_VERSION.to_le_bytes());
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out.extend_from_slice(&key_count.to_le_bytes());
+ for word in date(23, 59, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ for word in date(0, 15, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ keys(&mut out);
+ for word in date(1, 30, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out
+ }
+
+ fn two_track_document(palette: [u32; KEY_COLOR_COUNT]) -> Vec<u8> {
+ let mut out = Vec::new();
+ out.extend_from_slice(&SKE_HEADER_MARKER.to_le_bytes());
+ out.extend_from_slice(&SKE_VERSION.to_le_bytes());
+ out.extend_from_slice(&2_u32.to_le_bytes());
+
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out.extend_from_slice(&2_u32.to_le_bytes());
+ for word in date(23, 59, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ for word in date(0, 1, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ key_with_seconds(&mut out, 23, 0, 0, 7, palette, [0.0, 0.0, 0.0, 0.0], &[]);
+ key_with_seconds(&mut out, 23, 59, 59, 7, palette, [0.0, 0.0, 0.0, 0.0], &[]);
+
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out.extend_from_slice(&3_u32.to_le_bytes());
+ for word in date(23, 59, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ for word in date(0, 2, 0) {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ key_with_seconds(
+ &mut out,
+ 0,
+ 0,
+ 0,
+ 3,
+ palette,
+ [0.0, 0.0, 2.0, 1.0],
+ &[b"rain"],
+ );
+ key_with_seconds(
+ &mut out,
+ 6,
+ 0,
+ 0,
+ 7,
+ palette,
+ [0.0, 0.0, 2.0, 1.0],
+ &[b"future"],
+ );
+ key_with_seconds(
+ &mut out,
+ 12,
+ 0,
+ 0,
+ 4,
+ palette,
+ [0.0, 0.0, 0.0, 0.0],
+ &[b"stop"],
+ );
+
+ for word in [1_u32, 0, 0, 12, 0, 0, 0, 0] {
+ out.extend_from_slice(&word.to_le_bytes());
+ }
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out
+ }
+
+ #[test]
+ fn parses_exact_v5_layout_and_rejects_trailing_bytes() {
+ let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ let bytes = document(1, |out| {
+ key(out, 0, 0, 3, palette, [0.0, 0.0, 0.0, 1.0], &[b"rain"])
+ });
+ let parsed = TypedAtmosphere::parse(&bytes).expect("v5 parse");
+ assert_eq!(parsed.marker, -1);
+ assert_eq!(parsed.version, 5);
+ assert_eq!(parsed.tracks.len(), 1);
+ assert_eq!(parsed.tracks[0].duration_seconds, 900);
+ assert_eq!(parsed.tracks[0].keys[0].references[0].as_str(), "rain");
+ assert_eq!(parsed.trailer.date.hours(), 1);
+ assert!(matches!(
+ TypedAtmosphere::parse(&[bytes, vec![0]].concat()),
+ Err(AtmosphereError::Decode(DecodeError::TrailingBytes { .. }))
+ ));
+ }
+
+ #[test]
+ fn maps_original_sky_color_order_and_interpolates() {
+ let first_palette = std::array::from_fn(|i| 0xFF00_0000_u32 | i as u32);
+ let second_palette = std::array::from_fn(|i| 0xFF00_1000_u32 | (i as u32) << 4);
+ let bytes = document(2, |out| {
+ key(out, 0, 0, 7, first_palette, [0.0, 0.0, 0.0, 0.0], &[]);
+ key(out, 12, 0, 7, second_palette, [1.0, 2.0, 3.0, 1.0], &[]);
+ });
+ let parsed = TypedAtmosphere::parse(&bytes).expect("parse");
+ let start = parsed.sample_day(0, 0.0);
+ assert_eq!(start.sky.colors[0].raw(), 0xFF44_5566);
+ assert_eq!(start.sky.colors[1].raw(), 0xFF77_8899);
+ assert_eq!(start.sky.colors[2].raw(), 0xFF11_2233);
+ assert_eq!(start.sky.colors[4].raw(), first_palette[0]);
+ assert_eq!(start.sky.colors[5].raw(), first_palette[3]);
+ assert_eq!(start.sky.colors[8].raw(), first_palette[4]);
+ assert_eq!(start.sky.colors[12].raw(), first_palette[8]);
+ assert_eq!(start.sun.intensity, 0.0);
+ let middle = parsed.sample_day(0, 6.0 * 3_600.0);
+ assert!((middle.sun.intensity - 1.5).abs() < 0.01);
+ assert_eq!(middle.rain.intensity, 0.0);
+ }
+
+ #[test]
+ fn wraps_last_key_to_first_and_emits_events() {
+ let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ let bytes = document(2, |out| {
+ key(out, 6, 0, 3, palette, [0.0, 0.0, 0.0, 0.0], &[b"rain"]);
+ key(out, 12, 0, 4, palette, [1.0, 1.0, 1.0, 0.0], &[]);
+ });
+ let parsed = TypedAtmosphere::parse(&bytes).expect("parse");
+ let near_midnight = parsed.sample_day(0, 23.0 * 3_600.0);
+ assert!(!near_midnight.rain.active);
+ let events = parsed.events_between(0.0, 300.0);
+ assert_eq!(events.len(), 1);
+ assert_eq!(events[0].kind, AtmosphereKind::RainStart);
+ let wrapped = parsed.events_between(900.0, 1_300.0);
+ assert_eq!(wrapped.len(), 1);
+ assert_eq!(wrapped[0].kind, AtmosphereKind::RainStart);
+ }
+
+ #[test]
+ fn samples_across_tracks_uses_integer_key_times_and_trailer_offset() {
+ let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ let parsed = TypedAtmosphere::parse(&two_track_document(palette)).expect("parse");
+
+ assert_eq!(parsed.tracks[0].duration_seconds, 60);
+ assert_eq!(parsed.tracks[1].duration_seconds, 120);
+ assert_eq!(parsed.initial_offset_seconds(), 120);
+ assert_eq!(parsed.timeline()[1].real_seconds, 59.0);
+
+ let before_transition = parsed.sample(59.5);
+ assert_eq!(before_transition.track_index, 0);
+ assert!((before_transition.sun.intensity - 1.0).abs() < 0.01);
+
+ let active = parsed.sample_day(1, 6.0 * 3_600.0);
+ assert_eq!(active.real_seconds, 90.0);
+ assert!(active.rain.active);
+ assert_eq!(active.rain.resources.len(), 1);
+ assert_eq!(active.rain.resources[0].as_str(), "rain");
+ assert!(parsed
+ .events_between(59.0, 61.0)
+ .iter()
+ .any(|event| event.kind == AtmosphereKind::RainStart));
+ }
+
+ #[test]
+ fn repeating_schedule_shifts_celestial_birth_with_absolute_clock() {
+ let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ let bytes = document(2, |out| {
+ key(out, 0, 0, 0, palette, [0.0, 0.0, 1.0, 0.0], &[]);
+ key(out, 12, 0, 1, palette, [0.0, 0.0, 1.0, 0.0], &[]);
+ });
+ let mut parsed = TypedAtmosphere::parse(&bytes).expect("parse");
+ for (key, name) in parsed.tracks[0].keys.iter_mut().zip(["sun", "sun"]) {
+ key.strings[0] = AtmosphereString {
+ raw: name.as_bytes().to_vec(),
+ text: name.to_owned(),
+ };
+ }
+
+ let total = parsed.total_duration_seconds();
+ let first = parsed.sample(300.0);
+ let later = parsed.sample(300.0 + total);
+ let first_birth = first.sun.birth.expect("sun birth");
+ let later_birth = later.sun.birth.expect("wrapped sun birth");
+ assert!((later_birth.start_seconds - first_birth.start_seconds - total).abs() < 1.0e-5);
+ assert_eq!(first_birth.lifetime_seconds, later_birth.lifetime_seconds);
+ assert_eq!(
+ first_birth.initial_angles_degrees,
+ later_birth.initial_angles_degrees
+ );
+ assert_eq!(first_birth.material_row, later_birth.material_row);
+
+ let first_frame = crate::sky::sun_frame(
+ &first.sun,
+ 300.0,
+ crate::sky::SunConfig::from_birth(first_birth),
+ );
+ let later_frame = crate::sky::sun_frame(
+ &later.sun,
+ 300.0 + total,
+ crate::sky::SunConfig::from_birth(later_birth),
+ );
+ assert!((first_frame.age - later_frame.age).abs() < 1.0e-5);
+ for (first, later) in first_frame.direction.iter().zip(later_frame.direction) {
+ assert!((*first - later).abs() < 1.0e-5);
+ }
+ }
+
+ #[test]
+ fn rejects_non_finite_key_values() {
+ let palette = [0xFF00_0000_u32; KEY_COLOR_COUNT];
+ let mut bytes = document(1, |out| {
+ key(out, 0, 0, 7, palette, [f32::NAN, 0.0, 0.0, 0.0], &[])
+ });
+ assert!(matches!(
+ TypedAtmosphere::parse(&bytes),
+ Err(AtmosphereError::NonFiniteValue {
+ field: "parameters",
+ index: 0,
+ ..
+ })
+ ));
+ bytes[0] = 0;
+ assert!(matches!(
+ TypedAtmosphere::parse(&bytes),
+ Err(AtmosphereError::InvalidHeader { .. })
+ ));
+ }
+
+ #[test]
+ #[ignore = "requires the original GOG installation"]
+ fn parses_all_original_gog_sky_files() {
+ let root = std::env::var_os("PARKAN_GOG_ROOT")
+ .map(PathBuf::from)
+ .unwrap_or_else(|| PathBuf::from(r"C:\GOG Games\Parkan - Iron Strategy"));
+ let mission_root = root.join("MISSIONS");
+ let mut files = Vec::new();
+ collect_ske(&mission_root, &mut files);
+ files.sort();
+ assert_eq!(files.len(), 29);
+ for path in files {
+ let bytes = std::fs::read(&path).expect("read sky.ske");
+ let parsed = TypedAtmosphere::parse(&bytes)
+ .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
+ assert!(parsed.total_duration_seconds().is_finite());
+ assert!(!parsed.timeline().is_empty(), "{}", path.display());
+ let materials = std::fs::read(path.with_file_name("sky.wea"))
+ .ok()
+ .and_then(|bytes| crate::sky::SkyMaterials::parse(&bytes).ok())
+ .unwrap_or_default();
+ let mut sky = crate::sky::SkySystem::new(Default::default(), materials)
+ .unwrap_or_else(|error| panic!("{}: {error}", path.display()));
+ for second in [0.0, 0.5, 900.0, 86_399.0, 172_800.0] {
+ let frame = parsed.sample(second);
+ assert!(frame.sky.values.iter().all(|value| value.is_finite()));
+ assert!(frame.sun.rgb.iter().all(|value| value.is_finite()));
+ assert!(frame.rain.intensity.is_finite());
+ assert!(frame.snow.intensity.is_finite());
+ assert!(frame.lightning.intensity.is_finite());
+ let sky_frame = sky
+ .update(&frame, second)
+ .unwrap_or_else(|error| panic!("{} at {second}: {error}", path.display()));
+ assert_eq!(sky_frame.directional_lights.len(), 4);
+ let optics = sky_frame
+ .sun_optics(crate::sky::SunVisibility {
+ projected_sun: [640.0, 360.0],
+ viewport: [1280.0, 720.0],
+ view_forward: [0.0, 0.0, 1.0],
+ unoccluded: true,
+ })
+ .expect("native sky optics");
+ assert_eq!(optics.flares.len(), 12);
+ }
+ }
+ }
+
+ fn collect_ske(root: &Path, out: &mut Vec<PathBuf>) {
+ let Ok(entries) = std::fs::read_dir(root) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_ske(&path, out);
+ } else if path.file_name().is_some_and(|name| name == "sky.ske") {
+ out.push(path);
+ }
+ }
+ }
+}
diff --git a/crates/fparkan-fx/src/environment.rs b/crates/fparkan-fx/src/environment.rs
new file mode 100644
index 0000000..babbd0f
--- /dev/null
+++ b/crates/fparkan-fx/src/environment.rs
@@ -0,0 +1,2803 @@
+//! CPU-side precipitation and lightning output.
+//!
+//! The original game keeps atmospheric objects close to the observer. This
+//! module follows that ownership boundary: it advances precipitation particles
+//! and native lightning effect lifetimes and returns renderer/audio records
+//! for the current frame. It does not invent a scene graph or hide resource
+//! loading behind the simulation.
+
+use crate::sky::SkyMaterials;
+use crate::{FxDocument, FxError, FxOpcode};
+use std::sync::Arc;
+
+// Terrain.dll writes these velocity vectors into the shared precipitation
+// particle state on every update (CSnow at 10045E27 and CRain at 10046555).
+// Keep the values together so a renderer cannot accidentally substitute the
+// old placeholder speeds.
+pub const RAIN_VELOCITY: [f32; 3] = [0.5, 0.0, -60.0];
+pub const SNOW_VELOCITY: [f32; 3] = [0.5, 0.0, -4.0];
+
+/// Near plane used by Terrain's precipitation volume constructor at
+/// `100444C0`.
+pub const PRECIPITATION_NEAR: f32 = 2.0;
+/// Far plane used by Terrain's precipitation volume constructor at
+/// `100444C0`.
+pub const PRECIPITATION_FAR: f32 = 50.0;
+/// Half horizontal angle used by Terrain's precipitation volume constructor.
+pub const PRECIPITATION_HALF_ANGLE_X: f32 = 1.3 * 0.5;
+/// Half vertical angle used by Terrain's precipitation volume constructor.
+pub const PRECIPITATION_HALF_ANGLE_Y: f32 = 0.975 * 0.5;
+/// Native rain size factor (`Terrain.dll!65E30`).
+pub const RAIN_SIZE_FACTOR: f32 = 0.0065;
+/// Native snow size factor (`Terrain.dll!65DE8`).
+pub const SNOW_SIZE_FACTOR: f32 = 0.0195;
+/// Native snow minimum depth fade (`Terrain.dll!65D98`).
+pub const SNOW_MIN_DEPTH_FADE: f32 = 0.1;
+/// Native precipitation particle-count multiplier (`Terrain.dll!65DE4`).
+pub const PARTICLE_COUNT_SCALE: f32 = 1_000.0;
+
+/// Native precipitation spawn volume. The fields mirror the descriptor
+/// built by Terrain at `100444C0`; particles are sampled from this box by
+/// `100445B0` using three 16-bit random values divided by 32768.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PrecipitationVolume {
+ /// Local-space lower corner `[near, -tan(angle_x)*far, -tan(angle_y)*far]`.
+ pub origin: [f32; 3],
+ /// Local-space extent `[far-near, 2*tan(angle_x)*far, 2*tan(angle_y)*far]`.
+ pub extent: [f32; 3],
+ /// Reciprocals stored by the native descriptor for containment tests.
+ pub reciprocal_extent: [f32; 3],
+}
+
+impl PrecipitationVolume {
+ /// Reconstructs the descriptor constants used by Terrain.
+ #[must_use]
+ pub fn native() -> Self {
+ let width = PRECIPITATION_HALF_ANGLE_X.tan() * PRECIPITATION_FAR;
+ let height = PRECIPITATION_HALF_ANGLE_Y.tan() * PRECIPITATION_FAR;
+ let extent = [
+ PRECIPITATION_FAR - PRECIPITATION_NEAR,
+ 2.0 * width,
+ 2.0 * height,
+ ];
+ Self {
+ origin: [PRECIPITATION_NEAR, -width, -height],
+ extent,
+ reciprocal_extent: extent.map(|value| 1.0 / value),
+ }
+ }
+
+ /// Rebuilds the same depth box for a camera projection. The native
+ /// emitter uses the camera FOV and aspect ratio for this box while the
+ /// reference descriptor above keeps the original 1.3 by 0.975 angles.
+ #[must_use]
+ pub fn for_camera(self, camera: Camera) -> Self {
+ let near = self.origin[0];
+ let far = near + self.extent[0];
+ let half_y = (camera.vertical_fov * 0.5).tan() * far;
+ let half_x = (camera.vertical_fov * 0.5).tan() * camera.aspect_ratio * far;
+ let extent = [far - near, 2.0 * half_x, 2.0 * half_y];
+ Self {
+ origin: [near, -half_x, -half_y],
+ extent,
+ reciprocal_extent: extent.map(|value| 1.0 / value),
+ }
+ }
+
+ /// Returns an axis-aligned world-space bound around the camera-local
+ /// descriptor. The native emitter transforms each sampled local point by
+ /// the camera basis before handing it to the particle object.
+ #[must_use]
+ pub fn bounds(self, camera: Camera) -> PrecipitationBounds {
+ let corners: [[f32; 3]; 8] = std::array::from_fn(|index| {
+ let local = [
+ self.origin[0] + if index & 1 == 0 { 0.0 } else { self.extent[0] },
+ self.origin[1] + if index & 2 == 0 { 0.0 } else { self.extent[1] },
+ self.origin[2] + if index & 4 == 0 { 0.0 } else { self.extent[2] },
+ ];
+ camera.world_from_local(local)
+ });
+ let mut min = [f32::INFINITY; 3];
+ let mut max = [f32::NEG_INFINITY; 3];
+ for corner in corners {
+ for axis in 0..3 {
+ min[axis] = min[axis].min(corner[axis]);
+ max[axis] = max[axis].max(corner[axis]);
+ }
+ }
+ PrecipitationBounds { min, max }
+ }
+
+ /// Computes the native reference particle count for a weather intensity.
+ /// The camera-aware form below applies the current/reference volume ratio.
+ #[must_use]
+ pub fn particle_count(self, intensity: f32) -> usize {
+ self.particle_count_for(self, 1.0, intensity)
+ }
+
+ /// Computes the native count from the current/reference volume ratio and
+ /// the object's density scalar. The original uses FISTP's nearest-even
+ /// conversion after multiplying by 1000.
+ #[must_use]
+ pub fn particle_count_for(self, current: Self, density: f32, intensity: f32) -> usize {
+ if !density.is_finite() || !intensity.is_finite() || density <= 0.0 || intensity <= 0.0 {
+ return 0;
+ }
+ let reference_volume = volume_product(self.extent);
+ let current_volume = volume_product(current.extent);
+ if !reference_volume.is_finite() || reference_volume <= 0.0 || !current_volume.is_finite() {
+ return 0;
+ }
+ let ratio = (current_volume / reference_volume).clamp(0.0, 1.0);
+ let value = f64::from(ratio)
+ * f64::from(density)
+ * f64::from(intensity)
+ * f64::from(PARTICLE_COUNT_SCALE);
+ if !value.is_finite() || value <= 0.0 {
+ 0
+ } else if value >= usize::MAX as f64 {
+ usize::MAX
+ } else {
+ value.round_ties_even() as usize
+ }
+ }
+}
+
+impl Default for PrecipitationVolume {
+ fn default() -> Self {
+ Self::native()
+ }
+}
+
+/// World-space bounds for one observer-local precipitation volume.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PrecipitationBounds {
+ /// Inclusive lower corner.
+ pub min: [f32; 3],
+ /// Inclusive upper corner.
+ pub max: [f32; 3],
+}
+
+impl PrecipitationBounds {
+ /// Checks whether a world-space point lies inside this bound.
+ #[must_use]
+ pub fn contains(self, point: [f32; 3]) -> bool {
+ point
+ .into_iter()
+ .zip(self.min.into_iter().zip(self.max))
+ .all(|(value, (min, max))| value >= min && value <= max)
+ }
+}
+
+// CLightning::Update (Terrain.dll!10043870) uses a DWORD game tick.
+const LIGHTNING_COOLDOWN_MS: u64 = 6_000;
+const LIGHTNING_DELAY_MS: f64 = 60_000.0;
+const LIGHTNING_INTENSITY_CAP: f32 = 0.95;
+// CLightning's effect descriptor stores a 600-unit vertical quad and
+// CLightning::CreateEffect (100439D0) adds half of that length to the
+// sampled Z coordinate before creating the visual effect.
+const LIGHTNING_NATIVE_WIDTH: f32 = 40.0;
+const LIGHTNING_NATIVE_LENGTH: f32 = 600.0;
+const LIGHTNING_NATIVE_ORIGIN_OFFSET: f32 = 300.0;
+/// Effective CLightning descriptor used by the renderer-facing quad.
+pub const LIGHTNING_QUAD_DIMENSIONS: [f32; 3] = [
+ LIGHTNING_NATIVE_WIDTH,
+ LIGHTNING_NATIVE_WIDTH,
+ LIGHTNING_NATIVE_LENGTH,
+];
+const NATIVE_RANDOM_SCALE: f32 = 1.0 / 32_768.0;
+
+/// The CRT linear congruential generator used by Terrain.dll and Effect.dll.
+/// Both modules read and update the same process-wide state, so the
+/// environment system intentionally owns one stream and passes it through
+/// each emitter in update order.
+#[derive(Clone, Copy, Debug)]
+struct NativeRng {
+ state: u32,
+}
+
+impl NativeRng {
+ fn new(seed: u64) -> Self {
+ Self { state: seed as u32 }
+ }
+
+ fn next_u15(&mut self) -> u16 {
+ self.state = self.state.wrapping_mul(0x343F_D).wrapping_add(0x269E_C);
+ ((self.state >> 16) & 0x7FFF) as u16
+ }
+
+ fn next_unit(&mut self) -> f32 {
+ f32::from(self.next_u15()) * NATIVE_RANDOM_SCALE
+ }
+}
+
+/// The two precipitation classes present in the original environment data.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum PrecipitationKind {
+ /// Rain drops using the `RAIN_DROP` material.
+ Rain,
+ /// Snow flakes using the `SNOWFLAKE` material.
+ Snow,
+}
+
+/// A sampled weather state supplied by the atmosphere schedule or a caller.
+#[derive(Clone, Debug, PartialEq)]
+pub struct WeatherState {
+ /// Whether this weather object is currently running.
+ pub active: bool,
+ /// Non-negative emission strength. Values above one are capped for the
+ /// bounded CPU emitter.
+ pub intensity: f32,
+ /// RGBA color passed to the renderer.
+ pub color: [f32; 4],
+ /// Resource names attached to the active interval in `sky.ske`.
+ /// Rain uses the first name as its loop sound; lightning uses the FXID
+ /// selected by the caller. Keeping these names here avoids silently
+ /// substituting a global weather asset.
+ pub resources: Vec<String>,
+ /// Material row resolved from the current `sky.wea` table.
+ pub material: Option<String>,
+}
+
+impl WeatherState {
+ /// Constructs a weather state.
+ #[must_use]
+ pub fn new(active: bool, intensity: f32, color: [f32; 4]) -> Self {
+ Self {
+ active,
+ intensity,
+ color,
+ resources: Vec::new(),
+ material: None,
+ }
+ }
+
+ /// Constructs a weather state with resource names from the atmosphere
+ /// key that started its active interval.
+ #[must_use]
+ pub fn with_resources<I, S>(active: bool, intensity: f32, color: [f32; 4], resources: I) -> Self
+ where
+ I: IntoIterator<Item = S>,
+ S: Into<String>,
+ {
+ Self {
+ active,
+ intensity,
+ color,
+ resources: resources.into_iter().map(Into::into).collect(),
+ material: None,
+ }
+ }
+
+ /// Constructs an inactive weather state.
+ #[must_use]
+ pub fn inactive() -> Self {
+ Self::new(false, 0.0, [0.0; 4])
+ }
+
+ fn sanitized(self) -> Self {
+ let color = self.color.map(|component| {
+ if component.is_finite() {
+ component.clamp(0.0, 1.0)
+ } else {
+ 0.0
+ }
+ });
+ let intensity = if self.intensity.is_finite() {
+ self.intensity.clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ Self {
+ active: self.active,
+ intensity,
+ color,
+ resources: self
+ .resources
+ .into_iter()
+ .filter(|resource| !resource.is_empty())
+ .collect(),
+ material: self.material.filter(|material| !material.is_empty()),
+ }
+ }
+}
+
+impl Default for WeatherState {
+ fn default() -> Self {
+ Self::inactive()
+ }
+}
+
+/// Weather input for one environment update.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct WeatherInput {
+ /// Rain state.
+ pub rain: WeatherState,
+ /// Snow state.
+ pub snow: WeatherState,
+ /// Lightning state.
+ pub lightning: WeatherState,
+}
+
+impl WeatherInput {
+ /// Constructs a clear-weather input.
+ #[must_use]
+ pub fn clear() -> Self {
+ Self {
+ rain: WeatherState::inactive(),
+ snow: WeatherState::inactive(),
+ lightning: WeatherState::inactive(),
+ }
+ }
+
+ /// Converts a sampled `sky.ske` frame into environment input.
+ #[must_use]
+ pub fn from_atmosphere(frame: &crate::atmosphere::AtmosphereFrame) -> Self {
+ Self {
+ rain: from_atmosphere_weather(&frame.rain),
+ snow: from_atmosphere_weather(&frame.snow),
+ lightning: from_atmosphere_weather(&frame.lightning),
+ }
+ }
+
+ /// Converts a sampled atmosphere frame and the current `sky.wea` table
+ /// into environment input. Rows seven and eight are the native snow and
+ /// rain material slots; the table is resolved at runtime so missions can
+ /// replace their names.
+ #[must_use]
+ pub fn from_atmosphere_with_materials(
+ frame: &crate::atmosphere::AtmosphereFrame,
+ materials: &SkyMaterials,
+ ) -> Self {
+ let mut input = Self::from_atmosphere(frame);
+ input.rain.material = materials.material_name(8).map(str::to_owned);
+ input.snow.material = materials.material_name(7).map(str::to_owned);
+ input
+ }
+
+ fn sanitized(self) -> Self {
+ Self {
+ rain: self.rain.sanitized(),
+ snow: self.snow.sanitized(),
+ lightning: self.lightning.sanitized(),
+ }
+ }
+}
+
+/// Camera snapshot used to keep finite weather geometry around the observer.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct Camera {
+ /// World-space camera position. The renderer uses +Z as up.
+ pub position: [f32; 3],
+ /// Unit world-space direction of camera-local depth.
+ pub forward: [f32; 3],
+ /// Unit world-space direction of camera-local horizontal offset.
+ pub right: [f32; 3],
+ /// Unit world-space direction of camera-local vertical offset.
+ pub up: [f32; 3],
+ /// Vertical camera field of view in radians.
+ pub vertical_fov: f32,
+ /// Horizontal-to-vertical projection aspect ratio.
+ pub aspect_ratio: f32,
+ /// Viewport width and height in pixels used by screen-space particle quads.
+ pub viewport: [f32; 2],
+}
+
+impl Camera {
+ /// Creates a camera snapshot.
+ #[must_use]
+ pub const fn new(position: [f32; 3]) -> Self {
+ Self {
+ position,
+ forward: [1.0, 0.0, 0.0],
+ right: [0.0, 1.0, 0.0],
+ up: [0.0, 0.0, 1.0],
+ vertical_fov: 0.975,
+ // tan(0.65) / tan(0.4875), the original reference box aspect.
+ aspect_ratio: 1.4338578,
+ viewport: [640.0, 480.0],
+ }
+ }
+
+ /// Creates a camera snapshot with the orientation used by the native
+ /// camera-local precipitation transform.
+ #[must_use]
+ pub const fn with_basis(
+ position: [f32; 3],
+ forward: [f32; 3],
+ right: [f32; 3],
+ up: [f32; 3],
+ ) -> Self {
+ Self {
+ position,
+ forward,
+ right,
+ up,
+ vertical_fov: 0.975,
+ aspect_ratio: 1.4338578,
+ viewport: [640.0, 480.0],
+ }
+ }
+
+ /// Creates a camera snapshot with orientation and projection data.
+ #[must_use]
+ pub const fn with_projection(
+ position: [f32; 3],
+ forward: [f32; 3],
+ right: [f32; 3],
+ up: [f32; 3],
+ vertical_fov: f32,
+ aspect_ratio: f32,
+ ) -> Self {
+ Self {
+ position,
+ forward,
+ right,
+ up,
+ vertical_fov,
+ aspect_ratio,
+ viewport: [640.0, 480.0],
+ }
+ }
+
+ /// Sets the pixel viewport used by the projected particle geometry.
+ #[must_use]
+ pub const fn with_viewport(self, viewport: [f32; 2]) -> Self {
+ Self { viewport, ..self }
+ }
+
+ /// Returns the horizontal field of view represented by this projection.
+ #[must_use]
+ pub fn horizontal_fov(self) -> f32 {
+ 2.0 * ((self.vertical_fov * 0.5).tan() * self.aspect_ratio).atan()
+ }
+
+ /// Returns Terrain's precipitation size query: viewport width divided by
+ /// the camera's horizontal FOV in radians.
+ #[must_use]
+ pub fn precipitation_size_scalar(self) -> f32 {
+ let fov = self.horizontal_fov();
+ if fov.is_finite() && fov > 0.0 {
+ self.viewport[0] / fov
+ } else {
+ 0.0
+ }
+ }
+
+ /// Transforms a camera-local point into world space.
+ #[must_use]
+ pub fn world_from_local(self, local: [f32; 3]) -> [f32; 3] {
+ [
+ self.position[0]
+ + self.forward[0] * local[0]
+ + self.right[0] * local[1]
+ + self.up[0] * local[2],
+ self.position[1]
+ + self.forward[1] * local[0]
+ + self.right[1] * local[1]
+ + self.up[1] * local[2],
+ self.position[2]
+ + self.forward[2] * local[0]
+ + self.right[2] * local[1]
+ + self.up[2] * local[2],
+ ]
+ }
+
+ /// Transforms a camera-local vector into world space.
+ #[must_use]
+ pub fn world_vector(self, local: [f32; 3]) -> [f32; 3] {
+ [
+ self.forward[0] * local[0] + self.right[0] * local[1] + self.up[0] * local[2],
+ self.forward[1] * local[0] + self.right[1] * local[1] + self.up[1] * local[2],
+ self.forward[2] * local[0] + self.right[2] * local[1] + self.up[2] * local[2],
+ ]
+ }
+
+ /// Transforms a world-space point into camera-local coordinates.
+ #[must_use]
+ pub fn local_from_world(self, world: [f32; 3]) -> [f32; 3] {
+ let delta = [
+ world[0] - self.position[0],
+ world[1] - self.position[1],
+ world[2] - self.position[2],
+ ];
+ [
+ dot3(self.forward, delta),
+ dot3(self.right, delta),
+ dot3(self.up, delta),
+ ]
+ }
+
+ fn sanitized(self) -> Self {
+ let mut camera = self;
+ camera.position = camera.position.map(|component| {
+ if component.is_finite() {
+ component
+ } else {
+ 0.0
+ }
+ });
+ camera.forward = finite_unit(camera.forward).unwrap_or([1.0, 0.0, 0.0]);
+ camera.right = finite_unit(camera.right).unwrap_or([0.0, 1.0, 0.0]);
+ camera.up = finite_unit(camera.up).unwrap_or([0.0, 0.0, 1.0]);
+ if !camera.vertical_fov.is_finite() || camera.vertical_fov <= 0.0 {
+ camera.vertical_fov = 0.975;
+ }
+ camera.vertical_fov = camera.vertical_fov.min(std::f32::consts::PI - 0.001);
+ if !camera.aspect_ratio.is_finite() || camera.aspect_ratio <= 0.0 {
+ camera.aspect_ratio = 1.4338578;
+ }
+ for value in &mut camera.viewport {
+ if !value.is_finite() || *value <= 0.0 {
+ *value = 1.0;
+ }
+ }
+ camera
+ }
+}
+
+impl Default for Camera {
+ fn default() -> Self {
+ Self::new([0.0; 3])
+ }
+}
+
+/// World-space range used by the native lightning position sampler.
+///
+/// `CLightning::Update` samples X from `[x_min, x_max]`, Y from
+/// `[y_min, y_max]`, and copies `z` unchanged. The atmosphere manager owns
+/// these values in the original engine, so the default environment system
+/// uses the camera position as a zero-size range until a caller supplies the
+/// current bounds.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct LightningBounds {
+ /// Lower and upper X bounds in world coordinates.
+ pub x: [f32; 2],
+ /// Lower and upper Y bounds in world coordinates.
+ pub y: [f32; 2],
+ /// Z coordinate copied to the effect origin.
+ pub z: f32,
+}
+
+impl LightningBounds {
+ /// Creates a world-space lightning sampling range.
+ #[must_use]
+ pub const fn new(x_min: f32, x_max: f32, y_min: f32, y_max: f32, z: f32) -> Self {
+ Self {
+ x: [x_min, x_max],
+ y: [y_min, y_max],
+ z,
+ }
+ }
+
+ fn sanitized(self) -> Option<Self> {
+ if self
+ .x
+ .into_iter()
+ .chain(self.y)
+ .chain([self.z])
+ .all(f32::is_finite)
+ {
+ Some(self)
+ } else {
+ None
+ }
+ }
+}
+
+/// Native point-light state produced by an Effect opcode 1 command.
+///
+/// Effect.dll's opcode 1 handler (`1000F6E0`) updates a LightManager record.
+/// The A and B groups are the submitted position and direction, C is the
+/// four-component HDR light color, the scalar pair at body offsets 108 and
+/// 112 is the light range, and body offsets 120, 124, and 128 are the native
+/// attenuation coefficients. These values do not describe lightning mesh
+/// geometry or texture coordinates.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct PointLightFrame {
+ /// Opcode 1 type word. `env_lightning` uses type 7.
+ pub effect_type: u32,
+ /// Effect age supplied to the native evaluator, in seconds.
+ pub elapsed_seconds: f32,
+ /// Interpolation parameter between the opcode 1 start/end times.
+ pub normalized_time: f32,
+ /// FXID lifetime in seconds.
+ pub duration_seconds: f32,
+ /// World-space LightManager position.
+ pub position: [f32; 3],
+ /// World-space direction submitted alongside the point-light record.
+ pub direction: [f32; 3],
+ /// HDR RGBA color submitted to LightManager slot +0C.
+ pub color: [f32; 4],
+ /// Light range submitted to LightManager slot +10.
+ pub range: f32,
+ /// Native attenuation coefficients `(a0, a1, a2)`.
+ pub attenuation: [f32; 3],
+ /// The scalar at body offset 116. Its native role is an effect factor,
+ /// not alpha or a UV parameter, so it is preserved without reinterpretation.
+ pub effect_factor: f32,
+ /// Whether the opcode 1 command is active at `elapsed_seconds`.
+ pub active: bool,
+}
+
+/// Native quad produced by the opcode 3 lightning visual.
+///
+/// CLightning creates this effect after moving the sampled position 300
+/// units upward. The owner descriptor is `[40, 40, 600]`; opcode 1 does not
+/// supply the quad vertices or texture coordinates.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct LightningQuadFrame {
+ /// Four world-space vertices in native perimeter order: bottom plus
+ /// horizontal, bottom minus horizontal, top minus horizontal, top plus
+ /// horizontal.
+ pub vertices: [[f32; 3]; 4],
+ /// Native UVs in the same order as `vertices`.
+ pub uv: [[f32; 2]; 4],
+ /// CLightning's width, height, and effective streak length.
+ pub dimensions: [f32; 3],
+ /// Opcode 3 animated local position from the native start/delta channels.
+ pub local_offset: [f32; 3],
+ /// Opcode 3 animated local scale from the native start/end channels.
+ pub local_scale: [f32; 3],
+ /// Native opcode 3 alpha channel. `env_lightning` fades from one to zero
+ /// over its active interval.
+ pub opacity: f32,
+ /// Effect age supplied to the native opcode 3 evaluator, in seconds.
+ pub elapsed_seconds: f32,
+ /// Interpolation parameter between the opcode 3 start/end times.
+ pub normalized_time: f32,
+ /// FXID lifetime in seconds.
+ pub duration_seconds: f32,
+ /// Whether the opcode 3 command is active at `elapsed_seconds`.
+ pub active: bool,
+}
+
+/// Screen-space quad data produced by the native rain/snow geometry paths.
+/// `head`, `tail`, and `corners` are normalized device coordinates. The native
+/// size remains in viewport pixels so a renderer can apply the same query
+/// without mixing pixel and NDC units.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ScreenBillboard {
+ /// World-space head point.
+ pub world_head: [f32; 3],
+ /// World-space tail point.
+ pub world_tail: [f32; 3],
+ /// Projected head point in NDC.
+ pub head: [f32; 2],
+ /// Projected tail point in NDC. Snow uses the same point as `head`.
+ pub tail: [f32; 2],
+ /// Four NDC corners in tail-left, tail-right, head-left, head-right order.
+ pub corners: [[f32; 2]; 4],
+ /// Texture coordinates for the four corners.
+ pub uv: [[f32; 2]; 4],
+ /// Native half-size argument in viewport pixels.
+ pub half_size: f32,
+ /// Camera-local depth of the projected head.
+ pub head_depth: f32,
+ /// Camera-local depth of the projected tail.
+ pub tail_depth: f32,
+}
+
+/// Geometry and light output decoded from the FXID.
+#[derive(Clone, Debug, PartialEq)]
+pub struct LightningBolt {
+ /// Material archive and name resolved from opcode 3.
+ pub material: EffectResource,
+ /// World-space sampled position supplied to CLightning.
+ pub position: [f32; 3],
+ /// Opcode 3 world-space quad and material state.
+ pub quad: LightningQuadFrame,
+ /// Current weather intensity used by the event scheduler.
+ pub intensity: f32,
+ /// Effect age in seconds from its FXID start time.
+ pub age_seconds: f32,
+ /// FXID lifetime in seconds used by the native evaluator.
+ pub duration_seconds: f32,
+}
+
+/// Native environment primitives consumed by the renderer.
+#[derive(Clone, Debug, PartialEq)]
+pub enum EnvironmentPrimitive {
+ /// A rain drop or snow flake billboard instance.
+ Particle {
+ /// Precipitation class.
+ kind: PrecipitationKind,
+ /// World-space position.
+ position: [f32; 3],
+ /// World-space velocity.
+ velocity: [f32; 3],
+ /// Native size argument after applying the rain/snow size factor.
+ size: f32,
+ /// RGBA color.
+ color: [f32; 4],
+ /// Material lookup name from `sky.wea`.
+ material: String,
+ /// Native projected quad geometry in viewport pixels.
+ screen: ScreenBillboard,
+ },
+ /// A lightning bolt with opcode 3 quad data.
+ Lightning(LightningBolt),
+}
+
+/// Action applied to a sound resource by the caller's audio backend.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SoundAction {
+ /// Start a loop at the event's volume.
+ StartLoop,
+ /// Change the volume of an already-running loop.
+ SetLoopVolume,
+ /// Stop a previously started loop.
+ StopLoop,
+ /// Play one sample once.
+ OneShot,
+}
+
+/// One sound event generated by the environment simulation.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SoundEvent {
+ /// Archive to resolve through the resource repository. Empty means the
+ /// audio owner should use its mission-configured weather library.
+ pub archive: String,
+ /// Entry name in `archive`.
+ pub name: String,
+ /// World-space emitter position.
+ pub position: [f32; 3],
+ /// Linear volume multiplier.
+ pub volume: f32,
+ /// Native minimum distance in world units. A non-positive value disables
+ /// the native range clamp for callers such as atmosphere loops.
+ pub min_distance: f32,
+ /// Native maximum distance in world units. A non-positive or non-finite
+ /// value disables the native range clamp.
+ pub max_distance: f32,
+ /// Native playback-frequency ratio evaluated at event creation.
+ pub frequency_ratio: f32,
+ /// Playback action.
+ pub action: SoundAction,
+}
+
+/// Native opcode-2 sound parameters evaluated for one effect age.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SoundParameters {
+ /// DirectSound minimum distance in world units.
+ pub min_distance: f32,
+ /// DirectSound maximum distance in world units.
+ pub max_distance: f32,
+ /// Playback frequency ratio. The shipped thunder effect keeps this at 1.
+ pub frequency_ratio: f32,
+}
+
+impl Default for SoundParameters {
+ fn default() -> Self {
+ Self {
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ }
+ }
+}
+
+/// All visible and audible environment output for one frame.
+#[derive(Clone, Debug, Default, PartialEq)]
+pub struct EnvironmentFrame {
+ /// Renderer primitives in stable emitter order.
+ pub primitives: Vec<EnvironmentPrimitive>,
+ /// Dynamic point lights emitted by native Effect opcode 1 commands.
+ pub point_lights: Vec<PointLightFrame>,
+ /// Sound transitions and one-shot events.
+ pub sounds: Vec<SoundEvent>,
+}
+
+/// A validated resource name from the `env_lightning` FXID.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct EffectResource {
+ /// Archive name.
+ pub archive: String,
+ /// Entry name.
+ pub name: String,
+}
+
+/// The real visual and sound contracts extracted from `env_lightning`.
+#[derive(Clone, Debug, PartialEq)]
+pub struct LightningEffect {
+ /// FXID header lifetime in seconds.
+ pub duration_seconds: f32,
+ /// Visual material resource from the opcode 3 body.
+ pub visual: EffectResource,
+ /// Complete opcode-3 body used by the native quad evaluator.
+ pub visual_body: Arc<[u8]>,
+ /// Complete opcode-1 body for the native Effect evaluator.
+ pub opcode1_body: Arc<[u8]>,
+ /// Command index containing those visual parameters.
+ pub opcode1_command_index: usize,
+ /// Sound resource from the opcode 2 body.
+ pub sound: EffectResource,
+ /// Complete opcode-2 body used to evaluate the native thunder gain and
+ /// active interval at the event origin.
+ pub sound_body: Arc<[u8]>,
+ /// Command index containing the visual material.
+ pub visual_command_index: usize,
+ /// Command index containing the sound resource.
+ pub sound_command_index: usize,
+}
+
+impl LightningEffect {
+ /// Extracts the visual, point-light, and sound contracts from a decoded
+ /// lightning FXID.
+ ///
+ /// The generic FXID parser intentionally preserves command bodies without
+ /// claiming field semantics. Opcode 3 has a material reference at body
+ /// offset 132, opcode 1 carries the LightManager record, and opcode 2
+ /// has a sound reference at body offsets 80 and 112.
+ ///
+ /// # Errors
+ ///
+ /// Returns an error if the document does not contain those commands or
+ /// contains truncated command bodies.
+ pub fn from_fxid(document: &FxDocument) -> Result<Self, EnvironmentError> {
+ let duration_seconds = document.header().duration_seconds;
+ if !duration_seconds.is_finite() || duration_seconds <= 0.0 {
+ return Err(EnvironmentError::InvalidDuration { duration_seconds });
+ }
+
+ let mut visual = None;
+ let mut opcode1 = None;
+ let mut sound = None;
+ for (index, command) in document.commands().iter().enumerate() {
+ match command.opcode {
+ FxOpcode::Op3 if visual.is_none() => {
+ let resource = read_resource(index, command.opcode, &command.raw_body, 132)?;
+ validate_opcode3_body(index, &command.raw_body)?;
+ visual = Some((index, resource, Arc::clone(&command.raw_body)));
+ }
+ FxOpcode::Op1 if opcode1.is_none() => {
+ validate_opcode1_body(index, &command.raw_body)?;
+ opcode1 = Some((index, Arc::clone(&command.raw_body)));
+ }
+ FxOpcode::Op2 if sound.is_none() => {
+ validate_opcode2_body(index, &command.raw_body)?;
+ let resource = read_resource(index, command.opcode, &command.raw_body, 80)?;
+ let name = read_name(&command.raw_body, index, command.opcode, 112)?;
+ sound = Some((
+ index,
+ EffectResource {
+ archive: resource.archive,
+ name,
+ },
+ Arc::clone(&command.raw_body),
+ ));
+ }
+ _ => {}
+ }
+ }
+
+ let Some((visual_command_index, visual, visual_body)) = visual else {
+ return Err(EnvironmentError::MissingCommand {
+ opcode: FxOpcode::Op3,
+ });
+ };
+ let Some((opcode1_command_index, opcode1_body)) = opcode1 else {
+ return Err(EnvironmentError::MissingCommand {
+ opcode: FxOpcode::Op1,
+ });
+ };
+ let Some((sound_command_index, sound, sound_body)) = sound else {
+ return Err(EnvironmentError::MissingCommand {
+ opcode: FxOpcode::Op2,
+ });
+ };
+ Ok(Self {
+ duration_seconds,
+ visual,
+ visual_body,
+ opcode1_body,
+ opcode1_command_index,
+ sound,
+ sound_body,
+ visual_command_index,
+ sound_command_index,
+ })
+ }
+
+ /// Evaluates the native opcode-2 distance and playback parameters at an
+ /// effect age. The FX command stores minimum/maximum distance at body
+ /// offsets `0x3c`/`0x40` and interpolates the frequency ratio from
+ /// `0x44` to `0x48`; body offset `0x4c` remains in the preserved raw
+ /// command because its backend meaning is not established.
+ #[must_use]
+ pub fn sound_parameters(&self, elapsed_seconds: f32) -> Option<SoundParameters> {
+ if !elapsed_seconds.is_finite()
+ || !self.duration_seconds.is_finite()
+ || self.duration_seconds <= 0.0
+ {
+ return None;
+ }
+ let (normalized_time, _) = active_interval(
+ self.sound_body.as_ref(),
+ elapsed_seconds,
+ self.duration_seconds,
+ )?;
+ let min_distance = read_f32(self.sound_body.as_ref(), 60)?;
+ let max_distance = read_f32(self.sound_body.as_ref(), 64)?;
+ let frequency_ratio = lerp(
+ read_f32(self.sound_body.as_ref(), 68)?,
+ read_f32(self.sound_body.as_ref(), 72)?,
+ normalized_time,
+ );
+ if !min_distance.is_finite() || !max_distance.is_finite() || !frequency_ratio.is_finite() {
+ return None;
+ }
+ Some(SoundParameters {
+ min_distance,
+ max_distance,
+ frequency_ratio,
+ })
+ }
+
+ /// Evaluates the native opcode 1 point-light command at `elapsed_seconds`.
+ #[must_use]
+ pub fn evaluate_point_light(
+ &self,
+ elapsed_seconds: f32,
+ origin: [f32; 3],
+ ) -> Option<PointLightFrame> {
+ self.evaluate_point_light_with_basis(elapsed_seconds, origin, identity_basis())
+ }
+
+ /// Evaluates opcode 1 and applies a parent basis to its LightManager
+ /// position and direction. `basis` stores the world-space vectors
+ /// corresponding to local +X, +Y, and +Z.
+ #[must_use]
+ pub fn evaluate_point_light_with_basis(
+ &self,
+ elapsed_seconds: f32,
+ origin: [f32; 3],
+ basis: [[f32; 3]; 3],
+ ) -> Option<PointLightFrame> {
+ if !self.valid_effect_time(elapsed_seconds, origin, basis) {
+ return None;
+ }
+
+ let body = self.opcode1_body.as_ref();
+ let effect_type = read_u32(body, 0)?;
+ let (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?;
+ let local_position = lerp3(read_vec3(body, 12)?, read_vec3(body, 24)?, normalized_time);
+ let local_direction = lerp3(read_vec3(body, 36)?, read_vec3(body, 48)?, normalized_time);
+ let color = lerp4(read_vec4(body, 60)?, read_vec4(body, 76)?, normalized_time);
+ let range = lerp(read_f32(body, 108)?, read_f32(body, 112)?, normalized_time);
+ let attenuation = [
+ read_f32(body, 120)?,
+ read_f32(body, 124)?,
+ read_f32(body, 128)?,
+ ];
+ let effect_factor = read_f32(body, 116)?;
+ let direction = finite_unit(transform3(basis, local_direction))
+ .unwrap_or_else(|| finite_unit(basis[0]).unwrap_or([1.0, 0.0, 0.0]));
+
+ Some(PointLightFrame {
+ effect_type,
+ elapsed_seconds,
+ normalized_time,
+ duration_seconds: self.duration_seconds,
+ position: add3(origin, transform3(basis, local_position)),
+ direction,
+ color: color.map(sanitize_float),
+ range: sanitize_float(range).max(0.0),
+ attenuation: attenuation.map(sanitize_float),
+ effect_factor: sanitize_float(effect_factor),
+ active: true,
+ })
+ }
+
+ /// Evaluates the native opcode 3 visual quad at `elapsed_seconds`.
+ ///
+ /// The native owner supplies the effective descriptor `[40, 40, 600]`
+ /// and the effect origin after adding 300 units to the sampled Z. Opcode
+ /// 3 contributes the material modifiers and active interval; its fixed
+ /// full-texture UVs are independent of opcode 1.
+ #[must_use]
+ pub fn evaluate_quad(
+ &self,
+ elapsed_seconds: f32,
+ origin: [f32; 3],
+ camera: Camera,
+ ) -> Option<LightningQuadFrame> {
+ self.evaluate_quad_with_mirror(elapsed_seconds, origin, camera, false)
+ }
+
+ /// Evaluates the native opcode 3 visual quad with the per-bolt mirrored
+ /// texture orientation selected by `CLightning::CreateEffect`.
+ #[must_use]
+ pub fn evaluate_quad_with_mirror(
+ &self,
+ elapsed_seconds: f32,
+ origin: [f32; 3],
+ camera: Camera,
+ mirrored: bool,
+ ) -> Option<LightningQuadFrame> {
+ let camera = camera.sanitized();
+ if !self.valid_effect_time(elapsed_seconds, origin, identity_basis()) {
+ return None;
+ }
+ let body = self.visual_body.as_ref();
+ let (normalized_time, _) = active_interval(body, elapsed_seconds, self.duration_seconds)?;
+ let local_offset = evaluate_position(body, normalized_time)?;
+ let local_scale = evaluate_scale(body, normalized_time)?;
+ // The owner matrix is diagonal. The mirrored path negates its local
+ // Y basis; that sign participates in both the inverse camera
+ // transform and the forward transform of the billboard frame.
+ // Effect.dll first computes `world_center = M_base * local_offset`,
+ // then builds `M_base * Diag(local_scale)`. It never applies the
+ // animated scale to the inverse camera vector.
+ let base_diag = lightning_parent_diagonal(mirrored);
+ let mut geometry_diag = base_diag.map(|value| value);
+ for (axis, scale) in geometry_diag.iter_mut().zip(local_scale) {
+ *axis *= scale;
+ }
+ let dimensions = [
+ LIGHTNING_QUAD_DIMENSIONS[0] * local_scale[0],
+ LIGHTNING_QUAD_DIMENSIONS[1] * local_scale[1],
+ LIGHTNING_QUAD_DIMENSIONS[2] * local_scale[2],
+ ];
+ let center = add3(
+ origin,
+ [
+ base_diag[0] * local_offset[0],
+ base_diag[1] * local_offset[1],
+ base_diag[2] * local_offset[2],
+ ],
+ );
+
+ // The shipped env_lightning command is type 1. Its native receiver
+ // builds a local frame with columns `(axis, U, W)`, where
+ // `U = normalize(V x axis)` and `W = axis x U`. The canonical quad
+ // uses the first two columns; the third column supplies the
+ // camera-facing normal to the native receiver. `V` is intentionally
+ // not normalized before the exact parallel-view test.
+ let axis = [0.0, 0.0, 1.0];
+ let camera_local = [
+ (camera.position[0] - origin[0]) / base_diag[0],
+ (camera.position[1] - origin[1]) / base_diag[1],
+ (camera.position[2] - origin[2]) / base_diag[2],
+ ];
+ let raw_view = sub3(camera_local, local_offset);
+ // Native type 1 compares the raw dot product against exactly +/-1.
+ // A near-parallel view therefore follows the cross-product path.
+ if dot3(raw_view, axis).abs() == 1.0 {
+ return None;
+ }
+ let horizontal_local = normalized_or_fallback(cross3(raw_view, axis), [1.0, 0.0, 0.0]);
+ let _normal_local = cross3(axis, horizontal_local);
+ let horizontal = transform_diagonal(geometry_diag, horizontal_local);
+ let vertical = transform_diagonal(geometry_diag, axis);
+ let bottom = sub3(center, scale3(vertical, 0.5));
+ let top = add3(center, scale3(vertical, 0.5));
+ let positive_horizontal = scale3(horizontal, 0.5);
+ let negative_horizontal = scale3(horizontal, -0.5);
+ Some(LightningQuadFrame {
+ vertices: [
+ add3(bottom, positive_horizontal),
+ add3(bottom, negative_horizontal),
+ add3(top, negative_horizontal),
+ add3(top, positive_horizontal),
+ ],
+ uv: [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]],
+ dimensions,
+ local_offset: local_offset.map(sanitize_float),
+ local_scale: local_scale.map(sanitize_float),
+ opacity: sanitize_float(1.0 - normalized_time).clamp(0.0, 1.0),
+ elapsed_seconds,
+ normalized_time,
+ duration_seconds: self.duration_seconds,
+ active: true,
+ })
+ }
+
+ fn valid_effect_time(
+ &self,
+ elapsed_seconds: f32,
+ origin: [f32; 3],
+ basis: [[f32; 3]; 3],
+ ) -> bool {
+ elapsed_seconds.is_finite()
+ && self.duration_seconds.is_finite()
+ && self.duration_seconds > 0.0
+ && elapsed_seconds >= 0.0
+ && elapsed_seconds <= self.duration_seconds
+ && origin.iter().all(|value| value.is_finite())
+ && basis.iter().flatten().all(|value| value.is_finite())
+ }
+}
+
+/// Decodes and validates the original `env_lightning` FXID payload.
+pub fn decode_env_lightning_fxid(bytes: Arc<[u8]>) -> Result<LightningEffect, EnvironmentError> {
+ let document = crate::decode_fxid(bytes)?;
+ LightningEffect::from_fxid(&document)
+}
+
+/// Environment simulation failure.
+#[derive(Debug)]
+pub enum EnvironmentError {
+ /// Generic FXID framing error.
+ Fx(FxError),
+ /// FXID lifetime is unusable for an active effect.
+ InvalidDuration {
+ /// Value from the FXID header.
+ duration_seconds: f32,
+ },
+ /// Required command is absent.
+ MissingCommand {
+ /// Required opcode.
+ opcode: FxOpcode,
+ },
+ /// A command body is too short for the established layout.
+ InvalidCommandLayout {
+ /// Command index.
+ command_index: usize,
+ /// Command opcode.
+ opcode: FxOpcode,
+ /// Required body length.
+ required: usize,
+ /// Actual body length.
+ actual: usize,
+ },
+ /// A known command points at an unexpected resource.
+ UnexpectedResource {
+ /// Command index.
+ command_index: usize,
+ /// Resource field being checked.
+ part: &'static str,
+ /// Decoded archive name.
+ archive: String,
+ /// Decoded resource name.
+ name: String,
+ },
+ /// Opcode 1 contains a non-finite visual parameter.
+ NonFiniteVisualValue {
+ /// Command index.
+ command_index: usize,
+ /// Value index in the decoded prefix.
+ index: usize,
+ /// Value read from the command body.
+ value: f32,
+ },
+ /// Opcode 2 contains a non-finite sound parameter.
+ NonFiniteSoundValue {
+ /// Command index.
+ command_index: usize,
+ /// Value index in the numeric sound prefix.
+ index: usize,
+ /// Value read from the command body.
+ value: f32,
+ },
+}
+
+impl From<FxError> for EnvironmentError {
+ fn from(value: FxError) -> Self {
+ Self::Fx(value)
+ }
+}
+
+impl std::fmt::Display for EnvironmentError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Fx(error) => write!(f, "{error}"),
+ Self::InvalidDuration { duration_seconds } => {
+ write!(f, "invalid env_lightning FXID duration {duration_seconds}")
+ }
+ Self::MissingCommand { opcode } => {
+ write!(f, "env_lightning is missing required {opcode:?} command")
+ }
+ Self::InvalidCommandLayout {
+ command_index,
+ opcode,
+ required,
+ actual,
+ } => write!(
+ f,
+ "env_lightning command {command_index} ({opcode:?}) needs {required} body bytes, found {actual}"
+ ),
+ Self::UnexpectedResource {
+ command_index,
+ part,
+ archive,
+ name,
+ } => write!(
+ f,
+ "unexpected env_lightning {part} at command {command_index}: {archive}/{name}"
+ ),
+ Self::NonFiniteVisualValue {
+ command_index,
+ index,
+ value,
+ } => write!(
+ f,
+ "env_lightning opcode 1 command {command_index} has non-finite visual value {index}: {value}"
+ ),
+ Self::NonFiniteSoundValue {
+ command_index,
+ index,
+ value,
+ } => write!(
+ f,
+ "env_lightning opcode 2 command {command_index} has non-finite sound value {index}: {value}"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for EnvironmentError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Fx(error) => Some(error),
+ Self::InvalidDuration { .. }
+ | Self::MissingCommand { .. }
+ | Self::InvalidCommandLayout { .. }
+ | Self::UnexpectedResource { .. }
+ | Self::NonFiniteVisualValue { .. }
+ | Self::NonFiniteSoundValue { .. } => None,
+ }
+ }
+}
+
+/// Stateful CPU environment emitter.
+#[derive(Clone, Debug)]
+pub struct EnvironmentSystem {
+ time_seconds: f32,
+ time_millis: u64,
+ time_millis_fraction: f64,
+ /// Terrain and Effect share the CRT `rand` state. Keep one stream for
+ /// particle respawns, lightning scheduling, and lightning placement so
+ /// the native call order remains observable across subsystems.
+ rng: NativeRng,
+ rain: PrecipitationEmitter,
+ snow: PrecipitationEmitter,
+ materials: SkyMaterials,
+ lightning_effect: Option<LightningEffect>,
+ lightning_bounds: Option<LightningBounds>,
+ lightning: LightningState,
+ rain_loop_active: bool,
+ rain_loop_resource: Option<String>,
+}
+
+impl EnvironmentSystem {
+ /// Creates a deterministic environment system.
+ #[must_use]
+ pub fn new(seed: u64) -> Self {
+ Self {
+ time_seconds: 0.0,
+ time_millis: 0,
+ time_millis_fraction: 0.0,
+ rng: NativeRng::new(seed),
+ rain: PrecipitationEmitter::new(PrecipitationKind::Rain, PrecipitationVolume::native()),
+ snow: PrecipitationEmitter::new(PrecipitationKind::Snow, PrecipitationVolume::native()),
+ materials: SkyMaterials::default(),
+ lightning_effect: None,
+ lightning_bounds: None,
+ lightning: LightningState::new(),
+ rain_loop_active: false,
+ rain_loop_resource: None,
+ }
+ }
+
+ /// Installs the mission's `sky.wea` material table. The rain and snow
+ /// emitters resolve rows eight and seven from this table on every frame.
+ pub fn set_sky_materials(&mut self, materials: SkyMaterials) {
+ self.materials = materials;
+ }
+
+ /// Returns the active precipitation descriptor.
+ #[must_use]
+ pub fn precipitation_volume(&self) -> PrecipitationVolume {
+ self.rain.volume
+ }
+
+ /// Installs a validated `env_lightning` effect contract.
+ pub fn set_lightning_effect(&mut self, effect: LightningEffect) {
+ self.lightning_effect = effect
+ .duration_seconds
+ .is_finite()
+ .then_some(effect)
+ .filter(|effect| effect.duration_seconds > 0.0);
+ self.lightning.clear_visual();
+ }
+
+ /// Removes the installed lightning effect.
+ pub fn clear_lightning_effect(&mut self) {
+ self.lightning_effect = None;
+ self.lightning.clear_visual();
+ }
+
+ /// Supplies the current world-space bounds used by the native lightning
+ /// sampler. Passing non-finite bounds makes the system fall back to the
+ /// camera position for subsequent flashes.
+ pub fn set_lightning_bounds(&mut self, bounds: LightningBounds) {
+ self.lightning_bounds = bounds.sanitized();
+ }
+
+ /// Removes explicit lightning bounds. Subsequent effects use the camera
+ /// position as a zero-size range.
+ pub fn clear_lightning_bounds(&mut self) {
+ self.lightning_bounds = None;
+ }
+
+ /// Current simulation time in seconds.
+ #[must_use]
+ pub fn time_seconds(&self) -> f32 {
+ self.time_seconds
+ }
+
+ /// Current simulation time in whole native milliseconds.
+ #[must_use]
+ pub fn time_millis(&self) -> u64 {
+ self.time_millis
+ }
+
+ /// Advances precipitation and lightning and returns renderer/audio output.
+ ///
+ /// `dt_seconds` is sanitized to a finite non-negative value. Particle
+ /// positions are retained between frames and wrapped in the camera-local
+ /// descriptor using the native floor-based wrap operation, so a renderer
+ /// can consume this frame as a direct list of instances.
+ #[must_use]
+ pub fn update(
+ &mut self,
+ dt_seconds: f32,
+ weather: WeatherInput,
+ camera: Camera,
+ ) -> EnvironmentFrame {
+ let dt_seconds = if dt_seconds.is_finite() {
+ dt_seconds.max(0.0)
+ } else {
+ 0.0
+ };
+ let weather = weather.sanitized();
+ let camera = camera.sanitized();
+ self.time_seconds = self.time_seconds.max(0.0) + dt_seconds;
+ self.advance_millis(dt_seconds);
+
+ let mut frame = EnvironmentFrame::default();
+ self.rain.update(
+ dt_seconds,
+ &weather.rain,
+ camera,
+ self.materials.material_name(8),
+ &mut self.rng,
+ &mut frame.primitives,
+ );
+ self.snow.update(
+ dt_seconds,
+ &weather.snow,
+ camera,
+ self.materials.material_name(7),
+ &mut self.rng,
+ &mut frame.primitives,
+ );
+
+ let rain_resource = weather.rain.resources.first().cloned();
+ if weather.rain.active && self.rain_loop_active && rain_resource != self.rain_loop_resource
+ {
+ if let Some(name) = self.rain_loop_resource.clone() {
+ let (archive, name) = split_resource(&name);
+ frame.sounds.push(SoundEvent {
+ archive,
+ name,
+ position: camera.position,
+ volume: 0.0,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ action: SoundAction::StopLoop,
+ });
+ }
+ }
+ if weather.rain.active != self.rain_loop_active
+ || (weather.rain.active && rain_resource != self.rain_loop_resource)
+ {
+ let (resource, action) = if weather.rain.active {
+ (rain_resource.clone(), SoundAction::StartLoop)
+ } else {
+ (self.rain_loop_resource.clone(), SoundAction::StopLoop)
+ };
+ if let Some(resource) = resource {
+ let (archive, name) = split_resource(&resource);
+ frame.sounds.push(SoundEvent {
+ archive,
+ name,
+ position: camera.position,
+ volume: weather.rain.intensity,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ action,
+ });
+ }
+ } else if weather.rain.active {
+ // Atmosphere intensity is continuously interpolated between
+ // keys. The native rain object keeps its loop alive while that
+ // value changes, so the audio owner must receive the current
+ // volume even when the lifecycle state is unchanged.
+ if let Some(name) = rain_resource.clone() {
+ let (archive, name) = split_resource(&name);
+ frame.sounds.push(SoundEvent {
+ archive,
+ name,
+ position: camera.position,
+ volume: weather.rain.intensity,
+ min_distance: 0.0,
+ max_distance: f32::INFINITY,
+ frequency_ratio: 1.0,
+ action: SoundAction::SetLoopVolume,
+ });
+ }
+ }
+ self.rain_loop_active = weather.rain.active;
+ self.rain_loop_resource = weather.rain.active.then_some(rain_resource).flatten();
+
+ self.lightning.update(
+ self.time_millis,
+ weather.lightning,
+ camera,
+ self.lightning_effect.as_ref(),
+ self.lightning_bounds,
+ &mut self.rng,
+ &mut frame.primitives,
+ &mut frame.point_lights,
+ &mut frame.sounds,
+ );
+
+ frame
+ }
+
+ /// Advances from an `AtmosphereFrame` sampled by the `sky.ske` parser.
+ #[must_use]
+ pub fn update_atmosphere(
+ &mut self,
+ dt_seconds: f32,
+ frame: &crate::atmosphere::AtmosphereFrame,
+ camera: Camera,
+ ) -> EnvironmentFrame {
+ self.update(dt_seconds, WeatherInput::from_atmosphere(frame), camera)
+ }
+
+ /// Advances from a sampled `sky.ske` frame and its mission `sky.wea`
+ /// material table.
+ #[must_use]
+ pub fn update_atmosphere_with_materials(
+ &mut self,
+ dt_seconds: f32,
+ frame: &crate::atmosphere::AtmosphereFrame,
+ materials: &SkyMaterials,
+ camera: Camera,
+ ) -> EnvironmentFrame {
+ self.set_sky_materials(materials.clone());
+ self.update(
+ dt_seconds,
+ WeatherInput::from_atmosphere_with_materials(frame, materials),
+ camera,
+ )
+ }
+
+ fn advance_millis(&mut self, dt_seconds: f32) {
+ let millis = self.time_millis_fraction + f64::from(dt_seconds) * 1_000.0;
+ if !millis.is_finite() || millis <= 0.0 {
+ return;
+ }
+ let whole = millis.floor();
+ self.time_millis = self
+ .time_millis
+ .saturating_add(if whole >= u64::MAX as f64 {
+ u64::MAX
+ } else {
+ whole as u64
+ });
+ self.time_millis_fraction = millis - whole;
+ }
+}
+
+#[derive(Clone, Debug)]
+struct PrecipitationEmitter {
+ kind: PrecipitationKind,
+ volume: PrecipitationVolume,
+ particles: Vec<ParticleState>,
+}
+
+#[derive(Clone, Copy, Debug)]
+struct ParticleState {
+ /// World-space head position. Native integration advances this directly
+ /// before converting it to camera-local coordinates for wrapping.
+ position: [f32; 3],
+ /// Previous world-space head position, used as the rain tail.
+ previous_position: [f32; 3],
+ respawns: u64,
+}
+
+impl PrecipitationEmitter {
+ fn new(kind: PrecipitationKind, volume: PrecipitationVolume) -> Self {
+ Self {
+ kind,
+ volume,
+ particles: Vec::new(),
+ }
+ }
+
+ fn update(
+ &mut self,
+ dt_seconds: f32,
+ state: &WeatherState,
+ camera: Camera,
+ default_material: Option<&str>,
+ rng: &mut NativeRng,
+ out: &mut Vec<EnvironmentPrimitive>,
+ ) {
+ if !state.active || state.intensity <= 0.0 {
+ return;
+ }
+ let Some(material) = state
+ .material
+ .as_deref()
+ .or(default_material)
+ .filter(|material| !material.is_empty())
+ else {
+ return;
+ };
+ let current_volume = self.volume.for_camera(camera);
+ let desired = self
+ .volume
+ .particle_count_for(current_volume, 1.0, state.intensity);
+ self.particles.truncate(desired);
+ while self.particles.len() < desired {
+ let index = self.particles.len();
+ self.particles.push(ParticleState {
+ position: [0.0; 3],
+ previous_position: [0.0; 3],
+ respawns: 0,
+ });
+ self.respawn(index, camera, current_volume, rng);
+ }
+
+ let velocity = self.velocity();
+ let kind = self.kind;
+ let size_factor = match kind {
+ PrecipitationKind::Rain => RAIN_SIZE_FACTOR,
+ PrecipitationKind::Snow => SNOW_SIZE_FACTOR,
+ };
+ let size = camera.precipitation_size_scalar() * size_factor;
+ for index in 0..desired {
+ let particle = &mut self.particles[index];
+ particle.previous_position = particle.position;
+ particle.position[0] += velocity[0] * dt_seconds;
+ particle.position[1] += velocity[1] * dt_seconds;
+ particle.position[2] += velocity[2] * dt_seconds;
+ let local = camera.local_from_world(particle.position);
+ let mut wrapped_local = local;
+ let mut wrapped = false;
+ for axis in 0..3 {
+ let offset = local[axis] - current_volume.origin[axis];
+ let wrapped_offset = wrap_local(offset, current_volume.extent[axis]);
+ wrapped_local[axis] = current_volume.origin[axis] + wrapped_offset;
+ wrapped |= (wrapped_offset - offset).abs() > f32::EPSILON;
+ }
+ if wrapped {
+ // A wrapped particle has no continuous temporal tail across
+ // the opposite faces of the volume.
+ particle.position = camera.world_from_local(wrapped_local);
+ particle.previous_position = particle.position;
+ }
+ let position = particle.position;
+ let tail = particle.previous_position;
+ out.push(EnvironmentPrimitive::Particle {
+ kind,
+ position,
+ velocity,
+ size,
+ color: state.color,
+ material: material.to_owned(),
+ screen: screen_billboard(kind, camera, position, tail, size, index),
+ });
+ }
+ }
+
+ fn respawn(
+ &mut self,
+ index: usize,
+ camera: Camera,
+ volume: PrecipitationVolume,
+ rng: &mut NativeRng,
+ ) {
+ let particle = &mut self.particles[index];
+ let local = [
+ rng.next_unit() * volume.extent[0],
+ rng.next_unit() * volume.extent[1],
+ rng.next_unit() * volume.extent[2],
+ ];
+ particle.position = camera.world_from_local(add3(volume.origin, local));
+ particle.previous_position = particle.position;
+ particle.respawns = particle.respawns.wrapping_add(1);
+ }
+
+ fn velocity(&self) -> [f32; 3] {
+ match self.kind {
+ PrecipitationKind::Rain => RAIN_VELOCITY,
+ PrecipitationKind::Snow => SNOW_VELOCITY,
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+struct LightningState {
+ phase: u8,
+ last_time_ms: u64,
+ deadline_ms: u64,
+ active: bool,
+ visual: Option<LightningRuntime>,
+}
+
+#[derive(Clone, Debug)]
+struct LightningRuntime {
+ effect: LightningEffect,
+ position: [f32; 3],
+ intensity: f32,
+ mirrored: bool,
+ started_ms: u64,
+}
+
+impl LightningState {
+ fn new() -> Self {
+ Self {
+ // CLightning's constructor stores state 1, which means the first
+ // active update schedules a delay immediately.
+ phase: 1,
+ last_time_ms: 0,
+ deadline_ms: 0,
+ active: false,
+ visual: None,
+ }
+ }
+
+ fn clear_visual(&mut self) {
+ self.visual = None;
+ }
+
+ fn update(
+ &mut self,
+ now_ms: u64,
+ weather: WeatherState,
+ camera: Camera,
+ effect: Option<&LightningEffect>,
+ bounds: Option<LightningBounds>,
+ rng: &mut NativeRng,
+ primitives: &mut Vec<EnvironmentPrimitive>,
+ point_lights: &mut Vec<PointLightFrame>,
+ sounds: &mut Vec<SoundEvent>,
+ ) {
+ if !weather.active {
+ // The atmosphere destroys and recreates the lightning object on a
+ // stop/start transition. Reproduce that lifecycle so an old
+ // deadline cannot fire after clear weather.
+ self.active = false;
+ self.phase = 1;
+ self.last_time_ms = 0;
+ self.deadline_ms = 0;
+ self.visual = None;
+ return;
+ }
+ if !self.active {
+ self.active = true;
+ self.phase = 1;
+ self.last_time_ms = 0;
+ self.deadline_ms = 0;
+ }
+
+ self.emit_visual(now_ms, weather.color, camera, primitives, point_lights);
+
+ // Keep the state machine's one-transition-per-call behaviour. The
+ // original virtual update returns after changing state 0->1 or
+ // state 1->2; it does not catch up multiple lightning events after a
+ // long frame.
+ match self.phase {
+ 0 => {
+ if now_ms.saturating_sub(self.last_time_ms) >= LIGHTNING_COOLDOWN_MS {
+ self.phase = 1;
+ }
+ }
+ 1 => {
+ if weather.intensity <= 0.0 {
+ return;
+ }
+ let intensity = weather.intensity.min(LIGHTNING_INTENSITY_CAP);
+ let random = rng.next_unit();
+ let delay = ((1.0 - f64::from(intensity)) * LIGHTNING_DELAY_MS * f64::from(random))
+ .round_ties_even();
+ let delay = if delay.is_finite() && delay >= 0.0 {
+ delay.min(u64::MAX as f64) as u64
+ } else {
+ 0
+ };
+ self.deadline_ms = now_ms.saturating_add(delay);
+ self.phase = 2;
+ }
+ 2 => {
+ if now_ms < self.deadline_ms {
+ return;
+ }
+ let position = self.random_position(camera, bounds, rng);
+ if let Some(effect) = effect {
+ // CLightning consumes one CRT random value for the
+ // effect seed after sampling the X and Y coordinates.
+ // The seed has no renderer-facing field, but it remains
+ // part of the shared native stream.
+ let effect_seed = rng.next_unit();
+ self.visual = Some(LightningRuntime {
+ effect: effect.clone(),
+ position,
+ intensity: weather.intensity,
+ mirrored: effect_seed < 0.5,
+ started_ms: now_ms,
+ });
+ let parameters = effect.sound_parameters(0.0).unwrap_or_default();
+ sounds.push(SoundEvent {
+ archive: effect.sound.archive.clone(),
+ name: effect.sound.name.clone(),
+ position,
+ // The native opcode-2 command owns its frequency and
+ // range; weather intensity controls scheduling, not
+ // the thunder sample's source gain.
+ volume: 1.0,
+ min_distance: parameters.min_distance,
+ max_distance: parameters.max_distance,
+ frequency_ratio: parameters.frequency_ratio,
+ action: SoundAction::OneShot,
+ });
+ self.emit_visual(now_ms, weather.color, camera, primitives, point_lights);
+ }
+ self.last_time_ms = now_ms;
+ self.phase = 0;
+ }
+ _ => self.phase = 1,
+ }
+ }
+
+ fn emit_visual(
+ &mut self,
+ now_ms: u64,
+ _color: [f32; 4],
+ camera: Camera,
+ primitives: &mut Vec<EnvironmentPrimitive>,
+ point_lights: &mut Vec<PointLightFrame>,
+ ) {
+ let Some(visual) = self.visual.as_ref() else {
+ return;
+ };
+ let duration_ms = seconds_to_millis(visual.effect.duration_seconds);
+ let elapsed_ms = now_ms.saturating_sub(visual.started_ms);
+ if elapsed_ms >= duration_ms.max(1) {
+ self.visual = None;
+ return;
+ }
+ // CLightning creates the Effect object 300 world units above the
+ // sampled ground point. Its opcode 3 type-1 quad keeps a 600-unit
+ // world-Z streak axis and billboards only its horizontal axis.
+ let origin = [
+ visual.position[0],
+ visual.position[1],
+ visual.position[2] + LIGHTNING_NATIVE_ORIGIN_OFFSET,
+ ];
+ let basis = diagonal_basis(lightning_parent_diagonal(visual.mirrored));
+ let elapsed_seconds = elapsed_ms as f32 * 0.001;
+ if let Some(point_light) =
+ visual
+ .effect
+ .evaluate_point_light_with_basis(elapsed_seconds, origin, basis)
+ {
+ point_lights.push(point_light);
+ }
+ if let Some(quad) = visual.effect.evaluate_quad_with_mirror(
+ elapsed_seconds,
+ origin,
+ camera,
+ visual.mirrored,
+ ) {
+ primitives.push(EnvironmentPrimitive::Lightning(LightningBolt {
+ material: visual.effect.visual.clone(),
+ position: visual.position,
+ quad,
+ intensity: visual.intensity,
+ age_seconds: elapsed_seconds,
+ duration_seconds: visual.effect.duration_seconds,
+ }));
+ }
+ }
+
+ fn random_position(
+ &mut self,
+ camera: Camera,
+ bounds: Option<LightningBounds>,
+ rng: &mut NativeRng,
+ ) -> [f32; 3] {
+ let x_random = rng.next_unit();
+ let y_random = rng.next_unit();
+ let bounds = bounds.unwrap_or_else(|| {
+ LightningBounds::new(
+ camera.position[0],
+ camera.position[0],
+ camera.position[1],
+ camera.position[1],
+ camera.position[2],
+ )
+ });
+ [
+ bounds.x[0] + (bounds.x[1] - bounds.x[0]) * x_random,
+ bounds.y[0] + (bounds.y[1] - bounds.y[0]) * y_random,
+ bounds.z,
+ ]
+ }
+}
+
+fn from_atmosphere_weather(sample: &crate::atmosphere::WeatherSample) -> WeatherState {
+ WeatherState::with_resources(
+ sample.active,
+ sample.intensity,
+ sample.color,
+ sample.resources.iter().map(|resource| resource.as_str()),
+ )
+}
+
+fn split_resource(resource: &str) -> (String, String) {
+ resource.split_once('/').map_or_else(
+ || (String::new(), resource.to_owned()),
+ |(archive, name)| (archive.to_owned(), name.to_owned()),
+ )
+}
+
+fn seconds_to_millis(seconds: f32) -> u64 {
+ if !seconds.is_finite() || seconds <= 0.0 {
+ return 0;
+ }
+ let millis = f64::from(seconds) * 1_000.0;
+ if millis >= u64::MAX as f64 {
+ u64::MAX
+ } else {
+ millis.round() as u64
+ }
+}
+
+fn read_resource(
+ command_index: usize,
+ opcode: FxOpcode,
+ body: &[u8],
+ archive_offset: usize,
+) -> Result<EffectResource, EnvironmentError> {
+ let archive = read_name(body, command_index, opcode, archive_offset)?;
+ let name = read_name(body, command_index, opcode, archive_offset + 32)?;
+ Ok(EffectResource { archive, name })
+}
+
+fn validate_opcode1_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> {
+ let required = 132;
+ body.get(..required)
+ .ok_or(EnvironmentError::InvalidCommandLayout {
+ command_index,
+ opcode: FxOpcode::Op1,
+ required,
+ actual: body.len(),
+ })?;
+ for (index, offset) in (4..required).step_by(4).enumerate() {
+ let value = read_f32(body, offset).expect("validated opcode 1 layout");
+ if !value.is_finite() {
+ return Err(EnvironmentError::NonFiniteVisualValue {
+ command_index,
+ index,
+ value,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn validate_opcode3_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> {
+ let required = 132;
+ body.get(..required)
+ .ok_or(EnvironmentError::InvalidCommandLayout {
+ command_index,
+ opcode: FxOpcode::Op3,
+ required,
+ actual: body.len(),
+ })?;
+ for (index, offset) in (4..required).step_by(4).enumerate() {
+ let value = read_f32(body, offset).expect("validated opcode 3 layout");
+ if !value.is_finite() {
+ return Err(EnvironmentError::NonFiniteVisualValue {
+ command_index,
+ index,
+ value,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn validate_opcode2_body(command_index: usize, body: &[u8]) -> Result<(), EnvironmentError> {
+ let required = 144;
+ body.get(..required)
+ .ok_or(EnvironmentError::InvalidCommandLayout {
+ command_index,
+ opcode: FxOpcode::Op2,
+ required,
+ actual: body.len(),
+ })?;
+ for (index, offset) in (4..80).step_by(4).enumerate() {
+ let value = read_f32(body, offset).expect("validated opcode 2 layout");
+ if !value.is_finite() {
+ return Err(EnvironmentError::NonFiniteSoundValue {
+ command_index,
+ index,
+ value,
+ });
+ }
+ }
+ Ok(())
+}
+
+fn active_interval(body: &[u8], elapsed: f32, duration: f32) -> Option<(f32, f32)> {
+ let start = read_f32(body, 4)?;
+ let end = read_f32(body, 8)?;
+ if !start.is_finite()
+ || !end.is_finite()
+ || !duration.is_finite()
+ || duration <= 0.0
+ || elapsed < start
+ || elapsed > end
+ {
+ return None;
+ }
+ let span = end - start;
+ let normalized = if span.abs() > f32::EPSILON {
+ ((elapsed - start) / span).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ Some((normalized, end.min(duration)))
+}
+
+fn evaluate_position(body: &[u8], normalized_time: f32) -> Option<[f32; 3]> {
+ let start = read_vec3(body, 36)?;
+ let delta = read_vec3(body, 48)?;
+ let exponent = read_vec3(body, 60)?;
+ Some(std::array::from_fn(|axis| {
+ let input = normalized_time.powf(exponent[axis]);
+ start[axis] + delta[axis] * input
+ }))
+}
+
+fn evaluate_scale(body: &[u8], normalized_time: f32) -> Option<[f32; 3]> {
+ let start = read_vec3(body, 96)?;
+ let end = read_vec3(body, 108)?;
+ let exponent = read_vec3(body, 120)?;
+ Some(std::array::from_fn(|axis| {
+ let input = normalized_time.powf(exponent[axis]);
+ lerp(start[axis], end[axis], input)
+ }))
+}
+
+fn read_name(
+ body: &[u8],
+ command_index: usize,
+ opcode: FxOpcode,
+ offset: usize,
+) -> Result<String, EnvironmentError> {
+ let end = offset
+ .checked_add(32)
+ .ok_or(EnvironmentError::InvalidCommandLayout {
+ command_index,
+ opcode,
+ required: usize::MAX,
+ actual: body.len(),
+ })?;
+ let bytes = body
+ .get(offset..end)
+ .ok_or(EnvironmentError::InvalidCommandLayout {
+ command_index,
+ opcode,
+ required: end,
+ actual: body.len(),
+ })?;
+ let end = bytes
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(bytes.len());
+ Ok(String::from_utf8_lossy(&bytes[..end]).into_owned())
+}
+
+fn volume_product(extent: [f32; 3]) -> f32 {
+ extent[0] * extent[1] * extent[2]
+}
+
+fn finite_unit(vector: [f32; 3]) -> Option<[f32; 3]> {
+ let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt();
+ if length.is_finite() && length > f32::EPSILON {
+ Some(vector.map(|component| component / length))
+ } else {
+ None
+ }
+}
+
+fn wrap_local(value: f32, extent: f32) -> f32 {
+ if !value.is_finite() || !extent.is_finite() || extent <= 0.0 {
+ return 0.0;
+ }
+ value - (value / extent).floor() * extent
+}
+
+fn add3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] + right[0], left[1] + right[1], left[2] + right[2]]
+}
+
+fn scale3(value: [f32; 3], scale: f32) -> [f32; 3] {
+ [value[0] * scale, value[1] * scale, value[2] * scale]
+}
+
+fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0].mul_add(right[0], left[1].mul_add(right[1], left[2] * right[2]))
+}
+
+fn cross3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [
+ left[1].mul_add(right[2], -(left[2] * right[1])),
+ left[2].mul_add(right[0], -(left[0] * right[2])),
+ left[0].mul_add(right[1], -(left[1] * right[0])),
+ ]
+}
+
+fn lightning_parent_diagonal(mirrored: bool) -> [f32; 3] {
+ [
+ LIGHTNING_QUAD_DIMENSIONS[0],
+ if mirrored {
+ -LIGHTNING_QUAD_DIMENSIONS[1]
+ } else {
+ LIGHTNING_QUAD_DIMENSIONS[1]
+ },
+ LIGHTNING_QUAD_DIMENSIONS[2],
+ ]
+}
+
+fn normalized_or_fallback(vector: [f32; 3], fallback: [f32; 3]) -> [f32; 3] {
+ finite_unit(vector).unwrap_or(fallback)
+}
+
+fn transform_diagonal(diagonal: [f32; 3], vector: [f32; 3]) -> [f32; 3] {
+ [
+ diagonal[0] * vector[0],
+ diagonal[1] * vector[1],
+ diagonal[2] * vector[2],
+ ]
+}
+
+fn diagonal_basis(diagonal: [f32; 3]) -> [[f32; 3]; 3] {
+ [
+ [diagonal[0], 0.0, 0.0],
+ [0.0, diagonal[1], 0.0],
+ [0.0, 0.0, diagonal[2]],
+ ]
+}
+
+fn identity_basis() -> [[f32; 3]; 3] {
+ [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
+}
+
+fn read_u32(body: &[u8], offset: usize) -> Option<u32> {
+ let bytes = body.get(offset..offset.checked_add(4)?)?;
+ Some(u32::from_le_bytes(bytes.try_into().ok()?))
+}
+
+fn read_f32(body: &[u8], offset: usize) -> Option<f32> {
+ Some(f32::from_bits(read_u32(body, offset)?))
+}
+
+fn read_vec3(body: &[u8], offset: usize) -> Option<[f32; 3]> {
+ Some([
+ read_f32(body, offset)?,
+ read_f32(body, offset.checked_add(4)?)?,
+ read_f32(body, offset.checked_add(8)?)?,
+ ])
+}
+
+fn read_vec4(body: &[u8], offset: usize) -> Option<[f32; 4]> {
+ Some([
+ read_f32(body, offset)?,
+ read_f32(body, offset.checked_add(4)?)?,
+ read_f32(body, offset.checked_add(8)?)?,
+ read_f32(body, offset.checked_add(12)?)?,
+ ])
+}
+
+fn lerp(start: f32, end: f32, t: f32) -> f32 {
+ start + (end - start) * t
+}
+
+fn lerp3(start: [f32; 3], end: [f32; 3], t: f32) -> [f32; 3] {
+ [
+ lerp(start[0], end[0], t),
+ lerp(start[1], end[1], t),
+ lerp(start[2], end[2], t),
+ ]
+}
+
+fn lerp4(start: [f32; 4], end: [f32; 4], t: f32) -> [f32; 4] {
+ [
+ lerp(start[0], end[0], t),
+ lerp(start[1], end[1], t),
+ lerp(start[2], end[2], t),
+ lerp(start[3], end[3], t),
+ ]
+}
+
+fn transform3(basis: [[f32; 3]; 3], local: [f32; 3]) -> [f32; 3] {
+ [
+ basis[0][0].mul_add(
+ local[0],
+ basis[1][0].mul_add(local[1], basis[2][0] * local[2]),
+ ),
+ basis[0][1].mul_add(
+ local[0],
+ basis[1][1].mul_add(local[1], basis[2][1] * local[2]),
+ ),
+ basis[0][2].mul_add(
+ local[0],
+ basis[1][2].mul_add(local[1], basis[2][2] * local[2]),
+ ),
+ ]
+}
+
+fn sanitize_float(value: f32) -> f32 {
+ if value.is_finite() {
+ value
+ } else {
+ 0.0
+ }
+}
+
+fn sub3(left: [f32; 3], right: [f32; 3]) -> [f32; 3] {
+ [left[0] - right[0], left[1] - right[1], left[2] - right[2]]
+}
+
+fn screen_point(camera: Camera, point: [f32; 3]) -> ([f32; 2], f32) {
+ let delta = sub3(point, camera.position);
+ let depth = dot3(camera.forward, delta);
+ let depth_for_projection = depth.max(f32::EPSILON);
+ let half_vertical = (camera.vertical_fov * 0.5).tan();
+ let half_horizontal = half_vertical * camera.aspect_ratio;
+ let ndc_x = dot3(camera.right, delta) / (depth_for_projection * half_horizontal);
+ let ndc_y = dot3(camera.up, delta) / (depth_for_projection * half_vertical);
+ let x = (ndc_x + 1.0) * 0.5 * camera.viewport[0];
+ let y = (1.0 - ndc_y) * 0.5 * camera.viewport[1];
+ ([x, y], depth)
+}
+
+fn pixel_to_ndc(camera: Camera, point: [f32; 2]) -> [f32; 2] {
+ [
+ point[0] * 2.0 / camera.viewport[0] - 1.0,
+ 1.0 - point[1] * 2.0 / camera.viewport[1],
+ ]
+}
+
+fn screen_billboard(
+ kind: PrecipitationKind,
+ camera: Camera,
+ head_world: [f32; 3],
+ tail_world: [f32; 3],
+ size: f32,
+ index: usize,
+) -> ScreenBillboard {
+ let (head_pixels, depth) = screen_point(camera, head_world);
+ let (tail_pixels, tail_depth) = screen_point(camera, tail_world);
+ let near = PRECIPITATION_NEAR;
+ let extent_x = PRECIPITATION_FAR - PRECIPITATION_NEAR;
+ let depth_fade = (1.0 - (depth - near) / extent_x).max(0.0);
+ let half_size = match kind {
+ PrecipitationKind::Rain => size * 0.5 * depth_fade,
+ PrecipitationKind::Snow => size * 0.5 * depth_fade.max(SNOW_MIN_DEPTH_FADE),
+ };
+ let (corners_pixels, uv) = match kind {
+ PrecipitationKind::Rain => {
+ let direction = [
+ head_pixels[0] - tail_pixels[0],
+ head_pixels[1] - tail_pixels[1],
+ ];
+ let direction_length =
+ (direction[0] * direction[0] + direction[1] * direction[1]).sqrt();
+ let perpendicular = if direction_length.is_finite() && direction_length > f32::EPSILON {
+ [
+ -direction[1] / direction_length,
+ direction[0] / direction_length,
+ ]
+ } else {
+ [1.0, 0.0]
+ };
+ let offset = [perpendicular[0] * half_size, perpendicular[1] * half_size];
+ (
+ [
+ [tail_pixels[0] - offset[0], tail_pixels[1] - offset[1]],
+ [tail_pixels[0] + offset[0], tail_pixels[1] + offset[1]],
+ [head_pixels[0] - offset[0], head_pixels[1] - offset[1]],
+ [head_pixels[0] + offset[0], head_pixels[1] + offset[1]],
+ ],
+ [[0.0, 1.0], [1.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
+ )
+ }
+ PrecipitationKind::Snow => {
+ let signs = [[1.0, -1.0], [1.0, 1.0], [-1.0, -1.0], [-1.0, 1.0]];
+ let mirror = index & 0x10 != 0;
+ let corners = std::array::from_fn(|corner| {
+ let [mut x, mut y] = signs[corner];
+ if mirror {
+ std::mem::swap(&mut x, &mut y);
+ }
+ [
+ head_pixels[0] + x * half_size,
+ head_pixels[1] + y * half_size,
+ ]
+ });
+ let uv = if mirror {
+ [[1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [0.0, 1.0]]
+ } else {
+ [[1.0, 0.0], [1.0, 1.0], [0.0, 0.0], [0.0, 1.0]]
+ };
+ (corners, uv)
+ }
+ };
+ let head = pixel_to_ndc(camera, head_pixels);
+ let tail = pixel_to_ndc(camera, tail_pixels);
+ let corners = corners_pixels.map(|corner| pixel_to_ndc(camera, corner));
+ ScreenBillboard {
+ world_head: head_world,
+ world_tail: tail_world,
+ head,
+ tail,
+ corners,
+ uv,
+ half_size,
+ head_depth: depth,
+ tail_depth,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn active_rain_is_camera_local_and_uses_sky_material() {
+ let mut system = EnvironmentSystem::new(7);
+ let weather = WeatherInput {
+ rain: WeatherState::with_resources(true, 0.5, [0.7, 0.8, 1.0, 0.6], ["atm_rain1.wav"]),
+ ..WeatherInput::clear()
+ };
+ let frame = system.update(1.0, weather.clone(), Camera::new([10.0, -2.0, 4.0]));
+ let particles = frame
+ .primitives
+ .iter()
+ .filter_map(|primitive| match primitive {
+ EnvironmentPrimitive::Particle {
+ kind,
+ position,
+ velocity,
+ material,
+ ..
+ } => Some((*kind, *position, *velocity, material.as_str())),
+ _ => None,
+ })
+ .collect::<Vec<_>>();
+
+ assert_eq!(
+ particles.len(),
+ system.precipitation_volume().particle_count(0.5)
+ );
+ assert!(particles
+ .iter()
+ .all(|(kind, position, velocity, material)| {
+ *kind == PrecipitationKind::Rain
+ && *material == system.materials.material_name(8).unwrap()
+ && *velocity == RAIN_VELOCITY
+ && system
+ .precipitation_volume()
+ .bounds(Camera::new([10.0, -2.0, 4.0]))
+ .contains(*position)
+ }));
+ assert_eq!(frame.sounds.len(), 1);
+ assert_eq!(frame.sounds[0].name, "atm_rain1.wav");
+ assert_eq!(frame.sounds[0].action, SoundAction::StartLoop);
+
+ let update = system.update(0.1, weather, Camera::new([10.0, -2.0, 4.0]));
+ assert_eq!(update.sounds.len(), 1);
+ assert_eq!(update.sounds[0].action, SoundAction::SetLoopVolume);
+ assert_eq!(update.sounds[0].volume, 0.5);
+ }
+
+ #[test]
+ fn precipitation_uses_native_volume_ratio_and_nearest_even_count() {
+ let reference = PrecipitationVolume::native();
+ assert_eq!(reference.particle_count_for(reference, 1.0, 0.5004), 500);
+ assert_eq!(reference.particle_count_for(reference, 1.0, 0.5016), 502);
+
+ let narrow_camera = Camera::with_projection(
+ [0.0; 3],
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0],
+ 0.4875,
+ 1.0,
+ );
+ let current = reference.for_camera(narrow_camera);
+ assert!(reference.particle_count_for(current, 1.0, 1.0) < reference.particle_count(1.0));
+ }
+
+ #[test]
+ fn precipitation_keeps_world_velocity_when_camera_turns_and_wraps() {
+ let mut system = EnvironmentSystem::new(3);
+ let camera = Camera::new([10.0, 20.0, 30.0]);
+ let weather = WeatherInput {
+ rain: WeatherState::new(true, 0.01, [1.0; 4]),
+ ..WeatherInput::clear()
+ };
+ let initial = system.update(0.0, weather.clone(), camera);
+ let initial_position = initial
+ .primitives
+ .iter()
+ .find_map(|primitive| match primitive {
+ EnvironmentPrimitive::Particle { position, .. } => Some(*position),
+ _ => None,
+ })
+ .expect("initial rain particle");
+ let turned_camera = Camera::with_basis(
+ [10.0, 20.0, 30.0],
+ [0.0, 1.0, 0.0],
+ [-1.0, 0.0, 0.0],
+ [0.0, 0.0, 1.0],
+ );
+ let turned = system.update(0.0, weather.clone(), turned_camera);
+ let turned_position = turned
+ .primitives
+ .iter()
+ .find_map(|primitive| match primitive {
+ EnvironmentPrimitive::Particle { position, .. } => Some(*position),
+ _ => None,
+ })
+ .expect("turned rain particle");
+ assert!(sub3(initial_position, turned_position)
+ .into_iter()
+ .all(|component| component.abs() < 1.0e-5));
+
+ let frame = system.update(10.0, weather, camera);
+ let particle = frame
+ .primitives
+ .iter()
+ .find_map(|primitive| match primitive {
+ EnvironmentPrimitive::Particle {
+ kind,
+ position,
+ velocity,
+ size,
+ screen,
+ ..
+ } => Some((*kind, *position, *velocity, *size, *screen)),
+ _ => None,
+ })
+ .expect("rain particle");
+ assert_eq!(particle.0, PrecipitationKind::Rain);
+ assert_eq!(particle.2, RAIN_VELOCITY);
+ assert_eq!(
+ particle.3,
+ camera.precipitation_size_scalar() * RAIN_SIZE_FACTOR
+ );
+ assert!(system
+ .precipitation_volume()
+ .bounds(camera)
+ .contains(particle.1));
+ assert!(particle
+ .4
+ .corners
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite()));
+ }
+
+ #[test]
+ fn precipitation_size_uses_viewport_width_and_horizontal_fov() {
+ let camera = Camera::with_projection(
+ [0.0; 3],
+ [1.0, 0.0, 0.0],
+ [0.0, 1.0, 0.0],
+ [0.0, 0.0, 1.0],
+ 0.975,
+ 16.0 / 9.0,
+ )
+ .with_viewport([1920.0, 1080.0]);
+ let expected_fov = 2.0 * ((0.975_f32 * 0.5).tan() * (16.0_f32 / 9.0)).atan();
+ assert!((camera.horizontal_fov() - expected_fov).abs() < 1.0e-6);
+ assert!((camera.precipitation_size_scalar() - 1920.0 / expected_fov).abs() < 1.0e-4);
+ }
+
+ #[test]
+ fn lightning_fixture_extracts_real_material_and_sound_offsets() {
+ let mut bytes = header(3, 0.75);
+ // The shipped env_lightning FXID uses opcode words 3, 1, and 2 with
+ // bit 8 clear. Bit 8 is a native mode flag, not command enablement.
+ let mut visual = command(0x0003, 200);
+ visual[4..8].copy_from_slice(&1_u32.to_le_bytes());
+ copy_name(&mut visual[4 + 132..4 + 164], b"material.lib");
+ copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning");
+ bytes.extend_from_slice(&visual);
+ let mut opcode1 = command(0x0001, 224);
+ opcode1[4..8].copy_from_slice(&7_u32.to_le_bytes());
+ opcode1[12..16].copy_from_slice(&0.99_f32.to_le_bytes());
+ bytes.extend_from_slice(&opcode1);
+ let mut sound = command(0x0002, 148);
+ copy_name(&mut sound[4 + 80..4 + 112], b"sounds.lib");
+ copy_name(&mut sound[4 + 112..4 + 144], b"atm_light1.wav");
+ bytes.extend_from_slice(&sound);
+
+ let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice()))
+ .expect("env_lightning fixture");
+ assert_eq!(effect.duration_seconds, 0.75);
+ assert_eq!(
+ effect.visual,
+ EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "env_lightning".to_owned()
+ }
+ );
+ assert_eq!(
+ effect.sound,
+ EffectResource {
+ archive: "sounds.lib".to_owned(),
+ name: "atm_light1.wav".to_owned()
+ }
+ );
+ }
+
+ #[test]
+ fn opcode2_sound_parameters_use_native_distance_and_frequency_offsets() {
+ let mut bytes = header(3, 1.0);
+ let mut visual = command(0x0003, 200);
+ visual[4..8].copy_from_slice(&1_u32.to_le_bytes());
+ copy_name(&mut visual[4 + 132..4 + 164], b"material.lib");
+ copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning");
+ bytes.extend_from_slice(&visual);
+ let mut opcode1 = command(0x0001, 224);
+ opcode1[4..8].copy_from_slice(&7_u32.to_le_bytes());
+ bytes.extend_from_slice(&opcode1);
+ let mut sound = command(0x0002, 148);
+ let body = &mut sound[4..];
+ body[4..8].copy_from_slice(&0.0_f32.to_le_bytes());
+ body[8..12].copy_from_slice(&1.0_f32.to_le_bytes());
+ body[60..64].copy_from_slice(&100.0_f32.to_le_bytes());
+ body[64..68].copy_from_slice(&1_500.0_f32.to_le_bytes());
+ body[68..72].copy_from_slice(&0.75_f32.to_le_bytes());
+ body[72..76].copy_from_slice(&1.25_f32.to_le_bytes());
+ body[76..80].copy_from_slice(&0_u32.to_le_bytes());
+ copy_name(&mut body[80..112], b"sounds.lib");
+ copy_name(&mut body[112..144], b"atm_light1.wav");
+ bytes.extend_from_slice(&sound);
+
+ let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice()))
+ .expect("env_lightning sound fixture");
+ assert_eq!(
+ effect.sound_parameters(0.5),
+ Some(SoundParameters {
+ min_distance: 100.0,
+ max_distance: 1_500.0,
+ frequency_ratio: 1.0,
+ })
+ );
+ }
+
+ #[test]
+ fn opcode1_evaluator_emits_native_point_light_groups_and_maps_parent_axis() {
+ let mut bytes = header(3, 0.75);
+ let mut visual = command(0x0003, 200);
+ visual[4..8].copy_from_slice(&1_u32.to_le_bytes());
+ copy_name(&mut visual[4 + 132..4 + 164], b"material.lib");
+ copy_name(&mut visual[4 + 164..4 + 196], b"env_lightning");
+ visual[4 + 4..4 + 8].copy_from_slice(&(-1.0_f32).to_le_bytes());
+ visual[4 + 8..4 + 12].copy_from_slice(&1.0_f32.to_le_bytes());
+ for offset in [96_usize, 100, 104, 108, 112, 116, 120, 124, 128] {
+ visual[4 + offset..4 + offset + 4].copy_from_slice(&1.0_f32.to_le_bytes());
+ }
+ bytes.extend_from_slice(&visual);
+ let mut opcode1 = command(0x0001, 224);
+ let body = &mut opcode1[4..];
+ body[0..4].copy_from_slice(&7_u32.to_le_bytes());
+ body[4..8].copy_from_slice(&0.0_f32.to_le_bytes());
+ body[8..12].copy_from_slice(&1.0_f32.to_le_bytes());
+ body[12..24].copy_from_slice(
+ &[0.0_f32; 3]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[24..36].copy_from_slice(
+ &[2.0_f32, 4.0, 6.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[36..48].copy_from_slice(
+ &[1.0_f32, 0.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[48..60].copy_from_slice(
+ &[0.0_f32, 1.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[60..76].copy_from_slice(
+ &[7.0_f32, 7.0, 10.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[76..92].copy_from_slice(
+ &[0.0_f32, 0.0, 0.0, 0.0]
+ .iter()
+ .flat_map(|v| v.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[108..112].copy_from_slice(&100.0_f32.to_le_bytes());
+ body[112..116].copy_from_slice(&0.0_f32.to_le_bytes());
+ body[116..120].copy_from_slice(&0.25_f32.to_le_bytes());
+ body[120..124].copy_from_slice(&0.25_f32.to_le_bytes());
+ body[124..128].copy_from_slice(&0.75_f32.to_le_bytes());
+ body[128..132].copy_from_slice(&0.75_f32.to_le_bytes());
+ bytes.extend_from_slice(&opcode1);
+ let mut sound = command(0x0002, 148);
+ copy_name(&mut sound[4 + 80..4 + 112], b"sounds.lib");
+ copy_name(&mut sound[4 + 112..4 + 144], b"atm_light1.wav");
+ bytes.extend_from_slice(&sound);
+
+ let effect = decode_env_lightning_fxid(Arc::from(bytes.into_boxed_slice()))
+ .expect("env_lightning evaluator fixture");
+ let basis = [[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]];
+ let frame = effect
+ .evaluate_point_light_with_basis(0.5, [10.0, 20.0, 30.0], basis)
+ .expect("active opcode 1 sample");
+ assert_eq!(frame.effect_type, 7);
+ assert_eq!(frame.position, [12.0, 23.0, 31.0]);
+ let diagonal = 2.0_f32.sqrt().recip();
+ assert!((frame.direction[0] - diagonal).abs() < 1.0e-6);
+ assert_eq!(frame.direction[1], 0.0);
+ assert!((frame.direction[2] - diagonal).abs() < 1.0e-6);
+ assert_eq!(frame.color, [3.5, 3.5, 5.0, 0.0]);
+ assert_eq!(frame.range, 50.0);
+ assert_eq!(frame.attenuation, [0.25, 0.75, 0.75]);
+ assert_eq!(frame.effect_factor, 0.25);
+ assert!(frame.active);
+ let quad = effect
+ .evaluate_quad(0.5, [10.0, 20.0, 330.0], Camera::new([0.0, 0.0, 0.0]))
+ .expect("active opcode 3 sample");
+ assert_eq!(quad.dimensions, LIGHTNING_QUAD_DIMENSIONS);
+ assert_eq!(
+ quad.uv,
+ [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]]
+ );
+ assert_eq!(quad.opacity, 0.25);
+ assert!(quad
+ .vertices
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite()));
+ assert!(effect.evaluate_point_light(1.01, [0.0; 3]).is_none());
+ }
+
+ #[test]
+ fn opcode3_quad_applies_parent_diagonal_before_offset_and_scale() {
+ let mut body = vec![0; 196];
+ body[0..4].copy_from_slice(&1_u32.to_le_bytes());
+ body[4..8].copy_from_slice(&0.0_f32.to_le_bytes());
+ body[8..12].copy_from_slice(&1.0_f32.to_le_bytes());
+ body[36..48].copy_from_slice(
+ &[1.0_f32, 2.0, 0.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[48..60].copy_from_slice(
+ &[1.0_f32, 0.0, 0.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[60..72].copy_from_slice(
+ &[1.0_f32; 3]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[96..108].copy_from_slice(
+ &[2.0_f32, 3.0, 1.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[108..120].copy_from_slice(
+ &[4.0_f32, 5.0, 2.0]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ body[120..132].copy_from_slice(
+ &[1.0_f32; 3]
+ .iter()
+ .flat_map(|value| value.to_le_bytes())
+ .collect::<Vec<_>>(),
+ );
+ let effect = LightningEffect {
+ duration_seconds: 1.0,
+ visual: EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "env_lightning".to_owned(),
+ },
+ visual_body: Arc::from(body.into_boxed_slice()),
+ opcode1_body: Arc::from(vec![0; 132].into_boxed_slice()),
+ opcode1_command_index: 1,
+ sound: EffectResource {
+ archive: "sounds.lib".to_owned(),
+ name: "atm_light1.wav".to_owned(),
+ },
+ sound_body: Arc::from(vec![0; 144].into_boxed_slice()),
+ visual_command_index: 0,
+ sound_command_index: 2,
+ };
+ let quad = effect
+ .evaluate_quad(0.5, [10.0, 20.0, 30.0], Camera::new([130.0, 80.0, 100.0]))
+ .expect("synthetic opcode 3 sample");
+ assert_eq!(quad.local_offset, [1.5, 2.0, 0.0]);
+ assert_eq!(quad.local_scale, [3.0, 4.0, 1.5]);
+ assert_eq!(quad.dimensions, [120.0, 160.0, 900.0]);
+ let center = quad
+ .vertices
+ .iter()
+ .fold([0.0; 3], |sum, vertex| add3(sum, *vertex))
+ .map(|component| component * 0.25);
+ assert_eq!(center, [70.0, 100.0, 30.0]);
+ assert!((quad.vertices[2][2] - quad.vertices[0][2] - 900.0).abs() < 1.0e-5);
+
+ let parallel_camera = Camera::new([70.0, 100.0, 630.0]);
+ assert!(effect
+ .evaluate_quad(0.5, [10.0, 20.0, 30.0], parallel_camera)
+ .is_none());
+ let fallback_camera = Camera::new([70.0, 100.0, 1_230.0]);
+ let fallback = effect
+ .evaluate_quad(0.5, [10.0, 20.0, 30.0], fallback_camera)
+ .expect("non-unit parallel view uses native fallback axis");
+ assert_eq!(fallback.vertices[0][1], fallback.vertices[1][1]);
+ assert!((fallback.vertices[0][0] - fallback.vertices[1][0] - 120.0).abs() < 1.0e-5);
+ }
+
+ #[test]
+ fn lightning_uses_native_delay_cooldown_and_fx_lifetime() {
+ let effect = LightningEffect {
+ duration_seconds: 0.75,
+ visual: EffectResource {
+ archive: "material.lib".to_owned(),
+ name: "env_lightning".to_owned(),
+ },
+ visual_body: {
+ let mut body = vec![0; 196];
+ body[0..4].copy_from_slice(&1_u32.to_le_bytes());
+ body[4..8].copy_from_slice(&(-1.0_f32).to_le_bytes());
+ body[8..12].copy_from_slice(&1.0_f32.to_le_bytes());
+ for offset in [96_usize, 100, 104, 108, 112, 116, 120, 124, 128] {
+ body[offset..offset + 4].copy_from_slice(&1.0_f32.to_le_bytes());
+ }
+ Arc::from(body.into_boxed_slice())
+ },
+ opcode1_body: {
+ let mut body = vec![0; 220];
+ body[8..12].copy_from_slice(&0.75_f32.to_le_bytes());
+ body[36..40].copy_from_slice(&1.0_f32.to_le_bytes());
+ body[128..132].copy_from_slice(&1.0_f32.to_le_bytes());
+ Arc::from(body.into_boxed_slice())
+ },
+ opcode1_command_index: 1,
+ sound: EffectResource {
+ archive: "sounds.lib".to_owned(),
+ name: "atm_light1.wav".to_owned(),
+ },
+ sound_body: Arc::from(vec![0; 144].into_boxed_slice()),
+ visual_command_index: 0,
+ sound_command_index: 2,
+ };
+ let mut system = EnvironmentSystem::new(11);
+ system.set_lightning_effect(effect);
+ let weather = WeatherInput {
+ lightning: WeatherState::new(true, 1.0, [1.0; 4]),
+ ..WeatherInput::clear()
+ };
+ let camera = Camera::new([50.0, 0.0, 0.0]);
+ system.set_lightning_bounds(LightningBounds::new(0.0, 0.0, 0.0, 0.0, 0.0));
+ // The constructor starts in native state 1, which schedules a random
+ // delay. No external trigger sequence is needed.
+ let first = system.update(0.1, weather.clone(), camera);
+ assert!(first.sounds.is_empty());
+ assert_eq!(system.lightning.phase, 2);
+
+ let mut one_shots = 0;
+ let mut effect_frames = 0;
+ let mut point_light_frames = 0;
+ let mut inspected_shape = false;
+ for _ in 0..200 {
+ let frame = system.update(0.1, weather.clone(), camera);
+ point_light_frames += frame.point_lights.len();
+ one_shots += frame
+ .sounds
+ .iter()
+ .filter(|sound| sound.action == SoundAction::OneShot)
+ .count();
+ for primitive in &frame.primitives {
+ if let EnvironmentPrimitive::Lightning(bolt) = primitive {
+ inspected_shape = true;
+ assert_eq!(bolt.quad.dimensions, LIGHTNING_QUAD_DIMENSIONS);
+ assert!(bolt
+ .quad
+ .vertices
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite()));
+ assert_eq!(
+ bolt.quad.uv,
+ [[0.0, 0.99], [0.0, 0.0], [0.99, 0.0], [0.99, 0.99]]
+ );
+ assert!(bolt.age_seconds >= 0.0 && bolt.age_seconds < bolt.duration_seconds);
+ assert_eq!(bolt.duration_seconds, 0.75);
+ effect_frames += 1;
+ }
+ }
+ }
+ assert!(one_shots >= 2, "native timer must repeat lightning");
+ assert!(effect_frames > 0);
+ assert!(inspected_shape);
+ assert!(point_light_frames > 0);
+
+ let clear = system.update(0.1, WeatherInput::clear(), camera);
+ assert!(clear.primitives.is_empty());
+ assert!(clear.point_lights.is_empty());
+ assert!(clear.sounds.is_empty());
+ }
+
+ #[test]
+ fn fractional_milliseconds_do_not_drift_at_120_fps() {
+ let mut system = EnvironmentSystem::new(1);
+ for _ in 0..120 {
+ let _ = system.update(1.0 / 120.0, WeatherInput::clear(), Camera::default());
+ }
+ assert_eq!(system.time_millis(), 1_000);
+ }
+
+ fn header(command_count: u32, duration: f32) -> Vec<u8> {
+ let mut out = Vec::with_capacity(60);
+ out.extend_from_slice(&command_count.to_le_bytes());
+ out.extend_from_slice(&1_u32.to_le_bytes());
+ out.extend_from_slice(&duration.to_bits().to_le_bytes());
+ out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes());
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ out.extend_from_slice(&0_u32.to_le_bytes());
+ for _ in 0..9 {
+ out.extend_from_slice(&0.0_f32.to_bits().to_le_bytes());
+ }
+ out
+ }
+
+ fn command(word: u32, size: usize) -> Vec<u8> {
+ let mut out = vec![0; size];
+ out[..4].copy_from_slice(&word.to_le_bytes());
+ out
+ }
+
+ fn copy_name(dst: &mut [u8], name: &[u8]) {
+ dst[..name.len()].copy_from_slice(name);
+ }
+}
diff --git a/crates/fparkan-fx/src/lib.rs b/crates/fparkan-fx/src/lib.rs
index 44b1a59..437ae5c 100644
--- a/crates/fparkan-fx/src/lib.rs
+++ b/crates/fparkan-fx/src/lib.rs
@@ -1,23 +1,4 @@
#![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
@@ -25,6 +6,11 @@
//! reference stub until opcode timing, gates, RNG, and command-body semantics
//! are backed by runtime-captured evidence.
+pub mod atmosphere;
+pub mod environment;
+pub mod shadow;
+pub mod sky;
+
use fparkan_binary::{Cursor, DecodeError};
use std::sync::Arc;
@@ -104,8 +90,13 @@ pub struct FxCommand {
pub word: u32,
/// Decoded opcode.
pub opcode: FxOpcode,
- /// Enabled bit.
- pub enabled: bool,
+ /// Native command flag stored in bit 8 of the command word.
+ ///
+ /// This flag is passed to the native command object as a mode/feature
+ /// flag. It does not disable the command: real effects such as
+ /// `env_lightning` use zero for all three command flags and still execute
+ /// opcode 3, opcode 1, and opcode 2.
+ pub native_flag: bool,
/// Command body after the word.
pub raw_body: Arc<[u8]>,
/// Resource references discovered in known command layouts.
@@ -333,7 +324,7 @@ pub fn decode_fxid(bytes: Arc<[u8]>) -> Result<FxDocument, FxError> {
commands.push(FxCommand {
word,
opcode,
- enabled: ((word >> 8) & 1) != 0,
+ native_flag: ((word >> 8) & 1) != 0,
raw_body,
resource_refs,
});
@@ -388,9 +379,6 @@ pub fn emit(state: &FxState, out: &mut Vec<FxEmission>) -> Result<(), FxError> {
return Ok(());
}
for (index, command) in state.document.commands.iter().enumerate() {
- if !command.enabled {
- continue;
- }
let command_index = u32::try_from(index).map_err(|_| DecodeError::IntegerOverflow)?;
if command.opcode == FxOpcode::Op2 {
out.push(FxEmission::Sound(FxSoundEvent { command_index }));
@@ -624,7 +612,7 @@ mod tests {
assert_eq!(document.header().command_count, 2);
assert_eq!(document.commands()[0].opcode, FxOpcode::Op2);
- assert!(document.commands()[0].enabled);
+ assert!(document.commands()[0].native_flag);
assert_eq!(
document.commands()[0].resource_refs[0].archive_name(),
b"sounds.lib"
@@ -660,6 +648,25 @@ mod tests {
}
#[test]
+ fn zero_native_flags_still_emit_every_command() {
+ let mut bytes = header(3);
+ bytes.extend_from_slice(&command(0x0003, 200));
+ bytes.extend_from_slice(&command(0x0001, 224));
+ bytes.extend_from_slice(&command(0x0002, 148));
+ let document = Arc::new(decode_fxid(Arc::from(bytes.into_boxed_slice())).expect("fx"));
+
+ assert!(document
+ .commands()
+ .iter()
+ .all(|command| !command.native_flag));
+ let state = create_instance(document, FxSeed(9), Transform::default()).expect("state");
+ assert_eq!(
+ canonical_emission_capture(&state).expect("capture"),
+ b"P,0, Op3\nP,1, Op1\nS,2\n"
+ );
+ }
+
+ #[test]
fn rejects_unknown_opcode_at_command_index() {
let mut bytes = header(1);
bytes.extend_from_slice(&99_u32.to_le_bytes());
diff --git a/crates/fparkan-fx/src/shadow.rs b/crates/fparkan-fx/src/shadow.rs
new file mode 100644
index 0000000..64e2b8e
--- /dev/null
+++ b/crates/fparkan-fx/src/shadow.rs
@@ -0,0 +1,1785 @@
+//! CPU reference implementation of the native projected shadow path.
+//!
+//! The original renderer keeps shadow pages in one 256 by 256 ARGB4444
+//! texture. This module deliberately keeps that work independent from a
+//! graphics backend: callers provide world-space caster and receiver
+//! triangles, and receive an RGBA8 atlas plus ordinary projected geometry.
+
+use std::error::Error;
+use std::fmt;
+
+/// Native shadow atlas width in texels.
+pub const SHADOW_ATLAS_WIDTH: usize = 256;
+/// Native shadow atlas height in texels.
+pub const SHADOW_ATLAS_HEIGHT: usize = 256;
+/// Number of native page slots.
+pub const SHADOW_PAGE_COUNT: usize = 28;
+/// Maximum number of shadow casters considered by the native frame builder.
+///
+/// The atlas has 28 physical slots, but the native `CShade` object allocates
+/// only 20 live shadow records per frame.
+pub const SHADOW_MAX_ACTIVE_CASTERS: usize = 20;
+/// Native primary directional-light flag.
+pub const SHADOW_PRIMARY_DIRECTIONAL_FLAG: u32 = 0x0800_0000;
+/// Full receiver flag used by the ordinary shadow query.
+///
+/// `ShadowTriangle::mask` contains the full decoded face flags. The native
+/// general query excludes a triangle when this bit is present.
+pub const SHADOW_RECEIVER_MASK: u32 = 0x2000;
+/// Compact query mask passed to the native four-ray world call.
+pub const SHADOW_FOUR_RAY_QUERY_MASK_COMPACT: u32 = 0x8008;
+/// Full receiver flags produced by expanding [`SHADOW_FOUR_RAY_QUERY_MASK_COMPACT`].
+///
+/// Terrain expands the compact descriptor before testing a receiver triangle,
+/// so this is the mask that belongs in `ShadowTriangle::mask` comparisons.
+pub const SHADOW_FOUR_RAY_MASK: u32 = 0x0020_0020;
+const SHADOW_PROJECTION_EPSILON: f32 = -1.175_494_4e-37;
+const SHADOW_CLIP_EPSILON: f32 = f32::MIN_POSITIVE;
+const SHADOW_QUAD_UV_MAX: f32 = 0.99;
+
+/// Bounding sphere used by native light selection and page projection.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowSphere {
+ /// World-space center.
+ pub center: [f32; 3],
+ /// Radius in world units.
+ pub radius: f32,
+}
+
+/// World-space triangle used by a caster or receiver.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowTriangle {
+ /// Triangle positions in world space.
+ pub positions: [[f32; 3]; 3],
+ /// World-space normal. `new` derives this value when the caller does not
+ /// already have the native normal.
+ pub normal: [f32; 3],
+ /// Full decoded native face flags used as receiver exclusion bits.
+ pub mask: u32,
+}
+
+/// A world-space shadow caster.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ShadowCaster {
+ /// Stable caster id from the caller's object table.
+ pub id: u32,
+ /// Native object axes. Each entry is one world-space axis.
+ pub world_axes: [[f32; 3]; 3],
+ /// Native bounding sphere.
+ pub sphere: ShadowSphere,
+ /// Native object kind (`3` is the building path and `4` is the four-ray
+ /// actor path).
+ pub native_kind: u32,
+ /// Entire visible UNIT triangle set in world space.
+ pub triangles: Vec<ShadowTriangle>,
+}
+
+/// Camera values consumed by the native projected-size shadow LOD test.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowCamera {
+ /// World-space camera position.
+ pub position: [f32; 3],
+ /// Viewport width in pixels.
+ pub viewport_width: f32,
+ /// Horizontal field of view in radians.
+ pub horizontal_fov: f32,
+}
+
+/// Native shadow quality controls used by the page allocator and LOD test.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowLodSettings {
+ /// Minimum building LOD (`kind == 3`).
+ pub building_min_lod: u8,
+ /// Building screen-size multiplier.
+ pub building_detail: f32,
+ /// Minimum BTLU/robot LOD (`kind == 4`).
+ pub robot_min_lod: u8,
+ /// Robot screen-size multiplier.
+ pub robot_detail: f32,
+}
+
+impl Default for ShadowLodSettings {
+ fn default() -> Self {
+ Self {
+ building_min_lod: 1,
+ building_detail: 1.0,
+ robot_min_lod: 1,
+ robot_detail: 1.0,
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ShadowCacheSlot {
+ caster: u32,
+ age: u32,
+}
+
+/// Reusable native page ownership state.
+///
+/// The original allocator keeps page ownership and an age counter between
+/// frames. Keeping that small piece of state separate from the CPU raster
+/// output lets callers rebuild the atlas without changing a caster's UV page
+/// whenever the camera moves.
+#[derive(Clone, Debug)]
+pub struct ShadowPageCache {
+ slots: [Option<ShadowCacheSlot>; SHADOW_PAGE_COUNT],
+ /// Slots claimed while building the current frame. A slot already used
+ /// by an earlier candidate must not be evicted again by a later one.
+ frame_used: [bool; SHADOW_PAGE_COUNT],
+}
+
+impl Default for ShadowPageCache {
+ fn default() -> Self {
+ Self {
+ slots: [None; SHADOW_PAGE_COUNT],
+ frame_used: [false; SHADOW_PAGE_COUNT],
+ }
+ }
+}
+
+impl ShadowPageCache {
+ /// Builds a frame while preserving page ownership across calls.
+ pub fn build(&mut self, scene: ShadowScene<'_>) -> Result<ShadowFrame, ShadowError> {
+ self.begin_frame();
+ build_shadow_frame_inner(scene, Some(self))
+ }
+
+ /// Clears all page ownership. The next build will allocate from the
+ /// native preferred page groups again.
+ pub fn clear(&mut self) {
+ self.slots = [None; SHADOW_PAGE_COUNT];
+ self.frame_used = [false; SHADOW_PAGE_COUNT];
+ }
+
+ /// Returns the cached page slot for a caster, if it currently owns one.
+ #[must_use]
+ pub fn page_slot(&self, caster: u32) -> Option<usize> {
+ self.slots
+ .iter()
+ .enumerate()
+ .filter_map(|(index, slot)| {
+ slot.filter(|slot| slot.caster == caster)
+ .map(|slot| (index, slot.age))
+ })
+ .min_by_key(|&(_, age)| age)
+ .map(|(index, _)| index)
+ }
+
+ fn begin_frame(&mut self) {
+ self.frame_used = [false; SHADOW_PAGE_COUNT];
+ for slot in &mut self.slots {
+ if let Some(slot) = slot {
+ slot.age = slot.age.saturating_add(1);
+ }
+ }
+ }
+
+ fn assign(&mut self, caster: u32, lod: u8) -> Option<usize> {
+ // A native candidate is submitted at most once per frame. Preserve
+ // that invariant even when a caller repeats an id: falling through
+ // from the preferred group to the fallback group must not allocate a
+ // second page for the same caster.
+ if self.slots.iter().enumerate().any(|(index, slot)| {
+ self.frame_used[index] && slot.is_some_and(|slot| slot.caster == caster)
+ }) {
+ return None;
+ }
+ let large = lod <= 1;
+ let preferred = if large { 0..12 } else { 12..28 };
+ let fallback = if large { 12..28 } else { 0..28 };
+ let threshold = if large { 1 } else { 6 };
+ self.assign_range(caster, preferred, 6)
+ .or_else(|| self.assign_range(caster, fallback, threshold))
+ }
+
+ /// Applies the native allocator's inclusive-range search:
+ /// existing owner, first free slot, then the oldest eligible slot. A
+ /// current-frame slot is never eligible for reuse.
+ fn assign_range(
+ &mut self,
+ caster: u32,
+ range: std::ops::Range<usize>,
+ threshold: u32,
+ ) -> Option<usize> {
+ let mut oldest = None;
+ // 30BE0 scans the inclusive page range once. An existing owner or
+ // the first free page wins immediately; only an occupied page past
+ // the age threshold is deferred until the complete scan.
+ for index in range {
+ if self.frame_used[index] {
+ continue;
+ }
+ match self.slots[index] {
+ Some(slot) if slot.caster == caster => return self.claim(index, caster),
+ None => return self.claim(index, caster),
+ Some(slot) if slot.age >= threshold => {
+ // Native max-age selection keeps the later physical page
+ // on ties.
+ if oldest.is_none_or(|(age, old_index)| {
+ slot.age > age || (slot.age == age && index > old_index)
+ }) {
+ oldest = Some((slot.age, index));
+ }
+ }
+ Some(_) => {}
+ }
+ }
+ oldest.and_then(|(_, index)| self.claim(index, caster))
+ }
+
+ fn claim(&mut self, index: usize, caster: u32) -> Option<usize> {
+ if self.frame_used.get(index).copied().unwrap_or(true) {
+ return None;
+ }
+ self.frame_used[index] = true;
+ self.slots[index] = Some(ShadowCacheSlot { caster, age: 0 });
+ Some(index)
+ }
+}
+
+/// Native light records that can contribute to projected shadows.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum ShadowLight {
+ /// A directional sun or moon light.
+ Directional {
+ /// Native world-space direction.
+ direction: [f32; 3],
+ /// RGB intensity.
+ rgb: [f32; 3],
+ /// Whether the native slot is live.
+ active: bool,
+ /// Native light flags.
+ flags: u32,
+ },
+ /// A native point light.
+ Point {
+ /// World-space point-light position.
+ position: [f32; 3],
+ /// RGB intensity.
+ rgb: [f32; 3],
+ /// Light range.
+ range: f32,
+ /// Native attenuation coefficients.
+ coefficients: [f32; 3],
+ /// Whether the native slot is live.
+ active: bool,
+ /// Native light flags.
+ flags: u32,
+ },
+}
+
+/// The projection transform and opacity used by one atlas page.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ShadowProjection {
+ /// Row-major matrix from shadow-local coordinates to world space.
+ pub world_from_shadow: [f32; 16],
+ /// Row-major matrix from world space to shadow-local coordinates.
+ pub shadow_from_world: [f32; 16],
+ /// Shadow axis (`M.Z`) used by the native receiver test.
+ pub shadow_axis: [f32; 3],
+ /// Shadow opacity before the receiver normal term.
+ pub opacity: f32,
+ /// Upper and lower native extrusion distances along `shadow_axis`.
+ pub upper_distance: f32,
+ pub lower_distance: f32,
+ /// Maximum light strength used by receiver opacity.
+ light_strength: f32,
+}
+
+/// One occupied native atlas page.
+#[derive(Clone, Copy, Debug, PartialEq)]
+struct ShadowPage {
+ /// Top-left atlas origin.
+ pub origin: [u32; 2],
+ /// Page side in texels, either 64 or 32.
+ pub size: u32,
+ /// Caster assigned to this page.
+ caster: u32,
+ /// Native projection transform for this page.
+ pub projection: ShadowProjection,
+}
+
+/// One vertex of a projected receiver shadow.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct ShadowVertex {
+ /// World-space receiver position.
+ pub position: [f32; 3],
+ /// Normalized UV into the 256 by 256 atlas.
+ pub uv: [f32; 2],
+ /// Receiver alpha after native light and normal terms.
+ pub alpha: f32,
+ /// Receiver normal passed to the backend.
+ pub normal: [f32; 3],
+}
+
+/// CPU-produced shadow frame.
+#[derive(Clone, Debug, PartialEq)]
+pub struct ShadowFrame {
+ /// RGBA8 atlas. Native `F000` texels become `[0, 0, 0, 255]`.
+ pub atlas_rgba8: Vec<u8>,
+ /// Projected receiver vertices.
+ pub vertices: Vec<ShadowVertex>,
+ /// Triangle-list indices into [`ShadowFrame::vertices`].
+ pub indices: Vec<u32>,
+}
+
+/// Inputs for one shadow build.
+pub struct ShadowScene<'a> {
+ /// Active light records.
+ pub lights: &'a [ShadowLight],
+ /// Visible world-space casters.
+ pub casters: &'a [ShadowCaster],
+ /// World-space receiver triangles.
+ pub receivers: &'a [ShadowTriangle],
+ /// Native fade multiplier before the `0.2` opacity scale.
+ pub fade: f32,
+ /// Native blur passes: zero, forward once, or forward then reverse.
+ pub smooth_passes: u32,
+ /// Camera used for native projected-size LOD selection. `None` keeps the
+ /// CPU reference useful for callers that already performed visibility and
+ /// LOD selection upstream.
+ pub camera: Option<ShadowCamera>,
+ /// Native shadow quality controls.
+ pub lod: ShadowLodSettings,
+}
+
+/// Shadow build failures at the renderer trust boundary.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum ShadowError {
+ /// A caller supplied a non-finite or otherwise unusable value.
+ InvalidInput(&'static str),
+}
+
+impl fmt::Display for ShadowError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidInput(field) => write!(f, "invalid shadow input: {field}"),
+ }
+ }
+}
+
+impl Error for ShadowError {}
+
+/// Builds one native-style projected shadow frame.
+pub fn build_shadow_frame(scene: ShadowScene<'_>) -> Result<ShadowFrame, ShadowError> {
+ build_shadow_frame_inner(scene, None)
+}
+
+fn build_shadow_frame_inner(
+ scene: ShadowScene<'_>,
+ mut cache: Option<&mut ShadowPageCache>,
+) -> Result<ShadowFrame, ShadowError> {
+ if !scene.fade.is_finite() || scene.fade < 0.0 {
+ return Err(ShadowError::InvalidInput("fade"));
+ }
+ if let Some(camera) = scene.camera {
+ if !camera.position.iter().all(|value| value.is_finite())
+ || !camera.viewport_width.is_finite()
+ || camera.viewport_width <= 0.0
+ || !camera.horizontal_fov.is_finite()
+ || camera.horizontal_fov <= 0.0
+ {
+ return Err(ShadowError::InvalidInput("shadow camera"));
+ }
+ }
+ if !scene.lod.building_detail.is_finite()
+ || scene.lod.building_detail <= 0.0
+ || !scene.lod.robot_detail.is_finite()
+ || scene.lod.robot_detail <= 0.0
+ {
+ return Err(ShadowError::InvalidInput("shadow LOD settings"));
+ }
+ for light in scene.lights {
+ validate_light(light)?;
+ }
+ for receiver in scene.receivers {
+ validate_triangle(receiver, "receiver")?;
+ }
+
+ let mut frame = ShadowFrame {
+ atlas_rgba8: vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4],
+ vertices: Vec::new(),
+ indices: Vec::new(),
+ };
+ let mut pages = Vec::new();
+
+ let mut candidates = Vec::with_capacity(scene.casters.len().min(SHADOW_MAX_ACTIVE_CASTERS));
+ for caster in scene.casters {
+ validate_caster(caster)?;
+ let lod = scene
+ .camera
+ .map(|camera| projected_shadow_lod(caster, camera, scene.lod))
+ .unwrap_or(0);
+ if lod >= 8 {
+ continue;
+ }
+ candidates.push((caster, lod));
+ if candidates.len() == SHADOW_MAX_ACTIVE_CASTERS {
+ break;
+ }
+ }
+
+ let mut assignments = Vec::with_capacity(candidates.len());
+ for (caster, lod) in candidates {
+ let slot = match cache.as_deref_mut() {
+ Some(cache) => {
+ let Some(slot) = cache.assign(caster.id, lod) else {
+ // A page that is still too young, or a duplicate caster
+ // in this frame, is omitted by the native allocator. Do
+ // not invent a stateless slot: it could overwrite a page
+ // already assigned to an earlier candidate.
+ continue;
+ };
+ slot
+ }
+ None => assignments.len(),
+ };
+ assignments.push((slot, caster, lod));
+ }
+
+ // Terrain owns the candidate list and performs native radius/distance
+ // selection. The CPU contract therefore consumes the visible list in
+ // caller order while preserving the native 20-record frame budget. A
+ // cache supplies stable physical page slots; the stateless wrapper above
+ // retains the original deterministic slot order for simple callers.
+ for (slot, caster, _lod) in assignments {
+ let Some(contribution) = select_light(caster, scene.lights) else {
+ continue;
+ };
+ let (origin, size) = native_page_layout(slot);
+ let projection = make_projection(caster, contribution, scene.fade)?;
+ rasterize_page(
+ &mut frame.atlas_rgba8,
+ origin,
+ size,
+ caster,
+ projection,
+ scene.smooth_passes,
+ );
+ pages.push(ShadowPage {
+ origin: [origin[0] as u32, origin[1] as u32],
+ size: size as u32,
+ caster: caster.id,
+ projection,
+ });
+ }
+
+ // Page blur happens after all page rasterization and before receiver UVs
+ // are emitted, matching the native texture upload order.
+ for page in &pages {
+ project_receivers(&mut frame, page, scene.receivers, scene.casters);
+ }
+ Ok(frame)
+}
+
+/// Returns the fixed native page layout, including unused slots.
+#[cfg(test)]
+fn native_shadow_pages() -> Vec<([u32; 2], u32)> {
+ (0..SHADOW_PAGE_COUNT)
+ .map(native_page_layout)
+ .map(|(origin, size)| ([origin[0] as u32, origin[1] as u32], size as u32))
+ .collect()
+}
+
+#[derive(Clone, Copy, Debug)]
+struct LightContribution {
+ direction: [f32; 3],
+ strength: f32,
+}
+
+fn select_light(caster: &ShadowCaster, lights: &[ShadowLight]) -> Option<LightContribution> {
+ let center = caster.sphere.center;
+ let radius = caster.sphere.radius;
+ if caster.native_kind == 3 {
+ for light in lights {
+ if let ShadowLight::Directional {
+ direction,
+ rgb,
+ active: true,
+ flags,
+ } = *light
+ {
+ if flags & SHADOW_PRIMARY_DIRECTIONAL_FLAG == 0 {
+ continue;
+ }
+ let direction = normalize([direction[0], direction[1], direction[2] - 0.3])?;
+ let strength = length(rgb);
+ if strength > 0.0 && strength.is_finite() {
+ return Some(LightContribution {
+ direction,
+ strength,
+ });
+ }
+ }
+ }
+ return None;
+ }
+
+ let mut direction_sum = [0.0; 3];
+ let mut max_strength: f32 = 0.0;
+ for light in lights {
+ match *light {
+ ShadowLight::Directional {
+ direction,
+ rgb,
+ active: true,
+ flags,
+ } if flags & SHADOW_PRIMARY_DIRECTIONAL_FLAG != 0 => {
+ let direction = [direction[0], direction[1], direction[2] - 0.3];
+ let strength = length(rgb);
+ if strength > 0.0 && strength.is_finite() {
+ direction_sum = add(direction_sum, direction);
+ max_strength = max_strength.max(strength);
+ }
+ }
+ ShadowLight::Point {
+ position,
+ rgb,
+ range,
+ coefficients,
+ active: true,
+ flags,
+ } if flags & 0x8000_0000 == 0 && range >= 10.0 => {
+ let to_center = sub(center, position);
+ let distance = length(to_center);
+ if !(distance < range) || !distance.is_finite() {
+ continue;
+ }
+ let t = (range - distance) / range;
+ let strength = length(rgb)
+ * (coefficients[0] + coefficients[1] * t + coefficients[2] * t * t)
+ * 1.5;
+ if !(strength > 0.0) || !strength.is_finite() {
+ continue;
+ }
+ let Some(direction) = normalize(to_center) else {
+ continue;
+ };
+ let Some(direction) = normalize([direction[0], direction[1], -0.3]) else {
+ continue;
+ };
+ let contribution = (strength / 15.0 * range / (2.0 * radius)).min(2.0);
+ direction_sum = add(direction_sum, scale(direction, contribution));
+ max_strength = max_strength.max(strength);
+ }
+ _ => {}
+ }
+ }
+ // The ordinary-object path retains the magnitude of the accumulated
+ // vector; native uses it again in the receiver normal term.
+ if length(direction_sum) <= 1.0e-6 || !length(direction_sum).is_finite() {
+ return None;
+ }
+ Some(LightContribution {
+ direction: direction_sum,
+ strength: max_strength,
+ })
+}
+
+fn make_projection(
+ caster: &ShadowCaster,
+ light: LightContribution,
+ fade: f32,
+) -> Result<ShadowProjection, ShadowError> {
+ let center = caster.sphere.center;
+ let radius = caster.sphere.radius;
+ let light_direction =
+ normalize(light.direction).ok_or(ShadowError::InvalidInput("light direction"))?;
+ // Kind 3 arrives unit length. Other kinds intentionally keep the
+ // accumulated native vector magnitude in M.Z.
+ let shadow_axis = scale(light.direction, -1.0);
+ let mut axis = caster.world_axes[0];
+ let mut best = f32::INFINITY;
+ for candidate in caster.world_axes {
+ let Some(candidate) = normalize(candidate) else {
+ continue;
+ };
+ let score = dot(candidate, light_direction).abs();
+ if score < best {
+ best = score;
+ axis = candidate;
+ }
+ }
+ let mut u = normalize(cross(axis, shadow_axis));
+ if u.is_none() {
+ for candidate in identity_axes() {
+ u = normalize(cross(candidate, shadow_axis));
+ if u.is_some() {
+ break;
+ }
+ }
+ }
+ let u = u.ok_or(ShadowError::InvalidInput("world axes"))?;
+ let v = normalize(cross(shadow_axis, u)).ok_or(ShadowError::InvalidInput("shadow basis"))?;
+ let x_column = scale(v, 2.0 * radius);
+ let y_column = scale(u, -2.0 * radius);
+ let z_column = shadow_axis;
+ let world_from_shadow = matrix_from_columns(x_column, y_column, z_column, center);
+ let shadow_from_world = inverse_shadow_matrix(v, u, shadow_axis, center, radius);
+ Ok(ShadowProjection {
+ world_from_shadow,
+ shadow_from_world,
+ shadow_axis,
+ opacity: (fade * light.strength * 0.2).min(1.0),
+ upper_distance: 4.0 * radius,
+ lower_distance: -100.0,
+ light_strength: light.strength,
+ })
+}
+
+fn projected_shadow_lod(
+ caster: &ShadowCaster,
+ camera: ShadowCamera,
+ settings: ShadowLodSettings,
+) -> u8 {
+ let offset = sub(camera.position, caster.sphere.center);
+ let distance_squared = dot(offset, offset);
+ let radius_squared = caster.sphere.radius * caster.sphere.radius;
+ let measure = if distance_squared < radius_squared {
+ camera.viewport_width
+ } else {
+ let tangent_distance = (distance_squared - radius_squared).max(0.0).sqrt();
+ caster.sphere.radius.atan2(tangent_distance) * camera.viewport_width / camera.horizontal_fov
+ };
+ if !measure.is_finite() {
+ return 8;
+ }
+ match caster.native_kind {
+ 4 => {
+ let computed = if measure < 4.0 {
+ 8
+ } else if measure >= 70.0 {
+ 0
+ } else {
+ let scaled = measure * settings.robot_detail;
+ if scaled < 8.0 {
+ 3
+ } else if scaled < 30.0 {
+ 2
+ } else {
+ 1
+ }
+ };
+ computed.max(settings.robot_min_lod)
+ }
+ 3 => {
+ let computed = if measure < 10.0 {
+ 8
+ } else if measure >= 120.0 {
+ 1
+ } else {
+ let scaled = measure * settings.building_detail;
+ if scaled < 40.0 {
+ 3
+ } else if scaled < 80.0 {
+ 2
+ } else {
+ 1
+ }
+ };
+ computed.max(settings.building_min_lod)
+ }
+ _ => 8,
+ }
+}
+
+fn native_page_layout(index: usize) -> ([usize; 2], usize) {
+ // This is the physical order emitted by Terrain's page allocator, not a
+ // row-major packing approximation. The non-monotonic order is observable
+ // in the native page table and is part of the texture contract.
+ const LARGE: [[usize; 2]; 12] = [
+ [0, 0],
+ [0, 64],
+ [64, 64],
+ [64, 0],
+ [0, 128],
+ [0, 192],
+ [64, 192],
+ [64, 128],
+ [128, 0],
+ [128, 64],
+ [192, 64],
+ [192, 0],
+ ];
+ const SMALL: [[usize; 2]; 16] = [
+ [128, 128],
+ [128, 160],
+ [160, 160],
+ [160, 128],
+ [128, 192],
+ [128, 224],
+ [160, 224],
+ [160, 192],
+ [192, 192],
+ [192, 224],
+ [224, 224],
+ [224, 192],
+ [192, 128],
+ [192, 160],
+ [224, 160],
+ [224, 128],
+ ];
+ if index < LARGE.len() {
+ (LARGE[index], 64)
+ } else {
+ (SMALL[index - LARGE.len()], 32)
+ }
+}
+
+/// Matches the x86 `cvtt` helper used by Ngi103F0 with the renderer's
+/// default nearest-even rounding mode. `f32::round` is ties-away-from-zero,
+/// so it cannot be used for the native half-pixel boundaries.
+fn native_round(value: f32) -> isize {
+ debug_assert!(value.is_finite());
+ let lower = value.floor();
+ let fraction = value - lower;
+ let rounded = if fraction < 0.5 {
+ lower
+ } else if fraction > 0.5 {
+ lower + 1.0
+ } else if (lower as i64) & 1 == 0 {
+ lower
+ } else {
+ lower + 1.0
+ };
+ rounded as isize
+}
+
+fn rasterize_page(
+ atlas: &mut [u8],
+ origin: [usize; 2],
+ size: usize,
+ caster: &ShadowCaster,
+ projection: ShadowProjection,
+ smooth_passes: u32,
+) {
+ for triangle in &caster.triangles {
+ let points = triangle
+ .positions
+ .map(|position| transform_point(projection.shadow_from_world, position));
+ let area = cross2(sub2(points[1], points[0]), sub2(points[2], points[0]));
+ if !area.is_finite() || area < 0.0 {
+ continue;
+ }
+
+ // Ngi103F0 converts the projected endpoints with the active nearest,
+ // ties-to-even rounding mode. The raster loop visits rows below the
+ // rounded upper endpoint; evaluating the edge slopes at `y + 0.5`
+ // reproduces the native pixel-center scanline.
+ let min_y = points
+ .iter()
+ .map(|point| point[1])
+ .fold(f32::INFINITY, f32::min);
+ let max_y = points
+ .iter()
+ .map(|point| point[1])
+ .fold(f32::NEG_INFINITY, f32::max);
+ let first_y = native_round((min_y + 0.5) * size as f32);
+ let last_y = native_round((max_y + 0.5) * size as f32);
+ let first_y = first_y.max(0);
+ let last_y = last_y.min(size as isize);
+ if first_y >= last_y {
+ continue;
+ }
+ for y in first_y as usize..last_y as usize {
+ let local_y = (y as f32 + 0.5) / size as f32 - 0.5;
+ let mut intersections = [0.0_f32; 3];
+ let mut count = 0;
+ for edge in [(0usize, 1usize), (1, 2), (2, 0)] {
+ let a = points[edge.0];
+ let b = points[edge.1];
+ if ((a[1] <= local_y && b[1] > local_y) || (b[1] <= local_y && a[1] > local_y))
+ && count < intersections.len()
+ {
+ let t = (local_y - a[1]) / (b[1] - a[1]);
+ intersections[count] = a[0] + (b[0] - a[0]) * t;
+ count += 1;
+ }
+ }
+ if count < 2 {
+ continue;
+ }
+ let min_x = intersections[..count]
+ .iter()
+ .copied()
+ .fold(f32::INFINITY, f32::min);
+ let max_x = intersections[..count]
+ .iter()
+ .copied()
+ .fold(f32::NEG_INFINITY, f32::max);
+
+ // Ngi103F0 is also used for both X intersections. The native
+ // fill includes the rounded right endpoint, which matters for
+ // half-pixel and zero-width scanline cases.
+ let first = native_round((min_x + 0.5) * size as f32);
+ let last = native_round((max_x + 0.5) * size as f32);
+ let first = first.max(0);
+ let last = last.min(size as isize - 1);
+ if first > last {
+ continue;
+ }
+ for x in first as usize..=last as usize {
+ write_alpha_nibble(atlas, origin, size, x, y, 15);
+ }
+ }
+ }
+ if smooth_passes > 0 {
+ blur_page(atlas, origin, size, false);
+ }
+ if smooth_passes > 1 {
+ blur_page(atlas, origin, size, true);
+ }
+}
+
+fn blur_page(atlas: &mut [u8], origin: [usize; 2], size: usize, reverse: bool) {
+ if size < 2 {
+ return;
+ }
+
+ // FastProc3A368/24510 operates in place. The forward pass writes the
+ // top-left texel of each 2x2 window in row-major order; the last row and
+ // column are never visited. The optional reverse pass mirrors that
+ // traversal and writes the bottom-right texel, leaving the first row and
+ // column untouched. Native integer division always uses four samples,
+ // including at the page boundary.
+ if reverse {
+ for y in (1..size).rev() {
+ for x in (1..size).rev() {
+ let total = u32::from(read_alpha_nibble(atlas, origin, size, x - 1, y - 1))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x, y - 1))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x - 1, y))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x, y));
+ write_alpha_nibble(atlas, origin, size, x, y, (total / 4) as u8);
+ }
+ }
+ } else {
+ for y in 0..size - 1 {
+ for x in 0..size - 1 {
+ let total = u32::from(read_alpha_nibble(atlas, origin, size, x, y))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x + 1, y))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x, y + 1))
+ + u32::from(read_alpha_nibble(atlas, origin, size, x + 1, y + 1));
+ write_alpha_nibble(atlas, origin, size, x, y, (total / 4) as u8);
+ }
+ }
+ }
+}
+
+#[derive(Clone, Copy)]
+struct ClipVertex {
+ position: [f32; 3],
+ uv: [f32; 2],
+}
+
+fn project_receivers(
+ frame: &mut ShadowFrame,
+ page: &ShadowPage,
+ receivers: &[ShadowTriangle],
+ casters: &[ShadowCaster],
+) {
+ let Some(caster) = casters.iter().find(|caster| caster.id == page.caster) else {
+ return;
+ };
+ // Kind-4 small actors select the four-ray receiver path once for the
+ // caster. The native query descriptor is compact 0x8008, but Landscape
+ // expands it to full face flags 0x00200020 before filtering triangles;
+ // all other casters use the ordinary full 0x2000 exclusion bit.
+ let ray_path = caster.native_kind == 4 && caster.sphere.radius < 10.0;
+ let ray_fade = ray_path.then(|| four_ray_fade(page, receivers));
+ for receiver in receivers {
+ let use_rays = ray_path && receiver.mask & SHADOW_FOUR_RAY_MASK == 0;
+ let use_general = !ray_path && receiver.mask & SHADOW_RECEIVER_MASK == 0;
+ if !use_rays && !use_general {
+ continue;
+ }
+ let normal = normalize(receiver.normal).unwrap_or_else(|| {
+ normalize(cross(
+ sub(receiver.positions[1], receiver.positions[0]),
+ sub(receiver.positions[2], receiver.positions[0]),
+ ))
+ .unwrap_or([0.0, 0.0, 0.0])
+ });
+ let normal_term = dot(page.projection.shadow_axis, normal);
+ if normal_term <= 0.0 || !normal_term.is_finite() {
+ continue;
+ }
+
+ let plane_distance = dot(normal, receiver.positions[0]);
+ let corners = shadow_quad(&page.projection);
+ let mut projected = Vec::with_capacity(4);
+ for (upper, lower, uv) in corners.iter() {
+ let delta = sub(*lower, *upper);
+ if dot(normal, delta) >= SHADOW_PROJECTION_EPSILON {
+ projected.clear();
+ break;
+ }
+ let upper_distance = dot(normal, *upper) - plane_distance;
+ let lower_distance = dot(normal, *lower) - plane_distance;
+ if upper_distance < 0.0 || lower_distance >= 0.0 {
+ projected.clear();
+ break;
+ }
+ let denominator = upper_distance - lower_distance;
+ if denominator <= SHADOW_CLIP_EPSILON {
+ projected.clear();
+ break;
+ }
+ let t = (upper_distance / denominator).clamp(0.0, 1.0);
+ projected.push(ClipVertex {
+ position: lerp(*upper, *lower, t),
+ uv: *uv,
+ });
+ }
+ if projected.len() != 4 {
+ continue;
+ }
+
+ // Terrain's receiver routine clips the projected four-corner quad
+ // against the three receiver edge planes. Keeping UV with the point
+ // makes the later fan exactly the native interpolated quad.
+ for edge in 0..3 {
+ let a = receiver.positions[edge];
+ let b = receiver.positions[(edge + 1) % 3];
+ projected = clip_polygon(projected, |vertex| {
+ dot(cross(sub(b, a), sub(vertex.position, a)), normal)
+ });
+ }
+ projected.truncate(9);
+ if projected.len() < 3 {
+ continue;
+ }
+ let opacity = if use_rays {
+ (ray_fade.unwrap_or(0.0) * page.projection.light_strength * 0.2).min(1.0)
+ } else {
+ page.projection.opacity
+ };
+ let alpha = opacity * normal_term.sqrt();
+ if alpha <= 0.0 || !alpha.is_finite() {
+ continue;
+ }
+ let start = frame.vertices.len() as u32;
+ for vertex in &projected {
+ frame.vertices.push(ShadowVertex {
+ position: vertex.position,
+ uv: [
+ (page.origin[0] as f32 + vertex.uv[0] * page.size as f32)
+ / SHADOW_ATLAS_WIDTH as f32,
+ (page.origin[1] as f32 + vertex.uv[1] * page.size as f32)
+ / SHADOW_ATLAS_HEIGHT as f32,
+ ],
+ alpha,
+ normal,
+ });
+ }
+ for index in 1..projected.len() - 1 {
+ frame.indices.extend_from_slice(&[
+ start,
+ start + index as u32,
+ start + index as u32 + 1,
+ ]);
+ }
+ }
+}
+
+fn shadow_quad(projection: &ShadowProjection) -> [([f32; 3], [f32; 3], [f32; 2]); 4] {
+ let uv_max = SHADOW_QUAD_UV_MAX;
+ let corners = [[0.0, 0.0], [uv_max, 0.0], [uv_max, uv_max], [0.0, uv_max]];
+ corners.map(|corner| {
+ let local = [corner[0] - 0.5, corner[1] - 0.5];
+ let uv = corner;
+ (
+ transform_point(
+ projection.world_from_shadow,
+ [local[0], local[1], projection.upper_distance],
+ ),
+ transform_point(
+ projection.world_from_shadow,
+ [local[0], local[1], projection.lower_distance],
+ ),
+ uv,
+ )
+ })
+}
+
+/// Casts the four native shadow-quad segments against receiver query
+/// triangles. A miss contributes nothing; the native average divides by the
+/// number of successful ray hits rather than by four.
+fn four_ray_fade(page: &ShadowPage, receivers: &[ShadowTriangle]) -> f32 {
+ let corners = shadow_quad(&page.projection);
+ let mut sum = 0.0;
+ let mut hits = 0_u32;
+ for (upper, lower, _) in corners {
+ let delta = sub(lower, upper);
+ let mut nearest = f32::INFINITY;
+ for receiver in receivers {
+ if receiver.mask & SHADOW_FOUR_RAY_MASK != 0 {
+ continue;
+ }
+ if let Some(parameter) = segment_triangle_parameter(upper, delta, receiver) {
+ let hit = add(upper, scale(delta, parameter));
+ let offset = sub(hit, upper);
+ let distance_squared = dot(offset, offset);
+ nearest = nearest.min(distance_squared);
+ }
+ }
+ if nearest.is_finite() {
+ sum += (1.0 - nearest * 0.0001).clamp(0.0, 1.0);
+ hits += 1;
+ }
+ }
+ if hits == 0 {
+ 0.0
+ } else {
+ sum / hits as f32
+ }
+}
+
+fn segment_triangle_parameter(
+ origin: [f32; 3],
+ delta: [f32; 3],
+ triangle: &ShadowTriangle,
+) -> Option<f32> {
+ let edge1 = sub(triangle.positions[1], triangle.positions[0]);
+ let edge2 = sub(triangle.positions[2], triangle.positions[0]);
+ let p = cross(delta, edge2);
+ let determinant = dot(edge1, p);
+ if determinant.abs() <= SHADOW_CLIP_EPSILON {
+ return None;
+ }
+ let inverse = 1.0 / determinant;
+ let tvec = sub(origin, triangle.positions[0]);
+ let u = dot(tvec, p) * inverse;
+ if !(0.0..=1.0).contains(&u) {
+ return None;
+ }
+ let q = cross(tvec, edge1);
+ let v = dot(delta, q) * inverse;
+ if v < 0.0 || u + v > 1.0 {
+ return None;
+ }
+ let parameter = dot(edge2, q) * inverse;
+ (parameter >= 0.0 && parameter <= 1.0).then_some(parameter)
+}
+
+fn clip_polygon<F>(polygon: Vec<ClipVertex>, signed_distance: F) -> Vec<ClipVertex>
+where
+ F: Fn(ClipVertex) -> f32,
+{
+ if polygon.is_empty() {
+ return polygon;
+ }
+ let mut output = Vec::with_capacity(polygon.len() + 1);
+ let mut previous = *polygon.last().expect("non-empty polygon");
+ let mut previous_distance = signed_distance(previous);
+ for &current in &polygon {
+ let current_distance = signed_distance(current);
+ let previous_inside = previous_distance >= 0.0;
+ let current_inside = current_distance >= 0.0;
+ if previous_inside != current_inside {
+ let denominator = previous_distance - current_distance;
+ let t = if denominator.abs() > SHADOW_CLIP_EPSILON {
+ previous_distance / denominator
+ } else {
+ 0.0
+ };
+ output.push(interpolate_clip(previous, current, t));
+ }
+ if current_inside {
+ output.push(current);
+ }
+ previous = current;
+ previous_distance = current_distance;
+ }
+ output
+}
+
+fn interpolate_clip(a: ClipVertex, b: ClipVertex, t: f32) -> ClipVertex {
+ ClipVertex {
+ position: lerp(a.position, b.position, t),
+ uv: [
+ a.uv[0] + (b.uv[0] - a.uv[0]) * t,
+ a.uv[1] + (b.uv[1] - a.uv[1]) * t,
+ ],
+ }
+}
+
+fn validate_caster(caster: &ShadowCaster) -> Result<(), ShadowError> {
+ if !caster.sphere.radius.is_finite() || caster.sphere.radius <= 0.0 {
+ return Err(ShadowError::InvalidInput("caster radius"));
+ }
+ if !caster.sphere.center.iter().all(|value| value.is_finite())
+ || !caster
+ .world_axes
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ {
+ return Err(ShadowError::InvalidInput("caster transform"));
+ }
+ for triangle in &caster.triangles {
+ validate_triangle(triangle, "caster")?;
+ }
+ Ok(())
+}
+
+fn validate_triangle(triangle: &ShadowTriangle, field: &'static str) -> Result<(), ShadowError> {
+ if !triangle
+ .positions
+ .iter()
+ .flatten()
+ .all(|value| value.is_finite())
+ || !triangle.normal.iter().all(|value| value.is_finite())
+ {
+ return Err(ShadowError::InvalidInput(field));
+ }
+ Ok(())
+}
+
+fn validate_light(light: &ShadowLight) -> Result<(), ShadowError> {
+ match *light {
+ ShadowLight::Directional { direction, rgb, .. }
+ if direction.iter().all(|value| value.is_finite())
+ && rgb.iter().all(|value| value.is_finite()) =>
+ {
+ Ok(())
+ }
+ ShadowLight::Point {
+ position,
+ rgb,
+ range,
+ coefficients,
+ ..
+ } if position.iter().all(|value| value.is_finite())
+ && rgb.iter().all(|value| value.is_finite())
+ && range.is_finite()
+ && coefficients.iter().all(|value| value.is_finite()) =>
+ {
+ Ok(())
+ }
+ _ => Err(ShadowError::InvalidInput("light")),
+ }
+}
+
+fn write_alpha_nibble(
+ atlas: &mut [u8],
+ origin: [usize; 2],
+ size: usize,
+ x: usize,
+ y: usize,
+ nibble: u8,
+) {
+ let index = ((origin[1] + y) * SHADOW_ATLAS_WIDTH + origin[0] + x) * 4;
+ if index + 3 < atlas.len() {
+ atlas[index] = 0;
+ atlas[index + 1] = 0;
+ atlas[index + 2] = 0;
+ atlas[index + 3] = nibble.saturating_mul(17);
+ }
+ let _ = size;
+}
+
+fn read_alpha_nibble(atlas: &[u8], origin: [usize; 2], _size: usize, x: usize, y: usize) -> u8 {
+ let index = ((origin[1] + y) * SHADOW_ATLAS_WIDTH + origin[0] + x) * 4;
+ atlas.get(index + 3).copied().unwrap_or(0) / 17
+}
+
+fn matrix_from_columns(x: [f32; 3], y: [f32; 3], z: [f32; 3], translation: [f32; 3]) -> [f32; 16] {
+ [
+ x[0],
+ y[0],
+ z[0],
+ translation[0],
+ x[1],
+ y[1],
+ z[1],
+ translation[1],
+ x[2],
+ y[2],
+ z[2],
+ translation[2],
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ]
+}
+
+fn inverse_shadow_matrix(
+ v: [f32; 3],
+ u: [f32; 3],
+ shadow_axis: [f32; 3],
+ center: [f32; 3],
+ radius: f32,
+) -> [f32; 16] {
+ let x_scale = scale(v, 1.0 / (2.0 * radius));
+ let y_scale = scale(u, -1.0 / (2.0 * radius));
+ let z_scale = scale(shadow_axis, 1.0 / dot(shadow_axis, shadow_axis));
+ [
+ x_scale[0],
+ x_scale[1],
+ x_scale[2],
+ -dot(x_scale, center),
+ y_scale[0],
+ y_scale[1],
+ y_scale[2],
+ -dot(y_scale, center),
+ z_scale[0],
+ z_scale[1],
+ z_scale[2],
+ -dot(z_scale, center),
+ 0.0,
+ 0.0,
+ 0.0,
+ 1.0,
+ ]
+}
+
+fn transform_point(matrix: [f32; 16], point: [f32; 3]) -> [f32; 3] {
+ [
+ matrix[0] * point[0] + matrix[1] * point[1] + matrix[2] * point[2] + matrix[3],
+ matrix[4] * point[0] + matrix[5] * point[1] + matrix[6] * point[2] + matrix[7],
+ matrix[8] * point[0] + matrix[9] * point[1] + matrix[10] * point[2] + matrix[11],
+ ]
+}
+
+fn identity_axes() -> [[f32; 3]; 3] {
+ [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]
+}
+
+fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
+}
+
+fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
+}
+
+fn scale(a: [f32; 3], scalar: f32) -> [f32; 3] {
+ [a[0] * scalar, a[1] * scalar, a[2] * scalar]
+}
+
+fn lerp(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
+ add(a, scale(sub(b, a), t))
+}
+
+fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
+ a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+}
+
+fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ]
+}
+
+fn cross2(a: [f32; 2], b: [f32; 2]) -> f32 {
+ a[0] * b[1] - a[1] * b[0]
+}
+
+fn sub2(a: [f32; 3], b: [f32; 3]) -> [f32; 2] {
+ [a[0] - b[0], a[1] - b[1]]
+}
+
+fn length(a: [f32; 3]) -> f32 {
+ dot(a, a).sqrt()
+}
+
+fn normalize(a: [f32; 3]) -> Option<[f32; 3]> {
+ let length = length(a);
+ (length > 1.0e-6 && length.is_finite()).then(|| scale(a, 1.0 / length))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn native_layout_has_twelve_large_and_sixteen_small_pages() {
+ let pages = native_shadow_pages();
+ assert_eq!(pages.len(), 28);
+ assert_eq!(
+ pages[..12].iter().map(|(_, size)| *size).sum::<u32>(),
+ 12 * 64
+ );
+ assert!(pages[..12].iter().all(|(_, size)| *size == 64));
+ assert!(pages[12..].iter().all(|(_, size)| *size == 32));
+ assert_eq!(
+ pages.iter().map(|(origin, _)| *origin).collect::<Vec<_>>(),
+ vec![
+ [0, 0],
+ [0, 64],
+ [64, 64],
+ [64, 0],
+ [0, 128],
+ [0, 192],
+ [64, 192],
+ [64, 128],
+ [128, 0],
+ [128, 64],
+ [192, 64],
+ [192, 0],
+ [128, 128],
+ [128, 160],
+ [160, 160],
+ [160, 128],
+ [128, 192],
+ [128, 224],
+ [160, 224],
+ [160, 192],
+ [192, 192],
+ [192, 224],
+ [224, 224],
+ [224, 192],
+ [192, 128],
+ [192, 160],
+ [224, 160],
+ [224, 128],
+ ]
+ );
+ }
+
+ #[test]
+ fn directional_shadow_raster_and_receiver_projection_are_concrete() {
+ let caster_triangle = triangle([[-1.0, -1.0, 0.0], [0.0, 1.0, 0.0], [1.0, -1.0, 0.0]], 0);
+ let caster = caster(7, 2.0, 1, vec![caster_triangle]);
+ let receiver = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0);
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0, 1.0, 1.0],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[receiver],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("shadow frame");
+ assert_eq!(frame.atlas_rgba8.len(), 256 * 256 * 4);
+ assert!(frame.atlas_rgba8.chunks_exact(4).any(|rgba| rgba[3] == 255));
+ assert!(!frame.vertices.is_empty());
+ assert_eq!(frame.indices.len() % 3, 0);
+ assert!(frame.vertices.iter().all(|vertex| vertex.alpha > 0.0));
+ }
+
+ #[test]
+ fn point_light_range_and_native_flags_gate_pages() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let inactive = ShadowLight::Point {
+ position: [0.0, 0.0, -1.0],
+ rgb: [1.0; 3],
+ range: 20.0,
+ coefficients: [1.0, 0.0, 0.0],
+ active: true,
+ flags: 0x8000_0000,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[inactive],
+ casters: &[caster],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("valid empty frame");
+ assert!(frame.atlas_rgba8.iter().all(|byte| *byte == 0));
+ }
+
+ #[test]
+ fn ordinary_directional_uses_primary_raw_direction_and_max_strength_only() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let lights = [
+ ShadowLight::Directional {
+ direction: [2.0, 0.0, 4.0],
+ rgb: [0.0, 0.0, 3.0],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ },
+ ShadowLight::Directional {
+ direction: [100.0, 0.0, 100.0],
+ rgb: [100.0; 3],
+ active: true,
+ flags: 0x1000_0000,
+ },
+ ];
+ let contribution = select_light(&caster, &lights).expect("primary directional");
+ assert_eq!(contribution.direction, [2.0, 0.0, 3.7]);
+ assert!((contribution.strength - 3.0).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn point_direction_overwrites_normalized_z_before_second_normalize() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let light = ShadowLight::Point {
+ position: [0.0, 0.0, -5.0],
+ rgb: [1.0; 3],
+ range: 20.0,
+ coefficients: [1.0, 0.0, 0.0],
+ active: true,
+ flags: 0,
+ };
+ let contribution = select_light(&caster, &[light]).expect("point light");
+ let expected_strength = 3.0_f32.sqrt() * 1.5;
+ let expected_contribution = expected_strength / 15.0 * 20.0 / 2.0;
+ assert!((contribution.direction[2] + expected_contribution).abs() < 1.0e-6);
+ assert!(contribution.direction[0].abs() < 1.0e-6);
+ assert!((contribution.strength - expected_strength).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn four_ray_fade_averages_successes_and_zeroes_all_misses() {
+ let caster = caster(1, 1.0, 4, Vec::new());
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let projection = make_projection(
+ &caster,
+ select_light(&caster, &[light]).expect("light"),
+ 1.0,
+ )
+ .expect("projection");
+ let page = ShadowPage {
+ origin: [0, 0],
+ size: 64,
+ caster: 1,
+ projection,
+ };
+ let partial = triangle([[-1.0, 1.0, 1.0], [0.98, 1.0, 1.0], [-1.0, -0.98, 1.0]], 0);
+ let fade = four_ray_fade(&page, &[partial]);
+ assert!(
+ fade > 0.9,
+ "three successful rays must not be divided by four"
+ );
+ let fifty_world_units = triangle(
+ [
+ [-100.0, -100.0, 47.2],
+ [100.0, -100.0, 47.2],
+ [0.0, 100.0, 47.2],
+ ],
+ 0,
+ );
+ let fade_at_fifty = four_ray_fade(&page, &[fifty_world_units]);
+ assert!(
+ (fade_at_fifty - 0.75).abs() < 1.0e-6,
+ "fade at 50 world units: {fade_at_fifty}"
+ );
+ let miss = triangle([[10.0, 10.0, 1.0], [11.0, 10.0, 1.0], [10.0, 11.0, 1.0]], 0);
+ assert_eq!(four_ray_fade(&page, &[miss]), 0.0);
+ let excluded = triangle(
+ [[-1.0, 1.0, 1.0], [0.98, 1.0, 1.0], [-1.0, -0.98, 1.0]],
+ SHADOW_FOUR_RAY_MASK,
+ );
+ assert_eq!(four_ray_fade(&page, &[excluded]), 0.0);
+ }
+
+ #[test]
+ fn ordinary_alpha_keeps_native_hdr_normal_term_after_opacity_clamp() {
+ let caster = caster(1, 1.0, 1, Vec::new());
+ let receiver = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0);
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 10.0],
+ rgb: [100.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[receiver],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("HDR shadow frame");
+ assert!(frame.vertices.iter().any(|vertex| vertex.alpha > 1.0));
+ }
+
+ #[test]
+ fn smoothing_uses_nibble_quantization() {
+ let caster_triangle = triangle([[-1.0, -1.0, 0.0], [0.0, 1.0, 0.0], [1.0, -1.0, 0.0]], 0);
+ let caster = caster(1, 2.0, 1, vec![caster_triangle]);
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let frame = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 2,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("smooth frame");
+ assert!(frame
+ .atlas_rgba8
+ .chunks_exact(4)
+ .any(|rgba| rgba[3] > 0 && rgba[3] < 255 && rgba[3] % 17 == 0));
+ }
+
+ #[test]
+ fn native_rounding_and_blur_preserve_asymmetric_page_edges() {
+ assert_eq!(native_round(1.5), 2);
+ assert_eq!(native_round(2.5), 2);
+ assert_eq!(native_round(-0.5), 0);
+ assert_eq!(native_round(-1.5), -2);
+
+ let size = 4;
+ let origin = [0, 0];
+ let mut atlas = vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4];
+ for y in 0..size {
+ for x in 0..size {
+ write_alpha_nibble(&mut atlas, origin, size, x, y, (y * size + x) as u8);
+ }
+ }
+ blur_page(&mut atlas, origin, size, false);
+ let expected_forward = [
+ [2, 3, 4, 3],
+ [6, 7, 8, 7],
+ [10, 11, 12, 11],
+ [12, 13, 14, 15],
+ ];
+ for (y, row) in expected_forward.iter().enumerate() {
+ for (x, expected) in row.iter().enumerate() {
+ assert_eq!(
+ read_alpha_nibble(&atlas, origin, size, x, y),
+ *expected,
+ "forward blur at ({x},{y})"
+ );
+ }
+ }
+
+ let mut reverse_atlas = vec![0; SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4];
+ for y in 0..size {
+ for x in 0..size {
+ write_alpha_nibble(&mut reverse_atlas, origin, size, x, y, (y * size + x) as u8);
+ }
+ }
+ blur_page(&mut reverse_atlas, origin, size, true);
+ let expected_reverse = [[0, 1, 2, 3], [4, 2, 3, 4], [8, 6, 7, 8], [12, 10, 11, 12]];
+ for (y, row) in expected_reverse.iter().enumerate() {
+ for (x, expected) in row.iter().enumerate() {
+ assert_eq!(
+ read_alpha_nibble(&reverse_atlas, origin, size, x, y),
+ *expected,
+ "reverse blur at ({x},{y})"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn native_lod_uses_projected_measure_and_reuses_page_slots() {
+ let mut building = caster(41, 1.0, 3, Vec::new());
+ building.sphere.center = [0.0, 0.0, 1.05];
+ let camera = ShadowCamera {
+ position: [0.0, 0.0, 0.0],
+ viewport_width: 100.0,
+ horizontal_fov: std::f32::consts::FRAC_PI_2,
+ };
+ let close_lod = projected_shadow_lod(&building, camera, ShadowLodSettings::default());
+ assert_eq!(close_lod, 1);
+
+ let mut cache = ShadowPageCache::default();
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let first = cache
+ .build(ShadowScene {
+ lights: &[light],
+ casters: &[building.clone()],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: Some(camera),
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("first cached frame");
+ assert_eq!(
+ first.atlas_rgba8.len(),
+ SHADOW_ATLAS_WIDTH * SHADOW_ATLAS_HEIGHT * 4
+ );
+ let slot = cache.page_slot(building.id).expect("cached page");
+ let second = cache
+ .build(ShadowScene {
+ lights: &[light],
+ casters: &[building],
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: Some(camera),
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("reused cached frame");
+ assert_eq!(cache.page_slot(41), Some(slot));
+ assert_eq!(second.atlas_rgba8.len(), first.atlas_rgba8.len());
+ }
+
+ #[test]
+ fn native_lod_rejects_small_and_unknown_shadow_objects() {
+ let camera = ShadowCamera {
+ position: [0.0, 0.0, 0.0],
+ viewport_width: 100.0,
+ horizontal_fov: std::f32::consts::FRAC_PI_2,
+ };
+ let mut robot = caster(1, 1.0, 4, Vec::new());
+ robot.sphere.center = [0.0, 0.0, 100.0];
+ assert_eq!(
+ projected_shadow_lod(&robot, camera, ShadowLodSettings::default()),
+ 8
+ );
+ let unknown = caster(2, 100.0, 10, Vec::new());
+ assert_eq!(
+ projected_shadow_lod(&unknown, camera, ShadowLodSettings::default()),
+ 8
+ );
+ }
+
+ #[test]
+ fn page_cache_keeps_current_frame_slots_unique_and_rejects_stale_overflow() {
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let casters = (0..40)
+ .map(|id| caster(id, 1.0, 3, Vec::new()))
+ .collect::<Vec<_>>();
+ let mut cache = ShadowPageCache::default();
+
+ for (start, expected_used) in [(0, 20), (20, 16), (20, 16)] {
+ let selected = casters[start..start + 20].to_vec();
+ let _frame = cache
+ .build(ShadowScene {
+ lights: &[light],
+ casters: &selected,
+ receivers: &[],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("cached multi-frame shadow build");
+
+ let used = cache
+ .frame_used
+ .iter()
+ .enumerate()
+ .filter_map(|(index, used)| used.then_some(index))
+ .collect::<Vec<_>>();
+ assert_eq!(used.len(), expected_used);
+ let mut owners = used
+ .iter()
+ .map(|&index| cache.slots[index].expect("used slot owner").caster)
+ .collect::<Vec<_>>();
+ owners.sort_unstable();
+ owners.dedup();
+ assert_eq!(owners.len(), used.len(), "two current casters share a page");
+
+ for id in 0..40 {
+ let owner_count = cache
+ .slots
+ .iter()
+ .flatten()
+ .filter(|slot| slot.caster == id)
+ .count();
+ assert!(owner_count <= 1, "caster {id} owns duplicate page slots");
+ }
+ }
+
+ // The second frame has more new casters than the aged fallback range
+ // can accept. Rejected records must remain unowned rather than being
+ // assigned an occupied stateless index.
+ assert!(cache.page_slot(36).is_none());
+ assert!(cache.page_slot(37).is_none());
+ assert!(cache.page_slot(38).is_none());
+ assert!(cache.page_slot(39).is_none());
+ }
+
+ #[test]
+ fn receiver_masks_are_full_exclusions() {
+ let caster = caster(1, 1.0, 4, Vec::new());
+ let light = ShadowLight::Directional {
+ direction: [0.0, 0.0, 1.0],
+ rgb: [1.0; 3],
+ active: true,
+ flags: SHADOW_PRIMARY_DIRECTIONAL_FLAG,
+ };
+ let ground = triangle([[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]], 0);
+ let excluded = triangle(
+ [[-1.0, -1.0, 1.0], [0.0, 1.0, 1.0], [1.0, -1.0, 1.0]],
+ SHADOW_FOUR_RAY_MASK,
+ );
+ let accepted = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster.clone()],
+ receivers: &[ground],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("unmasked ground receiver");
+ let filtered = build_shadow_frame(ShadowScene {
+ lights: &[light],
+ casters: &[caster],
+ receivers: &[ground, excluded],
+ fade: 1.0,
+ smooth_passes: 0,
+ camera: None,
+ lod: ShadowLodSettings::default(),
+ })
+ .expect("masked receiver");
+ assert_eq!(filtered.vertices.len(), accepted.vertices.len());
+ assert_eq!(filtered.indices.len(), accepted.indices.len());
+ }
+
+ fn triangle(positions: [[f32; 3]; 3], mask: u32) -> ShadowTriangle {
+ ShadowTriangle {
+ positions,
+ normal: normalize(cross(
+ sub(positions[1], positions[0]),
+ sub(positions[2], positions[0]),
+ ))
+ .unwrap_or([0.0; 3]),
+ mask,
+ }
+ }
+
+ fn caster(
+ id: u32,
+ radius: f32,
+ native_kind: u32,
+ triangles: Vec<ShadowTriangle>,
+ ) -> ShadowCaster {
+ ShadowCaster {
+ id,
+ world_axes: identity_axes(),
+ sphere: ShadowSphere {
+ center: [0.0; 3],
+ radius,
+ },
+ native_kind,
+ triangles,
+ }
+ }
+}
diff --git a/crates/fparkan-fx/src/sky.rs b/crates/fparkan-fx/src/sky.rs
new file mode 100644
index 0000000..f41e920
--- /dev/null
+++ b/crates/fparkan-fx/src/sky.rs
@@ -0,0 +1,2079 @@
+//! CPU-side sky dome, sky materials, fog, and sun output.
+//!
+//! The original `CSky` object is a finite dome rather than an equirectangular
+//! skybox. This module keeps that geometry and the interpolated
+//! `sky.ske`/`sky.wea` data independent of any graphics backend. A renderer
+//! can upload [`SkyMesh`] once, update its vertex colors, and resolve the
+//! material names in [`SkyMaterials`] through its normal resource path.
+
+use crate::atmosphere::{AtmosphereFrame, PackedColor, SkySample, SunBirth, SunSample};
+use std::fmt;
+
+/// Number of rows used by the original environment `sky.wea` table.
+pub const SKY_MATERIAL_COUNT: usize = 9;
+
+/// Texture-coordinate scales used by the three original sky vertex stages.
+///
+/// The values are the constants at PE `65E94`, `65E98`, and `65E9C` in the
+/// original renderer. They are applied to `position.x / radius` and
+/// `position.y / radius`.
+pub const SKY_UV_SCALES: [f32; 3] = [1.0, 15.0, 3.0];
+
+/// Material names observed in the AutoDemo `sky.wea` table.
+///
+/// Missions are allowed to replace these names. Code should use the rows
+/// returned by [`SkyMaterials::parse`] when resolving a real mission.
+pub const OBSERVED_SKY_MATERIAL_NAMES: [&str; SKY_MATERIAL_COUNT] = [
+ "ENV_NEBULA_0",
+ "ENV_STARS",
+ "ENV_CLOUDS",
+ "ENV_SUN_3",
+ "ENV_MOON",
+ "ENV_FLARE_00",
+ "ENV_FLARE_01",
+ "SNOWFLAKE",
+ "RAIN_DROP",
+];
+
+const TAU: f32 = std::f32::consts::TAU;
+// These are the values written by Terrain's atmosphere-manager constructor:
+// 0x6581C = 10000, and 0x6580C = 3.1415901184 is halved twice before the
+// CSky constructor receives it.
+const NATIVE_PI: f32 = 3.1415901184;
+const DEFAULT_HEIGHT: f32 = 10_000.0;
+const DEFAULT_THETA_MAX: f32 = NATIVE_PI * 0.25;
+const DEFAULT_Z_SCALE: f32 = 1.0;
+const DEFAULT_AZIMUTH_COUNT: usize = 16;
+const DEFAULT_RINGS: usize = 5;
+const FOG_DISTANCE_SCALE: f32 = 700.0;
+const MAX_GEOMETRY_VERTICES: usize = 1_000_000;
+// CSky::Render (481C5) uses these data constants when it converts the camera
+// yaw to a four-color, ninety-degree horizon sector.
+const NATIVE_GRADIENT_SEGMENT_DEGREES: i64 = 90;
+const NATIVE_GRADIENT_FRACTION_SCALE: f32 = f32::from_bits(0x3C36_0B61);
+const NATIVE_GRADIENT_ZERO: f32 = 0.0;
+const NATIVE_GRADIENT_ONE: f32 = 1.0;
+const NATIVE_GRADIENT_THREE: f32 = 3.0;
+const NATIVE_GRADIENT_SHORT_SCALE: f32 = 0.8;
+const NATIVE_GRADIENT_LONG_SCALE: f32 = 0.1;
+const NATIVE_GRADIENT_LONG_OFFSET: f32 = 0.7;
+
+/// Configuration passed to the original `CSky` dome constructor.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SkyGeometryConfig {
+ /// Dome height before the z scale is applied.
+ pub height: f32,
+ /// Maximum polar angle in radians.
+ pub theta_max: f32,
+ /// Vertical scale applied to the generated positions.
+ pub z_scale: f32,
+ /// Number of azimuth sectors around the dome.
+ pub azimuth_count: usize,
+ /// Number of vertices in each azimuth sector after the zenith vertex.
+ pub rings: usize,
+}
+
+impl SkyGeometryConfig {
+ /// Creates a geometry configuration.
+ #[must_use]
+ pub const fn new(
+ height: f32,
+ theta_max: f32,
+ z_scale: f32,
+ azimuth_count: usize,
+ rings: usize,
+ ) -> Self {
+ Self {
+ height,
+ theta_max,
+ z_scale,
+ azimuth_count,
+ rings,
+ }
+ }
+
+ /// Returns the radius used by the original constructor.
+ #[must_use]
+ pub fn radius(self) -> f32 {
+ let half_theta = self.theta_max * 0.5;
+ self.height * 0.5 / half_theta.sin().powi(2)
+ }
+
+ /// Builds a colored sky mesh and validates all counts and floating point
+ /// inputs.
+ pub fn mesh(self, sample: &SkySample) -> Result<SkyMesh, SkyGeometryError> {
+ self.try_mesh(sample)
+ }
+
+ /// Builds a colored sky mesh and validates all counts and floating point
+ /// inputs.
+ pub fn try_mesh(self, sample: &SkySample) -> Result<SkyMesh, SkyGeometryError> {
+ let mut mesh = self.try_topology()?;
+ mesh.update_colors(sample);
+ Ok(mesh)
+ }
+
+ /// Builds the immutable dome topology.
+ ///
+ /// Vertex colors are initialized to the packed zero value and are filled
+ /// by [`SkyMesh::update_colors`] when the first atmosphere sample arrives.
+ /// Keeping topology construction separate lets a renderer upload the
+ /// position/normal/UV/index buffers once.
+ pub fn try_topology(self) -> Result<SkyMesh, SkyGeometryError> {
+ validate_geometry(self)?;
+ let vertex_count = self
+ .azimuth_count
+ .checked_mul(self.rings)
+ .and_then(|count| count.checked_add(1))
+ .ok_or(SkyGeometryError::CountOverflow)?;
+ if vertex_count > MAX_GEOMETRY_VERTICES {
+ return Err(SkyGeometryError::TooManyVertices { vertex_count });
+ }
+ let triangle_count = self
+ .azimuth_count
+ .checked_mul(
+ self.rings
+ .checked_mul(2)
+ .and_then(|count| count.checked_sub(1))
+ .ok_or(SkyGeometryError::CountOverflow)?,
+ )
+ .ok_or(SkyGeometryError::CountOverflow)?;
+ let index_count = triangle_count
+ .checked_mul(3)
+ .ok_or(SkyGeometryError::CountOverflow)?;
+ let radius = self.radius();
+ if !radius.is_finite() || radius <= 0.0 {
+ return Err(SkyGeometryError::NonFiniteRadius);
+ }
+
+ let mut vertices = Vec::with_capacity(vertex_count);
+ vertices.push(SkyVertex {
+ position: [0.0, 0.0, self.height * self.z_scale],
+ normal: [0, 0, 127],
+ uv: [[0.0, 0.0]; 3],
+ color: PackedColor::default(),
+ });
+
+ for azimuth in 0..self.azimuth_count {
+ let phi = azimuth as f32 / self.azimuth_count as f32 * TAU;
+ let sin_phi = phi.sin();
+ let cos_phi = phi.cos();
+ for ring in 0..self.rings {
+ let theta = (ring + 1) as f32 / self.rings as f32 * self.theta_max;
+ let sin_theta = theta.sin();
+ let cos_theta = theta.cos();
+ let position = [
+ radius * sin_theta * sin_phi,
+ radius * sin_theta * cos_phi,
+ (radius * cos_theta + self.height - radius) * self.z_scale,
+ ];
+ let normal = [
+ normal_byte(sin_theta * sin_phi),
+ normal_byte(sin_theta * cos_phi),
+ normal_byte(cos_theta),
+ ];
+ let uv = std::array::from_fn(|stage| {
+ [
+ position[0] / radius * SKY_UV_SCALES[stage],
+ position[1] / radius * SKY_UV_SCALES[stage],
+ ]
+ });
+ vertices.push(SkyVertex {
+ position,
+ normal,
+ uv,
+ color: PackedColor::default(),
+ });
+ }
+ }
+
+ let mut indices = Vec::with_capacity(index_count);
+ for azimuth in 0..self.azimuth_count {
+ let base = 1 + azimuth * self.rings;
+ let next = 1 + ((azimuth + 1) % self.azimuth_count) * self.rings;
+ indices.push(0);
+ indices.extend_from_slice(&[
+ u32::try_from(base).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(next).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ ]);
+ for ring in 0..self.rings - 1 {
+ let a = base + ring;
+ let b = base + ring + 1;
+ let c = next + ring;
+ let d = next + ring + 1;
+ indices.extend_from_slice(&[
+ u32::try_from(a).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(b).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(c).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(c).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(b).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ u32::try_from(d).map_err(|_| SkyGeometryError::IndexOverflow)?,
+ ]);
+ }
+ }
+
+ Ok(SkyMesh {
+ config: self,
+ radius,
+ vertices,
+ indices,
+ })
+ }
+}
+
+impl Default for SkyGeometryConfig {
+ fn default() -> Self {
+ Self::new(
+ DEFAULT_HEIGHT,
+ DEFAULT_THETA_MAX,
+ DEFAULT_Z_SCALE,
+ DEFAULT_AZIMUTH_COUNT,
+ DEFAULT_RINGS,
+ )
+ }
+}
+
+fn validate_geometry(config: SkyGeometryConfig) -> Result<(), SkyGeometryError> {
+ if !config.height.is_finite() || config.height <= 0.0 {
+ return Err(SkyGeometryError::InvalidHeight(config.height));
+ }
+ if !config.theta_max.is_finite() || config.theta_max <= 0.0 || config.theta_max >= TAU {
+ return Err(SkyGeometryError::InvalidTheta(config.theta_max));
+ }
+ if !config.z_scale.is_finite() {
+ return Err(SkyGeometryError::InvalidZScale(config.z_scale));
+ }
+ if config.azimuth_count == 0 || config.rings == 0 {
+ return Err(SkyGeometryError::ZeroCount);
+ }
+ Ok(())
+}
+
+fn normal_byte(component: f32) -> i8 {
+ // Terrain's native sky constructor writes signed normal components with
+ // x87 nearest-even rounding and a 127 scale. Keep the signed type here;
+ // a renderer that uploads the legacy byte stream can cast it to u8.
+ (component * 127.0).round_ties_even().clamp(-128.0, 127.0) as i8
+}
+
+fn color_for_vertex(
+ sample: &SkySample,
+ ring: usize,
+ azimuth: usize,
+ azimuth_count: usize,
+) -> PackedColor {
+ match ring {
+ 0 => sample.colors[12],
+ 1 => belt_color(sample, 8, azimuth, azimuth_count),
+ 2 => belt_color(sample, 4, azimuth, azimuth_count),
+ _ => belt_color(sample, 0, azimuth, azimuth_count),
+ }
+}
+
+fn belt_color(
+ sample: &SkySample,
+ offset: usize,
+ azimuth: usize,
+ azimuth_count: usize,
+) -> PackedColor {
+ let group_width = azimuth_count.saturating_add(3) / 4;
+ if group_width == 0 {
+ return sample.colors[offset];
+ }
+ let anchor = (azimuth / group_width).min(3);
+ let next = (anchor + 1) & 3;
+ let factor = (azimuth % group_width) as f32 / group_width as f32;
+ sample.colors[offset + anchor].lerp(sample.colors[offset + next], factor)
+}
+
+/// One CPU vertex in the original sky buffer layout.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SkyVertex {
+ /// World-space position.
+ pub position: [f32; 3],
+ /// Signed 8-bit normal components written by the original constructor.
+ pub normal: [i8; 3],
+ /// Three texture-coordinate stages, in original stage order.
+ pub uv: [[f32; 2]; 3],
+ /// Current ARGB vertex color.
+ pub color: PackedColor,
+}
+
+impl SkyVertex {
+ /// Returns the packed color as normalized RGBA.
+ #[must_use]
+ pub fn color_rgba(self) -> [f32; 4] {
+ self.color.rgba()
+ }
+
+ /// Decodes the native signed normal bytes to normalized components.
+ #[must_use]
+ pub fn normal_vector(self) -> [f32; 3] {
+ self.normal.map(|component| f32::from(component) / 127.0)
+ }
+}
+
+/// Generated sky dome geometry and indices.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkyMesh {
+ /// Configuration used to generate this mesh.
+ pub config: SkyGeometryConfig,
+ /// Radius from the original constructor formula.
+ pub radius: f32,
+ /// Zenith followed by azimuth-major ring vertices.
+ pub vertices: Vec<SkyVertex>,
+ /// Triangle-list indices: one fan triangle and `rings - 1` quads per
+ /// azimuth sector.
+ pub indices: Vec<u32>,
+}
+
+impl SkyMesh {
+ /// Number of triangles in the mesh.
+ #[must_use]
+ pub fn triangle_count(&self) -> usize {
+ self.indices.len() / 3
+ }
+
+ /// Updates only the dynamic vertex colors for an atmosphere sample.
+ ///
+ /// The vertex order is fixed by [`SkyGeometryConfig::try_topology`], so
+ /// this operation never reallocates the topology or index buffer. The
+ /// renderer can upload the changed color fields in place.
+ pub fn update_colors(&mut self, sample: &SkySample) {
+ let Some(zenith) = self.vertices.first_mut() else {
+ return;
+ };
+ zenith.color = sample.colors[12];
+ for azimuth in 0..self.config.azimuth_count {
+ let base = 1 + azimuth * self.config.rings;
+ for ring in 0..self.config.rings {
+ let Some(vertex) = self.vertices.get_mut(base + ring) else {
+ return;
+ };
+ vertex.color = color_for_vertex(sample, ring, azimuth, self.config.azimuth_count);
+ }
+ }
+ }
+}
+
+/// Errors found while constructing a sky dome.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum SkyGeometryError {
+ /// Height must be finite and positive.
+ InvalidHeight(f32),
+ /// Polar angle must be finite and in `(0, TAU)`.
+ InvalidTheta(f32),
+ /// Vertical scale must be finite.
+ InvalidZScale(f32),
+ /// A sector or ring count was zero.
+ ZeroCount,
+ /// A count calculation overflowed.
+ CountOverflow,
+ /// Mesh allocation would exceed the bounded CPU contract.
+ TooManyVertices { vertex_count: usize },
+ /// The radius formula did not produce a finite positive value.
+ NonFiniteRadius,
+ /// An index could not fit in the renderer-facing u32 index type.
+ IndexOverflow,
+}
+
+impl fmt::Display for SkyGeometryError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidHeight(value) => {
+ write!(f, "sky height must be finite and positive: {value}")
+ }
+ Self::InvalidTheta(value) => write!(f, "sky theta_max must be in (0, TAU): {value}"),
+ Self::InvalidZScale(value) => write!(f, "sky z_scale must be finite: {value}"),
+ Self::ZeroCount => write!(f, "sky azimuth and ring counts must be non-zero"),
+ Self::CountOverflow => write!(f, "sky geometry count overflow"),
+ Self::TooManyVertices { vertex_count } => {
+ write!(f, "sky geometry has too many vertices: {vertex_count}")
+ }
+ Self::NonFiniteRadius => write!(f, "sky geometry radius is not finite and positive"),
+ Self::IndexOverflow => write!(f, "sky geometry index does not fit in u32"),
+ }
+ }
+}
+
+impl std::error::Error for SkyGeometryError {}
+
+/// One row from a `sky.wea` material table.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SkyMaterialRow {
+ /// Legacy numeric id preserved from the source row.
+ pub legacy_id: i32,
+ /// Material lookup name.
+ pub name: String,
+}
+
+/// Parsed dynamic material names from `sky.wea`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct SkyMaterials {
+ /// Main material rows in file order.
+ pub rows: Vec<SkyMaterialRow>,
+ /// Optional lightmap rows after the `LIGHTMAPS` section.
+ pub lightmaps: Vec<SkyMaterialRow>,
+}
+
+impl SkyMaterials {
+ /// Parses a text `sky.wea` payload.
+ pub fn parse(bytes: &[u8]) -> Result<Self, SkyWeaError> {
+ let text = String::from_utf8_lossy(bytes);
+ let mut lines = text.lines().enumerate();
+ let (count_line, count_text) = lines
+ .find_map(|(line, text)| (!text.trim().is_empty()).then_some((line, text.trim())))
+ .ok_or(SkyWeaError::Empty)?;
+ let count = count_text
+ .parse::<usize>()
+ .map_err(|_| SkyWeaError::InvalidCount {
+ line: count_line,
+ text: count_text.to_string(),
+ })?;
+ if count == 0 {
+ return Err(SkyWeaError::ZeroCount);
+ }
+
+ let mut rows = Vec::with_capacity(count);
+ for index in 0..count {
+ let Some((line, text)) = lines.next() else {
+ return Err(SkyWeaError::MissingRow { index, count });
+ };
+ let text = text.trim();
+ if text.is_empty() {
+ return Err(SkyWeaError::InvalidRow {
+ index,
+ line,
+ text: text.to_string(),
+ });
+ }
+ rows.push(parse_material_row(index, line, text)?);
+ }
+
+ let mut lightmaps = Vec::new();
+ let mut rest = lines.filter_map(|(line, text)| {
+ let trimmed = text.trim();
+ (!trimmed.is_empty()).then_some((line, trimmed))
+ });
+ if let Some((line, marker)) = rest.next() {
+ if !marker.eq_ignore_ascii_case("LIGHTMAPS") {
+ return Err(SkyWeaError::UnexpectedTrailingLine {
+ line,
+ text: marker.to_string(),
+ });
+ }
+ let Some((count_line, count_text)) = rest.next() else {
+ return Err(SkyWeaError::InvalidLightmapCount {
+ line,
+ text: String::new(),
+ });
+ };
+ let lightmap_count =
+ count_text
+ .parse::<usize>()
+ .map_err(|_| SkyWeaError::InvalidLightmapCount {
+ line: count_line,
+ text: count_text.to_string(),
+ })?;
+ lightmaps.reserve(lightmap_count);
+ for index in 0..lightmap_count {
+ let Some((row_line, row_text)) = rest.next() else {
+ return Err(SkyWeaError::MissingLightmapRow {
+ index,
+ count: lightmap_count,
+ });
+ };
+ lightmaps.push(parse_material_row(index, row_line, row_text)?);
+ }
+ if let Some((extra_line, extra_text)) = rest.next() {
+ return Err(SkyWeaError::UnexpectedTrailingLine {
+ line: extra_line,
+ text: extra_text.to_string(),
+ });
+ }
+ }
+
+ Ok(Self { rows, lightmaps })
+ }
+
+ /// Creates a table from caller-provided names, assigning positional ids.
+ #[must_use]
+ pub fn from_names<I, S>(names: I) -> Self
+ where
+ I: IntoIterator<Item = S>,
+ S: Into<String>,
+ {
+ Self {
+ rows: names
+ .into_iter()
+ .enumerate()
+ .map(|(index, name)| SkyMaterialRow {
+ legacy_id: index as i32,
+ name: name.into(),
+ })
+ .collect(),
+ lightmaps: Vec::new(),
+ }
+ }
+
+ /// Returns the positional material row.
+ #[must_use]
+ pub fn row(&self, index: usize) -> Option<&SkyMaterialRow> {
+ self.rows.get(index)
+ }
+
+ /// Returns the material name at a positional row.
+ #[must_use]
+ pub fn material_name(&self, index: usize) -> Option<&str> {
+ self.row(index).map(|row| row.name.as_str())
+ }
+
+ /// Returns all main material names in file order.
+ #[must_use]
+ pub fn names(&self) -> Vec<&str> {
+ self.rows.iter().map(|row| row.name.as_str()).collect()
+ }
+}
+
+impl From<Vec<String>> for SkyMaterials {
+ fn from(names: Vec<String>) -> Self {
+ Self::from_names(names)
+ }
+}
+
+impl Default for SkyMaterials {
+ fn default() -> Self {
+ Self::from_names(OBSERVED_SKY_MATERIAL_NAMES)
+ }
+}
+
+fn parse_material_row(
+ index: usize,
+ line: usize,
+ text: &str,
+) -> Result<SkyMaterialRow, SkyWeaError> {
+ let mut parts = text.splitn(2, char::is_whitespace);
+ let legacy_text = parts.next().unwrap_or_default();
+ let name = parts.next().unwrap_or_default().trim();
+ let legacy_id = legacy_text
+ .parse::<i32>()
+ .map_err(|_| SkyWeaError::InvalidRow {
+ index,
+ line,
+ text: text.to_string(),
+ })?;
+ if name.is_empty() {
+ return Err(SkyWeaError::InvalidRow {
+ index,
+ line,
+ text: text.to_string(),
+ });
+ }
+ Ok(SkyMaterialRow {
+ legacy_id,
+ name: name.to_string(),
+ })
+}
+
+/// Errors returned while parsing `sky.wea`.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum SkyWeaError {
+ /// Payload has no non-empty lines.
+ Empty,
+ /// Main row count is malformed.
+ InvalidCount { line: usize, text: String },
+ /// Main table declared no rows.
+ ZeroCount,
+ /// A main row is missing.
+ MissingRow { index: usize, count: usize },
+ /// A main or lightmap row is malformed.
+ InvalidRow {
+ index: usize,
+ line: usize,
+ text: String,
+ },
+ /// Lightmap count is malformed.
+ InvalidLightmapCount { line: usize, text: String },
+ /// A lightmap row is missing.
+ MissingLightmapRow { index: usize, count: usize },
+ /// Non-empty data followed the main rows without a known section marker.
+ UnexpectedTrailingLine { line: usize, text: String },
+}
+
+impl fmt::Display for SkyWeaError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::Empty => write!(f, "sky.wea payload is empty"),
+ Self::InvalidCount { line, text } => {
+ write!(f, "invalid sky.wea count at line {line}: {text}")
+ }
+ Self::ZeroCount => write!(f, "sky.wea material count must be positive"),
+ Self::MissingRow { index, count } => {
+ write!(f, "missing sky.wea row {index} of {count}")
+ }
+ Self::InvalidRow { index, line, text } => {
+ write!(f, "invalid sky.wea row {index} at line {line}: {text}")
+ }
+ Self::InvalidLightmapCount { line, text } => {
+ write!(f, "invalid sky.wea lightmap count at line {line}: {text}")
+ }
+ Self::MissingLightmapRow { index, count } => {
+ write!(f, "missing sky.wea lightmap row {index} of {count}")
+ }
+ Self::UnexpectedTrailingLine { line, text } => {
+ write!(f, "unexpected sky.wea line {line}: {text}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for SkyWeaError {}
+
+/// Parses a `sky.wea` payload without requiring callers to name the parser
+/// type.
+pub fn parse_sky_wea(bytes: &[u8]) -> Result<SkyMaterials, SkyWeaError> {
+ SkyMaterials::parse(bytes)
+}
+
+/// The nine positional material roles used by the original sky renderer.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SkyLayerKind {
+ /// Nebula/background row zero.
+ Nebula,
+ /// Stars row one.
+ Stars,
+ /// Clouds row two.
+ Clouds,
+ /// Sun sprite row three.
+ Sun,
+ /// Moon sprite row four.
+ Moon,
+ /// First flare row five.
+ Flare0,
+ /// Second flare row six.
+ Flare1,
+ /// Snow row seven.
+ Snow,
+ /// Rain row eight.
+ Rain,
+ /// A mission-specific row after the known roles.
+ Other(usize),
+}
+
+impl SkyLayerKind {
+ /// Maps a positional WEA row to its known role.
+ #[must_use]
+ pub const fn from_row(row: usize) -> Self {
+ match row {
+ 0 => Self::Nebula,
+ 1 => Self::Stars,
+ 2 => Self::Clouds,
+ 3 => Self::Sun,
+ 4 => Self::Moon,
+ 5 => Self::Flare0,
+ 6 => Self::Flare1,
+ 7 => Self::Snow,
+ 8 => Self::Rain,
+ other => Self::Other(other),
+ }
+ }
+}
+
+/// Native draw order for the dome and its full-screen gradient.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SkyPassKind {
+ /// Untextured full-screen gradient quad.
+ ScreenGradient,
+ /// Row zero nebula with row one stars bound as its secondary texture.
+ NebulaStars,
+ /// Untextured colored gradient over the dome.
+ DomeGradient,
+ /// Row two cloud dome translated below the camera.
+ Clouds,
+}
+
+/// Renderer contract for one native sky pass.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkyPassFrame<'a> {
+ /// Pass order/role.
+ pub kind: SkyPassKind,
+ /// Primary positional WEA material row, if textured.
+ pub material_row: Option<usize>,
+ /// Secondary positional WEA row for the stars texture.
+ pub secondary_material_row: Option<usize>,
+ /// Primary material name.
+ pub material: Option<&'a str>,
+ /// Secondary material name.
+ pub secondary_material: Option<&'a str>,
+ /// Primary vertex UV stage.
+ pub uv_stage: Option<usize>,
+ /// Secondary vertex UV stage.
+ pub secondary_uv_stage: Option<usize>,
+ /// World-space Z offset relative to camera translation.
+ pub translation_z: f32,
+ /// Native blend mode of the pass.
+ pub blend: SkyBlendMode,
+ /// Native clouds directional RGB, when the pass uses it.
+ pub material_color: Option<[f32; 3]>,
+}
+
+/// Lighting-dependent colors produced by the native screen-gradient update.
+///
+/// `color` is the packed ARGB value sent to the full-screen gradient and to
+/// the last dome ring. `horizon_floor` is the independently computed RGB
+/// floor from the light-manager glare delta; native CSky clamps every stored
+/// dome palette color up to these three channels.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SkyGradientFrame {
+ /// Packed ARGB screen-gradient and last-ring color.
+ pub color: PackedColor,
+ /// Lower bound applied to dome palette RGB channels, normalized to 0..1.
+ pub horizon_floor: [f32; 3],
+}
+
+impl SkyGradientFrame {
+ /// Raises a packed dome color to the native lighting-dependent RGB floor.
+ #[must_use]
+ pub fn clamp_dome_color(self, color: PackedColor) -> PackedColor {
+ let rgba = color.rgba();
+ PackedColor::from_rgba([
+ rgba[0].max(self.horizon_floor[0]),
+ rgba[1].max(self.horizon_floor[1]),
+ rgba[2].max(self.horizon_floor[2]),
+ rgba[3],
+ ])
+ }
+}
+
+/// Per-row state supplied alongside [`SkyMesh`].
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkyLayerFrame<'a> {
+ /// Positional row in `sky.wea`.
+ pub row: usize,
+ /// Interpreted role for the row.
+ pub kind: SkyLayerKind,
+ /// Dynamic mission material name, if that row exists.
+ pub material: Option<&'a str>,
+ /// Whether the row is currently visible according to atmosphere state.
+ pub active: bool,
+ /// Vertex UV stage used by the native sky material pass.
+ pub uv_stage: usize,
+ /// Native UV scale for this layer's stage.
+ pub uv_scale: f32,
+ /// Normalized row intensity where the atmosphere supplies one.
+ pub intensity: f32,
+ /// Monotonic elapsed time for material UV animation.
+ pub phase_seconds: f32,
+}
+
+/// Blend operation used by a directional sky sprite pass.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum SkyBlendMode {
+ /// Use the sprite's alpha channel over the current sky.
+ Alpha,
+ /// Add the sprite contribution to the current sky.
+ Additive,
+}
+
+/// The fixed four-corner UV order used by native sun, moon, and flare quads.
+///
+/// `CSun` leaves a half-percent border around the source texture. The order
+/// is left-top, left-bottom, right-bottom, right-top in the native quad.
+pub const SKY_SPRITE_UV: [[f32; 2]; 4] = [
+ [0.005, 0.005],
+ [0.005, 0.995],
+ [0.995, 0.995],
+ [0.995, 0.005],
+];
+
+/// Positional flare rows emitted by the original sun object.
+pub const SKY_FLARE_ROWS: [usize; 12] = [5, 5, 6, 6, 5, 6, 5, 5, 6, 5, 5, 5];
+
+const SKY_FLARE_SIZES: [f32; 12] = [0.2, 0.3, 0.2, 0.1, 0.1, 0.3, 0.3, 0.7, 0.4, 1.0, 0.3, 0.2];
+const SKY_FLARE_OFFSETS: [f32; 12] = [
+ 1.2, 0.7, 0.5, 0.2, 0.0, -0.2, -0.3, -0.5, -0.6, -0.8, -1.0, -1.1,
+];
+const SKY_FLARE_COLORS: [u32; 12] = [
+ 0xFFB0_90A3,
+ 0xFF5A_58BB,
+ 0x9630_BE52,
+ 0x96C9_3432,
+ 0xFF30_BE52,
+ 0x9696_9664,
+ 0xFFB0_90A3,
+ 0xFF7C_6BC9,
+ 0x9630_6452,
+ 0xFF0B_17B9,
+ 0xFFB6_26B1,
+ 0xFF7C_C5C9,
+];
+
+const GLARE_COS_15: f32 = 0.965_925_8;
+const GLARE_COS_30: f32 = 0.866_025_4;
+const GLARE_COS_60: f32 = 0.5;
+const SUN_SPRITE_DISTANCE: f32 = 0.325;
+
+/// Renderer-facing state for one directional sun, moon or flare sprite.
+///
+/// The CPU side provides the native material row, direction, sampled RGB and
+/// unit-quad UVs. A renderer projects the direction through its active camera
+/// and applies the material's size/alpha state. Flare rows additionally need a
+/// camera visibility or occlusion test before drawing.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SkySpriteFrame<'a> {
+ /// Positional role in `sky.wea`.
+ pub kind: SkyLayerKind,
+ /// Positional material row.
+ pub row: usize,
+ /// Dynamic mission material name.
+ pub material: Option<&'a str>,
+ /// Whether the source celestial interval is active.
+ pub active: bool,
+ /// Direction in world space.
+ pub direction: [f32; 3],
+ /// Native packed sprite RGB (`SunSample::packed[0]`).
+ pub color: [f32; 3],
+ /// Native packed sprite alpha (`SunSample::packed[0]`).
+ pub alpha: f32,
+ /// The two native half-size sample values used by sun/moon quads.
+ pub size_values: [f32; 2],
+ /// Sampled intensity.
+ pub intensity: f32,
+ /// Unit quad texture coordinates in native corner order.
+ pub uv: [[f32; 2]; 4],
+ /// Blend operation for the sprite pass.
+ pub blend: SkyBlendMode,
+ /// Flares require camera-space visibility testing.
+ pub requires_occlusion: bool,
+}
+
+/// One native directional source submitted for a celestial object.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct DirectionalLightFrame {
+ /// False means the light slot must be removed from the light manager.
+ pub active: bool,
+ /// Direction used by the native light manager.
+ pub direction: [f32; 3],
+ /// RGB value passed to the native light manager without another intensity
+ /// multiplication.
+ pub color: [f32; 3],
+}
+
+impl DirectionalLightFrame {
+ const INACTIVE: Self = Self {
+ active: false,
+ direction: [0.0; 3],
+ color: [0.0; 3],
+ };
+}
+
+/// Camera data needed by the native screen-space sun and flare path.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SunVisibility {
+ /// Projected sun center in viewport pixels.
+ pub projected_sun: [f32; 2],
+ /// Viewport width and height in pixels.
+ pub viewport: [f32; 2],
+ /// Camera forward vector in world space.
+ pub view_forward: [f32; 3],
+ /// Result of the renderer's world-ray visibility query.
+ pub unoccluded: bool,
+}
+
+/// One screen-space flare generated from the native flare tables.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct FlareQuad {
+ /// Native positional material row (5 or 6).
+ pub row: usize,
+ /// Whether this quad should be submitted this frame.
+ pub active: bool,
+ /// Final RGBA color after the packed sun alpha multiplication.
+ pub color: [f32; 4],
+ /// Center in viewport pixels.
+ pub center_pixels: [f32; 2],
+ /// Equal X/Y half-size in viewport pixels.
+ pub half_size_pixels: f32,
+ /// Native UV order for the quad.
+ pub uv: [[f32; 2]; 4],
+}
+
+/// Result of the native sun glare/flare calculation.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SunOptics {
+ /// RGB submitted to the primary directional light after the glare boost.
+ pub primary_color: [f32; 3],
+ /// Squared 15-degree cone amount before the horizon factor.
+ pub glare_amount: f32,
+ /// Twelve fixed-topology flare slots.
+ pub flares: [FlareQuad; 12],
+}
+
+/// Errors returned when a camera projection cannot supply native sprite data.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum SkyProjectionError {
+ /// Viewport dimensions must be finite and positive.
+ InvalidViewport([f32; 2]),
+ /// Horizontal FOV must be finite and in `(0, pi)`.
+ InvalidHorizontalFov(f32),
+ /// A camera forward vector could not be normalized.
+ InvalidViewForward([f32; 3]),
+}
+
+impl fmt::Display for SkyProjectionError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::InvalidViewport(value) => write!(f, "invalid sky viewport: {value:?}"),
+ Self::InvalidHorizontalFov(value) => {
+ write!(f, "invalid sky horizontal FOV: {value}")
+ }
+ Self::InvalidViewForward(value) => {
+ write!(f, "invalid sky view forward vector: {value:?}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for SkyProjectionError {}
+
+/// Errors returned while updating a persistent sky owner.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum SkyUpdateError {
+ /// An externally supplied atmosphere sample contains a non-finite value.
+ NonFiniteAtmosphere,
+ /// The persistent mesh no longer matches its immutable configuration.
+ TopologyChanged,
+}
+
+impl fmt::Display for SkyUpdateError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::NonFiniteAtmosphere => {
+ write!(f, "sky atmosphere sample contains a non-finite value")
+ }
+ Self::TopologyChanged => write!(f, "sky topology changed after construction"),
+ }
+ }
+}
+
+impl std::error::Error for SkyUpdateError {}
+
+/// Fog values sent by the original `CSky` update.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct FogFrame {
+ /// Normalized RGB fog color from `SkySample::packed[1]`.
+ pub color: [f32; 3],
+ /// Native global lighting floor from the same sampled RGB state.
+ ///
+ /// `CShade` merges this vector with the material lighting accumulator by
+ /// taking the per-channel maximum before the shader receives the result.
+ pub lighting_floor: [f32; 3],
+ /// Fog start distance (`sky.values[0] * 700`).
+ pub start: f32,
+ /// Fog end distance (`sky.values[1] * 700`).
+ pub end: f32,
+}
+
+impl FogFrame {
+ /// Converts an interpolated sky sample to fog parameters.
+ #[must_use]
+ pub fn from_sample(sample: &SkySample) -> Self {
+ let start = finite_or_zero(sample.values[0]) * FOG_DISTANCE_SCALE;
+ let end = finite_or_zero(sample.values[1]) * FOG_DISTANCE_SCALE;
+ let rgba = sample.packed[1].rgba();
+ let color = [rgba[0], rgba[1], rgba[2]];
+ Self {
+ color,
+ lighting_floor: color,
+ start,
+ end,
+ }
+ }
+}
+
+/// Converts an interpolated sky sample to fog parameters.
+#[must_use]
+pub fn fog_frame(sample: &SkySample) -> FogFrame {
+ FogFrame::from_sample(sample)
+}
+
+/// Birth and orbit settings for a sun object.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SunConfig {
+ /// Time at which the stateless sun sample starts, in seconds.
+ pub start_seconds: f32,
+ /// Lifetime used by the original `(now - start) / life` age calculation.
+ pub lifetime_seconds: f32,
+ /// Two integer birth angles from the sun birth payload, in degrees.
+ pub initial_angles_degrees: [f32; 2],
+}
+
+impl SunConfig {
+ /// Creates sun settings.
+ #[must_use]
+ pub const fn new(
+ start_seconds: f32,
+ lifetime_seconds: f32,
+ initial_angles_degrees: [f32; 2],
+ ) -> Self {
+ Self {
+ start_seconds,
+ lifetime_seconds,
+ initial_angles_degrees,
+ }
+ }
+
+ fn angles_radians(self) -> [f32; 2] {
+ [
+ finite_or_zero(self.initial_angles_degrees[0]).to_radians(),
+ finite_or_zero(self.initial_angles_degrees[1]).to_radians(),
+ ]
+ }
+
+ /// Converts the native birth payload emitted by the atmosphere sampler.
+ #[must_use]
+ pub const fn from_birth(birth: SunBirth) -> Self {
+ Self::new(
+ birth.start_seconds,
+ birth.lifetime_seconds,
+ birth.initial_angles_degrees,
+ )
+ }
+}
+
+impl Default for SunConfig {
+ fn default() -> Self {
+ Self::new(0.0, 1.0, [0.0; 2])
+ }
+}
+
+/// A renderer-facing sun sample.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct SunFrame {
+ /// Whether the latest SunStart/SunStop interval is active.
+ pub active: bool,
+ /// Final world-space direction after the original birth-angle matrix.
+ pub direction: [f32; 3],
+ /// Intermediate orbit direction before the birth-angle matrix.
+ pub orbit_direction: [f32; 3],
+ /// RGB light color from `SunSample::rgb`.
+ pub color: [f32; 3],
+ /// RGBA color stored in the native sprite descriptor.
+ pub sprite_color: [f32; 4],
+ /// RGB color of the opposite directional light from packed parameter 1.
+ pub secondary_color: [f32; 3],
+ /// The two native screen-quad size values from the SKE tail.
+ pub size_values: [f32; 2],
+ /// Material row selected by the native birth dispatcher.
+ pub material_row: Option<usize>,
+ /// Atmosphere intensity from trailing key parameter two.
+ pub intensity: f32,
+ /// Clamped normalized lifecycle age.
+ pub age: f32,
+ /// Untransformed orbit angle in radians.
+ pub theta: f32,
+}
+
+impl SunFrame {
+ /// Converts a sampled sun and stateless clock into a frame.
+ #[must_use]
+ pub fn from_sample(sample: &SunSample, now_seconds: f32, config: SunConfig) -> Self {
+ sun_frame(sample, now_seconds, config)
+ }
+
+ /// Returns an inactive frame without inventing lifecycle timing.
+ #[must_use]
+ pub fn inactive(sample: &SunSample) -> Self {
+ let sprite_color = sample.packed[0].rgba();
+ let secondary_color = sample.packed[1].rgba();
+ Self {
+ active: false,
+ direction: [0.0; 3],
+ orbit_direction: [0.0; 3],
+ color: sample.rgb.map(finite_or_zero),
+ sprite_color,
+ secondary_color: [secondary_color[0], secondary_color[1], secondary_color[2]],
+ size_values: sample.values.map(finite_or_zero),
+ material_row: None,
+ intensity: finite_or_zero(sample.intensity).max(0.0),
+ age: 0.0,
+ theta: 0.0,
+ }
+ }
+
+ /// Direction from the camera toward the native celestial geometry.
+ ///
+ /// The light manager stores the opposite vector, so the screen/world
+ /// sprite path negates [`Self::direction`].
+ #[must_use]
+ pub fn geometry_direction(self) -> [f32; 3] {
+ self.direction.map(|component| -component)
+ }
+
+ /// Places the native celestial object at half the camera far plane.
+ /// This is `camera_translation - light_direction * far / 2`, with the
+ /// geometry direction exposed by [`Self::geometry_direction`].
+ #[must_use]
+ pub fn world_center(self, camera_translation: [f32; 3], camera_far: f32) -> [f32; 3] {
+ let distance = finite_or_zero(camera_far) * 0.5;
+ let direction = self.geometry_direction();
+ std::array::from_fn(|index| camera_translation[index] + direction[index] * distance)
+ }
+
+ /// Returns primary and opposite directional lights in native order.
+ #[must_use]
+ pub fn directional_lights(self) -> [DirectionalLightFrame; 2] {
+ if !self.active {
+ return [DirectionalLightFrame::INACTIVE; 2];
+ }
+ [
+ DirectionalLightFrame {
+ active: true,
+ direction: self.direction,
+ color: self.color,
+ },
+ DirectionalLightFrame {
+ active: true,
+ direction: self.direction.map(|component| -component),
+ color: self.secondary_color,
+ },
+ ]
+ }
+}
+
+/// Converts an interpolated atmosphere sun sample to a renderer-facing frame.
+///
+/// The original update computes `orbit_direction = [cos(theta), 0,
+/// -sin(theta)]`, then applies the row-vector product of the two birth-angle
+/// matrices. With `A` and `B` in radians, the resulting direction is:
+///
+/// ```text
+/// [x*cos(A), -x*sin(A)*cos(B) + z*sin(B),
+/// x*sin(A)*sin(B) + z*cos(B)]
+/// ```
+///
+/// where `x` and `z` are the two non-zero orbit components.
+#[must_use]
+pub fn sun_frame(sample: &SunSample, now_seconds: f32, config: SunConfig) -> SunFrame {
+ let color = sample.rgb.map(finite_or_zero);
+ let sprite_color = sample.packed[0].rgba();
+ let secondary = sample.packed[1].rgba();
+ let intensity = finite_or_zero(sample.intensity).max(0.0);
+ if !sample.active {
+ return SunFrame::inactive(sample);
+ }
+
+ let now_seconds = finite_or_zero(now_seconds);
+ let start_seconds = finite_or_zero(config.start_seconds);
+ let lifetime = finite_or_zero(config.lifetime_seconds);
+ let age = if lifetime > 0.0 {
+ ((now_seconds - start_seconds) / lifetime).clamp(0.0, 1.0)
+ } else {
+ 1.0
+ };
+ let theta = (age * 1.2 - 0.1) * std::f32::consts::PI;
+ let orbit_direction = [theta.cos(), 0.0, -theta.sin()];
+ let [angle_a, angle_b] = config.angles_radians();
+ let (sin_a, cos_a) = angle_a.sin_cos();
+ let (sin_b, cos_b) = angle_b.sin_cos();
+ let x = orbit_direction[0];
+ let z = orbit_direction[2];
+ let direction = normalize3([
+ x * cos_a,
+ -x * sin_a * cos_b + z * sin_b,
+ x * sin_a * sin_b + z * cos_b,
+ ]);
+ SunFrame {
+ active: true,
+ direction,
+ orbit_direction,
+ color,
+ sprite_color,
+ secondary_color: [secondary[0], secondary[1], secondary[2]],
+ size_values: sample.values.map(finite_or_zero),
+ material_row: sample.birth.map(|birth| birth.material_row),
+ intensity,
+ age,
+ theta,
+ }
+}
+
+/// Converts a sampled celestial object using the birth payload generated by
+/// the atmosphere dispatcher.
+///
+/// The schedule sampler carries the absolute start time, lifetime, angles and
+/// material row for the active interval. Keeping that payload in the sample
+/// means a renderer does not need a stateful wrapper or a caller-provided
+/// placeholder configuration.
+#[must_use]
+pub fn celestial_frame(sample: &SunSample, now_seconds: f32) -> SunFrame {
+ sample
+ .birth
+ .map(|birth| sun_frame(sample, now_seconds, SunConfig::from_birth(birth)))
+ .unwrap_or_else(|| SunFrame::inactive(sample))
+}
+
+/// Computes the native half-size of a sun or moon screen quad in pixels.
+///
+/// The SKE tail values are the first two fields of `SunSample::values`; they
+/// are deliberately not read from the material descriptor. The renderer
+/// supplies the real viewport and horizontal FOV for the current swapchain.
+pub fn sprite_half_size_pixels(
+ values: [f32; 2],
+ viewport: [f32; 2],
+ horizontal_fov: f32,
+) -> Result<[f32; 2], SkyProjectionError> {
+ validate_viewport(viewport)?;
+ if !horizontal_fov.is_finite()
+ || horizontal_fov <= 0.0
+ || horizontal_fov >= std::f32::consts::PI
+ {
+ return Err(SkyProjectionError::InvalidHorizontalFov(horizontal_fov));
+ }
+ let scale = viewport[0] / horizontal_fov * SUN_SPRITE_DISTANCE * 0.5;
+ Ok([
+ scale * finite_or_zero(values[0]),
+ scale * finite_or_zero(values[1]),
+ ])
+}
+
+/// Computes native glare and flare quads for the current camera.
+pub fn sun_optics(
+ sun: SunFrame,
+ visibility: SunVisibility,
+) -> Result<SunOptics, SkyProjectionError> {
+ validate_viewport(visibility.viewport)?;
+ let view_forward = normalize3_checked(visibility.view_forward)?;
+ let light_direction = sun.direction;
+ let to_sun = light_direction.map(|component| -component);
+ let cone = dot3(view_forward, to_sun);
+ let cone_amount = ((cone - GLARE_COS_15) / (1.0 - GLARE_COS_15)).max(0.0);
+ let glare_amount = cone_amount * cone_amount;
+ let projected_inside = visibility.projected_sun[0].is_finite()
+ && visibility.projected_sun[1].is_finite()
+ && visibility.projected_sun[0] >= 0.0
+ && visibility.projected_sun[0] <= visibility.viewport[0]
+ && visibility.projected_sun[1] >= 0.0
+ && visibility.projected_sun[1] <= visibility.viewport[1];
+ let primary_length = length3(sun.color);
+ let visible = sun.active && projected_inside && visibility.unoccluded && primary_length > 1.1;
+
+ let mut primary_color = sun.color;
+ if visible {
+ let height =
+ ((-light_direction[2] - GLARE_COS_60) / (GLARE_COS_30 - GLARE_COS_60)).clamp(0.0, 1.0);
+ let boost = 1.0 + 4.0 * sun.sprite_color[3].clamp(0.0, 1.0) * height * glare_amount;
+ primary_color = primary_color.map(|component| component * boost);
+ }
+
+ let flare_active = visible && glare_amount <= 0.1;
+ let viewport_center = [visibility.viewport[0] * 0.5, visibility.viewport[1] * 0.5];
+ let flares = std::array::from_fn(|index| {
+ let base = PackedColor(SKY_FLARE_COLORS[index]);
+ let rgba = base.rgba();
+ let base_alpha = ((SKY_FLARE_COLORS[index] >> 24) & 0xFF) as f32;
+ let alpha = (base_alpha * sun.sprite_color[3].clamp(0.0, 1.0)).round_ties_even() / 255.0;
+ let offset = SKY_FLARE_OFFSETS[index];
+ let center = [
+ viewport_center[0] + (visibility.projected_sun[0] - viewport_center[0]) * offset,
+ viewport_center[1] + (visibility.projected_sun[1] - viewport_center[1]) * offset,
+ ];
+ FlareQuad {
+ row: SKY_FLARE_ROWS[index],
+ active: flare_active,
+ color: [rgba[0], rgba[1], rgba[2], alpha],
+ center_pixels: center,
+ half_size_pixels: visibility.viewport[0] * SKY_FLARE_SIZES[index] / 8.0,
+ uv: SKY_SPRITE_UV,
+ }
+ });
+ Ok(SunOptics {
+ primary_color,
+ glare_amount,
+ flares,
+ })
+}
+
+fn validate_viewport(viewport: [f32; 2]) -> Result<(), SkyProjectionError> {
+ if viewport[0].is_finite() && viewport[1].is_finite() && viewport[0] > 0.0 && viewport[1] > 0.0
+ {
+ Ok(())
+ } else {
+ Err(SkyProjectionError::InvalidViewport(viewport))
+ }
+}
+
+fn normalize3_checked(vector: [f32; 3]) -> Result<[f32; 3], SkyProjectionError> {
+ let length = length3(vector);
+ if length.is_finite() && length > 0.0 {
+ Ok(vector.map(|component| component / length))
+ } else {
+ Err(SkyProjectionError::InvalidViewForward(vector))
+ }
+}
+
+fn length3(vector: [f32; 3]) -> f32 {
+ (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt()
+}
+
+fn dot3(left: [f32; 3], right: [f32; 3]) -> f32 {
+ left[0] * right[0] + left[1] * right[1] + left[2] * right[2]
+}
+
+/// Samples the native full-screen sky gradient and lighting-dependent horizon
+/// floor for one view.
+///
+/// The original `CSky::Render` code at `481C5` converts `(yaw + pi)` to
+/// degrees with the x87 nearest-even conversion, chooses one of four
+/// ninety-degree sectors, and interpolates the corresponding two entries of
+/// `SkySample::colors[0..4]`. The later `483A3..485EE` block derives a
+/// non-negative light-manager glare RGB delta and raises the stored dome
+/// palette to its piecewise RGB floor. The renderer supplies
+/// `glare_rgb_delta = max(current_light_rgb - base_light_rgb, 0)` componentwise;
+/// it is not a camera or projection vector.
+#[must_use]
+pub fn screen_gradient_frame(
+ sample: &SkySample,
+ yaw_radians: f32,
+ glare_rgb_delta: [f32; 3],
+) -> SkyGradientFrame {
+ let yaw_radians = finite_or_zero(yaw_radians);
+ let phase_degrees = ((yaw_radians + NATIVE_PI) * (180.0 / NATIVE_PI)).round_ties_even();
+ let phase_degrees = phase_degrees as i64;
+ let segment = phase_degrees.div_euclid(NATIVE_GRADIENT_SEGMENT_DEGREES);
+ let remainder = phase_degrees.rem_euclid(NATIVE_GRADIENT_SEGMENT_DEGREES);
+ let palette_index = (segment - 2).rem_euclid(4) as usize;
+ let next_palette_index = (palette_index + 1) & 3;
+ let palette_fraction = remainder as f32 * NATIVE_GRADIENT_FRACTION_SCALE;
+ let color = interpolate_gradient_color(
+ sample.colors[palette_index],
+ sample.colors[next_palette_index],
+ palette_fraction,
+ );
+ let glare_rgb_delta = glare_rgb_delta.map(|component| finite_or_zero(component).max(0.0));
+ let horizon_floor = glare_rgb_delta.map(native_horizon_floor_component);
+ SkyGradientFrame {
+ color,
+ horizon_floor,
+ }
+}
+
+fn interpolate_gradient_color(
+ first: PackedColor,
+ second: PackedColor,
+ fraction: f32,
+) -> PackedColor {
+ let channel = |shift: u32| {
+ let a = ((first.raw() >> shift) & 0xFF) as f32;
+ let b = ((second.raw() >> shift) & 0xFF) as f32;
+ (a + (b - a) * fraction).clamp(0.0, 255.0).round_ties_even() as u32
+ };
+ PackedColor(0xFF00_0000 | (channel(16) << 16) | (channel(8) << 8) | channel(0))
+}
+
+fn native_horizon_floor_component(delta: f32) -> f32 {
+ let mapped = if delta <= NATIVE_GRADIENT_ONE {
+ delta * NATIVE_GRADIENT_SHORT_SCALE
+ } else if delta <= NATIVE_GRADIENT_THREE {
+ delta * NATIVE_GRADIENT_LONG_SCALE + NATIVE_GRADIENT_LONG_OFFSET
+ } else {
+ NATIVE_GRADIENT_ONE
+ };
+ // Keep the normalized API bounded even if a caller supplies a malformed
+ // negative or non-finite value after the input sanitization above.
+ mapped.clamp(NATIVE_GRADIENT_ZERO, NATIVE_GRADIENT_ONE)
+}
+
+/// Complete CPU output for one sky frame.
+#[derive(Clone, Debug, PartialEq)]
+pub struct SkyFrame<'a> {
+ /// Interpolated atmosphere sky payload retained for renderer extensions.
+ pub sky: SkySample,
+ /// Sun and light-manager state.
+ pub sun: SunFrame,
+ /// Independent moon object state from the moon SunStart/SunStop interval.
+ pub moon: SunFrame,
+ /// Primary and opposite light for the sun, followed by the moon pair.
+ pub directional_lights: [DirectionalLightFrame; 4],
+ /// Native directional sprite data for sun, moon and both flares.
+ pub sprites: [SkySpriteFrame<'a>; 4],
+ /// Native sky draw order. The renderer submits these four passes as a
+ /// stable topology and changes only sampled uniforms/material dynamics.
+ pub passes: [SkyPassFrame<'a>; 4],
+ /// Fog state.
+ pub fog: FogFrame,
+ /// Native global lighting floor copied from the sampled sky state.
+ pub lighting_floor: [f32; 3],
+ /// Per-row cloud/star/sprite state.
+ pub layers: Vec<SkyLayerFrame<'a>>,
+ /// Clock value supplied by the caller.
+ pub time_seconds: f32,
+}
+
+impl<'a> SkyFrame<'a> {
+ /// Builds a frame from one sampled atmosphere snapshot.
+ #[must_use]
+ pub fn from_atmosphere(
+ atmosphere: &AtmosphereFrame,
+ materials: &'a SkyMaterials,
+ now_seconds: f32,
+ ) -> SkyFrame<'a> {
+ let sky = atmosphere.sky.clone();
+ let sun = celestial_frame(&atmosphere.sun, now_seconds);
+ let moon = celestial_frame(&atmosphere.moon, now_seconds);
+ let time_seconds = finite_or_zero(now_seconds);
+ let fog = fog_frame(&sky);
+ let passes = build_passes(materials, &sky);
+ Self {
+ sky,
+ sun,
+ moon,
+ directional_lights: celestial_lights(&sun, &moon),
+ sprites: build_sprites(materials, &sun, &moon),
+ passes,
+ lighting_floor: fog.lighting_floor,
+ fog,
+ layers: build_layers(materials, &sun, atmosphere, time_seconds),
+ time_seconds,
+ }
+ }
+
+ /// Computes camera-dependent sun glare and the fixed twelve flare slots.
+ pub fn sun_optics(&self, visibility: SunVisibility) -> Result<SunOptics, SkyProjectionError> {
+ sun_optics(self.sun, visibility)
+ }
+
+ /// Computes the lighting-dependent native screen-gradient and horizon floor.
+ #[must_use]
+ pub fn screen_gradient(&self, yaw_radians: f32, glare_rgb_delta: [f32; 3]) -> SkyGradientFrame {
+ screen_gradient_frame(&self.sky, yaw_radians, glare_rgb_delta)
+ }
+}
+
+/// Stateful CPU sky owner suitable for a renderer's per-frame update.
+#[derive(Clone, Debug)]
+pub struct SkySystem {
+ /// Dome geometry configuration.
+ pub geometry: SkyGeometryConfig,
+ /// Mission material rows.
+ pub materials: SkyMaterials,
+ /// Static dome topology with mutable per-frame colors.
+ mesh: SkyMesh,
+}
+
+impl SkySystem {
+ /// Creates a sky system.
+ pub fn new(
+ geometry: SkyGeometryConfig,
+ materials: SkyMaterials,
+ ) -> Result<Self, SkyGeometryError> {
+ Ok(Self {
+ geometry,
+ materials,
+ mesh: geometry.try_topology()?,
+ })
+ }
+
+ /// Returns the persistent dome mesh.
+ #[must_use]
+ pub const fn mesh(&self) -> &SkyMesh {
+ &self.mesh
+ }
+
+ /// Updates only dynamic colors and frame state on the persistent topology.
+ pub fn update(
+ &mut self,
+ atmosphere: &AtmosphereFrame,
+ now_seconds: f32,
+ ) -> Result<SkyFrame<'_>, SkyUpdateError> {
+ validate_atmosphere(atmosphere)?;
+ let expected_vertices = self
+ .geometry
+ .azimuth_count
+ .checked_mul(self.geometry.rings)
+ .and_then(|count| count.checked_add(1));
+ let expected_indices = self
+ .geometry
+ .azimuth_count
+ .checked_mul(self.geometry.rings.saturating_mul(2).saturating_sub(1))
+ .and_then(|count| count.checked_mul(3));
+ if expected_vertices != Some(self.mesh.vertices.len())
+ || expected_indices != Some(self.mesh.indices.len())
+ {
+ return Err(SkyUpdateError::TopologyChanged);
+ }
+ let sky = atmosphere.sky.clone();
+ self.mesh.update_colors(&sky);
+ let sun = celestial_frame(&atmosphere.sun, now_seconds);
+ let moon = celestial_frame(&atmosphere.moon, now_seconds);
+ let time_seconds = finite_or_zero(now_seconds);
+ let fog = fog_frame(&sky);
+ let passes = build_passes(&self.materials, &sky);
+ Ok(SkyFrame {
+ sky,
+ sun,
+ moon,
+ directional_lights: celestial_lights(&sun, &moon),
+ sprites: build_sprites(&self.materials, &sun, &moon),
+ passes,
+ lighting_floor: fog.lighting_floor,
+ fog,
+ layers: build_layers(&self.materials, &sun, atmosphere, time_seconds),
+ time_seconds,
+ })
+ }
+}
+
+fn build_sprites<'a>(
+ materials: &'a SkyMaterials,
+ sun: &SunFrame,
+ moon: &SunFrame,
+) -> [SkySpriteFrame<'a>; 4] {
+ [
+ sprite_frame(
+ materials,
+ SkyLayerKind::Sun,
+ 3,
+ sun,
+ false,
+ SkyBlendMode::Alpha,
+ ),
+ sprite_frame(
+ materials,
+ SkyLayerKind::Moon,
+ 4,
+ moon,
+ false,
+ SkyBlendMode::Alpha,
+ ),
+ sprite_frame(
+ materials,
+ SkyLayerKind::Flare0,
+ 5,
+ sun,
+ true,
+ SkyBlendMode::Additive,
+ ),
+ sprite_frame(
+ materials,
+ SkyLayerKind::Flare1,
+ 6,
+ sun,
+ true,
+ SkyBlendMode::Additive,
+ ),
+ ]
+}
+
+fn build_passes<'a>(materials: &'a SkyMaterials, sky: &SkySample) -> [SkyPassFrame<'a>; 4] {
+ [
+ SkyPassFrame {
+ kind: SkyPassKind::ScreenGradient,
+ material_row: None,
+ secondary_material_row: None,
+ material: None,
+ secondary_material: None,
+ uv_stage: None,
+ secondary_uv_stage: None,
+ translation_z: 0.0,
+ blend: SkyBlendMode::Alpha,
+ material_color: None,
+ },
+ SkyPassFrame {
+ kind: SkyPassKind::NebulaStars,
+ material_row: Some(0),
+ secondary_material_row: Some(1),
+ material: materials.material_name(0),
+ secondary_material: materials.material_name(1),
+ uv_stage: Some(0),
+ secondary_uv_stage: Some(1),
+ translation_z: 0.0,
+ blend: SkyBlendMode::Alpha,
+ material_color: None,
+ },
+ SkyPassFrame {
+ kind: SkyPassKind::DomeGradient,
+ material_row: None,
+ secondary_material_row: None,
+ material: None,
+ secondary_material: None,
+ uv_stage: None,
+ secondary_uv_stage: None,
+ translation_z: 0.0,
+ blend: SkyBlendMode::Alpha,
+ material_color: None,
+ },
+ SkyPassFrame {
+ kind: SkyPassKind::Clouds,
+ material_row: Some(2),
+ secondary_material_row: None,
+ material: materials.material_name(2),
+ secondary_material: None,
+ uv_stage: Some(2),
+ secondary_uv_stage: None,
+ translation_z: -5000.0,
+ blend: SkyBlendMode::Alpha,
+ material_color: {
+ let rgba = sky.packed[0].rgba();
+ Some([rgba[0], rgba[1], rgba[2]])
+ },
+ },
+ ]
+}
+
+fn sprite_frame<'a>(
+ materials: &'a SkyMaterials,
+ kind: SkyLayerKind,
+ row: usize,
+ source: &SunFrame,
+ requires_occlusion: bool,
+ blend: SkyBlendMode,
+) -> SkySpriteFrame<'a> {
+ SkySpriteFrame {
+ kind,
+ row,
+ material: materials.material_name(row),
+ active: source.active,
+ direction: source.geometry_direction(),
+ color: [
+ source.sprite_color[0],
+ source.sprite_color[1],
+ source.sprite_color[2],
+ ],
+ alpha: source.sprite_color[3],
+ size_values: source.size_values,
+ intensity: source.intensity,
+ uv: SKY_SPRITE_UV,
+ blend,
+ requires_occlusion,
+ }
+}
+
+fn build_layers<'a>(
+ materials: &'a SkyMaterials,
+ sun: &SunFrame,
+ atmosphere: &AtmosphereFrame,
+ phase_seconds: f32,
+) -> Vec<SkyLayerFrame<'a>> {
+ let count = materials.rows.len().max(SKY_MATERIAL_COUNT);
+ (0..count)
+ .map(|row| {
+ let kind = SkyLayerKind::from_row(row);
+ let (active, intensity, uv_stage) = match kind {
+ SkyLayerKind::Nebula => (true, 1.0, 0),
+ SkyLayerKind::Stars => (true, 1.0, 1),
+ SkyLayerKind::Clouds => (true, 1.0, 2),
+ SkyLayerKind::Sun | SkyLayerKind::Flare0 | SkyLayerKind::Flare1 => {
+ (sun.active, sun.intensity, 0)
+ }
+ SkyLayerKind::Moon => (atmosphere.moon.active, atmosphere.moon.intensity, 0),
+ SkyLayerKind::Snow => (atmosphere.snow.active, atmosphere.snow.intensity, 0),
+ SkyLayerKind::Rain => (atmosphere.rain.active, atmosphere.rain.intensity, 0),
+ SkyLayerKind::Other(_) => (true, 1.0, 0),
+ };
+ SkyLayerFrame {
+ row,
+ kind,
+ material: materials.material_name(row),
+ active,
+ uv_stage,
+ uv_scale: SKY_UV_SCALES[uv_stage],
+ intensity: finite_or_zero(intensity).max(0.0),
+ phase_seconds,
+ }
+ })
+ .collect()
+}
+
+fn celestial_lights(sun: &SunFrame, moon: &SunFrame) -> [DirectionalLightFrame; 4] {
+ let sun_lights = sun.directional_lights();
+ let moon_lights = moon.directional_lights();
+ [sun_lights[0], sun_lights[1], moon_lights[0], moon_lights[1]]
+}
+
+fn validate_atmosphere(atmosphere: &AtmosphereFrame) -> Result<(), SkyUpdateError> {
+ let finite = |values: &[f32]| values.iter().all(|value| value.is_finite());
+ if finite(&atmosphere.sky.values)
+ && finite(&atmosphere.sun.rgb)
+ && finite(&atmosphere.sun.values)
+ && atmosphere.sun.intensity.is_finite()
+ && finite(&atmosphere.moon.rgb)
+ && finite(&atmosphere.moon.values)
+ && atmosphere.moon.intensity.is_finite()
+ && atmosphere.rain.intensity.is_finite()
+ && atmosphere.snow.intensity.is_finite()
+ && atmosphere.lightning.intensity.is_finite()
+ && atmosphere.real_seconds.is_finite()
+ && atmosphere.track_seconds.is_finite()
+ && atmosphere.day_seconds.is_finite()
+ {
+ Ok(())
+ } else {
+ Err(SkyUpdateError::NonFiniteAtmosphere)
+ }
+}
+
+fn finite_or_zero(value: f32) -> f32 {
+ if value.is_finite() {
+ value
+ } else {
+ 0.0
+ }
+}
+
+fn normalize3(vector: [f32; 3]) -> [f32; 3] {
+ let length = (vector[0] * vector[0] + vector[1] * vector[1] + vector[2] * vector[2]).sqrt();
+ if length.is_finite() && length > 0.0 {
+ vector.map(|component| component / length)
+ } else {
+ [0.0; 3]
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::atmosphere::{AtmosphereFrame, WeatherSample};
+
+ fn sample() -> SkySample {
+ let colors = std::array::from_fn(|index| PackedColor(0xFF00_0000 | index as u32));
+ SkySample {
+ colors,
+ values: [1.0, 2.0],
+ packed: [PackedColor(0), PackedColor(0xFF11_2233)],
+ }
+ }
+
+ fn sun(active: bool) -> SunSample {
+ SunSample {
+ active,
+ rgb: [0.5, 0.25, 0.125],
+ values: [0.0; 2],
+ packed: [PackedColor::default(); 2],
+ intensity: 0.5,
+ birth: None,
+ }
+ }
+
+ fn atmosphere(active: bool) -> AtmosphereFrame {
+ AtmosphereFrame {
+ real_seconds: 0.0,
+ track_index: 0,
+ track_seconds: 0.0,
+ day_seconds: 0.0,
+ sky: sample(),
+ sun: sun(active),
+ moon: sun(false),
+ rain: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ snow: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ lightning: WeatherSample {
+ active: false,
+ intensity: 0.0,
+ color: [0.0; 4],
+ raw_color: [0.0; 4],
+ resources: Vec::new(),
+ },
+ }
+ }
+
+ #[test]
+ fn geometry_matches_original_counts_positions_uv_and_winding() {
+ let config = SkyGeometryConfig::new(10.0, std::f32::consts::FRAC_PI_2, 2.0, 4, 3);
+ let mesh = config.try_mesh(&sample()).expect("valid mesh");
+ assert_eq!(mesh.vertices.len(), 13);
+ assert_eq!(mesh.indices.len(), 4 * (6 * 3 - 3));
+ assert_eq!(mesh.indices[0..3], [0, 1, 4]);
+ assert_eq!(mesh.indices[3..9], [1, 2, 4, 4, 2, 5]);
+ assert_eq!(mesh.vertices[0].position, [0.0, 0.0, 20.0]);
+ assert!((mesh.vertices[1].position[0]).abs() < 1.0e-5);
+ assert!((mesh.vertices[1].position[1] - 5.0).abs() < 1.0e-5);
+ assert!((mesh.vertices[1].position[2] - 17.320_508).abs() < 1.0e-5);
+ assert!((mesh.vertices[1].uv[1][1] - 7.5).abs() < 1.0e-5);
+ assert_eq!(mesh.vertices[0].normal, [0, 0, 127]);
+ assert_eq!(mesh.vertices[0].normal_vector(), [0.0, 0.0, 1.0]);
+ }
+
+ #[test]
+ fn normals_use_signed_127_nearest_even_encoding() {
+ assert_eq!(normal_byte(0.5), 64);
+ assert_eq!(normal_byte(-0.5), -64);
+ assert_eq!(normal_byte(-1.0), -127);
+ }
+
+ #[test]
+ fn colors_use_zenith_and_three_interpolated_quadrant_belts() {
+ let mut sky = sample();
+ sky.colors[12] = PackedColor(0xFFAA_BBCC);
+ sky.colors[8] = PackedColor(0xFF00_0000);
+ sky.colors[9] = PackedColor(0xFF00_FF00);
+ sky.colors[10] = PackedColor(0xFF00_00FF);
+ sky.colors[11] = PackedColor(0xFFFF_FFFF);
+ let config = SkyGeometryConfig::new(10.0, 1.0, 1.0, 12, 4);
+ let mesh = config.try_mesh(&sky).expect("valid mesh");
+ assert_eq!(mesh.vertices[0].color.raw(), 0xFFAA_BBCC);
+ assert_eq!(mesh.vertices[1].color.raw(), 0xFFAA_BBCC);
+ // First belt, azimuth one: one third from colors 8 to 9.
+ assert_eq!(mesh.vertices[6].color.raw(), PackedColor(0xFF00_5500).raw());
+ // The next belt starts at the fourth ring, and the last belt is used
+ // for every ring after the three original color bands.
+ let ring_two = 1 + 2;
+ let ring_three = 1 + 3;
+ assert_eq!(mesh.vertices[ring_two].color.raw(), sky.colors[4].raw());
+ assert_eq!(mesh.vertices[ring_three].color.raw(), sky.colors[0].raw());
+ }
+
+ #[test]
+ fn wea_parser_keeps_dynamic_names_and_optional_lightmaps() {
+ let table = SkyMaterials::parse(
+ b"2\r\n7 custom_snow\r\n8 custom_rain\r\n\r\nLIGHTMAPS\r\n1\r\n0 lm\r\n",
+ )
+ .expect("sky.wea");
+ assert_eq!(table.material_name(0), Some("custom_snow"));
+ assert_eq!(table.rows[1].legacy_id, 8);
+ assert_eq!(table.lightmaps[0].name, "lm");
+ }
+
+ #[test]
+ fn fog_uses_original_scale_and_packed_rgb() {
+ let frame = fog_frame(&sample());
+ assert_eq!(frame.start, 700.0);
+ assert_eq!(frame.end, 1_400.0);
+ assert_eq!(
+ frame.color,
+ [
+ 0x11 as f32 / 255.0,
+ 0x22 as f32 / 255.0,
+ 0x33 as f32 / 255.0
+ ]
+ );
+ }
+
+ #[test]
+ fn screen_gradient_uses_native_yaw_and_lighting_floor() {
+ let mut sky = sample();
+ sky.colors[0] = PackedColor(0xFF00_0000);
+ sky.colors[1] = PackedColor(0xFF00_FF00);
+ sky.colors[2] = PackedColor(0xFF00_00FF);
+ sky.colors[3] = PackedColor(0xFFFF_0000);
+
+ let at_sector_start = screen_gradient_frame(&sky, 0.0, [0.0; 3]);
+ assert_eq!(at_sector_start.color, sky.colors[0]);
+
+ let halfway = screen_gradient_frame(&sky, std::f32::consts::FRAC_PI_4, [0.5, 2.0, 4.0]);
+ assert_eq!(halfway.color, PackedColor(0xFF00_8000));
+ assert_eq!(halfway.horizon_floor, [0.4, 0.9, 1.0]);
+ assert_eq!(
+ halfway.clamp_dome_color(PackedColor(0xFF10_2030)),
+ PackedColor(0xFF66_E6_FF)
+ );
+ }
+
+ #[test]
+ fn lighting_floor_maps_native_boundaries() {
+ for (delta, expected) in [(0.5, 0.4), (1.0, 0.8), (2.0, 0.9), (3.0, 1.0), (4.0, 1.0)] {
+ let frame = screen_gradient_frame(&sample(), 0.0, [delta; 3]);
+ assert_eq!(frame.horizon_floor, [expected; 3], "delta={delta}");
+ }
+ }
+
+ #[test]
+ fn sun_orientation_applies_birth_matrix_after_orbit_vector() {
+ let sample = sun(true);
+ let at_start = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [90.0, 0.0]));
+ assert!((at_start.direction[0]).abs() < 1.0e-5);
+ assert!((at_start.direction[1] + at_start.orbit_direction[0]).abs() < 1.0e-5);
+ assert!((at_start.direction[2] - at_start.orbit_direction[2]).abs() < 1.0e-5);
+
+ let side = sun_frame(&sample, 0.0, SunConfig::new(0.0, 1.0, [0.0, 90.0]));
+ assert!((side.direction[0] - side.orbit_direction[0]).abs() < 1.0e-5);
+ assert!((side.direction[1] - side.orbit_direction[2]).abs() < 1.0e-5);
+ assert!(side.direction[2].abs() < 1.0e-5);
+ }
+
+ #[test]
+ fn native_birth_keeps_the_real_start_time() {
+ assert!(!celestial_frame(&sun(false), 100.0).active);
+ let birth = SunBirth {
+ start_seconds: 90.0,
+ lifetime_seconds: 20.0,
+ initial_angles_degrees: [0.0; 2],
+ material_row: 3,
+ };
+ let mut active = sun(true);
+ active.birth = Some(birth);
+ let first = celestial_frame(&active, 100.0);
+ let later = celestial_frame(&active, 105.0);
+ assert!((first.age - 0.5).abs() < 1.0e-5);
+ assert!((later.age - 0.75).abs() < 1.0e-5);
+ assert!(!celestial_frame(&sun(false), 106.0).active);
+ }
+
+ #[test]
+ fn frame_exposes_dynamic_layers_and_moon_direction() {
+ let atmosphere = atmosphere(true);
+ let materials = SkyMaterials::from_names(["nebula", "stars", "clouds", "sun", "moon"]);
+ let frame = SkyFrame::from_atmosphere(&atmosphere, &materials, 0.0);
+ assert_eq!(frame.layers[3].material.as_deref(), Some("sun"));
+ assert_eq!(frame.layers[8].material, None);
+ assert_eq!(frame.moon.direction, [0.0; 3]);
+ assert_eq!(frame.sprites[0].material, Some("sun"));
+ }
+
+ #[test]
+ fn system_reuses_topology_and_updates_only_colors() {
+ let materials = SkyMaterials::from_names(["nebula", "stars", "clouds"]);
+ let mut system = SkySystem::new(SkyGeometryConfig::default(), materials).expect("geometry");
+ let vertex_ptr = system.mesh().vertices.as_ptr();
+ let index_ptr = system.mesh().indices.as_ptr();
+ let positions = system
+ .mesh()
+ .vertices
+ .iter()
+ .map(|vertex| vertex.position)
+ .collect::<Vec<_>>();
+
+ let mut atmosphere = atmosphere(true);
+ atmosphere.sky.colors[12] = PackedColor(0xFF12_3456);
+ let (lighting_floor, fog_color, sprites_have_native_uv) = {
+ let frame = system.update(&atmosphere, 10.0).expect("sky update");
+ (
+ frame.lighting_floor,
+ frame.fog.color,
+ frame
+ .sprites
+ .iter()
+ .all(|sprite| sprite.uv == SKY_SPRITE_UV),
+ )
+ };
+
+ assert_eq!(system.mesh().vertices.as_ptr(), vertex_ptr);
+ assert_eq!(system.mesh().indices.as_ptr(), index_ptr);
+ assert_eq!(
+ system
+ .mesh()
+ .vertices
+ .iter()
+ .map(|vertex| vertex.position)
+ .collect::<Vec<_>>(),
+ positions
+ );
+ assert_eq!(system.mesh().vertices[0].color, PackedColor(0xFF12_3456));
+ assert_eq!(lighting_floor, fog_color);
+ assert!(sprites_have_native_uv);
+ }
+
+ #[test]
+ fn native_sprite_and_light_contract_uses_packed_secondary_values() {
+ let mut source = sun(true);
+ source.packed = [PackedColor(0x8011_2233), PackedColor(0xFF44_5566)];
+ source.values = [2.0, 3.0];
+ let frame = sun_frame(&source, 0.5, SunConfig::new(0.0, 1.0, [0.0, 0.0]));
+ assert_eq!(
+ frame.sprite_color,
+ [
+ 0x11 as f32 / 255.0,
+ 0x22 as f32 / 255.0,
+ 0x33 as f32 / 255.0,
+ 0x80 as f32 / 255.0,
+ ]
+ );
+ let lights = frame.directional_lights();
+ assert_eq!(lights[0].color, frame.color);
+ assert_eq!(lights[1].direction, frame.direction.map(|value| -value));
+ assert_eq!(
+ lights[1].color,
+ [
+ 0x44 as f32 / 255.0,
+ 0x55 as f32 / 255.0,
+ 0x66 as f32 / 255.0,
+ ]
+ );
+ assert_eq!(
+ frame.geometry_direction(),
+ frame.direction.map(|value| -value)
+ );
+ let center = frame.world_center([1.0, 2.0, 3.0], 100.0);
+ assert!((center[0] - 1.0).abs() < 1.0e-4);
+ assert!((center[1] - 2.0).abs() < 1.0e-4);
+ assert!((center[2] - 53.0).abs() < 1.0e-4);
+ assert_eq!(
+ sprite_half_size_pixels(frame.size_values, [1000.0, 500.0], 2.0).expect("projection"),
+ [162.5, 243.75]
+ );
+ }
+
+ #[test]
+ fn glare_boost_and_flares_follow_native_visibility_gates() {
+ let mut source = sun(true);
+ source.rgb = [1.0, 1.0, 1.0];
+ source.packed[0] = PackedColor(0xFF11_2233);
+ let sun = sun_frame(&source, 0.5, SunConfig::new(0.0, 1.0, [0.0, 0.0]));
+ let visibility = SunVisibility {
+ projected_sun: [50.0, 50.0],
+ viewport: [100.0, 100.0],
+ view_forward: sun.geometry_direction(),
+ unoccluded: true,
+ };
+ let glare = sun_optics(sun, visibility).expect("optics");
+ assert_eq!(glare.glare_amount, 1.0);
+ assert!(glare.primary_color[0] > sun.color[0]);
+ assert!(glare.flares.iter().all(|flare| !flare.active));
+
+ let mut visibility = visibility;
+ visibility.view_forward = sun.direction;
+ let flare = sun_optics(sun, visibility).expect("optics");
+ assert!(flare.glare_amount < 0.1);
+ assert!(flare.flares.iter().all(|quad| quad.active));
+ assert_eq!(flare.flares[0].row, 5);
+ assert_eq!(flare.flares[0].uv, SKY_SPRITE_UV);
+ assert_eq!(flare.flares[0].center_pixels, [50.0, 50.0]);
+ }
+
+ #[test]
+ fn native_passes_preserve_nebula_stars_gradient_cloud_order() {
+ let materials = SkyMaterials::from_names([
+ "nebula", "stars", "clouds", "sun", "moon", "flare0", "flare1", "snow", "rain",
+ ]);
+ let sky = sample();
+ let frame = SkyFrame::from_atmosphere(&atmosphere(false), &materials, 0.0);
+ assert_eq!(frame.passes.len(), 4);
+ assert_eq!(frame.passes[0].kind, SkyPassKind::ScreenGradient);
+ assert_eq!(frame.passes[1].kind, SkyPassKind::NebulaStars);
+ assert_eq!(frame.passes[1].material_row, Some(0));
+ assert_eq!(frame.passes[1].secondary_material_row, Some(1));
+ assert_eq!(frame.passes[1].uv_stage, Some(0));
+ assert_eq!(frame.passes[1].secondary_uv_stage, Some(1));
+ assert_eq!(frame.passes[2].kind, SkyPassKind::DomeGradient);
+ assert_eq!(frame.passes[3].kind, SkyPassKind::Clouds);
+ assert_eq!(frame.passes[3].uv_stage, Some(2));
+ assert_eq!(frame.passes[3].translation_z, -5000.0);
+ let rgba = sky.packed[0].rgba();
+ assert_eq!(
+ frame.passes[3].material_color,
+ Some([rgba[0], rgba[1], rgba[2]])
+ );
+ }
+
+ #[test]
+ fn sky_update_rejects_non_finite_input() {
+ let mut system = SkySystem::new(SkyGeometryConfig::default(), SkyMaterials::default())
+ .expect("geometry");
+ let mut frame = atmosphere(false);
+ frame.sky.values[0] = f32::NAN;
+ assert_eq!(
+ system.update(&frame, 0.0),
+ Err(SkyUpdateError::NonFiniteAtmosphere)
+ );
+ }
+}
diff --git a/crates/fparkan-inspection/Cargo.toml b/crates/fparkan-inspection/Cargo.toml
deleted file mode 100644
index 240f34d..0000000
--- a/crates/fparkan-inspection/Cargo.toml
+++ /dev/null
@@ -1,21 +0,0 @@
-[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
diff --git a/crates/fparkan-inspection/src/lib.rs b/crates/fparkan-inspection/src/lib.rs
deleted file mode 100644
index be1f098..0000000
--- a/crates/fparkan-inspection/src/lib.rs
+++ /dev/null
@@ -1,1009 +0,0 @@
-#![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
- )
-)]
-//! Shared inspection helpers for format-backed tooling.
-
-use fparkan_diagnostics::{
- diagnostic, render_human, Diagnostic, DiagnosticCode, DiagnosticContext, Phase, SourceSpan,
-};
-use fparkan_material::{decode_wear, resolve_material, MaterialFallback};
-use fparkan_msh::{
- decode_msh, node38_metadata, selected_slot, validate_msh, Group, Lod, ModelAsset, NodeId,
-};
-use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
-use fparkan_path::{normalize_relative, PathPolicy};
-use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
-use fparkan_rsli::decode as decode_rsli;
-use fparkan_terrain_format::{decode_land_map, decode_land_msh, LandMeshDocument};
-use fparkan_texm::decode_texm;
-use fparkan_vfs::{DirectoryVfs, Vfs};
-use std::fs;
-#[cfg(unix)]
-use std::os::unix::ffi::OsStrExt;
-use std::path::Path;
-use std::sync::Arc;
-
-/// Archive inspection variants.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum ArchiveInspection {
- /// `NRes` inspection summary.
- Nres {
- /// Archive entry count.
- entries: usize,
- /// Lookup order validity.
- lookup_order_valid: bool,
- /// Entry samples (subject to request limit).
- sample: Vec<NresEntrySummary>,
- },
- /// `RsLi` inspection summary.
- Rsli {
- /// Archive entry count.
- entries: usize,
- },
- /// Unknown/unsupported archive magic.
- Unsupported,
-}
-
-/// `NRes` entry summary.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct NresEntrySummary {
- /// ASCII/legacy resource name.
- pub name: String,
- /// Entry type identifier.
- pub type_id: u32,
- /// Declared entry payload size.
- pub data_size: u32,
-}
-
-/// Model inspection payload.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct ModelInspection {
- /// Terrain stream/document stream count.
- pub streams: usize,
- /// Node count.
- pub nodes: usize,
- /// Slot count.
- pub slots: usize,
- /// Position count.
- pub positions: usize,
- /// Index count.
- pub indices: usize,
- /// Batch count.
- pub batches: usize,
- /// Original node record stride.
- pub node_stride: usize,
- /// Standard-node metadata in source order, when the model uses `Node38`.
- pub node38: Vec<Node38Inspection>,
- /// Number of decoded type-8 animation keys, when available.
- pub animation_keys: Option<usize>,
- /// Declared type-19 animation frame count, when available.
- pub animation_frame_count: Option<u32>,
-}
-
-/// Inspection view of a standard 38-byte model node.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct Node38Inspection {
- /// Source node index.
- pub index: usize,
- /// Opaque source field at byte offset two.
- pub parent_or_link_raw: u16,
- /// Type-19 frame-map offset, or `0xFFFF`.
- pub anim_map_start: u16,
- /// Type-8 fallback key index.
- pub fallback_key: u16,
- /// Whether LOD zero/group zero selects a geometry slot.
- pub has_lod0_group0: bool,
-}
-
-/// Texture inspection payload.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct TextureInspection {
- /// Width.
- pub width: u32,
- /// Height.
- pub height: u32,
- /// Texture format debug text.
- pub format: String,
- /// Mip level count.
- pub mips: usize,
- /// Total page rectangles.
- pub pages: usize,
-}
-
-/// Diffuse TEXM selected through an original WEAR and MAT0 material chain.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct WearMaterialTexture {
- /// Source WEAR archive.
- pub wear_archive: String,
- /// Source WEAR resource.
- pub wear_resource: String,
- /// Positional WEAR selector used by an MSH batch.
- pub material_index: u16,
- /// Resolved MAT0 resource name after the original fallback chain.
- pub material_name: String,
- /// Fallback route used while resolving the MAT0 resource.
- pub material_fallback: MaterialFallback,
- /// Texture name selected from phase zero of the resolved MAT0 document.
- pub texture_name: String,
- /// Decoded RGBA8 mip zero suitable for the Vulkan upload boundary.
- pub image: fparkan_texm::RgbaImage,
-}
-
-/// Compact inspection summary of a WEAR resource stored in an archive.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct WearInspection {
- /// Number of material rows.
- pub materials: usize,
- /// Number of lightmap rows.
- pub lightmaps: usize,
- /// First material resource name, when present.
- pub first_material: Option<String>,
- /// Last material resource name, when present.
- pub last_material: Option<String>,
-}
-
-/// Land map/msh inspection payload.
-#[derive(Clone, Debug, Eq, PartialEq)]
-pub struct MapInspection {
- /// Mapped mesh stream count.
- pub streams: usize,
- /// Slot count.
- pub slots: usize,
- /// Position count.
- pub positions: usize,
- /// Face count.
- pub faces: usize,
- /// Terrain areals.
- pub areals: usize,
- /// Declared areal count from map metadata.
- pub declared_areals: u32,
- /// Map grid width.
- pub grid_width: u32,
- /// Map grid height.
- pub grid_height: u32,
-}
-
-/// Axis-aligned position bounds of a decoded `Land.msh`.
-#[derive(Clone, Debug, PartialEq)]
-pub struct LandMeshBoundsInspection {
- /// Number of source positions covered by the bounds.
- pub positions: usize,
- /// Per-axis inclusive minimum position.
- pub min: [f32; 3],
- /// Per-axis inclusive maximum position.
- pub max: [f32; 3],
-}
-
-/// Supported land file kinds.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum LandFileKind {
- /// `land.msh` payload.
- LandMsh,
- /// `land.map` payload.
- LandMap,
-}
-
-/// Inspects a format archive.
-///
-/// # Errors
-///
-/// Returns a string error when the archive cannot be read or decoded.
-pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
- inspect_archive_file_diagnostic(path, sample_limit)
- .map_err(|diagnostic| render_human(&diagnostic))
-}
-
-/// Inspects a format archive and returns a structured diagnostic on failure.
-///
-/// # Errors
-///
-/// Returns a [`Diagnostic`] when the archive cannot be read or decoded.
-// Diagnostic is deliberately returned by value as the public structured-error contract.
-#[allow(clippy::result_large_err)]
-pub fn inspect_archive_file_diagnostic(
- path: &Path,
- sample_limit: usize,
-) -> Result<ArchiveInspection, Diagnostic> {
- let parent = path.parent().unwrap_or_else(|| Path::new("."));
- let file_name = path.file_name().ok_or_else(|| {
- diagnostic(
- DiagnosticCode("S1.VFS.PATH"),
- format!("{}: archive path has no file name", path.display()),
- )
- .with_context(DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(path.display().to_string()),
- ..DiagnosticContext::default()
- })
- })?;
- #[cfg(unix)]
- let raw_name = file_name.as_bytes();
- #[cfg(not(unix))]
- let raw_name = file_name
- .to_str()
- .ok_or_else(|| {
- diagnostic(
- DiagnosticCode("S1.VFS.PATH"),
- format!("{}: archive file name is not valid text", path.display()),
- )
- .with_context(DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(path.display().to_string()),
- ..DiagnosticContext::default()
- })
- })?
- .as_bytes();
- let normalized = normalize_relative(raw_name, PathPolicy::HostCompatible).map_err(|err| {
- diagnostic(
- DiagnosticCode("S1.VFS.PATH"),
- format!("{}: {err}", path.display()),
- )
- .with_context(DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(path.display().to_string()),
- ..DiagnosticContext::default()
- })
- })?;
- let vfs = DirectoryVfs::new(parent);
- let bytes = vfs.read(&normalized).map_err(|err| {
- diagnostic(
- DiagnosticCode("S1.VFS.READ"),
- format!("{}: {err}", path.display()),
- )
- .with_context(DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(path.display().to_string()),
- ..DiagnosticContext::default()
- })
- })?;
- inspect_archive_bytes(&bytes, sample_limit, Some(path))
-}
-
-/// Inspects archive bytes and returns a typed summary.
-// Keeps the internal diagnostic flow aligned with the public structured-error contract.
-#[allow(clippy::result_large_err)]
-fn inspect_archive_bytes(
- bytes: &[u8],
- sample_limit: usize,
- source: Option<&Path>,
-) -> Result<ArchiveInspection, Diagnostic> {
- if bytes.starts_with(b"NRes") {
- let document = decode_nres(
- Arc::from(bytes.to_vec().into_boxed_slice()),
- ReadProfile::Compatible,
- )
- .map_err(|err| {
- archive_parse_diagnostic("S1.NRES.DECODE", source, bytes, err.to_string())
- })?;
- let mut sample = Vec::new();
- for entry in document.entries().iter().take(sample_limit) {
- sample.push(NresEntrySummary {
- name: String::from_utf8_lossy(entry.name_bytes()).to_string(),
- type_id: entry.meta().type_id,
- data_size: entry.meta().data_size,
- });
- }
- Ok(ArchiveInspection::Nres {
- entries: document.entries().len(),
- lookup_order_valid: document.lookup_order_valid(),
- sample,
- })
- } else if bytes.get(0..4) == Some(b"NL\0\x01") {
- let document = decode_rsli(
- Arc::from(bytes.to_vec().into_boxed_slice()),
- fparkan_rsli::ReadProfile::Compatible,
- )
- .map_err(|err| {
- archive_parse_diagnostic("S1.RSLI.DECODE", source, bytes, err.to_string())
- })?;
- Ok(ArchiveInspection::Rsli {
- entries: document.entries().len(),
- })
- } else {
- Err(archive_parse_diagnostic(
- "S1.RESOURCE.UNSUPPORTED_ARCHIVE",
- source,
- bytes,
- "unsupported archive magic".to_string(),
- ))
- }
-}
-
-/// Inspects a model through repository-backed resource lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved or parsed as a
-/// valid model payload.
-pub fn inspect_model_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<ModelInspection, String> {
- let bytes = read_resource_bytes_diagnostic(root, archive, resource)
- .map_err(|err| render_human(&err))?;
- let document = decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| {
- render_human(&resource_parse_diagnostic(
- "S1.NRES.DECODE",
- archive,
- resource,
- &bytes,
- err.to_string(),
- ))
- })?;
- let msh = decode_msh(&document).map_err(|err| err.to_string())?;
- let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
- let node38 = if validated.node_stride == 38 {
- (0..validated.node_count)
- .filter_map(|index| {
- let node = NodeId(u32::try_from(index).ok()?);
- let metadata = node38_metadata(&validated, node)?;
- Some(Node38Inspection {
- index,
- parent_or_link_raw: metadata.parent_or_link_raw,
- anim_map_start: metadata.anim_map_start,
- fallback_key: metadata.fallback_key,
- has_lod0_group0: selected_slot(&validated, node, Lod(0), Group(0)).is_some(),
- })
- })
- .collect()
- } else {
- Vec::new()
- };
- Ok(ModelInspection {
- streams: msh.streams().len(),
- nodes: validated.node_count,
- slots: validated.slots.len(),
- positions: validated.positions.len(),
- indices: validated.indices.len(),
- batches: validated.batches.len(),
- node_stride: validated.node_stride,
- node38,
- animation_keys: validated
- .animation
- .as_ref()
- .map(|animation| animation.keys.len()),
- animation_frame_count: validated
- .animation
- .as_ref()
- .map(|animation| animation.frame_count),
- })
-}
-
-/// Inspects a WEAR resource through repository-backed lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved or parsed as a
-/// valid WEAR payload.
-pub fn inspect_wear_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<WearInspection, String> {
- let bytes = read_resource_bytes_diagnostic(root, archive, resource)
- .map_err(|err| render_human(&err))?;
- let wear = decode_wear(&bytes).map_err(|err| err.to_string())?;
- Ok(WearInspection {
- materials: wear.entries.len(),
- lightmaps: wear.lightmaps.len(),
- first_material: wear
- .entries
- .first()
- .map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()),
- last_material: wear
- .entries
- .last()
- .map(|entry| String::from_utf8_lossy(&entry.material.0).into_owned()),
- })
-}
-
-/// Loads and validates a model resource through repository-backed lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved or parsed as a
-/// valid model payload.
-pub fn load_model_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<ModelAsset, String> {
- let document = load_model_document_from_root_diagnostic(root, archive, resource)
- .map_err(|err| render_human(&err))?;
- let msh = decode_msh(&document).map_err(|err| err.to_string())?;
- validate_msh(&msh).map_err(|err| err.to_string())
-}
-
-/// Inspects a texture through repository-backed resource lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved or parsed as a
-/// valid texture payload.
-pub fn inspect_texture_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<TextureInspection, String> {
- let bytes = read_resource_bytes_diagnostic(root, archive, resource)
- .map_err(|err| render_human(&err))?;
- let document = decode_texm(bytes).map_err(|err| err.to_string())?;
- Ok(TextureInspection {
- width: document.width(),
- height: document.height(),
- format: format!("{:?}", document.format()),
- mips: document.mip_count(),
- pages: document.page_rects().len(),
- })
-}
-
-/// Loads a decoded TEXM document through repository-backed lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved or parsed as a
-/// valid TEXM payload.
-pub fn load_texture_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<fparkan_texm::TexmDocument, String> {
- let bytes = read_resource_bytes_diagnostic(root, archive, resource)
- .map_err(|err| render_human(&err))?;
- decode_texm(bytes).map_err(|err| err.to_string())
-}
-
-/// Loads and decodes TEXM mip 0 as RGBA8 through repository-backed lookup.
-///
-/// # Errors
-///
-/// Returns a string error when the resource cannot be resolved, decoded, or
-/// converted to the shared RGBA8 upload representation.
-pub fn load_texture_mip0_rgba8_from_root(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<fparkan_texm::RgbaImage, String> {
- let document = load_texture_from_root(root, archive, resource)?;
- fparkan_texm::decode_mip_rgba8(&document, 0).map_err(|err| err.to_string())
-}
-
-/// Resolves phase-zero diffuse TEXM through `WEAR → MAT0 → Textures.lib`.
-///
-/// The selector is the positional `Batch20.material_index`, not WEAR's legacy
-/// text id. MAT0 fallback remains owned by `fparkan-material`: exact requested
-/// entry, then `DEFAULT`, then the first material entry.
-///
-/// # Errors
-///
-/// Returns a string error if WEAR/MAT0 resolution, phase selection, or TEXM
-/// decoding fails. An empty phase-zero texture name is an intentional
-/// untextured material and is reported rather than substituted with a texture.
-pub fn load_wear_material_texture_mip0_rgba8_from_root(
- root: &Path,
- wear_archive: &str,
- wear_resource: &str,
- material_index: u16,
-) -> Result<WearMaterialTexture, String> {
- let wear_bytes = read_resource_bytes_diagnostic(root, wear_archive, wear_resource)
- .map_err(|err| render_human(&err))?;
- let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?;
- let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
- let material =
- resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
- let texture = material.document.primary_texture().ok_or_else(|| {
- "MAT0 phase zero declares an intentionally untextured material".to_string()
- })?;
- let texture_name = String::from_utf8_lossy(&texture.0).into_owned();
- let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?;
- Ok(WearMaterialTexture {
- wear_archive: wear_archive.to_string(),
- wear_resource: wear_resource.to_string(),
- material_index,
- material_name: String::from_utf8_lossy(&material.name.0).into_owned(),
- material_fallback: material.fallback,
- texture_name,
- image,
- })
-}
-
-/// Resolves phase-zero diffuse TEXM through a standalone map-local WEAR file.
-///
-/// Map terrain keeps its two WEAR tables adjacent to `Land.msh`, while their
-/// MAT0 and TEXM resources remain in the game root's normal archives. This
-/// preserves that split instead of treating the sidecar as an archive entry.
-///
-/// # Errors
-///
-/// Returns a string error if the standalone WEAR file, global material
-/// resources, or selected diffuse TEXM cannot be decoded.
-pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root(
- root: &Path,
- wear_path: &Path,
- material_index: u16,
-) -> Result<WearMaterialTexture, String> {
- let wear_bytes =
- fs::read(wear_path).map_err(|err| format!("{}: {err}", wear_path.display()))?;
- let wear = decode_wear(&wear_bytes).map_err(|err| err.to_string())?;
- let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
- let material =
- resolve_material(&repository, &wear, material_index).map_err(|err| err.to_string())?;
- let texture = material.document.primary_texture().ok_or_else(|| {
- "MAT0 phase zero declares an intentionally untextured material".to_string()
- })?;
- let texture_name = String::from_utf8_lossy(&texture.0).into_owned();
- let image = load_texture_mip0_rgba8_from_root(root, "Textures.lib", &texture_name)?;
- Ok(WearMaterialTexture {
- wear_archive: "<standalone>".to_string(),
- wear_resource: wear_path.display().to_string(),
- material_index,
- material_name: String::from_utf8_lossy(&material.name.0).into_owned(),
- material_fallback: material.fallback,
- texture_name,
- image,
- })
-}
-
-/// Inspects a terrain land file by path.
-///
-/// # Errors
-///
-/// Returns a string error when the file cannot be read or parsed as the
-/// requested terrain payload kind.
-pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> {
- let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
- let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
- .map_err(|err| err.to_string())?;
- match kind {
- LandFileKind::LandMsh => inspect_land_msh(&document),
- LandFileKind::LandMap => inspect_land_map(&document),
- }
-}
-
-/// Loads and validates a standalone `Land.msh` file.
-///
-/// # Errors
-///
-/// Returns a human-readable error if the file cannot be read, decoded as `NRes`,
-/// or decoded as the specialized terrain mesh format.
-pub fn load_land_msh_from_path(path: &Path) -> Result<LandMeshDocument, String> {
- let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
- let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
- .map_err(|err| err.to_string())?;
- decode_land_msh(&document).map_err(|err| err.to_string())
-}
-
-/// Inspects the source-coordinate bounds of a standalone `Land.msh` file.
-///
-/// # Errors
-///
-/// Returns a string error when the file cannot be read, decoded, or contains
-/// no finite source positions.
-pub fn inspect_land_msh_bounds_file(path: &Path) -> Result<LandMeshBoundsInspection, String> {
- let mesh = load_land_msh_from_path(path)?;
- inspect_land_msh_bounds(&mesh)
-}
-
-/// Computes source-coordinate bounds for an already validated `Land.msh`.
-///
-/// # Errors
-///
-/// Returns a string error when the mesh has no finite source positions.
-pub fn inspect_land_msh_bounds(
- mesh: &LandMeshDocument,
-) -> Result<LandMeshBoundsInspection, String> {
- let mut min = [f32::INFINITY; 3];
- let mut max = [f32::NEG_INFINITY; 3];
- for position in &mesh.positions {
- if !position.iter().all(|value| value.is_finite()) {
- return Err("Land.msh contains a non-finite source position".to_string());
- }
- for axis in 0..3 {
- min[axis] = min[axis].min(position[axis]);
- max[axis] = max[axis].max(position[axis]);
- }
- }
- if mesh.positions.is_empty() {
- return Err("Land.msh contains no source positions".to_string());
- }
- Ok(LandMeshBoundsInspection {
- positions: mesh.positions.len(),
- min,
- max,
- })
-}
-
-fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
- let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
- Ok(MapInspection {
- streams: land_msh.streams.len(),
- slots: land_msh.slots.slots_raw.len(),
- positions: land_msh.positions.len(),
- faces: land_msh.faces.len(),
- areals: 0,
- declared_areals: 0,
- grid_width: 0,
- grid_height: 0,
- })
-}
-
-fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
- let land_map = decode_land_map(document).map_err(|err| err.to_string())?;
- Ok(MapInspection {
- streams: 0,
- slots: 0,
- positions: 0,
- faces: 0,
- areals: land_map.areals.len(),
- declared_areals: land_map.areal_count,
- grid_width: land_map.grid.cells_x,
- grid_height: land_map.grid.cells_y,
- })
-}
-
-// Preserves the shared structured-error type without changing callers to boxed errors.
-#[allow(clippy::result_large_err)]
-fn read_resource_bytes_diagnostic(
- root: &Path,
- archive: &str,
- name: &str,
-) -> Result<Arc<[u8]>, Diagnostic> {
- let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
- let archive_path = archive_path(archive.as_bytes()).map_err(|err| {
- diagnostic(DiagnosticCode("S1.PATH.ARCHIVE"), err.to_string()).with_context(
- DiagnosticContext {
- phase: Some(Phase::Resolve),
- path: Some(archive.to_string()),
- archive_entry: Some(name.to_string()),
- ..DiagnosticContext::default()
- },
- )
- })?;
- let resource_name = resource_name(name.as_bytes());
- let archive_handle = repository.open_archive(&archive_path).map_err(|err| {
- diagnostic(DiagnosticCode("S1.RESOURCE.OPEN_ARCHIVE"), err.to_string()).with_context(
- DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(archive.to_string()),
- archive_entry: Some(name.to_string()),
- ..DiagnosticContext::default()
- },
- )
- })?;
- let Some(handle) = repository
- .find(archive_handle, &resource_name)
- .map_err(|err| {
- diagnostic(DiagnosticCode("S1.RESOURCE.FIND"), err.to_string()).with_context(
- DiagnosticContext {
- phase: Some(Phase::Resolve),
- path: Some(archive.to_string()),
- archive_entry: Some(name.to_string()),
- ..DiagnosticContext::default()
- },
- )
- })?
- else {
- return Err(diagnostic(
- DiagnosticCode("S1.RESOURCE.MISSING_ENTRY"),
- format!(
- "resource not found: {archive}/{}",
- String::from_utf8_lossy(name.as_bytes())
- ),
- )
- .with_context(DiagnosticContext {
- phase: Some(Phase::Resolve),
- path: Some(archive.to_string()),
- archive_entry: Some(name.to_string()),
- ..DiagnosticContext::default()
- }));
- };
- let bytes = repository.read(handle).map_err(|err| {
- diagnostic(DiagnosticCode("S1.RESOURCE.READ"), err.to_string()).with_context(
- DiagnosticContext {
- phase: Some(Phase::Read),
- path: Some(archive.to_string()),
- archive_entry: Some(name.to_string()),
- ..DiagnosticContext::default()
- },
- )
- })?;
- Ok(Arc::from(bytes.into_owned()))
-}
-
-// Preserves the shared structured-error type without changing callers to boxed errors.
-#[allow(clippy::result_large_err)]
-fn load_model_document_from_root_diagnostic(
- root: &Path,
- archive: &str,
- resource: &str,
-) -> Result<NresDocument, Diagnostic> {
- let bytes = read_resource_bytes_diagnostic(root, archive, resource)?;
- decode_nres(bytes.clone(), ReadProfile::Compatible).map_err(|err| {
- resource_parse_diagnostic("S1.NRES.DECODE", archive, resource, &bytes, err.to_string())
- })
-}
-
-fn archive_parse_diagnostic(
- code: &'static str,
- source: Option<&Path>,
- bytes: &[u8],
- message: String,
-) -> Diagnostic {
- diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
- phase: Some(Phase::Parse),
- path: source.map(|path| path.display().to_string()),
- span: Some(SourceSpan {
- offset: 0,
- length: u64::try_from(bytes.len().min(4)).unwrap_or(4),
- }),
- ..DiagnosticContext::default()
- })
-}
-
-fn resource_parse_diagnostic(
- code: &'static str,
- archive: &str,
- resource: &str,
- bytes: &[u8],
- message: String,
-) -> Diagnostic {
- diagnostic(DiagnosticCode(code), message).with_context(DiagnosticContext {
- phase: Some(Phase::Parse),
- path: Some(archive.to_string()),
- archive_entry: Some(resource.to_string()),
- span: Some(SourceSpan {
- offset: 0,
- length: u64::try_from(bytes.len().min(4)).unwrap_or(4),
- }),
- ..DiagnosticContext::default()
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use fparkan_terrain_format::TerrainSlotTable;
- use std::io::Write as _;
- use std::path::PathBuf;
-
- const TEST_NRES_HEADER_LEN: usize = 16;
- const TEST_NRES_NAME_LEN: usize = 36;
- const TEST_NRES_VERSION_0100: u32 = 0x100;
-
- #[test]
- fn inspect_rsli_rejects_malformed_archive() {
- let dir = temp_dir("inspect");
- let path = dir.join("test.rsli");
- let mut file = fs::File::create(&path).expect("file");
- file.write_all(b"NL\0\x01").expect("magic");
- drop(file);
-
- let error = inspect_archive_file(&path, 0).expect_err("malformed archive");
- assert!(error.contains("entry table out of bounds"));
- }
-
- #[test]
- fn archive_diagnostic_preserves_source_path_phase_and_span() {
- let dir = temp_dir("inspect-diagnostic");
- let path = dir.join("broken.nres");
- fs::write(&path, b"NRes").expect("broken nres");
-
- let diagnostic = inspect_archive_file_diagnostic(&path, 0).expect_err("diagnostic failure");
-
- assert_eq!(diagnostic.code.0, "S1.NRES.DECODE");
- let expected_path = path.display().to_string();
- assert_eq!(
- diagnostic.context.path.as_deref(),
- Some(expected_path.as_str())
- );
- assert_eq!(diagnostic.context.phase, Some(Phase::Parse));
- assert_eq!(
- diagnostic.context.span,
- Some(SourceSpan {
- offset: 0,
- length: 4
- })
- );
- }
-
- #[test]
- fn nres_entry_summary_fields_are_readable() {
- let dir = temp_dir("inspect-nres");
- let archive = dir.join("test.nres");
- let payload = Vec::from("NRes\x00\x00\x00\x00");
- fs::write(&archive, &payload).expect("nres");
-
- let _ = inspect_archive_file(&archive, 2);
- }
-
- #[test]
- fn model_archive_diagnostic_preserves_archive_entry_context() {
- let dir = temp_dir("inspect-model-diagnostic");
- let archive = dir.join("models.rlb");
- fs::write(&archive, build_single_entry_nres(b"BROKEN.MSH", b"NRes")).expect("archive");
-
- let diagnostic = load_model_document_from_root_diagnostic(&dir, "models.rlb", "BROKEN.MSH")
- .expect_err("nested diagnostic failure");
-
- assert_eq!(diagnostic.code.0, "S1.NRES.DECODE");
- assert_eq!(diagnostic.context.phase, Some(Phase::Parse));
- assert_eq!(diagnostic.context.path.as_deref(), Some("models.rlb"));
- assert_eq!(
- diagnostic.context.archive_entry.as_deref(),
- Some("BROKEN.MSH")
- );
- assert_eq!(
- diagnostic.context.span,
- Some(SourceSpan {
- offset: 0,
- length: 4
- })
- );
- }
-
- #[test]
- fn wear_material_texture_loader_preserves_original_selection_provenance() {
- let dir = temp_dir("wear-material-texture");
- fs::write(
- dir.join("wear.rlb"),
- build_single_entry_nres(b"MODEL.WEA", b"1\n0 MAT\n"),
- )
- .expect("wear archive");
- let mut mat0 = vec![0; 4 + 34];
- mat0[0..2].copy_from_slice(&1_u16.to_le_bytes());
- mat0[22..25].copy_from_slice(b"TEX");
- fs::write(
- dir.join("material.lib"),
- build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0),
- )
- .expect("material archive");
- fs::write(
- dir.join("Textures.lib"),
- build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])),
- )
- .expect("texture archive");
-
- let selected =
- load_wear_material_texture_mip0_rgba8_from_root(&dir, "wear.rlb", "MODEL.WEA", 0)
- .expect("resolved material texture");
-
- assert_eq!(selected.material_name, "MAT");
- assert_eq!(selected.material_fallback, MaterialFallback::Exact);
- assert_eq!(selected.texture_name, "TEX");
- assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
- }
-
- #[test]
- fn standalone_wear_material_texture_loader_keeps_map_sidecar_separate() {
- let dir = temp_dir("standalone-wear-material-texture");
- let wear_path = dir.join("Land2.wea");
- fs::write(&wear_path, b"1\n0 MAT\n").expect("standalone wear");
- let mut mat0 = vec![0; 4 + 34];
- mat0[0..2].copy_from_slice(&1_u16.to_le_bytes());
- mat0[22..25].copy_from_slice(b"TEX");
- fs::write(
- dir.join("material.lib"),
- build_single_entry_nres_with_meta(b"MAT", fparkan_material::MAT0_KIND, 0, &mat0),
- )
- .expect("material archive");
- fs::write(
- dir.join("Textures.lib"),
- build_single_entry_nres(b"TEX", &texm_argb8888_pixel([0x40, 0x11, 0x22, 0x33])),
- )
- .expect("texture archive");
-
- let selected =
- load_standalone_wear_material_texture_mip0_rgba8_from_root(&dir, &wear_path, 0)
- .expect("resolved material texture");
-
- assert_eq!(selected.wear_archive, "<standalone>");
- assert_eq!(selected.wear_resource, wear_path.display().to_string());
- assert_eq!(selected.texture_name, "TEX");
- assert_eq!(selected.image.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
- }
-
- #[test]
- fn land_mesh_bounds_preserve_each_source_axis() {
- let mesh = LandMeshDocument {
- streams: Vec::new(),
- nodes_raw: Vec::new(),
- slots: TerrainSlotTable {
- header_raw: Vec::new(),
- slots_raw: Vec::new(),
- },
- positions: vec![[4.0, -2.0, 8.0], [-3.0, 6.0, 1.0]],
- normals: Vec::new(),
- uv0: Vec::new(),
- accelerator: Vec::new(),
- aux14: Vec::new(),
- aux18: Vec::new(),
- faces: Vec::new(),
- };
-
- let bounds = inspect_land_msh_bounds(&mesh).expect("bounds");
-
- assert_eq!(bounds.positions, 2);
- assert_eq!(bounds.min, [-3.0, -2.0, 1.0]);
- assert_eq!(bounds.max, [4.0, 6.0, 8.0]);
- }
-
- fn temp_dir(name: &str) -> PathBuf {
- let base = PathBuf::from("/tmp")
- .join("fparkan-inspection-tests")
- .join(name);
- let _ = fs::remove_dir_all(&base);
- fs::create_dir_all(&base).expect("tmp dir");
- base
- }
-
- fn build_single_entry_nres(name: &[u8], payload: &[u8]) -> Vec<u8> {
- build_single_entry_nres_with_meta(name, 1, 0, payload)
- }
-
- fn build_single_entry_nres_with_meta(
- name: &[u8],
- type_id: u32,
- attr2: u32,
- payload: &[u8],
- ) -> Vec<u8> {
- let mut out = vec![0; TEST_NRES_HEADER_LEN];
- let payload_offset = u32::try_from(out.len()).expect("payload offset");
- out.extend_from_slice(payload);
- let padding = (8 - (out.len() % 8)) % 8;
- out.resize(out.len() + padding, 0);
-
- push_u32(&mut out, type_id);
- push_u32(&mut out, 0);
- push_u32(&mut out, attr2);
- push_u32(&mut out, u32::try_from(payload.len()).expect("payload len"));
- push_u32(&mut out, 0);
- let mut raw_name = [0; TEST_NRES_NAME_LEN];
- raw_name[..name.len()].copy_from_slice(name);
- out.extend_from_slice(&raw_name);
- push_u32(&mut out, payload_offset);
- push_u32(&mut out, 0);
-
- out[0..4].copy_from_slice(b"NRes");
- out[4..8].copy_from_slice(&TEST_NRES_VERSION_0100.to_le_bytes());
- out[8..12].copy_from_slice(&1_u32.to_le_bytes());
- let total_size = u32::try_from(out.len()).expect("total size");
- out[12..16].copy_from_slice(&total_size.to_le_bytes());
- out
- }
-
- fn push_u32(out: &mut Vec<u8>, value: u32) {
- out.extend_from_slice(&value.to_le_bytes());
- }
-
- fn texm_argb8888_pixel(pixel: [u8; 4]) -> Vec<u8> {
- let mut out = Vec::new();
- out.extend_from_slice(&0x6D78_6554_u32.to_le_bytes());
- out.extend_from_slice(&1_u32.to_le_bytes());
- out.extend_from_slice(&1_u32.to_le_bytes());
- out.extend_from_slice(&1_u32.to_le_bytes());
- out.extend_from_slice(&0_u32.to_le_bytes());
- out.extend_from_slice(&0_u32.to_le_bytes());
- out.extend_from_slice(&0_u32.to_le_bytes());
- out.extend_from_slice(&8888_u32.to_le_bytes());
- out.extend_from_slice(&pixel);
- out
- }
-}
diff --git a/crates/fparkan-material/Cargo.toml b/crates/fparkan-material/Cargo.toml
index 4a00a73..63174d5 100644
--- a/crates/fparkan-material/Cargo.toml
+++ b/crates/fparkan-material/Cargo.toml
@@ -7,12 +7,9 @@ repository.workspace = true
[dependencies]
encoding_rs = "0.8"
-fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
-fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
+fparkan-path = { path = "../fparkan-path" }
+fparkan-resource = { path = "../fparkan-resource" }
[dev-dependencies]
-fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
-fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-nres = { path = "../fparkan-nres" }
+fparkan-vfs = { path = "../fparkan-vfs" }
diff --git a/crates/fparkan-material/src/lib.rs b/crates/fparkan-material/src/lib.rs
index 7da9952..da0b716 100644
--- a/crates/fparkan-material/src/lib.rs
+++ b/crates/fparkan-material/src/lib.rs
@@ -1,23 +1,4 @@
#![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;
@@ -86,6 +67,26 @@ pub struct MaterialPhase {
pub texture_raw: [u8; 16],
}
+impl MaterialPhase {
+ /// Decodes the native floating-point material coefficients.
+ #[must_use]
+ pub fn coefficients(&self) -> MaterialCoefficients {
+ let normalized = |index: usize| f32::from(self.parameters[index]) / 255.0;
+ MaterialCoefficients {
+ additive_rgb: [normalized(0), normalized(1), normalized(2)],
+ opacity: f32::from(self.parameters[3]) * 0.01,
+ directional_rgb: [normalized(4), normalized(5), normalized(6)],
+ directional_extra: normalized(7),
+ specular_rgb: [normalized(8), normalized(9), normalized(10)],
+ specular_extra: normalized(11),
+ extra_rgb: [normalized(12), normalized(13), normalized(14)],
+ extra_alpha: normalized(15),
+ power: self.parameters[16],
+ page_index: i8::from_ne_bytes([self.parameters[17]]),
+ }
+ }
+}
+
/// Material animation block.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MaterialAnimationBlock {
@@ -119,38 +120,88 @@ pub enum MaterialFallback {
FirstEntry,
}
-/// Material timeline mode.
+/// Native MAT0 animation mode encoded by `header_raw & 7`.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum MaterialTimelineMode {
- /// Play once from phase zero.
- OneShot,
- /// Clamp frame to the last phase.
- Clamp,
- /// Loop over all phases.
+pub enum MaterialAnimationMode {
+ /// Repeat the timeline from its first key.
Loop,
- /// Ping-pong over all phases.
+ /// Repeat the timeline in alternating forward and reverse directions.
PingPong,
+ /// Hold the final key after the timeline duration.
+ Clamp,
+ /// Choose a fresh time from the caller-provided random value per query.
+ RandomPerQuery,
+ /// Preserve an unknown native mode without making the sampler panic.
+ Unknown(u8),
}
-/// Material runtime sampling profile.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct MaterialTimelineProfile {
- /// Timeline mode.
- pub mode: MaterialTimelineMode,
- /// Apply deterministic material-only random offset.
- pub random_offset: bool,
+impl MaterialAnimationMode {
+ /// Decodes the low three mode bits from a native block header.
+ #[must_use]
+ pub const fn from_header(header_raw: u32) -> Self {
+ match (header_raw & 7) as u8 {
+ 0 => Self::Loop,
+ 1 => Self::PingPong,
+ 2 => Self::Clamp,
+ 3 => Self::RandomPerQuery,
+ other => Self::Unknown(other),
+ }
+ }
}
-/// Sampled material phase.
-#[derive(Clone, Debug, Eq, PartialEq)]
+impl MaterialAnimationBlock {
+ /// Returns the native timeline mode from this block's header.
+ #[must_use]
+ pub const fn mode(&self) -> MaterialAnimationMode {
+ MaterialAnimationMode::from_header(self.header_raw)
+ }
+
+ /// Returns the native interpolation bit mask from `header_raw >> 3`.
+ #[must_use]
+ pub const fn interpolation_mask(&self) -> u32 {
+ self.header_raw >> 3
+ }
+
+ /// Returns the final key end time in milliseconds, if a key exists.
+ #[must_use]
+ pub fn duration_ms(&self) -> Option<u32> {
+ self.keys.last().map(|key| u32::from(key.k1))
+ }
+}
+
+/// Coefficients produced by the native MAT0 phase loader.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub struct MaterialCoefficients {
+ /// Parameters 0..2, normalized additive RGB.
+ pub additive_rgb: [f32; 3],
+ /// Parameter 3, stored as a percentage (`raw * 0.01`).
+ pub opacity: f32,
+ /// Parameters 4..6, normalized directional RGB.
+ pub directional_rgb: [f32; 3],
+ /// Parameter 7, normalized and held when the animation mask is bit 2.
+ pub directional_extra: f32,
+ /// Parameters 8..10, normalized specular RGB.
+ pub specular_rgb: [f32; 3],
+ /// Parameter 11, normalized and held when the animation mask is bit 4.
+ pub specular_extra: f32,
+ /// Parameters 12..14, normalized extra RGB.
+ pub extra_rgb: [f32; 3],
+ /// Parameter 15, normalized and held when the animation mask is bit 8.
+ pub extra_alpha: f32,
+ /// Parameter 16, an integer material power value.
+ pub power: u8,
+ /// Parameter 17, a signed TEXM page selector.
+ pub page_index: i8,
+}
+
+/// Sampled native material phase.
+#[derive(Clone, Copy, Debug, PartialEq)]
pub struct MaterialPhaseSample {
/// Selected phase index.
pub phase_index: usize,
- /// Effective frame after mode and random offset.
- pub effective_frame: u32,
- /// Sampled parameter bytes.
- pub parameters: [u8; 18],
- /// Sampled texture bytes.
+ /// Native floating-point coefficients after optional interpolation.
+ pub coefficients: MaterialCoefficients,
+ /// Texture name bytes from the selected phase; never interpolated.
pub texture_raw: [u8; 16],
}
@@ -161,6 +212,8 @@ pub struct ResolvedMaterial {
pub name: ResourceName,
/// Fallback path.
pub fallback: MaterialFallback,
+ /// Raw NRes `attr1`, retained for renderer blend/category selection.
+ pub attr1: u32,
/// Decoded document.
pub document: Mat0Document,
}
@@ -240,6 +293,29 @@ pub enum MaterialError {
MissingMaterial(String),
/// A material document has no phases for runtime sampling.
EmptyMaterial,
+ /// An animation block has no positive duration for a multi-key timeline.
+ InvalidAnimationDuration {
+ /// Selected animation block.
+ block_index: usize,
+ },
+ /// An animation key points outside the decoded phase table.
+ InvalidAnimationPhaseIndex {
+ /// Selected animation block.
+ block_index: usize,
+ /// Key index within the block.
+ key_index: usize,
+ /// Raw phase index from the key.
+ phase_index: u16,
+ /// Number of decoded phases.
+ phase_count: usize,
+ },
+ /// Animation key end times are not strictly increasing.
+ InvalidAnimationKeyTimes {
+ /// Selected animation block.
+ block_index: usize,
+ /// Key index whose end time is invalid.
+ key_index: usize,
+ },
}
impl From<ResourceError> for MaterialError {
@@ -290,6 +366,25 @@ impl std::fmt::Display for MaterialError {
Self::Resource(message) => write!(f, "{message}"),
Self::MissingMaterial(name) => write!(f, "missing material: {name}"),
Self::EmptyMaterial => write!(f, "material has no phases"),
+ Self::InvalidAnimationDuration { block_index } => {
+ write!(f, "MAT0 animation block {block_index} has zero duration")
+ }
+ Self::InvalidAnimationPhaseIndex {
+ block_index,
+ key_index,
+ phase_index,
+ phase_count,
+ } => write!(
+ f,
+ "MAT0 animation block {block_index} key {key_index} phase {phase_index} outside {phase_count} phases"
+ ),
+ Self::InvalidAnimationKeyTimes {
+ block_index,
+ key_index,
+ } => write!(
+ f,
+ "MAT0 animation block {block_index} key {key_index} has non-increasing end time"
+ ),
}
}
}
@@ -313,7 +408,8 @@ pub fn decode_wear(bytes: &[u8]) -> Result<WearTable, MaterialError> {
return Err(MaterialError::ZeroWearCount);
}
- let mut entries = Vec::with_capacity(count);
+ let available_lines = lines.clone().count();
+ let mut entries = Vec::with_capacity(count.min(available_lines));
for index in 0..count {
let line = lines
.next()
@@ -444,97 +540,214 @@ pub fn resolve_material(
))
}
-/// Samples a material phase with deterministic runtime timeline semantics.
+/// Samples one native MAT0 animation block.
+///
+/// `animation_block_index` selects a block from the already-resolved material
+/// table. The packed WEAR handle (table and row) is resolved by the caller;
+/// its high half is not an animation-block selector. An out-of-range block
+/// uses block zero, matching `GetPhase`. `clock_ms` is the paused-compensated
+/// `timeGetTime` value and `wear_row_start_ms` is the start timestamp captured
+/// for the owning WEA row; the sampler subtracts the latter with wrapping
+/// `u32` arithmetic. For mode [`MaterialAnimationMode::RandomPerQuery`],
+/// `random_value` is the caller's fresh native-style random value for this
+/// query. The sampler does not own an RNG.
///
/// # Errors
///
-/// Returns [`MaterialError::EmptyMaterial`] when the MAT0 document has no
-/// phases.
+/// Returns an error for an empty material, a malformed multi-key duration,
+/// non-increasing key end times, or a key that references no phase.
pub fn sample_material_phase(
document: &Mat0Document,
- profile: MaterialTimelineProfile,
- frame: u32,
- seed: u64,
+ animation_block_index: usize,
+ clock_ms: u32,
+ wear_row_start_ms: u32,
+ random_value: u32,
) -> Result<MaterialPhaseSample, MaterialError> {
- if document.phases.is_empty() {
+ let Some(first_phase) = document.phases.first() else {
return Err(MaterialError::EmptyMaterial);
+ };
+
+ let Some((selected_block_index, block)) = document
+ .animation_blocks
+ .get(animation_block_index)
+ .map(|block| (animation_block_index, block))
+ .or_else(|| document.animation_blocks.first().map(|block| (0, block)))
+ else {
+ return Ok(sample_static_phase(0, first_phase));
+ };
+
+ if block.keys.is_empty() {
+ return Ok(sample_static_phase(0, first_phase));
+ }
+
+ let keys = &block.keys;
+ let resolve_phase = |key_index: usize, key: &MaterialKey| {
+ let phase_index = usize::from(key.k0);
+ document
+ .phases
+ .get(phase_index)
+ .map(|phase| (phase_index, phase))
+ .ok_or(MaterialError::InvalidAnimationPhaseIndex {
+ block_index: selected_block_index,
+ key_index,
+ phase_index: key.k0,
+ phase_count: document.phases.len(),
+ })
+ };
+
+ if keys.len() == 1 {
+ let (phase_index, phase) = resolve_phase(0, &keys[0])?;
+ return Ok(sample_static_phase(phase_index, phase));
}
- let phase_count = document.phases.len();
- let offset = if profile.random_offset {
- material_random_offset(seed, phase_count)
+
+ let duration_ms = u32::from(keys[keys.len() - 1].k1);
+ if duration_ms == 0 {
+ return Err(MaterialError::InvalidAnimationDuration {
+ block_index: selected_block_index,
+ });
+ }
+ validate_key_times(keys, selected_block_index)?;
+ for (key_index, key) in keys.iter().enumerate() {
+ resolve_phase(key_index, key)?;
+ }
+
+ let elapsed_ms = clock_ms.wrapping_sub(wear_row_start_ms);
+ let mode = block.mode();
+ let (local_time_ms, forced_last_key) = match mode {
+ MaterialAnimationMode::Loop | MaterialAnimationMode::Unknown(_) => {
+ (elapsed_ms % duration_ms, false)
+ }
+ MaterialAnimationMode::PingPong => {
+ let cycle = elapsed_ms / duration_ms;
+ let remainder = elapsed_ms % duration_ms;
+ let local_time = if cycle & 1 == 0 {
+ remainder
+ } else {
+ duration_ms - remainder
+ };
+ (local_time, false)
+ }
+ MaterialAnimationMode::Clamp => {
+ if elapsed_ms >= duration_ms {
+ // Native clamp selects the last key and leaves the local
+ // remainder at zero before entering the common interpolation
+ // path, preserving its unsigned endpoint arithmetic.
+ (0, true)
+ } else {
+ (elapsed_ms, false)
+ }
+ }
+ MaterialAnimationMode::RandomPerQuery => (random_value % duration_ms, false),
+ };
+
+ let (key_index, previous_end_ms) = if forced_last_key {
+ (keys.len() - 1, u32::from(keys[keys.len() - 2].k1))
} else {
- 0
+ select_key(keys, local_time_ms)
};
- let effective_frame = frame.wrapping_add(offset);
- let phase_index = select_phase_index(profile.mode, effective_frame, phase_count);
- let phase = &document.phases[phase_index];
+ let key = &keys[key_index];
+ let next_key_index = (key_index + 1) % keys.len();
+ let next_key = &keys[next_key_index];
+ let (phase_index, phase) = resolve_phase(key_index, key)?;
+ let (_, next_phase) = resolve_phase(next_key_index, next_key)?;
+ let span_ms = u32::from(key.k1).wrapping_sub(previous_end_ms);
+ if span_ms == 0 {
+ return Err(MaterialError::InvalidAnimationKeyTimes {
+ block_index: selected_block_index,
+ key_index,
+ });
+ }
+ // The original routine subtracts unsigned dwords before converting both
+ // values to floating point. Clamp's post-duration endpoint intentionally
+ // retains this wraparound behavior.
+ let fraction = local_time_ms.wrapping_sub(previous_end_ms) as f32 / span_ms as f32;
+ let coefficients =
+ interpolate_coefficients(phase, next_phase, block.interpolation_mask(), fraction);
Ok(MaterialPhaseSample {
phase_index,
- effective_frame,
- parameters: phase.parameters,
+ coefficients,
texture_raw: phase.texture_raw,
})
}
-/// Interpolates selected parameter bytes according to a bit mask.
-///
-/// Unmasked fields are copied from `left`; masked fields are linearly blended
-/// and rounded to nearest integer.
-#[must_use]
-pub fn interpolate_parameter_bytes(
- left: [u8; 18],
- right: [u8; 18],
- interpolation_mask: u32,
- t: f32,
-) -> [u8; 18] {
- let mut out = left;
- for (index, value) in out.iter_mut().enumerate() {
- if interpolation_mask & (1_u32 << index) == 0 {
- continue;
+fn sample_static_phase(phase_index: usize, phase: &MaterialPhase) -> MaterialPhaseSample {
+ MaterialPhaseSample {
+ phase_index,
+ coefficients: phase.coefficients(),
+ texture_raw: phase.texture_raw,
+ }
+}
+
+fn validate_key_times(keys: &[MaterialKey], block_index: usize) -> Result<(), MaterialError> {
+ let mut previous_end = 0_u32;
+ for (key_index, key) in keys.iter().enumerate() {
+ let end = u32::from(key.k1);
+ if end <= previous_end {
+ return Err(MaterialError::InvalidAnimationKeyTimes {
+ block_index,
+ key_index,
+ });
}
- let blended =
- f32::from(left[index]) + (f32::from(right[index]) - f32::from(left[index])) * t;
- *value = rounded_clamped_byte(blended);
+ previous_end = end;
}
- out
+ Ok(())
}
-fn rounded_clamped_byte(value: f32) -> u8 {
- let rounded = value.round();
- if !rounded.is_finite() || rounded <= 0.0 {
- return 0;
+fn select_key(keys: &[MaterialKey], local_time_ms: u32) -> (usize, u32) {
+ let mut previous_end = 0_u32;
+ for (key_index, key) in keys.iter().enumerate() {
+ let end = u32::from(key.k1);
+ if local_time_ms < end {
+ return (key_index, previous_end);
+ }
+ previous_end = end;
}
- if rounded >= f32::from(u8::MAX) {
- return u8::MAX;
+ // The native backward search falls through to key zero when the local
+ // value is exactly the duration (the ping-pong turnaround endpoint).
+ if local_time_ms >= u32::from(keys[keys.len() - 1].k1) {
+ (0, 0)
+ } else {
+ (keys.len() - 1, previous_end)
}
- (0_u8..=u8::MAX)
- .find(|candidate| f32::from(*candidate) >= rounded)
- .unwrap_or(u8::MAX)
}
-/// Builds a deterministic capture for material phase sampling.
-///
-/// # Errors
-///
-/// Returns [`MaterialError`] when sampling fails.
-pub fn material_phase_capture(
- document: &Mat0Document,
- profile: MaterialTimelineProfile,
- frames: &[u32],
- seed: u64,
-) -> Result<Vec<u8>, MaterialError> {
- let mut out = Vec::new();
- for frame in frames {
- let sample = sample_material_phase(document, profile, *frame, seed)?;
- out.extend_from_slice(
- format!(
- "M,{},{},{}\n",
- frame, sample.effective_frame, sample.phase_index
- )
- .as_bytes(),
- );
+fn interpolate_coefficients(
+ left: &MaterialPhase,
+ right: &MaterialPhase,
+ mask: u32,
+ fraction: f32,
+) -> MaterialCoefficients {
+ let left = left.coefficients();
+ let right = right.coefficients();
+ let mut output = left;
+ if mask & 1 != 0 {
+ output.additive_rgb = lerp3(left.additive_rgb, right.additive_rgb, fraction);
}
- Ok(out)
+ if mask & 2 != 0 {
+ output.directional_rgb = lerp3(left.directional_rgb, right.directional_rgb, fraction);
+ }
+ if mask & 4 != 0 {
+ output.specular_rgb = lerp3(left.specular_rgb, right.specular_rgb, fraction);
+ }
+ if mask & 8 != 0 {
+ output.extra_rgb = lerp3(left.extra_rgb, right.extra_rgb, fraction);
+ }
+ if mask & 16 != 0 {
+ output.opacity = lerp(left.opacity, right.opacity, fraction);
+ }
+ output
+}
+
+fn lerp(left: f32, right: f32, fraction: f32) -> f32 {
+ left + (right - left) * fraction
+}
+
+fn lerp3(left: [f32; 3], right: [f32; 3], fraction: f32) -> [f32; 3] {
+ [
+ lerp(left[0], right[0], fraction),
+ lerp(left[1], right[1], fraction),
+ lerp(left[2], right[2], fraction),
+ ]
}
impl Mat0Document {
@@ -560,46 +773,6 @@ impl Mat0Document {
}
}
-fn select_phase_index(mode: MaterialTimelineMode, frame: u32, phase_count: usize) -> usize {
- let count = u32::try_from(phase_count).unwrap_or(u32::MAX).max(1);
- let index = match mode {
- MaterialTimelineMode::OneShot | MaterialTimelineMode::Clamp => frame.min(count - 1),
- MaterialTimelineMode::Loop => frame % count,
- MaterialTimelineMode::PingPong => {
- if count == 1 {
- 0
- } else {
- let period = count.saturating_mul(2).saturating_sub(2);
- let local = frame % period;
- if local < count {
- local
- } else {
- period - local
- }
- }
- }
- };
- usize::try_from(index).unwrap_or(phase_count.saturating_sub(1))
-}
-
-fn material_random_offset(seed: u64, phase_count: usize) -> u32 {
- let count = u64::try_from(phase_count).unwrap_or(u64::MAX).max(1);
- let mut state = 0xa076_1d64_78bd_642f_u64 ^ seed;
- for byte in b"material" {
- state ^= u64::from(*byte);
- state = splitmix64(state);
- }
- u32::try_from(splitmix64(state) % count).unwrap_or(0)
-}
-
-fn splitmix64(mut value: u64) -> u64 {
- value = value.wrapping_add(0x9e37_79b9_7f4a_7c15);
- let mut mixed = value;
- mixed = (mixed ^ (mixed >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
- mixed = (mixed ^ (mixed >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
- mixed ^ (mixed >> 31)
-}
-
fn load_material_entry(
repository: &dyn ResourceRepository,
archive: fparkan_resource::ArchiveId,
@@ -618,6 +791,7 @@ fn load_material_entry(
Ok(Some(ResolvedMaterial {
name: info.key.name,
fallback,
+ attr1: info.attr1,
document,
}))
}
@@ -638,6 +812,7 @@ fn load_first_material_entry(
Ok(Some(ResolvedMaterial {
name: info.key.name,
fallback: MaterialFallback::FirstEntry,
+ attr1: info.attr1,
document,
}))
}
@@ -661,7 +836,8 @@ fn parse_lightmaps(lines: &[&str]) -> Result<Vec<LightmapEntry>, MaterialError>
let count = parse_count(count_line)
.map_err(|_| MaterialError::InvalidLightmapCount((*count_line).to_string()))?;
cursor += 1;
- let mut lightmaps = Vec::with_capacity(count);
+ let available_lines = lines.len().saturating_sub(cursor);
+ let mut lightmaps = Vec::with_capacity(count.min(available_lines));
for index in 0..count {
let line = lines
.get(cursor)
@@ -802,6 +978,7 @@ mod tests {
use fparkan_nres::ReadProfile;
use fparkan_resource::CachedResourceRepository;
use fparkan_vfs::MemoryVfs;
+ use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -833,6 +1010,27 @@ mod tests {
}
#[test]
+ fn wear_and_lightmap_counts_do_not_reserve_beyond_available_lines() {
+ let wear = format!("{}\n", usize::MAX);
+ assert!(matches!(
+ decode_wear(wear.as_bytes()),
+ Err(MaterialError::MissingWearRow {
+ index: 0,
+ count: usize::MAX
+ })
+ ));
+
+ let lightmaps = format!("1\n0 MAT\n\nLIGHTMAPS\n{}\n", usize::MAX);
+ assert!(matches!(
+ decode_wear(lightmaps.as_bytes()),
+ Err(MaterialError::MissingLightmapRow {
+ index: 0,
+ count: usize::MAX
+ })
+ ));
+ }
+
+ #[test]
fn mat0_version_prefix_and_primary_texture() {
let mut bytes = vec![0; 4 + 10 + 68];
bytes[0..2].copy_from_slice(&2_u16.to_le_bytes());
@@ -918,7 +1116,7 @@ mod tests {
#[test]
fn resolve_material_uses_exact_match() {
let repo = material_repo(&[
- material_entry(b"MAT_A", &mat0_with_texture(b"TEX_A")),
+ material_entry_with_attr1(b"MAT_A", &mat0_with_texture(b"TEX_A"), 0x1234_5678),
material_entry(b"DEFAULT", &mat0_with_texture(b"TEX_DEFAULT")),
]);
let table = decode_wear(b"1\n0 MAT_A\n").expect("wear");
@@ -927,6 +1125,7 @@ mod tests {
assert_eq!(resolved.name.0, b"MAT_A");
assert_eq!(resolved.fallback, MaterialFallback::Exact);
+ assert_eq!(resolved.attr1, 0x1234_5678);
assert_eq!(
resolved.document.primary_texture().expect("texture").0,
b"TEX_A"
@@ -999,146 +1198,303 @@ mod tests {
}
#[test]
- fn material_modes_zero_to_three_choose_stable_phases() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
-
- let cases = [
- (MaterialTimelineMode::OneShot, 9, 2),
- (MaterialTimelineMode::Clamp, 9, 2),
- (MaterialTimelineMode::Loop, 4, 1),
- (MaterialTimelineMode::PingPong, 3, 1),
- ];
- for (mode, frame, expected_phase) in cases {
- let sample = sample_material_phase(
- &document,
- MaterialTimelineProfile {
- mode,
- random_offset: false,
- },
- frame,
- 0,
- )
- .expect("sample");
- assert_eq!(sample.phase_index, expected_phase, "{mode:?}");
- }
+ fn material_animation_header_decodes_native_mode_and_mask() {
+ let block = MaterialAnimationBlock {
+ header_raw: (0x1f_u32 << 3) | 3,
+ keys: Vec::new(),
+ bytes: Vec::new(),
+ };
+
+ assert_eq!(block.mode(), MaterialAnimationMode::RandomPerQuery);
+ assert_eq!(block.interpolation_mask(), 0x1f);
+ assert_eq!(
+ MaterialAnimationMode::from_header(4),
+ MaterialAnimationMode::Unknown(4)
+ );
}
#[test]
- fn material_exact_key_boundary_selects_exact_phase() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
-
- let sample = sample_material_phase(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::Clamp,
- random_offset: false,
- },
- 1,
- 0,
- )
- .expect("sample");
+ fn material_phase_coefficients_decode_native_parameter_slots() {
+ let mut parameters = [0_u8; 18];
+ parameters.copy_from_slice(&[
+ 0, 64, 128, 25, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 9, 0xfe,
+ ]);
+ let phase = MaterialPhase {
+ parameters,
+ texture_raw: [0; 16],
+ };
- assert_eq!(sample.phase_index, 1);
- assert_eq!(&sample.texture_raw[..1], b"B");
+ let coefficients = phase.coefficients();
+ assert_eq!(
+ coefficients.additive_rgb,
+ [0.0, 64.0 / 255.0, 128.0 / 255.0]
+ );
+ assert!((coefficients.opacity - 0.25).abs() < f32::EPSILON);
+ assert_eq!(
+ coefficients.directional_rgb,
+ [10.0 / 255.0, 20.0 / 255.0, 30.0 / 255.0]
+ );
+ assert_eq!(coefficients.directional_extra, 40.0 / 255.0);
+ assert_eq!(
+ coefficients.specular_rgb,
+ [50.0 / 255.0, 60.0 / 255.0, 70.0 / 255.0]
+ );
+ assert_eq!(coefficients.specular_extra, 80.0 / 255.0);
+ assert_eq!(
+ coefficients.extra_rgb,
+ [90.0 / 255.0, 100.0 / 255.0, 110.0 / 255.0]
+ );
+ assert_eq!(coefficients.extra_alpha, 120.0 / 255.0);
+ assert_eq!(coefficients.power, 9);
+ assert_eq!(coefficients.page_index, -2);
}
#[test]
- fn material_interpolation_mask_affects_only_selected_fields() {
- let mut left = [10_u8; 18];
- let mut right = [20_u8; 18];
- left[1] = 100;
- right[1] = 200;
+ fn material_loop_interpolates_native_groups_and_selects_current_texture() {
+ let left = phase_with_parameters(
+ [
+ 0, 10, 20, 25, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 7, 1,
+ ],
+ b"LEFT",
+ );
+ let right = phase_with_parameters(
+ [
+ 255, 110, 220, 75, 230, 240, 250, 160, 170, 180, 190, 200, 210, 220, 230, 240, 9, 2,
+ ],
+ b"RIGHT",
+ );
+ let document = animation_document(
+ 0x1f_u32 << 3,
+ vec![left, right],
+ vec![
+ MaterialKey {
+ k0: 0,
+ k1: 100,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 1,
+ k1: 200,
+ k2: 0,
+ },
+ ],
+ );
- let out = interpolate_parameter_bytes(left, right, 0b101, 0.5);
+ let sample = sample_material_phase(&document, 0, 150, 0, 0).expect("sample");
- assert_eq!(out[0], 15);
- assert_eq!(out[1], 100);
- assert_eq!(out[2], 15);
- assert_eq!(out[3], 10);
+ assert_eq!(sample.phase_index, 1);
+ assert_eq!(&sample.texture_raw[..5], b"RIGHT");
+ assert!((sample.coefficients.additive_rgb[0] - (127.5 / 255.0)).abs() < 1e-6);
+ assert!((sample.coefficients.opacity - 0.5).abs() < 1e-6);
+ assert!((sample.coefficients.directional_rgb[1] - (140.0 / 255.0)).abs() < 1e-6);
+ assert_eq!(sample.coefficients.directional_extra, 160.0 / 255.0);
+ assert!((sample.coefficients.specular_rgb[2] - (140.0 / 255.0)).abs() < 1e-6);
+ assert_eq!(sample.coefficients.specular_extra, 200.0 / 255.0);
+ assert!((sample.coefficients.extra_rgb[0] - (160.0 / 255.0)).abs() < 1e-6);
+ assert_eq!(sample.coefficients.extra_alpha, 240.0 / 255.0);
+ assert_eq!(sample.coefficients.power, 9);
+ assert_eq!(sample.coefficients.page_index, 2);
+
+ let boundary = sample_material_phase(&document, 0, 100, 0, 0).expect("boundary");
+ assert_eq!(boundary.phase_index, 1);
+ assert_eq!(&boundary.texture_raw[..5], b"RIGHT");
+ assert_eq!(boundary.coefficients.additive_rgb[0], 1.0);
+
+ let wrapped = sample_material_phase(&document, 0, 200, 0, 0).expect("wrapped");
+ assert_eq!(wrapped.phase_index, 0);
+ assert_eq!(&wrapped.texture_raw[..4], b"LEFT");
}
#[test]
- fn material_timeline_profile_cases_are_evidence_labeled() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
-
+ fn material_ping_pong_reverses_local_time_and_clamp_holds_last_phase() {
+ let phases = vec![
+ phase_with_parameters([0; 18], b"A"),
+ phase_with_parameters([255; 18], b"B"),
+ ];
+ let keys = vec![
+ MaterialKey {
+ k0: 0,
+ k1: 100,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 1,
+ k1: 200,
+ k2: 0,
+ },
+ ];
+ let ping_pong = animation_document((0x1_u32) | (1 << 3), phases.clone(), keys.clone());
assert_eq!(
- material_phase_capture(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::OneShot,
- random_offset: false,
- },
- &[0, 1, 4],
- 0,
- )
- .expect("one-shot"),
- b"M,0,0,0\nM,1,1,1\nM,4,4,2\n"
+ sample_material_phase(&ping_pong, 0, 100, 0, 0)
+ .expect("turn")
+ .phase_index,
+ 1
);
assert_eq!(
- material_phase_capture(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::Loop,
- random_offset: false,
- },
- &[0, 1, 4],
- 0,
- )
- .expect("loop"),
- b"M,0,0,0\nM,1,1,1\nM,4,4,1\n"
+ sample_material_phase(&ping_pong, 0, 150, 0, 0)
+ .expect("reverse")
+ .phase_index,
+ 1
);
assert_eq!(
- material_phase_capture(
- &document,
- MaterialTimelineProfile {
- mode: MaterialTimelineMode::PingPong,
- random_offset: false,
- },
- &[0, 1, 3],
- 0,
- )
- .expect("ping-pong"),
- b"M,0,0,0\nM,1,1,1\nM,3,3,1\n"
+ sample_material_phase(&ping_pong, 0, 200, 0, 0)
+ .expect("restart")
+ .phase_index,
+ 0
);
+ let turnaround = sample_material_phase(&ping_pong, 0, 200, 0, 0).expect("turnaround");
+ assert!((turnaround.coefficients.additive_rgb[0] - 2.0).abs() < 1e-6);
+
+ let clamp = animation_document((2_u32) | (1 << 3), phases, keys);
+ let sample = sample_material_phase(&clamp, 0, 500, 0, 0).expect("clamp");
+ assert_eq!(sample.phase_index, 1);
+ assert!(sample.coefficients.additive_rgb[0] < 0.0);
}
#[test]
- fn material_random_offset_uses_material_stream_only() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
- let profile = MaterialTimelineProfile {
- mode: MaterialTimelineMode::Loop,
- random_offset: true,
+ fn material_random_mode_uses_caller_value_and_invalid_block_uses_block_zero() {
+ let phases = vec![
+ phase_with_parameters([0; 18], b"A"),
+ phase_with_parameters([255; 18], b"B"),
+ ];
+ let keys = vec![
+ MaterialKey {
+ k0: 0,
+ k1: 100,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 1,
+ k1: 200,
+ k2: 0,
+ },
+ ];
+ let document = Mat0Document {
+ version: 0,
+ animation_block_count: 2,
+ phases,
+ prefix: Vec::new(),
+ header_opaque: [2, 0],
+ animation_blocks: vec![
+ MaterialAnimationBlock {
+ header_raw: 3,
+ keys: keys.clone(),
+ bytes: Vec::new(),
+ },
+ MaterialAnimationBlock {
+ header_raw: 2,
+ keys,
+ bytes: Vec::new(),
+ },
+ ],
};
- let before = material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture");
- let mut unrelated = 0x5555_u64;
- for _ in 0..16 {
- unrelated = unrelated.rotate_left(11).wrapping_mul(31);
- }
- assert_ne!(unrelated, 0);
+ let sample = sample_material_phase(&document, 0, 0, 0, 150).expect("random");
+ assert_eq!(sample.phase_index, 1);
+ let fallback = sample_material_phase(&document, 99, 500, 0, 0).expect("fallback");
+ assert_eq!(fallback.phase_index, 0);
+ }
+
+ #[test]
+ fn material_static_and_single_key_sampling_uses_phase_zero_or_key_phase() {
+ let phases = vec![
+ phase_with_parameters([1; 18], b"A"),
+ phase_with_parameters([2; 18], b"B"),
+ ];
+ let static_document = Mat0Document {
+ version: 0,
+ animation_block_count: 0,
+ phases: phases.clone(),
+ prefix: Vec::new(),
+ header_opaque: [0, 0],
+ animation_blocks: Vec::new(),
+ };
assert_eq!(
- material_phase_capture(&document, profile, &[0, 1, 2], 99).expect("capture"),
- before
+ sample_material_phase(&static_document, 0, 123, 50, 0)
+ .expect("static")
+ .phase_index,
+ 0
+ );
+
+ let single_document = animation_document(
+ 0,
+ phases,
+ vec![MaterialKey {
+ k0: 1,
+ k1: 0,
+ k2: 0,
+ }],
);
+ let sample = sample_material_phase(&single_document, 0, 123, 50, 0).expect("single");
+ assert_eq!(sample.phase_index, 1);
+ assert_eq!(&sample.texture_raw[..1], b"B");
}
#[test]
- fn material_same_seed_and_timeline_produces_same_phase_capture() {
- let document =
- decode_mat0(&mat0_with_phase_textures(&[b"A", b"B", b"C"]), 0).expect("mat0");
- let profile = MaterialTimelineProfile {
- mode: MaterialTimelineMode::Loop,
- random_offset: true,
- };
+ fn material_sampling_rejects_bad_timeline_data() {
+ let phase = phase_with_parameters([0; 18], b"A");
+ let invalid_ref = animation_document(
+ 0,
+ vec![phase.clone()],
+ vec![
+ MaterialKey {
+ k0: 0,
+ k1: 10,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 4,
+ k1: 20,
+ k2: 0,
+ },
+ ],
+ );
+ assert!(matches!(
+ sample_material_phase(&invalid_ref, 0, 0, 0, 0),
+ Err(MaterialError::InvalidAnimationPhaseIndex { .. })
+ ));
- assert_eq!(
- material_phase_capture(&document, profile, &[0, 4, 7], 123).expect("first"),
- material_phase_capture(&document, profile, &[0, 4, 7], 123).expect("second")
+ let invalid_times = animation_document(
+ 0,
+ vec![phase.clone()],
+ vec![
+ MaterialKey {
+ k0: 0,
+ k1: 20,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 0,
+ k1: 10,
+ k2: 0,
+ },
+ ],
+ );
+ assert!(matches!(
+ sample_material_phase(&invalid_times, 0, 0, 0, 0),
+ Err(MaterialError::InvalidAnimationKeyTimes { .. })
+ ));
+
+ let zero_duration = animation_document(
+ 0,
+ vec![phase],
+ vec![
+ MaterialKey {
+ k0: 0,
+ k1: 0,
+ k2: 0,
+ },
+ MaterialKey {
+ k0: 0,
+ k1: 0,
+ k2: 0,
+ },
+ ],
);
+ assert!(matches!(
+ sample_material_phase(&zero_duration, 0, 0, 0, 0),
+ Err(MaterialError::InvalidAnimationDuration { .. })
+ ));
}
#[test]
@@ -1152,6 +1508,8 @@ mod tests {
let mut mat0_count = 0usize;
let mut archive_wear_count = 0usize;
let mut standalone_wear_count = 0usize;
+ let mut animation_modes = BTreeMap::<u8, usize>::new();
+ let mut interpolation_masks = BTreeSet::<u32>::new();
for path in files_under(&root) {
let Ok(bytes) = std::fs::read(&path) else {
continue;
@@ -1175,9 +1533,16 @@ mod tests {
let payload = archive.payload(entry.id()).expect("payload");
match entry.meta().type_id {
MAT0_KIND => {
- decode_mat0(payload, entry.meta().attr2).unwrap_or_else(|err| {
- panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
- });
+ let document =
+ decode_mat0(payload, entry.meta().attr2).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ for block in &document.animation_blocks {
+ *animation_modes
+ .entry((block.header_raw & 7) as u8)
+ .or_default() += 1;
+ interpolation_masks.insert(block.interpolation_mask());
+ }
mat0_count += 1;
}
WEAR_KIND => {
@@ -1199,6 +1564,9 @@ mod tests {
standalone_wear_count, expected_standalone_wear,
"{corpus} standalone WEAR count"
);
+ eprintln!(
+ "{corpus} MAT0 animation inventory: modes={animation_modes:?}, masks={interpolation_masks:?}"
+ );
}
}
@@ -1242,6 +1610,7 @@ mod tests {
struct TestMaterialEntry<'a> {
name: &'a [u8],
type_id: u32,
+ attr1: u32,
attr2: u32,
payload: &'a [u8],
}
@@ -1250,6 +1619,21 @@ mod tests {
TestMaterialEntry {
name,
type_id: MAT0_KIND,
+ attr1: 0,
+ attr2: 0,
+ payload,
+ }
+ }
+
+ fn material_entry_with_attr1<'a>(
+ name: &'a [u8],
+ payload: &'a [u8],
+ attr1: u32,
+ ) -> TestMaterialEntry<'a> {
+ TestMaterialEntry {
+ name,
+ type_id: MAT0_KIND,
+ attr1,
attr2: 0,
payload,
}
@@ -1273,20 +1657,33 @@ mod tests {
bytes
}
- fn mat0_with_phase_textures(textures: &[&[u8]]) -> Vec<u8> {
- let mut bytes = vec![0; 4 + textures.len() * 34];
- bytes[0..2].copy_from_slice(
- &u16::try_from(textures.len())
- .expect("phase count")
- .to_le_bytes(),
- );
- for (index, texture) in textures.iter().enumerate() {
- let offset = 4 + index * 34;
- bytes[offset] = u8::try_from(index).expect("index");
- let len = texture.len().min(16);
- bytes[offset + 18..offset + 18 + len].copy_from_slice(&texture[..len]);
+ fn phase_with_parameters(parameters: [u8; 18], texture: &[u8]) -> MaterialPhase {
+ let mut texture_raw = [0_u8; 16];
+ let len = texture.len().min(texture_raw.len());
+ texture_raw[..len].copy_from_slice(&texture[..len]);
+ MaterialPhase {
+ parameters,
+ texture_raw,
+ }
+ }
+
+ fn animation_document(
+ header_raw: u32,
+ phases: Vec<MaterialPhase>,
+ keys: Vec<MaterialKey>,
+ ) -> Mat0Document {
+ Mat0Document {
+ version: 0,
+ animation_block_count: 1,
+ phases,
+ prefix: Vec::new(),
+ header_opaque: [1, 0],
+ animation_blocks: vec![MaterialAnimationBlock {
+ header_raw,
+ keys,
+ bytes: Vec::new(),
+ }],
}
- bytes
}
fn build_material_nres(entries: &[TestMaterialEntry<'_>]) -> Vec<u8> {
@@ -1302,7 +1699,7 @@ mod tests {
order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
for (idx, entry) in entries.iter().enumerate() {
push_u32(&mut out, entry.type_id);
- push_u32(&mut out, 0);
+ push_u32(&mut out, entry.attr1);
push_u32(&mut out, entry.attr2);
push_u32(
&mut out,
diff --git a/crates/fparkan-mission-format/Cargo.toml b/crates/fparkan-mission-format/Cargo.toml
index 1bfc362..3802966 100644
--- a/crates/fparkan-mission-format/Cargo.toml
+++ b/crates/fparkan-mission-format/Cargo.toml
@@ -7,7 +7,4 @@ repository.workspace = true
[dependencies]
encoding_rs = "0.8"
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-binary = { path = "../fparkan-binary" }
diff --git a/crates/fparkan-mission-format/src/lib.rs b/crates/fparkan-mission-format/src/lib.rs
index 1562256..e796d61 100644
--- a/crates/fparkan-mission-format/src/lib.rs
+++ b/crates/fparkan-mission-format/src/lib.rs
@@ -1,23 +1,4 @@
#![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;
diff --git a/crates/fparkan-msh/Cargo.toml b/crates/fparkan-msh/Cargo.toml
index 7df80f9..61ebc2b 100644
--- a/crates/fparkan-msh/Cargo.toml
+++ b/crates/fparkan-msh/Cargo.toml
@@ -7,8 +7,5 @@ repository.workspace = true
[dependencies]
encoding_rs = "0.8"
-fparkan-animation = { path = "../fparkan-animation", version = "0.1.0" }
-fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-animation = { path = "../fparkan-animation" }
+fparkan-nres = { path = "../fparkan-nres" }
diff --git a/crates/fparkan-msh/src/lib.rs b/crates/fparkan-msh/src/lib.rs
index fd222c6..b6459ab 100644
--- a/crates/fparkan-msh/src/lib.rs
+++ b/crates/fparkan-msh/src/lib.rs
@@ -1,23 +1,4 @@
#![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;
@@ -37,6 +18,9 @@ pub const STREAM_POSITIONS: u32 = 3;
pub const STREAM_NORMALS: u32 = 4;
/// Texture coordinate stream.
pub const STREAM_UV0: u32 = 5;
+/// Optional secondary texture coordinate stream used by native lightmap/detail
+/// stages. MSH stores it as the type-18 four-byte packed UV stream.
+pub const STREAM_UV1: u32 = 18;
/// Triangle index stream.
pub const STREAM_INDICES: u32 = 6;
/// Animation key stream.
@@ -100,14 +84,20 @@ pub struct ModelAsset {
pub node_count: usize,
/// Raw node table.
pub nodes_raw: Vec<u8>,
+ /// Model-level bounding sphere center from the Res2 header.
+ pub bounding_sphere_center: [f32; 3],
+ /// Model-level bounding sphere radius from the Res2 header.
+ pub bounding_sphere_radius: f32,
/// Slot table.
pub slots: Vec<Slot>,
/// Vertex positions.
pub positions: Vec<[f32; 3]>,
/// Optional normals.
pub normals: Option<Vec<[i8; 4]>>,
- /// Optional texture coordinates.
- pub uv0: Option<Vec<[i16; 2]>>,
+ /// Optional primary packed texture coordinates from type 5.
+ pub uv0: Option<Vec<[u16; 2]>>,
+ /// Optional secondary packed texture coordinates from type 18.
+ pub uv1: Option<Vec<[u16; 2]>>,
/// Triangle indices.
pub indices: Vec<u16>,
/// Draw batches.
@@ -186,18 +176,24 @@ pub struct Slot {
pub struct Batch {
/// Batch flags.
pub batch_flags: u16,
- /// Material index.
- pub material_index: u16,
- /// Opaque field.
- pub opaque4: u16,
- /// Opaque field.
- pub opaque6: u16,
+ /// High material selector/slot field at byte offset `+0x02`.
+ ///
+ /// The ordinary WEAR material selector is the byte at `+0x04`. This
+ /// field is retained separately because the native path can override it
+ /// with a forced material index.
+ pub material_index_hi: u16,
+ /// WEAR material selector at byte offset `+0x04`.
+ pub material_index: u8,
+ /// Lightmap selector at byte offset `+0x05`.
+ pub lightmap_index: u8,
+ /// Local batch index at byte offset `+0x06`.
+ pub local_batch_index: u16,
/// Index count.
pub index_count: u16,
/// First index offset.
pub index_start: u32,
- /// Opaque field.
- pub opaque14: u16,
+ /// Vertex count at byte offset `+0x0E`.
+ pub vertex_count: u16,
/// Base vertex.
pub base_vertex: u32,
}
@@ -213,8 +209,10 @@ pub struct VertexStreams {
pub positions: Vec<[f32; 3]>,
/// Optional normals.
pub normals: Option<Vec<[i8; 4]>>,
- /// Optional texture coordinates.
- pub uv0: Option<Vec<[i16; 2]>>,
+ /// Optional primary packed texture coordinates from type 5.
+ pub uv0: Option<Vec<[u16; 2]>>,
+ /// Optional secondary packed texture coordinates from type 18.
+ pub uv1: Option<Vec<[u16; 2]>>,
}
/// Preserved non-core stream.
@@ -228,11 +226,11 @@ pub struct PreservedStream {
pub bytes: std::sync::Arc<[u8]>,
}
-/// LOD id.
+/// Native state id kept under the historical `Lod` API name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Lod(pub u8);
-/// Group id.
+/// Native LOD id kept under the historical `Group` API name.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Group(pub u8);
@@ -396,7 +394,10 @@ pub fn validate_msh(document: &MshDocument) -> Result<ModelAsset, MshError> {
Ok(model)
}
-/// Returns the selected slot for a node/lod/group tuple.
+/// Returns the selected slot for a node/native-state/native-LOD tuple.
+///
+/// The argument names are retained for API compatibility: `Lod` is the
+/// three-value native state and `Group` is the five-value native LOD.
#[must_use]
pub fn selected_slot(model: &ModelAsset, node: NodeId, lod: Lod, group: Group) -> Option<SlotId> {
if model.node_stride != 38 || lod.0 >= 3 || group.0 >= 5 {
@@ -436,12 +437,13 @@ pub fn node38_metadata(model: &ModelAsset, node: NodeId) -> Option<Node38Metadat
})
}
-/// Returns the source fallback pose of a standard node.
+/// Returns the fallback key as a native Node38 source-local pose.
///
/// 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.
+/// assembling a runtime hierarchy. Its quaternion is converted to match native
+/// AniMesh's quaternion-to-matrix convention.
#[must_use]
pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
let metadata = node38_metadata(model, node)?;
@@ -451,6 +453,18 @@ pub fn node38_fallback_pose(model: &ModelAsset, node: NodeId) -> Option<Pose> {
.keys
.get(usize::from(metadata.fallback_key))
.map(AnimKey24::sampling_pose)
+ .map(node38_native_local_pose)
+}
+
+/// Converts a sampled MSH source-local quaternion into the native AniMesh
+/// quaternion-matrix convention before Node38 hierarchy composition. Native
+/// AniMesh's matrix coefficients are the transpose of the portable `Pose`
+/// basis.
+fn node38_native_local_pose(mut pose: Pose) -> Pose {
+ pose.rotation[0] = -pose.rotation[0];
+ pose.rotation[1] = -pose.rotation[1];
+ pose.rotation[2] = -pose.rotation[2];
+ pose
}
/// Evaluates the static fallback pose hierarchy of a standard `Node38` model.
@@ -481,8 +495,9 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
evaluate_hierarchy(&parents, &poses).ok()
}
-/// Evaluates the portable-reference pose hierarchy of a standard `Node38`
-/// model at one explicitly supplied logical frame.
+/// Evaluates a standard `Node38` hierarchy at one explicitly supplied logical
+/// frame, using portable key sampling and native AniMesh local-matrix
+/// convention.
///
/// 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
@@ -494,6 +509,59 @@ pub fn node38_fallback_hierarchy(model: &ModelAsset) -> Option<NodePoseBuffer> {
/// representation or the map cannot be safely resolved.
#[must_use]
pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePoseBuffer> {
+ node38_sampled_hierarchy_with_selector(model, |_| {
+ Some((u32::from(frame), AnimationTime(f32::from(frame))))
+ })
+}
+
+/// Evaluates a standard `Node38` hierarchy with an independent floating-point
+/// sample time for each node.
+///
+/// `Some(time)` uses the nearest-even integer to `time - 0.5` as the type-19
+/// frame-map selector, then samples the selected key and its immediate
+/// successor at the original fractional time. This matches the native
+/// sampler's selector calculation while keeping interpolation time fractional.
+/// The selector uses Rust's default-nearest, ties-to-even rounding. This is
+/// the portable counterpart of the native x87 conversion under its default
+/// rounding mode; it does not claim parity under every x87 control-word mode.
+/// A missing time uses that node's exact fallback key. Supplied times must be
+/// finite and non-negative; the caller owns choosing the per-node times.
+///
+/// Returns `None` when the model is not a complete standard-node animation,
+/// the slice does not match the node count, any supplied time is invalid, or a
+/// frame-map entry cannot be safely resolved.
+#[must_use]
+pub fn node38_sampled_hierarchy_at_times(
+ model: &ModelAsset,
+ node_times: &[Option<AnimationTime>],
+) -> Option<NodePoseBuffer> {
+ if node_times.len() != model.node_count
+ || node_times
+ .iter()
+ .flatten()
+ .any(|time| !time.0.is_finite() || time.0 < 0.0)
+ {
+ return None;
+ }
+ node38_sampled_hierarchy_with_selector(model, |index| {
+ let time = node_times[index]?;
+ let selector = (time.0 - 0.5).round_ties_even();
+ // A finite, non-negative time can still be beyond the representable
+ // map range. Saturate to a frame that will resolve to the node's
+ // fallback key rather than allowing a float-to-index wraparound.
+ let map_frame = if selector < 0.0 || selector >= u32::MAX as f32 {
+ u32::MAX
+ } else {
+ selector as u32
+ };
+ Some((map_frame, time))
+ })
+}
+
+fn node38_sampled_hierarchy_with_selector(
+ model: &ModelAsset,
+ mut selector_for_node: impl FnMut(usize) -> Option<(u32, AnimationTime)>,
+) -> Option<NodePoseBuffer> {
if model.node_stride != 38 || model.node_count == 0 {
return None;
}
@@ -511,21 +579,36 @@ pub fn node38_sampled_hierarchy(model: &ModelAsset, frame: u16) -> Option<NodePo
};
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 {
+ let sample = selector_for_node(index);
+ let key_index = if let Some((map_frame, _)) = sample {
+ if metadata.anim_map_start == u16::MAX
+ || map_frame == u32::MAX
+ || map_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))?,
- )?);
+ let mapped_index = usize::from(
+ *animation.frame_map.get(
+ usize::from(metadata.anim_map_start)
+ .checked_add(usize::try_from(map_frame).ok()?)?,
+ )?,
+ );
if mapped_index < fallback_index {
mapped_index
} else {
fallback_index
}
- };
- let pose = sample_node38_key_pair(&animation.keys, key_index, fallback_index, frame)?;
+ }
+ } else {
+ fallback_index
+ };
+ let sample_time = sample.map_or(AnimationTime(0.0), |(_, time)| time);
+ let pose = node38_native_local_pose(sample_node38_key_pair(
+ &animation.keys,
+ key_index,
+ fallback_index,
+ sample_time,
+ )?);
parents.push(parent);
poses.push(pose);
}
@@ -536,7 +619,7 @@ fn sample_node38_key_pair(
keys: &[AnimKey24],
key_index: usize,
fallback_index: usize,
- frame: u16,
+ sample_time: AnimationTime,
) -> Option<Pose> {
let key = *keys.get(key_index)?;
if key_index == fallback_index {
@@ -560,7 +643,7 @@ fn sample_node38_key_pair(
],
)
.ok()?;
- track.sample(AnimationTime(f32::from(frame))).ok()
+ track.sample(sample_time).ok()
}
/// Returns draw batches for a validated slot.
@@ -686,6 +769,8 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
}
let node_count = nodes_stream.bytes.len() / node_stride;
+ let (bounding_sphere_center, bounding_sphere_radius) =
+ parse_res2_bounding_sphere(&slots_stream.bytes)?;
let slots = parse_slots(&slots_stream.bytes)?;
let positions = parse_positions(&positions_stream.bytes)?;
let indices = parse_u16_array(&indices_stream.bytes, "Res6")?;
@@ -697,7 +782,10 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
.map(|raw| parse_i8x4_array(&raw.bytes, "Res4"))
.transpose()?;
let uv0 = read_optional_stream(document, STREAM_UV0)?
- .map(|raw| parse_i16x2_array(&raw.bytes, "Res5"))
+ .map(|raw| parse_u16x2_array(&raw.bytes, "Res5"))
+ .transpose()?;
+ let uv1 = read_optional_stream(document, STREAM_UV1)?
+ .map(|raw| parse_secondary_uv_stream(raw, positions.len()))
.transpose()?;
let node_names = read_optional_stream(document, STREAM_NAMES)?
.map(|raw| parse_res10_names(&raw.bytes, node_count))
@@ -708,10 +796,13 @@ fn parse_model_document(document: &NresDocument) -> Result<ModelAsset, MshError>
node_stride,
node_count,
nodes_raw: nodes_stream.bytes,
+ bounding_sphere_center,
+ bounding_sphere_radius,
slots,
positions,
normals,
uv0,
+ uv1,
indices,
batches,
node_names,
@@ -841,6 +932,27 @@ fn parse_slots(data: &[u8]) -> Result<Vec<Slot>, MshError> {
Ok(slots)
}
+fn parse_res2_bounding_sphere(data: &[u8]) -> Result<([f32; 3], f32), MshError> {
+ if data.len() < 0x8C {
+ return Err(MshError::InvalidGeometry(format!(
+ "invalid Res2 size: {}",
+ data.len()
+ )));
+ }
+ let center = [
+ read_f32(data, 0x60)?,
+ read_f32(data, 0x64)?,
+ read_f32(data, 0x68)?,
+ ];
+ let radius = read_f32(data, 0x6C)?;
+ if !center.iter().all(|value| value.is_finite()) || !radius.is_finite() || radius < 0.0 {
+ return Err(MshError::InvalidGeometry(
+ "invalid Res2 bounding sphere".to_string(),
+ ));
+ }
+ Ok((center, radius))
+}
+
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>, MshError> {
if !data.len().is_multiple_of(12) {
return Err(invalid_resource_size("Res3", data.len(), 12));
@@ -864,12 +976,17 @@ fn parse_batches(data: &[u8]) -> Result<Vec<Batch>, MshError> {
for offset in (0..data.len()).step_by(20) {
out.push(Batch {
batch_flags: read_u16_required(data, offset)?,
- material_index: read_u16_required(data, offset + 2)?,
- opaque4: read_u16_required(data, offset + 4)?,
- opaque6: read_u16_required(data, offset + 6)?,
+ material_index_hi: read_u16_required(data, offset + 2)?,
+ material_index: *data
+ .get(offset + 4)
+ .ok_or_else(|| MshError::InvalidGeometry("batch material selector".to_string()))?,
+ lightmap_index: *data
+ .get(offset + 5)
+ .ok_or_else(|| MshError::InvalidGeometry("batch lightmap selector".to_string()))?,
+ local_batch_index: read_u16_required(data, offset + 6)?,
index_count: read_u16_required(data, offset + 8)?,
index_start: read_u32(data, offset + 10)?,
- opaque14: read_u16_required(data, offset + 14)?,
+ vertex_count: read_u16_required(data, offset + 14)?,
base_vertex: read_u32(data, offset + 16)?,
});
}
@@ -903,17 +1020,51 @@ fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>, Ms
Ok(out)
}
-fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>, MshError> {
+fn parse_u16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[u16; 2]>, MshError> {
if !data.len().is_multiple_of(4) {
return Err(invalid_resource_size(label, data.len(), 4));
}
let mut out = Vec::with_capacity(data.len() / 4);
for offset in (0..data.len()).step_by(4) {
- out.push([read_i16(data, offset)?, read_i16(data, offset + 2)?]);
+ out.push([
+ read_u16_required(data, offset)?,
+ read_u16_required(data, offset + 2)?,
+ ]);
}
Ok(out)
}
+fn parse_secondary_uv_stream(
+ stream: RawStream,
+ position_count: usize,
+) -> Result<Vec<[u16; 2]>, MshError> {
+ if stream.attributes.attr3 != 4 {
+ return Err(MshError::InvalidGeometry(format!(
+ "invalid Res18 stride: expected 4, got {}",
+ stream.attributes.attr3
+ )));
+ }
+ let values = parse_u16x2_array(&stream.bytes, "Res18")?;
+ let declared_count = usize::try_from(stream.attributes.attr1).map_err(|_| {
+ MshError::InvalidGeometry("Res18 vertex count does not fit usize".to_string())
+ })?;
+ if declared_count != values.len() {
+ return Err(MshError::InvalidGeometry(format!(
+ "Res18 vertex count does not match payload: declared={}, decoded={}",
+ declared_count,
+ values.len()
+ )));
+ }
+ if values.len() != position_count {
+ return Err(MshError::InvalidGeometry(format!(
+ "Res18 vertex count does not match Res3: uv1={}, positions={}",
+ values.len(),
+ position_count
+ )));
+ }
+ Ok(values)
+}
+
fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>, MshError> {
let mut out = Vec::with_capacity(node_count);
let mut offset = 0usize;
@@ -1057,16 +1208,6 @@ fn read_u16_required(bytes: &[u8], offset: usize) -> Result<u16, MshError> {
Ok(u16::from_le_bytes(arr))
}
-fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, MshError> {
- let raw = bytes
- .get(offset..offset.saturating_add(2))
- .ok_or_else(|| MshError::InvalidGeometry("integer overflow".to_string()))?;
- let arr: [u8; 2] = raw
- .try_into()
- .map_err(|_| MshError::InvalidGeometry("integer overflow".to_string()))?;
- Ok(i16::from_le_bytes(arr))
-}
-
fn read_i8(bytes: &[u8], offset: usize) -> Result<i8, MshError> {
let byte = bytes
.get(offset)
@@ -1328,18 +1469,21 @@ mod tests {
let mut nodes = root;
nodes.extend(child);
let mut keys = Vec::new();
- for (x, y, z, qz, qw) in [
+ // Type 8 stores WXYZ, which `AnimKey24::decode` reorders to XYZW.
+ // The native Node38 local-basis conversion happens before hierarchy
+ // evaluation, not in the generic animation decoder.
+ for (x, y, z, qw, qz) in [
(1.0, 0.0, 0.0, 23_170_i16, 23_170_i16),
- (2.0, 0.0, 0.0, 0_i16, 32_767_i16),
+ (2.0, 0.0, 0.0, 32_767_i16, 0_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, qw.cast_unsigned());
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),
@@ -1355,7 +1499,78 @@ mod tests {
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);
+ assert!((hierarchy.poses[1].translation[1] + 2.0).abs() < 0.001);
+ }
+
+ #[test]
+ fn node38_native_x_rotation_moves_child_translation_for_fallback_and_sampled_paths() {
+ let mut root = node38([u16::MAX; 15]);
+ root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
+ root[4..6].copy_from_slice(&0_u16.to_le_bytes());
+ root[6..8].copy_from_slice(&1_u16.to_le_bytes());
+ let mut child = node38([u16::MAX; 15]);
+ child[2..4].copy_from_slice(&0_u16.to_le_bytes());
+ child[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
+ child[6..8].copy_from_slice(&2_u16.to_le_bytes());
+ let mut nodes = root;
+ nodes.extend(child);
+ let half_sqrt_two = std::f32::consts::FRAC_1_SQRT_2;
+ let root_rotation = [half_sqrt_two, 0.0, 0.0, half_sqrt_two];
+ let identity = [0.0, 0.0, 0.0, 1.0];
+ let child_translation = [0.0, -1.01209, -0.001792];
+ let model = ModelAsset {
+ node_stride: 38,
+ node_count: 2,
+ nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 1.0,
+ slots: Vec::new(),
+ positions: Vec::new(),
+ normals: None,
+ uv0: None,
+ uv1: None,
+ indices: Vec::new(),
+ batches: Vec::new(),
+ node_names: None,
+ animation: Some(ModelAnimation {
+ keys: vec![
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
+ translation: [0.0; 3],
+ rotation: root_rotation,
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(1.0),
+ pose: Pose {
+ translation: [0.0; 3],
+ rotation: root_rotation,
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(0.0),
+ pose: Pose {
+ translation: child_translation,
+ rotation: identity,
+ },
+ },
+ ],
+ frame_map: vec![0],
+ frame_count: 1,
+ }),
+ };
+
+ let fallback_root = node38_fallback_pose(&model, NodeId(0)).expect("fallback root pose");
+ assert!(fallback_root.rotation[0] < -0.7);
+ let fallback = node38_fallback_hierarchy(&model).expect("fallback hierarchy");
+ let sampled = node38_sampled_hierarchy(&model, 0).expect("sampled hierarchy");
+ for hierarchy in [&fallback, &sampled] {
+ let child_translation = hierarchy.poses[1].translation;
+ assert!(child_translation[0].abs() < 0.0001);
+ assert!((child_translation[1] + 0.001792).abs() < 0.0001);
+ assert!((child_translation[2] - 1.01209).abs() < 0.0001);
+ }
}
#[test]
@@ -1368,10 +1583,13 @@ mod tests {
node_stride: 38,
node_count: 1,
nodes_raw: node,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 1.0,
slots: Vec::new(),
positions: Vec::new(),
normals: None,
uv0: None,
+ uv1: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
@@ -1414,6 +1632,105 @@ mod tests {
}
#[test]
+ fn standard_nodes_sample_independent_fractional_times_and_selector_rounding() {
+ let mut root = node38([u16::MAX; 15]);
+ root[2..4].copy_from_slice(&u16::MAX.to_le_bytes());
+ root[4..6].copy_from_slice(&0_u16.to_le_bytes());
+ root[6..8].copy_from_slice(&4_u16.to_le_bytes());
+ let mut child = node38([u16::MAX; 15]);
+ child[2..4].copy_from_slice(&0_u16.to_le_bytes());
+ child[4..6].copy_from_slice(&2_u16.to_le_bytes());
+ child[6..8].copy_from_slice(&5_u16.to_le_bytes());
+ let mut nodes = root;
+ nodes.extend(child);
+ let model = ModelAsset {
+ node_stride: 38,
+ node_count: 2,
+ nodes_raw: nodes,
+ bounding_sphere_center: [0.0; 3],
+ bounding_sphere_radius: 1.0,
+ slots: Vec::new(),
+ positions: Vec::new(),
+ normals: None,
+ uv0: None,
+ uv1: 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(1.0),
+ pose: Pose {
+ translation: [20.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(2.0),
+ pose: Pose {
+ translation: [30.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(9.0),
+ pose: Pose {
+ translation: [100.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ AnimKey24 {
+ time: AnimationTime(9.0),
+ pose: Pose {
+ translation: [300.0, 0.0, 0.0],
+ rotation: [0.0, 0.0, 0.0, 1.0],
+ },
+ },
+ ],
+ frame_map: vec![0, 1, 0, 2],
+ frame_count: 4,
+ }),
+ };
+
+ let hierarchy = node38_sampled_hierarchy_at_times(
+ &model,
+ &[Some(AnimationTime(1.0)), Some(AnimationTime(1.55))],
+ )
+ .expect("per-node fractional hierarchy");
+ // The root's tie selects map frame zero and interpolates to x=6. The
+ // child independently selects frame one and samples its own 1.55 time;
+ // its global pose includes the root translation.
+ assert!((hierarchy.poses[0].translation[0] - 6.0).abs() < f32::EPSILON);
+ assert!((hierarchy.poses[1].translation[0] - 31.5).abs() < 0.001);
+ let fallback = node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0)), None])
+ .expect("per-node fallback hierarchy");
+ assert_eq!(fallback.poses[1].translation[0], 306.0);
+ assert!(node38_sampled_hierarchy_at_times(&model, &[Some(AnimationTime(1.0))]).is_none());
+ for invalid in [-1.0, f32::NAN, f32::INFINITY] {
+ assert!(node38_sampled_hierarchy_at_times(
+ &model,
+ &[Some(AnimationTime(invalid)), None]
+ )
+ .is_none());
+ }
+ }
+
+ #[test]
fn type2_header_and_slot_tail_framing_are_exact() {
let too_small = decode_nested(&build_nres(&[
stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
@@ -1440,6 +1757,44 @@ mod tests {
}
#[test]
+ fn type2_header_bounding_sphere_is_preserved_and_rejects_invalid_values() {
+ let valid_slots = slots_payload_with_bounds([1.25, -2.5, 3.75], 4.5, &[]);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &valid_slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("nested");
+ let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
+ assert_eq!(model.bounding_sphere_center, [1.25, -2.5, 3.75]);
+ assert_eq!(model.bounding_sphere_radius, 4.5);
+
+ for (offset, value) in [
+ (0x60, f32::NAN),
+ (0x68, f32::INFINITY),
+ (0x6C, f32::NAN),
+ (0x6C, -1.0),
+ ] {
+ let mut slots = slots_payload(&[]);
+ slots[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots),
+ stream(STREAM_POSITIONS, 0, b"Res3", &[]),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ ]))
+ .expect("nested");
+ assert!(matches!(
+ validate_msh(&decode_msh(&document).expect("msh")),
+ Err(MshError::InvalidGeometry(_))
+ ));
+ }
+ }
+
+ #[test]
fn slot_batch_range_out_of_bounds_is_error() {
let slots = slots_payload(&[slot_record(1, 1, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0], 1.0)]);
let document = decode_nested(&build_nres(&[
@@ -1467,6 +1822,7 @@ mod tests {
(STREAM_POSITIONS, b"Res3".as_slice(), vec![0; 11]),
(STREAM_NORMALS, b"Res4".as_slice(), vec![0; 3]),
(STREAM_UV0, b"Res5".as_slice(), vec![0; 3]),
+ (STREAM_UV1, b"Res18".as_slice(), vec![0; 3]),
(STREAM_INDICES, b"Res6".as_slice(), vec![0; 1]),
] {
let slots = slots_payload(&[]);
@@ -1491,6 +1847,127 @@ mod tests {
}
#[test]
+ fn type18_secondary_uv_decodes_unsigned_pairs_for_each_source_vertex() {
+ let positions = positions_payload(&[[0.0, 0.0, 0.0]; 4]);
+ let mut uv1 = Vec::new();
+ for pair in [[2_u16, 5_u16], [12, 8], [16, 30], [34, 2]] {
+ push_u16(&mut uv1, pair[0]);
+ push_u16(&mut uv1, pair[1]);
+ }
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 12, b"Res3", &positions),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream_with_attr1(STREAM_UV1, 4, 4, b"Res18", &uv1),
+ ]))
+ .expect("nested");
+ let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
+
+ assert_eq!(model.positions.len(), 4);
+ assert_eq!(model.uv1, Some(vec![[2, 5], [12, 8], [16, 30], [34, 2]]));
+ }
+
+ #[test]
+ fn type18_secondary_uv_rejects_untrusted_count_or_stride() {
+ let positions = positions_payload(&[[0.0, 0.0, 0.0]; 1]);
+ for (attr1, attr3, expected) in [(1, 8, "stride"), (2, 4, "count")] {
+ let mut uv1 = Vec::new();
+ push_u16(&mut uv1, 2);
+ push_u16(&mut uv1, 5);
+ let document = decode_nested(&build_nres(&[
+ stream(STREAM_NODE_TABLE, 38, b"Res1", &[]),
+ stream(STREAM_SLOTS, 0, b"Res2", &slots_payload(&[])),
+ stream(STREAM_POSITIONS, 12, b"Res3", &positions),
+ stream(STREAM_INDICES, 0, b"Res6", &[]),
+ stream(STREAM_BATCHES, 0, b"Res13", &[]),
+ stream_with_attr1(STREAM_UV1, attr1, attr3, b"Res18", &uv1),
+ ]))
+ .expect("nested");
+ let err = validate_msh(&decode_msh(&document).expect("msh")).expect_err("invalid");
+ let message = err.to_string();
+ assert!(message.contains(expected), "{message}");
+ }
+ }
+
+ #[test]
+ fn installed_gog_secondary_uv_fixture_is_aligned_and_real() {
+ let Some(root) = std::env::var_os("FPARKAN_GAME_ROOT") else {
+ return;
+ };
+ let root = PathBuf::from(root);
+ assert!(
+ root.is_dir(),
+ "FPARKAN_GAME_ROOT is missing: {}",
+ root.display()
+ );
+
+ let mut models_with_uv1 = 0usize;
+ let mut fixture_found = false;
+ for path in files_under(&root) {
+ if !path
+ .extension()
+ .is_some_and(|extension| extension.eq_ignore_ascii_case("rlb"))
+ {
+ continue;
+ }
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ let Ok(archive) =
+ fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
+ else {
+ continue;
+ };
+ for entry in archive
+ .entries()
+ .iter()
+ .filter(|entry| has_msh_extension(entry.name_bytes()))
+ {
+ let payload = archive.payload(entry.id()).expect("payload");
+ let nested = fparkan_nres::decode(
+ Arc::from(payload.to_vec().into_boxed_slice()),
+ ReadProfile::Compatible,
+ )
+ .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
+ let msh = decode_msh(&nested)
+ .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
+ let model = validate_msh(&msh)
+ .unwrap_or_else(|err| panic!("{path:?} {:?}: {err}", entry.name_bytes()));
+ let Some(uv1) = model.uv1.as_ref() else {
+ continue;
+ };
+ models_with_uv1 += 1;
+ assert_eq!(
+ uv1.len(),
+ model.positions.len(),
+ "{path:?} {:?}",
+ entry.name_bytes()
+ );
+ let descriptor = msh
+ .streams()
+ .iter()
+ .find(|stream| stream.type_id == STREAM_UV1)
+ .expect("Res18 descriptor");
+ assert_eq!(descriptor.attributes.attr1 as usize, uv1.len());
+ assert_eq!(descriptor.attributes.attr3, 4);
+
+ if entry.name_bytes().eq_ignore_ascii_case(b"fr_l_gener.msh") {
+ assert_eq!(&uv1[..4], &[[2, 5], [12, 8], [16, 30], [34, 2]]);
+ fixture_found = true;
+ }
+ }
+ }
+
+ assert!(models_with_uv1 > 0, "FPARKAN_GAME_ROOT has no type18 MSH");
+ assert!(
+ fixture_found,
+ "FPARKAN_GAME_ROOT lacks fr_l_gener.msh fixture"
+ );
+ }
+
+ #[test]
fn batch20_uses_unaligned_field_offsets() {
let positions = positions_payload(&[[0.0, 0.0, 0.0]]);
let mut indices = Vec::new();
@@ -1507,12 +1984,13 @@ mod tests {
let model = validate_msh(&decode_msh(&document).expect("msh")).expect("model");
assert_eq!(model.batches[0].batch_flags, 0x1100);
- assert_eq!(model.batches[0].material_index, 0x2200);
- assert_eq!(model.batches[0].opaque4, 0x3300);
- assert_eq!(model.batches[0].opaque6, 0x4400);
+ assert_eq!(model.batches[0].material_index_hi, 0x2200);
+ assert_eq!(model.batches[0].material_index, 0x00);
+ assert_eq!(model.batches[0].lightmap_index, 0x33);
+ assert_eq!(model.batches[0].local_batch_index, 0x4400);
assert_eq!(model.batches[0].index_count, 1);
assert_eq!(model.batches[0].index_start, 0);
- assert_eq!(model.batches[0].opaque14, 0x5500);
+ assert_eq!(model.batches[0].vertex_count, 0x5500);
assert_eq!(model.batches[0].base_vertex, 0);
}
@@ -1606,44 +2084,49 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
- fn licensed_corpus_msh_assets_validate() {
- for (corpus, expected) in [("IS", 435_usize), ("IS2", 511_usize)] {
- let root = corpus_root(corpus);
- let mut count = 0usize;
- for path in files_under(&root) {
- let Ok(bytes) = std::fs::read(&path) else {
- continue;
- };
- let Ok(archive) = fparkan_nres::decode(
- Arc::from(bytes.into_boxed_slice()),
+ fn licensed_corpus_part1_msh_assets_validate() {
+ validate_licensed_corpus_msh_assets("IS", 435);
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_corpus_part2_msh_assets_validate() {
+ validate_licensed_corpus_msh_assets("IS2", 511);
+ }
+
+ fn validate_licensed_corpus_msh_assets(corpus: &str, expected: usize) {
+ let root = corpus_root(corpus);
+ let mut count = 0usize;
+ for path in files_under(&root) {
+ let Ok(bytes) = std::fs::read(&path) else {
+ continue;
+ };
+ let Ok(archive) =
+ fparkan_nres::decode(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
+ else {
+ continue;
+ };
+ for entry in archive
+ .entries()
+ .iter()
+ .filter(|entry| has_msh_extension(entry.name_bytes()))
+ {
+ let payload = archive.payload(entry.id()).expect("payload");
+ let nested = fparkan_nres::decode(
+ Arc::from(payload.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
- ) else {
- continue;
- };
- for entry in archive
- .entries()
- .iter()
- .filter(|entry| has_msh_extension(entry.name_bytes()))
- {
- let payload = archive.payload(entry.id()).expect("payload");
- let nested = fparkan_nres::decode(
- Arc::from(payload.to_vec().into_boxed_slice()),
- ReadProfile::Compatible,
- )
- .unwrap_or_else(|err| {
- panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
- });
- let msh = decode_msh(&nested).unwrap_or_else(|err| {
- panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
- });
- validate_msh(&msh).unwrap_or_else(|err| {
- panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
- });
- count += 1;
- }
+ )
+ .unwrap_or_else(|err| panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes()));
+ let msh = decode_msh(&nested).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ validate_msh(&msh).unwrap_or_else(|err| {
+ panic!("{corpus} {path:?} {:?}: {err}", entry.name_bytes())
+ });
+ count += 1;
}
- assert_eq!(count, expected, "{corpus} MSH count");
}
+ assert_eq!(count, expected, "{corpus} MSH count");
}
#[test]
@@ -1951,6 +2434,23 @@ mod tests {
fn stream<'a>(type_id: u32, attr3: u32, name: &'a [u8], payload: &'a [u8]) -> TestEntry<'a> {
TestEntry {
type_id,
+ attr1: 0,
+ attr3,
+ name,
+ payload,
+ }
+ }
+
+ fn stream_with_attr1<'a>(
+ type_id: u32,
+ attr1: u32,
+ attr3: u32,
+ name: &'a [u8],
+ payload: &'a [u8],
+ ) -> TestEntry<'a> {
+ TestEntry {
+ type_id,
+ attr1,
attr3,
name,
payload,
@@ -1959,6 +2459,7 @@ mod tests {
struct TestEntry<'a> {
type_id: u32,
+ attr1: u32,
attr3: u32,
name: &'a [u8],
payload: &'a [u8],
@@ -1977,7 +2478,7 @@ mod tests {
order.sort_by(|left, right| entries[*left].name.cmp(entries[*right].name));
for (idx, entry) in entries.iter().enumerate() {
push_u32(&mut out, entry.type_id);
- push_u32(&mut out, 0);
+ push_u32(&mut out, entry.attr1);
push_u32(&mut out, 0);
push_u32(
&mut out,
@@ -2019,7 +2520,19 @@ mod tests {
}
fn slots_payload(records: &[Vec<u8>]) -> Vec<u8> {
+ slots_payload_with_bounds([0.0; 3], 1.0, records)
+ }
+
+ fn slots_payload_with_bounds(center: [f32; 3], radius: f32, records: &[Vec<u8>]) -> Vec<u8> {
let mut out = vec![0; 0x8c];
+ for (offset, value) in [
+ (0x60, center[0]),
+ (0x64, center[1]),
+ (0x68, center[2]),
+ (0x6C, radius),
+ ] {
+ out[offset..offset + 4].copy_from_slice(&value.to_le_bytes());
+ }
for record in records {
assert_eq!(record.len(), 68);
out.extend_from_slice(record);
@@ -2065,7 +2578,6 @@ mod tests {
out
}
- #[allow(clippy::similar_names, clippy::too_many_arguments)]
fn batch_record(
batch_flags: u16,
material_index: u16,
diff --git a/crates/fparkan-nres/Cargo.toml b/crates/fparkan-nres/Cargo.toml
index 539bf16..ae507af 100644
--- a/crates/fparkan-nres/Cargo.toml
+++ b/crates/fparkan-nres/Cargo.toml
@@ -6,8 +6,5 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
-fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-binary = { path = "../fparkan-binary" }
+fparkan-path = { path = "../fparkan-path" }
diff --git a/crates/fparkan-nres/src/lib.rs b/crates/fparkan-nres/src/lib.rs
index e82ca08..56c4370 100644
--- a/crates/fparkan-nres/src/lib.rs
+++ b/crates/fparkan-nres/src/lib.rs
@@ -1,23 +1,4 @@
#![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::{checked_allocation_len, Cursor, DecodeError};
diff --git a/crates/fparkan-path/Cargo.toml b/crates/fparkan-path/Cargo.toml
index 57664b7..66749f2 100644
--- a/crates/fparkan-path/Cargo.toml
+++ b/crates/fparkan-path/Cargo.toml
@@ -6,6 +6,3 @@ license.workspace = true
repository.workspace = true
[dependencies]
-
-[lints]
-workspace = true
diff --git a/crates/fparkan-path/src/lib.rs b/crates/fparkan-path/src/lib.rs
index 8051b0f..efde47d 100644
--- a/crates/fparkan-path/src/lib.rs
+++ b/crates/fparkan-path/src/lib.rs
@@ -1,23 +1,4 @@
#![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;
diff --git a/crates/fparkan-platform/Cargo.toml b/crates/fparkan-platform/Cargo.toml
index 3f9d593..143ae1c 100644
--- a/crates/fparkan-platform/Cargo.toml
+++ b/crates/fparkan-platform/Cargo.toml
@@ -7,6 +7,3 @@ repository.workspace = true
[dependencies]
raw-window-handle = "0.6"
-
-[lints]
-workspace = true
diff --git a/crates/fparkan-platform/src/lib.rs b/crates/fparkan-platform/src/lib.rs
index c2a3a0f..04ea32d 100644
--- a/crates/fparkan-platform/src/lib.rs
+++ b/crates/fparkan-platform/src/lib.rs
@@ -1,23 +1,4 @@
#![forbid(unsafe_code)]
-#![cfg_attr(
- test,
- allow(
- clippy::cast_possible_truncation,
- clippy::cast_possible_wrap,
- clippy::cast_precision_loss,
- clippy::expect_used,
- clippy::float_cmp,
- clippy::identity_op,
- clippy::too_many_lines,
- clippy::uninlined_format_args,
- clippy::map_unwrap_or,
- clippy::needless_raw_string_hashes,
- clippy::semicolon_if_nothing_returned,
- clippy::type_complexity,
- clippy::panic,
- clippy::unwrap_used
- )
-)]
//! Platform ports for clocks, event sources and window descriptors.
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
diff --git a/crates/fparkan-prototype/Cargo.toml b/crates/fparkan-prototype/Cargo.toml
index 0426c23..fec907c 100644
--- a/crates/fparkan-prototype/Cargo.toml
+++ b/crates/fparkan-prototype/Cargo.toml
@@ -7,13 +7,10 @@ repository.workspace = true
[dependencies]
encoding_rs = "0.8"
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
-fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
-fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
-fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-binary = { path = "../fparkan-binary" }
+fparkan-path = { path = "../fparkan-path" }
+fparkan-resource = { path = "../fparkan-resource" }
+fparkan-vfs = { path = "../fparkan-vfs" }
[dev-dependencies]
-fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
+fparkan-nres = { path = "../fparkan-nres" }
diff --git a/crates/fparkan-prototype/src/lib.rs b/crates/fparkan-prototype/src/lib.rs
index f4540d4..dece041 100644
--- a/crates/fparkan-prototype/src/lib.rs
+++ b/crates/fparkan-prototype/src/lib.rs
@@ -1,23 +1,4 @@
#![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
- )
-)]
//! Prototype registry and unit DAT primitives.
use encoding_rs::WINDOWS_1251;
@@ -34,6 +15,9 @@ const UNIT_DAT_MIN_SIZE: usize = 0x48;
const UNIT_DAT_MAGIC: u32 = 0x0000_F0F1;
const PROTOTYPE_INHERITANCE_DEPTH_LIMIT: usize = 32;
+/// `objects.rlb` external geometry entry type (`EXTO`).
+pub const PROTOTYPE_TYPE_EXTO: u32 = u32::from_le_bytes(*b"EXTO");
+
/// Prototype key.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct PrototypeKey(pub ResourceName);
@@ -80,10 +64,21 @@ pub struct UnitComponentRecord {
pub parent_or_link: i32,
/// Description raw bytes.
pub description_raw: [u8; 32],
- /// Opaque tail.
+ /// Opaque record word at offset `0x68`.
pub tail0: u32,
- /// Opaque tail.
- pub tail1: u32,
+ /// Number of immediate children in the preorder unit tree.
+ pub immediate_child_count: u32,
+}
+
+/// One node in a decoded Unit DAT preorder tree.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct UnitComponentTreeNode {
+ /// Original preorder record index.
+ pub record_index: usize,
+ /// Parent record index, or `None` for the root.
+ pub parent_index: Option<usize>,
+ /// Direct child record indices in source order.
+ pub children: Vec<usize>,
}
/// Prototype geometry.
@@ -104,8 +99,18 @@ pub struct EffectivePrototype {
pub geometry: PrototypeGeometry,
/// Resolution source.
pub source: PrototypeSource,
+ /// Source registry entry type, when the prototype came from an NRes
+ /// object registry (for example `EXTO`, `INTO`, or `BTLU`).
+ pub source_type: Option<u32>,
/// Resource dependencies discovered while resolving this prototype.
pub dependencies: Vec<ResourceKey>,
+ /// Explicit control-data resource referenced by this prototype's resolved
+ /// `objects.rlb` registry inheritance chain, when present.
+ ///
+ /// This is deliberately separate from visual dependencies: controller
+ /// state belongs to each component instance and must not alter shared
+ /// mesh/material cache identity.
+ pub control_resource: Option<ResourceKey>,
}
/// Prototype resolution source.
@@ -420,6 +425,8 @@ pub enum PrototypeError {
InvalidSize,
/// Invalid unit DAT magic.
InvalidUnitDatMagic(u32),
+ /// Unit DAT child counts do not describe one complete preorder tree.
+ InvalidUnitHierarchy(String),
/// Invalid path.
InvalidPath(String),
/// VFS error.
@@ -454,6 +461,9 @@ impl std::fmt::Display for PrototypeError {
Self::InvalidUnitDatMagic(magic) => {
write!(f, "invalid unit DAT magic: {magic:#010X}")
}
+ Self::InvalidUnitHierarchy(message) => {
+ write!(f, "invalid unit DAT preorder hierarchy: {message}")
+ }
Self::InvalidPath(value) => write!(f, "invalid path: {value}"),
Self::Vfs(source) => write!(f, "vfs error: {source}"),
Self::Resource(source) => write!(f, "resource error: {source}"),
@@ -465,7 +475,10 @@ impl std::error::Error for PrototypeError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Decode(source) => Some(source),
- Self::InvalidSize | Self::InvalidUnitDatMagic(_) | Self::InvalidPath(_) => None,
+ Self::InvalidSize
+ | Self::InvalidUnitDatMagic(_)
+ | Self::InvalidUnitHierarchy(_)
+ | Self::InvalidPath(_) => None,
Self::Vfs(source) => Some(source),
Self::Resource(source) => Some(source),
}
@@ -531,7 +544,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
let parent_or_link = cursor.read_i32_le()?;
description_raw.copy_from_slice(cursor.read_exact(32)?);
let tail0 = cursor.read_u32_le()?;
- let tail1 = cursor.read_u32_le()?;
+ let immediate_child_count = cursor.read_u32_le()?;
records.push(UnitComponentRecord {
archive_raw,
resource_raw,
@@ -539,7 +552,7 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
parent_or_link,
description_raw,
tail0,
- tail1,
+ immediate_child_count,
});
}
cursor.require_eof()?;
@@ -549,6 +562,86 @@ pub fn decode_unit_dat(payload: &[u8]) -> Result<UnitDat, PrototypeError> {
})
}
+/// Builds the immediate-child tree encoded by Unit DAT record counts.
+///
+/// Records are stored in preorder. The final word of each 112-byte record is
+/// the number of immediate children, so the tree can be reconstructed without
+/// treating `parent_or_link` as a parent index. That field is retained as the
+/// native mount-socket value for attachment consumers.
+///
+/// # Errors
+///
+/// Returns [`PrototypeError::InvalidUnitHierarchy`] when child counts consume
+/// too few or too many records.
+pub fn unit_component_tree(
+ records: &[UnitComponentRecord],
+) -> Result<Vec<UnitComponentTreeNode>, PrototypeError> {
+ if records.is_empty() {
+ return Ok(Vec::new());
+ }
+
+ let root_child_count = usize::try_from(records[0].immediate_child_count).map_err(|_| {
+ PrototypeError::InvalidUnitHierarchy("record 0 child count does not fit usize".to_string())
+ })?;
+ let mut nodes = Vec::with_capacity(records.len());
+ nodes.push(UnitComponentTreeNode {
+ record_index: 0,
+ parent_index: None,
+ children: Vec::new(),
+ });
+ // Each open frame holds a preorder node and the direct children still due.
+ // This keeps hostile deep chains off the call stack.
+ let mut open_parents = vec![(0_usize, root_child_count)];
+ let mut cursor = 1_usize;
+ while cursor < records.len() {
+ while open_parents
+ .last()
+ .is_some_and(|(_, remaining_children)| *remaining_children == 0)
+ {
+ open_parents.pop();
+ }
+ let Some((parent_node_index, remaining_children)) = open_parents.last_mut() else {
+ return Err(PrototypeError::InvalidUnitHierarchy(format!(
+ "root tree consumed {cursor} records, but {} were encoded",
+ records.len()
+ )));
+ };
+ let parent_node_index = *parent_node_index;
+ *remaining_children -= 1;
+
+ let record_index = cursor;
+ let record = &records[record_index];
+ let child_count = usize::try_from(record.immediate_child_count).map_err(|_| {
+ PrototypeError::InvalidUnitHierarchy(format!(
+ "record {record_index} child count does not fit usize"
+ ))
+ })?;
+ let node_index = nodes.len();
+ nodes.push(UnitComponentTreeNode {
+ record_index,
+ parent_index: Some(nodes[parent_node_index].record_index),
+ children: Vec::new(),
+ });
+ nodes[parent_node_index].children.push(node_index);
+ cursor += 1;
+ open_parents.push((node_index, child_count));
+ }
+
+ while open_parents
+ .last()
+ .is_some_and(|(_, remaining_children)| *remaining_children == 0)
+ {
+ open_parents.pop();
+ }
+ if !open_parents.is_empty() {
+ return Err(PrototypeError::InvalidUnitHierarchy(format!(
+ "child count reached record {cursor}, but only {} records exist",
+ records.len()
+ )));
+ }
+ Ok(nodes)
+}
+
/// Decodes a mission unit DAT binding.
///
/// # Errors
@@ -824,7 +917,7 @@ pub fn build_prototype_graph(
///
/// This function reports per-root failures in [`PrototypeGraphReport`] instead
/// of returning early.
-#[allow(clippy::too_many_lines)]
+
pub fn build_prototype_graph_report(
repository: &dyn ResourceRepository,
vfs: &dyn Vfs,
@@ -1048,7 +1141,7 @@ fn resolve_archive_model(
let Some(mesh) = find_mesh_resource(repository, archive, model_key)? else {
return Ok(None);
};
- Ok(Some(effective(model_key.clone(), mesh, source)))
+ Ok(Some(effective(model_key.clone(), mesh, source, None, None)))
}
fn resolve_objects_registry_model(
@@ -1056,17 +1149,33 @@ fn resolve_objects_registry_model(
registry_archive: &NormalizedPath,
object_key: &ResourceName,
) -> Result<Option<EffectivePrototype>, PrototypeError> {
+ let source_type = match repository.open_archive(registry_archive) {
+ Ok(archive_id) => {
+ find_any_candidate(repository, archive_id, &mesh_name_candidates(&object_key.0))?
+ .map(|(entry, _)| repository.entry_info(entry))
+ .transpose()?
+ .and_then(|info| info.key.type_id)
+ }
+ Err(ResourceError::MissingArchive { .. }) => return Ok(None),
+ Err(error) => return Err(error.into()),
+ };
let Some(refs) =
collect_registry_refs(repository, registry_archive, object_key, &mut Vec::new(), 0)?
else {
return Ok(None);
};
+ let control_resource = resolve_explicit_control_resource(object_key, &refs)?;
let mut missing_mesh_refs = Vec::new();
for item in refs.iter().filter(|item| is_explicit_mesh_ref(item)) {
- if let Some(prototype) =
- resolve_object_ref_model(repository, object_key, item, cstr_bytes(&item.resource_raw))?
- {
+ if let Some(prototype) = resolve_object_ref_model(
+ repository,
+ object_key,
+ item,
+ cstr_bytes(&item.resource_raw),
+ source_type,
+ control_resource.clone(),
+ )? {
return Ok(Some(prototype));
}
missing_mesh_refs.push(describe_object_ref(item));
@@ -1083,7 +1192,9 @@ fn resolve_objects_registry_model(
key: PrototypeKey(object_key.clone()),
geometry: PrototypeGeometry::NonGeometric,
source: PrototypeSource::ObjectsRegistry,
+ source_type,
dependencies: Vec::new(),
+ control_resource,
}))
}
@@ -1149,6 +1260,8 @@ fn resolve_object_ref_model(
requested: &ResourceName,
item: &ObjectRefRecord,
model_name: &[u8],
+ source_type: Option<u32>,
+ control_resource: Option<ResourceKey>,
) -> Result<Option<EffectivePrototype>, PrototypeError> {
let archive = normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?;
let Some(mesh) = find_mesh_resource(repository, &archive, &ResourceName(model_name.to_vec()))?
@@ -1159,6 +1272,8 @@ fn resolve_object_ref_model(
requested.clone(),
mesh,
PrototypeSource::ObjectsRegistry,
+ source_type,
+ control_resource,
)))
}
@@ -1166,6 +1281,61 @@ fn is_explicit_mesh_ref(item: &ObjectRefRecord) -> bool {
has_extension_bytes(cstr_bytes(&item.resource_raw), b"msh")
}
+fn is_explicit_control_ref(item: &ObjectRefRecord) -> bool {
+ has_extension_bytes(cstr_bytes(&item.resource_raw), b"ctl")
+}
+
+fn resolve_explicit_control_resource(
+ requested: &ResourceName,
+ refs: &[ObjectRefRecord],
+) -> Result<Option<ResourceKey>, PrototypeError> {
+ let mut resolved = Vec::new();
+ for item in refs.iter().filter(|item| is_explicit_control_ref(item)) {
+ let archive =
+ normalized_path_from_name(&ResourceName(cstr_bytes(&item.archive_raw).to_vec()))?;
+ let name = resource_name(cstr_bytes(&item.resource_raw));
+ // CTLD is an optional pose sidecar. Keep the exact registry reference
+ // here, then let asset preparation treat a missing archive/entry as an
+ // absent override while still rejecting a present malformed payload.
+ let key = ResourceKey {
+ archive,
+ name,
+ type_id: None,
+ };
+ if !resolved
+ .iter()
+ .any(|existing| same_resource_key(existing, &key))
+ {
+ resolved.push(key);
+ }
+ }
+ match resolved.len() {
+ 0 => Ok(None),
+ 1 => Ok(resolved.pop()),
+ _ => Err(PrototypeError::Resource(ResourceError::Format(format!(
+ "prototype {} has ambiguous explicit control resources: {}",
+ String::from_utf8_lossy(&requested.0),
+ resolved
+ .iter()
+ .map(|key| format!(
+ "{}:{}",
+ key.archive.as_str(),
+ String::from_utf8_lossy(&key.name.0)
+ ))
+ .collect::<Vec<_>>()
+ .join(", "),
+ )))),
+ }
+}
+
+fn same_resource_key(left: &ResourceKey, right: &ResourceKey) -> bool {
+ left.archive
+ .as_str()
+ .eq_ignore_ascii_case(right.archive.as_str())
+ && eq_ignore_ascii_case(&left.name.0, &right.name.0)
+ && left.type_id == right.type_id
+}
+
fn describe_object_ref(item: &ObjectRefRecord) -> String {
format!(
"{}:{}",
@@ -1214,12 +1384,16 @@ fn effective(
requested: ResourceName,
mesh: ResourceKey,
source: PrototypeSource,
+ source_type: Option<u32>,
+ control_resource: Option<ResourceKey>,
) -> EffectivePrototype {
EffectivePrototype {
key: PrototypeKey(requested),
geometry: PrototypeGeometry::Mesh(mesh.clone()),
source,
+ source_type,
dependencies: vec![mesh],
+ control_resource,
}
}
@@ -1399,7 +1573,7 @@ mod tests {
assert_eq!(record.parent_or_link, -7);
assert_eq!(&record.description_raw[..description.len()], description);
assert_eq!(record.tail0, 0x1122_3344);
- assert_eq!(record.tail1, 0x5566_7788);
+ assert_eq!(record.immediate_child_count, 0x5566_7788);
}
#[test]
@@ -1426,6 +1600,88 @@ mod tests {
}
#[test]
+ fn unit_component_tree_follows_preorder_child_counts_and_keeps_mount_links() {
+ let mut bytes = build_unit_dat(&[
+ (b"objects.rlb".as_slice(), b"root".as_slice()),
+ (b"objects.rlb".as_slice(), b"child".as_slice()),
+ (b"objects.rlb".as_slice(), b"grandchild".as_slice()),
+ ]);
+ bytes[8 + 68..8 + 72].copy_from_slice(&(-1_i32).to_le_bytes());
+ bytes[8 + 108..8 + 112].copy_from_slice(&1_u32.to_le_bytes());
+ bytes[8 + 112 + 68..8 + 112 + 72].copy_from_slice(&7_i32.to_le_bytes());
+ bytes[8 + 112 + 108..8 + 112 + 112].copy_from_slice(&1_u32.to_le_bytes());
+ bytes[8 + 224 + 68..8 + 224 + 72].copy_from_slice(&3_i32.to_le_bytes());
+
+ let unit = decode_unit_dat(&bytes).expect("unit");
+ let tree = unit_component_tree(&unit.records).expect("preorder tree");
+ assert_eq!(tree.len(), 3);
+ assert_eq!(tree[0].record_index, 0);
+ assert_eq!(tree[0].parent_index, None);
+ assert_eq!(tree[0].children, vec![1]);
+ assert_eq!(tree[1].parent_index, Some(0));
+ assert_eq!(tree[1].children, vec![2]);
+ assert_eq!(tree[2].parent_index, Some(1));
+ assert_eq!(tree[2].children, Vec::<usize>::new());
+ assert_eq!(unit.records[1].parent_or_link, 7);
+ assert_eq!(unit.records[2].parent_or_link, 3);
+ }
+
+ #[test]
+ fn unit_component_tree_handles_deep_preorder_chains_iteratively() {
+ const DEPTH: usize = 32_768;
+ let records: Vec<_> = (0..DEPTH)
+ .map(|index| UnitComponentRecord {
+ archive_raw: [0; 32],
+ resource_raw: [0; 32],
+ kind: 0,
+ parent_or_link: 0,
+ description_raw: [0; 32],
+ tail0: 0,
+ immediate_child_count: u32::from(index + 1 < DEPTH),
+ })
+ .collect();
+
+ let tree = unit_component_tree(&records).expect("deep preorder chain");
+
+ assert_eq!(tree.len(), DEPTH);
+ assert_eq!(tree[0].parent_index, None);
+ assert_eq!(tree[0].children, vec![1]);
+ assert_eq!(tree[DEPTH / 2].parent_index, Some(DEPTH / 2 - 1));
+ assert_eq!(tree[DEPTH - 2].children, vec![DEPTH - 1]);
+ assert_eq!(tree[DEPTH - 1].parent_index, Some(DEPTH - 2));
+ assert!(tree[DEPTH - 1].children.is_empty());
+ }
+
+ #[test]
+ fn unit_component_tree_rejects_missing_child_records() {
+ let bytes = build_unit_dat(&[
+ (b"objects.rlb".as_slice(), b"root".as_slice()),
+ (b"objects.rlb".as_slice(), b"child".as_slice()),
+ ]);
+ let mut unit = decode_unit_dat(&bytes).expect("unit");
+ unit.records[0].immediate_child_count = 2;
+
+ assert!(matches!(
+ unit_component_tree(&unit.records),
+ Err(PrototypeError::InvalidUnitHierarchy(message))
+ if message.contains("child count reached record 2")
+ ));
+ }
+
+ #[test]
+ fn unit_component_tree_rejects_unconsumed_preorder_records() {
+ let bytes = build_unit_dat(&[
+ (b"objects.rlb".as_slice(), b"root".as_slice()),
+ (b"objects.rlb".as_slice(), b"child".as_slice()),
+ ]);
+ let unit = decode_unit_dat(&bytes).expect("unit");
+ assert!(matches!(
+ unit_component_tree(&unit.records),
+ Err(PrototypeError::InvalidUnitHierarchy(_))
+ ));
+ }
+
+ #[test]
fn resolves_synthetic_objects_registry_model() {
let mut vfs = MemoryVfs::default();
let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
@@ -1455,6 +1711,7 @@ mod tests {
.expect("prototype");
assert_eq!(resolved.source, PrototypeSource::ObjectsRegistry);
+ assert!(resolved.control_resource.is_none());
let PrototypeGeometry::Mesh(mesh) = resolved.geometry else {
panic!("expected mesh");
};
@@ -1874,10 +2131,10 @@ mod tests {
build_nres(&[
(
b"parent_proto".as_slice(),
- build_object_refs(&[(
- b"static.rlb".as_slice(),
- b"parent_proto.msh".as_slice(),
- )])
+ build_object_refs(&[
+ (b"static.rlb".as_slice(), b"parent_proto.msh".as_slice()),
+ (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
+ ])
.as_slice(),
),
(
@@ -1885,6 +2142,7 @@ mod tests {
build_object_refs(&[
(b"objects.rlb".as_slice(), b"parent_proto".as_slice()),
(b"fortif.rlb".as_slice(), b"child_proto.bas".as_slice()),
+ (b"fortif.rlb".as_slice(), b"parent_proto.ctl".as_slice()),
])
.as_slice(),
),
@@ -1900,7 +2158,13 @@ mod tests {
);
vfs.insert(
fortif_path,
- Arc::from(build_nres(&[(b"child_proto.bas".as_slice(), b"base")]).into_boxed_slice()),
+ Arc::from(
+ build_nres(&[
+ (b"child_proto.bas".as_slice(), b"base"),
+ (b"parent_proto.ctl".as_slice(), b"control"),
+ ])
+ .into_boxed_slice(),
+ ),
);
let vfs = Arc::new(vfs);
let repo = CachedResourceRepository::new(vfs.clone());
@@ -1914,6 +2178,108 @@ mod tests {
};
assert_eq!(mesh.archive.as_str(), "static.rlb");
assert!(mesh.name.0.eq_ignore_ascii_case(b"parent_proto.msh"));
+ let control = resolved.control_resource.expect("inherited explicit CTL");
+ assert_eq!(control.archive.as_str(), "fortif.rlb");
+ assert!(control.name.0.eq_ignore_ascii_case(b"parent_proto.ctl"));
+ }
+
+ #[test]
+ fn objects_registry_retains_missing_explicit_control_metadata() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"optional_control".as_slice(),
+ build_object_refs(&[
+ (b"static.rlb".as_slice(), b"optional_control.msh".as_slice()),
+ (
+ b"missing.rlb".as_slice(),
+ b"optional_control.ctl".as_slice(),
+ ),
+ ])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"optional_control.msh".as_slice(), mesh.as_slice())])
+ .into_boxed_slice(),
+ ),
+ );
+
+ let vfs = Arc::new(vfs);
+ let repository = CachedResourceRepository::new(vfs.clone());
+ let resolved = resolve_prototype_single(
+ &repository,
+ vfs.as_ref(),
+ &resource_name(b"optional_control"),
+ )
+ .expect("resolve despite absent optional CTL")
+ .expect("prototype");
+
+ let control = resolved
+ .control_resource
+ .expect("preserve explicit missing CTL reference");
+ assert_eq!(control.archive.as_str(), "missing.rlb");
+ assert_eq!(control.name, resource_name(b"optional_control.ctl"));
+ assert_eq!(control.type_id, None);
+ }
+
+ #[test]
+ fn objects_registry_rejects_distinct_explicit_controls() {
+ let mut vfs = MemoryVfs::default();
+ let objects_path = resource_archive_path(b"objects.rlb").expect("objects path");
+ let static_path = resource_archive_path(b"static.rlb").expect("static path");
+ let controls_path = resource_archive_path(b"controls.rlb").expect("controls path");
+ let mesh = minimal_msh_payload();
+ vfs.insert(
+ objects_path,
+ Arc::from(
+ build_nres(&[(
+ b"prototype".as_slice(),
+ build_object_refs(&[
+ (b"static.rlb".as_slice(), b"prototype.msh".as_slice()),
+ (b"controls.rlb".as_slice(), b"first.ctl".as_slice()),
+ (b"controls.rlb".as_slice(), b"second.ctl".as_slice()),
+ ])
+ .as_slice(),
+ )])
+ .into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ static_path,
+ Arc::from(
+ build_nres(&[(b"prototype.msh".as_slice(), mesh.as_slice())]).into_boxed_slice(),
+ ),
+ );
+ vfs.insert(
+ controls_path,
+ Arc::from(
+ build_nres(&[
+ (b"first.ctl".as_slice(), b"first control"),
+ (b"second.ctl".as_slice(), b"second control"),
+ ])
+ .into_boxed_slice(),
+ ),
+ );
+ let vfs = Arc::new(vfs);
+ let repo = CachedResourceRepository::new(vfs.clone());
+ let error = resolve_prototype_single(&repo, vfs.as_ref(), &resource_name(b"prototype"))
+ .expect_err("distinct explicit controls are ambiguous");
+
+ assert!(matches!(
+ error,
+ PrototypeError::Resource(ResourceError::Format(message))
+ if message.contains("ambiguous explicit control resources")
+ ));
}
#[test]
diff --git a/crates/fparkan-render/Cargo.toml b/crates/fparkan-render/Cargo.toml
index f28496a..9485140 100644
--- a/crates/fparkan-render/Cargo.toml
+++ b/crates/fparkan-render/Cargo.toml
@@ -6,7 +6,3 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
-
-[lints]
-workspace = true
diff --git a/crates/fparkan-render/src/lib.rs b/crates/fparkan-render/src/lib.rs
index 96ccc2a..08a285d 100644
--- a/crates/fparkan-render/src/lib.rs
+++ b/crates/fparkan-render/src/lib.rs
@@ -1,26 +1,5 @@
#![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;
+//! Camera mathematics and graphics pipeline state.
/// A 64-byte transform block returned by the original Terrain camera ABI.
///
@@ -217,7 +196,7 @@ impl LegacyD3d7Projection {
// 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();
@@ -338,115 +317,9 @@ impl LegacyIron3dEulerTransform {
}
}
-/// 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 {
- /// View matrix, row-major.
- 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 {
- fn default() -> Self {
- Self {
- view: identity_transform(),
- projection: identity_transform(),
- raw_pose: None,
- raw_projection: None,
- }
- }
-}
-
-/// Draw id.
-#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
-pub struct DrawId(pub u64);
-
-/// GPU mesh id.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct GpuMeshId(pub u64);
-
-/// GPU material id.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct GpuMaterialId(pub u64);
-
-/// Render phase.
-#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
-pub enum RenderPhase {
- /// Terrain.
- Terrain,
- /// Opaque.
- Opaque,
- /// Alpha test.
- AlphaTest,
- /// Transparent.
- Transparent,
- /// Effects.
- Effects,
- /// Debug.
- Debug,
- /// UI.
- Ui,
-}
-
/// Fixed-function blend behaviour represented without a graphics API type.
///
-/// This is a compatibility contract, not yet a decoded MAT0 mapping.
+/// This is the compatibility contract for the native MAT0 pipeline category.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum LegacyBlendMode {
/// Do not blend the fragment with the existing colour.
@@ -454,6 +327,14 @@ pub enum LegacyBlendMode {
Opaque,
/// Blend using source alpha.
SourceAlpha,
+ /// Add the source-alpha-scaled fragment to the existing colour.
+ Additive,
+ /// Replace the source factor with zero and use source colour as the
+ /// destination factor.
+ ZeroSourceColor,
+ /// Multiply the source by destination colour and destination by source
+ /// colour, matching the native two-colour blend mode.
+ DestColorSourceColor,
}
/// Depth-buffer behaviour represented without a graphics API type.
@@ -496,10 +377,36 @@ pub struct LegacyPipelineState {
pub alpha_test: bool,
}
+impl LegacyPipelineState {
+ /// Applies the native MAT0 pipeline category while retaining the caller's
+ /// depth and cull state.
+ ///
+ /// Categories 0..4 are the complete table recovered from the native
+ /// material path. The native table enables `GREATER_EQUAL` alpha testing
+ /// for categories 1..4; the alpha reference remains per draw range.
+ #[must_use]
+ pub const fn with_material_category(self, category: u8) -> Option<Self> {
+ let (blend, alpha_test) = match category {
+ 0 => (LegacyBlendMode::Opaque, false),
+ 1 => (LegacyBlendMode::SourceAlpha, true),
+ 2 => (LegacyBlendMode::Additive, true),
+ 3 => (LegacyBlendMode::ZeroSourceColor, true),
+ 4 => (LegacyBlendMode::DestColorSourceColor, true),
+ _ => return None,
+ };
+ Some(Self {
+ blend,
+ alpha_test,
+ ..self
+ })
+ }
+}
+
/// 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.
+/// remain stable across processes and Rust toolchain updates. Three bits are
+/// reserved for the five native material blend modes.
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub struct PipelineKey(u8);
@@ -516,6 +423,9 @@ impl From<LegacyPipelineState> for PipelineKey {
let blend = match state.blend {
LegacyBlendMode::Opaque => 0,
LegacyBlendMode::SourceAlpha => 1,
+ LegacyBlendMode::Additive => 2,
+ LegacyBlendMode::ZeroSourceColor => 3,
+ LegacyBlendMode::DestColorSourceColor => 4,
};
let depth = match state.depth {
LegacyDepthMode::Disabled => 0,
@@ -527,548 +437,8 @@ impl From<LegacyPipelineState> for PipelineKey {
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 {
- /// Start.
- pub start: u32,
- /// Count.
- pub count: u32,
-}
-
-/// A draw candidate in an immutable render snapshot.
-#[derive(Clone, Debug, PartialEq)]
-pub struct RenderSnapshotDraw {
- /// Draw id.
- pub id: DrawId,
- /// Phase.
- pub phase: RenderPhase,
- /// Object id.
- pub object_id: Option<OriginalObjectId>,
- /// Mesh.
- pub mesh: GpuMeshId,
- /// Material table after WEAR/MAT0 fallback resolution.
- 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.
- pub range: IndexRange,
- /// Stable sort order.
- pub stable_order: u64,
-}
-
-/// Immutable backend-neutral render snapshot.
-#[derive(Clone, Debug, Default, PartialEq)]
-pub struct RenderSnapshot {
- /// Camera data for the frame.
- pub camera: CameraSnapshot,
- /// Draw candidates gathered from world/assets.
- pub draws: Vec<RenderSnapshotDraw>,
-}
-
-/// Command generation profile.
-#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
-pub struct RenderProfile {
- /// Include UI phase commands when present.
- pub include_ui: bool,
-}
-
-/// Draw command.
-#[derive(Clone, Debug, PartialEq)]
-pub struct DrawCommand {
- /// Draw id.
- pub id: DrawId,
- /// Phase.
- pub phase: RenderPhase,
- /// Object id.
- pub object_id: Option<OriginalObjectId>,
- /// Mesh.
- 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.
- pub range: IndexRange,
- /// Stable sort order.
- pub stable_order: u64,
-}
-
-/// Render command.
-#[derive(Clone, Debug, PartialEq)]
-pub enum RenderCommand {
- /// Begin frame.
- BeginFrame,
- /// Draw.
- Draw(DrawCommand),
- /// End frame.
- EndFrame,
-}
-
-/// Render command list.
-#[derive(Clone, Debug, Default, PartialEq)]
-pub struct RenderCommandList {
- /// Commands.
- pub commands: Vec<RenderCommand>,
-}
-
-/// Optional render command validation limits.
-#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
-pub struct RenderValidationLimits {
- /// Exclusive upper bound for GPU mesh ids.
- pub mesh_count: Option<u64>,
- /// Exclusive upper bound for index ranges.
- pub index_count: Option<u32>,
-}
-
-/// Frame output.
-#[derive(Clone, Debug, Default, Eq, PartialEq)]
-pub struct FrameOutput;
-
-/// Render error.
-#[derive(Debug)]
-pub enum RenderError {
- /// Invalid range.
- InvalidRange,
- /// Invalid command stream framing or ordering.
- InvalidCommandStream {
- /// Command index.
- index: usize,
- /// Contextual error message.
- message: &'static str,
- },
- /// Invalid draw range with command-generation context.
- InvalidDrawRange {
- /// Draw id.
- draw_id: DrawId,
- /// Stable sort order.
- stable_order: u64,
- /// Range start.
- start: u32,
- /// Range count.
- count: u32,
- },
- /// Index range arithmetic overflow.
- IndexRangeOverflow {
- /// Draw id.
- draw_id: DrawId,
- /// Range start.
- start: u32,
- /// Range count.
- count: u32,
- },
- /// Index range exceeds validation limits.
- IndexRangeOutOfBounds {
- /// Draw id.
- draw_id: DrawId,
- /// Exclusive index limit.
- index_count: u32,
- /// Range end.
- end: u32,
- },
- /// Mesh id exceeds validation limits.
- MeshOutOfBounds {
- /// Draw id.
- draw_id: DrawId,
- /// Mesh id.
- mesh: GpuMeshId,
- /// Exclusive mesh limit.
- mesh_count: u64,
- },
- /// Draw transform contains a non-finite value.
- NonFiniteTransform {
- /// Draw id.
- draw_id: DrawId,
- /// Matrix element index.
- element: usize,
- },
- /// Draw commands are not ordered by phase, stable order and draw id.
- PhaseOrderViolation {
- /// Draw id.
- draw_id: DrawId,
- /// Previous phase.
- previous: RenderPhase,
- /// Current phase.
- current: RenderPhase,
- },
- /// A batch material index did not resolve through the material table.
- MaterialIndexOutOfBounds {
- /// Draw id.
- draw_id: DrawId,
- /// Requested material index.
- material_index: u16,
- /// Available material slots.
- material_count: usize,
- },
-}
-
-impl std::fmt::Display for RenderError {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- match self {
- Self::InvalidRange => write!(f, "render command contains an empty index range"),
- Self::InvalidCommandStream { index, message } => {
- write!(
- f,
- "render command stream is invalid at command {index}: {message}"
- )
- }
- Self::InvalidDrawRange {
- draw_id,
- stable_order,
- start,
- count,
- } => write!(
- f,
- "draw {} has invalid index range start={} count={} at stable order {}",
- draw_id.0, start, count, stable_order
- ),
- Self::IndexRangeOverflow {
- draw_id,
- start,
- count,
- } => write!(
- f,
- "draw {} index range overflows start={} count={}",
- draw_id.0, start, count
- ),
- Self::IndexRangeOutOfBounds {
- draw_id,
- index_count,
- end,
- } => write!(
- f,
- "draw {} index range ends at {} but mesh has {} indices",
- draw_id.0, end, index_count
- ),
- Self::MeshOutOfBounds {
- draw_id,
- mesh,
- mesh_count,
- } => write!(
- f,
- "draw {} references mesh {} but only {} meshes are available",
- draw_id.0, mesh.0, mesh_count
- ),
- Self::NonFiniteTransform { draw_id, element } => write!(
- f,
- "draw {} has non-finite transform element {}",
- draw_id.0, element
- ),
- Self::PhaseOrderViolation {
- draw_id,
- previous,
- current,
- } => write!(
- f,
- "draw {} phase order regressed from {:?} to {:?}",
- draw_id.0, previous, current
- ),
- Self::MaterialIndexOutOfBounds {
- draw_id,
- material_index,
- material_count,
- } => write!(
- f,
- "draw {} references material index {} but only {} material slots are available",
- draw_id.0, material_index, material_count
- ),
- }
- }
-}
-
-impl std::error::Error for RenderError {}
-
-/// Builds a deterministic command list from an immutable render snapshot.
-///
-/// # Errors
-///
-/// Returns [`RenderError`] when a draw has an invalid index range or a material
-/// index that cannot be resolved through its material slot table.
-pub fn build_commands(
- snapshot: &RenderSnapshot,
- profile: RenderProfile,
-) -> Result<RenderCommandList, RenderError> {
- let mut draws = snapshot
- .draws
- .iter()
- .filter(|draw| profile.include_ui || draw.phase != RenderPhase::Ui)
- .collect::<Vec<_>>();
- draws.sort_by_key(|draw| (draw.phase, draw.stable_order, draw.id));
-
- let mut commands = Vec::with_capacity(draws.len() + 2);
- commands.push(RenderCommand::BeginFrame);
- for draw in draws {
- if draw.range.count == 0 {
- return Err(RenderError::InvalidDrawRange {
- draw_id: draw.id,
- stable_order: draw.stable_order,
- start: draw.range.start,
- count: draw.range.count,
- });
- }
- validate_index_range(draw.id, draw.range)?;
- validate_transform(draw.id, &draw.transform)?;
- let material = draw
- .material_slots
- .get(usize::from(draw.material_index))
- .copied()
- .ok_or(RenderError::MaterialIndexOutOfBounds {
- draw_id: draw.id,
- material_index: draw.material_index,
- material_count: draw.material_slots.len(),
- })?;
- commands.push(RenderCommand::Draw(DrawCommand {
- id: draw.id,
- phase: draw.phase,
- 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,
- }));
- }
- commands.push(RenderCommand::EndFrame);
- Ok(RenderCommandList { commands })
-}
-
-/// Backend port.
-pub trait RenderBackend {
- /// Executes commands.
- ///
- /// # Errors
- ///
- /// Returns [`RenderError`] when the command stream is malformed for the
- /// backend.
- 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;
-
-impl RenderBackend for NullBackend {
- fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
- validate_command_list(commands)?;
- Ok(FrameOutput)
- }
-}
-
-/// Backend that stores deterministic command captures for verification.
-#[derive(Clone, Debug, Default)]
-pub struct RecordingBackend {
- captures: Vec<Vec<u8>>,
-}
-
-impl RecordingBackend {
- /// Returns all captures in submission order.
- #[must_use]
- pub fn captures(&self) -> &[Vec<u8>] {
- &self.captures
- }
-
- /// Returns the most recent capture.
- #[must_use]
- pub fn last_capture(&self) -> Option<&[u8]> {
- self.captures.last().map(Vec::as_slice)
- }
-
- /// Clears stored captures without changing backend behavior.
- pub fn clear(&mut self) {
- self.captures.clear();
- }
-}
-
-impl RenderBackend for RecordingBackend {
- fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
- let capture = canonical_capture(commands)?;
- self.captures.push(capture);
- Ok(FrameOutput)
- }
-}
-
-/// Builds a canonical capture.
-///
-/// # Errors
-///
-/// Returns [`RenderError`] when a draw command contains an invalid index range.
-pub fn canonical_capture(commands: &RenderCommandList) -> Result<Vec<u8>, RenderError> {
- validate_command_list(commands)?;
- let mut out = Vec::new();
- for command in &commands.commands {
- match command {
- RenderCommand::BeginFrame => out.extend_from_slice(b"B\n"),
- RenderCommand::EndFrame => out.extend_from_slice(b"E\n"),
- RenderCommand::Draw(draw) => {
- out.extend_from_slice(
- format!(
- "D,{:?},{},{},{},{},{}\n",
- draw.phase,
- draw.id.0,
- draw.mesh.0,
- draw.material.0,
- draw.pipeline_key.packed(),
- draw.stable_order
- )
- .as_bytes(),
- );
- }
- }
- }
- Ok(out)
-}
-
-/// Validates a render command list without backend-specific resource limits.
-///
-/// # Errors
-///
-/// Returns [`RenderError`] when framing, ordering or draw data is invalid.
-pub fn validate_command_list(commands: &RenderCommandList) -> Result<(), RenderError> {
- validate_command_list_with_limits(commands, RenderValidationLimits::default())
-}
-
-/// Validates a render command list with optional backend resource limits.
-///
-/// # Errors
-///
-/// Returns [`RenderError`] when framing, ordering, draw data or resource bounds
-/// are invalid.
-pub fn validate_command_list_with_limits(
- commands: &RenderCommandList,
- limits: RenderValidationLimits,
-) -> Result<(), RenderError> {
- let Some(first) = commands.commands.first() else {
- return Err(RenderError::InvalidCommandStream {
- index: 0,
- message: "empty command list",
- });
- };
- if !matches!(first, RenderCommand::BeginFrame) {
- return Err(RenderError::InvalidCommandStream {
- index: 0,
- message: "first command must be BeginFrame",
- });
- }
- if commands.commands.len() < 2 {
- return Err(RenderError::InvalidCommandStream {
- index: 0,
- message: "frame must end with EndFrame",
- });
- }
- let end_index = commands.commands.len() - 1;
- if !matches!(commands.commands[end_index], RenderCommand::EndFrame) {
- return Err(RenderError::InvalidCommandStream {
- index: end_index,
- message: "last command must be EndFrame",
- });
- }
-
- let mut previous_key: Option<(RenderPhase, u64, DrawId)> = None;
- for (index, command) in commands.commands.iter().enumerate() {
- match command {
- RenderCommand::BeginFrame if index == 0 => {}
- RenderCommand::BeginFrame => {
- return Err(RenderError::InvalidCommandStream {
- index,
- message: "nested BeginFrame is not allowed",
- });
- }
- RenderCommand::EndFrame if index == end_index => {}
- RenderCommand::EndFrame => {
- return Err(RenderError::InvalidCommandStream {
- index,
- message: "EndFrame before final command is not allowed",
- });
- }
- RenderCommand::Draw(draw) => {
- validate_draw_command(draw, limits)?;
- let key = (draw.phase, draw.stable_order, draw.id);
- if let Some(previous) = previous_key {
- if key < previous {
- return Err(RenderError::PhaseOrderViolation {
- draw_id: draw.id,
- previous: previous.0,
- current: draw.phase,
- });
- }
- }
- previous_key = Some(key);
- }
- }
- }
- Ok(())
-}
-
-fn validate_draw_command(
- draw: &DrawCommand,
- limits: RenderValidationLimits,
-) -> Result<(), RenderError> {
- if draw.range.count == 0 {
- return Err(RenderError::InvalidRange);
- }
- let end = validate_index_range(draw.id, draw.range)?;
- validate_transform(draw.id, &draw.transform)?;
- if let Some(mesh_count) = limits.mesh_count {
- if draw.mesh.0 >= mesh_count {
- return Err(RenderError::MeshOutOfBounds {
- draw_id: draw.id,
- mesh: draw.mesh,
- mesh_count,
- });
- }
- }
- if let Some(index_count) = limits.index_count {
- if end > index_count {
- return Err(RenderError::IndexRangeOutOfBounds {
- draw_id: draw.id,
- index_count,
- end,
- });
- }
- }
- Ok(())
-}
-
-fn validate_index_range(draw_id: DrawId, range: IndexRange) -> Result<u32, RenderError> {
- range
- .start
- .checked_add(range.count)
- .ok_or(RenderError::IndexRangeOverflow {
- draw_id,
- start: range.start,
- count: range.count,
- })
-}
-
-fn validate_transform(draw_id: DrawId, transform: &[f32; 16]) -> Result<(), RenderError> {
- for (element, value) in transform.iter().enumerate() {
- if !value.is_finite() {
- return Err(RenderError::NonFiniteTransform { draw_id, element });
- }
+ Self(blend | (depth << 3) | (cull << 5) | (u8::from(state.alpha_test) << 7))
}
- Ok(())
-}
-
-fn identity_transform() -> [f32; 16] {
- [
- 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
- ]
}
#[cfg(test)]
@@ -1098,49 +468,6 @@ mod tests {
}
#[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,
@@ -1260,454 +587,48 @@ mod tests {
);
}
- fn snapshot_draw(
- id: u64,
- phase: RenderPhase,
- material_index: u16,
- stable_order: u64,
- ) -> RenderSnapshotDraw {
- RenderSnapshotDraw {
- id: DrawId(id),
- phase,
- object_id: Some(OriginalObjectId(u32::try_from(id).expect("id fits"))),
- 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,
- }
- }
-
#[test]
- fn capture_is_stable() {
- let list = RenderCommandList {
- commands: vec![
- RenderCommand::BeginFrame,
- RenderCommand::Draw(DrawCommand {
- id: DrawId(1),
- phase: RenderPhase::Opaque,
- 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,
- }),
- RenderCommand::EndFrame,
- ],
- };
- assert_eq!(
- canonical_capture(&list).expect("capture"),
- 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,
+ fn native_material_categories_preserve_depth_and_cull_state() {
+ let base = LegacyPipelineState {
+ depth: LegacyDepthMode::TestWrite,
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;
- let invalid = RenderCommandList {
- commands: vec![
- RenderCommand::BeginFrame,
- RenderCommand::Draw(DrawCommand {
- id: DrawId(1),
- phase: RenderPhase::Opaque,
- 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,
- }),
- RenderCommand::EndFrame,
- ],
- };
-
- assert!(matches!(
- backend.execute(&invalid),
- Err(RenderError::InvalidRange)
- ));
- }
-
- #[test]
- fn recording_backend_stores_captures() {
- let mut backend = RecordingBackend::default();
- let list = RenderCommandList {
- commands: vec![RenderCommand::BeginFrame, RenderCommand::EndFrame],
- };
-
- backend.execute(&list).expect("execute");
- backend.execute(&list).expect("execute");
-
- assert_eq!(backend.captures().len(), 2);
- assert_eq!(backend.last_capture(), Some(&b"B\nE\n"[..]));
- backend.clear();
- assert!(backend.captures().is_empty());
- }
-
- #[test]
- fn one_snapshot_draw_produces_one_draw_command() -> Result<(), RenderError> {
- let snapshot = RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![snapshot_draw(1, RenderPhase::Opaque, 0, 10)],
- };
-
- let commands = build_commands(&snapshot, RenderProfile::default())?;
-
- assert!(matches!(commands.commands[0], RenderCommand::BeginFrame));
- assert!(matches!(commands.commands[2], RenderCommand::EndFrame));
- let RenderCommand::Draw(draw) = &commands.commands[1] else {
- panic!("expected draw");
- };
- assert_eq!(draw.id, DrawId(1));
- assert_eq!(draw.mesh, GpuMeshId(11));
- assert_eq!(draw.range, IndexRange { start: 0, count: 3 });
- Ok(())
- }
-
- #[test]
- fn material_index_maps_through_resolved_material_slots() -> Result<(), RenderError> {
- let snapshot = RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![snapshot_draw(2, RenderPhase::Opaque, 1, 10)],
- };
-
- let commands = build_commands(&snapshot, RenderProfile::default())?;
-
- let RenderCommand::Draw(draw) = &commands.commands[1] else {
- panic!("expected draw");
- };
- assert_eq!(draw.material, GpuMaterialId(37));
- Ok(())
- }
-
- #[test]
- fn node_transform_is_retained() -> Result<(), RenderError> {
- let mut draw = snapshot_draw(3, RenderPhase::Opaque, 0, 10);
- draw.transform[3] = 12.5;
- draw.transform[7] = -4.0;
- let snapshot = RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![draw],
- };
-
- let commands = build_commands(&snapshot, RenderProfile::default())?;
-
- let RenderCommand::Draw(draw) = &commands.commands[1] else {
- panic!("expected draw");
- };
- assert_eq!(draw.transform[3], 12.5);
- assert_eq!(draw.transform[7], -4.0);
- Ok(())
- }
-
- #[test]
- fn command_order_uses_phase_then_stable_key() -> Result<(), RenderError> {
- let snapshot = RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![
- snapshot_draw(3, RenderPhase::Transparent, 0, 0),
- snapshot_draw(2, RenderPhase::Opaque, 0, 20),
- snapshot_draw(1, RenderPhase::Opaque, 0, 10),
- ],
- };
-
- let commands = build_commands(&snapshot, RenderProfile::default())?;
- let capture = canonical_capture(&commands)?;
-
- assert_eq!(
- capture,
- 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(())
- }
-
- #[test]
- fn command_capture_independent_of_snapshot_construction_order() -> Result<(), RenderError> {
- let forward = RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![
- snapshot_draw(1, RenderPhase::Opaque, 0, 10),
- snapshot_draw(2, RenderPhase::Opaque, 1, 20),
- ],
+ ..LegacyPipelineState::default()
};
- let reverse = RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![
- snapshot_draw(2, RenderPhase::Opaque, 1, 20),
- snapshot_draw(1, RenderPhase::Opaque, 0, 10),
- ],
- };
-
- assert_eq!(
- canonical_capture(&build_commands(&forward, RenderProfile::default())?)?,
- canonical_capture(&build_commands(&reverse, RenderProfile::default())?)?
- );
- Ok(())
- }
-
- #[test]
- fn invalid_range_returns_contextual_error() {
- let mut draw = snapshot_draw(9, RenderPhase::Opaque, 0, 10);
- draw.range = IndexRange { start: 4, count: 0 };
- let snapshot = RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![draw],
- };
-
- assert!(matches!(
- build_commands(&snapshot, RenderProfile::default()),
- Err(RenderError::InvalidDrawRange {
- draw_id: DrawId(9),
- stable_order: 10,
- start: 4,
- count: 0
- })
- ));
- }
-
- #[test]
- fn command_validation_rejects_bad_frame_framing() {
- let missing_begin = RenderCommandList {
- commands: vec![RenderCommand::EndFrame],
- };
- assert!(matches!(
- validate_command_list(&missing_begin),
- Err(RenderError::InvalidCommandStream {
- index: 0,
- message: "first command must be BeginFrame"
- })
- ));
-
- let nested = RenderCommandList {
- commands: vec![
- RenderCommand::BeginFrame,
- RenderCommand::BeginFrame,
- RenderCommand::EndFrame,
- ],
- };
- assert!(matches!(
- validate_command_list(&nested),
- Err(RenderError::InvalidCommandStream {
- index: 1,
- message: "nested BeginFrame is not allowed"
- })
- ));
- }
-
- #[test]
- fn command_validation_rejects_nonfinite_transform_and_range_overflow() {
- let mut draw = snapshot_draw(10, RenderPhase::Opaque, 0, 10);
- draw.transform[5] = f32::NAN;
- let nonfinite = build_commands(
- &RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![draw],
- },
- RenderProfile::default(),
- );
- assert!(matches!(
- nonfinite,
- Err(RenderError::NonFiniteTransform {
- draw_id: DrawId(10),
- element: 5
- })
- ));
-
- let list = RenderCommandList {
- commands: vec![
- RenderCommand::BeginFrame,
- RenderCommand::Draw(DrawCommand {
- id: DrawId(11),
- phase: RenderPhase::Opaque,
- object_id: None,
- mesh: GpuMeshId(2),
- material: GpuMaterialId(3),
- pipeline_key: LegacyPipelineState::default().into(),
- transform: identity_transform(),
- range: IndexRange {
- start: u32::MAX,
- count: 1,
- },
- stable_order: 4,
- }),
- RenderCommand::EndFrame,
- ],
- };
- assert!(matches!(
- validate_command_list(&list),
- Err(RenderError::IndexRangeOverflow {
- draw_id: DrawId(11),
- start: u32::MAX,
- count: 1
- })
- ));
+ let expected = [
+ (LegacyBlendMode::Opaque, false),
+ (LegacyBlendMode::SourceAlpha, true),
+ (LegacyBlendMode::Additive, true),
+ (LegacyBlendMode::ZeroSourceColor, true),
+ (LegacyBlendMode::DestColorSourceColor, true),
+ ];
+ for (category, (blend, alpha_test)) in expected.into_iter().enumerate() {
+ let state = base
+ .with_material_category(category as u8)
+ .expect("native category is represented");
+ assert_eq!(state.blend, blend);
+ assert_eq!(state.alpha_test, alpha_test);
+ assert_eq!(state.depth, base.depth);
+ assert_eq!(state.cull, base.cull);
+ }
+ assert_eq!(base.with_material_category(5), None);
}
#[test]
- fn command_validation_checks_order_and_resource_bounds() {
- let ordered = build_commands(
- &RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![snapshot_draw(1, RenderPhase::Opaque, 0, 10)],
- },
- RenderProfile::default(),
- )
- .expect("commands");
- assert!(matches!(
- validate_command_list_with_limits(
- &ordered,
- RenderValidationLimits {
- mesh_count: Some(5),
- index_count: Some(16)
- }
- ),
- Err(RenderError::MeshOutOfBounds {
- draw_id: DrawId(1),
- mesh: GpuMeshId(11),
- mesh_count: 5
- })
- ));
-
- let out_of_bounds = RenderCommandList {
- commands: vec![
- RenderCommand::BeginFrame,
- RenderCommand::Draw(DrawCommand {
- id: DrawId(12),
- phase: RenderPhase::Opaque,
- object_id: None,
- mesh: GpuMeshId(2),
- material: GpuMaterialId(3),
- pipeline_key: LegacyPipelineState::default().into(),
- transform: identity_transform(),
- range: IndexRange {
- start: 14,
- count: 3,
- },
- stable_order: 4,
- }),
- RenderCommand::EndFrame,
- ],
- };
- assert!(matches!(
- validate_command_list_with_limits(
- &out_of_bounds,
- RenderValidationLimits {
- mesh_count: Some(5),
- index_count: Some(16)
- }
- ),
- Err(RenderError::IndexRangeOutOfBounds {
- draw_id: DrawId(12),
- index_count: 16,
- end: 17
- })
- ));
-
- let unordered = RenderCommandList {
- commands: vec![
- RenderCommand::BeginFrame,
- RenderCommand::Draw(DrawCommand {
- id: DrawId(1),
- phase: RenderPhase::Transparent,
- 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,
- }),
- RenderCommand::Draw(DrawCommand {
- id: DrawId(2),
- phase: RenderPhase::Opaque,
- 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,
- }),
- RenderCommand::EndFrame,
- ],
+ fn native_material_categories_have_distinct_pipeline_keys() {
+ let base = LegacyPipelineState {
+ depth: LegacyDepthMode::TestWrite,
+ ..LegacyPipelineState::default()
};
- assert!(matches!(
- validate_command_list(&unordered),
- Err(RenderError::PhaseOrderViolation {
- draw_id: DrawId(2),
- previous: RenderPhase::Transparent,
- current: RenderPhase::Opaque
+ let keys: Vec<_> = (0..=4)
+ .map(|category| {
+ PipelineKey::from(
+ base.with_material_category(category)
+ .expect("native category is represented"),
+ )
})
- ));
- }
-
- #[test]
- fn render_error_display_is_actionable() {
- assert_eq!(
- RenderError::InvalidDrawRange {
- draw_id: DrawId(9),
- stable_order: 10,
- start: 4,
- count: 0
- }
- .to_string(),
- "draw 9 has invalid index range start=4 count=0 at stable order 10"
- );
- assert_eq!(
- RenderError::MaterialIndexOutOfBounds {
- draw_id: DrawId(7),
- material_index: 3,
- material_count: 2
- }
- .to_string(),
- "draw 7 references material index 3 but only 2 material slots are available"
- );
- }
-
- #[test]
- fn ui_phase_is_excluded_until_requested() -> Result<(), RenderError> {
- let snapshot = RenderSnapshot {
- camera: CameraSnapshot::default(),
- draws: vec![
- snapshot_draw(1, RenderPhase::Opaque, 0, 10),
- snapshot_draw(2, RenderPhase::Ui, 0, 20),
- ],
- };
-
- let default_commands = build_commands(&snapshot, RenderProfile::default())?;
- let ui_commands = build_commands(&snapshot, RenderProfile { include_ui: true })?;
-
- assert_eq!(default_commands.commands.len(), 3);
- assert_eq!(ui_commands.commands.len(), 4);
- Ok(())
+ .collect();
+ for (index, key) in keys.iter().enumerate() {
+ assert!(keys[index + 1..].iter().all(|other| other != key));
+ }
}
}
diff --git a/crates/fparkan-resource/Cargo.toml b/crates/fparkan-resource/Cargo.toml
index 011583e..815bef9 100644
--- a/crates/fparkan-resource/Cargo.toml
+++ b/crates/fparkan-resource/Cargo.toml
@@ -6,11 +6,8 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
-fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
-fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
-fparkan-rsli = { path = "../fparkan-rsli", version = "0.1.0" }
-fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
-
-[lints]
-workspace = true
+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" }
diff --git a/crates/fparkan-resource/src/lib.rs b/crates/fparkan-resource/src/lib.rs
index f996a71..c470501 100644
--- a/crates/fparkan-resource/src/lib.rs
+++ b/crates/fparkan-resource/src/lib.rs
@@ -1,23 +1,4 @@
#![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
- )
-)]
//! Resource identity and repository ports.
use fparkan_binary::{sha256, Sha256Digest};
diff --git a/crates/fparkan-rsli/Cargo.toml b/crates/fparkan-rsli/Cargo.toml
index 0b72705..2230dc2 100644
--- a/crates/fparkan-rsli/Cargo.toml
+++ b/crates/fparkan-rsli/Cargo.toml
@@ -6,8 +6,5 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
+fparkan-binary = { path = "../fparkan-binary" }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
-
-[lints]
-workspace = true
diff --git a/crates/fparkan-rsli/src/lib.rs b/crates/fparkan-rsli/src/lib.rs
index 0bb1498..23b1be2 100644
--- a/crates/fparkan-rsli/src/lib.rs
+++ b/crates/fparkan-rsli/src/lib.rs
@@ -1,23 +1,4 @@
#![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;
@@ -954,7 +935,6 @@ struct EntryRecord {
effective_offset: usize,
}
-#[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)?,
diff --git a/crates/fparkan-runtime/Cargo.toml b/crates/fparkan-runtime/Cargo.toml
index 858e5af..ddf09a1 100644
--- a/crates/fparkan-runtime/Cargo.toml
+++ b/crates/fparkan-runtime/Cargo.toml
@@ -6,16 +6,13 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-assets = { path = "../fparkan-assets", version = "0.1.0" }
-fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
-fparkan-platform = { path = "../fparkan-platform", version = "0.1.0" }
-fparkan-prototype = { path = "../fparkan-prototype", version = "0.1.0" }
-fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
-fparkan-terrain = { path = "../fparkan-terrain", version = "0.1.0" }
-fparkan-resource = { path = "../fparkan-resource", version = "0.1.0" }
-fparkan-script = { path = "../fparkan-script", version = "0.1.0" }
-fparkan-vfs = { path = "../fparkan-vfs", version = "0.1.0" }
-fparkan-world = { path = "../fparkan-world", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-assets = { path = "../fparkan-assets" }
+fparkan-path = { path = "../fparkan-path" }
+fparkan-platform = { path = "../fparkan-platform" }
+fparkan-prototype = { path = "../fparkan-prototype" }
+fparkan-render = { path = "../fparkan-render" }
+fparkan-terrain = { path = "../fparkan-terrain" }
+fparkan-resource = { path = "../fparkan-resource" }
+fparkan-script = { path = "../fparkan-script" }
+fparkan-vfs = { path = "../fparkan-vfs" }
+fparkan-world = { path = "../fparkan-world" }
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 2bb1be1..e0088a3 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -1,32 +1,13 @@
#![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
- )
-)]
-//! Runtime orchestration for headless and rendered modes.
+//! Runtime orchestration for mission loading and world stepping.
use fparkan_assets::{
decode_mission_land_path, decode_mission_payload, decode_nres_payload,
- derive_mission_land_paths, extend_graph_report_with_visual_dependencies_with_progress,
+ derive_mission_land_paths, extend_graph_report_with_visual_dependencies,
mission_script_bundle_bases, prepare_terrain_world, AssetError as AssetPreparationError,
- AssetId, AssetManager, AssetPreparationPhase, BuildCategory, MissionAssetPlan, MissionDocument,
- MissionError, MissionTerrainPaths, NresError, PreparedVisual, TerrainFormatError,
- TerrainPreparationError, TerrainWorld, TmaProfile, VisualDependencyPhase,
+ AssetId, AssetManager, BuildCategory, MissionAssetPlan, MissionDocument, MissionError,
+ MissionTerrainPaths, NresError, PreparedVisual, TerrainFormatError, TerrainPreparationError,
+ TerrainWorld, TmaProfile,
};
use fparkan_path::{normalize_relative, NormalizedPath, PathError, PathPolicy};
use fparkan_prototype::{
@@ -53,47 +34,6 @@ pub use fparkan_assets::MissionAssets;
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
-/// Engine mode.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum EngineMode {
- /// Headless.
- Headless,
- /// Rendered.
- Rendered,
-}
-
-/// Scheduler phase.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum SchedulerPhase {
- /// Collect platform events.
- CollectPlatformEvents,
- /// Build input snapshot.
- BuildInputSnapshot,
- /// Advance clock.
- AdvanceGameClock,
- /// Calculate world queue.
- CalculateWorldQueue,
- /// Apply deferred operations.
- ApplyDeferredOperations,
- /// Update animation/effects.
- UpdateAnimationAndEffects,
- /// Publish render snapshot.
- PublishRenderSnapshot,
- /// Render world.
- RenderWorld,
- /// End frame callbacks.
- EndFrameCallbacks,
- /// Maintenance.
- Maintenance,
-}
-
-/// Engine config.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub struct EngineConfig {
- /// Mode.
- pub mode: EngineMode,
-}
-
/// Injectable engine services used by composition roots.
#[derive(Clone, Default)]
pub struct EngineServices {
@@ -116,97 +56,6 @@ pub struct MissionRequest {
pub key: String,
}
-/// Mission loading phase captured for diagnostics and acceptance tests.
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-pub enum MissionLoadPhase {
- /// Resolve services and mission request context.
- Context,
- /// Decode and validate TMA.
- Tma,
- /// Decode and validate terrain map assets.
- Map,
- /// Expand object roots into a prototype graph.
- Graph,
- /// Expand model-backed visual dependencies of the prototype graph.
- GraphVisuals,
- /// Resolve WEAR tables while expanding visual dependencies.
- GraphVisualWears,
- /// Resolve MAT0 documents while expanding visual dependencies.
- GraphVisualMaterials,
- /// Progress marker emitted after each 64 MAT0 validation requests.
- GraphVisualMaterialRequests {
- /// Number of MAT0 validation requests started.
- request_count: usize,
- /// Graph nodes materialized before the request.
- graph_node_count: usize,
- /// Graph edges materialized before the request.
- graph_edge_count: usize,
- /// Distinct WEAR validation keys retained before the request.
- wear_cache_entries: usize,
- /// Distinct MAT0 validation keys retained before the request.
- material_cache_entries: usize,
- /// Distinct TEXM validation keys retained before the request.
- texture_cache_entries: usize,
- },
- /// Validate TEXM documents while expanding visual dependencies.
- GraphVisualTextures,
- /// Progress marker emitted after each 64 TEXM validation requests.
- GraphVisualTextureRequests {
- /// Number of TEXM validation requests started.
- request_count: usize,
- /// Graph nodes materialized before the request.
- graph_node_count: usize,
- /// Graph edges materialized before the request.
- graph_edge_count: usize,
- /// Distinct WEAR validation keys retained before the request.
- wear_cache_entries: usize,
- /// Distinct MAT0 validation keys retained before the request.
- material_cache_entries: usize,
- /// Distinct TEXM validation keys retained before the request.
- texture_cache_entries: usize,
- },
- /// Prepare all reachable visual/resource dependencies.
- Assets,
- /// Decode and validate MSH model meshes.
- AssetModelMeshes,
- /// Decode WEAR material tables.
- AssetWearTables,
- /// Resolve MAT0 material documents.
- AssetMaterials,
- /// Decode diffuse textures and baked lightmaps.
- AssetTextures,
- /// Construct all object drafts before registration.
- Construct,
- /// Register constructed objects.
- Register,
-}
-
-/// Raw placed transform preserved by the mission loader.
-#[derive(Clone, Debug, PartialEq)]
-pub struct PlacedTransformProfile {
- /// Object index in TMA order.
- pub object_index: usize,
- /// Raw position vector.
- pub position: [f32; 3],
- /// Raw orientation vector. No Euler order is inferred here.
- pub orientation_raw: [f32; 3],
- /// Raw scale vector.
- pub scale: [f32; 3],
-}
-
-/// Mission loading trace.
-#[derive(Clone, Debug, Default, PartialEq)]
-pub struct MissionLoadTrace {
- /// Observed phases in execution order.
- pub phases: Vec<MissionLoadPhase>,
- /// Number of object drafts constructed before the first registration.
- pub drafts_before_registration: usize,
- /// Number of objects registered.
- pub registered_objects: usize,
- /// Raw transform profile for placed objects.
- pub transforms: Vec<PlacedTransformProfile>,
-}
-
/// Ordered mission property preserved for later runtime stages.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionObjectProperty {
@@ -435,20 +284,10 @@ pub struct LoadedMission {
pub struct FrameResult {
/// Snapshot.
pub snapshot: WorldSnapshot,
- /// Scheduler phases executed for this frame.
- pub trace: FrameTrace,
-}
-
-/// Scheduler trace for a completed frame.
-#[derive(Clone, Debug, Default, Eq, PartialEq)]
-pub struct FrameTrace {
- /// Frame phases in execution order.
- pub phases: Vec<SchedulerPhase>,
}
/// Engine.
pub struct Engine {
- config: EngineConfig,
services: EngineServices,
world: World,
loaded: Option<LoadedMissionState>,
@@ -559,13 +398,6 @@ pub enum EngineError {
World(fparkan_world::WorldError),
/// Reference movement input or terrain query failed.
Movement(String),
- /// Scheduler phase order was violated.
- SchedulerPhaseOrder {
- /// Previous phase.
- previous: SchedulerPhase,
- /// Current phase.
- current: SchedulerPhase,
- },
/// Staged mission world was torn down after a registration-phase failure.
RegistrationTeardown {
/// Registered objects before the forced failure.
@@ -676,10 +508,6 @@ impl std::fmt::Display for EngineError {
}
Self::World(source) => write!(f, "{source}"),
Self::Movement(message) => write!(f, "reference movement: {message}"),
- Self::SchedulerPhaseOrder { previous, current } => write!(
- f,
- "scheduler phase order regressed from {previous:?} to {current:?}"
- ),
Self::RegistrationTeardown {
registered_objects,
released_objects,
@@ -710,22 +538,14 @@ impl std::error::Error for EngineError {
| Self::ScriptRuntime { .. }
| Self::Movement(_)
| Self::PrototypeGraph { .. }
- | Self::SchedulerPhaseOrder { .. }
| Self::RegistrationTeardown { .. } => None,
}
}
}
-/// Creates engine.
-///
-/// # Errors
-///
-/// Currently this constructor is infallible, but it returns
-/// [`EngineError`] to keep the composition-root API stable as services become
-/// mandatory.
-pub fn create(config: EngineConfig, services: EngineServices) -> Result<Engine, EngineError> {
+/// Creates an engine with the supplied services.
+pub fn create(services: EngineServices) -> Result<Engine, EngineError> {
Ok(Engine {
- config,
services,
world: new_world(WorldConfig),
loaded: None,
@@ -744,7 +564,7 @@ pub fn load_mission(
engine: &mut Engine,
request: MissionRequest,
) -> Result<LoadedMission, EngineError> {
- load_mission_with_trace(engine, request).map(|(loaded, _trace)| loaded)
+ load_mission_with_options(engine, request, MissionLoadOptions::default())
}
/// Loads a mission using the bounded static-preview asset scope.
@@ -763,123 +583,48 @@ pub fn load_mission_static_preview(
engine: &mut Engine,
request: MissionRequest,
) -> Result<LoadedMission, EngineError> {
- load_mission_static_preview_with_progress(engine, request, |_| {})
-}
-
-/// Loads a static preview for the first requested non-zero count of mission roots.
-///
-/// Normal gameplay still uses [`load_mission`] and always resolves every root.
-///
-/// # Errors
-///
-/// Returns [`EngineError`] under the same conditions as
-/// [`load_mission_static_preview`].
-pub fn load_mission_static_preview_roots(
- engine: &mut Engine,
- request: MissionRequest,
- root_count: NonZeroUsize,
-) -> Result<LoadedMission, EngineError> {
- load_mission_static_preview_roots_with_progress(engine, request, root_count, |_| {})
-}
-
-/// Loads selected static-preview roots while synchronously reporting phases.
-///
-/// # Errors
-///
-/// Returns [`EngineError`] under the same conditions as
-/// [`load_mission_static_preview_roots`].
-pub fn load_mission_static_preview_roots_with_progress(
- engine: &mut Engine,
- request: MissionRequest,
- root_count: NonZeroUsize,
- mut on_phase: impl FnMut(MissionLoadPhase),
-) -> Result<LoadedMission, EngineError> {
- load_mission_with_options_and_progress(
+ load_mission_with_options(
engine,
request,
MissionLoadOptions {
- asset_scope: MissionAssetScope::PreviewRoots(root_count),
+ asset_scope: MissionAssetScope::FirstMeshPreview,
..MissionLoadOptions::default()
},
- Some(&mut on_phase),
)
- .map(|(loaded, _trace)| loaded)
}
-/// Loads a static preview while synchronously reporting entered loading phases.
+/// Loads a static preview for the first requested non-zero count of mission roots.
///
-/// This has the same bounded asset scope as [`load_mission_static_preview`].
+/// Normal gameplay still uses [`load_mission`] and always resolves every root.
///
/// # Errors
///
/// Returns [`EngineError`] under the same conditions as
/// [`load_mission_static_preview`].
-pub fn load_mission_static_preview_with_progress(
+pub fn load_mission_static_preview_roots(
engine: &mut Engine,
request: MissionRequest,
- mut on_phase: impl FnMut(MissionLoadPhase),
+ root_count: NonZeroUsize,
) -> Result<LoadedMission, EngineError> {
- load_mission_with_options_and_progress(
+ load_mission_with_options(
engine,
request,
MissionLoadOptions {
- asset_scope: MissionAssetScope::FirstMeshPreview,
+ asset_scope: MissionAssetScope::PreviewRoots(root_count),
..MissionLoadOptions::default()
},
- Some(&mut on_phase),
- )
- .map(|(loaded, _trace)| loaded)
-}
-
-/// Loads mission transactionally and returns a diagnostic trace.
-///
-/// # Errors
-///
-/// Returns [`EngineError`] under the same conditions as [`load_mission`].
-pub fn load_mission_with_trace(
- engine: &mut Engine,
- request: MissionRequest,
-) -> Result<(LoadedMission, MissionLoadTrace), EngineError> {
- load_mission_with_options_and_progress(engine, request, MissionLoadOptions::default(), None)
-}
-
-/// Loads a mission while synchronously reporting each entered loading phase.
-///
-/// The observer runs immediately after the phase is recorded in the returned
-/// trace. It is intended for diagnostic progress reporting; it does not alter
-/// loader ordering, validation, or transaction behavior.
-///
-/// # Errors
-///
-/// Returns [`EngineError`] under the same conditions as [`load_mission`].
-pub fn load_mission_with_progress(
- engine: &mut Engine,
- request: MissionRequest,
- mut on_phase: impl FnMut(MissionLoadPhase),
-) -> Result<LoadedMission, EngineError> {
- load_mission_with_options_and_progress(
- engine,
- request,
- MissionLoadOptions::default(),
- Some(&mut on_phase),
)
- .map(|(loaded, _trace)| loaded)
}
-#[allow(clippy::too_many_lines)]
-fn load_mission_with_options_and_progress(
+fn load_mission_with_options(
engine: &mut Engine,
request: MissionRequest,
options: MissionLoadOptions,
- mut on_phase: Option<&mut dyn FnMut(MissionLoadPhase)>,
-) -> Result<(LoadedMission, MissionLoadTrace), EngineError> {
- let mut trace = MissionLoadTrace::default();
- record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Context);
+) -> Result<LoadedMission, EngineError> {
let vfs = engine.services.vfs.clone().ok_or(EngineError::MissingVfs)?;
let mission_path = normalize_engine_path("mission", &request.key)?;
let mission_bytes = read_vfs(&vfs, &mission_path)?;
- record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Map);
let land_path =
decode_mission_land_path(&mission_bytes, TmaProfile::Strict).map_err(|source| {
EngineError::Mission {
@@ -919,7 +664,6 @@ fn load_mission_with_options_and_progress(
TerrainPreparationError::Runtime(source) => EngineError::Terrain(source),
}
})?;
- record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Tma);
let mission = decode_mission_payload(mission_bytes, TmaProfile::Strict).map_err(|source| {
EngineError::Mission {
path: mission_path.as_str().to_string(),
@@ -927,18 +671,6 @@ fn load_mission_with_options_and_progress(
}
})?;
let loaded_scripts = load_mission_scripts(&vfs, &mission)?;
- trace.transforms = mission
- .objects
- .iter()
- .enumerate()
- .map(|(object_index, object)| PlacedTransformProfile {
- object_index,
- position: object.position,
- orientation_raw: object.orientation,
- scale: object.scale,
- })
- .collect();
- record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Graph);
let repository = CachedResourceRepository::with_payload_cache_budget(
vfs.clone(),
MISSION_DECODED_PAYLOAD_CACHE_ENTRIES,
@@ -957,93 +689,34 @@ fn load_mission_with_options_and_progress(
};
let (mut prototype_graph, resolved_prototypes, mut prototype_report) =
build_prototype_graph_report(&repository, vfs.as_ref(), scoped_graph_roots);
- record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::GraphVisuals);
- let mut last_graph_visual_phase = None;
- extend_graph_report_with_visual_dependencies_with_progress(
+ extend_graph_report_with_visual_dependencies(
&repository,
&mut prototype_report,
&mut prototype_graph,
&resolved_prototypes,
- |progress| {
- let runtime_phase = match progress.phase {
- VisualDependencyPhase::Wear => MissionLoadPhase::GraphVisualWears,
- VisualDependencyPhase::Material => MissionLoadPhase::GraphVisualMaterials,
- VisualDependencyPhase::Texture => MissionLoadPhase::GraphVisualTextures,
- };
- if last_graph_visual_phase != Some(runtime_phase) {
- record_load_phase(&mut trace, &mut on_phase, runtime_phase);
- last_graph_visual_phase = Some(runtime_phase);
- }
- if progress.request_count > 1 {
- let request_phase = match progress.phase {
- VisualDependencyPhase::Wear => None,
- VisualDependencyPhase::Material => {
- Some(MissionLoadPhase::GraphVisualMaterialRequests {
- request_count: progress.request_count,
- graph_node_count: progress.graph_node_count,
- graph_edge_count: progress.graph_edge_count,
- wear_cache_entries: progress.cache_entries.wear_entries,
- material_cache_entries: progress.cache_entries.material_entries,
- texture_cache_entries: progress.cache_entries.texture_entries,
- })
- }
- VisualDependencyPhase::Texture => {
- Some(MissionLoadPhase::GraphVisualTextureRequests {
- request_count: progress.request_count,
- graph_node_count: progress.graph_node_count,
- graph_edge_count: progress.graph_edge_count,
- wear_cache_entries: progress.cache_entries.wear_entries,
- material_cache_entries: progress.cache_entries.material_entries,
- texture_cache_entries: progress.cache_entries.texture_entries,
- })
- }
- };
- if let Some(request_phase) = request_phase {
- record_load_phase(&mut trace, &mut on_phase, request_phase);
- }
- }
- },
);
if !prototype_report.is_success() {
return Err(EngineError::PrototypeGraph {
failures: prototype_report.failures.clone(),
});
}
- record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Assets);
let asset_manager = AssetManager::new(repository);
- let mut last_asset_phase = None;
- let mut observe_asset_phase = |phase| {
- let runtime_phase = match phase {
- AssetPreparationPhase::ModelMesh => MissionLoadPhase::AssetModelMeshes,
- AssetPreparationPhase::WearTable => MissionLoadPhase::AssetWearTables,
- AssetPreparationPhase::Materials => MissionLoadPhase::AssetMaterials,
- AssetPreparationPhase::Textures => MissionLoadPhase::AssetTextures,
- };
- if last_asset_phase != Some(runtime_phase) {
- record_load_phase(&mut trace, &mut on_phase, runtime_phase);
- last_asset_phase = Some(runtime_phase);
- }
- };
let mission_assets = match options.asset_scope {
- MissionAssetScope::Full => asset_manager.prepare_mission_assets_with_progress(
+ MissionAssetScope::Full => asset_manager.prepare_mission_assets(
&prototype_graph.root_prototype_request_spans,
&resolved_prototypes,
- &mut observe_asset_phase,
),
MissionAssetScope::FirstMeshPreview => prepare_first_preview_assets(
&asset_manager,
&prototype_graph.root_prototype_request_spans,
&resolved_prototypes,
- &mut observe_asset_phase,
),
- MissionAssetScope::PreviewRoots(root_count) => asset_manager
- .prepare_mission_assets_with_progress(
- &prototype_graph.root_prototype_request_spans[..root_count
- .get()
- .min(prototype_graph.root_prototype_request_spans.len())],
- &resolved_prototypes,
- &mut observe_asset_phase,
- ),
+ MissionAssetScope::PreviewRoots(root_count) => asset_manager.prepare_mission_assets(
+ &prototype_graph.root_prototype_request_spans[..root_count
+ .get()
+ .min(prototype_graph.root_prototype_request_spans.len())],
+ &resolved_prototypes,
+ ),
}
.map_err(|source| EngineError::AssetPreparation {
mission: request.key.clone(),
@@ -1079,7 +752,6 @@ fn load_mission_with_options_and_progress(
.collect();
let mut new_runtime_world = new_world(WorldConfig);
let mut handles = Vec::with_capacity(mission.objects.len());
- record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Construct);
for (index, object) in mission.objects.iter().enumerate() {
let original_id = u32::try_from(index).ok().map(OriginalObjectId);
let handle = construct_object(&mut new_runtime_world, ObjectDraft { original_id })?;
@@ -1094,19 +766,18 @@ fn load_mission_with_options_and_progress(
)?;
handles.push(handle);
}
- trace.drafts_before_registration = handles.len();
- record_load_phase(&mut trace, &mut on_phase, MissionLoadPhase::Register);
+ let mut registered_objects = 0;
for handle in &handles {
- if options.fail_after_registered_objects == Some(trace.registered_objects) {
+ if options.fail_after_registered_objects == Some(registered_objects) {
let report = fparkan_world::shutdown(new_runtime_world);
return Err(EngineError::RegistrationTeardown {
- registered_objects: trace.registered_objects,
+ registered_objects,
released_objects: report.released_objects.len(),
managers_released: report.managers_released,
});
}
register_object(&mut new_runtime_world, *handle)?;
- trace.registered_objects += 1;
+ registered_objects += 1;
}
let summary = LoadedMission {
@@ -1174,32 +845,17 @@ fn load_mission_with_options_and_progress(
script_varset_states: loaded_scripts.varset_states,
script_outcome: MissionScriptOutcome::Active,
});
- Ok((summary, trace))
-}
-
-fn record_load_phase(
- trace: &mut MissionLoadTrace,
- on_phase: &mut Option<&mut dyn FnMut(MissionLoadPhase)>,
- phase: MissionLoadPhase,
-) {
- trace.phases.push(phase);
- if let Some(observer) = on_phase.as_deref_mut() {
- observer(phase);
- }
+ Ok(summary)
}
fn prepare_first_preview_assets<R: ResourceRepository>(
asset_manager: &AssetManager<R>,
root_spans: &[std::ops::Range<usize>],
prototypes: &[fparkan_prototype::EffectivePrototype],
- on_phase: &mut dyn FnMut(AssetPreparationPhase),
) -> Result<MissionAssets, AssetPreparationError> {
for span in root_spans {
- let assets = asset_manager.prepare_mission_assets_with_progress(
- std::slice::from_ref(span),
- prototypes,
- &mut *on_phase,
- )?;
+ let assets =
+ asset_manager.prepare_mission_assets(std::slice::from_ref(span), prototypes)?;
if !assets.models.is_empty() {
return Ok(assets);
}
@@ -1216,7 +872,9 @@ pub fn step_headless(
engine: &mut Engine,
input: InputSnapshot,
) -> Result<FrameResult, EngineError> {
- run_frame(engine, input, SchedulerPresentation::Headless)
+ Ok(FrameResult {
+ snapshot: step(&mut engine.world, &input)?,
+ })
}
/// Steps rendered mode.
@@ -1225,20 +883,7 @@ pub fn step_headless(
///
/// Returns [`EngineError`] when the world step fails.
pub fn frame(engine: &mut Engine) -> Result<FrameResult, EngineError> {
- match engine.config.mode {
- EngineMode::Headless => step_headless(engine, InputSnapshot),
- EngineMode::Rendered => run_frame(engine, InputSnapshot, SchedulerPresentation::Rendered),
- }
-}
-
-/// Shuts down engine.
-///
-/// # Errors
-///
-/// Currently shutdown is infallible, but the `Result` preserves the lifecycle
-/// API for future service teardown failures.
-pub fn shutdown(_engine: Engine) -> Result<(), EngineError> {
- Ok(())
+ step_headless(engine, InputSnapshot)
}
/// Returns the loaded mission summary.
@@ -1451,78 +1096,6 @@ pub fn loaded_mission_object_drafts(engine: &Engine) -> Option<&[MissionObjectDr
.map(|state| state.object_drafts.as_slice())
}
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-enum SchedulerPresentation {
- Headless,
- Rendered,
-}
-
-#[derive(Clone, Debug, Default)]
-struct Scheduler {
- phase: Option<SchedulerPhase>,
- trace: FrameTrace,
-}
-
-impl Scheduler {
- fn enter(&mut self, phase: SchedulerPhase) -> Result<(), EngineError> {
- if let Some(previous) = self.phase {
- if scheduler_phase_index(phase) <= scheduler_phase_index(previous) {
- return Err(EngineError::SchedulerPhaseOrder {
- previous,
- current: phase,
- });
- }
- }
- self.phase = Some(phase);
- self.trace.phases.push(phase);
- Ok(())
- }
-
- fn finish(self) -> FrameTrace {
- self.trace
- }
-}
-
-fn run_frame(
- engine: &mut Engine,
- input: InputSnapshot,
- presentation: SchedulerPresentation,
-) -> Result<FrameResult, EngineError> {
- let mut scheduler = Scheduler::default();
- scheduler.enter(SchedulerPhase::CollectPlatformEvents)?;
- scheduler.enter(SchedulerPhase::BuildInputSnapshot)?;
- scheduler.enter(SchedulerPhase::AdvanceGameClock)?;
- scheduler.enter(SchedulerPhase::CalculateWorldQueue)?;
- let snapshot = step(&mut engine.world, &input)?;
- scheduler.enter(SchedulerPhase::ApplyDeferredOperations)?;
- scheduler.enter(SchedulerPhase::UpdateAnimationAndEffects)?;
- if presentation == SchedulerPresentation::Rendered {
- scheduler.enter(SchedulerPhase::PublishRenderSnapshot)?;
- scheduler.enter(SchedulerPhase::RenderWorld)?;
- }
- scheduler.enter(SchedulerPhase::EndFrameCallbacks)?;
- scheduler.enter(SchedulerPhase::Maintenance)?;
- Ok(FrameResult {
- snapshot,
- trace: scheduler.finish(),
- })
-}
-
-fn scheduler_phase_index(phase: SchedulerPhase) -> u8 {
- match phase {
- SchedulerPhase::CollectPlatformEvents => 0,
- SchedulerPhase::BuildInputSnapshot => 1,
- SchedulerPhase::AdvanceGameClock => 2,
- SchedulerPhase::CalculateWorldQueue => 3,
- SchedulerPhase::ApplyDeferredOperations => 4,
- SchedulerPhase::UpdateAnimationAndEffects => 5,
- SchedulerPhase::PublishRenderSnapshot => 6,
- SchedulerPhase::RenderWorld => 7,
- SchedulerPhase::EndFrameCallbacks => 8,
- SchedulerPhase::Maintenance => 9,
- }
-}
-
fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPath, EngineError> {
normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| {
EngineError::Path {
@@ -1702,7 +1275,7 @@ fn handler19_x87_truncated_u32(
}
// ai.dll's __ftol saves the x87 control word, sets rounding-control bits
// to truncate, performs `fistp qword`, and restores the control word.
- #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
+
let word = value.trunc() as u32;
Ok(word)
}
@@ -1758,13 +1331,7 @@ mod tests {
#[test]
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::default(),
- )
- .expect("engine");
+ let mut engine = create(EngineServices::default()).expect("engine");
let before = step_headless(&mut engine, InputSnapshot).expect("step");
let err = load_mission(
&mut engine,
@@ -1779,30 +1346,6 @@ mod tests {
}
#[test]
- fn load_progress_reports_context_before_missing_vfs_error() {
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::default(),
- )
- .expect("engine");
- let mut phases = Vec::new();
-
- let err = load_mission_with_progress(
- &mut engine,
- MissionRequest {
- key: "MISSIONS/Autodemo.00/data.tma".to_string(),
- },
- |phase| phases.push(phase),
- )
- .expect_err("missing VFS");
-
- assert!(matches!(err, EngineError::MissingVfs));
- assert_eq!(phases, vec![MissionLoadPhase::Context]);
- }
-
- #[test]
fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() {
let mut personal_files = MemoryVfs::default();
personal_files.insert(
@@ -1997,87 +1540,8 @@ mod tests {
}
#[test]
- fn headless_scheduler_trace_skips_presentation_phases() {
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::default(),
- )
- .expect("engine");
-
- let result = frame(&mut engine).expect("frame");
-
- assert_eq!(result.snapshot.tick.0, 1);
- assert_eq!(
- result.trace.phases,
- vec![
- SchedulerPhase::CollectPlatformEvents,
- SchedulerPhase::BuildInputSnapshot,
- SchedulerPhase::AdvanceGameClock,
- SchedulerPhase::CalculateWorldQueue,
- SchedulerPhase::ApplyDeferredOperations,
- SchedulerPhase::UpdateAnimationAndEffects,
- SchedulerPhase::EndFrameCallbacks,
- SchedulerPhase::Maintenance,
- ]
- );
- }
-
- #[test]
- fn rendered_scheduler_trace_includes_presentation_after_simulation() {
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Rendered,
- },
- EngineServices::default(),
- )
- .expect("engine");
-
- let result = frame(&mut engine).expect("frame");
-
- assert_eq!(
- result.trace.phases,
- vec![
- SchedulerPhase::CollectPlatformEvents,
- SchedulerPhase::BuildInputSnapshot,
- SchedulerPhase::AdvanceGameClock,
- SchedulerPhase::CalculateWorldQueue,
- SchedulerPhase::ApplyDeferredOperations,
- SchedulerPhase::UpdateAnimationAndEffects,
- SchedulerPhase::PublishRenderSnapshot,
- SchedulerPhase::RenderWorld,
- SchedulerPhase::EndFrameCallbacks,
- SchedulerPhase::Maintenance,
- ]
- );
- }
-
- #[test]
- fn scheduler_rejects_phase_regressions() {
- let mut scheduler = Scheduler::default();
- scheduler
- .enter(SchedulerPhase::BuildInputSnapshot)
- .expect("enter build input");
-
- assert!(matches!(
- scheduler.enter(SchedulerPhase::CollectPlatformEvents),
- Err(EngineError::SchedulerPhaseOrder {
- previous: SchedulerPhase::BuildInputSnapshot,
- current: SchedulerPhase::CollectPlatformEvents,
- })
- ));
- }
-
- #[test]
fn reference_movement_rejects_non_finite_or_non_positive_input() {
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::default(),
- )
- .expect("engine");
+ let mut engine = create(EngineServices::default()).expect("engine");
let err =
advance_reference_movement(&mut engine, OriginalObjectId(0), [f32::NAN, 0.0], 1.0)
@@ -2095,59 +1559,10 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
- fn load_trace_records_preparation_before_registration_and_raw_transforms() {
- let root = licensed_root("IS");
- let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::new(vfs),
- )
- .expect("engine");
-
- let (loaded, trace) = load_mission_with_trace(
- &mut engine,
- MissionRequest {
- key: "MISSIONS/Autodemo.00/data.tma".to_string(),
- },
- )
- .expect("load mission with trace");
-
- assert_eq!(
- trace.phases,
- vec![
- MissionLoadPhase::Context,
- MissionLoadPhase::Map,
- MissionLoadPhase::Tma,
- MissionLoadPhase::Graph,
- MissionLoadPhase::GraphVisuals,
- MissionLoadPhase::Assets,
- MissionLoadPhase::Construct,
- MissionLoadPhase::Register,
- ]
- );
- assert_eq!(trace.drafts_before_registration, loaded.object_count);
- assert_eq!(trace.registered_objects, loaded.object_count);
- assert_eq!(trace.transforms.len(), loaded.object_count);
- assert!(trace.transforms.iter().all(|transform| transform
- .orientation_raw
- .iter()
- .all(|component| component.is_finite())));
- }
-
- #[test]
- #[ignore = "requires licensed corpus"]
fn reference_movement_snaps_a_live_mission_object_to_terrain() {
let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(&root));
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::new(vfs),
- )
- .expect("engine");
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
load_mission(
&mut engine,
MissionRequest {
@@ -2221,13 +1636,7 @@ mod tests {
inner: DirectoryVfs::new(&root),
denied,
});
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::new(vfs),
- )
- .expect("engine");
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
let before = step_headless(&mut engine, InputSnapshot).expect("before");
let err = load_mission(&mut engine, mission).expect_err("load error");
match denied {
@@ -2248,16 +1657,10 @@ mod tests {
fn registration_phase_failure_uses_normal_teardown_and_keeps_engine_world() {
let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::new(vfs),
- )
- .expect("engine");
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
let before = step_headless(&mut engine, InputSnapshot).expect("before");
- let err = load_mission_with_options_and_progress(
+ let err = load_mission_with_options(
&mut engine,
MissionRequest {
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
@@ -2266,7 +1669,6 @@ mod tests {
fail_after_registered_objects: Some(1),
..MissionLoadOptions::default()
},
- None,
)
.expect_err("forced registration failure");
@@ -2326,13 +1728,7 @@ mod tests {
] {
let root = local_testdata_root(case.0);
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::new(vfs),
- )
- .expect("engine");
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
let loaded = load_mission(
&mut engine,
MissionRequest {
@@ -2365,7 +1761,7 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
- fn licensed_corpora_load_all_mission_foundations() {
+ fn licensed_corpus_part1_loads_all_mission_foundations() {
let part1 = load_all(&licensed_root("IS"));
assert_eq!(part1.missions, 29);
assert_eq!(part1.paths, 34);
@@ -2385,7 +1781,11 @@ mod tests {
assert_eq!(part1.material_slots, part1.material_resolved);
assert_eq!(part1.texture_requests, part1.texture_resolved);
assert_eq!(part1.lightmap_requests, part1.lightmap_resolved);
+ }
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_corpus_part2_loads_all_mission_foundations() {
let part2 = load_all(&licensed_root("IS2"));
assert_eq!(part2.missions, 31);
assert_eq!(part2.paths, 61);
@@ -2440,13 +1840,7 @@ mod tests {
fn run_headless_case(case: HeadlessCase) -> WorldSnapshot {
let root = licensed_root(case.root);
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::new(vfs),
- )
- .expect("engine");
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
let loaded = load_mission(
&mut engine,
MissionRequest {
@@ -2474,13 +1868,7 @@ mod tests {
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
let mut totals = LoadTotals::default();
for mission in missions {
- let mut engine = create(
- EngineConfig {
- mode: EngineMode::Headless,
- },
- EngineServices::new(vfs.clone()),
- )
- .expect("engine");
+ let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
.expect("load mission foundation");
assert_eq!(loaded.object_count, loaded.registered_objects);
diff --git a/crates/fparkan-script/Cargo.toml b/crates/fparkan-script/Cargo.toml
index 0280be7..47ee000 100644
--- a/crates/fparkan-script/Cargo.toml
+++ b/crates/fparkan-script/Cargo.toml
@@ -6,7 +6,4 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-binary = { path = "../fparkan-binary" }
diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs
index 6887a03..88c2d0a 100644
--- a/crates/fparkan-script/src/lib.rs
+++ b/crates/fparkan-script/src/lib.rs
@@ -1,5 +1,4 @@
#![forbid(unsafe_code)]
-#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))]
//! Lossless, bounded reader for compiled AI `.scr` packages.
//!
//! This module preserves the layout proven by the GOG `ai.dll` reader. It
@@ -1061,9 +1060,7 @@ impl ScriptInstruction {
#[must_use]
pub fn dispatch_selector(&self) -> ScriptDispatchSelector {
match self.header_words[0] {
- value if value < GOG_HANDLER_COUNT =>
- {
- #[allow(clippy::cast_possible_truncation)]
+ value if value < GOG_HANDLER_COUNT => {
ScriptDispatchSelector::Handler(self.header_words[0] as u8)
}
u32::MAX => ScriptDispatchSelector::Sentinel,
diff --git a/crates/fparkan-terrain-format/Cargo.toml b/crates/fparkan-terrain-format/Cargo.toml
index 28a7a9c..dcd4e9e 100644
--- a/crates/fparkan-terrain-format/Cargo.toml
+++ b/crates/fparkan-terrain-format/Cargo.toml
@@ -6,8 +6,5 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
-fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-binary = { path = "../fparkan-binary" }
+fparkan-nres = { path = "../fparkan-nres" }
diff --git a/crates/fparkan-terrain-format/src/lib.rs b/crates/fparkan-terrain-format/src/lib.rs
index 54bd3f4..639fc57 100644
--- a/crates/fparkan-terrain-format/src/lib.rs
+++ b/crates/fparkan-terrain-format/src/lib.rs
@@ -1,23 +1,4 @@
#![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};
@@ -62,16 +43,18 @@ 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 base and overlay 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`.
+/// Both selectors address the map-local `Land1.wea` table. The low byte is
+/// always the base row; the high byte is an optional overlay row and uses
+/// `0xff` as its no-overlay sentinel. `Land2.wea` is a separate detail bank
+/// selected by the corresponding detail path.
#[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,
+ /// Low-byte base selector for `Land1.wea`.
+ pub base_selector: u8,
+ /// Optional high-byte overlay selector for `Land1.wea`.
+ pub overlay_selector: Option<u8>,
}
/// One `World3D` material-manager lookup key.
@@ -98,31 +81,56 @@ 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();
+ let [base_selector, overlay] = material_tag.to_le_bytes();
Self {
- land1_selector: if land1 == u8::MAX { None } else { Some(land1) },
- land2_selector,
+ base_selector,
+ overlay_selector: if overlay == u8::MAX {
+ None
+ } else {
+ Some(overlay)
+ },
}
}
- /// Returns the table-zero `Land1.wea` selection when it exists.
+ /// Returns the base selection from `Land1.wea`.
#[must_use]
- pub fn land1_selection(self) -> Option<TerrainMaterialSelection> {
- self.land1_selector
+ pub fn base_selection(self) -> TerrainMaterialSelection {
+ TerrainMaterialSelection {
+ table_index: 0,
+ material_index: u16::from(self.base_selector),
+ }
+ }
+
+ /// Returns the optional overlay selection from `Land1.wea`.
+ #[must_use]
+ pub fn overlay_selection(self) -> Option<TerrainMaterialSelection> {
+ self.overlay_selector
.map(|material_index| TerrainMaterialSelection {
table_index: 0,
material_index: u16::from(material_index),
})
}
- /// Returns the table-one `Land2.wea` selection.
+ /// Returns the detail selection corresponding to the base row in
+ /// `Land2.wea`.
#[must_use]
- pub fn land2_selection(self) -> TerrainMaterialSelection {
+ pub fn detail_base_selection(self) -> TerrainMaterialSelection {
TerrainMaterialSelection {
table_index: 1,
- material_index: u16::from(self.land2_selector),
+ material_index: u16::from(self.base_selector),
}
}
+
+ /// Returns the optional detail selection corresponding to the overlay row
+ /// in `Land2.wea`.
+ #[must_use]
+ pub fn detail_overlay_selection(self) -> Option<TerrainMaterialSelection> {
+ self.overlay_selector
+ .map(|material_index| TerrainMaterialSelection {
+ table_index: 1,
+ material_index: u16::from(material_index),
+ })
+ }
}
/// Terrain face with 28-byte source layout.
@@ -250,7 +258,7 @@ pub struct LandMeshDocument {
/// Packed normals from type 4.
pub normals: Vec<[i8; 4]>,
/// Packed UV from type 5.
- pub uv0: Vec<[i16; 2]>,
+ pub uv0: Vec<[u16; 2]>,
/// Type 11 accelerator words.
pub accelerator: Vec<[u8; 4]>,
/// Type 14 auxiliary words.
@@ -650,7 +658,7 @@ pub fn decode_land_msh(nres: &NresDocument) -> Result<LandMeshDocument, TerrainF
validate_slots(nres, slots)?;
let positions = parse_positions(nres, positions)?;
let normals = parse_i8x4_stream(nres, TYPE_NORMALS, normals)?;
- let uv0 = parse_i16x2_stream(nres, TYPE_UV0, uv0)?;
+ let uv0 = parse_u16x2_stream(nres, TYPE_UV0, uv0)?;
let accelerator = parse_word_stream(nres, TYPE_ACCELERATOR, accelerator)?;
let aux14 = parse_word_stream(nres, TYPE_AUX14, aux14)?;
let aux18 = parse_word_stream(nres, TYPE_AUX18, aux18)?;
@@ -702,6 +710,11 @@ pub fn decode_land_map(nres: &NresDocument) -> Result<LandMapDocument, TerrainFo
let payload = nres.payload(entry.id())?;
let areal_count =
usize::try_from(meta.attr1).map_err(|_| TerrainFormatError::IntegerOverflow)?;
+ checked_count_bytes(
+ u64::from(meta.attr1),
+ u64::try_from(AREAL_PREFIX_SIZE).map_err(|_| TerrainFormatError::IntegerOverflow)?,
+ payload.len() as u64,
+ )?;
let mut cursor = Cursor::new(payload);
let mut areals = Vec::with_capacity(areal_count);
for area_index in 0..areal_count {
@@ -1091,11 +1104,11 @@ fn parse_i8x4_stream(
.collect())
}
-fn parse_i16x2_stream(
+fn parse_u16x2_stream(
nres: &NresDocument,
type_id: u32,
payload: &[u8],
-) -> Result<Vec<[i16; 2]>, TerrainFormatError> {
+) -> Result<Vec<[u16; 2]>, TerrainFormatError> {
if !payload.len().is_multiple_of(4) {
return Err(TerrainFormatError::InvalidSize {
type_id,
@@ -1107,7 +1120,7 @@ fn parse_i16x2_stream(
validate_stream(nres, type_id, 4, count)?;
let mut out = Vec::with_capacity(count);
for chunk in payload.chunks_exact(4) {
- out.push([read_i16(chunk, 0)?, read_i16(chunk, 2)?]);
+ out.push([read_u16(chunk, 0)?, read_u16(chunk, 2)?]);
}
Ok(out)
}
@@ -1330,6 +1343,7 @@ fn parse_areal_grid(
if cell_count == 0 {
return Err(TerrainFormatError::InvalidGridSize { cells_x, cells_y });
}
+ checked_count_bytes(u64::from(cell_count), 2, cursor.remaining() as u64)?;
let cell_count_usize =
usize::try_from(cell_count).map_err(|_| TerrainFormatError::IntegerOverflow)?;
let mut cells = Vec::with_capacity(cell_count_usize);
@@ -1383,13 +1397,6 @@ fn read_u16(bytes: &[u8], offset: usize) -> Result<u16, TerrainFormatError> {
Ok(u16::from_le_bytes([raw[0], raw[1]]))
}
-fn read_i16(bytes: &[u8], offset: usize) -> Result<i16, TerrainFormatError> {
- let raw = bytes
- .get(offset..offset + 2)
- .ok_or(TerrainFormatError::IntegerOverflow)?;
- Ok(i16::from_le_bytes([raw[0], raw[1]]))
-}
-
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, TerrainFormatError> {
let raw = bytes
.get(offset..offset + 4)
@@ -1593,7 +1600,7 @@ mod tests {
}
#[test]
- fn terrain_material_tag_decodes_two_sidecar_selectors() {
+ fn terrain_material_tag_decodes_base_overlay_and_detail_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");
@@ -1602,24 +1609,40 @@ mod tests {
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
- land1_selector: Some(1),
- land2_selector: 2,
+ base_selector: 2,
+ overlay_selector: Some(1),
}
);
assert_eq!(
- document.faces[0].material_layers().land1_selection(),
- Some(TerrainMaterialSelection {
+ document.faces[0].material_layers().base_selection(),
+ TerrainMaterialSelection {
table_index: 0,
- material_index: 1,
- })
+ material_index: 2,
+ }
+ );
+ assert_eq!(
+ document.faces[0]
+ .material_layers()
+ .overlay_selection()
+ .expect("overlay")
+ .phase_key(),
+ 0x0000_0001
);
assert_eq!(
document.faces[0]
.material_layers()
- .land2_selection()
+ .detail_base_selection()
.phase_key(),
0x0001_0002
);
+ assert_eq!(
+ document.faces[0]
+ .material_layers()
+ .detail_overlay_selection()
+ .expect("detail overlay")
+ .phase_key(),
+ 0x0001_0001
+ );
raw_face[4..6].copy_from_slice(&0xff03_u16.to_le_bytes());
let nres = decode_nres(&minimal_land_msh(&raw_face)).expect("nres");
@@ -1627,18 +1650,28 @@ mod tests {
assert_eq!(
document.faces[0].material_layers(),
TerrainMaterialLayers {
- land1_selector: None,
- land2_selector: 3,
+ base_selector: 3,
+ overlay_selector: None,
}
);
- assert_eq!(document.faces[0].material_layers().land1_selection(), None);
assert_eq!(
- document.faces[0].material_layers().land2_selection(),
+ document.faces[0].material_layers().base_selection(),
TerrainMaterialSelection {
- table_index: 1,
+ table_index: 0,
material_index: 3,
}
);
+ assert_eq!(
+ document.faces[0].material_layers().overlay_selection(),
+ None
+ );
+ assert_eq!(
+ document.faces[0]
+ .material_layers()
+ .detail_base_selection()
+ .phase_key(),
+ 0x0001_0003
+ );
}
#[test]
@@ -1657,6 +1690,21 @@ mod tests {
}
#[test]
+ fn land_map_bounds_area_and_grid_reservations_by_payload() {
+ let empty = build_nres(&[entry(TYPE_AREAL_MAP, u32::MAX, 0, &[])]);
+ let nres = decode_nres(&empty).expect("nres");
+ assert!(decode_land_map(&nres).is_err());
+
+ let mut payload = Vec::new();
+ push_areal_prefix(&mut payload, 0, 0);
+ push_u32(&mut payload, 65_535);
+ push_u32(&mut payload, 65_535);
+ let bytes = build_nres(&[entry(TYPE_AREAL_MAP, 1, 0, &payload)]);
+ let nres = decode_nres(&bytes).expect("nres");
+ assert!(decode_land_map(&nres).is_err());
+ }
+
+ #[test]
fn land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof() {
let nres = decode_nres(&minimal_land_map_with_poly(1, true)).expect("nres");
let document = decode_land_map(&nres).expect("land map");
diff --git a/crates/fparkan-terrain/Cargo.toml b/crates/fparkan-terrain/Cargo.toml
index cf10191..8273f3f 100644
--- a/crates/fparkan-terrain/Cargo.toml
+++ b/crates/fparkan-terrain/Cargo.toml
@@ -6,10 +6,7 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-terrain-format = { path = "../fparkan-terrain-format", version = "0.1.0" }
+fparkan-terrain-format = { path = "../fparkan-terrain-format" }
[dev-dependencies]
-fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-nres = { path = "../fparkan-nres" }
diff --git a/crates/fparkan-terrain/src/lib.rs b/crates/fparkan-terrain/src/lib.rs
index de67fd0..cd2a874 100644
--- a/crates/fparkan-terrain/src/lib.rs
+++ b/crates/fparkan-terrain/src/lib.rs
@@ -1,33 +1,16 @@
#![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
- )
-)]
//! Validated terrain runtime queries using XY ground coordinates and Z height.
-use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument};
+use fparkan_terrain_format::{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};
+pub use fparkan_terrain_format::{
+ FullSurfaceMask, TerrainMaterialLayers, TerrainMaterialSelection,
+};
/// Terrain world.
#[derive(Clone, Debug, Default)]
@@ -160,6 +143,21 @@ pub trait SurfaceQuery {
direction: [f32; 3],
mask: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError>;
+
+ /// Raycast while requiring every bit in `include` and rejecting every
+ /// face carrying a bit in `exclude`.
+ ///
+ /// The native landscape query has both predicates. The legacy
+ /// [`Self::raycast`] shorthand keeps its historical include-only form;
+ /// callers that model a native receiver or visibility test should use
+ /// this method so exclusion flags cannot be mistaken for inclusion bits.
+ fn raycast_excluding(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ include: FullSurfaceMask,
+ exclude: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, TerrainError>;
}
/// Navigation query.
@@ -424,6 +422,28 @@ impl SurfaceQuery for TerrainWorld {
direction: [f32; 3],
mask: FullSurfaceMask,
) -> Result<Option<SurfaceHit>, TerrainError> {
+ self.raycast_filtered(origin, direction, mask, FullSurfaceMask(0))
+ }
+
+ fn raycast_excluding(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ include: FullSurfaceMask,
+ exclude: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, TerrainError> {
+ self.raycast_filtered(origin, direction, include, exclude)
+ }
+}
+
+impl TerrainWorld {
+ fn raycast_filtered(
+ &self,
+ origin: [f32; 3],
+ direction: [f32; 3],
+ include: FullSurfaceMask,
+ exclude: FullSurfaceMask,
+ ) -> Result<Option<SurfaceHit>, TerrainError> {
if self.surfaces.is_empty() {
return Err(TerrainError::Unsupported);
}
@@ -432,7 +452,10 @@ impl SurfaceQuery for TerrainWorld {
let mut best: Option<SurfaceHit> = None;
for index in candidates {
let triangle = &self.surfaces[index];
- if mask.0 != 0 && triangle.mask.0 & mask.0 == 0 {
+ if include.0 != 0 && triangle.mask.0 & include.0 != include.0 {
+ continue;
+ }
+ if triangle.mask.0 & exclude.0 != 0 {
continue;
}
let Some(distance) = triangle.raycast(origin, direction) else {
@@ -1051,6 +1074,31 @@ mod tests {
.expect("raycast"),
None
);
+
+ // Native include masks require every requested bit, while exclusion
+ // masks reject any matching face bit.
+ assert_eq!(
+ world
+ .raycast_excluding(
+ [0.25, 0.25, 2.0],
+ [0.0, 0.0, -1.0],
+ FullSurfaceMask(0x0000_0003),
+ FullSurfaceMask(0),
+ )
+ .expect("raycast include all bits"),
+ None
+ );
+ assert_eq!(
+ world
+ .raycast_excluding(
+ [0.25, 0.25, 2.0],
+ [0.0, 0.0, -1.0],
+ FullSurfaceMask(0),
+ FullSurfaceMask(0x0000_0001),
+ )
+ .expect("raycast exclusion"),
+ None
+ );
}
#[test]
diff --git a/crates/fparkan-test-support/Cargo.toml b/crates/fparkan-test-support/Cargo.toml
deleted file mode 100644
index 3649ded..0000000
--- a/crates/fparkan-test-support/Cargo.toml
+++ /dev/null
@@ -1,12 +0,0 @@
-[package]
-name = "fparkan-test-support"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-fparkan-render = { path = "../fparkan-render", version = "0.1.0" }
-
-[lints]
-workspace = true
diff --git a/crates/fparkan-test-support/src/lib.rs b/crates/fparkan-test-support/src/lib.rs
deleted file mode 100644
index cc2e9e8..0000000
--- a/crates/fparkan-test-support/src/lib.rs
+++ /dev/null
@@ -1,44 +0,0 @@
-#![forbid(unsafe_code)]
-#![cfg_attr(
- test,
- allow(
- clippy::cast_possible_truncation,
- clippy::cast_possible_wrap,
- clippy::cast_precision_loss,
- clippy::expect_used,
- clippy::float_cmp,
- clippy::identity_op,
- clippy::too_many_lines,
- clippy::uninlined_format_args,
- clippy::map_unwrap_or,
- clippy::needless_raw_string_hashes,
- clippy::semicolon_if_nothing_returned,
- clippy::type_complexity,
- clippy::panic,
- clippy::unwrap_used
- )
-)]
-//! Dev-only synthetic builders and fake ports.
-
-use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
-
-/// Fake clock.
-#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
-pub struct FakeClock {
- /// Current tick.
- pub tick: u64,
-}
-
-/// Recording backend.
-#[derive(Clone, Debug, Default)]
-pub struct RecordingRenderBackend {
- /// Recorded command lists.
- pub captures: Vec<RenderCommandList>,
-}
-
-impl RenderBackend for RecordingRenderBackend {
- fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
- self.captures.push(commands.clone());
- Ok(FrameOutput)
- }
-}
diff --git a/crates/fparkan-texm/Cargo.toml b/crates/fparkan-texm/Cargo.toml
index 5845efb..b6f9424 100644
--- a/crates/fparkan-texm/Cargo.toml
+++ b/crates/fparkan-texm/Cargo.toml
@@ -8,7 +8,4 @@ repository.workspace = true
[dependencies]
[dev-dependencies]
-fparkan-nres = { path = "../fparkan-nres", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-nres = { path = "../fparkan-nres" }
diff --git a/crates/fparkan-texm/src/lib.rs b/crates/fparkan-texm/src/lib.rs
index c73bb95..8747737 100644
--- a/crates/fparkan-texm/src/lib.rs
+++ b/crates/fparkan-texm/src/lib.rs
@@ -1,23 +1,4 @@
#![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;
diff --git a/crates/fparkan-vfs/Cargo.toml b/crates/fparkan-vfs/Cargo.toml
index e5fab96..2a20dda 100644
--- a/crates/fparkan-vfs/Cargo.toml
+++ b/crates/fparkan-vfs/Cargo.toml
@@ -6,8 +6,5 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
-fparkan-path = { path = "../fparkan-path", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-binary = { path = "../fparkan-binary" }
+fparkan-path = { path = "../fparkan-path" }
diff --git a/crates/fparkan-vfs/src/lib.rs b/crates/fparkan-vfs/src/lib.rs
index 0b25056..3c92fa1 100644
--- a/crates/fparkan-vfs/src/lib.rs
+++ b/crates/fparkan-vfs/src/lib.rs
@@ -1,23 +1,4 @@
#![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};
@@ -312,7 +293,7 @@ pub struct MemoryVfs {
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_bytes().to_vec();
self.files.insert(path, bytes);
@@ -347,13 +328,13 @@ impl MemoryVfs {
}
#[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
diff --git a/crates/fparkan-world/Cargo.toml b/crates/fparkan-world/Cargo.toml
index 0b0d900..a78fcd1 100644
--- a/crates/fparkan-world/Cargo.toml
+++ b/crates/fparkan-world/Cargo.toml
@@ -6,7 +6,4 @@ license.workspace = true
repository.workspace = true
[dependencies]
-fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
-
-[lints]
-workspace = true
+fparkan-binary = { path = "../fparkan-binary" }
diff --git a/crates/fparkan-world/src/lib.rs b/crates/fparkan-world/src/lib.rs
index e14ac97..41c2038 100644
--- a/crates/fparkan-world/src/lib.rs
+++ b/crates/fparkan-world/src/lib.rs
@@ -1,23 +1,4 @@
#![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;
diff --git a/deny.toml b/deny.toml
deleted file mode 100644
index 7c14ac5..0000000
--- a/deny.toml
+++ /dev/null
@@ -1,30 +0,0 @@
-[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"]
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index 6f9553e..661069a 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -98,42 +98,3 @@ typed schema свойств по consumers, а не по одному имени
Такие ветки реализуются по бинарному коду и synthetic tests, а статус
corpus-verified получают только после реального файла или runtime trace.
-
-## Dynamic-stage requirements
-
-Оставшиеся вопросы нельзя закрыть только статическими архивами. Нужна
-изолированная 32-bit Windows-среда, неизменённые игровые каталоги, manifest
-SHA-256, debugger, API/vtable hooks, controlled clocks/input и автоматический
-launcher, который восстанавливает snapshot, запускает один test case, собирает
-логи и завершает процесс без ручного вмешательства.
-
-Для каждого capture сохраняются build profile, module hashes, mission/resource
-key, configuration, device profile, initial state, input/time script и версии
-инструментов.
-
-## Local evidence requests
-
-Поддерживаемая desktop-платформа — только Windows/Vulkan. Локальный smoke
-`fparkan-vulkan-smoke` уже подтверждает настоящий Win32 surface/swapchain,
-300 кадров, controlled resize и отсутствие validation warnings/errors.
-Это закрывает фундамент Stage 0, но не доказывает визуальный паритет игры.
-
-Следующие доказательства всё ещё нужны именно для Iron3D-compatible рендера:
-
-- capability-gated capture из оригинального GOG процесса с camera/matrix,
- draw/state и frame-boundary provenance;
-- фиксированные Windows Vulkan captures статической модели, lightmapped модели
- и terrain после совпадения backend-neutral command capture;
-- затем controlled captures анимации, FX, прозрачности, теней и атмосферы.
-
-Linux/macOS, GLES2, RG40XX и удалённые portability runners не являются
-acceptance-гейтами этого проекта. Они не должны появляться в списке блокеров
-или подменять Windows evidence.
-
-## Closure criteria
-
-Вопрос считается закрытым только при наличии build fingerprint, raw trace,
-parser trace-а, минимального воспроизводимого input/resource/save/message,
-формального контракта или явно ограниченной гипотезы, differential test для
-изменённых DLL, обновления тематической главы и regression case, запускаемого
-без ручного анализа.
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index afc63e5..64dc4fc 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -10,7 +10,7 @@ messages, teleports, задачи, research и campaign transitions. Точки
и `briefing.cfg`.
`.scr` — binary package с version checks, symbol/event sections и offsets;
-полная opcode grammar не доказана. Его внешний framing теперь читает
+полная 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 вложенных
@@ -52,18 +52,11 @@ number, size, operands, control flow, effects, errors и минимальный
GOG `ai.dll` доказывает этот framing двумя consumer-ами: loader по
`0x10001000` открывает `<bundle>.scr`, `varset.var`, `<bundle>.fml`, затем
собирает ровно 73 pointers handlers; `0x10011b20` читает описанную count-driven
-структуру. Команда
+структуру. В GOG `c1m2p.scr` reader находит 73 handler slots,
+9 events, 17 nested records и 20 references, заканчивая на EOF.
+Это подтверждает разбор пакета, но не смысл всех opcodes.
-```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 в
+Selector устроен следующим образом: loader `0x10001000` создаёт 73 pointers в
фиксированном порядке, а `0x10011e70` копирует их без перестановки в runtime
array. Во всех 58 GOG `.scr` первый header word каждого nested record равен
`0..72` либо `0xffff_ffff`: соответственно 2095 handler selectors и 3992
@@ -115,24 +108,21 @@ unsupported result, а не «примерный» game command.
записи `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`: его
+находится в отдельном позднем update path.
+
+Во всех 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`.
+`0x100174a0`.
-Следующий pass восстанавливает эту индексацию. `0x100174a0` добавляет каждый
+`0x100174a0` добавляет каждый
recognized source declaration в encounter order как 48-byte record; GOG shared
`varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries
образуют точно это index space. `0x10013570` возвращает `DWORD` record kind
@@ -141,10 +131,9 @@ capture. Полный GOG scan всех 246 Handler(30) instructions показÑ
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`.
+out-of-range и incomplete instructions вместо silent coercion.
-Следующий static pass закрывает equality/update policy. Identity ровно равна
+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`. При
@@ -154,12 +143,11 @@ slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+
изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode,
не назначает игровых имён и не делает event lookup за original VM.
-На границе mission runtime выбранный TMA clan `first_resource` теперь
+На границе 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-ятся, а ошибка чтения сохраняет
+package вместе с clan provenance. Это именно wiring входных данных, не VM
+execution: Init и остальные events пока не dispatch-ятся, а ошибка чтения сохраняет
transactional rollback mission loader-а.
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
@@ -214,9 +202,9 @@ 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
+`ai.dll + 0x55398` for `n <= 63`. A read-only capture of the running GOG
+AutoDemo process observed `(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.
@@ -224,12 +212,7 @@ 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`.
+remaining script event semantics.
### Runtime Handler(30) operand binding
@@ -264,12 +247,7 @@ 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
+Runtime applies only this 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
@@ -292,9 +270,7 @@ 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`.
+the table owner, the pre-reset helper, or callback semantics.
### Handler(15): typed target-call boundary
@@ -322,8 +298,7 @@ with that record and the third word. Its zero/non-zero result becomes
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`.
+and unobserved-mode inputs are explicit errors.
## Готовность
diff --git a/docs/baseline/current-project-audit.md b/docs/baseline/current-project-audit.md
deleted file mode 100644
index 0dcde93..0000000
--- a/docs/baseline/current-project-audit.md
+++ /dev/null
@@ -1,83 +0,0 @@
-# Current Project Audit
-
-Baseline command:
-
-```text
-cargo xtask ci
-```
-
-Result on 2026-07-18:
-
-- 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.
diff --git a/docs/baseline/notion-reconciliation.md b/docs/baseline/notion-reconciliation.md
deleted file mode 100644
index 8cf2e5d..0000000
--- a/docs/baseline/notion-reconciliation.md
+++ /dev/null
@@ -1,88 +0,0 @@
-# Сверка локальной книги с 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 уже разнесены по
-тематическим локальным документам, а новые результаты разработки должны
-добавляться только локально.
diff --git a/docs/baseline/vulkan-revision-plan.md b/docs/baseline/vulkan-revision-plan.md
deleted file mode 100644
index 868f408..0000000
--- a/docs/baseline/vulkan-revision-plan.md
+++ /dev/null
@@ -1,158 +0,0 @@
-# План реализации: 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`.
diff --git a/docs/evidence/original_engine_hashes.md b/docs/evidence/original_engine_hashes.md
deleted file mode 100644
index fab8dc7..0000000
--- a/docs/evidence/original_engine_hashes.md
+++ /dev/null
@@ -1,65 +0,0 @@
-# Original Engine Hashes
-
-Страница фиксирует минимальный статический baseline, на который должны
-ссылаться capture fixtures и Stage 4 evidence.
-
-## Scope
-
-- Источник: локальная статическая сверка Part 1 (`IS`) и Part 2 (`IS2`).
-- Метод: SHA-256, export/import tables, `objdump -p`, `strings`.
-- Эта страница не заменяет динамические traces: она задаёт only-if-match
- binary baseline для дальнейших runtime captures.
-
-## Stable binaries across Part 1 / Part 2
-
-| Binary | SHA-256 | Size | Notes |
-| --- | --- | ---: | --- |
-| `Ngi32.dll` | `bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5` | 253952 | Shared low-level render/resource/audio boundary |
-| `World3D.dll` | `17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e` | 208896 | Shared gameplay/world/render lifecycle baseline |
-| `Terrain.dll` | `6d3e68f0e15b297f6c184af3113baf1f31e19c3326c18f0150dec659242ed667` | 708608 | Shared terrain/shade/world baseline |
-| `iron_3d.exe` / `iron_3d_p2.exe` | `f476af85c034a4b4f34f49d0806e4dff397b5da0ee26d382a7674231144979f7` | 36864 | Shared launcher binary |
-
-## GOG research baseline
-
-The canonical disassembly source is the windowed GOG installation, not the
-Part 1/Part 2 test installations. Its `Terrain.dll` is a distinct revision:
-
-| Binary | SHA-256 | Size | Notes |
-| --- | --- | ---: | --- |
-| `Terrain.dll` | `af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` | 499712 | GOG camera receiver evidence; do not reuse Part 1/2 RVAs without a matching hash |
-
-## Divergent binaries across Part 1 / Part 2
-
-Эти модули нельзя автоматически считать behavior-compatible между частями:
-
-- `AniMesh.dll`
-- `Effect.dll`
-- `iron3d.dll`
-- `services.dll`
-- `Control.dll`
-- `ArealMap.dll`
-
-## Practical use
-
-1. Frame-order traces для `World3D.dll`, `Terrain.dll` и `Ngi32.dll` можно
- привязывать к shared profile, пока hash совпадает.
-2. Animation and FX captures обязаны храниться раздельно для Part 1 и Part 2,
- потому что `AniMesh.dll` и `Effect.dll` отличаются.
-3. Любой runtime fixture должен записывать минимум:
- - `game_part`
- - `module_name`
- - `module_sha256`
- - `mission`
- - `frame_or_tick`
- - `schema_version`
-
-## Export / import focus for Stage 4
-
-- `World3D.dll`: `stdCalculateGame`, `stdRenderGame`, `sendEndOfRender`
-- `Terrain.dll`: `GetShade`, `GetWorld`, `stdSetCurrentCamera2`
-- `AniMesh.dll`: `LoadAgent`, `LoadAniMesh`
-- `Effect.dll`: `CreateFxManager`, `InitializeSettings`
-- `Ngi32.dll`: `niGet3DRender`, `n3dPrimitive`, `n3dEndScene`, `rsLoadTexture`, `rsLoadMultiTexture`
-
-Если будущий capture fixture не указывает, к какому hash он относится, такой
-fixture нельзя считать acceptance evidence.
diff --git a/docs/evidence/stage4_capture_schema.md b/docs/evidence/stage4_capture_schema.md
deleted file mode 100644
index 18efd91..0000000
--- a/docs/evidence/stage4_capture_schema.md
+++ /dev/null
@@ -1,107 +0,0 @@
-# Stage 4 Capture Schema
-
-Stage 4 нельзя закрывать набором ad-hoc логов. Нужна схема, по которой
-animation, FX и rendered frame captures сравниваются между Part 1, Part 2 и
-современной реализацией.
-
-## Goals
-
-- сделать captures пригодными для автоматического diff;
-- не хранить host-specific пути, временные каталоги и нестабильные handles;
-- связывать frame traces, command captures и pixel artifacts общим identity.
-
-## Common envelope
-
-```json
-{
- "schema_version": "fparkan-stage4-capture-v1",
- "capture_kind": "frame-trace | animation-pose | fx-lifecycle | render-frame",
- "game_part": "part1 | part2",
- "mission": "MISSIONS/.../data.tma",
- "frame_id": 123,
- "tick": 123,
- "module_hashes": {
- "World3D.dll": "sha256...",
- "Terrain.dll": "sha256...",
- "AniMesh.dll": "sha256...",
- "Effect.dll": "sha256..."
- },
- "tool_version": "codex/manual/fixture version",
- "notes": []
-}
-```
-
-## Capture kinds
-
-### `frame-trace`
-
-Используется для порядка фаз и внешних вызовов.
-
-Required fields:
-
-- `events`: ordered list of `{ phase, symbol, sequence, object_id?, fx_id?, camera_id? }`
-- `queue_counters`: deferred operations, visible objects, emitted FX, UI callbacks
-- `rng_state`: optional, if recoverable
-
-### `animation-pose`
-
-Используется для x87 / portable sampler parity.
-
-Required fields:
-
-- `clip_id`
-- `node_index`
-- `sample_time`
-- `numeric_profile`
-- `translation`
-- `rotation_quat`
-- `scale`
-- `matrix_hash`
-
-### `fx-lifecycle`
-
-Используется для create/update/emit/stop parity.
-
-Required fields:
-
-- `fx_id`
-- `instance_id`
-- `time`
-- `opcode_events`
-- `rng_calls`
-- `resource_refs`
-- `emissions`
-
-### `render-frame`
-
-Связывает backend-neutral snapshot с live Vulkan output.
-
-Required fields:
-
-- `camera`
-- `visible_object_ids`
-- `draws`
-- `pipeline_keys`
-- `resource_ids`
-- `validation`
-- `pixel_artifact`
-
-## Stability rules
-
-1. Не записывать абсолютные host paths.
-2. Не записывать raw pointer addresses как identity fields.
-3. `frame_id` должен совпадать между trace, command capture и pixel artifact.
-4. GPU-specific transient handles допустимы только внутри diagnostics fields и
- не участвуют в canonical equality.
-5. Любой capture without `module_hashes` считается informational, а не
- acceptance-grade.
-
-## Acceptance mapping
-
-- `S4-TRACE-*` rows читают `frame-trace`
-- `S4-ANIM-*` rows читают `animation-pose`
-- `S4-FX-*` rows читают `fx-lifecycle`
-- `S4-VK-*` и `S4-PIXEL-*` rows читают `render-frame`
-
-Эта схема intentionally минимальна. Новые поля можно добавлять, но нельзя
-ломать перечисленные identity and parity anchors без смены `schema_version`.
diff --git a/docs/index.md b/docs/index.md
index ea17a63..b6e62f4 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -1,72 +1,34 @@
# FParkan
-FParkan -- самостоятельная техническая книга о восстановлении игрового движка
-Iron3D из *Parkan: Iron Strategy*. Она ведёт от запуска оригинальной программы
-и карты DLL к форматам ресурсов, загрузке миссии, геометрии, материалам,
-рендеру, поведению, звуку, сети и плану чистой совместимой реализации.
-
-Сайт оформлен как онлайн-книга: тома читаются последовательно, а справочник
-используется как быстрый доступ к форматам, проверочным правилам и границам
-доказанного знания.
-
-## Источник истины и синхронизация
-
-Каталог `docs/` — единственный рабочий источник истины для FParkan. 18 июля
-2026 года его содержательно сопоставили с книгой FParkan в Notion; результат,
-маршрутизация статей и сознательно исключённые цели зафиксированы в
-[сверке](baseline/notion-reconciliation.md). Актуальные локальные материалы
-содержат и последующие результаты разработки. Notion больше не
-является местом внесения изменений: новые факты, исправления и решения
-фиксируются только здесь, в томе, справочнике или evidence-документе, которому
-они принадлежат.
-
-При расхождении приоритет имеют более новое локальное доказательство и текущий
-scope проекта: самостоятельный runtime ориентирован исключительно на Windows и
-Vulkan. Исторические упоминания других ОС в старых внешних заметках не
-расширяют поддерживаемую платформу.
-
-Действующий dependency-ordered план `stage 0--5` находится в
-[Vulkan revision для Windows](baseline/vulkan-revision-plan.md). Это
-редакторская локальная версия: она включает недостающие контрактные требования
-из Notion, но не переносит снятые с проекта Linux/macOS и hosted-CI цели.
-
-## Как читать
-
-Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится
-лексика, доказательная политика, модульная архитектура и жизненный цикл кадра.
-
-Если нужна реализация совместимого движка, читайте тома III--VII линейно:
-ресурсы, миссии, мир, рендер, интерактивные подсистемы и порядок работ.
-
-Если вы проверяете выводы, переходите к тому VIII и приложениям. Там собраны
-уровни уверенности, corpus gates, открытые вопросы и критерии закрытия.
-
-## Восемь томов
-
-1. **Путеводитель и методика** -- назначение книги, маршруты чтения, язык
- предметной области и правила проверки.
-2. **Запуск, архитектура и игровой цикл** -- `iron_3d.exe`, пятнадцать DLL,
- сервисы, World3D, очередь объектов и границы кадра.
-3. **Ресурсная система и форматы** -- NRes, RsLi, кэши, имена, `objects.rlb`,
- unit DAT и сквозное разрешение ресурсов.
-4. **Мир, миссии и runtime** -- TMA, ландшафт, ареалы, маршруты, создание мира
- и свойства размещённых объектов.
-5. **Геометрия, материалы и рендер** -- MSH, анимация, WEAR, MAT0, Texm, FXID,
- свет, атмосфера и полный render frame.
-6. **Поведение, управление, звук и сеть** -- AI, Behavior, Wizard, Control,
- ввод, камера, звук и DirectPlay-слой.
-7. **Руководство по полной реализации** -- целевая архитектура, этапы работ,
- тестовый контур, точность, скорость и критерий совместимости.
-8. **Справочник и доказательная база** -- ABI, конфигурация, статистика
- корпусов, границы знания и глоссарий.
-
-## Политика доказательств
-
-Специфические утверждения об Iron3D принимаются только после локальной проверки
-на исполняемых файлах, DLL, демоверсии, полных каталогах Частей 1 и 2 или на
-взаимных инвариантах реальных ресурсов. Внешние описания и текущий код FParkan
-могут подсказывать вопросы, но не заменяют проверку.
-
-Неизвестные поля не получают правдоподобных имён. Пока смысл не закрыт,
-документация фиксирует raw layout, границы, безопасное чтение и lossless
-сохранение.
+Эта книга рассказывает, как устроен движок *Паркан: Железная стратегия* и как
+FParkan воспроизводит его работу на Rust и Vulkan. Начнём с простого вопроса:
+что должно произойти между выбором миссии и появлением первого кадра?
+Ответ связывает архивы, модели, поверхность карты, камеру, свет и поведение
+объектов в одну программу.
+
+Главы читаются последовательно. Для знакомства с игровой разработкой
+подойдут первые две; подробные структуры файлов можно искать в справочнике.
+
+1. [Путеводитель](tomes/01-guide.md): основные понятия и чтение бинарных структур.
+2. [Архитектура и игровой цикл](tomes/02-architecture.md): запуск, мир и кадр.
+3. [Ресурсы](tomes/03-resources.md): архивы, имена и связи файлов.
+4. [Миссии и ландшафт](tomes/04-world.md): размещение объектов и поверхность.
+5. [Геометрия и рендер](tomes/05-render.md): путь от вершины до пикселя.
+6. [Поведение, управление и звук](tomes/06-behavior.md): интерактивный мир.
+7. [Работа над движком](tomes/07-implementation.md): код, тесты и проверка результата.
+8. [Устройство оригинальной программы](tomes/08-evidence.md): DLL, адреса и конфигурация.
+
+В справочнике можно быстро сверить [NRes](reference/nres.md),
+[RsLi](reference/rsli.md), [TMA](reference/tma.md), [MSH](reference/msh.md),
+[материалы](reference/materials.md), [Texm](reference/texm.md),
+[кадр рендера](reference/render-frame.md), [атмосферу и небо](reference/atmosphere.md)
+и [эффекты окружения](reference/environment-effects.md). Таблица DLL находится
+в [описании оригинальных модулей](reference/original-binaries.md).
+
+В [глоссарии](appendices/glossary.md) собраны термины, а в
+[открытых вопросах](appendices/knowledge-boundaries.md) — ещё не восстановленное
+поведение. Описание алгоритма оригинала и возможности текущего приложения
+различаются явно. Предположение остаётся предположением, пока его не
+подтверждают игровые файлы или код оригинала.
+
+Команды запуска и проверки приведены в [README](../README.md).
diff --git a/docs/reference/atmosphere.md b/docs/reference/atmosphere.md
new file mode 100644
index 0000000..65ed176
--- /dev/null
+++ b/docs/reference/atmosphere.md
@@ -0,0 +1,236 @@
+# Атмосфера и небо
+
+Атмосфера Parkan задаётся двумя связанными ресурсами: `sky.ske` хранит
+расписание, цвета и параметры, а `sky.wea` сопоставляет позиционные строки с
+материалами. Ресурсы сначала разбираются в `TypedAtmosphere` и `SkyMaterials`,
+затем на каждом кадре из расписания получается `AtmosphereFrame`. Модуль
+`fparkan-fx::sky` превращает этот кадр в данные для рендерера: постоянную
+геометрию купола, динамические цвета вершин, небесные слои, положение
+светил, туман и нижнюю границу освещения.
+
+## Игровые сутки и смена дня и ночи
+
+Вызов `TypedAtmosphere::sample(real_seconds)` принимает секунды от начала
+повторяющегося расписания. Дорожки идут в порядке файла и занимают свои
+реальные длительности; после последней дорожки время возвращается к началу.
+Внутри выбранной дорожки игровой день всегда проходит от 00:00 до 24:00:
+
+```text
+track_seconds = real_seconds_in_track
+ day_seconds = track_seconds / track_duration * 86400
+```
+
+Время ключа вычисляется по часам и минутам даты. Нативный конструктор
+игнорирует сохранённое поле секунд, поэтому для планировщика используется:
+
+```text
+duration = (end_hour * 60 + end_minute) * 60
+key_time = floor(duration * (key_hour * 60 + key_minute) * 60 / 86400)
+```
+
+Сэмплер ищет предыдущий и следующий ключ во всём расписании. Переход через
+конец дорожки или конец цикла интерполируется так же, как переход внутри
+дорожки. `SkeDate::seconds_of_day()` сохраняет все три компонента даты для
+инструментов; оно не меняет формулу реального времени расписания.
+
+День и ночь в самом формате не являются отдельным флагом. Их вид получается
+из интерполированных цветовых параметров купола и состояния объектов солнца и
+луны. Ключи с kind `0` и `1` запускают и останавливают небесное светило. Для
+активного интервала модуль восстанавливает рождение объекта: ищет ближайший
+предыдущий ключ `SunStart`, следующий соответствующий `SunStop`, вычисляет
+реальное время начала и длительность, включая переход через цикл, и сохраняет
+эти значения в `SunBirth`.
+
+Имя в первой фиксированной строке определяет объект. Для строки `sun`
+нативный код использует углы `[90, 30]` градусов и строку материала 3; для
+`moon` — `[0, 50]` и строку 4. Начальный вектор орбиты строится по формуле
+`[cos(theta), 0, -sin(theta)]`, где
+
+```text
+theta = (clamp((now - birth_start) / lifetime, 0, 1) * 1.2 - 0.1) * pi
+```
+
+Затем к нему применяется матрица рождения. У солнца и луны независимые
+интервалы и независимые направления: при отсутствии активной луны нельзя
+подменять её направление противоположностью солнца. `SunFrame::color` — это
+единственный primary directional RGB. Второй источник получает направление
+`-SunFrame::direction` и RGB из `SunSample::packed[1]`; оба источника для
+активного объекта находятся в `SkyFrame::directional_lights` (сначала солнце,
+затем луна). Экранная геометрия использует `-SunFrame::direction`, а
+`world_center` размещает её по нативной формуле относительно camera
+translation и половины far plane. Материал, строка и состояние объекта
+берутся из его `SunBirth` и таблицы `sky.wea`.
+
+## Купол
+
+Оригинальный `CSky` строит конечный купол, а не кубическую skybox. Значения
+конструктора, подтверждённые в Terrain, такие: высота `10000`,
+`theta_max = pi / 4`, вертикальный масштаб `1`, `16` азимутальных секторов и
+`5` колец. Радиус вычисляется как
+
+```text
+radius = height * 0.5 / sin(theta_max * 0.5)^2
+```
+
+Вершина с индексом 0 находится в зените `[0, 0, height * z_scale]`.
+Остальные вершины идут в порядке `azimuth`, затем `ring`; для них
+
+```text
+phi = azimuth / azimuth_count * 2*pi
+theta = (ring + 1) / rings * theta_max
+x = radius * sin(theta) * sin(phi)
+y = radius * sin(theta) * cos(phi)
+z = (radius * cos(theta) + height - radius) * z_scale
+```
+
+На сектор приходится один треугольник веера и по два треугольника на каждую
+пару соседних колец. Текстурные координаты трёх исходных стадий используют
+масштабы `[1, 15, 3]` для `position.x / radius` и `position.y / radius`.
+Цвет зенита и первого пояса берётся из palette 12. Следующие пояса используют
+группы palette 8..11, 4..7 и 0..3 с интерполяцией по азимуту.
+
+Позиции, signed normal bytes, UV и индексы создаются один раз через
+`SkyGeometryConfig::mesh`/`SkySystem::new`. Последующие кадры меняют только
+цвета вершин методом `SkyMesh::update_colors`; индексы и topology не
+пересоздаются. Ошибка конфигурации возвращается как `SkyGeometryError`, а
+ошибка входного кадра — как `SkyUpdateError`; они не превращаются в пустой
+буфер. Рендерер может загрузить `SkySystem::mesh()` в статический
+vertex/index buffer и обновлять только небольшой цветовой диапазон.
+
+Нормали купола повторяют native `Terrain47BFF` contract: каждый компонент
+хранится как signed `i8`, округляется режимом nearest-even и умножается на
+`127.0`. `SkyVertex::normal_vector()` декодирует эти bytes делением на `127`;
+зенит имеет `[0, 0, 127]`. Вершинный backend должен передавать signed byte
+representation без трактовки его как unsigned `0..255`.
+
+## Облака, звёзды и спрайты
+
+`sky.wea` — позиционная таблица. В стандартном AutoDemo строки имеют роли:
+
+| Строка | Роль | Имя в AutoDemo |
+|---:|---|---|
+| 0 | фон/туманность | `ENV_NEBULA_0` |
+| 1 | звёзды | `ENV_STARS` |
+| 2 | облака | `ENV_CLOUDS` |
+| 3 | солнце | `ENV_SUN_3` |
+| 4 | луна | `ENV_MOON` |
+| 5 | первый flare | `ENV_FLARE_00` |
+| 6 | второй flare | `ENV_FLARE_01` |
+| 7 | снежинка | `SNOWFLAKE` |
+| 8 | капля дождя | `RAIN_DROP` |
+
+Это наблюдаемые строки, а не глобальные имена, на которые можно полагаться в
+миссии. `SkyMaterials::parse` сохраняет числовой id и имя каждой строки;
+миссия может заменить любой материал. `SkyLayerKind` и `SkyLayerFrame` дают
+роль, позиционную строку, активность, intensity, UV stage и фазу времени.
+
+Нативный порядок купола находится в `SkyFrame::passes`: экранный gradient,
+nebula row 0 со stars row 1 как второй texture (material mode 4), dome
+gradient и clouds row 2. Для nebula/stars/clouds используются UV scales
+`1/15/3`; clouds получают translation Z `-5000` относительно камеры и
+directional RGB из `sky.packed[0]`. Это четыре фиксированных pass, а не три
+независимых цветных слоя.
+
+Цвет первого screen-gradient и последнего пояса вычисляет
+`SkyFrame::screen_gradient(yaw_radians, glare_rgb_delta)`. Native `CSky` сначала
+округляет `(yaw + pi) * 180 / pi` режимом nearest-even, выбирает сектор
+`(floor(degrees / 90) - 2) mod 4`, а затем смешивает соседние цвета
+`SkySample::colors[0..4]`. Остаток сектора умножается на точный коэффициент
+из PE `65F64` (`0x3C360B61`, примерно `0.01113567`), alpha результата равна
+`255`. `glare_rgb_delta` — входной RGB delta от light manager: для каждого
+канала это `max(current_primary_light_rgb - base_sun_rgb, 0)`. Каждый компонент
+затем преобразуется так: `d <= 1 -> d*0.8`, `1 < d <= 3 -> d*0.1 + 0.7`,
+`d > 3 -> 1`.
+`SkyGradientFrame::horizon_floor` задаёт RGB minimum для dome palette, а
+`clamp_dome_color` применяет его, сохраняя alpha исходного цвета.
+
+Солнце, луна и две строки flare являются направленными экранными слоями.
+Нативный quad использует UV
+`[[.005,.005],[.005,.995],[.995,.995],[.995,.005]]`. Полуразмеры sun/moon в
+пикселях вычисляет `sprite_half_size_pixels`: `viewport_width / horizontal_fov
+* .325 * .5 * values[0/1]`, где `values` — первые два tail float SKE.
+Цвет sprite и его alpha берутся из `SunSample::packed[0]`, отдельно от
+directional `SunFrame::color`.
+
+`SkyFrame::sun_optics` принимает projected sun, viewport, camera forward и
+результат world-ray visibility. Он применяет native 15-degree glare cone и
+возвращает фиксированные 12 `FlareQuad`; flare slots активны только при
+видимом солнце, `length(primary RGB) > 1.1` и cone amount `<= .1`. При cone
+`> .1` остаётся только glare boost. Renderer отвечает за сам projection и
+occlusion query.
+
+Дождь, снег и молния используют те же состояния расписания, но их camera-local
+геометрия и звуки обновляются `EnvironmentSystem`. Для активного интервала
+ресурсы берутся только из ключа, который его запустил. Интенсивность дождя,
+снега и молнии — четвёртый trailing float; цвет активной погоды использует
+RGB palette 12 с минимумом `80/255` на канал и alpha `150/255`.
+
+## Туман и свет
+
+Нативное обновление `CSky` записывает две дальности и один packed RGB.
+`FogFrame` повторяет этот контракт:
+
+```text
+fog.start = sky.values[0] * 700
+fog.end = sky.values[1] * 700
+fog.color = RGB(sky.packed[1]) / 255
+```
+
+Поля не скрывают порядок исходных параметров: значения сохраняются в
+`SkySample`, а преобразование выполняется только в `fog_frame`. Vulkan path
+передаёт цвет и дальности в frame uniforms; vertex shader вычисляет линейный
+коэффициент по расстоянию до камеры, а fragment shader смешивает RGB материала
+с fog color в ветвях combiner, которые используют туман. Конкретный draw state
+может обходить это смешивание.
+
+Цвет primary света солнца — RGB palette 12, нормированный делением на 255 и
+умноженный на trailing parameter 2. `SunFrame::direction` — нормированный
+вектор после матрицы рождения и направление primary light; sprite/world
+geometry использует его противоположность. Второй directional RGB берётся из
+packed-параметра 1 без дополнительного умножения intensity. Когда объект
+неактивен, sampled поля сохраняются, но оба light slot и sprite inactive.
+
+В native `CShade` sampled RGB из состояния атмосферы также устанавливает
+глобальную нижнюю границу освещения. Для каждого RGB-канала она объединяется
+операцией `max` с накопленным цветом материала. Поэтому `SkyFrame` отдаёт
+`lighting_floor` из того же sampled packed RGB, а владелец renderer передаёт
+его в глобальный light state. Фиксированный ambient-цвет в этом месте меняет
+ночной уровень и не соответствует цепочке Terrain → CShade → Ngi32.
+
+## Бинарный формат `sky.ske`
+
+Файл little-endian, версия 5. В начале находятся `i32 marker = -1`,
+`u32 version = 5` и число дорожек. Дорожка содержит `u32 version = 1`, число
+ключей, две даты по 32 байта и ключи. Дата состоит из восьми `u32`; для
+планировщика значимы слова 3, 4 и 5 (часы, минуты, секунды), а остальные слова
+сохраняются без интерпретации.
+
+Ключ имеет `u32 version = 3`, дату, raw kind, opaque word, четыре packed ARGB
+цвета, два `f32`, пятнадцать packed ARGB цветов, шесть length-prefixed строк,
+четыре trailing `f32` и список length-prefixed ссылок. Строка хранится как
+`u32 byte_length` и ровно столько байт без завершающего NUL. Неизвестный kind
+сохраняется как `AtmosphereKind::Unknown`, как и opaque слова/строки.
+
+В конце находятся дата и два opaque `u32`. Декодер ограничивает количества и
+размеры строк, отклоняет NaN/Infinity во float-полях и требует точного конца
+файла. Трейлер выбирает исходную дорожку и позицию час/минута; метод
+`initial_offset_seconds()` складывает длительности предыдущих дорожек и
+применяет ту же формулу floor.
+
+Из интерполированного ключа `SkySample` формируется в исходном порядке:
+
+```text
+colors = [header[1], header[2], header[0], header[3],
+ palette[0], palette[3], palette[1], palette[2],
+ palette[4], palette[7], palette[5], palette[6], palette[8]]
+values = header_values
+packed = [palette[11], palette[13]]
+```
+
+Солнце и луна используют palette 12 и trailing parameter 2 для RGB,
+parameters 0 и 1 сохраняются как raw values, а packed-параметры светила —
+palette 10 и 14. Kind `0/1` управляет солнцем и луной по имени, `3/4` —
+дождём, `5/6` — снегом, `8/9` — молнией; kind `2` и `7` меняют
+интерполируемое состояние. Поля, для которых в native renderer ещё нет
+проверенного назначения, остаются raw в Rust API и не получают придуманных
+значений по умолчанию.
diff --git a/docs/reference/environment-effects.md b/docs/reference/environment-effects.md
new file mode 100644
index 0000000..31024f7
--- /dev/null
+++ b/docs/reference/environment-effects.md
@@ -0,0 +1,203 @@
+# Эффекты окружения
+
+Погода в Parkan состоит из двух связанных частей. Файл `sky.ske` задаёт
+расписание: когда начинается дождь или снег, как меняются интенсивность и цвет,
+какие имена ресурсов принадлежат активному интервалу. Таблица `sky.wea` задаёт
+материалы, которыми эти осадки рисуются. `fparkan-fx::environment` соединяет
+оба входа с текущей камерой и возвращает один `EnvironmentFrame`.
+
+```text
+sky.ske
+ -> AtmosphereFrame: время, цвет, интенсивность, ссылки на ресурсы
+sky.wea
+ -> SkyMaterials: имена материалов строк 7 (снег) и 8 (дождь)
+камера + EnvironmentSystem
+ -> EnvironmentFrame: мировые частицы, экранные квадраты, гром и дождевой loop
+```
+
+Модуль не владеет графическим устройством или звуковой картой. Renderer
+разрешает имя материала и отправляет геометрию в прозрачный проход, а audio
+backend разрешает архив и имя звука. Это позволяет одной и той же модели
+погоды работать в preview и в игровом цикле.
+
+## Входы и границы
+
+Для расписания используется такой вызов:
+
+```rust
+let frame = environment.update_atmosphere_with_materials(
+ dt_seconds,
+ &atmosphere_frame,
+ &sky_materials,
+ camera,
+);
+```
+
+`AtmosphereFrame` передаёт ссылки из стартового ключа активного интервала.
+Для дождя первая ссылка используется как звук фонового loop. Строка вида
+`archive/name` разделяется на архив и имя. У простой строки `name` архив
+остаётся пустым: звуковой владелец подставляет библиотеку, выбранную миссией.
+Имя не заменяется глобальным именем из AutoDemo.
+
+`SkyMaterials` хранит разобранную таблицу, поэтому строки 7 и 8 являются
+положением в формате исходной игры, а не зашитыми названиями. В AutoDemo там
+находятся `SNOWFLAKE` и `RAIN_DROP`; миссия может передать другие имена. Если
+активной погоде не назначен материал, частицы для неё не создаются.
+
+На входной границе конечные значения приводятся к безопасному диапазону:
+`dt_seconds` и интенсивность неотрицательны, интенсивность и каждый компонент
+цвета ограничены единицей, а нечисловые значения заменяются нулём. Камера
+получает конечную ортонормированную основу, FOV, aspect ratio и размер
+viewport от владельца кадра.
+
+## Объём и движение осадков
+
+Исходный эмиттер хранит прямоугольный объём в координатах камеры. Его
+эталонные параметры таковы:
+
+```text
+near = 2
+far = 50
+half_angle_x = 0.65
+half_angle_y = 0.4875
+```
+
+Для эталонной камеры:
+
+```text
+half_x = tan(half_angle_x) * far
+half_y = tan(half_angle_y) * far
+origin = [near, -half_x, -half_y]
+extent = [far - near, 2 * half_x, 2 * half_y]
+```
+
+Текущий FOV и aspect ratio пересчитывают две боковые грани по той же глубине:
+
+```text
+half_y = tan(vertical_fov / 2) * far
+half_x = half_y * aspect_ratio
+```
+
+Количество частиц зависит от объёма и интенсивности. Вспомогательная
+формула использует округление FISTP к ближайшему чётному целому:
+
+```text
+N = round_even(
+ clamp(current_volume / reference_volume, 0, 1)
+ * density
+ * intensity
+ * 1000
+ )
+```
+
+Текущий эмиттер передаёт `density = 1`. Публичный
+`PrecipitationVolume::particle_count_for` оставляет этот множитель явным для
+владельца, который хранит собственную плотность.
+
+Порядок обновления частицы важен:
+
+1. при создании три 15-битных значения детерминированного генератора
+ заполняют локальный объём;
+2. локальная точка переводится через основу камеры в мировую позицию;
+3. мировая позиция интегрируется с мировым вектором скорости;
+4. для проверки границ позиция временно переводится обратно в локальные
+ координаты, и к каждой оси применяется floor-based wrap;
+5. после wrap точка снова переводится в мир, а её хвост сбрасывается на голову.
+
+Скорости не принадлежат системе координат камеры:
+
+```text
+rain = [ 0.5, 0, -60 ]
+snow = [ 0.5, 0, -4 ]
+```
+
+Это мировые векторы. Поворот камеры меняет область появления, wrap и
+проекцию, но не вращает уже движущуюся каплю вокруг наблюдателя. В
+`EnvironmentPrimitive::Particle` `position`, `velocity`, `world_head` и
+`world_tail` имеют мировые координаты.
+
+Размер частиц состоит из коэффициента класса и масштаба погодного объекта:
+
+```text
+rain_size = scalar * 0.0065
+snow_size = scalar * 0.0195
+```
+
+`scalar` вычисляется из той же проекции, которую передаёт владелец кадра:
+
+```text
+horizontal_fov = 2 * atan(tan(vertical_fov / 2) * aspect_ratio)
+scalar = viewport_width / horizontal_fov
+```
+
+Это соответствует запросу размера native camera (`Terrain` primary vtable
+slot `+6c`): ширина `RECT` делится на горизонтальный FOV в радианах. Поэтому
+`Camera::with_projection(...).with_viewport([width, height])` должен получать
+реальный viewport каждого кадра; отдельного setter-а масштаба нет.
+
+`ScreenBillboard` содержит мировые голову и хвост, их проекцию и четыре NDC
+угла с UV. `half_size` остаётся native-величиной в пикселях, а обе глубины
+хранятся отдельно; renderer не смешивает эти единицы. Дождь использует
+предыдущую мировую голову как хвост и строит полосу перпендикулярно экранному
+направлению движения. Снег остаётся квадратом в текущей голове, использует
+исходную таблицу знаков и минимальный depth fade `0.1`. Renderer сам решает,
+как загрузить этот квадрат и как смешать его материал.
+
+## Молния и звуковые события
+
+`decode_env_lightning_fxid` извлекает из FXID длительность, ссылки opcode 3 и
+2, первые четыре числа opcode 1 и полный body opcode 1. Имена не подменяются
+глобальным whitelist-ом: материал и звук принадлежат выбранному FXID.
+`LightningEffect` устанавливается через `set_lightning_effect`; без него
+погодный таймер не создаёт визуал или звук.
+
+Активный объект использует следующий таймер. При интенсивности `I` сначала
+применяется `min(I, 0.95)`, затем для случайного `U` из 15-битного диапазона
+вычисляется задержка:
+
+```text
+delay_ms = round_even((1 - min(I, 0.95)) * 60000 * U)
+```
+
+После достижения срока выбираются X и Y из переданных `LightningBounds`, а Z
+берётся без изменения. Если границы не заданы, используется позиция камеры.
+Следующая попытка разрешена через 6000 миллисекунд. Длительность уже
+созданного FX берётся из его заголовка. Renderer получает native descriptor
+`[40, 40, 600]`, начало эффекта на `sampled_z + 300`; при нулевом локальном
+смещении концы quad находятся на `sampled_z` и `sampled_z + 600`. Opcode 3
+задаёт материал, локальное смещение, scale и lifetime visual quad. Opcode 1
+вычисляется отдельно как динамический point light и не задаёт размеры или UV
+quad.
+CPU не подменяет numeric поля шириной или линейным lifetime fade.
+
+Звуки возвращаются рядом с графикой как `SoundEvent`:
+
+```text
+StartLoop -> начать дождевой loop
+SetLoopVolume -> обновить его громкость при интерполяции погоды
+StopLoop -> остановить loop
+OneShot -> воспроизвести разовый звук молнии
+```
+
+Каждое событие содержит архив, имя, мировую позицию и линейную громкость.
+Для FX-звука также передаются native `min_distance`, `max_distance` и
+`frequency_ratio`; у дождевого loop используются `0`, бесконечность и `1`.
+Событие `OneShot` молнии использует ту же случайно выбранную позицию, что и
+визуальный эффект, а его диапазон и частота читаются из opcode 2: в
+поставленном `env_lightning` это `100`, `1500` и `1`. Audio backend загружает
+объявленный архив и запись лениво, кэширует проверенный sample, создаёт
+отдельный spatial source, применяет DirectSound range/pan и текущий listener.
+Отсутствие устройства вывода не должно останавливать симуляцию.
+
+## Что остаётся за владельцем
+
+`EnvironmentSystem` не извлекает камеру из renderer, не разрешает материалы
+или звуки и не выполняет финальный GPU pass. Владелец должен передать основу
+камеры, projection, реальный viewport, `SkyMaterials` и декодированный FXID.
+Из projection и viewport модуль сам получает native scalar осадков.
+
+CPU-контракт проверяет движение, wrap, количество, время молнии и порядок
+звуковых переходов. Он не заявляет совместимость с нераскрытой семантикой
+всех FX opcode, с точным native RNG sequence или с конкретным mixer/device
+backend. Подробное описание связи с listener и mission audio находится в
+[томе VI](../tomes/06-behavior.md#погода-осадки-и-звуковые-события).
diff --git a/docs/reference/materials.md b/docs/reference/materials.md
index 8146a2c..bfeccf4 100644
--- a/docs/reference/materials.md
+++ b/docs/reference/materials.md
@@ -38,13 +38,9 @@ MAT0 имеет type ID `0x3054414D`, обычно расположен в `Mate
```c
#pragma pack(push, 1)
-struct Mat0PrefixV4Plus {
+struct Mat0Header {
uint16_t phase_count;
uint16_t animation_block_count;
- uint8_t metadata_a;
- uint8_t metadata_b;
- uint32_t metadata_c_raw;
- uint32_t metadata_d_raw;
};
struct Phase34 {
@@ -54,8 +50,78 @@ struct Phase34 {
#pragma pack(pop)
```
-Versioned fields читаются только если версия их содержит. Для старых версий
-используются runtime defaults, а raw values сохраняются.
+`attr1` читается из NRes entry metadata и сохраняется вместе с разрешённым
+материалом. Это runtime flags материала; его нельзя путать с `attr2`, который
+задаёт версию payload. Versioned prefix bytes читаются только если версия их
+содержит, а raw values сохраняются для последующих слоёв.
+
+### Pipeline category
+
+Call site извлекает native category как `(attr1 >> 2) & 0xF` и передаёт её в
+`LegacyPipelineState::with_material_category`. Таблица содержит пять
+подтверждённых категорий; helper возвращает `None` для остальных значений и не
+меняет уже выбранные depth и cull state.
+
+| category | source/destination colour factors | alpha test |
+| ---: | --- | --- |
+| 0 | `ONE / ZERO`, blending disabled | disabled |
+| 1 | `SRC_ALPHA / INV_SRC_ALPHA` | enabled, `GREATER_EQUAL` |
+| 2 | `SRC_ALPHA / ONE` | enabled, `GREATER_EQUAL` |
+| 3 | `ZERO / SRC_COLOR` | enabled, `GREATER_EQUAL` |
+| 4 | `DEST_COLOR / SRC_COLOR` | enabled, `GREATER_EQUAL` |
+
+The mapping comes from the native five render-state pairs selected by the
+material category. Alpha reference remains draw-range data, while the Vulkan
+pipeline key includes the blend mode, depth mode, cull mode and alpha-test
+variant so ranges with different native state do not share a pipeline.
+
+Каждая phase занимает 34 байта: 18 parameter bytes и 16-byte C string имени
+текстуры. Loader переводит параметры в коэффициенты так:
+
+| bytes | runtime value |
+| --- | --- |
+| `p0..p2` | additive RGB, `p / 255` |
+| `p3` | opacity, `p * 0.01` |
+| `p4..p7` | directional values, `p / 255` |
+| `p8..p11` | specular values, `p / 255` |
+| `p12..p15` | extra values, `p / 255` |
+| `p16` | integer power |
+| `p17` | signed TEXM page index |
+
+Texture name и page index выбираются из текущей phase. Они не смешиваются с
+соседней phase.
+
+## Animation
+
+После phase table идут `animation_block_count` плотных блоков. Каждый блок имеет
+`u32 header_raw`, `u16 key_count`, затем `key_count` записей по три `u16`:
+
+```text
+u32 header_raw
+u16 key_count
+repeat key_count:
+ u16 phase_index
+ u16 end_time_ms
+ u16 raw_k2
+```
+
+`header_raw & 7` задаёт режим: `0` loop, `1` ping-pong, `2` clamp, `3`
+random-per-query. Значения `4..7` сохраняются как unknown. `header_raw >> 3`
+является interpolation mask: bits `1`, `2`, `4`, `8` смешивают группы
+`p0..p2`, `p4..p6`, `p8..p10`, `p12..p14`, а bit `16` смешивает `p3`.
+Компоненты `p7`, `p11`, `p15`, `p16` и `p17` копируются из текущей phase.
+
+Время для блока вычисляется как `clock_ms - wear_row_start_ms` с wrapping
+`u32` subtraction. Для loop берётся остаток от последнего `end_time_ms`, для
+ping-pong нечётный цикл идёт в обратном направлении, clamp после duration
+выбирает последнюю phase, а random-per-query использует переданное случайное
+значение по модулю duration. Ключ выбирается по интервалу
+`previous_end <= local_time < end_time`; следующий ключ циклически замыкается на
+первый. На ping-pong turnaround и clamp после duration native routine проходит
+общий float interpolation path, включая unsigned subtraction endpoint arithmetic;
+это может дать extrapolated coefficients при ненулевой mask. Переданный в
+sampler `animation_block_index` уже выбран вызывающим runtime из block table;
+packed WEAR handle и номер строки WEAR являются отдельными значениями.
## Fallback
diff --git a/docs/reference/msh.md b/docs/reference/msh.md
index 774e388..165b750 100644
--- a/docs/reference/msh.md
+++ b/docs/reference/msh.md
@@ -19,7 +19,7 @@ type 10 strings and node names
type 13 Batch20 records
type 15 auxiliary stream
type 17 auxiliary data
-type 18 rare stream
+type 18 packed UV1 (optional secondary coordinates)
type 19 animation frame map
type 20 rare auxiliary table
```
@@ -40,27 +40,77 @@ struct Node38 {
};
```
-`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
-отсутствие геометрии для комбинации LOD/group.
+В исходном layout это 3 состояния по 5 LOD: `slot_index[state * 5 + lod]`.
+Публичные Rust-обёртки пока сохраняют исторические имена `Lod` и `Group`,
+поэтому в `selected_slot` аргумент `Lod` задаёт native state (0..2), а
+`Group` задаёт native LOD (0..4). `0xFFFF` означает отсутствие геометрии для
+комбинации state/LOD.
-Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как
+Validated `ModelAsset` сохраняет decoded type 8 keys и type 19 map как
`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
-то есть доказанный static input. `parent_or_link == 0xFFFF` означает root;
-иначе это parent index, обязательно меньший индекса child. Этот контракт
-подтверждён на licensed animation gates обеих частей и защищён fallback-ом:
-модель с нарушенным порядком не получает придуманную hierarchy.
-
-В legacy-camera static preview стандартный узел уже получает свой fallback pose
-до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation,
-затем translation суммируется, а rotations умножаются; после полученной global
-pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия
-намеренно дублируется на draw-range узла, потому что один source vertex может
-быть нарисован разными node poses. Это static fallback hierarchy, а не полная
-animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей.
-
-Для воспроизводимого исследования `fparkan-cli model inspect --root <game>
---archive <archive> --resource <model.msh>` выводит Node38 metadata, включая
-parent index, fallback key и наличие LOD0/group0 geometry.
+а `node38_fallback_hierarchy` собирает parent-before-child hierarchy.
+`node38_sampled_hierarchy` использует type 19 map в пределах объявленного
+числа кадров и интерполирует соседние type 8 keys; при отсутствии usable map
+или выходе за frame count используется fallback key. Для покомпонентного
+sampling `node38_sampled_hierarchy_at_times` принимает отдельный optional float
+time для каждого узла: `Some(time)` выбирает type 19 frame по округлению
+`time - 0.5` к ближайшему целому с ties-to-even, затем интерполирует выбранный
+type 8 key и его следующий key в исходный float time; `None` использует
+fallback key. Значения должны быть конечными и неотрицательными. Это
+portable-поведение при default-nearest rounding; полного совпадения со всеми
+режимами округления x87 оно не заявляет. `parent_or_link == 0xFFFF`
+означает root, иначе это parent index, который должен быть меньше индекса
+child. Модель с нарушенным порядком не получает придуманную hierarchy.
+
+Type 8 записывает quaternion в порядке WXYZ, после decode `AnimKey24` хранит
+его как XYZW. Native AniMesh `+0x12560` строит local matrix через NGI32
+`g_FastProc + 0x38`; scalar implementation выдаёт transpose обычной active
+`Pose` basis (`M01=2(xy+zw)`, `M02=2(xz-yw)`, `M10=2(xy-zw)`). Поэтому путь
+Node38 MSH сопрягает source-local quaternion ровно один раз после sampling и
+до hierarchy composition (меняет знаки компонент `x`, `y`, `z`). Для fallback
+poses применяется та же граница. Общий animation decoder и другие форматы
+анимации не изменяются.
+
+В legacy-camera static preview без `--static-animation-frame <u16>` используются
+сохранённые начальные значения из явной ссылки каждого prototype на `.ctl`.
+Узлы без control binding получают time `0`. Для каждой component instance
+обрабатываются её CTLD bindings, поэтому общий deduplicated MSH не смешивает
+controls разных компонентов. Строки идут в source order, и последняя включённая
+строка для одного узла заменяет предыдущую. Flag `0x01` выполняет один wrap
+значения blend на единицу, иначе значение ограничивается `[0, 1]`; flag `0x02`
+инвертирует результат (`1 - blend`), а строки с `0x04` пропускаются. Выбранный
+float time равен `(1 - blend) * frame_a + blend * frame_b`. Если он выходит за
+type 19 map, MSH sampler использует fallback key; если после одной wrap/clamp и
+invert получается отрицательное или нечисловое время, preview завершится
+понятной ошибкой. Явный `--static-animation-frame` остаётся global override
+для всех компонентов. Parent pose поворачивает child translation, затем
+translation суммируется, а rotations умножаются; после полученной global pose
+применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия намеренно
+дублируется на draw-range узла, потому что один source vertex может быть
+нарисован разными node poses. Это только начальная статическая pose: она не
+запускает игровой Control/AI и не обещает полного совпадения с runtime или
+всеми x87 rounding modes.
+
+## Vertex streams
+
+Основные vertex streams имеют фиксированный source index. У type 3 `attr1`
+задаёт количество вершин, а `attr3` — размер записи в байтах. Type 4
+содержит четыре signed bytes нормали. Type 5 и optional type 18 содержат по
+два little-endian `uint16` на вершину:
+
+```c
+struct PackedUv16x2 {
+ uint16_t u;
+ uint16_t v;
+};
+```
+
+Type 5 — primary UV0, type 18 — secondary UV1 для lightmap/detail stage.
+Для type 18 loader требует `attr3 == 4`, совпадение `attr1` и длины payload,
+а также одинаковое число записей с type 3. Обе пары декодируются как
+`packed / 1024.0`; renderer обращается к UV1 по тому же `source_index`, что и
+к UV0. Отсутствующий type 18 означает, что secondary stage не имеет authored
+координат.
## Slot and batch
@@ -86,16 +136,20 @@ Type 13 задаёт draw ranges:
#pragma pack(push, 1)
struct Batch20 {
uint16_t batch_flags;
- uint16_t material_index;
- uint16_t opaque4;
- uint16_t opaque6;
+ uint16_t material_index_hi; // +0x02, native high selector/slot field
+ uint8_t material_index; // +0x04, WEAR material selector
+ uint8_t lightmap_index; // +0x05, 0xFF means no lightmap
+ uint16_t local_batch_index; // +0x06
uint16_t index_count;
uint32_t index_start;
- uint16_t opaque14;
+ uint16_t vertex_count; // +0x0E
uint32_t base_vertex;
};
#pragma pack(pop)
```
-Index check выполняется как `base_vertex + index < vertex_count` для всего
-используемого slice.
+Byte `material_index` выбирает строку WEAR; high word at `+0x02` сохраняется
+отдельно для native overrides. Loader проверяет, что
+`index_start + index_count` остаётся внутри type 6, а каждый использованный
+индекс через `base_vertex` попадает в position stream. `vertex_count` и
+`local_batch_index` доступны renderer для исходного draw contract.
diff --git a/docs/reference/original-binaries.md b/docs/reference/original-binaries.md
new file mode 100644
index 0000000..05c0141
--- /dev/null
+++ b/docs/reference/original-binaries.md
@@ -0,0 +1,33 @@
+# Оригинальные модули
+
+RVA в книге относится к определённой сборке DLL. SHA-256 позволяет
+проверить, что сравнивается тот же код.
+
+## Совпадающие модули частей 1 и 2
+
+| Binary | SHA-256 | Size | Notes |
+| --- | --- | ---: | --- |
+| `Ngi32.dll` | `bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5` | 253952 | Shared low-level render/resource/audio boundary |
+| `World3D.dll` | `17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e` | 208896 | Shared gameplay/world/render lifecycle baseline |
+| `Terrain.dll` | `6d3e68f0e15b297f6c184af3113baf1f31e19c3326c18f0150dec659242ed667` | 708608 | Shared terrain/shade/world baseline |
+| `iron_3d.exe` / `iron_3d_p2.exe` | `f476af85c034a4b4f34f49d0806e4dff397b5da0ee26d382a7674231144979f7` | 36864 | Shared launcher binary |
+
+## Сборка GOG
+
+The canonical disassembly source is the windowed GOG installation, not the
+Part 1/Part 2 test installations. Its `Terrain.dll` is a distinct revision:
+
+| Binary | SHA-256 | Size | Notes |
+| --- | --- | ---: | --- |
+| `Terrain.dll` | `af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` | 499712 | GOG camera receiver evidence; do not reuse Part 1/2 RVAs without a matching hash |
+
+## Различающиеся модули
+
+Эти модули нельзя автоматически считать behavior-compatible между частями:
+
+- `AniMesh.dll`
+- `Effect.dll`
+- `iron3d.dll`
+- `services.dll`
+- `Control.dll`
+- `ArealMap.dll`
diff --git a/docs/reference/render-frame.md b/docs/reference/render-frame.md
index ec78020..5add02b 100644
--- a/docs/reference/render-frame.md
+++ b/docs/reference/render-frame.md
@@ -69,6 +69,14 @@ projection matrix contract.
## Parity risks
+`VulkanStaticCamera::from_legacy_d3d7` keeps the D3D7 view and projection
+reconstruction in `fparkan-render`, then negates the vertical projection term
+once at the Vulkan adapter boundary. D3D7's top-down viewport places positive
+NDC Y at the top; the Vulkan swapchain uses a positive viewport height, so the
+adapter flips NDC Y while the shader preserves `gl_Position`. The generic
+`from_row_major_view_projection` path remains unchanged; its callers supply
+Vulkan-ready matrices, as the free-flight camera already does.
+
- x87 precision and rounding;
- scalar/SIMD `g_FastProc` differences;
- object, batch and transparent primitive order;
diff --git a/docs/rendering/renderer_truth_table.md b/docs/rendering/renderer_truth_table.md
deleted file mode 100644
index 2687bd4..0000000
--- a/docs/rendering/renderer_truth_table.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# Renderer Truth Table
-
-Эта страница нужна для одной вещи: не позволять путям smoke, planning и capture
-выглядеть как «почти готовый renderer». Каждый путь доказывает разный класс
-свойств, и acceptance не должен смешивать их.
-
-## Краткая матрица
-
-| Path | Native window / swapchain | Draws pixels | Uses original assets | Acceptance class | Что доказывает | Чего не доказывает |
-| --- | --- | --- | --- | --- | --- | --- |
-| `fparkan-vulkan-smoke` / `VulkanSmokeRenderer` | Yes | Yes | Static MSH plus sampled TEXM | `covered-gpu` for Stage 0 smoke and explicit MSH/TEXM/descriptor bridge IDs | Loader, instance, surface, swapchain, submit/present, validation-clean triangle path; original MSH indexed draw; TEXM RGBA8 staging upload; per-batch WEAR/MAT0 diffuse descriptors and fragment sampling | MAT0 phase animation, lightmaps, legacy blend/depth/cull states, terrain, camera/node transforms, gameplay rendering |
-| `VulkanPlanningBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Deterministic command validation, canonical capture, frame submission planning | Любой live GPU draw, pixel parity, validation-clean asset frame |
-| `RecordingBackend` | No | No | Optional CPU-side IDs only | `covered-planning` | Stable command capture for backend-neutral tests | Native window, Vulkan, GPU resource lifetime, pixels |
-| `NullBackend` | No | No | Optional CPU-side IDs only | Usually `covered` for validation-only rows | Command stream framing and bounds validation | Capture stability, GPU execution, pixels |
-| `VulkanAssetRenderer` | Yes | Yes | Yes | `covered-gpu` | Static original asset rendering: MSH/Texm/WEAR/MAT0/terrain through Vulkan | Animation/FX parity unless explicitly wired |
-| `fparkan-game --backend static-vulkan` | Yes, GOG `Autodemo.00` | Yes, full static AutoDemo preview | Eight mission objects, 66 MSH components, original diffuse TEXM and Land2 terrain base layer | `covered-gpu` for the bounded static-scene bridge | Diagnostic XY framing with recovered translation/scale/Z Euler placement, fallback node hierarchy, optional captured legacy camera, 32-bit GPU indices, diffuse descriptors, terrain base draws and synchronized teardown telemetry | Material phases, Land1 blend/microtexture/lightmaps, animated keys, FX, camera control and gameplay parity |
-| Future rendered `fparkan-game` mode | Yes | Yes | Yes | `covered-gpu` plus original-evidence IDs | Mission-driven render snapshot execution and pixel capture | Original-runtime parity for animation/FX/x87 without dedicated captures |
-
-## Rules
-
-1. IDs со смыслом `VK`, `GPU`, `DRAW`, `PIXEL`, `VALIDATION` или `RENDERED`
- на Stage 3+ не могут закрываться через `NullBackend`, `RecordingBackend`
- или `VulkanPlanningBackend`.
-2. `covered-planning` означает command planning/capture evidence. Этот статус
- никогда не считается доказательством draw пикселей.
-3. `covered-stub` зарезервирован для явно помеченных `STUB` acceptance rows и
- не считается compatibility closure для FX lifecycle.
-4. `covered-gpu` требует live native handles, реальный draw path и связанный
- renderer artifact: report, capture или approved pixel.
-
-## Current repository status
-
-- Реальный Vulkan в репозитории имеет smoke triangle path и узкий static asset bridge. `VulkanStaticDrawRange` сохраняет исходный `Batch20.material_index`; когда smoke запускается с `--wear-root`, `--wear-archive`, `--wear-name` без override, он дедуплицирует selectors, проходит каждый через `WEAR → MAT0 → Textures.lib`, создаёт по одному image/descriptor set и бинит set непосредственно перед соответствующим indexed draw. Direct TEXM и `--material-index` — намеренно однотекстурные compatibility modes. Fresh GOG `fortif.rlb::FR_L_MTP.msh` подтверждает 237 batch draws, но его selectors все `0`, поэтому live report содержит один binding `MTP_01.0`; unit contract подтверждает точное сопоставление двух разных selectors с разными descriptor sets. Это не доказывает material phase animation, lightmaps, alpha/depth/cull state, terrain, camera/node transforms или pixel approval.
-- Lightmap остаётся отдельным, не реализованным contract: оригинальный `World3D.dll` экспортирует самостоятельный `SetLightMapLib` наряду с `SetTexturesLib` и `SetMaterialLib`; WEAR содержит независимый блок `LIGHTMAPS`. Текущая документация не подтверждает связь этих slots с `Batch20.material_index` или их UV/channel semantics, поэтому viewer не подменяет lightmap diffuse texture и не добавляет недоказанное binding.
-- `apps/fparkan-game` по умолчанию выдаёт `render-planning` JSON report поверх
- synthetic window descriptor и `VulkanPlanningBackend`. Opt-in `--backend static-vulkan`
- создаёт native `winit` window. Для GOG `MISSIONS/Autodemo.00/data.tma` он уже
- рендерит весь статический набор из восьми mission objects и 66 MSH components с
- diagnostic XY camera (or an optional captured legacy camera), доказанным `Rz * Ry * Rx` placement transform,
- fallback node hierarchy и Land2 base terrain. Последний validation-clean
- текущий трёхкадровый запуск после применения placement transform в XY path
- имел 71 material descriptor, `clip_visible_vertices=75543` и readback hash
- `5444013368935681345`. Это `covered-gpu` для ограниченного
- static-scene bridge, но не pixel parity и не доказательство material phase,
- Land1 blend, microtexture, lightmap, dynamic animation, FX, live camera
- selection или gameplay rendering.
-- `apps/fparkan-viewer` сейчас inspection-only CLI и не открывает live Vulkan
- asset viewer.
-- Следующий реальный milestone для rendered acceptance: `VulkanAssetRenderer`
- с upload/draw/capture path для хотя бы одной оригинальной модели и одного
- terrain slice.
diff --git a/docs/tomes/01-guide.md b/docs/tomes/01-guide.md
index cc4e4c0..e83c9b8 100644
--- a/docs/tomes/01-guide.md
+++ b/docs/tomes/01-guide.md
@@ -1,115 +1,19 @@
-# I. Путеводитель и методика
-
-Первый том задаёт язык и правила всей документации. Он объясняет, как читать
-технические главы, какие термины используются для игрового runtime, как
-разделяются уровни уверенности и какие требования предъявляются к реализации,
-которая должна работать с оригинальными данными без потери информации.
-
-Документация рассчитана на разработчика, который уже умеет читать C/C++,
-байтовые форматы, PE-модули и графические pipeline, но не обязательно знаком с
-Iron3D. Поэтому этот том не описывает один конкретный crate, package или
-физическое деление будущего кода. Он фиксирует контракты: что должно быть
-прочитано, сохранено, рассчитано и показано.
-
-## Назначение книги
-
-Книга ведёт от общей архитектуры Iron3D к точным форматам данных и алгоритмам
-исполнения. Практическая цель -- реализация, способная открыть оригинальный
-каталог *Parkan: Iron Strategy*, загрузить миссию, создать мир, провести
-игровой шаг и сформировать кадр.
-
-Форматы в главах описываются как байтовые контракты. Если указано поле
-`+0x10`, это означает расположение в потоке или структуре данных, а не
-разрешение читать файл прямым `reinterpret_cast`. Для постоянных layouts
-используются offsets, проверки размеров, bounded cursor и явное сохранение
-неизвестных байтов. Для versioned и variable-length записей приоритет имеет
-последовательный parser с контролем границ.
-
-Игровое поведение описывается не только размером структур. Совместимая
-реализация должна учитывать порядок событий, время, fallback-правила,
-идентификаторы объектов, численные ограничения, состояние материалов,
-границы кадра и правила завершения операций.
-
-## Маршруты чтения
-
-**Читатель, новый для игровой разработки**, начинает с базовых понятий этого
-тома, затем переходит к архитектуре, игровому циклу и вводу в рендер. После
-этого имеет смысл читать главы о миссиях, мире и ресурсных форматах.
-
-**Разработчик совместимого движка** читает тома II-VII линейно. Технические
-главы имеют одинаковую логику: назначение подсистемы, данные на диске,
-представление в памяти, алгоритм работы, проверки и требования к новой
-реализации.
-
-**Аналитик оригинальной программы** использует этот том вместе с разделами о
-доказательной базе, ABI, результатах корпусных проверок и границах знания.
-Факты, согласованные выводы и открытые вопросы должны оставаться разделёнными:
-это позволяет расширять реализацию без подмены проверенных контрактов
-удобными догадками.
-
-## Состав документации
-
-1. **Путеводитель и методика** -- язык предметной области, правила чтения и
- процедура проверки.
-2. [**Запуск, архитектура и игровой цикл**](02-architecture.md) -- от
- `iron_3d.exe` до расчёта и вывода кадра.
-3. [**Ресурсная система и форматы**](03-resources.md) -- архивы, кэши, реестры
- и служебные данные.
-4. [**Мир, миссии и игровой runtime**](04-world.md) -- TMA, ландшафт, ареалы и
- создание объектов.
-5. [**Геометрия, материалы и рендер**](05-render.md) -- от вершины модели до
- изображения на экране.
-6. [**Поведение, управление, звук и сеть**](06-behavior.md) -- интерактивные
- подсистемы.
-7. [**Руководство по полной реализации**](07-implementation.md) -- предлагаемая
- архитектура и порядок работ.
-8. [**Справочник и доказательная база**](08-evidence.md) -- ABI,
- конфигурация, статистика и открытые вопросы.
-
-Дополнительные краткие определения собраны в
-[глоссарии](../appendices/glossary.md). Технические области, где контракт ещё
-не закрыт полностью, перечислены в
-[границах знания](../appendices/knowledge-boundaries.md).
-
-## Условные обозначения
-
-`+0x10` означает смещение поля относительно начала структуры или записи.
-`RVA 0x13B60` -- адрес относительно базы PE-модуля. `u16`, `u32`, `i16` и
-`float32` обозначают типы фиксированной ширины. `LE` означает little-endian.
-`payload` -- полезные данные записи после метаданных контейнера. `EOF` -- точное
-завершение файла или ограниченного блока.
-
-Если в тексте указан hash, RVA или ordinal, значение относится к явно
-обозначенному binary profile. Адреса разных сборок не объединяются по имени
-функции. При публикации функции нужны минимум модуль, SHA-256 сборки и RVA.
-
-Размеры структур выражаются в байтах. Счётчики и offsets считаются частью
-формата, даже когда их можно восстановить из длины файла. Padding, reserved
-поля, неизвестные хвосты и gaps не нормализуются без доказанного правила.
-
-## Совместимость
-
-Слово "совместимость" в этой книге имеет несколько уровней.
-
-**Reader** умеет открыть файл, проверить границы, извлечь известные поля и
-сохранить неизвестные bytes так, чтобы данные можно было записать обратно.
-
-**Viewer** умеет показать ресурс: модель, texture, material, эффект или карту.
-Viewer может быть полезен для анализа, но он не доказывает поведение runtime.
-
-**Runtime** умеет создать мир, зарегистрировать объекты, исполнять события,
-обновлять время, применять контроллеры, выбирать видимое состояние и передавать
-его рендеру.
-
-**Полноценный движок** дополнительно воспроизводит порядок операций, численные
-правила, fallback-поведение, resource lifetime, reference ownership, pause,
-manual input, сетевые идентификаторы, boundaries кадра и состояние
-интерактивных подсистем.
-
-Поэтому файл может быть "прочитан правильно", но всё ещё не быть реализованным
-на уровне движка. Например, reader MSH может восстановить вершины и индексы,
-viewer может нарисовать mesh, а runtime обязан ещё сохранить material slots,
-animation state, bounds, LOD, visibility, collision и связи с объектом мира.
+# I. Путеводитель
+
+Игра выглядит как непрерывное движение, но компьютер строит её из отдельных
+шагов. Он читает ввод, обновляет мир, вычисляет положение моделей и рисует
+очередной кадр. Чтобы воспроизвести старую игру, нужно понять как её файлы,
+так и правила этих шагов.
+
+Книга не требует опыта игровой разработки. Ниже объясняются основные понятия,
+а последующие главы показывают их на конкретных данных «Паркана».
+Подробные таблицы адресов и байтов полезны при реализации; при первом чтении
+их можно пропустить.
+
+`+0x10` означает смещение в байтах от начала записи. `RVA` — адрес относительно
+начала загруженной DLL. `u16`, `u32`, `i16`, `float32` — числа фиксированного
+размера. `LE` означает little-endian: младший байт числа записан первым.
+`EOF` — конец файла. Неизвестные поля сохраняются без изменений.
## Движок как программа длительного действия
@@ -250,122 +154,15 @@ Writer пересчитывает только производные значе
записей, сортировочные таблицы и padding, если правило доказано. Unknown fields
и reserved ranges сохраняются побайтно.
-## Иерархия доказательств
-
-Документация использует четыре уровня уверенности.
-
-**Прямое наблюдение** -- поле, значение или последовательность видны в
-инструкции программы, таблице PE, экспорте, строке, обработчике файла или в
-самом ресурсе. Это самый сильный уровень.
-
-**Корпусное подтверждение** -- правило проверено на всех подходящих файлах
-одного или нескольких явно названных наборов: демоверсии, Части 1 и Части 2.
-Например, базовый корпус содержит 435 моделей MSH, 518 textures Texm и 923
-эффекта FXID, прошедших структурные проверки без ошибок; полные части расширяют
-эту матрицу вариантов.
-
-**Согласованный вывод** -- назначение восстановлено по нескольким независимым
-признакам: вызывающим функциям, vtable slots, строкам ошибок, диапазонам
-значений и связям между форматами. Такой вывод пригоден для реализации, но его
-численные детали следует проверять тестами.
-
-**Открытый вопрос** -- данные можно читать и сохранять, однако предметный смысл
-поля или редкой ветки не доказан. Такие bytes нельзя обнулять,
-переупорядочивать или превращать в authoring API.
-
-Уровень уверенности должен быть виден из формулировки. "Поле равно" означает
-проверенный layout или значение. "Вероятно отвечает за" означает согласованный
-вывод. "Неизвестно" означает сохранять без изменения и не строить вокруг этого
-публичный контракт.
-
-## Проверенные материалы
-
-Локальный набор проверки включает демоверсию, полные каталоги Частей 1 и 2,
-исполняемые файлы, 15 DLL каждой сборки и игровые ресурсы. DLL из
-первоначального архива и DLL демоверсии совпали по SHA-256: `15/15`, поэтому
-выводы по этому коду и demo-ресурсам образуют один доказательный профиль.
-
-Исполняемый файл демоверсии `iron_3d.exe` имеет размер 36 864 байта, PE32/x86,
-entry RVA `0x141E`, image base `0x400000` и SHA-256
-`b0a8b0db1c3a8698c4d4604d89c655496bd91ac1f8859a455e8a45838aebfbd6`.
-
-Исполняемые файлы Частей 1 и 2 также имеют размер 36 864 байта и побайтно
-совпадают между собой, но относятся к другому binary profile: entry RVA
-`0x147E`, SHA-256
-`f476af85c034a4b4f34f49d0806e4dff397b5da0ee26d382a7674231144979f7`.
-
-Полные каталоги Частей 1 и 2 суммарно включают 60 TMA, 1 101 unit DAT, 254
-NRes-файла и 14 975 NRes entries. Все контейнеры и TMA прошли bounded parser до
-точного EOF; полный достижимый граф обеих частей разрешился без ошибок.
-
-## Процедура проверки
-
-Проверка строится как воспроизводимая цепочка:
-
-1. Снять PE-метаданные, хэши, импорты, экспорты, ordinals, RTTI и строки.
-2. Построить граф вызовов между модулями и отметить фабрики подсистем.
-3. Разобрать функции запуска, загрузчики файлов, главный цикл и критические
- vtable-вызовы.
-4. Проверить форматы независимыми reader-скриптами с контролем границ и точного
- завершения файла.
-5. Построить цепочку миссия -> объект -> прототип -> модель -> материал ->
- texture.
-6. Сравнить счётчики, диапазоны, ссылки и размеры на всём доступном корпусе.
-
-Ключевой результат сквозной проверки демо-миссий: все 201 объектов шести
-миссий разрешились в 501 запрос прототипов, затем в 501 модель, 501 таблицу
-WEAR, 3 879 слотов материалов и 5 085 ссылок на textures или lightmaps. Ошибок
-в фактически исполняемом пути нет.
-
-## Что не считается доказательством
-
-Удобное имя поля не доказывает его назначение. Совпадение layout с текущей
-реализацией не доказывает поведение оригинального runtime. Успешный viewer не
-доказывает writer. Успешный reader одного файла не доказывает формат всего
-корпуса. Совпадение ABI не доказывает побайтную идентичность всех сборок.
-
-Если локальные данные и предположение расходятся, приоритет имеют исполняемый
-код, реальные ресурсы и взаимные invariants между форматами. Неизвестное поле
-лучше оставить без имени, чем дать ему ложное предметное значение.
-
-## Требования к воспроизводимости
-
-Каждая новая реализация должна иметь strict parser mode, lossless roundtrip
-mode и набор corpus tests. Неизвестные поля сохраняются побайтно. Любое
-присвоенное полю имя должно сопровождаться наблюдаемым поведением или тестом.
-Численные правила -- округление, порядок умножения, RNG и время -- считаются
-частью формата исполнения, даже если файл читается правильно.
-
-Минимальный отчёт проверки должен фиксировать:
-
-1. build profile и hashes модулей;
-2. путь или ключ ресурса;
-3. размер входного файла и hash входных bytes;
-4. версию parser-а или commit реализации;
-5. список включённых quirks;
-6. число прочитанных записей и точку EOF;
-7. ошибки, предупреждения и unknown ranges;
-8. результат roundtrip, если writer участвует в проверке.
-
-Для runtime-проверок дополнительно нужны mission key, configuration, device
-profile, начальное состояние, input/time script и trace значимых callbacks.
-
-## Разделение профилей
-Binary profile описывает исполняемый код: PE-метаданные, exports/imports,
-ordinals, hashes, RVA и layout функций. Corpus profile описывает набор файлов:
-каталог, миссии, ресурсы, размеры, counts, variants и статистику parser-а.
-
-Эти профили нельзя смешивать без явной пометки. Один и тот же формат может
-иметь общий смысл в разных сборках, но отличаться редкими ветками, адресами
-функций или набором встреченных вариантов. Один и тот же address может иметь
-смысл только внутри конкретного module hash.
-
-При расширении документации новое утверждение должно отвечать на три вопроса:
+## Проверка предположений
-1. Где это видно напрямую?
-2. На каком корпусе это проверено?
-3. Что должна сделать реализация, если правило нарушено?
+Формат подтверждается тем, как оригинальная программа читает запись, и тем,
+какие значения встречаются в игровых ресурсах. Изображение из стороннего
+просмотрщика помогает заметить ошибку, но само по себе не доказывает формулу.
+Для восстановленного поведения оставляем небольшой тест с конкретным входом
+и ожидаемым результатом. Для неопределённого поля прямо пишем, что его смысл
+неизвестен.
-Если на один из вопросов нет ответа, утверждение остаётся согласованным выводом
-или открытым вопросом, а не закрытым контрактом.
+RVA зависит от сборки DLL. Сравнивая инструкции, сначала сверяют соответствующий
+файл с [таблицей оригинальных модулей](../reference/original-binaries.md).
diff --git a/docs/tomes/03-resources.md b/docs/tomes/03-resources.md
index fa5377a..29fef77 100644
--- a/docs/tomes/03-resources.md
+++ b/docs/tomes/03-resources.md
@@ -484,21 +484,6 @@ Visual dependency expansion наследует этот индекс на edges
diffuse texture и lightmap. Поэтому ошибка или asset в любой из этих фаз
сохраняет путь до конкретной записи unit DAT, а не только до миссионного object.
-Для воспроизводимой проверки используется `fparkan-cli prototype inspect`.
-Схема JSON `fparkan-prototype-inspect-v2` содержит lossless hex каждого
-32-byte поля unit record и materialized graph edges с `unit_component_index`.
-Например, для GOG AutoDemo:
-
-```powershell
-cargo run -p fparkan-cli -- prototype inspect `
- --root 'C:\GOG Games\Parkan - Iron Strategy' `
- --key 'UNITS\UNITS\AutoDEMO\w_m_wlk2.dat' --format json
-```
-
-Вывод фиксирует 18 component records, их MSH/WEAR/MAT0/Texm dependencies и
-точные parent edges. JSON предназначен для анализа и regression evidence, а
-не для интерпретации `kind`, links или opaque tails как готовой game logic.
-
## Вспомогательные форматы
MSH, материал и текстура отвечают за видимую форму. Полноценный прототип
diff --git a/docs/tomes/05-render.md b/docs/tomes/05-render.md
index e63c3ae..71cad1d 100644
--- a/docs/tomes/05-render.md
+++ b/docs/tomes/05-render.md
@@ -86,10 +86,8 @@ drivers и video modes, проверяет поддержку 3D, перевод
`IDirect3D` 1/2/3/7 и соответствующих `IDirect3DDevice`. Следовательно,
современная реализация заменяет наблюдаемую fixed-function семантику над этой
границей, а не должна эмулировать COM-объекты DirectX. Равный SHA-256 Ngi32 в
-Частях 1 и 2 зафиксирован в
-[evidence](../evidence/original_engine_hashes.md); это поддерживает единый
-Vulkan backend contract, но не отменяет раздельных corpus/capture baselines
-для изменённых assets и gameplay DLL.
+Частях 1 и 2 зафиксирован в [таблице оригинальных модулей](../reference/original-binaries.md);
+это поддерживает единый контракт Vulkan backend.
```text
enumerate adapters and video modes
@@ -361,9 +359,16 @@ local.set_translation(pose.position);
world[n] = world[parent(n)] * local;
```
-Для parity особенно важны x87-compatible округление при выборе frame index и
-порядок операций. Одинаковая формула на SSE может выбрать соседний кадр возле
-границы.
+Статический Vulkan preview берёт начальный кадр узла из явной ссылки прототипа
+на CTLD `.ctl`. Используются только включённые строки, а при повторе node ID
+поздняя строка заменяет раннюю. Без `--static-animation-frame` preview применяет
+эти настройки узлов; узлы без настройки остаются на кадре `0`. Явный
+`--static-animation-frame N` переопределяет их и задаёт `N` всем узлам.
+Результат — одна статическая поза, не обновление анимации, Control или AI.
+MSH sampler выбирает type 19 frame округлением `(time - 0.5)` к ближайшему
+целому с ties-to-even, затем интерполирует выбранный type 8 key и следующий
+key в исходном `time`; это portable-вариант native x87 conversion в default
+rounding mode, без обещания совпадения при любом x87 control word.
Проверки animation data:
@@ -613,11 +618,20 @@ Lightmap не является обычной diffuse texture. WEAR содерж
diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию.
Тени проходят отдельным render pass. Terrain содержит пути для теней зданий и
-роботов, ограничения максимального числа, detail level и smoothing. Доказаны
-shadow manager/pass, настройки detail/smoothing/count и зависимость от
-Terrain/CShade; полная формула projection geometry для каждого caster требует
-dynamic trace. Unknown settings из `shade.cfg` читаются и сохраняются по
-именам, а не заменяются произвольными modern defaults.
+роботов, ограничения максимального числа, detail level и smoothing. Реализованы
+native visibility по шести Vulkan clip-плоскостям с радиусом sphere +100,
+порядок кандидатов и лимит 20 до построения страниц, projected-size LOD,
+directional/point light contribution, native page layout, raster, smoothing и
+receiver projection с edge clipping. Unknown settings из `shade.cfg` читаются и
+сохраняются по именам, а не заменяются произвольными modern defaults. Для native ray query
+surface descriptor в `+0x10` требует наличия всех переданных bits, а descriptor
+в `+0x14` исключает поверхности с любым переданным bit. Общий shadow query
+использует full exclusion mask `0x2000`; actor four-ray path получает compact
+mask `0x8008`, который landscape boundary разворачивает в full mask
+`0x00200020`. Sun path передаёт full `0x20`, кодирует его как compact bit `8`
+и затем получает тот же full bit после разворачивания. Hit record хранит в
+`+0x20` квадрат евклидовой длины луча, поэтому native four-ray fade использует
+`distance² * 1e-4`, а не линейную дальность.
Atmosphere manager создаёт world objects для фоновых и погодных явлений.
Отдельно подтверждены lightning, sun render, flare, `env_lightning`, rain
@@ -626,6 +640,9 @@ background sound и обязательные ссылки на lightning effect.
требует screen position и occlusion test, rain -- области рядом с observer,
sound -- listener. Их нельзя один раз запечь в terrain.
+Sun occlusion начинается от точки `camera + 0.5 * normalize(sun - camera)`;
+этот offset является частью native query, а не произвольным bias renderer-а.
+
RNG для lightning, atmosphere phases и FX должен иметь стабильный порядок.
Даже правильный средний интервал не даёт повторяемый кадр, если random values
запрашиваются в другой последовательности.
@@ -679,11 +696,14 @@ settings ID сохраняются.
```text
opcode = command_word & 0xFF
-enabled = (command_word >> 8) & 1
+native_flag = (command_word >> 8) & 1
```
-Bits 9-31 являются частью данных и сохраняются. Между командами нет
-выравнивания. Размер команды, включая word:
+`native_flag` передаётся созданному native command object как режим/feature
+flag и не отключает команду. Например, реальный `env_lightning` использует
+`native_flag == 0` для opcode 3, opcode 1 и opcode 2, и все три команды
+исполняются. Bits 9-31 являются частью данных и сохраняются. Между командами
+нет выравнивания. Размер команды, включая word:
```text
opcode 1 224 байта
@@ -842,1107 +862,94 @@ draw_indexed(item.batch.base_vertex,
item.batch.index_count);
```
-## Текущий контракт pipeline key
-
-`fparkan-render` теперь переносит вместе с каждым backend-neutral draw
-`LegacyPipelineState` и детерминированный `PipelineKey`. Ключ не хешируется:
-он явно упаковывает blend mode, depth mode, cull mode и флаг alpha-test, поэтому
-одинаковые snapshots дают одинаковый capture и будущий Vulkan cache не зависит
-от версии Rust или процесса. Alpha reference намеренно не входит в key: это
-dynamic material constant, а не структурный вариант graphics pipeline.
-
-Текущий static Vulkan viewer дедуплицирует состояния ranges при каждой сборке
-swapchain resources, создаёт один `vk::Pipeline` на уникальный `PipelineKey` и
-выбирает его непосредственно перед соответствующим `vkCmdDrawIndexed`.
-Кэш корректно уничтожается при recreate/teardown; setup failure откатывает уже
-созданные variants. Baseline оригинального MSH подтверждён с состоянием
-`opaque + depth disabled + cull disabled + alpha-test disabled`.
-
-`SourceAlpha`, front/back culling и depth modes имеют Vulkan mapping в этом
-кэше. При создании swapchain resources renderer выбирает capability-approved
-depth/stencil format, выделяет device-local attachment, очищает его в render
-pass и прикрепляет к каждому framebuffer. `TestWrite` включает depth test и
-write; `TestReadOnly` включает test без write. Alpha test выполняется во
-fragment shader: перед каждым draw renderer передаёт через push constant
-`alpha_test_reference / 255`, а shader отбрасывает fragment с меньшей alpha.
-При выключенном `alpha_test` cutoff принудительно равен нулю; reference остаётся
-dynamic material data и не входит в `PipelineKey`. Также не установлен источник
-значений для Batch20/MAT0: их
-поля нельзя объявлять blend/depth/cull mapping без dynamic capture или
-дополнительного дизассемблирования. Это частично реализованная compatibility
-boundary, а не заявление о готовой parity fixed-function state.
-
-### Capability-gated swapchain source for future pixel capture
-
-Pixel parity требует чтения результата GPU, но swapchain image разрешает это
-только если surface сообщает `VK_IMAGE_USAGE_TRANSFER_SRC_BIT`. Поэтому
-swapchain policy всегда запрашивает обязательный `COLOR_ATTACHMENT`, а
-`TRANSFER_SRC` добавляет лишь при фактической поддержке surface; выбранные raw
-usage bits попадают в deterministic plan и native smoke report. Это не создаёт
-readback buffer, не копирует pixels и не закрывает pixel-capture acceptance.
-
-На Windows GOG native smoke 2026-07-18 (`MTCHECK.MSH` из `system.rlb` и
-`DEFAULT.0` из `Textures.lib`) AMD Radeon Pro WX 3200 Series подтвердил
-`image_usage=17` (`COLOR_ATTACHMENT | TRANSFER_SRC`): 300 frames, 3 resize
-events, 2 swapchain recreations, Vulkan validation warnings/errors `0/0`.
-Следующий шаг должен выполнить явный image-to-buffer copy и сравнить полученные
-bytes с зафиксированным reference capture.
-
-### First synchronized Vulkan pixel-readback artifact
-
-The current artifact contract is one final swapchain image, not a
-concatenation of every swapchain allocation. After the final successful
-graphics submission, the renderer retains that image index; synchronized
-teardown reads only its matching buffer. The raw payload is therefore exactly
-`width * height * 4` bytes for the current format-50 path. Historical
-multi-image byte counts below describe the superseded concatenation behavior
-and are not comparable to this final-image contract.
-
-Stage 3 static viewer теперь выполняет фактический readback для surface с
-`TRANSFER_SRC`: на каждый swapchain image создаётся host-visible coherent
-`TRANSFER_DST` buffer. После render pass command buffer переводит image из
-`PRESENT_SRC_KHR` в `TRANSFER_SRC_OPTIMAL`, выполняет `vkCmdCopyImageToBuffer`
-и возвращает image в `PRESENT_SRC_KHR`. CPU отображает memory только после
-`vkDeviceWaitIdle` during shutdown; это исключает чтение GPU work in flight.
-Smoke JSON фиксирует число записанных copy-команд, final byte count и FNV-1a-64
-hash всех current-swapchain readback buffers, не сохраняя игровые pixels в
-репозитории.
-
-На GOG `MTCHECK.MSH`/`DEFAULT.0` AMD Radeon Pro WX 3200 Series выполнил 300
-copy-команд, final artifact 4,147,200 bytes и hash `2184179010340020629` при
-validation warnings/errors `0/0`. Повторный идентичный запуск дал тот же
-размер и hash. Это доказывает Vulkan copy/readback path только для нашего
-static viewer. Он ещё не захватывает original DirectDraw frame, не задаёт
-fixed original camera и не сравнивает два изображения, поэтому pixel-parity
-acceptance остаётся blocked.
-
-The following paragraph records the superseded multi-image export only as
-historical bootstrap evidence; it is not the current artifact contract.
-
-Smoke также сохранял raw artifact рядом с JSON: `<report-stem>.readback-vkformat-<raw>.raw`.
-Это были concatenated current-swapchain images in Vulkan order, каждый с dimensions
-из JSON и четырьмя bytes per pixel; format намеренно указан в имени файла,
-потому что bytes не перекодируются; JSON содержит actual raw enum. GOG selected `50` and produced a 4,147,200-byte file
-for two 960x540 images. Артефакт остаётся локальным output и не попадает в Git.
-
-Smoke option `--expected-readback <path>` выполняет exact byte comparison после
-readback и завершает run с первым differing byte или length mismatch. Fresh GOG
-run с предыдущим format-50 artifact прошёл этот gate. Это regression contract
-нашего static Vulkan viewer, не comparison с оригинальным renderer.
-Comparator прежде сравнивает `vkformat` identity из имен обоих artifacts и
-отклоняет mismatch до byte scan; raw blobs разных Vulkan formats нельзя
-считать сопоставимыми без явной conversion contract.
-
-`fparkan-game` render-snapshot bridge больше не берёт только первый prepared
-visual mission object. Каждый `MissionAssets::visuals_for_object` entry
-создаёт отдельный backend-neutral draw с own mesh/material IDs; object без
-prepared visual сохраняет один compatibility fallback draw. Это сохраняет
-multi-component prototype graph для будущего real Vulkan renderer, однако
-текущий game app всё ещё использует planning backend и triangle ranges.
-
-### Mission position/scale render bridge
-
-`fparkan-game` now maps preserved TMA position and non-uniform scale into each
-backend-neutral draw matrix instead of diagnostic index placement. Raw
-orientation deliberately remains uninterpreted: the original Euler order and
-matrix convention are not yet proven. This preserves authored values without
-inventing transform math; the app still uses planning backend and triangle
-ranges, so it is not a full renderer claim.
-
-### Mission original-ID render provenance
-
-Each backend-neutral draw now retains the `OriginalObjectId` preserved by the
-TMA loader for its source mission object. This gives capture and diagnostics a
-stable link from a draw back to its original object record, including every
-visual component emitted for that object. The bridge deliberately leaves the
-value absent when no mission draft exists; it does not infer an original ID
-from a runtime slot or draw order.
-
-### Opt-in mission static-Vulkan bridge
-
-`fparkan-game --backend static-vulkan` is an explicit native-window experiment,
-not the default planning path. It visits only the first mission root, selects
-every prepared MSH component of that root, merges their static XY clip-space
-geometry, and renders a requested number of frames through
-`VulkanSmokeRenderer`; teardown rejects validation warnings/errors and reports
-swapchain/readback telemetry. For each used `Batch20.material_index`, it resolves
-the positional prepared WEAR material and uploads mip 0 of that material's first
-MAT0 diffuse texture request. Because `material_index` is local to an MSH/WEAR
-component, the merged preview assigns a unique preview-local selector only after
-this source resolution; it does not falsely treat equal local indexes as equal
-materials.
-
-This is a narrow bootstrap from mission assets to a live Vulkan renderer. It
-does not render every placed object, apply TMA transforms or orientation, select
-later MAT0 phases or animation, bind lightmaps, or establish a game camera. Fresh GOG
-`MISSIONS/Autodemo.00/data.tma` evidence now proves the narrow GPU bridge: one
-presented frame completed in 39.6 seconds with a native 1280×720 two-image
-swapchain, 14 merged mesh components and 14 selector-keyed original diffuse
-descriptors, 7,372,800-byte synchronized readback (FNV-1a
-`16595193636416981301`) and validation
-warnings/errors `0/0`. This is not a
-full-scene or original-renderer pixel-parity claim.
-
-The same bounded command was then run against the licensed installed Part 1
-(`C:\\Program Files (x86)\\Nikita\\IS`) and Part 2
-(`C:\\Program Files (x86)\\Nikita\\IS2`) corpora supplied for testing. Both
-`MISSIONS/Autodemo.00/data.tma` runs completed with 14 mesh components and 14
-original diffuse descriptors. Part 1 took 28.2 seconds and matched the GOG
-readback hash `16595193636416981301`; Part 2 took 93.6 seconds and remained
-validation-clean but produced distinct hash `18268338333658342130`. Part 2's
-synchronous checkpoint was `Graph` while it was still loading, so the longer
-startup is not attributed to Vulkan. This is only a cross-corpus confirmation
-of the first-root static-preview bridge, not a claim that the two games' full
-mission renderers are compatible.
-
-The preview now has an explicit root-prefix contract. `--preview-roots N`
-prepares the first non-zero `N` TMA roots for the opt-in static Vulkan path.
-Without that diagnostic limiter, the static Vulkan path now requests every TMA
-root (the runtime clamps the request to the decoded mission length), so a
-normal native preview no longer silently displays just root zero. Normal mission
-loading remains full and transactional; the prefix is retained for bounded
-investigation rather than as a hidden relaxation of gameplay validation.
-
-### Shared terrain/root diagnostic frame
-
-The bounded native preview now retains the already validated `Land.msh` inside
-`TerrainWorld`; this lets the application consume source geometry through the
-runtime boundary instead of decoding archive formats a second time. It merges
-one terrain component with the first TMA root's 14 reachable MSH components in
-one explicit top-down XY frame. The frame is computed from terrain positions
-and the root models after applying decoded TMA placement: non-uniform `scale`,
-then the recovered `Rz * Ry * Rx` orientation, then `position`. The XY camera
-itself remains diagnostic; applying the established placement transform here
-prevents differently oriented mission objects from being framed or rasterized
-at a false location.
-
-Terrain is assigned an explicit 1x1 white diagnostic texture, rather than a
-guessed terrain material. `Land.msh` slot selection, `material_tag`, masks,
-auxiliary streams, original terrain phases and camera remain unresolved. Thus
-this is an integrated geometry/provenance checkpoint, not a claim of original
-terrain shading or scene camera reconstruction.
-
-Fresh canonical GOG `MISSIONS/Autodemo.00/data.tma` evidence: one native
-1280x720 Vulkan frame completed in 40.6 seconds with 14 root MSH components,
-one terrain component, 15 descriptors, a 7,372,800-byte readback (FNV-1a
-`10739087367165646439`) and validation warnings/errors `0/0`. The distinct hash
-from the earlier first-root-only preview is expected because the submitted
-geometry and descriptor set changed; it is not an original-frame comparison.
-
-### Multiple static-preview roots
-
-The native bridge now combines every mesh-backed visual of each selected root,
-using that root's recovered static TMA placement (`Rz * Ry * Rx` after scale)
-in the shared diagnostic XY frame. It reports the actual selected root count as
-`preview_roots`, rather than implying that all mission objects are rendered.
-The original camera/frustum, dynamic orientation ownership and visibility
-remain unresolved.
-
-Fresh canonical GOG `Autodemo.00` evidence with `--preview-roots 2` completed
-in 59.7 seconds: 25 submitted MSH components, one terrain component and 26
-descriptors on the native 1280x720 two-image swapchain, with validation
-warnings/errors `0/0`. The readback FNV-1a remained
-`10739087367165646439`, equal to the one-root terrain/root run. That equality
-does **not** prove the second root is visible or equivalent: it was recorded as
-an investigation target, not hidden as a parity result.
-
-The reproducible `fparkan-cli mission inspect` probe closes two tempting but
-incorrect explanations for that equality. GOG `Autodemo.00` root 0 is
-`w_m_wlk2.dat` at `(418.10318, 717.433, 3.0409389)` while root 1 is
-`w_s_wlk1.dat` at `(479.12396, 795.95337, 1.6228507)`; therefore the TMA data
-is neither duplicate nor co-located. Separately, the Vulkan static submit loop
-calls `cmd_draw_indexed` for every prepared draw range and its depth comparison
-is `LESS_OR_EQUAL`. A later direct `Land.msh` bounds probe identified the actual
-static-viewer defect: GOG `AutoMAP/Land.msh` spans X/Y
-`0..1190.6976` but Z only `0..94.50981`; the same TMA objects use X/Y for their
-world placement and a small Z height. The old XZ CPU frame discarded the second
-horizontal TMA component and treated height as the screen axis. The renderer now
-uses a shared XY CPU frame and retains Z as geometry height. This proves only
-the source-axis convention needed by the diagnostic bridge; it does not recover
-the original camera, orientation order, culling or projection matrix.
-
-The new `fparkan-cli terrain inspect <Land.msh>` exposes these three-axis source
-bounds without a renderer. The first XY GPU rechecks were intentionally bounded
-to 120 seconds but remained at the existing `Graph` loading checkpoint and were
-terminated without a native frame/report; therefore no new readback hash or GPU
-acceptance result is claimed for the corrected projection yet.
-
-The load trace now splits base root expansion (`Graph`) from MSH/WEAR/MAT0/TEXM
-visual-dependency expansion (`GraphVisuals`) and enters `Assets` immediately
-before actual asset preparation. A controlled 60-second GOG first-root run of
-the XY build stopped at `GraphVisuals`; the process was terminated by the probe
-and produced no frame. The startup bottleneck is therefore narrowed to visual
-graph expansion, rather than the base prototype graph, asset decoding, window
-creation or Vulkan submission. This is a diagnostic boundary, not a claim that
-the visual expansion is semantically optional or may be skipped.
-
-Visual expansion now caches the validation result of each unique diffuse TEXM
-and each unique lightmap TEXM independently. It still emits an edge and request
-count for every original material reference, and it replays the same cached
-success or failure at every reference; only duplicate archive open/read/decode
-work is removed. Under the same controlled 60-second GOG first-root probe, the
-last checkpoint advanced to `Assets`. The process was deliberately terminated
-before a frame, so this proves reduced visual-graph work rather than a Vulkan
-acceptance result.
-
-Asset preparation additionally caches validated MSH models and decoded WEAR
-tables by their complete resource key. This preserves separate prepared visual
-provenance while avoiding repeat format work for repeated components. The same
-60-second first-root GOG probe remained at `Assets`, so that corpus does not
-demonstrate an additional checkpoint advance from this cache; it must not be
-reported as a measured startup improvement.
-
-Visual-graph material resolution now also caches the complete success or failure
-of each WEAR material request. Every material edge and failure remains in the
-graph; the cache only avoids reopening and decoding an already resolved MAT0.
-Under the controlled 60-second GOG first-root probe, the phase sequence advanced
-from `GraphVisuals` to `AssetModelMeshes`, `AssetWearTables`, then
-`AssetTextures` before termination. This is a bounded timing observation, not a
-claim that a frame completed or that every run has identical timing.
-
-After these visual-graph caches, the corrected XY static-Vulkan preview again
-completed on canonical GOG `MISSIONS/Autodemo.00/data.tma` within a controlled
-120-second run. It reported `Complete`, a native 1280x720 two-image swapchain,
-one frame, 14 root MSH components, one terrain component, 15 material
-descriptors, validation warnings/errors `0/0`, and a 7,372,800-byte readback
-FNV-1a `6451493914305554398`. This is the first GPU acceptance evidence for
-the corrected XY diagnostic projection; the hash is not compared with the
-original renderer and does not establish original camera, shading or parity.
-
-The identical bounded XY command also completed against the supplied licensed
-Part 1 installation (`C:\\Program Files (x86)\\Nikita\\IS`). Its report had
-the same 14 root MSH components, terrain component, 15 descriptors, native
-1280x720 two-image swapchain, validation `0/0`, and FNV-1a
-`6451493914305554398`. This confirms the current first-root diagnostic bridge
-across GOG and Part 1 data; it does not claim full mission or Part 2 parity.
-
-The corresponding Part 2 run (`C:\\Program Files (x86)\\Nikita\\IS2`) did
-not reach asset preparation or a Vulkan frame within a controlled 180-second
-window: its final persisted checkpoint was `GraphVisuals`, and the exact child
-process was terminated. This gives no Part 2 GPU acceptance or pixel result and
-does not attribute the delay to Vulkan; its visual-dependency corpus remains a
-separate profiling and compatibility target.
-
-`GraphVisuals` is now split observationally into `GraphVisualWears`,
-`GraphVisualMaterials` and `GraphVisualTextures`; it preserves the existing
-graph traversal, edge order and validation semantics. Repeating the controlled
-180-second Part 2 probe once ended at `GraphVisualTextures`. That particular
-execution had passed WEAR and MAT0 expansion before timeout, but a checkpoint
-is only the last phase reached in one run: it is not proof of a single global
-bottleneck or of Vulkan involvement.
-
-Visual expansion now reports each dependency-class entrance, its first request
-and every 64th later request; runtime progress persists MAT0/TEXM counts at
-those boundaries. This is diagnostic-only and does not alter traversal, edges
-or validation. A
-later controlled 180-second Part 2 probe ended at `GraphVisualMaterials`,
-before the first TEXM progress marker. The differing checkpoints rule out the
-previous overly narrow claim that TEXM is the sole remaining target. Profiling
-must compare WEAR, MAT0, TEXM, graph allocation and archive I/O quantitatively.
-
-Visual expansion now snapshots each prototype's base-graph `Prototype→MSH`
-anchor before it appends any WEAR/MAT0/TEXM nodes and edges. The subsequent
-traversal reads this immutable vector rather than repeatedly linearly searching
-the growing graph; graph content, node/edge IDs and provenance remain
-unchanged. The same controlled 180-second Part 2 probe still ended at
-`GraphVisualMaterials`, so this is a complexity/correctness improvement rather
-than measured evidence of a startup checkpoint advance.
-
-The MAT0 counter now records `GraphVisualMaterialRequests(N)` with the same
-64-request throttle. A fresh controlled Part 2 probe ended at
-`GraphVisualMaterialRequests(71)`: at least 71 MAT0 requests had begun before
-termination. It does not preserve the simultaneous TEXM count, so it neither
-identifies a slow individual material nor attributes time to MAT0 parsing; the
-next profiler revision needs one cumulative snapshot of all classes.
-
-Visual MAT0/TEXM request markers now also carry the graph node and edge counts
-materialized immediately before that request resolves. A rebuilt bounded Part 2
-probe recorded MAT0 request 3 at 52 nodes / 51 edges and request 4 at 55 nodes
-/ 54 edges. These are observational snapshots for graph-growth profiling only:
-they do not change traversal order, node or edge identities, provenance,
-validation, cache semantics, or rendering.
-
-The same throttled snapshots now retain the number of distinct validation-cache
-keys for WEAR, MAT0, and TEXM before the request. In a fresh 50-second Part 2
-probe, MAT0 request 3 recorded cache counts 2 / 3 / 3 and request 4 recorded
-3 / 4 / 4, while the graph moved from 52 / 51 to 55 / 54 nodes / edges. The
-counts distinguish cache growth from graph growth without adding per-request
-file writes or asserting which parser, archive I/O operation, or allocation
-caused the elapsed interval.
-
-`fparkan-game --load-progress` now initializes its file with `Starting` and
-appends each later mission-load event instead of replacing the prior line. A
-bounded probe can therefore retain both MAT0 and TEXM request milestones even
-when a later event is last. The behavior is diagnostic persistence only: it
-does not alter graph traversal, resource validation, or rendering.
-
-Each trace row now keeps the phase name first and appends a monotonic
-`elapsed_ms=<N>` field from one process-local `Instant`; successful completion
-is appended rather than overwriting the trace. A short rebuilt Part 2 probe
-confirmed real timestamps from `Map` at 5 ms through `GraphVisualTextures` at
-2,389 ms, then later MAT0 request milestones at 28,841 and 35,486 ms. The
-probe was deliberately terminated and provides no frame/GPU result. These
-timestamps measure whole elapsed intervals, including archive I/O, allocation,
-validation, cache effects and progress-file writes; they are not per-parser
-benchmarks.
-
-One bounded Part 2 trace reached `GraphVisualTextureRequests(64)` at 134,793
-ms but did not reach `Assets`: its MAT0 markers were 3/4 at 28,823/35,473 ms,
-13/16 at 88,260/88,334 ms, 25 at 108,137 ms, 38 at 121,369 ms, and 47 at
-128,160 ms. The process was stopped immediately after the 134.8-second marker.
-This makes visual-graph expansion, rather than asset preparation or Vulkan,
-the measured unfinished interval for this run. It still does not allocate that
-time among MAT0 decode, graph allocation, archive I/O, OS cache state, or the
-interleaved TEXM requests.
-
-Visual expansion now also caches WEAR validation by the complete derived WEAR
-archive/name key and replays both successes and failures. It still creates each
-per-prototype WEAR edge, increments the same request counters, and reports the
-same failure provenance. A unit test verifies an archive-qualified cached
-failure is replayed without consulting the repository. In a matched bounded
-Part 2 probe the first TEXM-64 marker occurred at 135,520 ms, versus 134,793
-ms in the preceding sample, and neither run reached `Assets`; therefore this
-corpus does not evidence a startup advance from WEAR caching. The path is kept
-as a correctness-preserving duplicate-resolve optimization, not a causal
-performance claim.
-
-A rebuilt executable then ran a controlled 180-second Part 2 `Autodemo.00`
-probe with the append-only trace. Before exact-child termination it recorded
-`GraphVisualTextureRequests(64)`, `GraphVisualMaterialRequests(100)`, and every
-asset-preparation checkpoint through `AssetTextures`. It produced no native
-window, frame, readback, or Vulkan acceptance report because it was terminated
-while preparation was still active. This is one warm-state timing sample, not
-evidence that the trace change improved loading or that either MAT0/TEXM alone
-caused the earlier timeout; it does show the prior last-event-only trace hid
-concurrent request classes and later preparation progress.
-
-Mission loading now raises the decoded-payload cache entry budget from 64 to
-256 while retaining its 64 MiB byte budget. This avoids premature entry-count
-eviction during resource-rich loads without making memory unbounded. The same
-180-second Part 2 probe nevertheless remained at `GraphVisualTextures`; this
-change is a safe cache-capacity improvement, not measured evidence of a Part 2
-startup advance.
-
-`Assets` now has four ordered diagnostic sub-checkpoints: `AssetModelMeshes`
-(MSH), `AssetWearTables` (WEAR), `AssetMaterials` (MAT0) and `AssetTextures`
-(diffuse TEXM and lightmaps). The callback is observational: it neither changes
-the preparation order nor deduplicates requests. A fresh controlled 60-second
-canonical GOG `Autodemo.00` first-root probe reached `AssetWearTables` but not
-`AssetMaterials`; its created process was terminated and no native frame was
-reported. At least one MSH therefore finished before the timeout, but this does
-not attribute the remaining interval to WEAR decoding rather than later
-MSH/WEAR iteration or archive I/O.
-
-The same source-axis proof now applies to `TerrainWorld`: its `Land.msh` surface
-height query and `Land.map` areal/grid lookup use XY ground coordinates, return
-source Z height, and leave raycasts as full 3D intersections. The Part 1 and
-Part 2 licensed `Land.map` gate successfully locates sampled polygon vertices
-under that contract. This closes the former XZ mismatch in terrain queries; it
-does not establish object orientation, physics/gravity, original culling or the
-camera matrix.
-
-### Camera ownership boundary from the GOG renderer
-
-The GOG `World3D.dll` export `LoadCamera` at RVA `0x1FB06` is only an import
-thunk: it jumps through IAT `0x10020148` to `Terrain.dll!LoadCamera`. The
-Terrain export is at RVA `0x4EBE0` and is a `__stdcall` entry with `ret 0x10`,
-so it receives four machine-word arguments. It allocates a `0x1A4`-byte object,
-forwards those four words to its constructor, and returns a pointer at object
-offset `+0x134`. The constructor is at RVA `0x4EC60`: it passes arguments 3 and
-4 to the base initialization at `this + 4`, stores argument 3 at `this + 0x138`,
-and passes arguments 1, 2 and 4 plus `this` to its helper at RVA `0x4CEF0`.
-Argument 1 is a NUL-terminated mode string at this call boundary: only the
-embedded literals `REFLECTION` and `REFLECTION_SHIFTED` select a non-zero value
-stored at `this + 0x1A0`. This proves that `LoadCamera` supports reflection
-variants, but not the semantic types of its remaining arguments or the meaning
-of the resulting value. `Terrain.dll!stdGetCurrentCamera2` returns a global
-camera pointer, and `stdSetCurrentCamera2` updates it through the object's
-interface methods.
-
-This establishes that original camera creation/selection is Terrain-owned and
-not a field that can safely be inferred from a TMA transform. It does **not**
-yet establish world-to-view matrix layout, FOV, near/far values, projection
-handedness, or initial mission camera selection. The Vulkan path must therefore
-continue to label its XY projection as diagnostic until a dynamic capture or
-further Terrain disassembly proves these contracts.
-
-On 2026-07-18 the canonical windowed GOG process was confirmed running with
-one targetable window titled `Parkan. Железная Стратегия`. Two read-only
-Windows.Graphics.Capture attempts failed before returning a frame with
-`SetIsBorderRequired failed: Интерфейс не поддерживается (0x80004002)`. No game
-input was sent and this contributes no visual, camera, timing, or memory
-evidence; a different capture/debugger path is required for dynamic analysis.
-
-The public `World3D.dll!stdSetCurrentCamera` is a one-pointer `__stdcall`
-wrapper: it delegates to the Terrain `stdSetCurrentCamera2` IAT thunk and then
-writes the same pointer to a World3D mirror global. An `iron3d.dll` call site
-at RVA `0x4FB95` loads the argument from an active game-state chain
-`[esi + 0xBC90] + 0x94` before calling that wrapper. This proves that the
-selected camera is an object reference supplied by game state, not a scalar
-mission-coordinate field. The caller's enclosing type and the meaning of
-offsets remain unnamed until a class layout is recovered.
-
-Terrain strings also identify `CBufferingCamera`, `ICamera::SetTransformMatrix`
-and `ICamera::GetTransformMatrix`, plus frustum/clip methods. These names prove
-that a transform-matrix interface exists, but give neither method slots nor a
-matrix convention; they must not be treated as a usable renderer ABI yet.
-
-### Buffering-camera projection path from static Terrain analysis
-
-A further headless-IDA review of the same GOG `Terrain.dll` identifies a
-`CBufferingCamera`-related implementation path without requiring a live game
-process. The procedure at RVA `0x4D740` copies exactly 64 bytes (16 dwords)
-into its receiver at offset `+0x10`. The frame-preparation procedure at RVA
-`0x4D9C0` queries a viewport rectangle through a virtual method at `+0x3C`, derives its
-width, height, centre and an aspect ratio from that rectangle, and obtains
-projection data through the camera interface. Projection type `0` uses a
-receiver float at offset `+0x234` as an angle passed to `tan(angle / 2)`;
-projection type `2` obtains a five-float block through a virtual method at
-`+0x70`. Any other non-zero type raises `Not supported projection type`.
-
-This is sufficient to prove that original viewport and projection state are
-live camera inputs, rather than a fixed diagnostic projection. It does **not**
-yet prove matrix row/column convention, angle units, near/far field mapping,
-handedness, or the initial mission camera object. In particular, the exported
-string-labelled `ICamera::SetTransformMatrix` (RVA `0x4F830`) and
-`ICamera::GetTransformMatrix` (RVA `0x4F850`) are both obsolete-call stubs, so
-they are not a usable method ABI.
-
-Live read-only evidence now confirms the relocation-aware global contract. In
-one elevated GOG run, `Terrain.dll` loaded at `0x02510000`; its global at
-`base + 0x7355C` held non-null `0x0B37DF08`, whose first dword was
-`0x025765B4` — exactly the relocated `off_100665B4` vtable from the
-`LoadCamera` construction path. This proves the global is a live camera object
-with that outer vtable. It also corrects an overreach: raw reads at global
-offsets `+0x10` and `+0x234` did not yield a finite 4×4 matrix and a projection
-angle in this sample. The static procedures' receiver and the exported global
-pointer are therefore not yet proven to have the same adjustment/layout; those
-offsets must remain unassigned until the selector/interface relationship is
-recovered.
-
-The unattended demo also proves that this global is dynamic. Across 25 seconds
-of read-only sampling it held three distinct heap pointers; all began with the
-same relocated outer vtables `0x025765B4` and `0x02576558`. Two selected
-objects exposed synchronized translation triples in the paired raw blocks at
-offsets `+0x2C/+0x3C/+0x4C` and `+0x6C/+0x7C/+0x8C`, with world-like values
-such as `(491.562, 761.551, 7.361)`. A third object used the same vtable but
-reported normalized-looking values near `(0.098, 0.018, 0.856)` and did not
-match the paired block. The observed pointer transitions are consistent with
-the unattended camera changes, but the sample carries no mode label and does
-not prove first- versus third-person identity. It does prove that the static
-direct-xref initializer is not the sole runtime writer of the global; indirect
-or unanalyzed write paths remain to be recovered.
-
-The shared outer vtable now recovers the exact transform adjustment for the
-world-like samples. Its slot `+0x54` asks the subobject at `outer + 4` for
-selector `0` through that subobject's slot `+0x20`, then copies three dwords
-from returned offsets `+0x0C`, `+0x1C`, and `+0x2C`. In the live world-like
-object, the selector field at `outer + 0x10` was `0xFFFFFFFF`; the recovered
-selector implementation therefore returns `outer + 0x20`. The copied triple is
-exactly `outer + 0x2C/+0x3C/+0x4C`, proving its association with the active
-affine transform rather than merely a sampled correlation. The paired block
-starts at `outer + 0x60` under selector `2`. Another outer slot at `+0x70`
-obtains that selector-2 transform and applies `atan2` to its axis values, so it
-is a proven orientation-angle path. Field names, angle order and matrix
-handedness remain unassigned, but a backend-neutral camera pose may now retain
-the raw affine transform and the exact translation triple without guessing them.
-
-The render contract now represents that boundary directly. `RawCameraTransform`
-preserves each selector result as sixteen original `u32` words, so diagnostic
-data is not rounded or silently assigned a row/column convention. Its only
-typed accessor reinterprets the proven X/Y/Z words `3/7/11` as IEEE-754
-floats. `RawCameraPose` retains selector `0` and selector `2` together, and
-`CameraSnapshot::raw_pose` is optional. The present Vulkan renderer continues
-to use its existing view/projection matrices: raw pose capture is intentionally
-not converted into a view matrix until the original transform/projection
-convention has evidence.
-
-The next live sample strengthens the representation contract but still does
-not name the transform's camera-space direction. For six no-input samples of
-one auto-demo camera object, selectors `0` and `2` were byte-identical,
-finite affine blocks with bottom row `(0, 0, 0, 1)`. Their upper 3x3 basis
-was normalized and their last-column translations changed from approximately
-`(441.038, 687.481, 10.754)` to `(433.545, 652.293, 10.673)`.
-
-This storage/order is not guessed: live `NGI32.dll!g_FastProc[23]` dispatches
-to RVA `0x1D9A0`, whose SSE implementation writes each output row as the sum
-of four scalar-weighted rows. Thus a contiguous 16-float block is multiplied
-as a conventional row-major 4x4 matrix. The render contract now exposes a
-mathematical `try_inverse_affine_row_major` helper for such finite,
-non-singular `[R | t; 0 0 0 1]` blocks. It returns no result for a singular or
-non-affine block and, crucially, does **not** identify that inverse as a view
-matrix. Whether a selector result is camera-to-world or already a renderer
-transform remains an evidence boundary.
-
-The active camera now also has a proven live projection-interface boundary.
-`LoadCamera` stores its selector-18 camera interface at outer offset `+0x19C`;
-in the elevated GOG AutoDemo sample it was a `CBufferingCamera` object with
-relocated vtable `0x02576344`. Its rectangle at `+0x10` was exactly
-`(left=0, top=0, right=1024, bottom=768)`, its projection-type field at
-`+0x230` was `0`, and its FOV field at `+0x234` was `1.04` radians.
-`CBufferingCamera` passes that FOV to `tan(fov / 2)` on its type-0 projection
-path. This proves the live angle input and viewport for this sample;
-it does not yet assign the two remaining projection values, near/far planes,
-depth range, clip-space Y direction, or a Vulkan projection matrix.
-
-The unresolved values are now located rather than guessed. `CBufferingCamera`
-uses its primary-render interface at component offset `+0x220` to select a
-render context, then asks that context for a five-float projection block. The
-sample's primary vtable was relocated `0x02574DF0`; its selector at `+0x0C`
-is only a table lookup keyed by two still-unnamed inputs. No virtual call was
-made by the probe. Therefore the block's values cannot yet be assigned as
-near/far or reused by the Vulkan adapter.
-
-The runtime render model now retains this evidence as `RawCameraProjection` on
-`CameraSnapshot`: the viewport rectangle, projection selector, exact FOV bits,
-and all five context words travel through the backend-neutral boundary without
-changing the current Vulkan projection. Its float accessors deliberately leave
-the context fields unnamed; preserving a raw ABI value is safer than encoding a
-near/far or clip-space convention before it is demonstrated.
-
-The next downstream boundary is now also identified. In the canonical static
-`CBufferingCamera` vtable (`0x10066344`), slot `+0x4C` is Terrain RVA
-`0x4D9C0`; it packages the camera transform, rectangle-derived viewport data,
-FOV-derived tangent, and primary-render context values for another renderer
-component. It does not itself assemble the final projection matrix. The GOG
-`Ngi32.dll` export named `vrtSetCameraParam` resolves to a no-op compatibility
-stub, while the live process loads the Direct3D7 runtime (`D3DIM700.DLL`). The
-next recovery target is therefore the Direct3D transform consumer in
-`iron3d.dll`, not a guessed Vulkan perspective formula. The observed `0.5`,
-`700`, `0.1`, and `0.99` values remain unlabelled until that consumer proves
-their roles.
-
-### Ngi32 Direct3D7 camera matrices recovered
-
-The downstream renderer is now concrete. `iron3d.dll` obtains an opaque
-render interface through `Ngi32!niGet3DRender`; Ngi32 RVA `0x5640` returns the
-global renderer object at RVA `0x3A460`. Its constructor at RVA `0x5E10` gives
-the `0x8CC`-byte object vtable `0x100315E0`. During device setup, Ngi32 RVA
-`0x8E70` calls the Direct3D7 device vtable at `+0x2C` (`SetTransform`) with
-state `3` for the matrix made by RVA `0x7030` and state `2` for the matrix made
-by RVA `0x9450`: projection then view.
-
-RVA `0x7030` exactly builds the row-major D3D7 projection from renderer FOV
-`f`, near `n`, far `z` and viewport width/height `w`/`h`:
-```text
-[ cos(f/2), 0, 0, 0 ]
-[ 0, w/h * cos(f/2), 0, 0 ]
-[ 0, 0, sin(f/2)*z/(z-n), sin(f/2) ]
-[ 0, 0, -sin(f/2)*n*z/(z-n), 0 ]
-```
+## Камера и размещение в оригинале
-The sine in the depth scale and homogeneous-W term is intentional: after the
-D3D perspective divide the diagonal has the expected cotangent scale and the
-depth range remains finite. RVA `0x9450` applies a
-specific axis permutation/sign change and translated dot products to selector
-0 before the view `SetTransform`; it is not merely the generic affine inverse.
-
-An elevated read-only AutoDemo probe sampled a 1024×768 renderer with
-near=`0.5`, far=`700`, FOV=`1.3` radians and showed its view-source pointer
-byte-identical to the active Terrain outer camera's selector-0 block. This
-proves ownership and the D3D7 boundary, while keeping the earlier
-`CBufferingCamera` FOV=`1.04` as a distinct upstream interface value.
-
-The live GOG layout is now independently repeatable without invoking any game
-method: `Terrain.dll + 0x7355c` contains the current outer camera-object
-pointer, whose vtable relocates to `Terrain.dll` RVA `0x665b4`. Selector 0
-returns the affine block beginning at outer `+0x20`; its sixteen row-major
-words have translations at indices 3, 7 and 11. The public `LoadCamera`
-interface at outer `+0x134` is a different view and must not be substituted
-for that block. Captures for `RawCameraTransform` therefore contain the sixteen
-selector-0 words from outer `+0x20`. A passive AutoDemo sample produced a
-finite affine camera matrix and passed through the offline Vulkan adapter as
-source-world geometry, rather than relying on a guessed identity view.
-
-The Vulkan static path now accepts this recovered camera as
-`VulkanStaticCamera`. It composes the row-major D3D7 result as
-`view * projection`, uploads its 64 bytes through a vertex-stage push constant,
-and changes static vertices from XY to XYZ. GLSL reads the same bytes as its
-default column-major `mat4`; therefore `matrix * vec4(position, 1)` is the
-transpose-equivalent of the original D3D7 row-vector multiplication. Both APIs
-use the 0..1 depth range, and this renderer uses a positive viewport height, so
-this narrow bridge needs no extra depth or Y correction. The fragment alpha
-cutoff moved to byte offset 64 in its own push-constant range. The default
-identity camera deliberately preserves the old XY diagnostic preview; feeding
-live terrain camera/projection values into mission rendering remains later
-runtime wiring, not a claim of scene parity.
-
-The geometry side of that handoff is also explicit. The static mesh adapter now
-has source-world projections for `Land.msh` and MSH: terrain positions retain
-all three decoded coordinates; model positions retain Z and apply the recovered
-static mission placement. The diagnostic XY counterpart uses that same placement
-before normalizing X/Y, so it no longer silently omits authored object rotation.
-These APIs preserve triangle order, batch ranges and packed UV decoding. The
-current game preview deliberately stays on its XY diagnostic projection until a
-runtime supplies a real legacy camera; selecting source-world coordinates with
-identity camera would be a misleading empty/off-screen viewer rather than a
-compatibility result.
-
-Static analysis of GOG `iron3d.dll` recovers the placement primitive at RVA
-`0x36610`: it evaluates a row-major affine `Rz(z) * Ry(y) * Rx(x)` and writes
-translation in the final column. The backend-neutral
-`LegacyIron3dEulerTransform` preserves that finite portable formula with golden
-yaw and scaled-point tests. A read-only elevated AutoDemo scan closes the
-previous TMA binding gap for static placement: it finds the exact raw mission
-records (position, `(0, 0, z)`, scale) and live object blocks at the same
-world positions. For example, `z = 1.6262363` produces the observed pair
-`-sin(z) = -0.99846`, `cos(z) = -0.05541`; matching pairs occur for the other
-observed objects. The opt-in captured-camera path therefore applies
-`R * (scale * local_position) + translation` before Vulkan upload. It remains
-a static-placement contract: dynamic/animated transforms, physics ownership,
-terrain material selection, lighting and gameplay parity are still separate
-evidence tasks.
-
-`VulkanSmokeRenderer::set_camera` now accepts a new finite camera between frame
-submissions and uses it for the next command buffer without rebuilding the
-swapchain, buffers, descriptors or pipelines. `VulkanStaticCamera` also accepts
-generic finite row-major view/projection pairs, so the final game camera
-controller need not be coupled to the D3D7 recovery type. The native Windows
-smoke executes that update before each of 300 validation-clean frames. This is
-the renderer-side cadence contract only; it does not yet supply a gameplay
-camera controller or claim camera movement parity.
-
-For controlled differential work, `fparkan-game --backend static-vulkan` now
-accepts `--legacy-camera-capture <path>`. The JSON capture stores only the
-selector-0 words and the already recovered Ngi32 viewport/near/far/FOV inputs;
-the application reconstructs the D3D7 camera offline, selects source-world
-geometry, and never attaches to or controls the original process. A live
-read-only AutoDemo confirmation corrected an ABI ambiguity: the Terrain global
-is the base of the `0x1A4` camera object (vtables at `+0` and `+4`), while
-`LoadCamera`'s public return is the separate interface view at `base + 0x134`
-with its own vtable. This capture format deliberately preserves the former
-base-object interpretation and does not invent camera ownership.
-
-A three-frame GOG AutoDemo run with one such read-only capture selected the
-`legacy-d3d7-capture` path, rendered all eight mission roots / 66 MSH
-components plus terrain, reported 5,226 vertices inside the recovered clip
-volume, produced a 7,372,800-byte readback with FNV-1a
-`1737487240747766901`, and ended with zero Vulkan validation warnings and
-errors. This validates the offline handoff for one captured instant; it does
-not claim live camera tracking, material parity, or an original-frame pixel
-comparison.
-
-`tools/capture-original-camera.ps1` makes that handoff repeatable for a live
-GOG process. It finds the actual `Terrain.dll` base with Toolhelp module
-enumeration, opens the chosen PID with only
-`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, verifies the expected outer
-vtable, and reads exactly 64 selector-0 bytes at outer `+0x20`. AutoDemo can
-briefly select a normalized/reflection-like camera object whose finite
-translation is near the origin. The tool therefore re-reads the global on
-each attempt and accepts a sample only when the proven translation length
-exceeds its explicit `-MinimumWorldTranslation` threshold (default `100`);
-the JSON retains the accepted outer pointer and translation for audit. It emits the
-`fparkan-legacy-camera-v1` JSON accepted by `fparkan-game`; it does not write
-to the process, send window messages, inject code, suspend threads, or invoke
-an original method. PowerShell execution policy may require the explicit
-one-shot bypass below; that changes neither the script nor the game.
-
-```powershell
-$capture = powershell -NoProfile -ExecutionPolicy Bypass -File `
- .\tools\capture-original-camera.ps1 -ProcessId 5632
-$capture | Set-Content -NoNewline target\fparkan\live-camera.json
-cargo run -q -p fparkan-game -- --root 'C:\GOG Games\Parkan - Iron Strategy' `
- --mission 'MISSIONS\Autodemo.00\data.tma' --backend static-vulkan --frames 3 `
- --legacy-camera-capture target\fparkan\live-camera.json `
- --readback-out target\fparkan\autodemo-live-camera.raw
-```
+Миссия хранит положение, три угла и масштаб объекта. Функция `iron3d.dll`
+RVA `0x36610` строит `Rz(z) * Ry(y) * Rx(x)` с переносом в последнем столбце.
+Точка модели преобразуется как `R * (scale * point) + position`.
+Повороты и переносы узлов модели применяются до размещения объекта в миссии.
-On the unattended live AutoDemo the accepted world-space capture completed the
-same all-root static bridge with 10,152 clip-visible vertices, 71 descriptors,
-a 7,372,800-byte format-50 readback, and zero Vulkan validation
-warnings/errors. A passive desktop-surface capture of that original window also
-proved that the selected instant contains the textured terrain, atmospheric
-sky, HUD and weapon FX; `PrintWindow` itself returns a black Direct3D buffer,
-so it must not be treated as an original-frame oracle. The paired images expose
-the next real renderer gap: sharing the raw pose and Ngi32 projection inputs is
-not yet pixel parity, because the static bridge still lacks the full runtime
-camera-selection timing, terrain composition, culling, lighting, UI and FX.
-
-For visual regression work, `fparkan-game --backend static-vulkan` also accepts
-`--readback-out <path>`. It writes the final synchronized Vulkan swapchain
-image only after the renderer has completed its normal teardown evidence; the
-JSON report records its raw Vulkan format, byte count, hash, and requested
-path. At 1280x720, format `50` (`VK_FORMAT_B8G8R8A8_UNORM`) produces exactly
-3,686,400 bytes. The artifact is a Vulkan-side comparison input, not an
-asserted original-frame image.
-
-The first bounded launch showed that repeated fingerprinting, rather than
-Vulkan initialization, was the load-path bottleneck: each new MAT0/TEXM request
-re-opened an already decoded archive and re-hashed its entire source file.
-`ResourceRepository::open_archive_unchanged` now makes the *explicit* bounded
-loading-transaction contract available. The normal `open_archive` remains the
-strict public path and still invalidates stale entry handles after an external
-archive replacement; only mission asset preparation uses the transactional
-variant because it consumes an immutable snapshot of the selected game root.
-
-With that scoped cache, canonical GOG
-`MISSIONS/Autodemo.00/data.tma --backend static-vulkan --preview-roots 1`
-completed preparation in 6.308 seconds (the visual graph completed in 6.012
-seconds) and produced a 1280×720 native Vulkan frame with the captured D3D7
-camera, 15 material descriptors, and zero validation warnings or errors. This
-is a real original-asset rendering milestone, but not pixel parity or gameplay
-camera parity: it intentionally renders the bounded first preview root and
-uses a one-time offline camera capture.
-
-Expanding that same preview to all eight AutoDemo mission objects exposed a
-different, renderer-local limit: the merged static scene crossed 65,535
-vertices before any Vulkan command was recorded. `VulkanStaticMesh` now uses a
-32-bit GPU index buffer (`VK_INDEX_TYPE_UINT32`), while source MSH/TerrainFace28
-indices remain decoded in their original 16-bit representations and are widened
-only at the static-render bridge. A regression test crosses the former boundary.
-The full captured-camera AutoDemo preview completed at 13.369 seconds and
-rendered three 1280×720 native frames: 8 mission objects, 66 mesh components,
-67 material descriptors, and zero Vulkan validation warnings or errors. A later
-orientation-enabled run rendered the same full scene for three 1280×720 frames
-with `validation_warnings=0`, `validation_errors=0` and readback hash
-`6406107678925513509`. It is still a static source-world preview: terrain
-material selection, lighting, animation, physics and gameplay behavior have
-not yet been claimed as parity.
-
-A fresh no-input launch of the canonical `iron_3d.exe` did create a responsive
-window titled `Parkan. Железная Стратегия`. A read-only probe then requested
-`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and attempted to read the known
-Terrain global at image address `0x1007355C`; `OpenProcess` returned
-`ERROR_ACCESS_DENIED` (5), so no process memory or camera value was read. The
-window was left running. A same- or higher-integrity 32-bit debugger is still
-required for live camera evidence.
-
-`Land.msh` использует отдельный geometry-only bridge: validated `TerrainFace28`
-сохраняет source triangle order, а его positions и packed UV0 попадают в тот же
-static vertex/index upload path. Для текущего диагностического viewer XY bounds
-нормализуются в clip-space, packed UV0 декодируется как signed `int16 / 1024`.
-Один static draw range намеренно не трактует `material_tag`, surface mask, slots
-или auxiliary streams как material/pipeline state: их связь с original terrain
-renderer пока не доказана. GOG `DATA/MAPS/11/Land.msh` дал 6 458 vertices и
-24 672 indices в 300-frame native Vulkan run без validation warning/error.
-
-Выбор depth/stencil attachment отделён от renderer lifetime:
-`select_depth_stencil_attachment_format` применяет тот же фиксированный порядок
-форматов, что и capability gate, к фактически поддерживаемому списку GPU.
-Это исключает ситуацию, когда admission принимает один совместимый формат, а
-allocation позднее выбирает другой; resource уничтожается после framebuffer и
-render pass при recreate/teardown.
-
-После последнего world pass renderer закрывает сцену и выводит back buffer.
-World3D снимает `in_render`, восстанавливает временный viewport state и вызывает
-`on_end_render` у active objects. Только после этого допустимо освобождать
-temporary vertex buffers или заменять render representation. UI/shell
-обслуживается верхним уровнем после возврата из world-render path; для
-диагностики полезно уметь сохранять world-only command list и финальный
-framebuffer отдельно.
-
-Terrain material discovery is now reproducible instead of inferred from the
-mesh shape. The original AutoDemo map places two textual sidecar WEAR tables,
-`Land1.wea` and `Land2.wea`, beside `Land.msh`; `Terrain.dll` contains the
-corresponding `1.wea` and `2.wea` loading suffixes. The new terrain inspector
-reports all packed face tags. For `DATA/MAPS/AutoMAP/Land.msh`, its 3,174 faces
-use `0x0102` (592), `0x0203` (386), `0xff01` (918), `0xff02` (970), and
-`0xff03` (308). The low byte is always a valid positional selector 1..3 in
-`Land2.wea`; the high byte is a `Land1.wea` selector or sentinel `0xff`.
-This is evidence for a two-layer terrain-material contract, but not yet for
-its blend equation, so the Vulkan bridge intentionally remains texture-agnostic
-until the base/overlay composition is recovered.
-
-Static recovery of `Terrain.dll!CLandscape` now fixes the loader contract behind
-that observation. The constructor opens `Land.msh`, creates one material manager
-from `Land1.wea`, then invokes that manager's second-table load with `Land2.wea`.
-It requires NRes type 18 with the original diagnostic “microtexture mapping
-chunk” and retains it as four-byte entries; type 14 is optional. Therefore the
-two WEAR selectors must not be collapsed into an invented one-layer material,
-and the type-18 `aux18` stream is now documented as microtexture mapping data.
-`TerrainFace28::material_layers` exposes the evidenced high-byte `Land1`
-selector (with `0xff` absent sentinel) and low-byte `Land2` selector. The
-manager's actual blend/pass operation remains unrecovered. `World3D.dll`
-supplies the manager ABI: its load slot appends `Land1` as table 0 and `Land2`
-as table 1, while its material-phase lookup receives the exact 32-bit selector
-`(table_index << 16) | material_index`. `TerrainMaterialSelection` preserves
-that key for the renderer without assuming how the two phases are blended.
-
-The same `World3D!GetMaterialPhase` recovery establishes the next boundary. It
-validates the table/row selector, chooses a MAT0 phase (or interpolates two
-phases for an animated material), returns the selected material record, and
-hands its caller a 76-byte expanded phase-state block. It does not submit a
-draw or compose the two terrain tables. The later consumer of that record and
-the type-18 microtexture mapping remains the required evidence before adding
-an overlay, blend, or microtexture shader.
-
-The terrain render traversal is now located at `Terrain.dll` RVA `0x11340`.
-After visibility/cell work, it derives a 68-byte source-material record and
-dispatches `Terrain!GetShade()` slot `+16` with four arguments: zero, the
-record's word at `+2`, a pointer selected from a four-byte table by its word at
-`+0`, and zero. `GetShade` is a Terrain singleton, not `niGet3DRender`; the
-preceding slot `+60` call sets its visibility mode. Slot `+16` consumes the
-second word as the count of adjacent `u16` pairs and the first as the pair-table
-index, then groups the pairs by material lookup before constructing draw work.
-`TerrainSlotTable::render_dispatch` exposes exactly those two proven disk
-fields, while `LandMeshDocument::slot_material_pairs` decodes the selected
-type-11 entries as `{ material_lookup: u16, flags: u16 }`. Flag `0x0010` is a
-proven batch boundary in `GetShade`. Crucially, this lookup does **not** select
-either map-local Land table: the `GetShade` constructor loads its own one-table
-manager from `system.rlb` resource `Shade.wea`. That WEAR table has one row,
-`LIGHT1`, whereas AutoDemo's 3,174 pairs use keys `0..3173`; therefore the
-16-bit value is an opaque manager lookup/cache key, **not** a table-row
-selector. `TerrainMaterialPair::shade_lookup_key` records this separate key
-without conflating it with `Land1.wea`/`Land2.wea`. The AutoDemo inspector now
-reports 128 slots and 392 `0x0010` batch boundaries. The meanings of remaining
-flags, this cache mapping, and the final blend operation remain unassigned.
-
-The cache boundary is now mechanically separated from the WEAR loader. In the
-`GetShade` object, the `Shade.wea` manager is stored at byte offset `3092`, but
-the dispatcher calls a distinct object at byte offset `3244`. Its virtual slot
-`+16` receives every type-11 key repeatedly with mode values `512`, `16`,
-`20497`, and `20752`. The cache object itself has an eight-byte entry table at
-`+316` and its exclusive key count at `+320`: the first dword of an entry is a
-stored-record pointer and its byte at `+4` selects a 212-byte profile bank. A
-key outside that count, or an entry pointer below `0x1000`, fails the lookup.
-
-The static constructor now identifies this object as the embedded `CLandscape`
-subobject whose vtable is `Terrain.dll` RVA `0x643D0`. It installs that vtable
-at parent offset `+316`, allocates the entry table at parent `+632` (subobject
-`+316`) as `0x1f40` bytes / 1,000 eight-byte entries, and initializes its count
-to zero. The profile-bank region starts at subobject `+332`: every one of the
-100 banks has a 116-byte header followed by a 96-byte initialized stream, hence
-the recovered 212-byte stride. The byte selected from each table entry is
-therefore a bank index into a constructor-owned, fixed 100-bank region. This
-proves the cache's allocation layout. Its population path is also now located.
-`Terrain.dll` RVA `0x10280` resets the count, recreates the base entries from
-the 28-byte stream at parent `+756`, and assigns bank zero. RVA `0x12e20`
-copies one `0xd4`-byte profile header into bank `profile_count`, increments that
-count, and remaps supplied keys to 28-byte records through the entry table. A
-mapped record is stored as `profile_record_base + 28 * record_index` with the
-new bank byte; a mapping whose second index is `-1` stores only the profile
-ordinal, below `0x1000`, and is deliberately rejected by the lookup routine.
-This explains the runtime sentinel without interpreting it as a missing WEAR
-row. The input map is not a direct `Shade.wea` decode: the sole call site is
-the terrain loader at `Terrain.dll` RVA `0x0a3f0`, after it has generated and
-stitched 28-byte terrain records. That loader first appends pairs copied from
-an intermediate primitive-key list with second value `-1`, then appends one
-`(-1, generated_record_index)` pair for every generated record. Before calling
-the cache builder it runs a three-edge matching pass over those records, using
-their quantized positions and adjacency selectors. The cache profile therefore
-describes loader-produced geometry and its generated correspondence map, not a
-named on-disk `Shade.wea` section. The source of the intermediate primitive-key
-list and the exact semantics of the 28-byte record's non-position fields remain
-unassigned; this is sufficient to rule out an invented static WEAR parser, but
-not to reproduce the profile generator yet.
-
-On success the method returns the shared result view at cache offset `+24`, not
-the stored-record pointer and not a WEAR row. Thus the dispatcher's observed
-flags at result-view `+60`, secondary selector `+24`, optional byte `+88`,
-referenced state block `+104`, three signed shorts `+108`, and another selector
-`+112` are materialized lookup state, not yet proven on-disk record offsets.
-Mode `0x0200` (`512`) only validates the key in the recovered path; mode
-`0x5011` includes the recovered selector-table transfer and mode `0x5110`
-includes the recovered bank-byte transfer. The meanings of their remaining
-bits, record population, and the result fields remain unrecovered, so the Vulkan
-path must not map any of them to invented pipeline state.
-
-A read-only elevated AutoDemo probe confirms that this is a lifecycle boundary,
-not a permanently allocated table: the live `GetShade` singleton is initialized
-and its `Shade.wea` manager pointer is non-null, while its byte-offset `3244`
-cache pointer is normally null. Passive 100-ms sampling of the unattended
-AutoDemo caught it briefly at `0x035C64B4`; its relocated vtable is
-`Terrain.dll` RVA `0x643D0` and slot `+16` is RVA `0x10910`, matching the
-recovered lookup routine. Therefore a future runtime implementation must treat
-cache availability as contextual and cannot dereference or prebuild the cache
-solely from constructor state.
-
-`tools/capture-terrain-shade-cache.ps1` now makes the same evidence repeatable
-without debugger attachment. It discovers `Terrain.dll` with Toolhelp, opens
-only `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, searches readable
-32-bit-process regions for the already proven cache vtable, and excludes the
-Terrain image itself because relocations there also contain that pointer value.
-It reports only addresses and count metadata; it does not alter the process or
-save game resources. On the unattended GOG AutoDemo process 5632 on 2026-07-18
-it found the live cache at `0x035C64B4`, with entry table `0x17284590` and
-exclusive count `3462`. Two adjacent passive captures retained the cache
-address/table/count but returned result views `0x0274BD89` and `0x008A60DC`.
-That changing result view is direct evidence that it is transient lookup
-state, not a persistent cache identity or a WEAR row. The observation narrows
-the next terrain task to profiling the cache's generated entries and their
-consumer; it still does not establish a Land1 blend equation.
-
-The probe now also reads the bounded eight-byte entry table and only hashes
-the fixed 212-byte profile banks it observes; raw process bytes never enter
-the repository. The same AutoDemo cache had 3,426 materialized record pointers
-among 3,462 entries and referenced banks `0..4`. Two consecutive captures
-kept that count, bank set, and all five FNV-1a-64 bank hashes stable:
-`9426539192111566946`, `2915543853261903601`, `13156371703608727216`,
-`11280405573172155968`, and `18234288329145104612`. Their result views changed
-from `0x008A565C` to `0x008A55A8`. Thus the generated cache/profile state is
-stable over these render samples while the shared result view is not. This
-still identifies neither the record-field semantics nor the final terrain
-blend/pass operation.
-
-```powershell
-powershell -NoProfile -ExecutionPolicy Bypass -File `
- tools\capture-terrain-shade-cache.ps1 -ProcessId <iron_3d-pid>
-```
+Горизонтальная плоскость карты — XY, высота — Z. У GOG AutoDemo диапазон XY
+ландшафта составляет `0..1190.6976`. Высоты, сохранённые в `Land.msh`, переводятся
+в единицы мира делением на 32. Это отдельное преобразование ландшафта;
+применять его к координатам размещённых объектов нельзя.
-The static Vulkan bridge now carries each contiguous face run as a separate
-draw range keyed by the original packed `material_tag`; it neither reorders
-triangles nor collapses the high byte. For the first usable visual layer, the
-low byte resolves positionally through the adjacent standalone `Land2.wea`,
-then the existing `MAT0 -> Textures.lib -> TEXM` chain supplies the RGBA8
-descriptor. `Land1.wea` remains preserved as the high byte/overlay channel and
-is deliberately not drawn until its blend equation is recovered. A canonical
-three-frame AutoDemo run now uses 71 descriptors (66 MSH plus five terrain
-tags), produces readback hash `17034026600547661445`, and stays at zero Vulkan
-validation warnings/errors. This is original terrain texture upload, not
-terrain visual parity.
-
-```powershell
-cargo run -q -p fparkan-cli -- terrain inspect `
- 'C:\GOG Games\Parkan - Iron Strategy\DATA\MAPS\AutoMAP\Land.msh' --format json
-```
+`Ngi32!niGet3DRender` (RVA `0x5640`) возвращает графический интерфейс.
+Вызов Direct3D7 `SetTransform` устанавливает projection из RVA `0x7030`
+и view из RVA `0x9450`. Для FOV `f`, ближней плоскости `n`, дальней `z`
+и размеров viewport `w`, `h` projection в порядке строк имеет вид:
-## Проверки паритета
-
-Главные риски совпадения кадра:
-
-- x87 extended precision и правила округления;
-- различия scalar/SIMD slots `g_FastProc`;
-- порядок objects, batches и transparent primitives;
-- depth write/test, cull, alpha test и blend transitions;
-- mip-skip, palette и `Page` coordinates;
-- material fallback и выбор phase;
-- последовательность RNG для FX и atmosphere;
-- capability fallback конкретного устройства;
-- quantization времени и дополнительный simulation step;
-- eager/lazy resource resolve и cache side effects.
-
-Минимальный deterministic frame capture должен включать camera state, viewport,
-visible object IDs, выбранные LOD/group/slot, draw-item list, material и texture
-handles, pipeline keys, matrices, render phase, sort key, причины culling и
-hashes промежуточных buffers. Без такой трассировки нельзя уверенно отделить
-ошибку формата MSH от ошибки state machine renderer-а или сортировки.
-
-Связанные справочные страницы с таблицами форматов: [MSH](../reference/msh.md),
-[materials](../reference/materials.md), [Texm](../reference/texm.md) и
-[render frame](../reference/render-frame.md).
-
-### Live AutoDemo: empty frame устранён, но parity ещё не достигнут
-
-Read-only `PrintWindow` capture работающего GOG AutoDemo даёт полноценный
-оригинальный кадр (terrain, варбот и близкая geometry), поэтому теперь есть
-безвводный source для будущего visual comparison. Текущий полный static-Vulkan
-preview раньше состоял только из clear color. Validation `0/0` тогда доказывал
-только GPU lifecycle, но не совпадение с original renderer.
-
-Новая CPU-диагностика применяет к тем же source vertices ту же row-major D3D7
-matrix, которую GLSL получает как column-major push constant. Для свежего
-captured Ngi32 camera (`viewport 1024x768`, near `5`, far `700`, FOV `1.3`)
-она насчитала `clip_visible_vertices=0`. Последующая сверка с
-`Ngi32!sub_10009450` и `sub_10007030` нашла два literal translation defects:
-Y translation view matrix должна быть `-(m23*m22 + m13*m12 + m03*m02)`, а
-projection использует `width/height` и множитель `sin(fov/2)` в обоих depth
-coefficients. После исправления того же capture диагностировал
-`clip_visible_vertices=938`, а `PrintWindow` native Vulkan window показал
-реальную rasterized terrain/model geometry. Это исключает прежний empty-frame
-барьер; face culling и texture selection не были его причиной.
-
-Получившийся кадр всё ещё явно не похож на оригинал: geometry сосредоточена у
-границы и material/terrain representation грубая. Значит, следующие задачи —
-camera timing/selection, source model-node transforms, terrain material phases,
-lighting и visibility, а не объявление pixel parity.
-
-MSH static bridge теперь также перестал отправлять каждый `Batch20` файла.
-Для стандартного `Node38` он выбирает `slot_index[LOD0, group0]`, затем
-соответствующий `Slot68.batch_start..batch_count`; alternate LOD/groups больше
-не подмешиваются в initial static pose. Regression test фиксирует selection,
-а fresh AutoDemo run меняет readback hash при тех же 938 clip-visible vertices
-и остаётся validation-clean. Новый capture по-прежнему показывает сильное
-смещение частей, поэтому одной selection недостаточно: нужны local node poses,
-parent hierarchy и animation-key/fallback semantics. Модели без layout Node38
-сохраняют прежний all-batches diagnostic fallback.
-
-Чтобы следующий шаг не перечитывал raw NRes streams в renderer, validated
-`ModelAsset` теперь переносит `ModelAnimation` с decoded type-8 keys, type-19
-map и declared frame count. `node38_fallback_pose` возвращает exact fallback
-key pose. Licensed gates Part 1/Part 2 подтвердили 435/511 models, 157/200
-animated models, 3,469/5,233 node samples и утверждённые captures.
-
-Следующий узкий шаг применяет этот уже декодированный `fallback_key` в
-legacy-camera static preview. Для каждого выбранного `Node38` bridge поворачивает
-вершины normalized quaternion, прибавляет local translation pose и только затем
-применяет доказанный TMA `Rz * Ry * Rx`, scale и mission translation. Вершины
-намеренно разворачиваются per node/draw range, чтобы один source vertex мог
-получить разные poses. `parent_or_link_raw == 0xFFFF` теперь доказан как root,
-а любое меньшее child значение — parent index: inspected `R_B_01.msh` содержит
-34-node parent-before-child tree, а Part 1/Part 2 animation gate подтвердил
-этот контракт для всех standard nodes. Parent rotation корректно поворачивает
-child translation до суммирования. Frame map всё ещё не семплируется, поэтому
-это static fallback hierarchy, а не full animation parity.
-На той же GOG `MISSIONS/Autodemo.00/data.tma` и offline Ngi32 camera capture
-full preview из 8 objects / 66 mesh components / 67 descriptors завершился за
-один native Vulkan run с `clip_visible_vertices=3415`, `validation_warnings=0`,
-`validation_errors=0` и readback hash `1275533143935640133`. Рост с 2584
-видимых вершин подтверждает, что composed parent poses реально вошли в geometry
-path; он не является оценкой сходства с original frame.
-
-Параллельно bridge для legacy-camera path теперь переводит только высоту
-`Land.msh` в world units с масштабом `1/32`: raw AutoDemo heights
-`95.29412..288.23532` становятся `2.978..9.007`, что согласуется с TMA Z и
-live camera height. Это доказанное coordinate conversion, но оно само по себе
-не сделало вершины видимыми и не является заявлением о pixel parity.
-
-### Explicit offline Node38 animation-frame preview
-
-The static Vulkan path now accepts `--static-animation-frame <u16>`. For each
-standard `Node38` model it resolves the already-decoded type-19 map at that
-logical frame, selects its mapped type-8 key when it is valid before the
-node's fallback key, samples the selected key and its immediate successor with
-the portable reference sampler, then composes the resulting parent hierarchy.
-Nodes with no usable map, or a requested frame outside the declared map,
-retain their exact fallback pose. An invalid/incomplete hierarchy deliberately
-falls back to the existing unposed static geometry path instead of guessing a
-runtime state.
-
-This option is a reproducible asset-viewer probe, not a gameplay clock: it
-does not identify the original animation controller, x87 rounding profile,
-clip selection, LOD/group controller, blending or FX timing. The JSON report
-contains `animation_frame` (`null` for the established fallback preview) so
-readbacks from the two modes cannot be mistaken for each other. On 2026-07-18,
-the licensed GOG `MISSIONS/Autodemo.00/data.tma` run with all eight roots,
-`--static-animation-frame 1`, two frames and format `50` completed with 66
-mesh components, 75,543 clip-visible vertices, a 1280×720 / 3,686,400-byte
-readback hash `4638497144561211935`, and validation warnings/errors `0/0`.
-
-### Windows PowerShell camera-capture hand-off
-
-`tools/capture-original-camera.ps1` is an observer: it opens the original
-process only with `PROCESS_QUERY_INFORMATION | PROCESS_VM_READ`, samples the
-active Terrain camera and emits `fparkan-legacy-camera-v1` JSON. Windows
-PowerShell 5.1 commonly redirects or pipes that text as UTF-16LE with a BOM.
-The Vulkan viewer now accepts BOM-marked UTF-16LE and UTF-16BE in addition to
-UTF-8 (including an UTF-8 BOM), so a normal PowerShell capture can be used
-directly as `--legacy-camera-capture` without a lossy manual re-encoding step.
-Malformed byte lengths and invalid Unicode remain explicit load errors.
-
-Fresh read-only evidence from the running licensed GOG AutoDemo sampled outer
-camera `0x0B368FD8` in `Terrain.dll` at base `0x02510000`; selector-0
-translation was `(548.020752, 559.672852, 3.00516248)`. Passing the captured
-UTF-16LE file to the native Vulkan viewer rendered all eight mission roots
-with `camera_mode="legacy-d3d7-capture"`, 66 mesh components, 6,787
-clip-visible vertices, 71 material descriptors, a 1280x720 / 3,686,400-byte
-format-50 readback hash `7904810245714997753`, and validation warnings/errors
-`0/0`. This proves the live-camera hand-off and projection path; it does not
-establish frame-by-frame camera-selection parity, terrain shading or pixel
-parity with the original renderer.
-
-### Explicit MAT0 phase preview
-
-`fparkan-game --backend static-vulkan --static-material-phase <u16>` now
-selects the exact decoded MAT0 texture for that phase of each MSH material.
-The override is deliberately per material: when the requested phase is absent
-or intentionally untextured, that material retains its existing first prepared
-diffuse request, rather than fabricating a texture or aborting the whole
-mission. Terrain remains on its separately proven Land2 base path. The JSON
-report records `material_phase` so the artifact's selector is explicit. This
-is an asset-viewer control, not a claim about the original material clock,
-phase mode, interpolation, random offset, lighting or Land1 composition.
-
-On 2026-07-18 the licensed GOG AutoDemo run with all eight roots, phase `1`,
-two frames and format `50` completed validation-clean with 71 descriptors and
-a 1280×720 / 3,686,400-byte readback hash `3152437928708207395`.
+```text
+[ cos(f/2), 0, 0, 0 ]
+[ 0, w/h*cos(f/2), 0, 0 ]
+[ 0, 0, sin(f/2)*z/(z-n), sin(f/2) ]
+[ 0, 0, -sin(f/2)*n*z/(z-n), 0 ]
+```
+
+После деления на однородную координату получается обычная перспективная
+проекция с глубиной `0..1`. Матрица view дополнительно меняет оси и знаки;
+она не равна одному лишь обратному преобразованию камеры.
+В проверенном GOG контексте Ngi32 использовались `n=0.5`, `z=700`, `f=1.3`.
+
+У GOG `Terrain.dll + 0x7355c` хранит указатель на активную внешнюю камеру.
+Её selector 0 возвращает 16 слов начиная с `+0x20`, перенос находится в словах
+3, 7, 11. Интерфейс `LoadCamera` расположен отдельно, по `+0x134` объекта.
+Эти два указателя нельзя подменять друг другом. Формулы камеры и размещения
+реализованы и проверяются в `fparkan-render`.
+
+## Материалы ландшафта
+
+Рядом с `Land.msh` лежат `Land1.wea` и `Land2.wea`. Старший байт
+`TerrainFace28.material_tag` выбирает материал первой таблицы, младший —
+второй. `0xff` в старшем байте означает отсутствие первого слоя.
+В AutoDemo встречаются теги `0102`, `0203`, `ff01`, `ff02`, `ff03`.
+Менеджер World3D получает селектор `(table_index << 16) | material_index`
+и возвращает выбранную фазу MAT0 размером 76 байт.
+
+Type 18 в `Land.msh` оригинал называет microtexture mapping chunk;
+он содержит четырёхбайтовые записи. Type 11 хранит пары `u16`, выбранные
+таблицей terrain slots. Их первое поле — ключ shade cache, а не номер строки
+WEAR. Флаг `0x0010` отделяет группы при обходе. `GetShade` использует собственный
+материал `LIGHT1` из `system.rlb::Shade.wea`.
+
+Shade cache в GOG `Terrain.dll` имеет vtable RVA `0x643D0`. Его таблица по
+`+316` состоит из восьмибайтовых записей: указатель и номер банка. Число записей
+находится по `+320`; банки начинаются с `+332`, их шаг — 212 байт. Lookup
+RVA `0x10910` возвращает временное представление по `+24`, а не саму запись.
+Построение в RVA `0x10280` и `0x12e20` использует созданную загрузчиком геометрию.
+В рабочем Vulkan-пути `TerrainMaterialLayers` разрешает для каждого terrain slot
+базовую, detail, overlay и overlay-detail фазы из WEAR/MAT0; их UV, alpha и
+lightmap state передаются в `VulkanStaticMaterial`, а shader последовательно
+сэмплирует эти четыре стадии. Animation clock обновляет выбранные фазы и
+коэффициенты без пересборки terrain mesh, сохраняя native type-14 overlay mask.
+
+## Реализация Vulkan
+
+`LegacyPipelineState` хранит blend, depth, cull и alpha-test. Из этих значений
+получается ключ Vulkan pipeline. Alpha reference передаётся отдельно через
+push constant; он не требует нового pipeline.
+
+Рабочий путь `fparkan-game` загружает terrain и все выбранные mission roots
+(по умолчанию весь список объектов), разрешая локальные material slots через
+WEAR и MAT0 до объединения геометрии. Индексы GPU имеют тип `u32`: вся карта
+может содержать больше 65 535 вершин, даже если каждый исходный mesh использует
+`u16`. Terrain передаёт в shader базовый, detail и overlay stages, lightmap и
+animation phase; object batches сохраняют их legacy blend/depth/alpha state.
+
+После загрузки world path создаёт интерактивную free-flight камеру (`WASD`,
+`E/Q`, `Shift`, `Ctrl`, RMB relative-look), обновляет listener и на каждом
+кадре собирает environment frame. Mission-local `sky.ske`/`sky.wea` выбирают
+атмосферное расписание и sky materials, `effects.rlb/env_lightning` даёт
+lightning visual; weather particles, flares, sun/moon, point lights,
+projected shadows и audio events обновляются вместе с камерой и временем.
+`--atmosphere-seconds` задаёт старт времени, `--frames 0` оставляет цикл
+бесконечным, а `--preview-roots` служит только диагностическим ограничителем.
+`--legacy-camera-capture` выбирает воспроизводимую камеру без free-flight
+управления.
+
+Для сохранения кадра surface должен поддерживать `TRANSFER_SRC`. Renderer
+копирует последний отправленный swapchain image в host-visible buffer и
+читает его после завершения GPU. Размер raw-файла — `width * height * 4`.
+Порядок каналов определяется выбранным Vulkan format; его необходимо учитывать
+при преобразовании в обычное изображение.
diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md
index cebb100..a3fa711 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -143,7 +143,7 @@ references каждого record, жёстко ограничивает counts/a
файла, но не таблица семантик: названия opcode/words появятся только после
handler contracts и runtime traces.
-Связь первого header word с dispatch теперь доказана статически: `ai.dll`
+Связь первого header word с dispatch доказана статически: `ai.dll`
создаёт 73 handler pointers в известном порядке и копирует table без
перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо
`0xffff_ffff` sentinel; `fparkan-script` отражает это как typed
@@ -201,16 +201,6 @@ Ghidra 12.1.2 decompile GOG `ai.dll` фиксирует отдельный evalu
typed condition/evaluation layer, но **не** формат `.scr`, размеры инструкций
или связь чисел tag с языковыми операторами.
-Выгрузка воспроизводится без изменения PE:
-
-```powershell
-& 'C:\Tools\ghidra_12.1.2_PUBLIC\support\analyzeHeadless.bat' `
- C:\temp\fparkan-ghidra ai -import 'C:\GOG Games\Parkan - Iron Strategy\ai.dll' `
- -processor x86:LE:32:default `
- -scriptPath C:\Develop\fparkan\tools\ghidra `
- -postScript ExportAiExpressionDispatcher.java -deleteProject
-```
-
### TRF и preload-данные
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
@@ -292,9 +282,7 @@ Headless Ghidra 12.1.2 decompile GOG binary подтверждает ABI форÐ
а mode передаётся последним; decompiler не восстанавливает предметные имена
остальных слов. `InitializeSettings` получает `CreateGameSettings()` из
World3D и делает virtual call slot `+0x24` с literal `0x15` и строкой по RVA
-`0x42478`. Reproducible extractor находится в
-`tools/ghidra/ExportControlFunctions.java`; он декомпилирует только эти exports
-в локальном Ghidra project и не изменяет оригинальную DLL.
+`0x42478`.
Именно update methods этих private objects, а не пять exports, остаются
следующим объектом динамической трассировки. Поэтому reference movement в
@@ -330,8 +318,7 @@ interface сразу получает пять virtual calls, связывающ
`+0x158`, `+0x160`, `+0x164`, `+0x168` и `+0x18c`; collision object затем
связывается с `+0x170`. Это достаточное основание хранить будущий Control
component как ordered raw-string/resource provenance, но не для присвоения
-этим строкам смысловых имён до трассировки private update methods. Extractor:
-`tools/ghidra/ExportAniMeshControlCaller.java`.
+этим строкам смысловых имён до трассировки private update methods.
Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object
draft. Это создаёт проверяемую границу передачи данных от loader-а к будущему
@@ -393,22 +380,36 @@ Collision manager не должен хранить прямую незащищё
### CTLD и physical resources
Реестр прототипов ссылается на `*.ctl`, `*.cpt` и связанные control resources.
-В Части 1 структурно проверен 531 CTLD payload без ошибок. Размеры и пять
-внутренних счётчиков образуют множество вариантов: наиболее частый размер
-392 байта с pattern `(0,0,0,1,0)`, но встречаются блоки от примерно 212 до
-1868 байт и более сложные комбинации.
-
-CTLD является составным count-driven форматом, а не фиксированной struct.
-Parser должен:
-
-- прочитать prefix и все счётчики с проверкой переполнения;
-- вычислить границы секций по их counts;
-- сохранять неизвестные records в typed raw containers;
-- требовать точного завершения payload;
-- не использовать размер одного популярного варианта как универсальный layout.
-
-Полная предметная семантика всех секций ещё не доказана, но существующие файлы
-можно безопасно читать, индексировать и сохранять.
+В заголовке CTLD идут пять `u32`; обозначим первые три counts как `S`, `M` и
+`T`. Native layout задаёт начало control-row table формулой
+`128 + S * (156 + 16 * M) + 4 * S * S`; за ним следуют `T` records по 36 байт.
+В record известны node ID (`i32`, `+0`), два endpoint frame (`f32`, `+4`, `+8`),
+начальный blend (`f32`, `+0x0c`) и raw flags (`u32`, `+0x20`). Остальные поля
+нельзя выводить из этой позовой привязки.
+
+Пример `fr_l_plant.ctl` имеет counts `[14, 0, 5, 11, 13]`: формула даёт
+`row_start = 3096 (0xC18)`, а пять control rows заканчиваются на `+0xCCC`.
+GOG `Control.dll` function RVA `0x9950` читает blend из `+0x0c` и flags из
+`+0x20`. Его branch RVA `0x99C4..0x9A66` применяет flags: при `flags & 0x1`
+blend один раз переносится через границу — из значения выше `1` вычитается
+`1`, к значению ниже `0` прибавляется `1`; без этого флага blend ограничивается
+`[0, 1]`. Затем `flags & 0x2` инвертирует значение (`1 - blend`). Константы
+`Control.dll+0x3B188 = 1.0` и `+0x3B18C = 0.0` подтверждают границы этих
+сравнений.
+
+Control в call site RVA `0x99B5` передаёт в AniMesh через slot `+0x30` пару A
+`[-1, -1]` и пару B из endpoint полей строки. В call site `0x9A7B` slot `+0x28`
+сохраняет нормализованный blend по `+0x114` и weight `1` по `+0x118`. AniMesh
+update RVA `0x8BF2..0x8C76` вычисляет
+`frame = (1 - blend) * endpoint_a + blend * endpoint_b`; update RVA `0x12560`
+выбирает B-state из-за weight `1`. В static preview строки с `flags & 0x4 != 0`
+пропускаются до проверки float полей, поскольку endpoint поля в них могут быть
+sentinel `-1`. Повторные node ID сохраняют исходный порядок, поэтому последняя
+включённая строка задаёт кадр узла. Привязки сохраняются отдельно для каждого
+экземпляра компонента, даже когда `PreparedVisual` разделяется через cache.
+Это даёт начальную позу preview, но не запускает игровой Control, AI или полный
+runtime animation path. Остальные
+секции CTLD shape records и contact solver не входят в эту интерпретацию.
### Terrain queries и movement handoff
@@ -447,33 +448,13 @@ Licensed test на GOG `Autodemo.00` запускает этот путь для
доказывает связывание current runtime data, но не доказывает семантику
оригинального движения.
-Путь доступен и через самостоятельный composition root:
-
-```powershell
-fparkan-headless --root "C:\GOG Games\Parkan - Iron Strategy" `
- --mission MISSIONS/Autodemo.00/data.tma `
- --move-object 0 419.10318 717.433 0.25 --ticks 1
-```
-
-`--move-object` принимает original object ID, target X/Y и maximum step;
-требует `--root` и `--mission`, допускается один раз за запуск и отвергает
-non-finite/неположительный шаг ещё при разборе аргументов. Приложение печатает
-`reached`, затем normal headless tick/hash. Проверка на GOG 18 июля 2026 года
-загрузила 8 objects, 343 areals и 3 174 terrain surfaces без graph failures;
-команда для объекта `0` вернула `reached=false`, что подтверждает именно
-ограниченный шаг, а не телепортацию к цели.
-
-### Различия Control в Части 2
-
`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
exports; RVA всех пяти exports изменились. Форматы и cross-module boundary
сохранились, но точное physical/collision behavior нельзя считать побайтно тем
же.
-CTLD-корпус расширен с 531 до 623 payload. Новых framing errors не найдено;
-большинство общих CTLD изменено вместе с переработанными моделями. Это
-подтверждает count-driven parser, но не закрывает предметную семантику shape
-records и contact solver.
+Покадровая привязка выше описывает начальную позу компонента. Она не задаёт
+семантику CTLD shape records и не восстанавливает contact solver.
Differential test обеих частей должен воспроизводить движение без препятствий,
slope following, pair collision, timing collision event и удаление объекта в
@@ -587,6 +568,142 @@ Ngi32 создаёт низкоуровневый DirectSound backend. `services
`ISoundServer`. Game, Terrain и FX работают уже через эти интерфейсы:
воспроизводят 2D/3D sources, меняют volume и связывают listener с camera.
+### Погода, осадки и звуковые события
+
+Погода для игрока — это одновременно движущиеся точки на экране и звуковой
+фон. Расписание `sky.ske` говорит, в какой момент действует дождь, снег или
+молния, а `sky.wea` назначает имена материалов. Система окружения каждый
+кадр превращает это состояние в два списка: мировые частицы для renderer-а и
+звуковые переходы для audio backend-а.
+
+```text
+sky.ske + sky.wea
+ -> состояние погоды
+ -> EnvironmentFrame
+ -> Particle / Lightning (renderer)
+ -> StartLoop, SetLoopVolume, StopLoop, OneShot (sound)
+```
+
+Такое разделение нужно для понятной границы ответственности. Система погоды
+решает, **что** произошло и где находится источник. Renderer решает, как
+нарисовать прозрачный квадрат или молнию. Audio backend разрешает архив и
+имя, создаёт источник звука и сравнивает его с текущим listener.
+
+У осадков есть объём перед наблюдателем. Эталонные границы имеют глубину
+`2..50`, половины углов `0.65` и `0.4875` радиана. Для текущей камеры:
+
+```text
+half_y = tan(vertical_fov / 2) * 50
+half_x = half_y * aspect_ratio
+```
+
+Размер осадков получает тот же camera query, что и native `Terrain`: ширина
+viewport делится на горизонтальный FOV в радианах.
+
+```text
+horizontal_fov = 2 * atan(tan(vertical_fov / 2) * aspect_ratio)
+precipitation_scalar = viewport_width / horizontal_fov
+rain_size = precipitation_scalar * 0.0065
+snow_size = precipitation_scalar * 0.0195
+```
+
+Число точек получает масштабирование по отношению текущего объёма к
+эталонному и округляется к ближайшему чётному целому:
+
+```text
+N = round_even(clamp(current_volume / reference_volume, 0, 1)
+ * density * intensity * 1000)
+```
+
+В рабочем эмиттере `density` равна единице. Точка сначала появляется в
+локальных координатах этого объёма, затем получает мировую позицию. Дождь
+движется с мировым вектором `[0.5, 0, -60]`, снег — `[0.5, 0, -4]`.
+Поворот камеры меняет видимую область и проекцию, но не вращает эти векторы.
+Когда точка пересекает грань, она переводится в локальные координаты,
+циклически переносится на противоположную грань и возвращается в мир.
+
+Поэтому источник звука и положение частицы должны храниться в мировых
+координатах. У дождя хвостом экранной полосы становится предыдущая мировая
+позиция; у снега остаётся квадрат в текущей позиции. Scalar вычисляется из
+projection и реального viewport каждого кадра, поэтому отдельная настройка
+размера не нужна.
+
+Дождевой loop следует жизненному циклу состояния:
+
+| Событие | Действие audio backend |
+| --- | --- |
+| `StartLoop` | открыть объявленный sample и начать пространственный loop |
+| `SetLoopVolume` | сохранить loop и применить новую интенсивность как громкость |
+| `StopLoop` | остановить текущий loop |
+| `OneShot` | создать отдельный источник и воспроизвести sample молнии один раз |
+
+Интенсивность между ключами `sky.ske` интерполируется, поэтому `SetLoopVolume`
+может приходить на каждом кадре. Имя из расписания сохраняется. Если указано
+только `atm_rain1.wav`, архив остаётся пустым и audio owner использует
+библиотеку миссии; запись `archive/name` задаёт архив явно. Ресурс загружается
+лениво и кэшируется после проверки, чтобы не читать все возможные погодные
+звуки при запуске миссии.
+
+Молния использует отдельный таймер. Для интенсивности `I` и случайного `U` из
+15-битного диапазона задержка имеет вид
+
+```text
+delay_ms = round_even((1 - min(I, 0.95)) * 60000 * U)
+```
+
+После срока выбираются мировые X и Y из `LightningBounds`, а Z копируется из
+границ. Затем объект ждёт 6000 миллисекунд перед новой попыткой. Визуальный
+контракт передаёт renderer-у material и numeric body opcode 3; native
+descriptor `[40, 40, 600]` начинается на `sampled_z + 300`. При нулевом
+локальном смещении его концы находятся на `sampled_z` и `sampled_z + 600`.
+Opcode 1 в это же время отдельно обновляет point light; он не задаёт размеры
+или UV quad. Звуковой `OneShot` использует ту же позицию, а его opcode 2
+параметры `min_distance=100`, `max_distance=1500`, `frequency_ratio=1`
+проходят в spatial source. Renderer и audio backend применяют numeric FX
+поля и lifetime из заголовка эффекта, а CPU не подменяет их собственной
+шириной или fade-кривой.
+
+Подробные поля `EnvironmentFrame`, правила wrap и границы CPU-модуля собраны
+в [справочнике эффектов окружения](../reference/environment-effects.md).
+
+### Ambient variations и переход день/ночь
+
+`ambient_music_loop` запускает `THEME` сразу после открытия миссии. Вариации
+не выбираются последовательным счётчиком: audio owner получает `dt_seconds`
+как приращение времени кадра и после строгого условия `elapsed > delay`
+выбирает один sample.
+Первый положительный tick поэтому запускает первую вариацию, а задержка между
+следующими попытками равна `10 + rand() % 10` секунд.
+
+В `ambient_music_variation` поддерживаются три независимых пула:
+
+| Пул | Ключи | Когда выбирается |
+| --- | --- | --- |
+| default | `DEFAULT_VARIATION1..n` | когда отсутствуют оба пула `DAY` и `NIGHT` |
+| day | `DAY_VARIATION1..n` | длина базового RGB активного небесного объекта больше `1.1` |
+| night | `NIGHT_VARIATION1..n` | длина базового RGB активного небесного объекта не больше `1.1` |
+
+Базовый RGB передаётся до camera-dependent glare и берётся у первого активного
+небесного объекта. Если существует хотя бы один day/night пул, выбранный
+пустой пул остаётся пустым: он не заменяется default или противоположным пулом.
+`LIBRARY` у `ambient_music_variation` может отличаться от библиотеки theme;
+если поле отсутствует, используется библиотека loop.
+
+Индекс выбирается двумя 16-битными состояниями Iron3D:
+
+```text
+a = (a << 1) xor b
+b = (b >> 1) xor a
+index = b % pool_length
+```
+
+При длине пула больше одного предыдущий индекс отбрасывается одной или более
+повторными выборками. Последний индекс сохраняется при переходе между day и
+night; для пустого пула sample не создаётся, но следующий таймер продолжает
+работать. При пустом пуле native сбрасывает индекс в `-1`. Пауза окна сохраняет
+логическое состояние таймера и индекса, поэтому
+возобновление не перескакивает на случайную вариацию.
+
Публичные функции Ngi32:
```text
diff --git a/docs/tomes/07-implementation.md b/docs/tomes/07-implementation.md
index c48fea2..9270bfa 100644
--- a/docs/tomes/07-implementation.md
+++ b/docs/tomes/07-implementation.md
@@ -1,711 +1,68 @@
-# VII. Руководство по полной реализации
-
-Этот том описывает инженерный путь к совместимому движку FParkan. Он опирается
-на доказанные форматы и runtime-контракты, но не требует повторять физическое
-деление оригинала на пятнадцать DLL. Повторить нужно наблюдаемое поведение:
-форматы, имена, fallback, object IDs, порядок событий, численную политику,
-границы кадра, сохранения и воспроизводимость прохождения.
-
-Предложенные ниже modules, handles, snapshots, queues и scheduler phases являются
-целевой архитектурой новой реализации, а не восстановленным внутренним layout
-оригинального Iron3D. Главная практическая цель: запускаться из неизменённого
-оригинального каталога игры, проходить corpus gates для демоверсии, Части 1 и
-Части 2, а затем измеримо двигаться от archive compatibility к полной игровой
-совместимости.
-
-## Целевая архитектура
-
-Практичная форма новой реализации -- модульный монолит с узкими интерфейсами и
-отдельными platform adapters. Внутренние границы должны соответствовать ролям
-Iron3D, а не обязательно его DLL. Это упрощает перенос на современные платформы
-и оставляет возможность поддерживать разные compatibility profiles для разных
-сборок данных.
-
-```text
-application запуск, окно, конфигурация, shutdown
-platform filesystem, clocks, input, threads, dynamic libraries
-resources NRes, RsLi, paths, archives, cache and diagnostics
-assets MSH, WEAR, MAT0, Texm, FXID and auxiliary formats
-mission TMA, unit DAT, prototype graph, scenario data
-world ObjectId, queue, lifecycle, time, messages, mirrors
-terrain Land.msh, Land.map, surface and spatial queries
-navigation areals, graph search, corridors
-behavior unit state machines, target and path requests
-physics control systems, collision proxies and contacts
-animation pose sampling, hierarchy and blending
-audio sample cache, sources, listener and buses
-render immutable frame contracts and modern backend
-network game message schema plus transport adapters
-tools validators, extractors, viewers, captures and editors
-```
-
-Каждый модуль зависит от нижележащих интерфейсов, а не от concrete managers.
-Behavior видит `INavigation` и `IPhysicsCommandSink`, но не включает headers
-renderer-а. Render получает immutable snapshot, а не mutable world. Network
-receive не меняет мир напрямую: validated messages попадают в очередь следующей
-calculation boundary.
-
-### Центральные идентичности
-
-Resource identity хранит и исходное написание, и нормализованный ASCII-key для
-поиска:
-
-```c
-struct ResourceKey {
- NormalizedRelativePath archive;
- FixedAsciiName name;
- uint32_t type_id;
-};
-```
-
-Normalization сохраняет исходную строку для diagnostics и roundtrip, а отдельный
-ASCII-casefold key используется только для lookup. Эта граница важна для
-архивов [NRes](../reference/nres.md), таблиц [RsLi](../reference/rsli.md),
-prototype references и fallback-путей материалов.
-
-Object identity разделяет внутреннюю защиту от dangling references и исходную
-сетевую/script-семантику:
-
-```c
-struct ObjectHandle { uint32_t generation; uint32_t slot; };
-struct OriginalObjectId { uint32_t raw; };
-```
-
-`ObjectHandle` нужен для безопасного внутреннего владения, deferred deletion и
-weak references. `OriginalObjectId` сохраняет наблюдаемую семантику исходной
-игры: scripts, mirrors, network messages и savegame references должны видеть
-логический ID, а не адрес объекта или номер slot в новом allocator-е.
-
-Frame snapshot отделяет simulation от render. Simulation пишет mutable state;
-renderer читает опубликованное состояние или строго ограниченную фазу
-`in_render`. Deferred deletion применяется между фазами, а не во время traversal.
-Командный контур renderer-а должен сверяться с [описанием кадра](../reference/render-frame.md)
-до pixel comparison.
-
-### Владение ресурсами
-
-Ресурс проходит несколько уровней:
-
-```text
-ArchiveHandle -> EntryView -> DecodedBlob -> ParsedAsset -> RuntimeResource
-```
-
-`EntryView` ссылается на metadata архива, `DecodedBlob` владеет подготовленными
-bytes, `ParsedAsset` является CPU-представлением, `RuntimeResource` может
-дополнительно владеть GPU/audio objects. Eviction верхнего уровня не закрывает
-архив, если он ещё нужен другому entry. Ссылки идут вниз только через явные
-handles.
-
-Для shared objects допустимы reference counting или generation handles.
-Intrusive refcount нужен только в ABI-shim; внутренний современный код
-предпочтительно держит понятное владение и weak handles. Архивы, decoded blobs,
-CPU assets и GPU resources имеют отдельные бюджеты и отдельные diagnostics.
-
-### Backend adapters
-
-Render, audio, input и network получают отдельные adapters. Compatibility state
-живёт вне Vulkan, D3D11 или Metal backend; DirectPlay compatibility живёт
-отдельно от modern transport. Так можно заменить платформу, не меняя форматы,
-игровую семантику и regression corpus.
-
-Backend adapter не должен быть местом, где исправляются данные. Если
-[MSH](../reference/msh.md), [MAT0](../reference/materials.md) или
-[Texm](../reference/texm.md) требуют fallback, это фиксируется в asset/runtime
-слое и попадает в trace. Backend получает уже выбранные resources, states и
-draw items.
-
-### Scheduler phases
-
-```text
-collect_platform_events
-build_input_snapshot
-advance_game_clock
-calculate_world_queue
-apply_deferred_operations
-update_navigation_physics_animation_fx
-publish_render_snapshot
-render_world
-render_ui
-end_frame_callbacks
-maintenance_and_eviction
-```
-
-Фазы имеют стабильный порядок и запрещённые операции. Registry mutation
-запрещена во время world traversal, GPU upload не изменяет simulation state, а
-maintenance не влияет на gameplay. Script timers, material animation и FX
-lifetime относятся к game time, если обратное не доказано.
-
-Сначала реализуется однопоточный эталон. Параллелизм добавляется только внутри
-фаз с детерминированным merge: decoding независимых assets, culling chunks или
-подготовка immutable draw items. Это снижает риск скрытых race conditions и
-расхождений replay.
-
-### Структурированные ошибки
-
-Каждая ошибка должна содержать фазу, путь, archive entry, object/prototype key,
-offset и цепочку причины.
-
-```text
-MissionLoadError
- mission: Campaign.00/Mission.02
- object: 17
- resource_name: UNITS/.../unit.dat
- component: e_tur_...
- prototype: objects.rlb::e_tur_...
- cause: model archive missing
-```
-
-Логическое отсутствие необязательного lightmap, отсутствующий entry в архиве,
-неизвестное opaque поле, выход ссылки за диапазон и повреждённый offset имеют
-разный severity и разные способы исправления. Ошибка данных должна быть
-actionable chain, а не строка вида `failed to load resource`.
-
-## Порядок работ
-
-Движок строится от данных к поведению и от детерминированных CPU-компонентов к
-аппаратным. Каждый этап заканчивается исполняемым инструментом и тестовым
-критерием. Нельзя начинать полноценный gameplay, пока ресурсный граф и
-model/material path не дают воспроизводимый результат.
-
-### Этап 0. Corpus harness
-
-- индексировать оригинальный каталог и вычислить hashes;
-- реализовать bounded binary cursor и structured diagnostics;
-- создать CLI для массового запуска parser-ов;
-- сохранять JSON-отчёт с counts, variants, warnings и failures;
-- зафиксировать демоверсию, Часть 1 и Часть 2 как независимые baselines.
-
-Готовность: повторный запуск на каждом неизменённом каталоге даёт идентичный
-отчёт. Любой parser умеет завершиться контролируемой ошибкой с offset и
-контекстом, а не crash или allocation по непроверенному count.
-
-### Этап 1. Архивы и пути
-
-- реализовать strict/lossless [NRes](../reference/nres.md) reader/writer;
-- реализовать [RsLi](../reference/rsli.md) mapping, table transform, lookup,
- LZSS и Deflate;
-- добавить адаптивный decoder для методов `0x080` и `0x0A0`;
-- воспроизвести overlay и известные compatibility quirks;
-- реализовать archive-handle cache и ASCII name policy.
-
-Готовность: неизменённые архивы проходят byte-identical roundtrip; поиск всех
-имён совпадает с каталогом; malformed corpus отклоняется без выхода за память.
-NRes с ненулевым unindexed region обязательно остаётся regression case.
-
-### Этап 2. Граф ресурсов
-
-- разобрать `objects.rlb` и unit DAT;
-- построить resolver прямой MSH, рекурсивного parent prototype через
- `objects.rlb` и отдельного BASE payload;
-- реализовать dependency graph с reachability от миссии;
-- добавить parsers CTPT, NDPR и остальных служебных форматов в lossless-режиме;
-- создать инспектор прототипа, показывающий все связанные ресурсы.
-
-Готовность: 201 demo-объект раскрывается в 501 прототип. Затем все миссии
-Частей 1 и 2 дают 4 701 и 5 845 prototype requests без failures. Недостижимые
-отсутствующие ресурсы отмечаются отдельно от критических ошибок в reachable
-graph.
-
-### Этап 3. Статический asset viewer
-
-- реализовать [MSH](../reference/msh.md) core streams, slots и batches;
-- декодировать Texm во все подтверждённые pixel formats;
-- разобрать WEAR и [MAT0](../reference/materials.md) с точными fallback;
-- построить современный renderer compatibility layer;
-- добавить wireframe, normals, bounds, LOD/group и material debug views.
-
-Готовность: открываются 435/511 моделей, 518/631 textures и 905/1 127 materials
-Частей 1/2; batch/index bounds не нарушаются; viewer показывает корректно
-текстурированную статическую модель из исходного архива. Красивый viewer всё ещё
-означает только asset compatibility, а не готовую игру.
-
-Текущее состояние репозитория нужно формулировать строже. `apps/fparkan-viewer`
-сейчас является inspection CLI и synthetic command producer, а не live Vulkan
-asset viewer. Реальный Vulkan в репозитории сегодня доказан только через
-Stage 0 smoke triangle path; Stage 3 GPU vertical slice для оригинального
-`MSH` + `Texm` + `WEAR/MAT0` + terrain остаётся блокером. Для различения
-smoke, planning и live GPU путей используйте [таблицу правды renderer paths](../rendering/renderer_truth_table.md).
-
-### Этап 4. Анимация и эффекты
-
-- реализовать MSH type 8/type 19 sampling и hierarchy;
-- добавить x87-compatible reference path для чувствительных формул;
-- реализовать material phase animation;
-- разобрать FXID header/commands и runtime instances;
-- сначала поддержать все opcodes, встречающиеся в корпусе, сохраняя raw body;
-- добавить deterministic RNG stream и effect capture.
-
-Готовность: frame-by-frame poses совпадают с golden reference своей части; все
-923/1 065 FXID создаются без parser errors; перезапуск одинакового effect seed
-даёт идентичный список emitted primitives.
-
-Текущее состояние репозитория опять же уже, чем целевой этап. В коде есть
-portable reference sampler и детерминированный FX reference stub, но нет
-runtime-captured parity для lifecycle/opcode semantics и нет Stage 4 rendered
-acceptance поверх live Vulkan asset renderer. Поэтому rendered Stage 4 следует
-считать заблокированным входным gate Stage 3, а parallel Stage 4 work вести
-через captures, schemas и backend-neutral snapshots.
-
-### Этап 5. Карта и мир
-
-- реализовать `Land.msh` и corrected `TerrainFace28` layout;
-- построить terrain rendering и CPU surface queries;
-- реализовать `Land.map`, cell grid и graph links;
-- визуализировать areals и найденные маршруты;
-- разобрать [TMA](../reference/tma.md) и выполнять staged mission loading;
-- создать World3D queue, ObjectId и deferred deletion.
-
-Готовность: 65 карт и 60 TMA Частей 1 и 2 загружаются до EOF; все areal links
-валидны; objects появляются в правильных transforms; мир выдерживает расчётные
-шаги без рендера.
-
-### Этап 6. Gameplay controllers
-
-- подключить input snapshot и camera controller;
-- реализовать navigation corridor, Behavior state machine и Wizard boundary;
-- создать physical controller и collision manager;
-- загрузить control resources в lossless typed model;
-- внедрить game time, pause, event queue и end-of-frame callbacks;
-- подключить AI layer и symbol/event layer сценариев.
-
-Готовность: юнит получает цель, строит маршрут, движется по terrain, реагирует
-на collision и исполняет базовые миссионные события в детерминированном replay.
-На этом этапе вводится differential branch для изменённых `AniMesh`, `Control` и
-`Effect`; неизменённые DLL используют общий reference path.
-
-### Этап 7. Полный кадр, звук и UI
-
-- реализовать render phases, sorting, lighting, shadows и atmosphere;
-- подключить 3D listener, sample cache, FX sounds и mission audio;
-- воспроизвести shell/UI loading и post-world pass;
-- добавить frame capture до UI и после UI;
-- зафиксировать capability fallback profiles.
-
-Готовность: миссия визуально и звуково проходима; каждый draw и sound event
-имеет trace; одинаковый replay создаёт одинаковые command lists. На этом этапе
-вводится differential branch для `iron3d` и `services`.
-
-### Этап 8. Сеть, сохранения и динамическая совместимость
-
-- реализовать modern transport над versioned game-message schema;
-- отдельно исследовать DirectPlay wire и `netZipData` для native compatibility;
-- добавить mirrors, ownership transfer и disconnect cleanup;
-- восстановить save/campaign state и dispatcher;
-- выполнить динамические captures оригинала для render states, script VM и
- physics edge cases.
-
-Готовность: одиночная кампания запускается из оригинального каталога,
-сохраняется и продолжается; multiplayer replay согласован между peers; full
-corpus не создаёт новых parser variants без явной регистрации.
-
-## Тестовый контур
-
-Совместимость нельзя подтвердить одним screenshot. Нужны тесты на уровне bytes,
-структур, ссылок, simulation state, команд renderer-а и конечного изображения.
-Каждый слой локализует свой класс ошибки.
-
-```text
-unit tests
- -> parser/property tests
- -> corpus validation
- -> cross-resource integration
- -> deterministic simulation replay
- -> render/audio command captures
- -> pixel and gameplay parity
-```
-
-Failure верхнего уровня всегда должен позволять спуститься к меньшему тесту и
-понять причину.
-
-### Unit, property и fuzz tests
-
-Для каждого binary primitive проверяются little-endian чтение, bounded strings,
-checked arithmetic и cursor boundaries. Для структур -- минимальный размер,
-максимальные counts, пустые arrays, нулевые варианты и редкие branches.
-
-Property tests генерируют случайные корректные NRes/RsLi/WEAR records,
-выполняют encode -> decode и сравнивают семантику. Fuzz tests изменяют длины,
-offsets, counts и termination bytes и требуют контролируемой ошибки без crash и
-чрезмерного выделения памяти.
-
-Критические алгоритмы имеют отдельные vectors: ASCII casefold, NRes permutation
-search, RsLi byte transform, LZSS backreferences, quaternion shortest path,
-matrix composition и terrain mask remap.
-
-### Corpus validation
-
-Каждый файл оригинального каталога проходит parser своего семейства. Отчёт
-содержит hash, variant, counts, warnings, errors и точный offset сбоя. Baseline
-демоверсии:
-
-```text
-MSH 435
-MAT0 905
-Texm 518
-FXID 923
-WEAR 457
-Land.msh 6
-Land.map 6
-TMA 6
-unit DAT 425
-errors 0
-```
-
-Изменение parser-а принимается только если baseline остаётся стабильной либо
-новый variant зарегистрирован с образцом и объяснением. Warnings должны быть
-именованными: «неизвестное opaque поле» не равно «выход ссылки за диапазон».
-
-### Cross-resource integration
-
-Интеграционный тест начинается с миссии и проходит весь dependency graph:
-object -> prototype -> MSH -> WEAR -> MAT0 -> Texm/lightmap/FXID. Он не
-ограничивается тем, что файлы существуют: material slot должен указывать на
-допустимый MAT0, phase -- на допустимую texture, model batch -- на существующий
-WEAR index.
-
-Demo mission total: 201 objects -> 501 prototypes -> 501 object MSH/WEAR.
-Чистый object graph даёт 3 873 material slots и 5 049 texture requests; после
-включения environment WEAR итог равен 3 879 material slots, 5 067 textures и
-18 lightmaps, failures 0. Такой тест ловит ошибки casefold, suffix, fallback и
-путей, которые отдельный parser не замечает.
-
-Для каждого отсутствующего узла отчёт хранит полный parent chain, чтобы
-различать broken global archive и реально достижимый mission failure.
-
-### Deterministic simulation replay
-
-#### Mission transform state in the world contract
-
-`fparkan-world` now carries a `TransformState` for every live object: the
-three TMA position words, three orientation words and three scale words are
-preserved as exact IEEE-754 bit patterns. This stores source identity before a
-movement or physics controller interprets axes, units or Euler order.
-`WorldSnapshot` publishes transforms in stable object-handle order and the
-canonical SHA-256 state hash includes every transform word.
-
-Mission loading assigns this state after construction and before registration.
-A headless licensed GOG AutoDemo run on 2026-07-18 loaded eight objects, 343
-areals and 3,174 terrain surfaces with zero graph failures, then completed two
-deterministic ticks. This is the state foundation for a future route/movement
-controller; it does not claim recovered velocity, collision or original
-behavior-controller semantics.
-
-The ordinary planning renderer now consumes this snapshot state before falling
-back to a mission draft. Its current transform bridge applies the preserved
-position and non-uniform scale only; raw orientation remains uninterpreted in
-this backend-neutral path. A GOG AutoDemo planning run on 2026-07-18 completed
-two ticks with eight objects, 66 draws and state hash
-`a54855a4f47ffa380911228f295dd49a9a7b88d6ff271a23db48ba318b1fbbb4`.
-
-Записывается начальная миссия, seed, input events, network messages и значения
-внешних часов. На контрольных ticks сохраняется canonical state hash:
-
-```text
-sorted ObjectId list
-transforms and velocities
-critical properties and owners
-AI/behavior state IDs
-active effect state
-game clock and RNG states
-```
-
-Pointer addresses, allocator order и GPU handles в hash не входят. Два запуска с
-одинаковым log должны давать одинаковый state hash на каждом checkpoint. Первое
-расхождение гораздо информативнее финального разного результата миссии.
-
-### Render command parity
-
-До pixel comparison сравнивается command list:
-
-```text
-camera matrices and viewport
-visible ObjectIds
-render phase and stable order
-model/node/slot/batch IDs
-material phase and texture handles
-legacy pipeline states
-index ranges and transforms
-```
-
-Если command lists совпадают, но pixels различаются, проблема находится в
-shader/backend, sampling или численной точности. Если command lists уже
-различаются, pixel diff лишь скрывает более раннюю ошибку.
-
-Golden captures следует хранить отдельно для статической модели, анимации,
-terrain, transparent FX, shadows, lightmap и atmosphere.
-
-### Pixel, audio и network tests
-
-Pixel tests используют фиксированное разрешение, camera, device profile, seed и
-timeline. Сравниваются exact pixels для CPU/reference path и tolerance metrics
-для GPU path, но tolerance не должна скрывать переставленные прозрачные
-primitives.
-
-Audio tests сравнивают список sound events, sample IDs, positions, loop flags и
-gains; waveform зависит от mixer/device и является вторичным уровнем. Network
-tests воспроизводят captured message sequences, проверяют mirrors, ownership и
-disconnect. Для native DirectPlay compatibility дополнительно нужен packet-level
-corpus.
-
-## Regression baselines
-
-Corpus validation формирует три независимых отчёта: демоверсия, Часть 1 и
-Часть 2. Каждый сохраняет manifest файлов, hashes executable/DLL, variants,
-warnings, global archive health и mission reachability.
-
-Ключевые corpus gates:
-
-```text
-NRes: 120 файлов / 6 804 entries и 134 / 8 171 для Частей 1/2
-TMA: 29 миссий / 864 objects / 28 extras и 31 / 885 / 41
-MSH: 435 и 511 моделей
-MAT0: 905 и 1 127 материалов
-Texm: 518 и 631 текстура
-FXID: 923 и 1 065 эффектов
-full reachability: 4 701 и 5 845 prototype requests, failures 0
-```
-
-Расширенные mission-reachability totals:
-
-```text
-Часть 1: 29 TMA, 864 objects, 4 701 prototypes,
- 36 954 materials, 48 806 textures, 139 lightmaps, failures 0
-Часть 2: 31 TMA, 885 objects, 5 845 prototypes,
- 50 888 materials, 68 603 textures, 214 lightmaps, failures 0
-```
-
-Обязательные regression cases:
-
-- NRes с ненулевым unindexed region;
-- prototype inheritance через `objects.rlb`;
-- unit DAT `description[32]` без NUL;
-- TMA epilogue и `extra_count` 0--4;
-- empty SWAV entry;
-- stale save-slot metadata без payload;
-- build-scoped RVA lookup.
-
-Byte-identical asset comparison выполняется только внутри одного корпуса. Между
-Частями 1 и 2 сравниваются semantic invariants и decoded representation,
-поскольку многие assets пересобраны.
-
-## Точность, скорость и повторяемость
-
-Совместимый движок должен быть корректным, повторяемым и достаточно быстрым.
-Эти свойства нельзя получать одним и тем же приёмом. Сначала создаётся простой
-эталонный путь, затем он измеряется и оптимизируется без изменения результата.
-
-Главные источники расхождений: x87 extended precision, преобразование float в
-integer, порядок операций, старые SIMD implementations, нестабильная сортировка,
-RNG и использование разных часов.
-
-### x87 и округление
-
-Оригинальный x86-код мог хранить промежуточные значения в 80-битных регистрах
-x87, а в память записывать 32-битный float. Современный compiler чаще использует
-SSE с округлением после каждой операции. Различие заметно на границах animation
-frame, culling plane и collision threshold.
-
-Для критических формул нужен reference mode:
-
-- фиксированный порядок операций без reassociation;
-- запрещённый fast-math;
-- явные преобразования и проверенный режим округления;
-- тесты возле half-integer и epsilon boundaries;
-- при необходимости extended intermediate через `long double` на проверенной
- платформе.
-
-Не требуется эмулировать x87 во всём движке. Нужно локализовать функции, где
-малое отличие меняет дискретное решение, и держать для них scalar reference path.
-
-### RNG как часть состояния
-
-FX, atmosphere и, вероятно, AI используют случайные значения. Один глобальный
-RNG легко расходится, если новая реализация запрашивает дополнительное число для
-визуальной оптимизации. Для трассировки полезны именованные streams:
-
-```text
-world/gameplay RNG
-AI/script RNG
-FX instance RNG
-atmosphere RNG
-non-deterministic cosmetic RNG
-```
-
-Для native parity может потребоваться один общий алгоритм и точная sequence. До
-подтверждения capture каждый stream хранит seed и счётчик вызовов в trace.
-Cosmetic stream не входит в simulation hash.
-
-### Стабильный порядок
-
-Коллекции не должны зависеть от адресов, unordered containers или порядка
-завершения worker threads. Для объектов, collision pairs, opaque/transparent
-draws и network messages задаются явные stable keys:
-
-- objects -- queue insertion sequence или OriginalObjectId;
-- collision pairs -- упорядоченная пара IDs;
-- opaque draws -- phase, pipeline key, material, stable insertion ID;
-- transparent draws -- layer, quantized distance, stable insertion ID;
-- network messages -- sequence и sender.
-
-Даже когда математический результат коммутативен, side effects, cache accesses и
-RNG делают порядок наблюдаемым.
-
-### Часы и fixed-step
-
-Monotonic platform clock хранится отдельно от game clock. Pause и time scaling
-применяются к game clock. Simulation работает с фиксированным или точно
-воспроизводимым шагом, а render может интерполировать presentation state, не
-изменяя authoritative world.
-
-Maintenance timers кэшей используют реальные часы или отдельную подтверждённую
-шкалу; их срабатывание не должно менять gameplay. При перегрузке лучше выполнить
-ограниченное число simulation steps и явно зафиксировать dropped presentation
-frames, чем передать огромный `dt` в AI/physics.
-
-### Оптимизация без потери эталона
-
-1. Сохранить scalar reference implementation.
-2. Добавить profiler counters на decoding, culling, sorting, animation, upload
- и draw.
-3. Оптимизировать только измеренный bottleneck.
-4. Сравнить SIMD/parallel результат с reference на полном corpus.
-5. Оставить runtime switch для отключения оптимизации при диагностике.
-
-`g_FastProc` удобно моделировать как таблицу function objects: все slots сначала
-указывают на scalar path, затем безопасные slots заменяются SIMD-вариантами
-после self-test на старте.
-
-### Кэш и память
-
-Архивы, decoded blobs, CPU assets и GPU resources имеют отдельные budgets.
-Eviction разрешена только для объектов с нулевым external refcount и после
-безопасной frame fence. Original delayed cleanup порядка десятков секунд можно
-воспроизвести policy-параметрами, не сканируя все entries каждый кадр.
-
-Основные показатели: число открытых архивов, decoded bytes, resident
-textures/lightmaps, models, active FX, draw items и deferred-delete size. Любой
-неограниченно растущий счётчик является regression. Производительность считается
-достаточной только после корректности: стабильные 60 FPS с неверным LOD или
-пропущенными эффектами не являются успехом.
-
-## Release gates
-
-Версия не выпускается, если:
-
-- появился новый corpus error;
-- изменился byte roundtrip неизменённых ресурсов;
-- dependency graph получил failure в достижимом пути;
-- deterministic replay расходится;
-- command capture изменился без ожидаемого changelog;
-- parser допускает allocation по непроверенному count;
-- новая оптимизация не имеет scalar reference comparison.
-
-Каждое исправление регистрирует минимальный regression asset или synthetic
-vector. Если новый behavior намеренно отличается от предыдущего, изменение
-должно иметь compatibility profile, corpus sample и объяснение, почему старый
-baseline был неполным или неверным.
-
-## Уровни совместимости
-
-Слово «совместимый» используется только с уровнем:
-
-1. **Archive-compatible** -- открывает и сохраняет контейнеры.
-2. **Asset-compatible** -- декодирует модели, материалы, текстуры и эффекты.
-3. **Mission-compatible** -- загружает карту и создаёт все объекты.
-4. **Runtime-compatible** -- исполняет время, события, поведение и физику.
-5. **Presentation-compatible** -- воспроизводит рендер и звук.
-6. **Game-compatible** -- позволяет пройти миссии, сохраняться и продолжать.
-7. **Native-interoperable** -- взаимодействует с оригинальной сетью и внешним
- ABI.
-
-Viewer с красивой моделью находится только на втором уровне.
-
-### Обязательные критерии запуска и данных
-
-- приложение запускается из неизменённого оригинального каталога;
-- относительные пути, регистр и legacy encodings разрешаются по исходным
- правилам;
-- все требуемые NRes/RsLi открываются без предварительной конвертации;
-- parsers проверяют границы и не используют неопределённые bytes как указатели;
-- неизвестные поля сохраняются lossless;
-- все mission-reachable prototype, model, material, texture, lightmap и effect
- references разрешаются;
-- отсутствие необязательного ресурса следует документированному fallback, а не
- случайному default.
-
-### Обязательные критерии мира
-
-- TMA разбирается до точного EOF;
-- `Land.msh` и `Land.map` создают корректную поверхность и areal graph;
-- ObjectId, owner и mirror semantics устойчивы;
-- queue traversal и deferred deletion безопасны;
-- pause, game time и simulation steps повторяемы;
-- AI/Behavior/Wizard/Control взаимодействуют через заданные границы;
-- collision и navigation не подменяют друг друга;
-- script events используют logical IDs и переживают удаление объектов;
-- deterministic replay совпадает на контрольных ticks.
-
-### Обязательные критерии presentation
-
-- static и animated MSH используют правильные slots, batches и transforms;
-- WEAR/MAT0/Texm fallback и phase timing совпадают;
-- mip-skip, palettes, Page atlases и lightmaps работают;
-- render phases, depth/cull/blend state и transparent order подтверждены
- captures;
-- FXID commands и RNG дают устойчивый результат;
-- camera и 3D sound listener синхронизированы;
-- atmosphere, тени, солнце и flares не являются декоративными заглушками;
-- UI и world rendering имеют правильную границу;
-- golden command captures стабильны, pixel parity измеряется на фиксированных
- сценах.
-
-### Обязательные критерии полной игры
-
-- все доступные миссии стартуют, завершаются и корректно сообщают
- success/failure;
-- campaign dispatcher сохраняет прогресс;
-- savegame восстанавливает world, script, AI, RNG и clocks, а не только
- placement;
-- input remapping, pause, camera modes, sound и настройки работают из UI;
-- длительный прогон не накапливает objects, resources или audio sources;
-- ошибки данных показывают actionable chain;
-- производительность приемлема без отключения подсистем;
-- демоверсия, Часть 1 и Часть 2 проходят один и тот же тестовый контур с
- раздельными manifests и эталонами.
-
-### Native interoperability
-
-Самый строгий уровень дополнительно требует совпадения x86 ABI экспортов, vtable
-slots и calling conventions для подключаемых оригинальных модулей, а также
-DirectPlay wire/framing и compression. Этот уровень независим от возможности
-играть в новом standalone runtime.
-
-Проект может честно заявлять game compatibility без native DLL/network
-interoperability, но это должно быть явно указано. Аналогично pixel-perfect режим
-может быть отдельным compatibility profile поверх функционально корректного
-renderer-а.
-
-### Совместимость нескольких наборов данных
-
-Критерий полной совместимости применяется отдельно к демоверсии, Части 1 и
-Части 2. Прохождение одного набора не позволяет заявлять поддержку остальных.
-
-Обязательное различие:
-
-- **format compatibility** -- один parser принимает все три набора;
-- **content compatibility** -- конкретная миссия разрешает весь reachable graph;
-- **behavior compatibility** -- runtime совпадает с соответствующей сборкой
- изменённых DLL;
-- **cross-version support** -- один новый движок выбирает корректные данные и
- defaults по fingerprint установки.
-
-Content fingerprint включает hashes executable/DLL и manifest ключевых архивов.
-Он не используется для запрета модификаций, но выбирает compatibility profile и
-делает отклонение диагностируемым.
-
-## Definition of done
-
-Полное документирование и реализация считаются завершёнными только когда каждый
-критерий связан с главой спецификации, executable test и хотя бы одним
-corpus/golden case. Утверждение без проверяемого критерия остаётся
-исследовательской заметкой, а не контрактом.
+# VII. Работа над движком
+
+FParkan развивается небольшими законченными изменениями. Для нового поведения
+сначала нужен пример из оригинала и Rust-тест, затем реализация и объяснение
+в соответствующей главе. Если меняется видимый результат, проверка заканчивается
+запуском приложения на настоящих игровых ресурсах.
+
+## Где находится код
+
+Библиотеки в `crates/` читают архивы и форматы, связывают ресурсы миссии,
+вычисляют геометрию и хранят мир. Они не создают окно и не обращаются к GPU.
+Окно находится в адаптере winit, графические ресурсы — в адаптере Vulkan.
+`fparkan-game` связывает их в приложение.
+
+У файла на диске и объекта в игре разные сроки жизни. Одна модель может быть
+прочитана однажды и использоваться многими объектами, у каждого из которых
+свои положение и время анимации. Материалы и текстуры тоже разделяются между
+экземплярами. Владение этими данными видно из Rust-структур; отдельный сервис
+или интерфейс добавляется, когда появляется реальная потребность.
+
+## Один тест на правило
+
+Для бинарного формата полезен короткий массив байтов: тест проверяет значения
+полей и отказ при обрыве записи или неправильной ссылке. Для преобразования
+координат удобны точки и повороты, результат которых легко посчитать вручную.
+Тесты находятся рядом с соответствующим алгоритмом и запускаются через
+`cargo test --workspace`.
+
+Оригинальные ресурсы остаются в установленной игре. Локальные тесты с ними
+помечены `#[ignore]`, чтобы обычная проверка работала без коммерческих файлов.
+Сообщение об ошибке должно сразу назвать ресурс и причину: например, какой
+материал сослался на отсутствующую текстуру. Поэтому диагностика остаётся
+частью самого запуска и не зависит от отдельного отчёта.
+
+## Числа и порядок вычислений
+
+Порядок умножения матриц имеет значение: поворот объекта вокруг своей оси и
+поворот его положения вокруг начала мира дают разные результаты. Единицы высоты
+ландшафта могут отличаться от единиц размещённых объектов. Эти преобразования
+выполняются в одном месте и проверяются конкретными точками.
+
+Оригинальный x86-код использует x87. Промежуточное значение может иметь большую
+точность, чем сохранённый `float32`. Особенно внимательно проверяются переходы
+между кадрами анимации, округление и границы треугольников. Эмулировать x87 во
+всём движке не требуется: существенные расхождения разбираются в конкретной
+формуле.
+
+Случайные числа и порядок обхода тоже влияют на поведение. Перестановка вызовов
+генератора меняет время молнии или траекторию частицы даже при прежнем seed.
+Для повторяемой проверки сохраняют начальное состояние и одинаковую
+последовательность шагов. Дополнительные потоки и альтернативные алгоритмы
+имеют смысл после измерения узкого места.
+
+## Проверка изображения
+
+Успешное создание Vulkan pipeline доказывает только корректность обращения
+к API. Положение моделей, масштаб карты, текстуры, прозрачность и свет
+проверяются в самом окне. Validation layer помогает находить ошибки владения
+GPU-ресурсами, синхронизации и пересоздания swapchain.
+
+Кадр для сравнения читается после завершения работы GPU. При изменении размера
+окна старые framebuffer и изображения освобождаются лишь после окончания их
+использования. При сворачивании окно может иметь нулевой размер; тогда новые
+кадры не отправляются. Обычное закрытие завершает программу успешно.
+
+Оптимизация начинается с измерения: время загрузки, число повторных декодирований,
+объём текстур и время кадра. Повторное использование уже загруженного ресурса
+обычно полезнее дополнительного слоя управления кэшем.
diff --git a/docs/tomes/08-evidence.md b/docs/tomes/08-evidence.md
index 98f6f13..35bee99 100644
--- a/docs/tomes/08-evidence.md
+++ b/docs/tomes/08-evidence.md
@@ -1,33 +1,9 @@
-# VIII. Справочник и доказательная база
+# VIII. Устройство оригинальной программы
-Восьмой том фиксирует, на чём держится книга: ABI, exports/imports, файловая
-поверхность, статистика корпусов, открытые вопросы, критерии доказанности и
-словарь терминов. Это самостоятельная справочная глава: она не заменяет
-профильные статьи о форматах, но задаёт общий контракт, по которому проверяются
-реализация, parser-ы, compatibility layer и будущие динамические эксперименты.
-
-## Как читать доказательства
-
-Доказательством считается наблюдение, которое можно повторить на конкретном
-файле, сборке или трассе. Вывод может объединять несколько наблюдений, но он
-должен сохранять происхождение данных: демоверсия, полная Часть 1 и полная
-Часть 2 не смешиваются в один безымянный corpus.
-
-Для каждого утверждения полезно различать четыре уровня:
-
-- `layout-confirmed`: известны offset, size, count, bounds и правила безопасного
- чтения;
-- `corpus-verified`: branch или вариант реально встречается в доступных игровых
- данных;
-- `code-confirmed`: branch виден в бинарном коде, но отсутствует в доступном
- corpus;
-- `behavior-confirmed`: поведение подтверждено исполнением оригинальной
- программы, трассой API/vtable или controlled differential test.
-
-Если поле не имеет доказанного предметного смысла, документация хранит его как
-opaque field. Это не мешает lossless read/write, но запрещает строить writer,
-который очищает, переименовывает или пересчитывает такое поле на основании
-правдоподобной догадки.
+Здесь собраны детали, полезные при чтении оригинальных DLL: экспортируемые
+функции, соглашения вызова, адреса и конфигурационные файлы. Адрес относится
+к конкретной сборке; сравнить её можно по
+[хэшам модулей](../reference/original-binaries.md).
## ABI и границы модулей
@@ -215,12 +191,10 @@ RVA используются только для сопоставления и Ñ
implementation не должна встраивать их как постоянные игровые идентификаторы.
Таблица внутренних RVA хранится по SHA-256 конкретного модуля.
-Операционные evidence-артефакты, которые должны оставаться синхронизированными
-с кодом и acceptance, вынесены в отдельные страницы:
-
-- [Hashes и import/export summary оригинального движка](../evidence/original_engine_hashes.md)
-- [Stage 4 capture schema](../evidence/stage4_capture_schema.md)
-- [Renderer truth table](../rendering/renderer_truth_table.md)
+Сводка hashes и import/export оригинального движка вынесена в отдельную
+[страницу](../reference/original-binaries.md). Текущее состояние
+реализации и границы live Vulkan path описаны в [справочнике render frame](../reference/render-frame.md)
+и в исходном коде адаптера.
Подтверждённые hashes неизменённых DLL:
@@ -229,11 +203,10 @@ World3D.dll 17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e
Ngi32.dll bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5
```
-Повторный headless IDA/Hex-Rays review GOG `World3D.dll` с этим hash уточнил
-RVA export-ов: `stdCalculateGame=0x139A0`, `stdRenderGame=0x13BD0`,
+Для GOG `World3D.dll` с этим hash RVA export-ов такие: `stdCalculateGame=0x139A0`,
+`stdRenderGame=0x13BD0`,
`sendEndOfRender=0x13D90`, `stdSetCurrentCamera=0x13E60` и
-`stdGetCurrentCamera=0x13E80`. Предыдущая таблица была сдвинута и не должна
-использоваться для hooks или differential capture.
+`stdGetCurrentCamera=0x13E80`.
`stdRenderGame(camera)` сначала вызывает экспорт Terrain
`stdSetCurrentCamera2(camera)`, затем сохраняет текущий camera pointer в
@@ -254,7 +227,7 @@ Terrain object. Следовательно, selector `18`, slot `+12` и global
должны оставаться именованными evidence boundary до dynamic capture; считать
`stdGetCurrentCamera2` getter-ом переданной camera было бы ошибкой.
-Повторный headless-IDA review той же GOG базы уточняет рабочий static contract
+Статический анализ той же GOG базы уточняет рабочий контракт
`CBufferingCamera`. Метод Terrain RVA `0x4D740` копирует ровно 64 байта
(16 dword) в component offset `+0x10`. Frame-preparation метод RVA `0x4D9C0`
получает viewport rectangle через virtual slot `+0x3C`, выводит width, height,
@@ -268,45 +241,20 @@ near/far mapping, handedness или initial camera selection. Важно, что
`ICamera::GetTransformMatrix` (RVA `0x4F850`) ведут только в obsolete-call
stubs и не дают usable ABI.
-Live elevated read-only probe впервые подтвердил relocation-aware runtime связь:
-`Terrain.dll` был загружен по `0x02510000`, global `base + 0x7355C` содержал
-non-null `0x0B37DF08`, а первый dword этого объекта был `0x025765B4` — ровно
-relocated `off_100665B4` из `LoadCamera` construction path. Это доказывает
-live camera object с outer vtable, но одновременно исправляет прежнее слишком
-сильное сопоставление offsets: raw read `global + 0x10` не дал finite 4x4 matrix,
-а `+0x234` был zero в данном sample. Receiver static procedures `0x4D740`/
-`0x4D9C0` и exported global ещё не доказаны как один layout без interface
-adjustment; их offsets остаются unassigned до recovery selector relationship.
-
-Локальная IDA-база уточняет адреса этой связи: `stdGetCurrentCamera2` — это
-шестибайтный getter по RVA `0x4FD80`, который возвращает `dword` по RVA
-`0x7355C`. Единственный найденный **direct static** initializer этого global — функция Terrain
-по RVA `0x4D4D0`: она запрашивает selector `8` у своего `this` и сохраняет
-полученный interface pointer. Это доказывает адрес хранения и путь заполнения,
-но не разрешает трактовать pointer как конкретный layout камеры либо читать его
-как runtime evidence без доступа к процессу на том же уровне привилегий.
-
-Elevated live sampling теперь доказывает, что direct static xref не исчерпывает
-runtime writers: за 25 секунд autoplay global переключился между тремя heap
-pointer, все с relocated outer vtables `0x025765B4`/`0x02576558`. У двух объектов
-paired blocks `+0x2C/+0x3C/+0x4C` и `+0x6C/+0x7C/+0x8C` синхронно несли
-world-like translation, например `(491.562, 761.551, 7.361)`; третий давал
-normalized-looking `(0.098, 0.018, 0.856)` и не совпадал с paired block.
-Наблюдение согласуется с автоматическими camera switches, но не маркирует mode;
-оно запрещает называть `0x4D4D0` единственным runtime writer и требует recovery
-indirect/unanalyzed write path.
-
-Outer vtable `off_100665B4` теперь даёт exact transform adjustment для
-world-like sample. Slot `+0x54` вызывает у subobject `outer + 4` slot `+0x20`
-с selector `0`, затем копирует returned `+0x0C/+0x1C/+0x2C`. В live объекте
-selector field `outer + 0x10` был `0xFFFFFFFF`; реализация selector `0` при
-этом возвращает `outer + 0x20`. Значит observed triple
-`outer + 0x2C/+0x3C/+0x4C` — доказанная translation часть active affine
-transform, а не корреляция. Selector `2` возвращает paired block `outer + 0x60`;
-outer slot `+0x70` применяет `atan2` к его axis values, что доказывает
-orientation-angle path. Названия полей, angle order и handedness пока не
-установлены, но raw affine transform и translation можно сохранять как
-backend-neutral camera pose без догадок.
+Глобальная camera boundary имеет более точное статическое описание.
+`stdGetCurrentCamera2` — короткий getter Terrain по RVA `0x4FD80`, который
+читает указатель из global RVA `0x7355C`; initializer по RVA `0x4D4D0`
+запрашивает selector `8` у своего `this` и сохраняет полученный interface
+pointer. Запрос selector `18` у landscape object проходит через RVA `0x106D0`
+и `0x107E0`, возвращает subobject по `base + 0x138`.
+
+Внешняя camera vtable по RVA `0x665B4` имеет slot `+0x54`, который вызывает у
+subobject `outer + 0x4` slot `+0x20` с selector `0` и копирует возвращённые
+компоненты `+0x0C`, `+0x1C` и `+0x2C`. При значении selector field
+`outer + 0x10 = -1` selector `0` возвращает `outer + 0x20`; selector `2`
+возвращает `outer + 0x60`, а slot `+0x70` выводит углы через `atan2`.
+Это фиксирует пути чтения положения и ориентации, но не назначает имена полям,
+порядок углов или handedness.
### Vtable и interface negotiation
@@ -315,6 +263,11 @@ backend-neutral camera pose без догадок.
world traversal; camera и viewport получаются через selector-based interface
calls; shared objects используют ранний slot как AddRef-подобную операцию.
+Запрос selector `18` у landscape object проходит через RVA `0x106D0` и
+`0x107E0`, возвращает subobject по `base + 0x138`, а его vtable slot `+0x18`
+ведёт через RVA `0x14230` к обработчику `0x127D0`. Это geometry-interface
+boundary, отдельная от хранения активной camera.
+
Правила реконструкции:
1. Зафиксировать byte offset slot и число аргументов.
@@ -326,45 +279,6 @@ calls; shared objects используют ранний slot как AddRef-поÐ
Нельзя добавлять virtual destructor в начало reconstructed interface: это
сдвинет все slots.
-### ABI-матрица Частей 1 и 2
-
-Во всех пятнадцати DLL совпадают export names, ordinals и import sets. Общее
-число exports остаётся 313. Обе полные части содержат 1 134 imported function
-slots; значение 1 126 относится к демоверсии и хранится отдельно.
-
-Побайтно идентичны девять DLL:
-
-```text
-ai.dll
-Behavior.dll
-Joystick.dll
-MisLoad.dll
-Net.dll
-Ngi32.dll
-Terrain.dll
-Wizard.dll
-World3D.dll
-```
-
-Пересобраны `AniMesh.dll`, `ArealMap.dll`, `Control.dll`, `Effect.dll`,
-`iron3d.dll`, `services.dll`.
-
-Изменение export RVA:
-
-```text
-AniMesh 2 / 2
-Control 5 / 5
-iron3d 8 / 8
-services 6 / 6
-ArealMap 0 / 9
-Effect 0 / 2
-```
-
-Нулевое изменение export RVA не доказывает идентичность тела функции:
-`ArealMap.dll` и `Effect.dll` имеют изменённый `.text` при прежних адресах
-exports. Compatibility headers фиксируют внешний ABI один раз, но внутренняя
-таблица адресов, тестов и semantic deltas выбирается по build fingerprint.
-
## Файловая поверхность
### Каталог как внешний API
@@ -546,628 +460,11 @@ provenance: parser хранит не только effective value, но и прÐ
identifier и path normalization должны оставаться разными слоями: локализация
текста не меняет ASCII-casefold policy имён entries.
-## Результаты проверки корпусов
-
-### Demo baseline
-
-Демоверсия содержит `iron_3d.exe`, те же 15 DLL и сокращённый набор
-миссий/ресурсов. Все 15 DLL совпали с первоначально исследованными файлами по
-SHA-256. Поэтому executable, бинарный код DLL и demo-assets относятся к одной
-совместимой технологической сборке.
-
-```text
-modules: 16, из них DLL: 15
-DLL exports: 313
-DLL imports: 1126
-DLL identity: 15/15
-```
-
-`iron_3d.exe`: 36 864 байта, PE32/x86, image base `0x400000`, entry RVA
-`0x141E`, timestamp 28 июня 2001 года, SHA-256
-`b0a8b0db1c3a8698c4d4604d89c655496bd91ac1f8859a455e8a45838aebfbd6`.
-
-### Миссии и сквозные ссылки
-
-Шесть TMA разобраны до точного EOF: суммарно 20 paths, 15 clans, 201 placed
-objects и 1 extra record. 48 объектов ссылаются на unit DAT, 153 -- на прямые
-prototype keys. Unit-файлы раскрыли 348 компонентов.
-
-Сквозной результат:
-
-```text
-501 prototype requests 501 resolved
-501 MSH requests 501 resolved
-501 WEAR requests 501 resolved
-3879 material slots 3879 resolved
-5067 texture requests 5067 resolved
-18 lightmap requests 18 resolved
-failures 0
-```
-
-Это самое сильное интеграционное подтверждение текущего корпуса: имена,
-архивы, ASCII casefold и fallback согласуются между реальными форматами.
-
-### Реестр и unit DAT
-
-`objects.rlb` содержит 590 prototype entries:
-
-```text
-554 имеют прямую MSH-ссылку
-549 прямых MSH разрешаются в demo-каталоге
-34 раскрываются через родительский prototype и локальный BASE
-7 не дают доступной геометрии
-41 ссылка общего реестра указывает на отсутствующий demo-content
-```
-
-Негеометрические или неразрешённые глобальные entries:
-
-```text
-sun_01
-sun_02
-ws_al_01
-ws_al_02
-ws_fl_01
-ws_hm_01
-ws_hm_02
-```
-
-Они не входят в фактически требуемую цепочку проверенных миссий.
-
-Проверено 425 unit DAT, 5 219 records, errors 0. Все records имеют kind 1 и
-archive `objects.rlb`; в 5 205 name fields есть ненулевые хвостовые байты после
-string terminator. Такой tail является данными, а не мусором, если цель --
-lossless roundtrip.
-
-### Модели
-
-Проверено 435 MSH без errors/warnings; 157 анимированных. Диапазоны: 1-38
-nodes, 1-112 slots, 12-9 686 vertices, 1-439 batches.
-
-```text
-414 моделей: types [1,2,3,4,5,15,13,6,7,8,19,9,10,17]
-21 модель: [1,2,3,4,5,18,15,13,6,7,8,19,9,10,17,20]
-```
-
-Type 17 непуст у 29 моделей; type 20 встречается у 21. Редкий variant type 1
-найден в `system.rlb::MTCHECK.MSH`.
-
-Повторная проверка terrain исправила layout face: vertex indices находятся с
-`+0x08`, neighbor indices с `+0x0E`. Эта локальная проверка имеет приоритет над
-ранними черновыми описаниями.
-
-### Материалы и текстуры
-
-Проверено 457 WEAR, 905 MAT0 и 518 Texm без ошибок. У всех MAT0 `attr2 = 6`.
-531 материал содержит одну phase; максимальное число phases -- 29. У 860
-материалов один animation block, у 43 -- два, у 2 -- восемь.
-
-Распределение Texm по форматам:
-
-```text
-indexed 15
-565 155
-4444 59
-888 52
-8888 237
-```
-
-Форматы 556 и 88 присутствуют в loader-е, но не встречаются в demo-assets.
-65 текстур содержат `Page`; размеры лежат от `8x8` до `256x256`. Все 385
-уникальных texture references из MAT0 разрешаются.
-
-### Эффекты
-
-Проверено 923 FXID без ошибок. Наиболее часты команды 3, 7, 1 и 2. Команда 6 в
-данных демоверсии не встречается. Наблюдаются режимы времени 0, 1, 2, 4, 5,
-14, 15, 16 и 17.
-
-### Карты
-
-Шесть `Land.msh` и шесть `Land.map` проходят проверку без ошибок. Всего 3 811
-ареалов; grid всегда `128x128`, максимальное число candidates в ячейке -- 10,
-`poly_count` во всех записях равен нулю.
-
-```text
-AutoMAP 3051 vertices, 3174 faces, 343 areas
-PROL 11125 vertices, 9234 faces, 731 area
-Tut_1 8827 vertices, 8290 faces, 378 areas
-Tut_2 9456 vertices, 8996 faces, 900 areas
-Tut_3 9833 vertices, 8560 faces, 722 areas
-Tut_4 9022 vertices, 8612 faces, 737 areas
-```
-
-Максимальное отклонение длины areal normal от единицы около `1.05e-7`.
-
-### Вспомогательные форматы
-
-```text
-CTPT 284 resources, 3599 points, errors 0
-NDPR 494 resources, 1915 records, errors 0
-BASE 30 resources, errors 0
-EXPL 144 resources, versions 1/2/3, errors 0
-reference arrays 585 resources, 2956 records, errors 0
-SUND 2 resources, 12 keys, errors 0
-CTLD 531 payloads, errors 0
-TRF 5 files, errors 0
-preload 38 entries
-ANI 8 resources
-SKE 6 resources
-```
-
-CTPT names подтверждают attachment semantics: `TurretCenter`, `TurretDirect`,
-`CameraCenter`, `TargetDirect`, `Root`, `Sfx`, `Width`, `Height`, `Dir` и
-другие.
-
-### Как читать статистику
-
-Нулевое число parser errors подтверждает layout и диапазонные инварианты на
-имеющихся variants, но не автоматически раскрывает предметный смысл каждого
-opaque field. Отсутствие opcode или poly branch в corpus означает, что эту
-ветку нельзя считать corpus-verified.
-
-Особенно важно различать весь архив и достижимый runtime path. В `objects.rlb`
-есть ссылки на вырезанный demo-content, однако шесть миссий не требуют их.
-Поэтому quality gate имеет два отчёта: global archive health и mission
-reachability.
-
-### Полные каталоги Частей 1 и 2
-
-Статистика демоверсии остаётся неизменной. Полные Части 1 и 2 образуют два
-самостоятельных профиля с отдельными manifests, hashes и golden data.
-
-Часть 1:
-
-```text
-files 1 017, bytes 197 056 957
-NRes 120 / 6 804 entries
-TMA 29 / 864 objects / 28 extras
-unit DAT 425 / 5 219 records
-objects.rlb 590 prototypes
-MSH 435, MAT0 905, Texm 518, FXID 923
-Land maps 33 / 34 662 areals
-reachable prototypes 4 701
-materials 36 954, textures 48 806, lightmaps 139
-reachability failures 0
-```
-
-Часть 2:
-
-```text
-files 1 302, bytes 358 004 931
-NRes 134 / 8 171 entries
-TMA 31 / 885 objects / 41 extras
-unit DAT 676 / 8 145 records
-objects.rlb 683 prototypes
-MSH 511, MAT0 1 127, Texm 631, FXID 1 065
-Land maps 32 / 18 984 areals
-reachable prototypes 5 845
-materials 50 888, textures 68 603, lightmaps 214
-reachability failures 0
-```
-
-Bootstrap Частей 1 и 2 идентичен. Девять DLL идентичны, шесть пересобраны при
-сохранённом ABI. Активные NRes entries сравниваются так: 3 733 идентичны, 2 503
-имеют изменённый payload, 1 934 добавлены в Части 2, 567 удалены. Это
-показывает стабильность форматов при существенной переработке content,
-особенно MSH, CTLD и FXID.
-
-## Границы знания
-
-### Закрытые или практически закрытые области
-
-- Startup bootstrap и восемь exports `iron3d.dll`.
-- Карта 15 DLL, exports/imports и основные interface boundaries.
-- NRes layout, поиск и writer rules.
-- RsLi header, table transform, lookup, mapping и используемые decode paths.
-- TMA всех 60 проверенных миссий, unit DAT и `objects.rlb` resolution.
-- MSH core/animation range contracts.
-- WEAR, MAT0, Texm и FXID framing.
-- `Land.msh`/`Land.map` и areal grid.
-- World3D calculation/render order и deferred deletion.
-- Сквозная mission-to-texture цепочка.
-
-Полная проверка доступных каталогов усилила NRes active ranges, recursive
-prototype inheritance через `objects.rlb`, bounded non-NUL unit descriptions,
-полный TMA epilogue, extra records и Clan mode 0, MSH/MAT0/Texm/FXID variant
-matrix Частей 1 и 2, 65 `Land.msh`/`Land.map`, полный reachable graph 60
-миссий, stability matrix пятнадцати DLL, empty SWAV и stale save-slot metadata.
-
-### Render-state и pixel parity
-
-Доказан порядок frame boundaries, world traversal, material resolve и крупных
-проходов. Не доказаны символами точные названия renderer vtable slots
-`+0x28/+0x30/+0x34`, полный набор state transitions CShade и окончательный
-взаимный порядок некоторых transparent/FX/shadow subpasses.
-
-Pixel parity требует эталонных кадров оригинала с фиксированными camera,
-timing, seed, разрешением и capability profile. Вместе с изображением
-необходимо сохранять command/state trace; иначе pixel difference не позволяет
-отличить ошибку формата от ошибки backend-а.
-
-Минимальный capture должен фиксировать resolution, bit depth, selected driver,
-device capabilities, camera matrices, mission, game time, seed, input log,
-scene boundaries, transforms, render states, texture-stage states, texture
-binds, viewport, clear, draw calls и `Blt/Flip`. Сначала сравниваются command
-lists; pixel diff имеет смысл только после совпадения geometry/state sequence.
-
-### FXID field-level semantics
-
-Размеры команд, resource references, lifecycle, flags families и используемые
-time modes известны. Не закрыто значение каждого поля body opcodes 1-10,
-отсутствующий во всех проверенных каталогах opcode 6 и точные формулы редких
-time modes.
-
-Закрывающий эксперимент: создать инструмент, который изменяет по одному полю
-копии эффекта, воспроизводить его в контролируемой сцене и логировать runtime
-command object, emitted primitives и sound events. Одновременно reads в
-`Effect.dll` сопоставляются с offsets body.
-
-### Script VM
-
-Сценарные packages, symbol names, event sections, variable declarations и
-version check доступны. Полная instruction grammar `.scr`, semantics всех
-opcodes и serialization состояния VM ещё не восстановлены.
-
-План реконструкции:
-
-1. Найти loader `.scr`, version check, границы bytecode, таблицы
- strings/symbols/events.
-2. Найти dispatcher loop по повторяющемуся чтению opcode и indirect branch или
- jump table.
-3. Для каждого handler определить instruction size, operands, чтения/записи VM
- state, stack effect, branch target и world side effects.
-4. Hook-нуть dispatcher и писать запись `package,event,ip,opcode,raw
- operands,state before,state after,next ip`.
-5. Построить disassembler и CFG; branch target обязан попадать на
- подтверждённую границу инструкции.
-6. Закрывать opcode после статического handler contract, одного динамического
- trace и одного regression script.
-
-После opcode table отдельно восстанавливаются serialization IP, call/event
-frames, variables, timers и RNG.
-
-### Physical/control formats
-
-CTLD и связанные resources структурно читаются, count patterns и variants
-известны. Не названы все секции, shape types, coefficients и точный contact
-solver. То же относится к редким MSH types 17/20 и части CTPT/NDPR flags.
-
-Закрывающий эксперимент: трассировать `LoadControlSystem`,
-`LoadPhysicalModel` и создание collision objects на нескольких прототипах;
-записать offsets, созданные shape instances и реакции на контролируемое
-движение. Изменение одного resource field должно связываться с одним
-наблюдаемым параметром.
-
-### Сеть
-
-DirectPlay lifecycle и имена игровых сообщений известны. Точные framing,
-payload schema, reliability flags и алгоритм `netZipData` пока не подтверждены
-записью сетевого обмена. Поэтому совместимость с оригинальным сетевым клиентом
-ещё не доказана.
-
-Для закрытия нужны два оригинальных клиента в изолированной среде и логирование
-`netZipData`, `netUnZipData`, DirectPlay Send/Receive и World3D message
-enqueue/dequeue. Native interoperability подтверждается только успешным
-обменом original client <-> compatibility implementation в обе стороны.
-
-### Редкие или отсутствующие corpus-ветки
-
-- `Land.map poly_count > 0`: layout читается из loader-а, но ни одна из 65
- проверенных карт не содержит живой записи.
-- RsLi adaptive methods `0x080`/`0x0A0`: decoder path известен, однако
- демоверсия и обе полные части их не используют.
-- Texm formats 556 и 88: loader поддерживает их, но ни один проверенный Texm не
- использует эти значения.
-- FX opcode 6: размер известен, однако живой command отсутствует во всём
- доступном corpus.
-- Некоторые material flags и MSH auxiliary streams встречаются слишком редко
- для полного authoring contract.
-
-Такие ветки реализуются строго по бинарному коду и synthetic tests, а статус
-corpus-verified получают только после появления реального файла.
-
-### Сохранения и campaign state
-
-`saveslots.cfg` и `missions/dispatcher.ini` найдены, но полный бинарный
-savegame payload, serialization World3D/AI/script/RNG и правила миграции версии
-не восстановлены. Без этого нельзя честно заявлять полную campaign
-compatibility.
-
-Минимальный набор сохранений для каждой части:
-
-```text
-S0 сразу после старта миссии
-S1 тот же state без simulation step
-S2 изменена только позиция одного объекта
-S3 изменено только здоровье/свойство
-S4 активен один Behavior order/path
-S5 активен один FX и timer
-S6 изменена одна script variable
-S7 изменён research/economy state
-S8 перед/после mission completion
-S9 pause и non-default game time
-```
-
-Без самих binary save payload возможно описать обязательный state и найти код
-сериализации, но невозможно доказать disk layout и roundtrip.
-
-### Shell, HUD, шрифты и локализация
-
-Граница shell подтверждена экспортами `createShell`/`getIShell`, `IGUIServer`,
-верхнеуровневым UI-pass и файлами `ui/*.cfg`, `DATA/TextRes.cfg`,
-`gamefont.rlb` и `sprites.lib`. RsLi framing двух библиотек закрыт, но widget
-tree, layout rules, font glyph metrics, sprite command semantics,
-focus/navigation и полный HUD state machine пока не восстановлены до
-field-level спецификации.
-
-До закрытия новая реализация может построить функционально эквивалентный UI
-поверх известных ресурсов, но не заявлять native layout/behavior parity.
-
-### Исследования, экономика и игровые свойства
-
-Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA
-properties доказывают отдельный слой исследований, стоимости, добычи и
-производственных параметров. Сквозные имена (`MaximumOre`, `CurrentOre`,
-`FreeResearchTime`, `FreeConstructionTime` и другие) доступны, однако формулы
-стоимости, dependency graph технологий, inventory/economy transitions и точная
-типизация всех 16-byte property values не закрыты.
-
-Закрывающий эксперимент: сопоставить `LoadResearch`/`CalcFullResearchCost` с
-ресурсами и UI, снять изменения state на контролируемых покупках/исследованиях
-и построить typed schema свойств по consumers, не по одному имени.
-
-### Условия динамического этапа
-
-Полное закрытие оставшихся вопросов технически возможно, но не только по
-статическим архивам. Нужна среда, способная запускать оригинальный 32-битный
-код, и набор эталонных наблюдений:
-
-1. Изолированная 32-битная Windows VM или отдельная машина с исходными
- DirectDraw/Direct3D/DirectSound/DirectPlay interfaces.
-2. Два неизменённых игровых каталога и manifest SHA-256 для executable, DLL,
- конфигураций и ключевых архивов.
-3. Отладчик с hardware/software breakpoints, просмотром x87/SSE state и
- сохранением memory dumps.
-4. API/vtable hooking для Win32 file I/O, DirectDraw/Direct3D, DirectSound и
- DirectPlay; hooks должны писать binary trace, не изменяя порядок вызовов.
-5. Управляемые clocks, input log и RNG seed либо trace всех вызовов источника
- случайности.
-6. Автоматический launcher, который восстанавливает snapshot VM, запускает один
- test case, собирает логи и завершает процесс без ручного вмешательства.
-
-Для каждого capture сохраняются profile сборки, hash модулей,
-mission/resource key, конфигурация, device profile, начальное состояние,
-input/time script и версии инструментов.
-
-### Критерий закрытия открытого вопроса
-
-Для каждого открытого вопроса должны существовать:
-
-- build fingerprint и адреса наблюдаемых функций;
-- raw trace и автоматический parser trace-а;
-- минимальный воспроизводимый input/resource/save/message;
-- формальный контракт или явно ограниченная гипотеза;
-- differential test для Частей 1 и 2, если модуль изменён;
-- обновление тематической статьи;
-- regression case, запускаемый без ручного анализа.
-
-До выполнения этих условий статический контракт пригоден для реализации, но
-утверждение о полном поведенческом или native-паритете не публикуется.
-
-## Глоссарий
-
-### Бинарные файлы и reverse engineering
-
-**PE (Portable Executable)** -- формат исполняемых файлов Windows: EXE и DLL.
-Он содержит заголовки, секции, таблицы импортов и экспортов, relocations и
-адрес точки входа.
-
-**Image base** -- предпочтительный адрес начала загруженного PE-образа.
-**VA** -- виртуальный адрес в процессе. **RVA** -- адрес относительно image
-base. Адрес функции в памяти обычно равен `image_base + RVA`.
-
-**Import** -- внешняя функция или переменная, которую модуль получает из другой
-DLL. **Export** -- символ, предоставляемый другим модулям. Имя, ordinal и
-calling convention вместе образуют часть бинарного контракта.
-
-**ABI** -- соглашение о двоичном взаимодействии: размещение аргументов, возврат
-значений, очистка stack, layout структур, порядок virtual methods и правила
-владения.
-
-**Calling convention** -- часть ABI, определяющая передачу аргументов и очистку
-stack. Для исследованного 32-битного кода важны `__cdecl`, `__stdcall` и
-`__thiscall`.
-
-**Vtable** -- массив указателей на virtual methods C++-объекта. Запись
-`vtable +0x34` означает вызов указателя по байтовому смещению `0x34` от начала
-таблицы.
-
-**Static analysis** исследует файл без его исполнения: disassembly, strings,
-imports, call graph и data flow. **Dynamic analysis** наблюдает работающую
-программу: breakpoints, traces, API hooks, memory state и packet/frame captures.
-
-**Evidence** -- наблюдение, которое можно повторить. **Inference** -- вывод,
-объединяющий несколько наблюдений. **Hypothesis** -- рабочее предположение, ещё
-не подтверждённое достаточным экспериментом.
-
-### Форматы данных и ресурсы
-
-**Archive** -- контейнер, объединяющий множество ресурсов. **Entry** -- запись
-его каталога. **Payload** -- полезные bytes конкретной записи.
-
-**Magic** -- короткая сигнатура формата, например `NRes` или `Texm`.
-**Version** -- номер варианта layout. Проверка одной magic без проверки version
-и размеров недостаточна.
-
-**Offset** -- положение данных относительно начала файла или структуры.
-**Size** -- число занимаемых bytes. **Stride** -- размер одного элемента
-массива. **Alignment** -- требование начинать данные на address или offset,
-кратном заданному числу.
-
-**Little-endian** -- порядок, в котором младший byte многобайтного числа
-расположен первым. Все основные числовые поля исследованных форматов Iron3D
-используют этот порядок.
-
-**Fixed-size string** -- поле заранее известной длины. Полезная строка
-заканчивается первым NUL, но оставшиеся bytes поля могут содержать служебный
-хвост и должны сохраняться.
-
-**Opaque field** -- поле с доказанными offset и размером, но не установленным
-предметным смыслом. Его безопасно читать и копировать, но нельзя очищать или
-переосмысливать без эксперимента.
-
-**Invariant** -- условие, которое обязано выполняться: диапазон находится
-внутри payload, индекс указывает на существующий элемент, число записей
-соответствует размеру секции.
-
-**Strict reader** отклоняет любое нарушение контракта. **Compatibility reader**
-дополнительно воспроизводит только известные особенности оригинала, например
-именованный fallback. Compatibility mode не означает игнорирование произвольной
-порчи.
-
-**Roundtrip** -- последовательность decode -> encode. **Byte-identical
-roundtrip** создаёт файл, полностью совпадающий с исходным. **Lossless editor**
-может изменить известное поле, сохранив все остальные bytes и порядок записей.
-
-**Fallback** -- явно предписанный запасной путь, например материал `DEFAULT`,
-затем entry 0. **Heuristic** -- догадка по похожим данным; она не должна
-незаметно заменять доказанный fallback.
-
-### Игровой runtime
-
-**Engine** -- программная среда, которая загружает данные, ведёт время,
-исполняет мир и формирует изображение/звук. **Game** -- конкретные правила,
-миссии и содержимое, работающие поверх engine services.
-
-**World** -- долгоживущее состояние миссии: objects, terrain, время, кланы и
-managers. **Scene** -- представление части мира для конкретной обработки, чаще
-всего текущей камеры.
-
-**Game object** -- сущность с идентичностью, transform, properties и lifecycle.
-**Component/controller** -- специализированная часть поведения: animation,
-physics, AI или rendering representation.
-
-**Simulation** отвечает за изменение мира. **Tick** -- один расчётный шаг
-simulation. **Frame** -- одно подготовленное изображение. Число ticks и frames
-за единицу времени не обязано совпадать.
-
-**Game loop** -- повторяющийся порядок ввода, расчёта, рендера и обслуживания.
-**Scheduler phase** -- явно ограниченный участок loop, где разрешены
-определённые операции.
-**Event/message** -- типизированное сообщение между objects или subsystems.
-**Queue traversal** -- стабильный обход зарегистрированных объектов.
-**Deferred deletion** -- перенос фактического удаления до безопасной границы
-после traversal.
+## Дальнейшее чтение
-**Determinism** -- одинаковый результат при одинаковом initial state, input,
-времени и порядке событий. **Replay** -- повторное исполнение записанной
-последовательности входов/сообщений для проверки determinism.
-
-**Authority** -- subsystem или network peer, которому разрешено окончательно
-менять состояние объекта. **Mirror object** -- локальное представление объекта,
-authority которого находится у другого player.
-
-### Геометрия, анимация и рендеринг
-
-**Mesh** -- набор vertex/index streams и draw-групп, описывающий форму.
-**Node** -- элемент hierarchy модели со своим local transform. **Slot** в MSH
--- выбранная геометрическая группа для комбинации node, LOD и group; он также
-хранит bounds и диапазоны batches.
-
-**Batch** -- непрерывный индексный диапазон с одним material slot и общим
-render state. **Transform** переводит данные между local, world, view и clip
-spaces. Порядок умножения matrices является частью контракта.
-
-**Quaternion** -- четырёхкомпонентное представление вращения. **Keyframe** --
-pose в определённое время. **Sampling** выбирает pose для времени, а
-**blending** смешивает animation states.
-
-**Bounds** -- упрощённый объём для быстрых тестов. **AABB** -- пара
-minimum/maximum по осям. **Bounding sphere** -- center и radius.
-
-**Renderer** -- subsystem, преобразующая подготовленную сцену в изображение.
-**Backend** -- реализация renderer поверх конкретного API или устройства.
-
-**Draw call** -- команда нарисовать диапазон primitives с текущими resources и
-states. **Material** -- правила отображения поверхности: texture, коэффициенты,
-прозрачность и режимы pipeline. **Material phase** -- одно временное состояние
-анимированного материала.
-
-**Texture** -- двумерный массив texels. **UV coordinates** -- координаты
-выборки. **Mip chain** -- последовательность уменьшенных уровней texture.
-**Lightmap** -- texture с заранее рассчитанным вкладом освещения.
-
-**Fixed-function pipeline** -- старый графический pipeline, где приложение
-выбирает predefined transform, lighting, texture-stage и blend states вместо
-пользовательских shaders.
-
-**Depth buffer** хранит глубину уже принятой поверхности. **Alpha test**
-полностью принимает или отвергает fragment. **Blending** смешивает новый цвет с
-framebuffer.
-
-**Back buffer** -- скрытый framebuffer. **Present/flip** делает завершённый
-кадр видимым. **Pixel parity** -- совпадение конечного изображения при
-фиксированных условиях.
-
-### Навигация, физика, звук и сеть
-
-**Areal** -- логическая область карты с границей, class/flags и связями с
-соседями. **Areal graph** -- граф, вершинами которого служат области, а рёбрами
--- допустимые переходы. **Cell grid** -- пространственный индекс для candidate
-areas или objects.
-
-**Pathfinding** -- поиск маршрута по графу. **A\*** использует стоимость уже
-пройденного пути и оценку расстояния до цели. Навигационная проходимость,
-отсутствие collision и видимость -- разные свойства.
-
-**Collision proxy** -- упрощённое представление объекта для столкновений.
-**Broad phase** быстро находит потенциальные пары; **narrow phase** выполняет
-точную проверку и вычисляет contact.
-
-**Sample** -- декодированные звуковые данные. **Source** -- экземпляр
-воспроизведения с position, gain, loop state и временем. **Listener** -- позиция
-и ориентация слушателя для 3D spatialization.
-
-**Transport** -- механизм доставки bytes между peers. **Protocol** -- framing,
-message types, порядок и правила подтверждения. **Serialization** --
-преобразование typed state в byte sequence.
-
-**Reliable delivery** гарантирует доставку/порядок в пределах выбранной модели;
-**unreliable delivery** допускает потери ради задержки. **Wire compatibility**
--- способность обмениваться данными с оригинальным клиентом, а не только
-воспроизводить ту же игровую семантику в новом протоколе.
-
-## Связанные локальные справки
-
-- [NRes](../reference/nres.md)
-- [RsLi](../reference/rsli.md)
-- [TMA](../reference/tma.md)
-- [MSH](../reference/msh.md)
-- [Texm](../reference/texm.md)
-- [Materials](../reference/materials.md)
-- [Render frame](../reference/render-frame.md)
-- [Границы знания](../appendices/knowledge-boundaries.md)
-- [Глоссарий](../appendices/glossary.md)
-
-## Дополнительное чтение
-
-Эти материалы помогают понять PE, ABI, сжатие, graphics pipeline, game loop и
-навигацию. Они не являются доказательством поведения Iron3D: детали движка
-принимаются только после проверки его бинарного кода и игровых ресурсов.
-
-- [Microsoft PE/COFF specification](https://learn.microsoft.com/en-us/windows/win32/debug/pe-format)
-- [Microsoft x86 calling conventions](https://learn.microsoft.com/en-us/cpp/build/x86-calling-conventions)
-- [Intel Software Developer Manuals](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html)
-- [Ghidra documentation](https://ghidra-sre.org/)
-- [RFC 1951: DEFLATE](https://www.rfc-editor.org/rfc/rfc1951)
-- [zlib manual](https://zlib.net/manual.html)
-- [Kaitai Struct user guide](https://doc.kaitai.io/user_guide.html)
-- [Microsoft Direct3D documentation](https://learn.microsoft.com/en-us/windows/win32/direct3d)
-- [Vulkan specification](https://registry.khronos.org/vulkan/specs/1.4-extensions/html/vkspec.html)
-- [Real-Time Rendering resources](https://www.realtimerendering.com/)
-- [LearnOpenGL](https://learnopengl.com/)
-- [Scratchapixel](https://www.scratchapixel.com/)
-- [Game Programming Patterns](https://gameprogrammingpatterns.com/)
-- [Fix Your Timestep](https://gafferongames.com/post/fix_your_timestep/)
-- [Red Blob Games: A*](https://www.redblobgames.com/pathfinding/a-star/introduction.html)
+[Глоссарий](../appendices/glossary.md),
+[открытые вопросы](../appendices/knowledge-boundaries.md),
+[сценарная VM](../appendices/script-vm.md),
+[сохранения и кампания](../appendices/saves-campaign.md),
+[интерфейс игры](../appendices/ui-shell.md).
diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv
deleted file mode 100644
index 74d681c..0000000
--- a/fixtures/acceptance/coverage.tsv
+++ /dev/null
@@ -1,447 +0,0 @@
-# Acceptance coverage manifest.
-# Format: <acceptance-id>\t<covered|covered-planning|covered-stub|covered-gpu|partial|blocked|omitted>\t<evidence>
-# Scope note: Stage 0 native runtime acceptance is Windows-only.
-# Scope note: Linux/macOS and hosted CI are outside the project scope.
-L0-COPYRIGHT-001 covered cargo test -p fparkan-corpus --offline report_json_contains_metrics_and_hashes_not_paths_or_payloads
-L0-P1-001 covered cargo test -p fparkan-corpus --offline licensed_part1_manifest_profile_and_counts_match_baseline
-L0-P1-002 covered cargo test -p fparkan-corpus --offline licensed_part1_has_no_casefold_relative_path_collisions
-L0-P2-001 covered cargo test -p fparkan-corpus --offline licensed_part2_manifest_profile_and_counts_match_baseline
-L0-P2-002 covered cargo test -p fparkan-corpus --offline licensed_part2_has_no_casefold_relative_path_collisions
-S0-ARCH-001 covered cargo xtask policy runs cargo metadata --offline --no-deps successfully
-S0-ARCH-002 covered cargo xtask policy rejects forbidden GUI/adapter dependencies from domain crates
-S0-ARCH-003 covered cargo xtask policy rejects platform/render adapter dependencies from the transitive fparkan-headless workspace manifest closure
-S0-ARCH-004 covered cargo xtask policy scans workspace-owned Rust/TOML for unsafe constructs and workspace lints forbid unsafe_code
-S0-ARCH-005 covered cargo xtask policy rejects Python source files, Python shebangs, and Python CI workflow steps while allowing docs requirements.txt
-S0-ARCH-006 covered cargo xtask policy rejects non-fparkan package directories under crates/
-S0-ARCH-007 covered cargo xtask ci runs fmt, policy, workspace test, clippy, rustdoc warnings, cargo-deny with reviewed deny.toml, and strict acceptance audit; built-in supply-chain fallback is opt-in local-only and forbidden when CI is set
-S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift
-S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job
-S0-ARCH-010 covered cargo xtask acceptance audit emits measured commit_sha, git_dirty, runner_identity, rust_toolchain, and msrv metadata into the JSON artifact
-S0-ARCH-011 covered cargo xtask native-smoke audit requires a passed Windows 300-frame report with measured resize/recreate, validation=0, clean git provenance, exact commit SHA shape, and a Windows target triple
-S0-DIAG-001 covered cargo test -p fparkan-diagnostics --offline diagnostic_chain_preserves_context
-S0-DIAG-002 covered cargo test -p fparkan-diagnostics --offline json_is_stable
-S0-CORPUS-001 covered cargo test -p fparkan-corpus --offline deterministic_traversal_is_creation_order_independent
-S0-CORPUS-002 covered cargo test -p fparkan-corpus --offline unreadable_directory_produces_error
-S0-CORPUS-003 covered cargo test -p fparkan-corpus --offline symlink_loop_is_not_traversed_by_default
-S0-CORPUS-004 covered cargo test -p fparkan-corpus --offline casefold_collisions_are_registered
-S0-CORPUS-005 covered cargo test -p fparkan-corpus --offline fingerprint_changes
-S0-CORPUS-006 covered cargo test -p fparkan-corpus --offline atomic_report_write
-S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_mapped
-S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version
-S0-PLAT-001 covered cargo test -p fparkan-platform-winit --offline window_port_reports_default_request_profile
-S0-PLAT-002 covered cargo clippy -p fparkan-platform -p fparkan-platform-winit --all-targets --all-features --locked -- -D warnings
-S0-PLAT-003 covered cargo test -p fparkan-platform-winit --offline smoke_window_plan_requires_native_handles_and_nonzero_extent smoke_window_plan_rejects_zero_extent
-S0-PLAT-004 covered cargo test -p fparkan-platform-winit --offline smoke_window_app_requires_created_native_window smoke_window_app_rejects_synthetic_window_without_native_handles
-S0-VK-001 covered-gpu Windows fparkan-vulkan-smoke native audit: real instance, Win32 surface, logical device, swapchain and indexed triangle presentation
-S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue
-S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed
-S0-VK-004 covered cargo test -p fparkan-render-vulkan --offline rejects_missing_graphics_present_swapchain_and_format
-S0-VK-005 covered cargo test -p fparkan-render-vulkan --offline capability_report_json_is_stable
-S0-VK-006 covered cargo test -p fparkan-render-vulkan --offline loader_probe_report_json_is_stable
-S0-VK-007 covered cargo xtask policy
-S0-VK-008 covered cargo test -p fparkan-render-vulkan --offline instance_plan_is_sorted_deduplicated_and_portability_aware
-S0-VK-009 covered cargo test -p fparkan-render-vulkan --offline instance_plan_adds_portability_extension_when_requested
-S0-VK-010 covered cargo test -p fparkan-render-vulkan --offline invalid_instance_extension_name_is_reported_before_loader_use
-S0-VK-011 covered cargo test -p fparkan-render-vulkan --offline surface_plan_requires_native_handles
-S0-VK-012 covered cargo test -p fparkan-render-vulkan --offline surface_plan_json_is_stable
-S0-VK-013 covered cargo test -p fparkan-render-vulkan --offline static_surface_extension_name_is_decoded
-S0-VK-014 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_prefers_srgb_mailbox_and_clamps_extent
-S0-VK-015 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_uses_fifo_and_current_extent_fallbacks
-S0-VK-016 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_rejects_missing_surface_data_and_empty_extent
-S0-VK-017 covered cargo test -p fparkan-render-vulkan --offline swapchain_plan_json_and_recreation_reports_are_stable
-S0-VK-018 covered cargo test -p fparkan-render-vulkan --offline triangle_shader_manifest_hashes_are_stable checked_in_shader_manifest_matches_generated_report
-S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable
-S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
-S0-VK-021 covered-gpu Windows fparkan-vulkan-smoke native audit: real submit/present loop, 300 frames and validation-clean teardown
-S0-VK-022 covered-gpu Windows fparkan-vulkan-smoke native audit: resize drives swapchain recreation before clean shutdown
-S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status
-S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version
-S0-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe
-S0-VK-026 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_window_probe rejects_passed_without_surface_probe parses_surface_probe_as_instance_probe
-S0-VK-027 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_swapchain_recreation blocked_report_includes_shader_manifest_and_bootstrap_status
-S0-VK-028 covered cargo test -p fparkan-vulkan-smoke --offline reports_rustc_host_triple blocked_report_includes_shader_manifest_and_bootstrap_status
-S0-VK-029 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_required_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
-S0-VK-030 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_with_failed_surface
-S0-VK-031 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_selected_device
-S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_swapchain
-S0-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device
-S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_required_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
-S0-VK-035 covered cargo test -p fparkan-render-vulkan --offline final_validation_snapshot_is_captured_after_surface_device_swapchain_drop
-S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
-S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
-L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
-L1-P2-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
-L1-P1-NRES-002 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
-L1-P2-NRES-002 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
-L1-P1-NRES-003 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
-L1-P2-NRES-003 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
-L1-P1-RSLI-001 covered cargo test -p fparkan-rsli --offline licensed_part1_rsli_method_distribution_baseline
-L1-P2-RSLI-001 covered cargo test -p fparkan-rsli --offline licensed_part2_rsli_method_distribution_baseline
-L1-RSLI-QUIRK-001 covered cargo test -p fparkan-rsli --offline licensed_corpora_rsli_quirk_is_only_approved_interf8_tex
-L1-P1-PATH-001 covered cargo test -p fparkan-corpus --offline licensed_part1_paths_stay_under_root
-L1-P2-PATH-001 covered cargo test -p fparkan-corpus --offline licensed_part2_paths_stay_under_root
-S1-NRES-001 covered cargo test -p fparkan-nres --offline parses_minimal_empty_archive
-S1-NRES-002 covered cargo test -p fparkan-nres --offline one_entry_archive_uses_8_byte_alignment
-S1-NRES-003 covered cargo test -p fparkan-nres --offline rejects_invalid_magic
-S1-NRES-004 covered cargo test -p fparkan-nres --offline rejects_unsupported_version
-S1-NRES-005 covered cargo test -p fparkan-nres --offline rejects_negative_entry_count
-S1-NRES-006 covered cargo test -p fparkan-nres --offline rejects_directory_size_before_allocation
-S1-NRES-007 covered cargo test -p fparkan-nres --offline rejects_total_size_mismatch
-S1-NRES-008 covered cargo test -p fparkan-nres --offline rejects_directory_before_header
-S1-NRES-009 covered cargo test -p fparkan-nres --offline rejects_payload_before_data_region
-S1-NRES-010 covered cargo test -p fparkan-nres --offline rejects_payload_crossing_directory
-S1-NRES-020 covered cargo test -p fparkan-nres --offline preserves_nonzero_unindexed_region
-S1-NRES-021 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
-S1-NRES-011 covered cargo test -p fparkan-nres --offline rejects_overlapping_payloads
-S1-NRES-012 covered cargo test -p fparkan-nres --offline rejects_name_without_nul_terminator
-S1-NRES-013 covered cargo test -p fparkan-nres --offline preserves_name_bytes_after_nul
-S1-NRES-014 covered cargo test -p fparkan-nres --offline rejects_sort_index_out_of_range
-S1-NRES-015 covered cargo test -p fparkan-nres --offline rejects_duplicate_sort_mapping
-S1-NRES-016 covered cargo test -p fparkan-nres --offline binary_lookup_returns_original_entry_index
-S1-NRES-017 covered cargo test -p fparkan-nres --offline compatible_profile_uses_linear_fallback_for_broken_mapping
-S1-NRES-018 covered cargo test -p fparkan-nres --offline lookup_is_ascii_case_insensitive
-S1-NRES-019 covered cargo test -p fparkan-nres --offline rejects_empty_names_and_resolves_duplicates_to_first_entry
-S1-NRES-022 covered cargo test -p fparkan-nres --offline canonical_compact_roundtrip_preserves_entry_semantics
-S1-NRES-023 covered cargo test -p fparkan-nres --offline editor_payload_update_rewrites_offsets_and_size
-S1-NRES-024 covered cargo test -p fparkan-nres --offline editor_rename_rebuilds_search_mapping
-S1-NRES-025 covered cargo test -p fparkan-nres --offline editor_rejects_invalid_authoring_names
-S1-NRES-026 covered cargo test -p fparkan-nres --offline strict_rejects_unsorted_lookup_table
-S1-LIMIT-001 covered cargo test -p fparkan-nres --offline rejects_directory_size_before_allocation decode_rejects_entry_count_above_limit decode_rejects_input_bytes_above_limit decode_rejects_preserved_bytes_above_limit
-S1-NRES-PROP-001 covered cargo test -p fparkan-nres --offline generated_archives_preserve_lossless_and_canonical_semantics
-S1-NRES-PROP-002 covered cargo test -p fparkan-nres --offline generated_editor_updates_roundtrip
-S1-NRES-FUZZ-001 covered cargo test -p fparkan-nres --offline arbitrary_small_inputs_do_not_panic_or_overallocate
-S1-PATH-001 covered cargo test -p fparkan-path --offline normalizes_separators
-S1-PATH-002 covered cargo test -p fparkan-path --offline ascii_casefold_does_not_unicode_fold
-S1-PATH-003 covered cargo test -p fparkan-path --offline non_ascii_original_bytes_remain_stable
-S1-PATH-004 covered cargo test -p fparkan-path --offline rejects_absolute_drive_and_nul_paths
-S1-PATH-005 covered cargo test -p fparkan-path --offline rejects_escape
-S1-PATH-006 covered cargo test -p fparkan-path --offline rejects_absolute_drive_and_nul_paths
-S1-PATH-007 covered cargo test -p fparkan-path --offline join_under_keeps_normalized_path_below_root
-S1-PATH-008 covered cargo test -p fparkan-path --offline original_separators_and_raw_bytes_are_preserved
-S1-PATH-009 covered cargo test -p fparkan-path --offline accepts_non_utf8_legacy_bytes
-S1-PATH-010 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_non_utf8_host_entries_by_raw_bytes
-S1-PATH-011 covered cargo test -p fparkan-path -p fparkan-vfs -p fparkan-resource --offline lossy_display_does_not_affect_identity_or_ordering overlay_vfs_keeps_lossy_equivalent_entries_distinct lossy_equivalent_archive_paths_remain_distinct
-S1-VFS-005 covered cargo test -p fparkan-vfs --offline memory_vfs_list_prefix_is_boundary_safe
-S1-RSLI-001 covered cargo test -p fparkan-rsli --offline parses_minimal_empty_library
-S1-RSLI-002 covered cargo test -p fparkan-rsli --offline rejects_invalid_header_fields
-S1-RSLI-003 covered cargo test -p fparkan-rsli --offline rejects_entry_table_bounds
-S1-RSLI-004 covered cargo test -p fparkan-rsli --offline table_xor_transform_uses_known_vector
-S1-RSLI-005 covered cargo test -p fparkan-rsli --offline table_xor_transform_is_symmetric
-S1-RSLI-006 covered cargo test -p fparkan-rsli --offline table_xor_state_spans_entries
-S1-RSLI-007 covered cargo test -p fparkan-rsli --offline presorted_mapping_uses_valid_permutation
-S1-RSLI-008 covered cargo test -p fparkan-rsli --offline compatible_profile_rebuilds_invalid_presorted_mapping
-S1-RSLI-009 covered cargo test -p fparkan-rsli --offline stored_method_uses_exact_size
-S1-RSLI-010 covered cargo test -p fparkan-rsli --offline xor_only_method_uses_entry_key
-S1-RSLI-011 covered cargo test -p fparkan-rsli --offline lzss_method_decodes_literals_references_and_wrap
-S1-RSLI-012 covered cargo test -p fparkan-rsli --offline xor_lzss_method_uses_entry_key
-S1-RSLI-013 covered cargo test -p fparkan-rsli --offline adaptive_lzss_method_decodes_synthetic_vector
-S1-RSLI-014 covered cargo test -p fparkan-rsli --offline xor_adaptive_lzss_method_decodes_synthetic_vector
-S1-RSLI-015 covered cargo test -p fparkan-rsli --offline raw_deflate_method_expects_raw_stream_not_zlib_wrapper
-S1-RSLI-016 covered cargo test -p fparkan-rsli --offline unknown_method_is_rejected_on_load
-S1-RSLI-017 covered cargo test -p fparkan-rsli --offline decoded_size_mismatch_is_rejected
-S1-RSLI-018 covered cargo test -p fparkan-rsli --offline ao_overlay_adjusts_effective_offsets
-S1-RSLI-019 covered cargo test -p fparkan-rsli --offline invalid_ao_overlay_is_rejected
-S1-RSLI-020 covered cargo test -p fparkan-rsli --offline rejects_registered_quirks_in_strict_profile
-S1-RSLI-021 covered cargo test -p fparkan-rsli --offline named_deflate_eof_plus_one_quirk_accepts_only_approved_entry
-S1-RSLI-022 covered cargo test -p fparkan-rsli --offline unknown_header_bytes_are_lossless
-S1-RSLI-023 covered cargo test -p fparkan-rsli --offline no_op_lossless_roundtrip_preserves_bytes
-S1-RSLI-024 covered cargo test -p fparkan-rsli --offline strict_rejects_unsorted_presorted_mapping
-S1-RSLI-025 covered cargo test -p fparkan-rsli --offline editor_can_mutate_names_and_payloads set_method_rejects_unknown_authoring_method
-S1-LIMIT-002 covered cargo test -p fparkan-rsli --offline decode_rejects_entry_count_above_limit load_rejects_unpacked_size_above_limit_before_allocation
-S1-RSLI-PROP-001 covered cargo test -p fparkan-rsli --offline generated_supported_methods_decode_expected_bytes
-S1-RSLI-FUZZ-001 covered cargo test -p fparkan-rsli --offline arbitrary_small_inputs_do_not_panic
-S1-RES-001 covered cargo test -p fparkan-resource --offline cached_repository_reads_synthetic_nres
-S1-RES-002 covered cargo test -p fparkan-resource --offline entry_handles_are_archive_qualified
-S1-RES-003 covered cargo test -p fparkan-resource --offline archive_cache_and_decoded_payload_cache_evict_independently
-S1-RES-004 covered cargo test -p fparkan-resource --offline entry_read_error_carries_archive_path_and_entry_name
-S1-RES-005 covered cargo test -p fparkan-resource --offline archive_cache_evicts_by_byte_budget
-S1-RES-006 covered cargo test -p fparkan-resource --offline archive_cache_eviction_makes_old_handles_stale
-S1-RES-007 covered cargo test -p fparkan-resource --offline lossy_equivalent_archive_paths_remain_distinct
-S1-DIAG-001 covered cargo test -p fparkan-inspection --offline archive_diagnostic_preserves_source_path_phase_and_span model_archive_diagnostic_preserves_archive_entry_context
-S1-VFS-001 covered cargo test -p fparkan-vfs --offline memory_vfs_uses_exact_lookup
-S1-VFS-002 covered cargo test -p fparkan-vfs --offline overlay_vfs_uses_first_matching_layer
-S1-VFS-003 covered cargo test -p fparkan-vfs --offline directory_vfs_resolves_ascii_casefolded_segments
-S1-VFS-004 covered cargo test -p fparkan-vfs --offline casefold_selector_reports_ambiguous_segments
-S1-LICENSED-001 covered local testdata corpora via FPARKAN_CORPUS_PART1_ROOT and FPARKAN_CORPUS_PART2_ROOT; cargo test -p fparkan-nres -p fparkan-resource -p fparkan-rsli --offline -- --ignored
-S1-LICENSED-002 covered local testdata corpora via FPARKAN_CORPUS_PART1_ROOT and FPARKAN_CORPUS_PART2_ROOT; cargo test -p fparkan-nres -p fparkan-resource -p fparkan-rsli --offline -- --ignored
-L2-P1-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts
-L2-P2-UNIT-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_unit_dat_parse_counts
-L2-P1-REG-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_registry_payloads_are_record_aligned
-L2-P2-REG-001 covered cargo test -p fparkan-prototype --offline licensed_corpora_registry_payloads_are_record_aligned
-L2-P1-GRAPH-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L2-P2-GRAPH-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L2-P1-INHERIT-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L2-P2-INHERIT-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L2-P1-NONGEO-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L2-P2-NONGEO-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L2-P1-GRAPH-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L2-P2-GRAPH-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-S2-REG-001 covered cargo test -p fparkan-prototype --offline registry_zero_records_payload_is_empty
-S2-REG-002 covered cargo test -p fparkan-prototype --offline registry_requires_record_multiple
-S2-REG-003 covered cargo test -p fparkan-prototype --offline registry_preserves_bounded_name_tails_and_order
-S2-REG-004 covered cargo test -p fparkan-prototype --offline registry_preserves_bounded_name_tails_and_order
-S2-UNIT-001 covered cargo test -p fparkan-prototype --offline unit_zero_records_uses_exact_size
-S2-UNIT-002 covered cargo test -p fparkan-prototype --offline unit_dat_one_record_uses_exact_size_formula
-S2-UNIT-003 covered cargo test -p fparkan-prototype --offline unit_dat_rejects_truncated_record
-S2-UNIT-004 covered cargo test -p fparkan-prototype --offline unit_dat_preserves_header_description_tail_and_parent_link
-S2-UNIT-005 covered cargo test -p fparkan-prototype --offline unit_dat_preserves_header_description_tail_and_parent_link
-S2-UNIT-006 covered cargo test -p fparkan-prototype --offline unit_dat_accepts_full_description_without_nul
-S2-UNIT-007 covered cargo test -p fparkan-prototype --offline unit_dat_preserves_header_description_tail_and_parent_link unit_dat_preserves_positive_parent_link
-S2-PROTO-001 covered cargo test -p fparkan-prototype --offline resolves_synthetic_objects_registry_model
-S2-PROTO-002 covered cargo test -p fparkan-prototype --offline base_only_registry_entry_is_nongeometric
-S2-PROTO-003 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures
-S2-PROTO-004 covered cargo test -p fparkan-prototype --offline missing_referenced_archive_reports_root_chain
-S2-PROTO-005 covered cargo test -p fparkan-prototype --offline missing_referenced_resource_reports_root_chain
-S2-PROTO-006 covered cargo test -p fparkan-prototype --offline objects_registry_inheritance_merges_parent_then_local_refs
-S2-PROTO-007 covered cargo test -p fparkan-prototype --offline objects_registry_inheritance_resolves_multiple_levels
-S2-PROTO-008 covered cargo test -p fparkan-prototype --offline objects_registry_inheritance_rejects_direct_cycle
-S2-PROTO-009 covered cargo test -p fparkan-prototype --offline objects_registry_inheritance_rejects_indirect_cycle
-S2-PROTO-010 covered cargo test -p fparkan-prototype --offline objects_registry_inheritance_rejects_depth_limit
-S2-PROTO-011 covered cargo test -p fparkan-prototype --offline base_only_registry_entry_is_nongeometric
-S2-PROTO-012 covered cargo test -p fparkan-prototype --offline first_existing_explicit_msh_is_selected_in_order
-S2-PROTO-013 covered cargo test -p fparkan-prototype --offline invalid_referenced_msh_is_error
-S2-PROTO-014 covered cargo test -p fparkan-prototype --offline resolver_cache_invalidates_when_archive_fingerprint_changes
-S2-GRAPH-001 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures
-S2-GRAPH-002 covered cargo test -p fparkan-prototype --offline unit_dat_expands_components_in_order
-S2-GRAPH-003 covered cargo test -p fparkan-assets --offline repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests
-S2-GRAPH-004 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures
-S2-GRAPH-005 covered cargo test -p fparkan-cli --offline prototype_graph_json_has_canonical_field_order
-S2-GRAPH-006 covered cargo test -p fparkan-prototype --offline graph_report_records_resolved_roots_and_failures
-S2-GRAPH-EDGE-001 covered cargo test -p fparkan-assets --offline graph_materializes_visual_dependency_nodes_and_edges
-S2-GRAPH-PROV-001 covered cargo test -p fparkan-assets --offline graph_visual_dependency_edges_preserve_root_and_parent_provenance
-S2-GRAPH-IDEMP-001 covered cargo test -p fparkan-assets --offline graph_visual_dependency_expansion_is_idempotent
-S2-UNIT-EMPTY-001 covered cargo test -p fparkan-prototype --offline empty_unit_dat_resolves_as_zero_component_root
-S2-ASSET-MODEL-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
-S2-ASSET-WEAR-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
-S2-ASSET-MATERIAL-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
-S2-ASSET-TEXM-001 covered cargo test -p fparkan-assets --offline prepare_single_visual_mission_assets_materialize_model_wear_material_and_texture_payloads
-S2-ASSET-ID-001 covered cargo test -p fparkan-assets --offline forced_model_id_collision_is_rejected forced_wear_id_collision_is_rejected forced_material_id_collision_is_rejected forced_texture_id_collision_is_rejected
-S2-ASSET-LIMIT-001 covered cargo test -p fparkan-assets --offline profiled_asset_preparation_reports_unique_asset_counts asset_preparation_limits_reject_texture_pixel_budget
-S2-RUNTIME-DRAFT-001 covered local testdata/IS and testdata/IS2; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
-S2-CLI-JSON-001 covered cargo test -p fparkan-cli --offline prototype_graph_json_has_canonical_field_order mission_graph_json_has_canonical_field_order
-S2-LICENSED-MISSION-P1-001 covered local testdata/IS; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
-S2-LICENSED-MISSION-P2-001 covered local testdata/IS2; cargo test -p fparkan-runtime --offline -- --ignored selected_is_and_is2_missions_preserve_runtime_object_drafts
-S2-PROP-001 covered cargo test -p fparkan-prototype --offline generated_acyclic_prototype_graph_resolves_deterministically
-S2-FUZZ-001 covered cargo test -p fparkan-prototype --offline arbitrary_unit_and_registry_bytes_are_bounded_and_panic_free
-L3-P1-MSH-001 covered cargo test -p fparkan-msh --offline licensed_corpus_msh_assets_validate
-L3-P2-MSH-001 covered cargo test -p fparkan-msh --offline licensed_corpus_msh_assets_validate
-L3-P1-TEXM-001 covered cargo test -p fparkan-texm --offline licensed_corpus_texm_assets_validate_and_decode_mip0
-L3-P2-TEXM-001 covered cargo test -p fparkan-texm --offline licensed_corpus_texm_assets_validate_and_decode_mip0
-L3-P1-MAT0-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse
-L3-P2-MAT0-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse
-L3-P1-WEAR-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse
-L3-P2-WEAR-001 covered cargo test -p fparkan-material --offline licensed_corpus_mat0_and_wear_parse
-L3-P1-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L3-P2-ASSET-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-S2-PLANNING-CAPTURE-P1-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
-S2-PLANNING-CAPTURE-P2-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
-S3-WEAR-001 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index
-S3-WEAR-002 covered cargo test -p fparkan-material --offline wear_requires_declared_rows
-S3-WEAR-003 covered cargo test -p fparkan-material --offline wear_preserves_legacy_id_but_selects_by_index
-S3-WEAR-004 covered cargo test -p fparkan-material --offline wear_requires_blank_separator_before_lightmaps
-S3-WEAR-005 covered cargo test -p fparkan-material --offline wear_parses_lightmaps
-S3-MAT0-001 covered cargo test -p fparkan-material --offline mat0_version_prefix_and_primary_texture
-S3-MAT0-002 covered cargo test -p fparkan-material --offline mat0_accepts_zero_phase_material
-S3-MAT0-003 covered cargo test -p fparkan-material --offline mat0_phase34_exact_framing_and_full_texture_name
-S3-MAT0-004 covered cargo test -p fparkan-material --offline mat0_phase34_exact_framing_and_full_texture_name
-S3-MAT0-005 covered cargo test -p fparkan-material --offline mat0_animation_block_has_no_implicit_padding
-S3-MAT0-006 covered cargo test -p fparkan-material --offline mat0_rejects_animation_block_count_limit
-S3-MSH-001 covered cargo test -p fparkan-msh --offline canonical_stream_set_is_independent_of_entry_order
-S3-MSH-002 covered cargo test -p fparkan-msh --offline missing_required_stream_is_error
-S3-MSH-003 covered cargo test -p fparkan-msh --offline duplicate_required_stream_type_is_error
-S3-MSH-004 covered cargo test -p fparkan-msh --offline node38_stride_is_exact
-S3-MSH-005 covered cargo test -p fparkan-msh --offline node38_uses_three_by_five_slot_mapping_and_absent_marker
-S3-MSH-006 covered cargo test -p fparkan-msh --offline node38_uses_three_by_five_slot_mapping_and_absent_marker
-S3-MSH-007 covered cargo test -p fparkan-msh --offline type2_header_and_slot_tail_framing_are_exact
-S3-MSH-008 covered cargo test -p fparkan-msh --offline type2_header_and_slot_tail_framing_are_exact
-S3-MSH-009 covered cargo test -p fparkan-msh --offline slot_batch_range_out_of_bounds_is_error
-S3-MSH-010 covered cargo test -p fparkan-msh --offline vertex_stream_strides_are_exact
-S3-MSH-011 covered cargo test -p fparkan-msh --offline base_vertex_plus_index_must_reference_position
-S3-MSH-012 covered cargo test -p fparkan-msh --offline batch20_uses_unaligned_field_offsets
-S3-MSH-013 covered cargo test -p fparkan-msh --offline auxiliary_and_extended_streams_are_preserved
-S3-MSH-014 covered cargo test -p fparkan-msh --offline auxiliary_and_extended_streams_are_preserved
-S3-MSH-015 covered cargo test -p fparkan-msh --offline mtcheck_variant_is_preserved_and_recognized
-S3-MSH-016 covered cargo test -p fparkan-msh --offline invalid_bounds_are_rejected
-S3-MSH-017 covered cargo test -p fparkan-msh --offline slot_batch_range_out_of_bounds_is_error
-S3-MSH-PROP-001 covered cargo test -p fparkan-msh --offline canonical_stream_set_is_independent_of_entry_order
-S3-MSH-FUZZ-001 covered cargo test -p fparkan-msh --offline arbitrary_nested_payloads_are_bounded_and_panic_free
-S3-TEXM-001 covered cargo test -p fparkan-texm --offline decodes_all_synthetic_formats
-S3-TEXM-002 covered cargo test -p fparkan-texm --offline rejects_zero_dimensions
-S3-TEXM-003 covered cargo test -p fparkan-texm --offline non_power_of_two_mip_chain_clamps_each_dimension
-S3-TEXM-004 covered cargo test -p fparkan-texm --offline rejects_mip_size_arithmetic_overflow_or_oob
-S3-TEXM-005 covered cargo test -p fparkan-texm --offline indexed_palette_requires_exact_1024_bytes
-S3-TEXM-006 covered cargo test -p fparkan-texm --offline channel_expansion_boundary_values_are_stable
-S3-TEXM-007 covered cargo test -p fparkan-texm --offline rgb888x_preserves_fourth_disk_byte_but_outputs_opaque_alpha
-S3-TEXM-008 covered cargo test -p fparkan-texm --offline page_tail_absent_and_exact_rect_framing
-S3-TEXM-009 covered cargo test -p fparkan-texm --offline page_tail_absent_and_exact_rect_framing
-S3-TEXM-010 covered cargo test -p fparkan-texm --offline invalid_page_magic_size_and_trailing_bytes_are_rejected
-S3-TEXM-011 covered cargo test -p fparkan-texm --offline invalid_page_magic_size_and_trailing_bytes_are_rejected
-S3-TEXM-012 covered cargo test -p fparkan-texm --offline exposes_mip_views_and_upload_plan_without_mutating_document
-S3-TEXM-013 covered cargo test -p fparkan-texm --offline page_scaling_uses_floor_origin_and_ceil_end_policy
-S3-TEXM-FUZZ-001 covered cargo test -p fparkan-texm --offline arbitrary_texm_payloads_do_not_panic
-S3-MAT0-007 covered cargo test -p fparkan-material --offline mat0_rejects_trailing_bytes
-S3-MAT-RESOLVE-001 covered cargo test -p fparkan-material --offline resolve_material_uses_exact_match
-S3-MAT-RESOLVE-002 covered cargo test -p fparkan-material --offline resolve_material_falls_back_to_default
-S3-MAT-RESOLVE-003 covered cargo test -p fparkan-material --offline resolve_material_uses_first_entry_only_after_missing_default
-S3-MAT-RESOLVE-004 covered cargo test -p fparkan-material --offline resolve_material_empty_texture_means_untextured
-S3-MAT-RESOLVE-005 covered cargo test -p fparkan-material --offline resolve_material_without_lightmap_keeps_lightmap_absent
-S2-PLANNING-RENDER-001 covered cargo test -p fparkan-render --offline one_snapshot_draw_produces_one_draw_command
-S2-PLANNING-RENDER-002 covered cargo test -p fparkan-render --offline material_index_maps_through_resolved_material_slots
-S2-PLANNING-RENDER-003 covered cargo test -p fparkan-render --offline node_transform_is_retained
-S2-PLANNING-RENDER-004 covered cargo test -p fparkan-render --offline command_order_uses_phase_then_stable_key
-S2-PLANNING-RENDER-005 covered cargo test -p fparkan-render --offline command_capture_independent_of_snapshot_construction_order
-S2-PLANNING-RENDER-006 covered cargo test -p fparkan-render --offline invalid_range_returns_contextual_error
-S2-PLANNING-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable
-S2-PLANNING-RENDER-008 covered-planning cargo test -p fparkan-render --offline recording_backend_stores_captures
-S2-PLANNING-RENDER-009 covered cargo xtask policy
-S3-VK-MESH-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH; original MSH reports 128 vertices and 252 indices
-S3-VK-TEXM-UPLOAD-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-texm-upload.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; original TEXM 16x16 decoded RGBA8, staged into device-local image and transitioned to SHADER_READ_ONLY_OPTIMAL
-S3-VK-DESCRIPTOR-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-texm-sampled.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; original TEXM is bound as set=0,binding=0 combined image sampler and sampled in fragment shader; validation_warning_count=0 and validation_error_count=0
-S3-VK-PIPELINE-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-alpha-test-smoke.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; PipelineKey selects a deduplicated live Vulkan pipeline per static draw range; capability-selected depth attachment supports TestWrite/TestReadOnly; fragment alpha test receives per-range dynamic cutoff through push constants; baseline key is live-proven, while Batch20/MAT0 state/reference mapping remains an evidence gap
-S3-VK-DRAW-MODEL-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-fr-l-mtp-per-batch-materials.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-root <GOG_ROOT> --wear-archive fortif.rlb --wear-name FR_L_MTP.WEA; live Win32 Vulkan issues 237 source-preserving indexed batch draws and binds the deduplicated WEAR→MAT0 material descriptor selected by each Batch20.material_index (this corpus model has selector 0 only)
-S3-VK-DRAW-TERRAIN-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-land-11-smoke.json --frames 300 --resize-frame 120 --timeout-seconds 90 --terrain-path <GOG_ROOT>/DATA/MAPS/11/Land.msh --texture-root <GOG_ROOT> --texture-archive Textures.lib --texture-name DEFAULT.0; validated Land.msh TerrainFace28 order becomes one live indexed Vulkan terrain draw (6458 vertices, 24672 indices); terrain material/slot/camera semantics remain an evidence gap
-S3-VK-PIXEL-CAPTURE-001 blocked awaits Stage 3 fixed-camera pixel capture approval flow
-S3-VK-VALIDATION-001 covered-gpu cargo run -p fparkan-vulkan-smoke --release --locked -- --out target/fparkan/stage3-mtcheck-msh.json --frames 300 --resize-frame 120 --timeout-seconds 90 --model-root <GOG_ROOT> --model-archive system.rlb --model-name MTCHECK.MSH; validation_warning_count=0 and validation_error_count=0
-S3-GL-001 omitted legacy OpenGL adapters are outside the Windows-only Vulkan renderer scope
-S3-GL-002 omitted GLES2 and portable-device targets are outside the Windows-only Vulkan renderer scope
-S3-GL-003 blocked legacy fparkan-render-gl adapter removed while Vulkan renderer path is being brought in as the stage-3 backend
-S3-VIEWER-001 covered cargo test -p fparkan-viewer --offline model_fixture_uses_viewer_service_and_render_commands
-S4-ANIM-001 covered cargo test -p fparkan-animation --offline anim_key24_decodes_signed_quaternion
-S4-ANIM-002 covered cargo test -p fparkan-animation --offline frame_map_decodes_u16_and_uses_attr_frame_count
-S4-ANIM-003 covered cargo test -p fparkan-animation --offline frame_map_falls_back_when_absent_or_invalid
-S4-ANIM-004 covered cargo test -p fparkan-animation --offline frame_map_falls_back_when_absent_or_invalid
-S4-ANIM-005 covered cargo test -p fparkan-animation --offline exact_key_time_returns_exact_pose
-S4-ANIM-006 covered cargo test -p fparkan-animation --offline pose_track_blends_translation_and_rotation
-S4-ANIM-007 covered cargo test -p fparkan-animation --offline quaternion_shortest_path_sign_flip_is_stable
-S4-ANIM-008 covered cargo test -p fparkan-animation --offline zero_or_degenerate_key_interval_is_rejected
-S4-ANIM-009 omitted current sampler is a portable reference path; runtime-captured x87 parity vectors are not implemented
-S4-ANIM-010 omitted current sampler accepts the profile marker but does not implement an independent x87 compatibility path
-S4-ANIM-011 covered cargo test -p fparkan-animation --offline blend_optional_pose_uses_valid_side
-S4-ANIM-012 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
-S4-ANIM-013 covered cargo test -p fparkan-animation --offline hierarchy_evaluates_parent_before_child_and_rejects_cycles
-S4-ANIM-014 covered cargo test -p fparkan-animation --offline pose_capture_uses_float_bits
-S4-ANIM-PROP-001 covered cargo test -p fparkan-animation --offline generated_valid_quaternions_remain_finite
-S4-MAT-001 covered cargo test -p fparkan-material --offline material_modes_zero_to_three_choose_stable_phases
-S4-MAT-002 covered cargo test -p fparkan-material --offline material_exact_key_boundary_selects_exact_phase
-S4-MAT-003 covered cargo test -p fparkan-material --offline material_interpolation_mask_affects_only_selected_fields
-S4-MAT-004 covered cargo test -p fparkan-material --offline material_timeline_profile_cases_are_evidence_labeled
-S4-MAT-005 covered cargo test -p fparkan-material --offline material_random_offset_uses_material_stream_only
-S4-MAT-006 covered cargo test -p fparkan-material --offline material_same_seed_and_timeline_produces_same_phase_capture
-S4-FX-001 covered cargo test -p fparkan-fx --offline header_is_exactly_sixty_bytes_and_command_sizes_are_fixed
-S4-FX-002 covered cargo test -p fparkan-fx --offline header_is_exactly_sixty_bytes_and_command_sizes_are_fixed
-S4-FX-011 covered cargo test -p fparkan-fx --offline header_is_exactly_sixty_bytes_and_command_sizes_are_fixed
-S4-FX-012 covered cargo test -p fparkan-fx --offline opcode6_four_byte_command_is_accepted
-S4-FX-013 covered cargo test -p fparkan-fx --offline rejects_unknown_opcode_at_command_index
-S4-FX-014 covered cargo test -p fparkan-fx --offline rejects_command_count_that_exceeds_payload
-S4-FX-015 covered cargo test -p fparkan-fx --offline rejects_trailing_bytes_after_command_stream
-S4-FX-016 covered cargo test -p fparkan-fx --offline fixed_resource_refs_preserve_tails
-S4-FX-017 covered cargo test -p fparkan-fx --offline missing_dependency_error_contains_effect_command_archive_and_name
-S4-FX-018 omitted FX create/update/emit is documented as a deterministic reference stub until runtime-captured lifecycle semantics are implemented
-S4-FX-019 omitted FX update side-effect contract is stub-level only and not accepted as original runtime parity evidence
-S4-FX-020 omitted FX emit side-effect contract is stub-level only and not accepted as original runtime parity evidence
-S4-FX-021 omitted FX command order is deterministic for the stub but original runtime ordering evidence is not implemented
-S4-FX-022 omitted FX lifecycle controls are deterministic for the stub but original stop/restart/end semantics are not implemented
-S4-FX-023 omitted FX emission capture is deterministic for the stub but lacks runtime-captured parity evidence
-S4-FX-024 omitted FX RNG accounting is not implemented beyond the reference stub and must not count as compatibility coverage
-S4-FX-FUZZ-001 covered cargo test -p fparkan-fx --offline arbitrary_command_streams_are_bounded_and_panic_free
-L4-P1-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures
-L4-P2-ANIM-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures
-L4-P1-CAPTURE-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures
-L4-P2-CAPTURE-001 covered cargo test -p fparkan-msh --offline licensed_corpus_animation_streams_sample_approved_pose_captures
-L4-P1-FX-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
-L4-P2-FX-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
-L4-P1-FX-002 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
-L4-P2-FX-002 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
-L4-FX-OP6-001 covered cargo test -p fparkan-fx --offline licensed_corpus_fxid_exact_eof_and_distribution
-L4-P1-EFFECT-001 omitted licensed FX emission capture currently covers deterministic reference-stub output, not original runtime-captured effect parity
-L4-P2-EFFECT-001 omitted licensed FX emission capture currently covers deterministic reference-stub output, not original runtime-captured effect parity
-S5-LMESH-001 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked
-S5-LMESH-002 covered cargo test -p fparkan-terrain-format --offline land_msh_required_streams_are_order_independent_and_stride_checked
-S5-LMESH-003 covered cargo test -p fparkan-terrain-format --offline decodes_minimal_land_msh
-S5-LMESH-004 covered cargo test -p fparkan-terrain-format --offline decodes_minimal_land_msh
-S5-LMESH-005 covered cargo test -p fparkan-terrain-format --offline rejects_invalid_vertex_index
-S5-LMESH-006 covered cargo test -p fparkan-terrain-format --offline rejects_invalid_neighbor_index
-S5-LMESH-007 covered cargo test -p fparkan-terrain-format --offline face_layout_preserves_tail_and_all_surface_mask_mappings_are_explicit
-S5-LMESH-008 covered cargo test -p fparkan-terrain-format --offline face_layout_preserves_tail_and_all_surface_mask_mappings_are_explicit
-S5-LMESH-009 covered cargo test -p fparkan-terrain-format --offline face_layout_preserves_tail_and_all_surface_mask_mappings_are_explicit
-S5-LMAP-001 covered cargo test -p fparkan-terrain-format --offline decodes_minimal_land_map
-S5-LMAP-002 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof
-S5-LMAP-003 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof
-S5-LMAP-004 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof
-S5-LMAP-005 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof
-S5-LMAP-006 covered cargo test -p fparkan-terrain-format --offline rejects_invalid_areal_link
-S5-LMAP-007 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof
-S5-LMAP-008 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof
-S5-LMAP-009 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof
-S5-LMAP-010 covered cargo test -p fparkan-terrain-format --offline rejects_invalid_grid_area_ref
-S5-LMAP-011 covered cargo test -p fparkan-terrain-format --offline land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof
-S5-TERRAIN-001 covered cargo test -p fparkan-terrain --offline locates_areal_and_routes_synthetic_neighbors
-S5-TERRAIN-002 covered cargo test -p fparkan-terrain --offline locates_areal_and_routes_synthetic_neighbors
-S5-TERRAIN-003 covered cargo test -p fparkan-terrain --offline missing_start_or_goal_returns_no_route
-S5-TERRAIN-004 covered cargo test -p fparkan-terrain --offline synthetic_surface_height_and_raycast_work
-S5-TMA-001 covered cargo test -p fparkan-mission-format --offline minimal_synthetic_exact_eof
-S5-TMA-002 covered cargo test -p fparkan-mission-format --offline lp_string_does_not_consume_implicit_nul
-S5-TMA-003 covered cargo test -p fparkan-mission-format --offline path_ids_retain_nonsequential_order_and_truncated_points_fail
-S5-TMA-004 covered cargo test -p fparkan-mission-format --offline path_ids_retain_nonsequential_order_and_truncated_points_fail
-S5-TMA-005 covered cargo test -p fparkan-mission-format --offline clan_modes_one_to_three_and_spatial_mode_zero_decode
-S5-TMA-006 covered cargo test -p fparkan-mission-format --offline clan_modes_one_to_three_and_spatial_mode_zero_decode
-S5-TMA-007 covered cargo test -p fparkan-mission-format --offline unknown_clan_mode_nonfinite_transform_and_trailing_bytes_are_rejected
-S5-TMA-008 covered cargo test -p fparkan-mission-format --offline synthetic_standard_clan_and_object_preserve_ordered_properties
-S5-TMA-009 covered cargo test -p fparkan-mission-format --offline synthetic_standard_clan_and_object_preserve_ordered_properties
-S5-TMA-010 covered cargo test -p fparkan-mission-format --offline synthetic_standard_clan_and_object_preserve_ordered_properties
-S5-TMA-011 covered cargo test -p fparkan-mission-format --offline unknown_clan_mode_nonfinite_transform_and_trailing_bytes_are_rejected
-S5-TMA-012 covered cargo test -p fparkan-mission-format --offline description_and_extras_are_exact_raw_records
-S5-TMA-013 covered cargo test -p fparkan-mission-format --offline description_and_extras_are_exact_raw_records
-S5-TMA-014 covered cargo test -p fparkan-mission-format --offline description_and_extras_are_exact_raw_records
-S5-TMA-015 covered cargo test -p fparkan-mission-format --offline unknown_clan_mode_nonfinite_transform_and_trailing_bytes_are_rejected
-S5-TMA-016 covered cargo test -p fparkan-mission-format --offline signatures_inside_strings_do_not_create_records_and_truncations_are_bounded
-S5-TMA-017 covered cargo test -p fparkan-mission-format --offline signatures_inside_strings_do_not_create_records_and_truncations_are_bounded
-S5-TMA-PROP-001 covered cargo test -p fparkan-mission-format --offline generated_valid_documents_and_arbitrary_inputs_are_bounded
-S5-TMA-FUZZ-001 covered cargo test -p fparkan-mission-format --offline generated_valid_documents_and_arbitrary_inputs_are_bounded
-S5-LOAD-001 covered cargo test -p fparkan-runtime --offline load_trace_records_preparation_before_registration_and_raw_transforms
-S5-LOAD-002 covered cargo test -p fparkan-runtime --offline missing_map_and_missing_reachable_resource_fail_before_registration
-S5-LOAD-003 covered cargo test -p fparkan-runtime --offline missing_map_and_missing_reachable_resource_fail_before_registration
-S5-LOAD-004 covered cargo test -p fparkan-material --offline resolve_material_falls_back_to_default
-S5-LOAD-005 covered cargo test -p fparkan-runtime --offline load_trace_records_preparation_before_registration_and_raw_transforms
-S5-LOAD-006 covered cargo test -p fparkan-runtime --offline load_mission_requires_vfs_and_keeps_world_unchanged_on_error
-S5-LOAD-007 covered cargo test -p fparkan-runtime --offline registration_phase_failure_uses_normal_teardown_and_keeps_engine_world
-S5-LOAD-008 covered cargo test -p fparkan-runtime --offline load_trace_records_preparation_before_registration_and_raw_transforms
-S5-LOAD-009 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-S5-LOAD-010 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-S5-WORLD-001 covered cargo test -p fparkan-world --offline construct_register_and_hash_are_stable
-S5-WORLD-002 covered cargo test -p fparkan-world --offline registration_sequence_stale_and_duplicate_original_contracts
-S5-WORLD-003 covered cargo test -p fparkan-world --offline registration_sequence_stale_and_duplicate_original_contracts
-S5-WORLD-004 covered cargo test -p fparkan-world --offline registration_sequence_stale_and_duplicate_original_contracts
-S5-WORLD-005 covered cargo test -p fparkan-world --offline identity_metadata_keeps_original_mirror_and_owner_distinct
-S5-WORLD-006 covered cargo test -p fparkan-world --offline command_fifo_and_deferred_delete_during_calculation
-S5-WORLD-007 covered cargo test -p fparkan-world --offline registration_sequence_stale_and_duplicate_original_contracts
-S5-WORLD-008 covered cargo test -p fparkan-world --offline command_fifo_and_deferred_delete_during_calculation
-S5-WORLD-009 covered cargo test -p fparkan-world --offline command_fifo_and_deferred_delete_during_calculation
-S5-WORLD-010 covered cargo test -p fparkan-world --offline command_fifo_and_deferred_delete_during_calculation
-S5-WORLD-011 covered cargo test -p fparkan-world --offline command_fifo_and_deferred_delete_during_calculation
-S5-WORLD-012 covered cargo test -p fparkan-world --offline fixed_step_pause_and_long_determinism_are_stable
-S5-WORLD-013 covered cargo test -p fparkan-world --offline fixed_step_pause_and_long_determinism_are_stable
-S5-WORLD-014 covered cargo test -p fparkan-world --offline snapshot_hash_determinism_and_immutability
-S5-WORLD-015 covered cargo test -p fparkan-world --offline snapshot_hash_determinism_and_immutability
-S5-WORLD-016 covered cargo test -p fparkan-world --offline fixed_step_pause_and_long_determinism_are_stable
-S5-WORLD-017 covered cargo test -p fparkan-world --offline render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order
-S5-WORLD-018 covered cargo test -p fparkan-world --offline render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order
-S5-WORLD-019 covered cargo test -p fparkan-world --offline render_disabled_does_not_change_hash_end_callbacks_and_shutdown_order
-S5-WORLD-PROP-001 covered cargo test -p fparkan-world --offline generated_command_delete_sequences_preserve_registry_invariants
-L5-P1-LMESH-001 covered cargo test -p fparkan-terrain-format --offline licensed_corpus_land_msh_validate
-L5-P2-LMESH-001 covered cargo test -p fparkan-terrain-format --offline licensed_corpus_land_msh_validate
-L5-P1-LMAP-001 covered cargo test -p fparkan-terrain-format --offline licensed_corpus_land_map_validate
-L5-P2-LMAP-001 covered cargo test -p fparkan-terrain-format --offline licensed_corpus_land_map_validate
-L5-LMAP-POLY-001 covered cargo test -p fparkan-terrain-format --offline licensed_corpus_land_map_validate land_map_prefix_absent_links_polygon_blocks_grid_size_and_exact_eof
-L5-P1-TMA-001 covered cargo test -p fparkan-mission-format --offline licensed_corpus_tma_validate
-L5-P2-TMA-001 covered cargo test -p fparkan-mission-format --offline licensed_corpus_tma_validate
-L5-P1-MISSION-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L5-P2-MISSION-001 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L5-P1-MISSION-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L5-P2-MISSION-002 covered cargo test -p fparkan-runtime --offline licensed_corpora_load_all_mission_foundations
-L5-P1-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks
-L5-P2-HEADLESS-001 covered cargo test -p fparkan-runtime --offline selected_is_and_is2_missions_execute_10000_deterministic_ticks
-L5-P1-RENDER-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
-L5-P2-RENDER-001 covered-planning cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
-L3-DEVICE-001 omitted RG40XX and portable-device profiles are outside the Windows-only runtime scope
-L5-RG40-001 omitted RG40XX 640x480 on-device mission smoke/performance/memory evidence is outside the Windows-only runtime scope
diff --git a/fixtures/acceptance/macos-gl33-triangle-capture.json b/fixtures/acceptance/macos-gl33-triangle-capture.json
deleted file mode 100644
index 6107dc2..0000000
--- a/fixtures/acceptance/macos-gl33-triangle-capture.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "schema_version": "fparkan-macos-gl33-triangle-capture-v1",
- "acceptance_id": "S3-GL-001",
- "platform_scope": "macos",
- "profile": "DesktopCore33",
- "probe": "temporary rustc CGL/OpenGL offscreen FBO triangle capture",
- "workspace_source_unsafe_free": true,
- "requested_cgl_profile": "3.2-core-or-newer",
- "version": "4.1 APPLE-23.1.1",
- "renderer": "Apple Software Renderer",
- "width": 64,
- "height": 64,
- "magenta_pixels": 1352,
- "black_pixels": 2744,
- "center_rgba": [255, 0, 255, 255],
- "fnv1a64": "060aba78d7123f25"
-}
diff --git a/fixtures/acceptance/stage_0_2_roadmap.md b/fixtures/acceptance/stage_0_2_roadmap.md
deleted file mode 100644
index 80e4bf8..0000000
--- a/fixtures/acceptance/stage_0_2_roadmap.md
+++ /dev/null
@@ -1,432 +0,0 @@
-# Stage 0-2 acceptance IDs
-
-`L0-COPYRIGHT-001`
-`L0-P1-001`
-`L0-P1-002`
-`L0-P2-001`
-`L0-P2-002`
-`S0-ARCH-001`
-`S0-ARCH-002`
-`S0-ARCH-003`
-`S0-ARCH-004`
-`S0-ARCH-005`
-`S0-ARCH-006`
-`S0-ARCH-007`
-`S0-ARCH-008`
-`S0-ARCH-009`
-`S0-ARCH-010`
-`S0-ARCH-011`
-`S0-DIAG-001`
-`S0-DIAG-002`
-`S0-CORPUS-001`
-`S0-CORPUS-002`
-`S0-CORPUS-003`
-`S0-CORPUS-004`
-`S0-CORPUS-005`
-`S0-CORPUS-006`
-`S0-CLI-001`
-`S0-CLI-002`
-`S0-PLAT-001`
-`S0-PLAT-002`
-`S0-PLAT-003`
-`S0-PLAT-004`
-`S0-VK-001`
-`S0-VK-002`
-`S0-VK-003`
-`S0-VK-004`
-`S0-VK-005`
-`S0-VK-006`
-`S0-VK-007`
-`S0-VK-008`
-`S0-VK-009`
-`S0-VK-010`
-`S0-VK-011`
-`S0-VK-012`
-`S0-VK-013`
-`S0-VK-014`
-`S0-VK-015`
-`S0-VK-016`
-`S0-VK-017`
-`S0-VK-018`
-`S0-VK-019`
-`S0-VK-020`
-`S0-VK-021`
-`S0-VK-022`
-`S0-VK-023`
-`S0-VK-024`
-`S0-VK-025`
-`S0-VK-026`
-`S0-VK-027`
-`S0-VK-028`
-`S0-VK-029`
-`S0-VK-030`
-`S0-VK-031`
-`S0-VK-032`
-`S0-VK-033`
-`S0-VK-034`
-`S0-VK-035`
-`S0-LIMIT-001`
-`S0-LIMIT-002`
-`L1-P1-NRES-001`
-`L1-P2-NRES-001`
-`L1-P1-NRES-002`
-`L1-P2-NRES-002`
-`L1-P1-NRES-003`
-`L1-P2-NRES-003`
-`L1-P1-RSLI-001`
-`L1-P2-RSLI-001`
-`L1-RSLI-QUIRK-001`
-`L1-P1-PATH-001`
-`L1-P2-PATH-001`
-`S1-NRES-001`
-`S1-NRES-002`
-`S1-NRES-003`
-`S1-NRES-004`
-`S1-NRES-005`
-`S1-NRES-006`
-`S1-NRES-007`
-`S1-NRES-008`
-`S1-NRES-009`
-`S1-NRES-010`
-`S1-NRES-020`
-`S1-NRES-021`
-`S1-NRES-011`
-`S1-NRES-012`
-`S1-NRES-013`
-`S1-NRES-014`
-`S1-NRES-015`
-`S1-NRES-016`
-`S1-NRES-017`
-`S1-NRES-018`
-`S1-NRES-019`
-`S1-NRES-022`
-`S1-NRES-023`
-`S1-NRES-024`
-`S1-NRES-025`
-`S1-NRES-PROP-001`
-`S1-NRES-PROP-002`
-`S1-NRES-FUZZ-001`
-`S1-PATH-001`
-`S1-PATH-002`
-`S1-PATH-003`
-`S1-PATH-004`
-`S1-PATH-005`
-`S1-PATH-006`
-`S1-PATH-007`
-`S1-PATH-008`
-`S1-PATH-009`
-`S1-VFS-005`
-`S1-RSLI-001`
-`S1-RSLI-002`
-`S1-RSLI-003`
-`S1-RSLI-004`
-`S1-RSLI-005`
-`S1-RSLI-006`
-`S1-RSLI-007`
-`S1-RSLI-008`
-`S1-RSLI-009`
-`S1-RSLI-010`
-`S1-RSLI-011`
-`S1-RSLI-012`
-`S1-RSLI-013`
-`S1-RSLI-014`
-`S1-RSLI-015`
-`S1-RSLI-016`
-`S1-RSLI-017`
-`S1-RSLI-018`
-`S1-RSLI-019`
-`S1-RSLI-020`
-`S1-RSLI-021`
-`S1-RSLI-022`
-`S1-RSLI-023`
-`S1-RSLI-PROP-001`
-`S1-RSLI-FUZZ-001`
-`S1-RES-001`
-`S1-RES-002`
-`S1-RES-003`
-`S1-RES-004`
-`S1-VFS-001`
-`S1-VFS-002`
-`S1-VFS-003`
-`S1-VFS-004`
-`L2-P1-UNIT-001`
-`L2-P2-UNIT-001`
-`L2-P1-REG-001`
-`L2-P2-REG-001`
-`L2-P1-GRAPH-001`
-`L2-P2-GRAPH-001`
-`L2-P1-INHERIT-001`
-`L2-P2-INHERIT-001`
-`L2-P1-NONGEO-001`
-`L2-P2-NONGEO-001`
-`L2-P1-GRAPH-002`
-`L2-P2-GRAPH-002`
-`S2-REG-001`
-`S2-REG-002`
-`S2-REG-003`
-`S2-REG-004`
-`S2-UNIT-001`
-`S2-UNIT-002`
-`S2-UNIT-003`
-`S2-UNIT-004`
-`S2-UNIT-005`
-`S2-UNIT-006`
-`S2-UNIT-007`
-`S2-PROTO-001`
-`S2-PROTO-002`
-`S2-PROTO-003`
-`S2-PROTO-004`
-`S2-PROTO-005`
-`S2-PROTO-006`
-`S2-PROTO-007`
-`S2-PROTO-008`
-`S2-PROTO-009`
-`S2-PROTO-010`
-`S2-PROTO-011`
-`S2-PROTO-012`
-`S2-PROTO-013`
-`S2-PROTO-014`
-`S2-GRAPH-001`
-`S2-GRAPH-002`
-`S2-GRAPH-003`
-`S2-GRAPH-004`
-`S2-GRAPH-005`
-`S2-GRAPH-006`
-`S2-GRAPH-EDGE-001`
-`S2-GRAPH-PROV-001`
-`S2-GRAPH-IDEMP-001`
-`S2-UNIT-EMPTY-001`
-`S2-ASSET-MODEL-001`
-`S2-ASSET-WEAR-001`
-`S2-ASSET-MATERIAL-001`
-`S2-ASSET-TEXM-001`
-`S2-ASSET-ID-001`
-`S2-ASSET-LIMIT-001`
-`S2-RUNTIME-DRAFT-001`
-`S2-CLI-JSON-001`
-`S2-LICENSED-MISSION-P1-001`
-`S2-LICENSED-MISSION-P2-001`
-`S2-PROP-001`
-`S2-FUZZ-001`
-`L3-P1-MSH-001`
-`L3-P2-MSH-001`
-`L3-P1-TEXM-001`
-`L3-P2-TEXM-001`
-`L3-P1-MAT0-001`
-`L3-P2-MAT0-001`
-`L3-P1-WEAR-001`
-`L3-P2-WEAR-001`
-`L3-P1-ASSET-001`
-`L3-P2-ASSET-001`
-`S2-PLANNING-CAPTURE-P1-001`
-`S2-PLANNING-CAPTURE-P2-001`
-`S3-WEAR-001`
-`S3-WEAR-002`
-`S3-WEAR-003`
-`S3-WEAR-004`
-`S3-WEAR-005`
-`S3-MAT0-001`
-`S3-MAT0-002`
-`S3-MAT0-003`
-`S3-MAT0-004`
-`S3-MAT0-005`
-`S3-MAT0-006`
-`S3-MSH-001`
-`S3-MSH-002`
-`S3-MSH-003`
-`S3-MSH-004`
-`S3-MSH-005`
-`S3-MSH-006`
-`S3-MSH-007`
-`S3-MSH-008`
-`S3-MSH-009`
-`S3-MSH-010`
-`S3-MSH-011`
-`S3-MSH-012`
-`S3-MSH-013`
-`S3-MSH-014`
-`S3-MSH-015`
-`S3-MSH-016`
-`S3-MSH-017`
-`S3-MSH-PROP-001`
-`S3-MSH-FUZZ-001`
-`S3-TEXM-001`
-`S3-TEXM-002`
-`S3-TEXM-003`
-`S3-TEXM-004`
-`S3-TEXM-005`
-`S3-TEXM-006`
-`S3-TEXM-007`
-`S3-TEXM-008`
-`S3-TEXM-009`
-`S3-TEXM-010`
-`S3-TEXM-011`
-`S3-TEXM-012`
-`S3-TEXM-013`
-`S3-TEXM-FUZZ-001`
-`S3-MAT0-007`
-`S3-MAT-RESOLVE-001`
-`S3-MAT-RESOLVE-002`
-`S3-MAT-RESOLVE-003`
-`S3-MAT-RESOLVE-004`
-`S3-MAT-RESOLVE-005`
-`S2-PLANNING-RENDER-001`
-`S2-PLANNING-RENDER-002`
-`S2-PLANNING-RENDER-003`
-`S2-PLANNING-RENDER-004`
-`S2-PLANNING-RENDER-005`
-`S2-PLANNING-RENDER-006`
-`S2-PLANNING-RENDER-007`
-`S2-PLANNING-RENDER-008`
-`S2-PLANNING-RENDER-009`
-`S3-VK-MESH-UPLOAD-001`
-`S3-VK-TEXM-UPLOAD-001`
-`S3-VK-DESCRIPTOR-001`
-`S3-VK-PIPELINE-001`
-`S3-VK-DRAW-MODEL-001`
-`S3-VK-DRAW-TERRAIN-001`
-`S3-VK-PIXEL-CAPTURE-001`
-`S3-VK-VALIDATION-001`
-`S3-GL-001`
-`S3-GL-002`
-`S3-GL-003`
-`S3-VIEWER-001`
-`S4-ANIM-001`
-`S4-ANIM-002`
-`S4-ANIM-003`
-`S4-ANIM-004`
-`S4-ANIM-005`
-`S4-ANIM-006`
-`S4-ANIM-007`
-`S4-ANIM-008`
-`S4-ANIM-009`
-`S4-ANIM-010`
-`S4-ANIM-011`
-`S4-ANIM-012`
-`S4-ANIM-013`
-`S4-ANIM-014`
-`S4-ANIM-PROP-001`
-`S4-MAT-001`
-`S4-MAT-002`
-`S4-MAT-003`
-`S4-MAT-004`
-`S4-MAT-005`
-`S4-MAT-006`
-`S4-FX-001`
-`S4-FX-002`
-`S4-FX-011`
-`S4-FX-012`
-`S4-FX-013`
-`S4-FX-014`
-`S4-FX-015`
-`S4-FX-016`
-`S4-FX-017`
-`S4-FX-018`
-`S4-FX-019`
-`S4-FX-020`
-`S4-FX-021`
-`S4-FX-022`
-`S4-FX-023`
-`S4-FX-024`
-`S4-FX-FUZZ-001`
-`L4-P1-ANIM-001`
-`L4-P2-ANIM-001`
-`L4-P1-CAPTURE-001`
-`L4-P2-CAPTURE-001`
-`L4-P1-FX-001`
-`L4-P2-FX-001`
-`L4-P1-FX-002`
-`L4-P2-FX-002`
-`L4-FX-OP6-001`
-`L4-P1-EFFECT-001`
-`L4-P2-EFFECT-001`
-`S5-LMESH-001`
-`S5-LMESH-002`
-`S5-LMESH-003`
-`S5-LMESH-004`
-`S5-LMESH-005`
-`S5-LMESH-006`
-`S5-LMESH-007`
-`S5-LMESH-008`
-`S5-LMESH-009`
-`S5-LMAP-001`
-`S5-LMAP-002`
-`S5-LMAP-003`
-`S5-LMAP-004`
-`S5-LMAP-005`
-`S5-LMAP-006`
-`S5-LMAP-007`
-`S5-LMAP-008`
-`S5-LMAP-009`
-`S5-LMAP-010`
-`S5-LMAP-011`
-`S5-TERRAIN-001`
-`S5-TERRAIN-002`
-`S5-TERRAIN-003`
-`S5-TERRAIN-004`
-`S5-TMA-001`
-`S5-TMA-002`
-`S5-TMA-003`
-`S5-TMA-004`
-`S5-TMA-005`
-`S5-TMA-006`
-`S5-TMA-007`
-`S5-TMA-008`
-`S5-TMA-009`
-`S5-TMA-010`
-`S5-TMA-011`
-`S5-TMA-012`
-`S5-TMA-013`
-`S5-TMA-014`
-`S5-TMA-015`
-`S5-TMA-016`
-`S5-TMA-017`
-`S5-TMA-PROP-001`
-`S5-TMA-FUZZ-001`
-`S5-LOAD-001`
-`S5-LOAD-002`
-`S5-LOAD-003`
-`S5-LOAD-004`
-`S5-LOAD-005`
-`S5-LOAD-006`
-`S5-LOAD-007`
-`S5-LOAD-008`
-`S5-LOAD-009`
-`S5-LOAD-010`
-`S5-WORLD-001`
-`S5-WORLD-002`
-`S5-WORLD-003`
-`S5-WORLD-004`
-`S5-WORLD-005`
-`S5-WORLD-006`
-`S5-WORLD-007`
-`S5-WORLD-008`
-`S5-WORLD-009`
-`S5-WORLD-010`
-`S5-WORLD-011`
-`S5-WORLD-012`
-`S5-WORLD-013`
-`S5-WORLD-014`
-`S5-WORLD-015`
-`S5-WORLD-016`
-`S5-WORLD-017`
-`S5-WORLD-018`
-`S5-WORLD-019`
-`S5-WORLD-PROP-001`
-`L5-P1-LMESH-001`
-`L5-P2-LMESH-001`
-`L5-P1-LMAP-001`
-`L5-P2-LMAP-001`
-`L5-LMAP-POLY-001`
-`L5-P1-TMA-001`
-`L5-P2-TMA-001`
-`L5-P1-MISSION-001`
-`L5-P2-MISSION-001`
-`L5-P1-MISSION-002`
-`L5-P2-MISSION-002`
-`L5-P1-HEADLESS-001`
-`L5-P2-HEADLESS-001`
-`L5-P1-RENDER-001`
-`L5-P2-RENDER-001`
-`L3-DEVICE-001`
-`L5-RG40-001`
diff --git a/fixtures/acceptance/stage_0_roadmap.md b/fixtures/acceptance/stage_0_roadmap.md
deleted file mode 100644
index 2ebd7d4..0000000
--- a/fixtures/acceptance/stage_0_roadmap.md
+++ /dev/null
@@ -1,69 +0,0 @@
-# Stage 0 acceptance IDs
-
-`L0-COPYRIGHT-001`
-`L0-P1-001`
-`L0-P1-002`
-`L0-P2-001`
-`L0-P2-002`
-`S0-ARCH-001`
-`S0-ARCH-002`
-`S0-ARCH-003`
-`S0-ARCH-004`
-`S0-ARCH-005`
-`S0-ARCH-006`
-`S0-ARCH-007`
-`S0-ARCH-008`
-`S0-ARCH-009`
-`S0-ARCH-010`
-`S0-ARCH-011`
-`S0-DIAG-001`
-`S0-DIAG-002`
-`S0-CORPUS-001`
-`S0-CORPUS-002`
-`S0-CORPUS-003`
-`S0-CORPUS-004`
-`S0-CORPUS-005`
-`S0-CORPUS-006`
-`S0-CLI-001`
-`S0-CLI-002`
-`S0-PLAT-001`
-`S0-PLAT-002`
-`S0-PLAT-003`
-`S0-PLAT-004`
-`S0-VK-001`
-`S0-VK-002`
-`S0-VK-003`
-`S0-VK-004`
-`S0-VK-005`
-`S0-VK-006`
-`S0-VK-007`
-`S0-VK-008`
-`S0-VK-009`
-`S0-VK-010`
-`S0-VK-011`
-`S0-VK-012`
-`S0-VK-013`
-`S0-VK-014`
-`S0-VK-015`
-`S0-VK-016`
-`S0-VK-017`
-`S0-VK-018`
-`S0-VK-019`
-`S0-VK-020`
-`S0-VK-021`
-`S0-VK-022`
-`S0-VK-023`
-`S0-VK-024`
-`S0-VK-025`
-`S0-VK-026`
-`S0-VK-027`
-`S0-VK-028`
-`S0-VK-029`
-`S0-VK-030`
-`S0-VK-031`
-`S0-VK-032`
-`S0-VK-033`
-`S0-VK-034`
-`S0-VK-035`
-`S0-LIMIT-001`
-`S0-LIMIT-002`
diff --git a/fixtures/acceptance/stage_packages.toml b/fixtures/acceptance/stage_packages.toml
deleted file mode 100644
index 5b0845c..0000000
--- a/fixtures/acceptance/stage_packages.toml
+++ /dev/null
@@ -1,48 +0,0 @@
-schema = 1
-
-[stages]
-"0" = [
- "fparkan-binary",
- "fparkan-corpus",
- "fparkan-diagnostics",
- "fparkan-platform",
- "fparkan-platform-winit",
- "fparkan-render",
- "fparkan-render-vulkan",
- "fparkan-test-support",
- "fparkan-vulkan-smoke",
- "xtask",
-]
-"1" = [
- "fparkan-cli",
- "fparkan-inspection",
- "fparkan-nres",
- "fparkan-path",
- "fparkan-resource",
- "fparkan-rsli",
- "fparkan-vfs",
-]
-"2" = [
- "fparkan-prototype",
-]
-"3" = [
- "fparkan-assets",
- "fparkan-material",
- "fparkan-msh",
- "fparkan-texm",
- "fparkan-viewer",
-]
-"4" = [
- "fparkan-animation",
- "fparkan-fx",
- "fparkan-script",
-]
-"5" = [
- "fparkan-game",
- "fparkan-headless",
- "fparkan-mission-format",
- "fparkan-runtime",
- "fparkan-terrain",
- "fparkan-terrain-format",
- "fparkan-world",
-]
diff --git a/fixtures/schemas/corpus-manifest.schema.json b/fixtures/schemas/corpus-manifest.schema.json
deleted file mode 100644
index 4542ec0..0000000
--- a/fixtures/schemas/corpus-manifest.schema.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "schema": 1,
- "required": ["schema", "corpus"],
- "properties": {
- "schema": { "const": 1 },
- "corpus": {
- "type": "array",
- "items": {
- "required": ["id", "kind", "root", "expected_profile"],
- "properties": {
- "id": { "type": "string" },
- "kind": { "enum": ["demo", "part1", "part2"] },
- "root": { "type": "string" },
- "expected_profile": { "type": "string" }
- }
- }
- }
- }
-}
diff --git a/fixtures/schemas/report.schema.json b/fixtures/schemas/report.schema.json
deleted file mode 100644
index 70ab4df..0000000
--- a/fixtures/schemas/report.schema.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
- "schema": 1,
- "required": ["schema", "kind", "files", "metrics"],
- "properties": {
- "schema": { "const": 1 },
- "kind": { "type": "string" },
- "files": { "type": "integer" },
- "metrics": { "type": "object" }
- }
-}
diff --git a/mkdocs.yml b/mkdocs.yml
index 1357ca4..bfd58bd 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -57,14 +57,14 @@ plugins:
nav:
- Начало: index.md
- Книга:
- - I. Путеводитель и методика: tomes/01-guide.md
+ - I. Путеводитель: tomes/01-guide.md
- II. Запуск, архитектура и игровой цикл: tomes/02-architecture.md
- III. Ресурсная система и форматы: tomes/03-resources.md
- IV. Мир, миссии и игровой runtime: tomes/04-world.md
- V. Геометрия, материалы и рендер: tomes/05-render.md
- VI. Поведение, управление, звук и сеть: tomes/06-behavior.md
- - VII. Руководство по полной реализации: tomes/07-implementation.md
- - VIII. Справочник и доказательная база: tomes/08-evidence.md
+ - VII. Работа над движком: tomes/07-implementation.md
+ - VIII. Устройство оригинальной программы: tomes/08-evidence.md
- Справочник:
- NRes: reference/nres.md
- RsLi: reference/rsli.md
@@ -73,12 +73,15 @@ nav:
- WEAR и MAT0: reference/materials.md
- Texm: reference/texm.md
- Render frame: reference/render-frame.md
- - Renderer Truth Table: rendering/renderer_truth_table.md
- - Original Engine Hashes: evidence/original_engine_hashes.md
- - Stage 4 Capture Schema: evidence/stage4_capture_schema.md
+ - Атмосфера и небо: reference/atmosphere.md
+ - Эффекты окружения: reference/environment-effects.md
+ - Оригинальные модули: reference/original-binaries.md
- Приложения:
- Глоссарий: appendices/glossary.md
- Границы знания: appendices/knowledge-boundaries.md
+ - Сценарная VM: appendices/script-vm.md
+ - Сохранения и кампания: appendices/saves-campaign.md
+ - Интерфейс игры: appendices/ui-shell.md
# Additional configuration
extra:
diff --git a/parity/README.md b/parity/README.md
deleted file mode 100644
index a7d1008..0000000
--- a/parity/README.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Render Parity Dataset
-
-This folder stores parity-test input for legacy render comparison workflows.
-
-- `cases.toml`: list of deterministic render cases.
-- `reference/*.png`: baseline frames captured from the original renderer.
-
-Expected workflow:
-
-1. Capture baseline PNG frames for each case.
-2. Add entries to `cases.toml`.
-3. Run the acceptance renderer capture workflow with fixed profiles and compare
- output captures out-of-tree.
-
-```text
-1) Prepare `cases.toml` and baseline captures.
-2) Run `fparkan-game` (or dedicated acceptance runner) with fixed seed.
-3) Compare outputs against baseline in dedicated comparison tooling.
-```
-
-The `render-parity` crate is no longer present as a standalone runner in this
-workspace snapshot; parity evidence is now produced through the acceptance
-artifacts and stage audit tooling.
diff --git a/parity/cases.toml b/parity/cases.toml
deleted file mode 100644
index 62bb0e3..0000000
--- a/parity/cases.toml
+++ /dev/null
@@ -1,27 +0,0 @@
-[meta]
-# Global defaults for all cases.
-width = 1280
-height = 720
-lod = 0
-group = 0
-angle = 0.0
-
-# Per-pixel change threshold for the "changed pixel ratio" metric.
-diff_threshold = 8
-
-# Allowed thresholds (case fails if any limit is exceeded).
-max_mean_abs = 2.0
-max_changed_ratio = 0.010
-
-# Add one block per model.
-#
-# [[case]]
-# id = "animals_a_l_01"
-# archive = "../testdata/Parkan - Iron Strategy/animals.rlb"
-# model = "A_L_01.msh"
-# reference = "reference/animals_a_l_01.png"
-# lod = 0
-# group = 0
-# angle = 0.0
-# max_mean_abs = 2.0
-# max_changed_ratio = 0.010
diff --git a/parity/reference/.gitkeep b/parity/reference/.gitkeep
deleted file mode 100644
index e69de29..0000000
--- a/parity/reference/.gitkeep
+++ /dev/null
diff --git a/renovate.config.cjs b/renovate.config.cjs
deleted file mode 100644
index 9d56531..0000000
--- a/renovate.config.cjs
+++ /dev/null
@@ -1,6 +0,0 @@
-module.exports = {
- endpoint: "https://code.popov.link",
- gitAuthor: "renovate[bot] <renovatebot@noreply.localhost>",
- optimizeForDisabled: true,
- platform: "gitea",
-}; \ No newline at end of file
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
deleted file mode 100644
index 415f88d..0000000
--- a/rust-toolchain.toml
+++ /dev/null
@@ -1,6 +0,0 @@
-[toolchain]
-channel = "1.99.0"
-components = ["clippy", "rustfmt", "rust-docs"]
-targets = [
- "x86_64-pc-windows-msvc",
-]
diff --git a/tools/capture-ai-init.ps1 b/tools/capture-ai-init.ps1
deleted file mode 100644
index 3ee91c7..0000000
--- a/tools/capture-ai-init.ps1
+++ /dev/null
@@ -1,111 +0,0 @@
-[CmdletBinding()]
-param([Parameter(Mandatory = $true)][int]$ProcessId)
-
-Set-StrictMode -Version Latest
-$ErrorActionPreference = 'Stop'
-
-# Read-only observer for ai.dll's `GetSuperAI` singleton array. It never sends
-# input, writes memory, suspends, injects, or calls into the original process.
-Add-Type -TypeDefinition @'
-using System;
-using System.Runtime.InteropServices;
-public static class FparkanAiInitCapture {
- public const uint TH32CS_SNAPMODULE = 0x00000008;
- public const uint TH32CS_SNAPMODULE32 = 0x00000010;
- public const uint PROCESS_QUERY_INFORMATION = 0x00000400;
- public const uint PROCESS_VM_READ = 0x00000010;
- [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
- public struct MODULEENTRY32 {
- public uint dwSize, th32ModuleID, th32ProcessID, GlblcntUsage, ProccntUsage;
- public IntPtr modBaseAddr;
- public uint modBaseSize;
- public IntPtr hModule;
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szModule;
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExePath;
- }
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId);
- [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- public static extern bool Module32First(IntPtr snapshot, ref MODULEENTRY32 entry);
- [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- public static extern bool Module32Next(IntPtr snapshot, ref MODULEENTRY32 entry);
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint processId);
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern bool ReadProcessMemory(IntPtr process, IntPtr address,
- [Out] byte[] buffer, IntPtr size, out IntPtr bytesRead);
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern bool CloseHandle(IntPtr handle);
-}
-'@
-
-function Read-Bytes([IntPtr]$Process, [Int64]$Address, [int]$Length) {
- $bytes = [byte[]]::new($Length); $read = [IntPtr]::Zero
- if (-not [FparkanAiInitCapture]::ReadProcessMemory($Process, [IntPtr]$Address,
- $bytes, [IntPtr]$Length, [ref]$read) -or $read.ToInt64() -ne $Length) {
- throw "ReadProcessMemory failed at 0x$('{0:X8}' -f $Address)"
- }
- $bytes
-}
-
-$snapshot = [FparkanAiInitCapture]::CreateToolhelp32Snapshot(
- [FparkanAiInitCapture]::TH32CS_SNAPMODULE -bor [FparkanAiInitCapture]::TH32CS_SNAPMODULE32,
- [uint32]$ProcessId)
-$aiBase = $null
-$modules = @()
-try {
- $entry = [FparkanAiInitCapture+MODULEENTRY32]::new()
- $entry.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanAiInitCapture+MODULEENTRY32])
- if ([FparkanAiInitCapture]::Module32First($snapshot, [ref]$entry)) {
- do {
- $modules += [ordered]@{
- name = $entry.szModule
- base = $entry.modBaseAddr.ToInt64()
- size = [int64]$entry.modBaseSize
- }
- if ($entry.szModule -ieq 'ai.dll') { $aiBase = $entry.modBaseAddr.ToInt64() }
- $entry = [FparkanAiInitCapture+MODULEENTRY32]::new()
- $entry.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanAiInitCapture+MODULEENTRY32])
- } while ([FparkanAiInitCapture]::Module32Next($snapshot, [ref]$entry))
- }
-} finally { [void][FparkanAiInitCapture]::CloseHandle($snapshot) }
-if ($null -eq $aiBase) { throw "ai.dll is not loaded by process $ProcessId" }
-
-$process = [FparkanAiInitCapture]::OpenProcess(
- [FparkanAiInitCapture]::PROCESS_QUERY_INFORMATION -bor [FparkanAiInitCapture]::PROCESS_VM_READ,
- $false, [uint32]$ProcessId)
-if ($process -eq [IntPtr]::Zero) { throw "OpenProcess read-only failed" }
-try {
- # CreateSuperAI stores its tenth host-callback argument at DAT_100555e4.
- $callbackBytes = Read-Bytes $process ($aiBase + 0x555e4) 4
- $callback = [BitConverter]::ToUInt32($callbackBytes, 0)
- $callbackModule = $modules | Where-Object {
- $callback -ge $_.base -and [int64]$callback -lt ($_.base + $_.size)
- } | Select-Object -First 1
- # GetSuperAI(i) returns (&DAT_10055398)[i], with ai.dll preferred base 0x10000000.
- $entries = Read-Bytes $process ($aiBase + 0x55398) (64 * 4)
- $samples = for ($index = 0; $index -lt 64; $index++) {
- $pointer = [BitConverter]::ToUInt32($entries, $index * 4)
- if ($pointer -le 0x10000) { continue }
- try {
- $fields = Read-Bytes $process ([int64]$pointer) 0x88
- [ordered]@{
- index = $index
- super_ai = ('0x{0:X8}' -f $pointer)
- word_7c = [BitConverter]::ToUInt32($fields, 0x7c)
- float_80 = [BitConverter]::ToSingle($fields, 0x80)
- float_84 = [BitConverter]::ToSingle($fields, 0x84)
- }
- } catch { }
- }
- [ordered]@{
- schema = 'fparkan-ai-init-v1'
- process_id = $ProcessId
- ai_module_base = ('0x{0:X8}' -f $aiBase)
- handler30_callback = ('0x{0:X8}' -f $callback)
- handler30_callback_module = if ($null -eq $callbackModule) { $null } else { $callbackModule.name }
- handler30_callback_rva = if ($null -eq $callbackModule) { $null } else { ('0x{0:X}' -f ([int64]$callback - $callbackModule.base)) }
- entries = @($samples)
- } |
- ConvertTo-Json -Depth 4 -Compress
-} finally { [void][FparkanAiInitCapture]::CloseHandle($process) }
diff --git a/tools/capture-original-camera.ps1 b/tools/capture-original-camera.ps1
deleted file mode 100644
index 9e95d91..0000000
--- a/tools/capture-original-camera.ps1
+++ /dev/null
@@ -1,213 +0,0 @@
-[CmdletBinding()]
-param(
- [Parameter(Mandatory = $true)]
- [int]$ProcessId,
- [ValidateRange(1, 16384)]
- [int]$ViewportWidth = 1024,
- [ValidateRange(1, 16384)]
- [int]$ViewportHeight = 768,
- [ValidateRange(0.001, 1000.0)]
- [double]$NearPlane = 0.5,
- [ValidateRange(0.01, 100000.0)]
- [double]$FarPlane = 700.0,
- [ValidateRange(0.01, 3.13)]
- [double]$FieldOfViewRadians = 1.3,
- [ValidateRange(1, 600)]
- [int]$CaptureAttempts = 60,
- [ValidateRange(0, 1000000.0)]
- [double]$MinimumWorldTranslation = 100.0,
- [ValidateRange(0, 1000)]
- [int]$RetryIntervalMilliseconds = 100
-)
-
-Set-StrictMode -Version Latest
-$ErrorActionPreference = 'Stop'
-
-# This observer deliberately opens only PROCESS_QUERY_INFORMATION | PROCESS_VM_READ.
-# It neither sends input nor writes, suspends, injects into, or calls the original game.
-Add-Type -TypeDefinition @'
-using System;
-using System.Runtime.InteropServices;
-
-public static class FparkanOriginalCamera {
- public const uint TH32CS_SNAPMODULE = 0x00000008;
- public const uint TH32CS_SNAPMODULE32 = 0x00000010;
- public const uint PROCESS_QUERY_INFORMATION = 0x00000400;
- public const uint PROCESS_VM_READ = 0x00000010;
-
- [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
- public struct MODULEENTRY32 {
- public uint dwSize;
- public uint th32ModuleID;
- public uint th32ProcessID;
- public uint GlblcntUsage;
- public uint ProccntUsage;
- public IntPtr modBaseAddr;
- public uint modBaseSize;
- public IntPtr hModule;
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szModule;
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExePath;
- }
-
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId);
- [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- public static extern bool Module32First(IntPtr snapshot, ref MODULEENTRY32 entry);
- [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- public static extern bool Module32Next(IntPtr snapshot, ref MODULEENTRY32 entry);
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint processId);
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern bool ReadProcessMemory(
- IntPtr process,
- IntPtr address,
- [Out] byte[] buffer,
- IntPtr size,
- out IntPtr bytesRead);
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern bool CloseHandle(IntPtr handle);
-}
-'@
-
-function Get-LastWin32ErrorText {
- $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
- "$code ($([ComponentModel.Win32Exception]::new($code).Message))"
-}
-
-function Read-OriginalBytes {
- param(
- [IntPtr]$Process,
- [Int64]$Address,
- [int]$Length
- )
- $buffer = [byte[]]::new($Length)
- $read = [IntPtr]::Zero
- if (-not [FparkanOriginalCamera]::ReadProcessMemory(
- $Process,
- [IntPtr]$Address,
- $buffer,
- [IntPtr]$Length,
- [ref]$read)) {
- throw "ReadProcessMemory at 0x$('{0:X8}' -f $Address) failed: $(Get-LastWin32ErrorText)"
- }
- if ($read.ToInt64() -ne $Length) {
- throw "ReadProcessMemory at 0x$('{0:X8}' -f $Address) returned $($read.ToInt64()) of $Length bytes"
- }
- $buffer
-}
-
-$snapshot = [FparkanOriginalCamera]::CreateToolhelp32Snapshot(
- [FparkanOriginalCamera]::TH32CS_SNAPMODULE -bor [FparkanOriginalCamera]::TH32CS_SNAPMODULE32,
- [uint32]$ProcessId
-)
-if ($snapshot -eq [IntPtr]::Zero -or $snapshot.ToInt64() -eq -1) {
- throw "CreateToolhelp32Snapshot failed: $(Get-LastWin32ErrorText)"
-}
-
-$terrainBase = $null
-try {
- $module = [FparkanOriginalCamera+MODULEENTRY32]::new()
- $module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanOriginalCamera+MODULEENTRY32])
- if (-not [FparkanOriginalCamera]::Module32First($snapshot, [ref]$module)) {
- throw "Module32First failed: $(Get-LastWin32ErrorText)"
- }
- do {
- if ($module.szModule -ieq 'Terrain.dll') {
- $terrainBase = $module.modBaseAddr.ToInt64()
- break
- }
- $module = [FparkanOriginalCamera+MODULEENTRY32]::new()
- $module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanOriginalCamera+MODULEENTRY32])
- } while ([FparkanOriginalCamera]::Module32Next($snapshot, [ref]$module))
-} finally {
- [void][FparkanOriginalCamera]::CloseHandle($snapshot)
-}
-
-if ($null -eq $terrainBase) {
- throw "Terrain.dll is not loaded by process $ProcessId"
-}
-
-$process = [FparkanOriginalCamera]::OpenProcess(
- [FparkanOriginalCamera]::PROCESS_QUERY_INFORMATION -bor [FparkanOriginalCamera]::PROCESS_VM_READ,
- $false,
- [uint32]$ProcessId
-)
-if ($process -eq [IntPtr]::Zero) {
- throw "OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) failed: $(Get-LastWin32ErrorText)"
-}
-
-try {
- # Terrain.dll image RVA 0x7355c -> active 0x1a4-byte outer camera object.
- # Its selector-0 affine block begins at outer + 0x20. Words 3/7/11 are
- # the proven world-space translation components. AutoDemo also briefly
- # selects normalized/reflection-like camera objects; a finite matrix alone
- # is not sufficient evidence that it can project the mission world.
- $expectedOuterVtable = [uint32]($terrainBase + 0x665b4)
- $matrixBytes = $null
- $translation = $null
- $cameraOuter = $null
- $lastObservation = 'camera pointer unavailable'
- for ($attempt = 1; $attempt -le $CaptureAttempts; $attempt++) {
- $candidateOuter = [BitConverter]::ToUInt32(
- (Read-OriginalBytes $process ($terrainBase + 0x7355c) 4),
- 0
- )
- if ($candidateOuter -lt 0x10000) {
- $lastObservation = "camera pointer 0x$('{0:X8}' -f $candidateOuter)"
- continue
- }
- $outerVtable = [BitConverter]::ToUInt32(
- (Read-OriginalBytes $process ([int64]$candidateOuter) 4),
- 0
- )
- if ($outerVtable -ne $expectedOuterVtable) {
- $lastObservation = "outer vtable 0x$('{0:X8}' -f $outerVtable)"
- continue
- }
- $matrixBytes = Read-OriginalBytes $process ([int64]$candidateOuter + 0x20) 64
- $candidateTranslation = @(
- [BitConverter]::ToSingle($matrixBytes, 12),
- [BitConverter]::ToSingle($matrixBytes, 28),
- [BitConverter]::ToSingle($matrixBytes, 44)
- )
- $nonFinite = @($candidateTranslation | Where-Object {
- [Single]::IsNaN($_) -or [Single]::IsInfinity($_)
- })
- $length = [Math]::Sqrt(
- [double]$candidateTranslation[0] * $candidateTranslation[0] +
- [double]$candidateTranslation[1] * $candidateTranslation[1] +
- [double]$candidateTranslation[2] * $candidateTranslation[2]
- )
- if ($nonFinite.Count -eq 0 -and $length -ge $MinimumWorldTranslation) {
- $cameraOuter = $candidateOuter
- $translation = $candidateTranslation
- break
- }
- $lastObservation = "translation length $length at 0x$('{0:X8}' -f $candidateOuter)"
- if ($attempt -lt $CaptureAttempts -and $RetryIntervalMilliseconds -gt 0) {
- Start-Sleep -Milliseconds $RetryIntervalMilliseconds
- }
- }
- if ($null -eq $translation) {
- throw "No world-space selector-0 transform after $CaptureAttempts samples (minimum translation length $MinimumWorldTranslation; last observation: $lastObservation)"
- }
- $words = for ($index = 0; $index -lt 16; $index++) {
- [BitConverter]::ToUInt32($matrixBytes, $index * 4)
- }
- [ordered]@{
- schema = 'fparkan-legacy-camera-v1'
- process_id = $ProcessId
- terrain_module_base = ('0x{0:X8}' -f $terrainBase)
- terrain_camera_global_rva = '0x7355c'
- terrain_camera_outer = ('0x{0:X8}' -f $cameraOuter)
- selector0_words = @($words)
- selector0_translation = @($translation)
- # LegacyD3d7Projection carries a D3D7 RECT: left, top, right, bottom.
- viewport = @(0, 0, $ViewportWidth, $ViewportHeight)
- near_plane = $NearPlane
- far_plane = $FarPlane
- field_of_view_radians = $FieldOfViewRadians
- } | ConvertTo-Json -Depth 4 -Compress
-} finally {
- [void][FparkanOriginalCamera]::CloseHandle($process)
-}
diff --git a/tools/capture-terrain-shade-cache.ps1 b/tools/capture-terrain-shade-cache.ps1
deleted file mode 100644
index 5c7b10d..0000000
--- a/tools/capture-terrain-shade-cache.ps1
+++ /dev/null
@@ -1,272 +0,0 @@
-[CmdletBinding()]
-param(
- [Parameter(Mandatory = $true)]
- [int]$ProcessId,
- [ValidateRange(1, 60)]
- [int]$CaptureAttempts = 6,
- [ValidateRange(0, 1000)]
- [int]$RetryIntervalMilliseconds = 100,
- [ValidateRange(0x00010000, 0x7fff0000)]
- [UInt32]$SearchStart = 0x01000000,
- [ValidateRange(0x00010000, 0x7fff0000)]
- [UInt32]$SearchEnd = 0x20000000
-)
-
-Set-StrictMode -Version Latest
-$ErrorActionPreference = 'Stop'
-
-# This observer deliberately opens only PROCESS_QUERY_INFORMATION | PROCESS_VM_READ.
-# It neither sends input nor writes, suspends, injects into, or calls the original game.
-Add-Type -TypeDefinition @'
-using System;
-using System.Runtime.InteropServices;
-
-public static class FparkanTerrainShadeProbe {
- public const uint TH32CS_SNAPMODULE = 0x00000008;
- public const uint TH32CS_SNAPMODULE32 = 0x00000010;
- public const uint PROCESS_QUERY_INFORMATION = 0x00000400;
- public const uint PROCESS_VM_READ = 0x00000010;
- public const uint MEM_COMMIT = 0x1000;
- public const uint PAGE_NOACCESS = 0x01;
- public const uint PAGE_GUARD = 0x100;
-
- [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
- public struct MODULEENTRY32 {
- public uint dwSize;
- public uint th32ModuleID;
- public uint th32ProcessID;
- public uint GlblcntUsage;
- public uint ProccntUsage;
- public IntPtr modBaseAddr;
- public uint modBaseSize;
- public IntPtr hModule;
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 256)] public string szModule;
- [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] public string szExePath;
- }
-
- [StructLayout(LayoutKind.Sequential)]
- public struct MEMORY_BASIC_INFORMATION {
- public IntPtr BaseAddress;
- public IntPtr AllocationBase;
- public uint AllocationProtect;
- public IntPtr RegionSize;
- public uint State;
- public uint Protect;
- public uint Type;
- }
-
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId);
- [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- public static extern bool Module32First(IntPtr snapshot, ref MODULEENTRY32 entry);
- [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
- public static extern bool Module32Next(IntPtr snapshot, ref MODULEENTRY32 entry);
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern IntPtr OpenProcess(uint access, bool inheritHandle, uint processId);
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern bool ReadProcessMemory(
- IntPtr process, IntPtr address, [Out] byte[] buffer, IntPtr size, out IntPtr bytesRead);
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern IntPtr VirtualQueryEx(
- IntPtr process, IntPtr address, out MEMORY_BASIC_INFORMATION buffer, IntPtr length);
- [DllImport("kernel32.dll", SetLastError = true)]
- public static extern bool CloseHandle(IntPtr handle);
-
- public static int FindU32(byte[] bytes, uint expected, int start) {
- for (int index = start; index <= bytes.Length - 4; index += 4) {
- if (BitConverter.ToUInt32(bytes, index) == expected) return index;
- }
- return -1;
- }
-
- public static ulong Fnv1a64(byte[] bytes, int offset, int length) {
- ulong hash = 14695981039346656037UL;
- unchecked {
- for (int index = offset; index < offset + length; index++) {
- hash ^= bytes[index];
- hash *= 1099511628211UL;
- }
- }
- return hash;
- }
-}
-'@
-
-function Get-LastWin32ErrorText {
- $code = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
- "$code ($([ComponentModel.Win32Exception]::new($code).Message))"
-}
-
-function Get-TerrainModuleBase {
- param([int]$ProbeProcessId)
- $snapshot = [FparkanTerrainShadeProbe]::CreateToolhelp32Snapshot(
- [FparkanTerrainShadeProbe]::TH32CS_SNAPMODULE -bor [FparkanTerrainShadeProbe]::TH32CS_SNAPMODULE32,
- [uint32]$ProbeProcessId
- )
- if ($snapshot -eq [IntPtr]::Zero -or $snapshot.ToInt64() -eq -1) {
- throw "CreateToolhelp32Snapshot failed: $(Get-LastWin32ErrorText)"
- }
- try {
- $module = [FparkanTerrainShadeProbe+MODULEENTRY32]::new()
- $module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanTerrainShadeProbe+MODULEENTRY32])
- if (-not [FparkanTerrainShadeProbe]::Module32First($snapshot, [ref]$module)) {
- throw "Module32First failed: $(Get-LastWin32ErrorText)"
- }
- do {
- if ($module.szModule -ieq 'Terrain.dll') {
- return $module.modBaseAddr.ToInt64()
- }
- $module = [FparkanTerrainShadeProbe+MODULEENTRY32]::new()
- $module.dwSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanTerrainShadeProbe+MODULEENTRY32])
- } while ([FparkanTerrainShadeProbe]::Module32Next($snapshot, [ref]$module))
- } finally {
- [void][FparkanTerrainShadeProbe]::CloseHandle($snapshot)
- }
- throw "Terrain.dll is not loaded by process $ProbeProcessId"
-}
-
-function Read-Bytes {
- param([IntPtr]$Process, [Int64]$Address, [int]$Length)
- $buffer = [byte[]]::new($Length)
- $read = [IntPtr]::Zero
- if (-not [FparkanTerrainShadeProbe]::ReadProcessMemory(
- $Process, [IntPtr]$Address, $buffer, [IntPtr]$Length, [ref]$read)) {
- return $null
- }
- if ($read.ToInt64() -ne $Length) {
- return $null
- }
- return $buffer
-}
-
-function Find-ShadeCache {
- param(
- [IntPtr]$Process,
- [UInt32]$ExpectedVtable,
- [UInt32]$Start,
- [UInt32]$End,
- [Int64]$ExcludedImageStart,
- [Int64]$ExcludedImageEnd
- )
- $mbiSize = [Runtime.InteropServices.Marshal]::SizeOf([type][FparkanTerrainShadeProbe+MEMORY_BASIC_INFORMATION])
- [Int64]$cursor = $Start
- while ($cursor -lt $End) {
- $mbi = [FparkanTerrainShadeProbe+MEMORY_BASIC_INFORMATION]::new()
- $queried = [FparkanTerrainShadeProbe]::VirtualQueryEx(
- $Process, [IntPtr]$cursor, [ref]$mbi, [IntPtr]$mbiSize
- )
- if ($queried -eq [IntPtr]::Zero) { break }
- $base = $mbi.BaseAddress.ToInt64()
- $size = $mbi.RegionSize.ToInt64()
- if ($size -le 0) { break }
- $next = $base + $size
- if ($mbi.State -eq [FparkanTerrainShadeProbe]::MEM_COMMIT -and
- ($mbi.Protect -band [FparkanTerrainShadeProbe]::PAGE_NOACCESS) -eq 0 -and
- ($mbi.Protect -band [FparkanTerrainShadeProbe]::PAGE_GUARD) -eq 0) {
- $regionStart = [Math]::Max($base, [Int64]$Start)
- $regionEnd = [Math]::Min($next, [Int64]$End)
- for ([Int64]$offset = $regionStart; $offset -lt $regionEnd; $offset += 65536) {
- $length = [int][Math]::Min(65536, $regionEnd - $offset)
- $bytes = Read-Bytes $Process $offset $length
- if ($null -eq $bytes) { continue }
- $searchIndex = 0
- while ($searchIndex -le $bytes.Length - 4) {
- $index = [FparkanTerrainShadeProbe]::FindU32($bytes, $ExpectedVtable, $searchIndex)
- if ($index -lt 0) { break }
- $candidate = $offset + $index
- if ($candidate -lt $ExcludedImageStart -or $candidate -ge $ExcludedImageEnd) {
- return $candidate
- }
- $searchIndex = $index + 4
- }
- }
- }
- $cursor = [Math]::Max($next, $cursor + 4096)
- }
- return $null
-}
-
-function Get-ShadeCacheSummary {
- param([IntPtr]$Process, [Int64]$Cache, [byte[]]$Header)
- $entryTable = [BitConverter]::ToUInt32($Header, 316)
- $entryCount = [BitConverter]::ToUInt32($Header, 320)
- $summary = [ordered]@{
- materialized_entries = $null
- profile_banks = @()
- }
- # The count field is runtime data. Keep a hard observer bound so a corrupt
- # or stale candidate cannot turn a passive sample into an excessive read.
- if ($entryTable -lt 0x1000 -or $entryCount -gt 100000) { return $summary }
- $entryBytes = Read-Bytes $Process $entryTable ([int]($entryCount * 8))
- if ($null -eq $entryBytes) { return $summary }
- $materialized = 0
- $banks = [System.Collections.Generic.SortedSet[int]]::new()
- for ($index = 0; $index -lt $entryCount; $index++) {
- $offset = $index * 8
- if ([BitConverter]::ToUInt32($entryBytes, $offset) -ge 0x1000) {
- $materialized++
- [void]$banks.Add([int]$entryBytes[$offset + 4])
- }
- }
- $summary.materialized_entries = $materialized
- if ($banks.Count -eq 0) { return $summary }
- $maxBank = $banks.Max
- if ($maxBank -ge 100) { return $summary }
- $bankBytes = Read-Bytes $Process ($Cache + 332) (($maxBank + 1) * 212)
- if ($null -eq $bankBytes) { return $summary }
- $profiles = [System.Collections.Generic.List[object]]::new()
- foreach ($bank in $banks) {
- $profiles.Add([ordered]@{
- bank = $bank
- fnv1a64 = [FparkanTerrainShadeProbe]::Fnv1a64($bankBytes, $bank * 212, 212).ToString()
- })
- }
- $summary.profile_banks = @($profiles)
- return $summary
-}
-
-if ($SearchEnd -le $SearchStart) { throw 'SearchEnd must exceed SearchStart' }
-$terrainBase = Get-TerrainModuleBase $ProcessId
-$process = [FparkanTerrainShadeProbe]::OpenProcess(
- [FparkanTerrainShadeProbe]::PROCESS_QUERY_INFORMATION -bor [FparkanTerrainShadeProbe]::PROCESS_VM_READ,
- $false,
- [uint32]$ProcessId
-)
-if ($process -eq [IntPtr]::Zero) { throw "OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) failed: $(Get-LastWin32ErrorText)" }
-
-try {
- $expectedVtable = [uint32]($terrainBase + 0x643d0)
- $last = 'cache vtable was not present in readable scan range'
- for ($attempt = 1; $attempt -le $CaptureAttempts; $attempt++) {
- # The vtable value itself naturally appears in the Terrain image as
- # relocation data; only a heap-resident object is a cache candidate.
- $cache = Find-ShadeCache $process $expectedVtable $SearchStart $SearchEnd $terrainBase ($terrainBase + 0x100000)
- if ($null -ne $cache) {
- $header = Read-Bytes $process $cache 324
- if ($null -ne $header) {
- $summary = Get-ShadeCacheSummary $process $cache $header
- [ordered]@{
- schema = 'fparkan-terrain-shade-cache-v1'
- process_id = $ProcessId
- terrain_module_base = ('0x{0:X8}' -f $terrainBase)
- cache_vtable_rva = '0x643d0'
- cache_object = ('0x{0:X8}' -f $cache)
- result_view = ('0x{0:X8}' -f [BitConverter]::ToUInt32($header, 24))
- entry_table = ('0x{0:X8}' -f [BitConverter]::ToUInt32($header, 316))
- entry_count = [BitConverter]::ToUInt32($header, 320)
- materialized_entries = $summary.materialized_entries
- profile_banks = $summary.profile_banks
- scan_attempt = $attempt
- } | ConvertTo-Json -Compress
- exit 0
- }
- $last = "cache candidate 0x$('{0:X8}' -f $cache) became unreadable"
- }
- if ($attempt -lt $CaptureAttempts -and $RetryIntervalMilliseconds -gt 0) {
- Start-Sleep -Milliseconds $RetryIntervalMilliseconds
- }
- }
- throw "No live GetShade cache after $CaptureAttempts scans ($last)"
-} finally {
- [void][FparkanTerrainShadeProbe]::CloseHandle($process)
-}
diff --git a/tools/ghidra/ExportAiCreateSuperAi.java b/tools/ghidra/ExportAiCreateSuperAi.java
deleted file mode 100644
index 80117cf..0000000
--- a/tools/ghidra/ExportAiCreateSuperAi.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the GOG CreateSuperAI export to recover the mission-to-SuperAI
-// initialization boundary. Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiCreateSuperAi extends GhidraScript {
- private static final long ADDRESS = 0x1000f710L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI CreateSuperAI =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiExpressionDispatcher.java b/tools/ghidra/ExportAiExpressionDispatcher.java
deleted file mode 100644
index 1f3e8fa..0000000
--- a/tools/ghidra/ExportAiExpressionDispatcher.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Emits the decompiled AI expression evaluator containing the recovered
-// tag 1..5 dispatch. Run through Ghidra headless analysis only; it never
-// mutates the original PE image.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiExpressionDispatcher extends GhidraScript {
- private static final long ADDRESS = 0x10005180L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionContaining(address);
- println("===== AI expression dispatcher =====");
- if (function == null) { println("missing"); return; }
- println("entry=" + function.getEntryPoint());
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiFtol.java b/tools/ghidra/ExportAiFtol.java
deleted file mode 100644
index 215dc01..0000000
--- a/tools/ghidra/ExportAiFtol.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the x87-to-integer helper called by Handler(19). Run headless; the
-// original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiFtol extends GhidraScript {
- private static final long ADDRESS = 0x1001df70L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI x87 __ftol helper =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiGetSuperAi.java b/tools/ghidra/ExportAiGetSuperAi.java
deleted file mode 100644
index 27ea107..0000000
--- a/tools/ghidra/ExportAiGetSuperAi.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the GOG ai.dll GetSuperAI export to recover the live singleton
-// boundary for read-only handler-input capture.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiGetSuperAi extends GhidraScript {
- private static final long ADDRESS = 0x1000f780L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI GetSuperAI =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiScriptLoader.java b/tools/ghidra/ExportAiScriptLoader.java
deleted file mode 100644
index 1b457d4..0000000
--- a/tools/ghidra/ExportAiScriptLoader.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the GOG AI script-bundle loader, discovered from references to
-// "MISSIONS\\SCRIPTS\\" and ".scr". Run headless; original input stays read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiScriptLoader extends GhidraScript {
- private static final long ADDRESS = 0x10001000L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI script loader =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiScriptLoaderStrings.java b/tools/ghidra/ExportAiScriptLoaderStrings.java
deleted file mode 100644
index e1443c7..0000000
--- a/tools/ghidra/ExportAiScriptLoaderStrings.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Emits path literals used by the GOG AI script loader at 0x10001000.
-// Run headless; the original PE remains read only.
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-
-public class ExportAiScriptLoaderStrings extends GhidraScript {
- private static final long[] ADDRESSES = {
- 0x10038a88L, 0x10038a9cL, 0x10038aa4L, 0x10038aacL,
- 0x10038ab4L, 0x10038abcL, 0x10038ad0L, 0x10038ad8L,
- 0x10038ae0L
- };
-
- @Override
- public void run() throws Exception {
- for (long value : ADDRESSES) {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(value);
- byte[] bytes = new byte[256];
- int count = currentProgram.getMemory().getBytes(address, bytes);
- StringBuilder text = new StringBuilder();
- for (int index = 0; index < count && bytes[index] != 0; index++) {
- text.append((char) (bytes[index] & 0xff));
- }
- println(address + " = \"" + text + "\"");
- }
- }
-}
diff --git a/tools/ghidra/ExportAiScriptPackageReader.java b/tools/ghidra/ExportAiScriptPackageReader.java
deleted file mode 100644
index 9365165..0000000
--- a/tools/ghidra/ExportAiScriptPackageReader.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the immediate .scr package reader called by the AI script loader.
-// Run through Ghidra headless analysis; the original PE is never modified.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiScriptPackageReader extends GhidraScript {
- private static final long ADDRESS = 0x10011B20L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI .scr package reader =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiSuperAiConstructor.java b/tools/ghidra/ExportAiSuperAiConstructor.java
deleted file mode 100644
index 85799e9..0000000
--- a/tools/ghidra/ExportAiSuperAiConstructor.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the SuperAI constructor called by CreateSuperAI. Run headless; the
-// original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiSuperAiConstructor extends GhidraScript {
- private static final long ADDRESS = 0x10001000L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI SuperAI constructor =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVarSetLoader.java b/tools/ghidra/ExportAiVarSetLoader.java
deleted file mode 100644
index 70c6fa4..0000000
--- a/tools/ghidra/ExportAiVarSetLoader.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the text `.var` loader called after the AI script loader selects a
-// bundle-local or shared varset. Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVarSetLoader extends GhidraScript {
- private static final long ADDRESS = 0x10011ea0L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI varset loader =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVarSetParser.java b/tools/ghidra/ExportAiVarSetParser.java
deleted file mode 100644
index f425f30..0000000
--- a/tools/ghidra/ExportAiVarSetParser.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the actual text-to-varset parser called by the AI varset loader.
-// Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVarSetParser extends GhidraScript {
- private static final long ADDRESS = 0x100174a0L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI varset parser =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVarSetU32Resolver.java b/tools/ghidra/ExportAiVarSetU32Resolver.java
deleted file mode 100644
index 247220d..0000000
--- a/tools/ghidra/ExportAiVarSetU32Resolver.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the u32 resolver used by corpus-reachable Handler(30) for indexed
-// varset records. Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVarSetU32Resolver extends GhidraScript {
- private static final long ADDRESS = 0x10013570L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI varset u32 resolver =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler0.java b/tools/ghidra/ExportAiVmHandler0.java
deleted file mode 100644
index 43e8046..0000000
--- a/tools/ghidra/ExportAiVmHandler0.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the first function in the AI DLL's verified 73-entry VM handler table.
-// Run through Ghidra headless analysis; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler0 extends GhidraScript {
- private static final long ADDRESS = 0x10008034L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM handler 0 =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler1.java b/tools/ghidra/ExportAiVmHandler1.java
deleted file mode 100644
index e58c1e9..0000000
--- a/tools/ghidra/ExportAiVmHandler1.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits Handler(1), the second function in the AI DLL's verified 73-entry VM table.
-// Run through Ghidra headless analysis; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler1 extends GhidraScript {
- private static final long ADDRESS = 0x10007fd0L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM Handler(1) =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler15.java b/tools/ghidra/ExportAiVmHandler15.java
deleted file mode 100644
index d1756aa..0000000
--- a/tools/ghidra/ExportAiVmHandler15.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits Handler(15), a frequent non-sentinel selector in the GOG compiled
-// script corpus. Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler15 extends GhidraScript {
- private static final long ADDRESS = 0x10008054L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM Handler(15) =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler19.java b/tools/ghidra/ExportAiVmHandler19.java
deleted file mode 100644
index 8729999..0000000
--- a/tools/ghidra/ExportAiVmHandler19.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits Handler(19), the first instruction in both AutoDemo default-script
-// Init events. Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler19 extends GhidraScript {
- private static final long ADDRESS = 0x1000aa38L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM Handler(19) =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler19Setter.java b/tools/ghidra/ExportAiVmHandler19Setter.java
deleted file mode 100644
index 2aa1ce0..0000000
--- a/tools/ghidra/ExportAiVmHandler19Setter.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits Handler(19)'s common x87 varset setter. Run headless; the original PE
-// remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler19Setter extends GhidraScript {
- private static final long ADDRESS = 0x10013770L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM Handler(19) setter =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler19SetterCallee.java b/tools/ghidra/ExportAiVmHandler19SetterCallee.java
deleted file mode 100644
index 92cf7d5..0000000
--- a/tools/ghidra/ExportAiVmHandler19SetterCallee.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the common varset storage helper reached by Handler(19)'s setter.
-// Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler19SetterCallee extends GhidraScript {
- private static final long ADDRESS = 0x10012fe0L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM Handler(19) setter storage helper =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler1Callees.java b/tools/ghidra/ExportAiVmHandler1Callees.java
deleted file mode 100644
index f59e619..0000000
--- a/tools/ghidra/ExportAiVmHandler1Callees.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Emits the two direct callees recovered from AI VM Handler(1).
-// Run through Ghidra headless analysis; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler1Callees extends GhidraScript {
- private static final long[] ADDRESSES = { 0x10002d30L, 0x10013190L };
-
- @Override
- public void run() throws Exception {
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- for (long value : ADDRESSES) {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(value);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM Handler(1) callee " + address + " =====");
- if (function == null) { println("missing"); continue; }
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- }
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler2.java b/tools/ghidra/ExportAiVmHandler2.java
deleted file mode 100644
index d4f3469..0000000
--- a/tools/ghidra/ExportAiVmHandler2.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits Handler(2), the third function in the AI DLL's verified 73-entry VM table.
-// Run through Ghidra headless analysis; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler2 extends GhidraScript {
- private static final long ADDRESS = 0x10009610L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM Handler(2) =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler2Callee.java b/tools/ghidra/ExportAiVmHandler2Callee.java
deleted file mode 100644
index c4246e8..0000000
--- a/tools/ghidra/ExportAiVmHandler2Callee.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the opaque direct callee reached by corpus-reachable AI VM Handler(2).
-// Run through Ghidra headless analysis; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler2Callee extends GhidraScript {
- private static final long ADDRESS = 0x100059f0L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM Handler(2) direct callee =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler2Dispatch.java b/tools/ghidra/ExportAiVmHandler2Dispatch.java
deleted file mode 100644
index 5762519..0000000
--- a/tools/ghidra/ExportAiVmHandler2Dispatch.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Emits the direct post-insertion dispatcher reached by the corpus-reachable
-// AI VM Handler(2) scheduler boundary. Run headless; the original PE is read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler2Dispatch extends GhidraScript {
- private static final long[] ADDRESSES = {
- 0x1000f920L, 0x10004be0L, 0x10004d00L, 0x10004db0L
- };
-
- @Override
- public void run() throws Exception {
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- for (long value : ADDRESSES) {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(value);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI Handler(2) post-insertion helper " + address + " =====");
- if (function == null) { println("missing"); continue; }
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- }
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler2SchedulerHelpers.java b/tools/ghidra/ExportAiVmHandler2SchedulerHelpers.java
deleted file mode 100644
index 76b335d..0000000
--- a/tools/ghidra/ExportAiVmHandler2SchedulerHelpers.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Emits record construction, equality, refresh and insertion helpers called by
-// the corpus-reachable AI VM Handler(2) scheduler boundary.
-// Run through Ghidra headless analysis; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler2SchedulerHelpers extends GhidraScript {
- private static final long[] ADDRESSES = {
- 0x10004e50L, 0x10004c50L, 0x10005070L, 0x100073e0L
- };
-
- @Override
- public void run() throws Exception {
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- for (long value : ADDRESSES) {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(value);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI Handler(2) scheduler helper " + address + " =====");
- if (function == null) { println("missing"); continue; }
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- }
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler30.java b/tools/ghidra/ExportAiVmHandler30.java
deleted file mode 100644
index 81918ff..0000000
--- a/tools/ghidra/ExportAiVmHandler30.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits Handler(30), the most frequent non-sentinel selector in the GOG
-// compiled-script corpus. Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler30 extends GhidraScript {
- private static final long ADDRESS = 0x1000c266L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM Handler(30) =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler8.java b/tools/ghidra/ExportAiVmHandler8.java
deleted file mode 100644
index 4539629..0000000
--- a/tools/ghidra/ExportAiVmHandler8.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits Handler(8), a frequent non-sentinel selector in the GOG compiled
-// script corpus. Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler8 extends GhidraScript {
- private static final long ADDRESS = 0x10009b0dL;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM Handler(8) =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler8Callees.java b/tools/ghidra/ExportAiVmHandler8Callees.java
deleted file mode 100644
index 6ddf317..0000000
--- a/tools/ghidra/ExportAiVmHandler8Callees.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Emits the two non-trivial local callees reached by Handler(8). Run headless;
-// the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler8Callees extends GhidraScript {
- private static final long[] ADDRESSES = {0x10002e90L, 0x10005710L};
-
- @Override
- public void run() throws Exception {
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- for (long value : ADDRESSES) {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(value);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI Handler(8) callee " + address + " =====");
- if (function == null) { println("missing"); continue; }
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- }
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmHandler8Transitions.java b/tools/ghidra/ExportAiVmHandler8Transitions.java
deleted file mode 100644
index 2eee9a8..0000000
--- a/tools/ghidra/ExportAiVmHandler8Transitions.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Emits the state-transition helpers selected by Handler(8). Run headless;
-// the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmHandler8Transitions extends GhidraScript {
- private static final long[] ADDRESSES = {0x10005010L, 0x10005040L};
-
- @Override
- public void run() throws Exception {
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- for (long value : ADDRESSES) {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(value);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI Handler(8) transition " + address + " =====");
- if (function == null) { println("missing"); continue; }
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- }
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAiVmTableInstall.java b/tools/ghidra/ExportAiVmTableInstall.java
deleted file mode 100644
index 842e7dc..0000000
--- a/tools/ghidra/ExportAiVmTableInstall.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the routine that receives the AI VM's verified 73-entry handler table.
-// Run through Ghidra headless analysis; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAiVmTableInstall extends GhidraScript {
- private static final long ADDRESS = 0x10011E70L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== AI VM handler table install =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportAniMeshControlCaller.java b/tools/ghidra/ExportAniMeshControlCaller.java
deleted file mode 100644
index 083d27f..0000000
--- a/tools/ghidra/ExportAniMeshControlCaller.java
+++ /dev/null
@@ -1,21 +0,0 @@
-// Emits the function containing the observed AniMesh Control-loader sequence.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportAniMeshControlCaller extends GhidraScript {
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(0x100032e7L);
- Function function = currentProgram.getFunctionManager().getFunctionContaining(address);
- println("===== AniMesh Control caller =====");
- if (function == null) { println("missing"); return; }
- println("entry=" + function.getEntryPoint());
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportControlFunctions.java b/tools/ghidra/ExportControlFunctions.java
deleted file mode 100644
index 0a31023..0000000
--- a/tools/ghidra/ExportControlFunctions.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Emits decompiled C for the stable public Control.dll exports.
-// Run only through Ghidra headless analysis; it does not modify the input PE.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportControlFunctions extends GhidraScript {
- private static final String[] NAMES = {
- "InitializeSettings", "LoadControlSystem", "LoadPhysicalModel",
- "CreateCollManager", "CreateCollObject"
- };
- private static final long[] ADDRESSES = {
- 0x10032260L, 0x10032280L, 0x10032580L, 0x100325d0L, 0x10032600L
- };
-
- @Override
- public void run() throws Exception {
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- for (int index = 0; index < NAMES.length; index++) {
- Address address = currentProgram.getAddressFactory()
- .getDefaultAddressSpace().getAddress(ADDRESSES[index]);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("\n===== " + NAMES[index] + " =====");
- if (function == null) {
- println("missing");
- continue;
- }
- println(decompiler.decompileFunction(function, 60, monitor)
- .getDecompiledFunction().getC());
- }
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportIron3dAiCallback.java b/tools/ghidra/ExportIron3dAiCallback.java
deleted file mode 100644
index 23e9888..0000000
--- a/tools/ghidra/ExportIron3dAiCallback.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the live CreateSuperAI host callback selected by the GOG AutoDemo.
-// Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportIron3dAiCallback extends GhidraScript {
- private static final long ADDRESS = 0x100611d0L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== Iron3D CreateSuperAI callback =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportIron3dAiCallbackCommand1.java b/tools/ghidra/ExportIron3dAiCallbackCommand1.java
deleted file mode 100644
index 08e8d73..0000000
--- a/tools/ghidra/ExportIron3dAiCallbackCommand1.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits the command-one consumer reached from the recovered CreateSuperAI
-// host callback. Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportIron3dAiCallbackCommand1 extends GhidraScript {
- private static final long ADDRESS = 0x10095160L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== Iron3D CreateSuperAI callback command 1 =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/ExportIron3dAiCallbackCommand1Dispatch.java b/tools/ghidra/ExportIron3dAiCallbackCommand1Dispatch.java
deleted file mode 100644
index a5712a1..0000000
--- a/tools/ghidra/ExportIron3dAiCallbackCommand1Dispatch.java
+++ /dev/null
@@ -1,23 +0,0 @@
-// Emits command one's selected-node dispatch callee. Run headless; the
-// original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-
-public class ExportIron3dAiCallbackCommand1Dispatch extends GhidraScript {
- private static final long ADDRESS = 0x10095600L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- Function function = currentProgram.getFunctionManager().getFunctionAt(address);
- println("===== Iron3D callback command 1 node dispatch =====");
- if (function == null) { println("missing"); return; }
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- println(decompiler.decompileFunction(function, 120, monitor).getDecompiledFunction().getC());
- decompiler.dispose();
- }
-}
diff --git a/tools/ghidra/FindAiScriptLoaderReferences.java b/tools/ghidra/FindAiScriptLoaderReferences.java
deleted file mode 100644
index 5aae152..0000000
--- a/tools/ghidra/FindAiScriptLoaderReferences.java
+++ /dev/null
@@ -1,29 +0,0 @@
-// Locates callers that reference the stable AI script-loader literals.
-// Run through Ghidra headless analysis; the original PE is read only.
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-import ghidra.program.model.mem.Memory;
-import ghidra.program.model.symbol.Reference;
-import ghidra.program.model.symbol.ReferenceManager;
-
-public class FindAiScriptLoaderReferences extends GhidraScript {
- private static final String[] NEEDLES = {".scr", "MISSIONS\\SCRIPTS\\"};
-
- @Override
- public void run() throws Exception {
- Memory memory = currentProgram.getMemory();
- ReferenceManager references = currentProgram.getReferenceManager();
- for (String needle : NEEDLES) {
- byte[] bytes = (needle + "\0").getBytes("US-ASCII");
- Address address = memory.findBytes(memory.getMinAddress(), memory.getMaxAddress(), bytes, null, true, monitor);
- println("===== " + needle + " =====");
- if (address == null) { println("missing"); continue; }
- println("literal=" + address);
- for (Reference reference : references.getReferencesTo(address)) {
- Function caller = currentProgram.getFunctionManager().getFunctionContaining(reference.getFromAddress());
- println("reference=" + reference.getFromAddress() + " caller=" + (caller == null ? "missing" : caller.getEntryPoint()));
- }
- }
- }
-}
diff --git a/tools/ghidra/FindAiVmHandler30Callback.java b/tools/ghidra/FindAiVmHandler30Callback.java
deleted file mode 100644
index a6da0ad..0000000
--- a/tools/ghidra/FindAiVmHandler30Callback.java
+++ /dev/null
@@ -1,27 +0,0 @@
-// Finds writers and readers of Handler(30)'s callback pointer, then decompiles
-// their containing functions. Run headless; the original PE remains read only.
-import ghidra.app.decompiler.DecompInterface;
-import ghidra.app.script.GhidraScript;
-import ghidra.program.model.address.Address;
-import ghidra.program.model.listing.Function;
-import ghidra.program.model.symbol.Reference;
-
-public class FindAiVmHandler30Callback extends GhidraScript {
- private static final long ADDRESS = 0x100555e4L;
-
- @Override
- public void run() throws Exception {
- Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
- .getAddress(ADDRESS);
- DecompInterface decompiler = new DecompInterface();
- decompiler.openProgram(currentProgram);
- for (Reference reference : currentProgram.getReferenceManager().getReferencesTo(address)) {
- Function function = currentProgram.getFunctionManager()
- .getFunctionContaining(reference.getFromAddress());
- println("===== callback reference " + reference.getFromAddress() + " =====");
- if (function == null) { println("no containing function"); continue; }
- println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
- }
- decompiler.dispose();
- }
-}
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
deleted file mode 100644
index 9808023..0000000
--- a/xtask/Cargo.toml
+++ /dev/null
@@ -1,17 +0,0 @@
-[package]
-name = "xtask"
-version.workspace = true
-edition.workspace = true
-license.workspace = true
-repository.workspace = true
-
-[dependencies]
-fparkan-binary = { path = "../crates/fparkan-binary", version = "0.1.0" }
-fparkan-corpus = { path = "../crates/fparkan-corpus", version = "0.1.0" }
-cargo_metadata = "0.23.0"
-serde = { version = "1.0", features = ["derive"] }
-serde_json = "1.0"
-toml = "0.9"
-
-[lints]
-workspace = true
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
deleted file mode 100644
index e2f4a53..0000000
--- a/xtask/src/main.rs
+++ /dev/null
@@ -1,3861 +0,0 @@
-#![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)]
-//! Repository automation for `FParkan`.
-
-use cargo_metadata::MetadataCommand;
-use fparkan_binary::{sha256, sha256_hex};
-use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
-use serde::{Deserialize, Serialize};
-use std::collections::{BTreeMap, BTreeSet};
-use std::fmt;
-use std::fs;
-use std::path::{Path, PathBuf};
-use std::process::Command;
-use std::time::{SystemTime, UNIX_EPOCH};
-
-const CORPORA_MANIFEST_ENV: &str = "FPARKAN_CORPORA_MANIFEST";
-const PART1_ROOT_ENV: &str = "FPARKAN_CORPUS_PART1_ROOT";
-const PART2_ROOT_ENV: &str = "FPARKAN_CORPUS_PART2_ROOT";
-const CI_ACCEPTANCE_ROADMAP: &str = "fixtures/acceptance/stage_0_roadmap.md";
-const CI_ACCEPTANCE_COVERAGE: &str = "fixtures/acceptance/coverage.tsv";
-const CI_ACCEPTANCE_REPORT: &str = "target/fparkan/acceptance/stage-0-audit.json";
-const SHADER_MANIFEST_REPORT: &str = "adapters/fparkan-render-vulkan/shaders/manifest.json";
-const STAGE_PACKAGE_MANIFEST: &str = "fixtures/acceptance/stage_packages.toml";
-const SUPPLY_CHAIN_POLICY_CONFIG: &str = "deny.toml";
-/// Windows is the only supported native runtime platform.
-const REQUIRED_NATIVE_SMOKE_PLATFORMS: &[&str] = &["windows"];
-const APPROVED_REGISTRY_SOURCE: &str = "registry+https://github.com/rust-lang/crates.io-index";
-const SUPPLY_CHAIN_BANNED_PACKAGES: &[&str] = &["native-tls", "openssl", "openssl-sys"];
-const PINNED_RUST_TOOLCHAIN: &str = "1.97.1";
-const WORKSPACE_MSRV: &str = "1.97";
-const ALLOW_SUPPLY_CHAIN_FALLBACK_ENV: &str = "FPARKAN_ALLOW_SUPPLY_CHAIN_FALLBACK";
-
-fn workspace_root_path() -> PathBuf {
- Path::new(env!("CARGO_MANIFEST_DIR"))
- .parent()
- .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")))
- .to_path_buf()
-}
-
-fn workspace_relative_path(path: &str) -> PathBuf {
- workspace_root_path().join(path)
-}
-
-fn main() {
- let args = std::env::args().skip(1).collect::<Vec<_>>();
- let code = match run(&args) {
- Ok(()) => 0,
- Err(err) => {
- eprintln!("{err}");
- 2
- }
- };
- std::process::exit(code);
-}
-
-fn run(args: &[String]) -> Result<(), String> {
- match args {
- [cmd] if cmd == "ci" => {
- run_cargo_fmt_check()?;
- run_policy(Path::new("."))?;
- run_shader_provenance_verification()?;
- cargo(&["test", "--workspace", "--all-targets", "--all-features", "--locked"])?;
- cargo(&[
- "clippy",
- "--workspace",
- "--all-targets",
- "--all-features",
- "--locked",
- "--",
- "-D",
- "warnings",
- ])?;
- run_cargo_doc()?;
- run_cargo_deny()?;
- run_acceptance_audit(&AuditOptions {
- roadmap: PathBuf::from(CI_ACCEPTANCE_ROADMAP),
- coverage: PathBuf::from(CI_ACCEPTANCE_COVERAGE),
- out: PathBuf::from(CI_ACCEPTANCE_REPORT),
- strict: true,
- })?;
- Ok(())
- }
- [cmd] if cmd == "policy" => run_policy(Path::new(".")),
- [cmd] if cmd == "shader-provenance" => run_shader_provenance_verification(),
- [cmd, subcmd, rest @ ..] if cmd == "acceptance" && subcmd == "report" => {
- let options = parse_acceptance_options(rest)?;
- run_acceptance_report(&options)
- }
- [cmd, subcmd, rest @ ..] if cmd == "acceptance" && subcmd == "audit" => {
- let options = parse_audit_options(rest)?;
- run_acceptance_audit(&options)
- }
- [cmd, subcmd, rest @ ..] if cmd == "native-smoke" && subcmd == "audit" => {
- let options = parse_native_smoke_audit_options(rest)?;
- run_native_smoke_audit(&options)
- }
- [cmd, rest @ ..] if cmd == "package" => {
- let options = parse_package_options(rest)?;
- run_package(&options)
- }
- [cmd, suite, rest @ ..] if cmd == "test" && suite == "synthetic" => {
- let options = parse_test_options(rest, PathBuf::from("testdata"))?;
- run_stage_tests(options.stage, TestSuite::Synthetic, None)
- }
- [cmd, suite, rest @ ..] if cmd == "test" && suite == "licensed" => {
- let options = parse_test_options(rest, PathBuf::from("testdata"))?;
- let roots = load_licensed_roots(options.manifest.as_deref())?;
- run_stage_tests(options.stage, TestSuite::Licensed, Some(&roots))
- }
- [cmd, subcmd, rest @ ..] if cmd == "corpus" && subcmd == "baseline" => {
- run_corpus_baseline(&parse_corpus_baseline_options(rest)?)
- }
- _ => Err(
- "usage: cargo xtask ci | policy | shader-provenance | acceptance report --suite synthetic|licensed [--stage 0..5|all] [--manifest corpora.toml] [--out <path>] | acceptance audit [--roadmap <path>] [--coverage <path>] [--out <path>] [--strict] | native-smoke audit --dir <path> [--expected-commit <sha>] [--expected-shader-manifest-hash <sha256>] | package --target <triple> --app viewer|game|headless|cli | test synthetic|licensed [--stage 0..5|all] [--manifest corpora.toml] | corpus baseline --root <path> [--out <path>]"
- .to_string(),
- ),
- }
-}
-
-fn cargo(args: &[&str]) -> Result<(), String> {
- let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
- let status = Command::new(cargo)
- .args(args)
- .status()
- .map_err(|err| format!("failed to run cargo: {err}"))?;
- if status.success() {
- Ok(())
- } else {
- Err(format!("cargo exited with {status}"))
- }
-}
-
-fn cargo_owned(args: &[String]) -> Result<(), String> {
- let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
- let status = Command::new(cargo)
- .args(args)
- .status()
- .map_err(|err| format!("failed to run cargo: {err}"))?;
- if status.success() {
- Ok(())
- } else {
- Err(format!("cargo exited with {status}"))
- }
-}
-
-fn cargo_with_env(args: &[&str], envs: &[(&str, &Path)]) -> Result<(), String> {
- let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
- let mut command = Command::new(cargo);
- command.args(args);
- for (key, value) in envs {
- command.env(key, value);
- }
- let status = command
- .status()
- .map_err(|err| format!("failed to run cargo: {err}"))?;
- if status.success() {
- Ok(())
- } else {
- Err(format!("cargo exited with {status}"))
- }
-}
-
-fn run_cargo_fmt_check() -> Result<(), String> {
- let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
- let status = Command::new(cargo)
- .args(["fmt", "--all", "--", "--check"])
- .status()
- .map_err(|err| format!("failed to run rustfmt: {err}"))?;
- if status.success() {
- Ok(())
- } else {
- Err(format!("cargo fmt exited with {status}"))
- }
-}
-
-fn run_shader_provenance_verification() -> Result<(), String> {
- let manifest_path = workspace_relative_path(SHADER_MANIFEST_REPORT);
- let manifest = load_shader_manifest(&manifest_path)?;
- let compiler_path = resolve_required_tool("FPARKAN_GLSLANG_VALIDATOR", &manifest.compiler)?;
- let validator_path = resolve_required_tool("FPARKAN_SPIRV_VAL", &manifest.validator)?;
-
- verify_tool_metadata(&compiler_path, &manifest.compiler)?;
- verify_tool_metadata(&validator_path, &manifest.validator)?;
-
- let out_dir = shader_provenance_output_dir();
- if out_dir.exists() {
- fs::remove_dir_all(&out_dir).map_err(|err| format!("{}: {err}", out_dir.display()))?;
- }
- fs::create_dir_all(&out_dir).map_err(|err| format!("{}: {err}", out_dir.display()))?;
-
- for module in &manifest.modules {
- verify_shader_module(&manifest, module, &compiler_path, &validator_path, &out_dir)?;
- }
- Ok(())
-}
-
-fn load_shader_manifest(path: &Path) -> Result<ShaderManifestJson, String> {
- let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
- let manifest = serde_json::from_str::<ShaderManifestJson>(&text)
- .map_err(|err| format!("{}: invalid shader manifest JSON: {err}", path.display()))?;
- if manifest.modules.is_empty() {
- return Err(format!(
- "{}: shader manifest must describe at least one module",
- path.display()
- ));
- }
- if manifest.manifest_hash.trim().is_empty() {
- return Err(format!(
- "{}: shader manifest must include a non-empty manifest_hash",
- path.display()
- ));
- }
- Ok(manifest)
-}
-
-fn resolve_required_tool(
- env_var: &str,
- manifest: &ShaderToolManifestJson,
-) -> Result<PathBuf, String> {
- let requested = std::env::var(env_var).unwrap_or_else(|_| manifest.name.clone());
- resolve_tool_path(&requested).ok_or_else(|| {
- format!(
- "required shader tool {} is unavailable (set {env_var} to override path)",
- manifest.name
- )
- })
-}
-
-fn resolve_tool_path(tool: &str) -> Option<PathBuf> {
- let candidate = Path::new(tool);
- if candidate.components().count() > 1 {
- return candidate.is_file().then(|| candidate.to_path_buf());
- }
-
- if let Some(path) = find_tool_on_path(tool) {
- return Some(path);
- }
-
- windows_vulkan_sdk_tool(tool)
-}
-
-fn find_tool_on_path(tool: &str) -> Option<PathBuf> {
- let path = std::env::var_os("PATH")?;
- let extensions: &[&str] = if cfg!(windows) { &[".exe", ""] } else { &[""] };
- for directory in std::env::split_paths(&path) {
- for extension in extensions {
- let candidate = directory.join(format!("{tool}{extension}"));
- if candidate.is_file() {
- return Some(candidate);
- }
- }
- }
- None
-}
-
-#[cfg(windows)]
-fn windows_vulkan_sdk_tool(tool: &str) -> Option<PathBuf> {
- let mut roots = Vec::new();
- if let Some(root) = std::env::var_os("VULKAN_SDK") {
- roots.push(PathBuf::from(root));
- }
- let sdk_root = PathBuf::from(r"C:\VulkanSDK");
- let mut versions = fs::read_dir(&sdk_root)
- .ok()?
- .filter_map(Result::ok)
- .map(|entry| entry.path())
- .filter(|path| path.is_dir())
- .collect::<Vec<_>>();
- versions.sort();
- versions.reverse();
- roots.extend(versions);
-
- roots
- .into_iter()
- .map(|root| root.join("Bin").join(format!("{tool}.exe")))
- .find(|candidate| candidate.is_file())
-}
-
-#[cfg(not(windows))]
-fn windows_vulkan_sdk_tool(_tool: &str) -> Option<PathBuf> {
- None
-}
-
-fn verify_tool_metadata(path: &Path, manifest: &ShaderToolManifestJson) -> Result<(), String> {
- let actual_name = path
- .file_name()
- .and_then(|value| value.to_str())
- .ok_or_else(|| format!("{}: invalid tool filename", path.display()))?;
- let actual_name = actual_name.strip_suffix(".exe").unwrap_or(actual_name);
- if actual_name != manifest.name {
- return Err(format!(
- "{}: tool name mismatch, expected {}, found {}",
- path.display(),
- manifest.name,
- actual_name
- ));
- }
- let actual_version = tool_version(path)?;
- let actual_sha256 = sha256_file(path)?;
- if actual_version != manifest.version || actual_sha256 != manifest.binary_sha256 {
- eprintln!(
- "warning: {} is non-canonical shader tooling (expected version {:?}, SHA-256 {}; found version {:?}, SHA-256 {}). Checked-in SPIR-V hashes remain mandatory.",
- path.display(),
- manifest.version,
- manifest.binary_sha256,
- actual_version,
- actual_sha256
- );
- }
- Ok(())
-}
-
-fn tool_version(path: &Path) -> Result<String, String> {
- let output = Command::new(path)
- .arg("--version")
- .output()
- .map_err(|err| format!("{} --version: {err}", path.display()))?;
- if !output.status.success() {
- return Err(format!(
- "{} --version exited with {}",
- path.display(),
- output.status
- ));
- }
- let stdout = String::from_utf8(output.stdout)
- .map_err(|err| format!("{} --version: invalid UTF-8: {err}", path.display()))?;
- stdout
- .lines()
- .find(|line| !line.trim().is_empty())
- .map(str::trim)
- .map(|line| {
- line.strip_prefix("Glslang Version: ")
- .unwrap_or(line)
- .to_string()
- })
- .ok_or_else(|| format!("{} --version returned no version line", path.display()))
-}
-
-fn verify_shader_module(
- manifest: &ShaderManifestJson,
- module: &ShaderModuleManifestJson,
- compiler_path: &Path,
- validator_path: &Path,
- out_dir: &Path,
-) -> Result<(), String> {
- let source_path = workspace_relative_path(&module.source_path);
- let checked_in_spirv_path = workspace_relative_path(&module.spirv_path);
- let generated_spirv_path = out_dir.join(format!("{}.spv", module.name));
-
- let source_sha256 = sha256_canonical_text_file(&source_path)?;
- if source_sha256 != module.source_sha256 {
- return Err(format!(
- "{}: source SHA-256 mismatch, expected {}, found {}",
- source_path.display(),
- module.source_sha256,
- source_sha256
- ));
- }
-
- let checked_in_spirv_sha256 = sha256_file(&checked_in_spirv_path)?;
- if checked_in_spirv_sha256 != module.sha256 {
- return Err(format!(
- "{}: checked-in SPIR-V SHA-256 mismatch, expected {}, found {}",
- checked_in_spirv_path.display(),
- module.sha256,
- checked_in_spirv_sha256
- ));
- }
-
- compile_shader_module(
- compiler_path,
- &manifest.target_env,
- module,
- &source_path,
- &generated_spirv_path,
- )?;
- validate_shader_module(validator_path, &manifest.target_env, &generated_spirv_path)?;
-
- let generated_spirv_sha256 = sha256_file(&generated_spirv_path)?;
- if generated_spirv_sha256 != module.sha256 {
- return Err(format!(
- "{}: generated SPIR-V SHA-256 mismatch, expected {}, found {}",
- generated_spirv_path.display(),
- module.sha256,
- generated_spirv_sha256
- ));
- }
- Ok(())
-}
-
-fn compile_shader_module(
- compiler_path: &Path,
- target_env: &str,
- module: &ShaderModuleManifestJson,
- source_path: &Path,
- output_path: &Path,
-) -> Result<(), String> {
- let stage = glslang_stage(&module.stage).ok_or_else(|| {
- format!(
- "{}: unsupported shader stage {:?}",
- source_path.display(),
- module.stage
- )
- })?;
- let output = Command::new(compiler_path)
- .args(["-V", "--target-env", target_env, "-S", stage, "-e"])
- .arg(&module.entry_point)
- .arg(source_path)
- .arg("-o")
- .arg(output_path)
- .output()
- .map_err(|err| {
- format!(
- "{}: shader compile failed to start: {err}",
- source_path.display()
- )
- })?;
- if !output.status.success() {
- return Err(format!(
- "{}: shader compile failed:\n{}{}",
- source_path.display(),
- String::from_utf8_lossy(&output.stdout),
- String::from_utf8_lossy(&output.stderr)
- ));
- }
- Ok(())
-}
-
-fn validate_shader_module(
- validator_path: &Path,
- target_env: &str,
- module_path: &Path,
-) -> Result<(), String> {
- let output = Command::new(validator_path)
- .args(["--target-env", target_env])
- .arg(module_path)
- .output()
- .map_err(|err| {
- format!(
- "{}: shader validation failed to start: {err}",
- module_path.display()
- )
- })?;
- if !output.status.success() {
- return Err(format!(
- "{}: shader validation failed:\n{}{}",
- module_path.display(),
- String::from_utf8_lossy(&output.stdout),
- String::from_utf8_lossy(&output.stderr)
- ));
- }
- Ok(())
-}
-
-fn glslang_stage(stage: &str) -> Option<&'static str> {
- match stage {
- "vertex" => Some("vert"),
- "fragment" => Some("frag"),
- _ => None,
- }
-}
-
-fn sha256_file(path: &Path) -> Result<String, String> {
- let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
- Ok(sha256_hex(&sha256(&bytes)))
-}
-
-fn sha256_canonical_text_file(path: &Path) -> Result<String, String> {
- let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
- let mut canonical = Vec::with_capacity(bytes.len());
- let mut index = 0;
- while index < bytes.len() {
- if bytes[index] == b'\r' && bytes.get(index + 1) == Some(&b'\n') {
- canonical.push(b'\n');
- index += 2;
- } else {
- canonical.push(bytes[index]);
- index += 1;
- }
- }
- Ok(sha256_hex(&sha256(&canonical)))
-}
-
-fn shader_provenance_output_dir() -> PathBuf {
- let nonce = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .as_nanos();
- workspace_root_path()
- .join("target")
- .join("fparkan")
- .join("shader-provenance")
- .join(format!("{}-{}", std::process::id(), nonce))
-}
-
-fn run_cargo_deny() -> Result<(), String> {
- validate_supply_chain_policy_config(&workspace_relative_path(SUPPLY_CHAIN_POLICY_CONFIG))?;
- let cargo_deny = std::env::var_os("CARGO_DENY").unwrap_or_else(|| "cargo-deny".into());
- let version_output = match Command::new(&cargo_deny).arg("--version").output() {
- Ok(output) => output,
- Err(err) => {
- return handle_cargo_deny_fallback(&format!(
- "failed to run cargo-deny --version: {err}"
- ));
- }
- };
- if !version_output.status.success() {
- return handle_cargo_deny_fallback(&format!(
- "cargo-deny --version exited with {}",
- version_output.status
- ));
- }
- let version_text = String::from_utf8(version_output.stdout)
- .map_err(|err| format!("cargo-deny --version produced invalid UTF-8: {err}"))?;
- if !version_text.contains(PINNED_CARGO_DENY_VERSION) {
- return handle_cargo_deny_fallback(&format!(
- "cargo-deny version mismatch: expected {PINNED_CARGO_DENY_VERSION}, found {}",
- version_text.trim()
- ));
- }
-
- let status = Command::new(cargo_deny)
- .args(["check", "advisories", "bans", "licenses", "sources"])
- .status()
- .map_err(|err| format!("failed to run cargo-deny: {err}"))?;
- if status.success() {
- Ok(())
- } else {
- Err(format!("cargo-deny exited with {status}"))
- }
-}
-
-const PINNED_CARGO_DENY_VERSION: &str = "0.19.9";
-
-fn handle_cargo_deny_fallback(reason: &str) -> Result<(), String> {
- if allow_supply_chain_fallback() {
- eprintln!(
- "{reason}; running built-in supply-chain policy fallback because {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV} is set"
- );
- run_builtin_supply_chain_policy(Path::new("."))
- } else if std::env::var_os(ALLOW_SUPPLY_CHAIN_FALLBACK_ENV).is_some() && ci_env_active() {
- Err(format!(
- "{reason}; {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV} is for local developer convenience only and is forbidden when CI is set"
- ))
- } else {
- Err(format!(
- "{reason}; install cargo-deny {PINNED_CARGO_DENY_VERSION} or explicitly opt into the fallback with {ALLOW_SUPPLY_CHAIN_FALLBACK_ENV}=1"
- ))
- }
-}
-
-fn validate_supply_chain_policy_config(path: &Path) -> Result<(), String> {
- if path.is_file() {
- Ok(())
- } else {
- Err(format!(
- "reviewed supply-chain policy config is missing: {}",
- path.display()
- ))
- }
-}
-
-fn allow_supply_chain_fallback() -> bool {
- std::env::var_os(ALLOW_SUPPLY_CHAIN_FALLBACK_ENV).is_some() && !ci_env_active()
-}
-
-fn ci_env_active() -> bool {
- ci_env_value_is_active(std::env::var("CI").ok().as_deref())
-}
-
-fn ci_env_value_is_active(value: Option<&str>) -> bool {
- value.is_some_and(|value| {
- let trimmed = value.trim();
- !trimmed.is_empty() && trimmed != "0" && !trimmed.eq_ignore_ascii_case("false")
- })
-}
-
-fn run_builtin_supply_chain_policy(root: &Path) -> Result<(), String> {
- let mut failures = Vec::new();
- validate_workspace_license(root, &mut failures)?;
- validate_lockfile_supply_chain(root, &mut failures)?;
- if failures.is_empty() {
- Ok(())
- } else {
- Err(format!(
- "built-in supply-chain policy failed:\n{}",
- failures.join("\n")
- ))
- }
-}
-
-fn run_cargo_doc() -> Result<(), String> {
- let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
- let status = Command::new(cargo)
- .args([
- "doc",
- "--workspace",
- "--all-features",
- "--locked",
- "--no-deps",
- ])
- .env(
- "RUSTDOCFLAGS",
- "-D warnings -D rustdoc::broken_intra_doc_links",
- )
- .status()
- .map_err(|err| format!("failed to run cargo doc: {err}"))?;
- if status.success() {
- Ok(())
- } else {
- Err(format!("cargo doc exited with {status}"))
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct LicensedCorpusRoots {
- part1: PathBuf,
- part2: PathBuf,
-}
-
-impl LicensedCorpusRoots {
- fn envs(&self) -> [(&str, &Path); 2] {
- [
- (PART1_ROOT_ENV, self.part1.as_path()),
- (PART2_ROOT_ENV, self.part2.as_path()),
- ]
- }
-}
-
-fn load_licensed_roots(manifest: Option<&Path>) -> Result<LicensedCorpusRoots, String> {
- let manifest = manifest
- .map(Path::to_path_buf)
- .or_else(|| std::env::var_os(CORPORA_MANIFEST_ENV).map(PathBuf::from))
- .ok_or_else(|| {
- format!(
- "licensed tests require --manifest or {CORPORA_MANIFEST_ENV}=<absolute corpora.toml>"
- )
- })?;
- parse_licensed_manifest(&manifest)
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(deny_unknown_fields)]
-struct LicensedManifest {
- schema: Option<u8>,
- #[serde(rename = "corpus")]
- corpora: Vec<CorpusEntry>,
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(deny_unknown_fields)]
-struct CorpusEntry {
- id: String,
- kind: CorpusKind,
- root: String,
- expected_profile: Option<String>,
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(rename_all = "lowercase")]
-enum CorpusKind {
- Part1,
- Part2,
-}
-
-fn parse_licensed_manifest(path: &Path) -> Result<LicensedCorpusRoots, String> {
- let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
- let manifest: LicensedManifest = toml::from_str(&text)
- .map_err(|err| format!("failed to parse {}: {err}", path.display()))?;
- if manifest.schema.is_some_and(|version| version != 1) {
- return Err(format!(
- "unsupported corpora manifest schema {} (expected 1)",
- manifest.schema.unwrap_or(1)
- ));
- }
-
- let mut part1 = None;
- let mut part2 = None;
-
- for entry in manifest.corpora {
- match entry.kind {
- CorpusKind::Part1 => {
- let root = PathBuf::from(entry.root);
- assign_manifest_root(&mut part1, root, "part1")?;
- }
- CorpusKind::Part2 => {
- let root = PathBuf::from(entry.root);
- assign_manifest_root(&mut part2, root, "part2")?;
- }
- }
- if entry.expected_profile.is_none() {
- return Err(format!(
- "{}: corpus entry '{}' must define expected_profile",
- path.display(),
- entry.id
- ));
- }
- }
-
- let roots = LicensedCorpusRoots {
- part1: part1
- .ok_or_else(|| "licensed manifest is missing part1 corpus entry".to_string())?,
- part2: part2
- .ok_or_else(|| "licensed manifest is missing part2 corpus entry".to_string())?,
- };
- validate_licensed_part("part1", &roots.part1)?;
- validate_licensed_part("part2", &roots.part2)?;
- Ok(roots)
-}
-
-fn assign_manifest_root(
- target: &mut Option<PathBuf>,
- root: PathBuf,
- kind: &str,
-) -> Result<(), String> {
- if target.replace(root).is_some() {
- return Err(format!("licensed manifest contains duplicate {kind} root"));
- }
- Ok(())
-}
-
-fn validate_licensed_part(kind: &str, root: &Path) -> Result<(), String> {
- if root.is_dir() {
- Ok(())
- } else {
- Err(format!(
- "licensed corpus {kind} root is missing: {}",
- root.display()
- ))
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct CorpusBaselineOptions {
- root: PathBuf,
- out: Option<PathBuf>,
-}
-
-fn parse_corpus_baseline_options(args: &[String]) -> Result<CorpusBaselineOptions, String> {
- let mut root = None;
- let mut out = None;
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- match arg.as_str() {
- "--root" => {
- root = Some(
- iter.next()
- .map(PathBuf::from)
- .ok_or_else(|| "--root requires a path".to_string())?,
- );
- }
- "--out" => {
- out = Some(
- iter.next()
- .map(PathBuf::from)
- .ok_or_else(|| "--out requires a path".to_string())?,
- );
- }
- other => return Err(format!("unknown corpus baseline option: {other}")),
- }
- }
- Ok(CorpusBaselineOptions {
- root: root.ok_or_else(|| "missing --root".to_string())?,
- out,
- })
-}
-
-fn run_corpus_baseline(options: &CorpusBaselineOptions) -> Result<(), String> {
- let manifest =
- discover(&options.root, DiscoverOptions::default()).map_err(|error| error.to_string())?;
- let baseline = report(&options.root, &manifest).map_err(|error| error.to_string())?;
- let json = render_report_json(&baseline);
- if let Some(out) = &options.out {
- if let Some(parent) = out.parent() {
- fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
- }
- fs::write(out, json).map_err(|error| format!("{}: {error}", out.display()))?;
- println!("{}", out.display());
- } else {
- println!("{json}");
- }
- Ok(())
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct PackageOptions {
- target: String,
- app: AppPackage,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-enum AppPackage {
- Cli,
- Game,
- Headless,
- Viewer,
-}
-
-impl AppPackage {
- fn parse(value: &str) -> Result<Self, String> {
- match value {
- "cli" => Ok(Self::Cli),
- "game" => Ok(Self::Game),
- "headless" => Ok(Self::Headless),
- "viewer" => Ok(Self::Viewer),
- _ => Err(format!("unknown app: {value}")),
- }
- }
-
- fn package(self) -> &'static str {
- match self {
- Self::Cli => "fparkan-cli",
- Self::Game => "fparkan-game",
- Self::Headless => "fparkan-headless",
- Self::Viewer => "fparkan-viewer",
- }
- }
-}
-
-fn parse_package_options(args: &[String]) -> Result<PackageOptions, String> {
- let mut target = None;
- let mut app = None;
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- match arg.as_str() {
- "--target" => {
- target = Some(
- iter.next()
- .cloned()
- .ok_or_else(|| "--target requires a value".to_string())?,
- );
- }
- "--app" => {
- let value = iter
- .next()
- .ok_or_else(|| "--app requires a value".to_string())?;
- app = Some(AppPackage::parse(value)?);
- }
- _ => return Err(format!("unknown package option: {arg}")),
- }
- }
- Ok(PackageOptions {
- target: target.ok_or_else(|| "missing --target".to_string())?,
- app: app.ok_or_else(|| "missing --app".to_string())?,
- })
-}
-
-fn run_package(options: &PackageOptions) -> Result<(), String> {
- cargo_owned(&[
- "build".to_string(),
- "-p".to_string(),
- options.app.package().to_string(),
- "--release".to_string(),
- "--locked".to_string(),
- "--offline".to_string(),
- "--target".to_string(),
- options.target.clone(),
- ])
-}
-
-fn run_policy(root: &Path) -> Result<(), String> {
- let mut failures = Vec::new();
- validate_toolchain_policy(root, &mut failures)?;
- scan_policy_dir(root, &mut failures)?;
- validate_cargo_metadata(root, &mut failures)?;
- validate_cargo_metadata_dependency_closures(root, &mut failures)?;
- validate_lockfile(root, &mut failures);
- validate_workspace_license(root, &mut failures)?;
- validate_dependency_boundaries(root, &mut failures)?;
- if failures.is_empty() {
- Ok(())
- } else {
- Err(format!("workspace policy failed:\n{}", failures.join("\n")))
- }
-}
-
-fn validate_cargo_metadata(root: &Path, failures: &mut Vec<String>) -> Result<(), String> {
- let manifest = root.join("Cargo.toml");
- if !manifest.exists() {
- return Ok(());
- }
- let metadata = workspace_metadata(root)?;
- if metadata.workspace_members.is_empty() {
- failures.push(format!(
- "{}: cargo metadata produced no workspace members",
- manifest.display()
- ));
- return Ok(());
- }
- let stage_manifest_path = root.join(STAGE_PACKAGE_MANIFEST);
- let stage_manifest = load_stage_package_manifest(&stage_manifest_path)?;
- let workspace_packages = metadata
- .workspace_packages()
- .iter()
- .map(|package| package.name.to_string())
- .collect::<BTreeSet<_>>();
- if let Err(err) =
- validate_stage_package_entries(&stage_manifest, &workspace_packages, &stage_manifest_path)
- {
- failures.push(err);
- }
- Ok(())
-}
-
-fn validate_cargo_metadata_dependency_closures(
- root: &Path,
- failures: &mut Vec<String>,
-) -> Result<(), String> {
- let metadata = workspace_metadata(root)?;
- let deps_by_package = workspace_dependency_names(&metadata);
- validate_package_closure_excludes("fparkan-headless", &deps_by_package, failures);
- Ok(())
-}
-
-/// Returns declared direct dependencies for each workspace package from Cargo's
-/// own metadata model. Cargo, rather than a source-text heuristic, resolves
-/// package identities and workspace membership before this policy sees them.
-fn workspace_dependency_names(
- metadata: &cargo_metadata::Metadata,
-) -> BTreeMap<String, BTreeSet<String>> {
- metadata
- .workspace_packages()
- .iter()
- .map(|package| {
- (
- package.name.to_string(),
- package
- .dependencies
- .iter()
- .map(|dependency| dependency.name.clone())
- .collect(),
- )
- })
- .collect()
-}
-
-fn validate_package_closure_excludes(
- package: &str,
- deps_by_package: &BTreeMap<String, BTreeSet<String>>,
- failures: &mut Vec<String>,
-) {
- if !deps_by_package.contains_key(package) {
- failures.push(format!(
- "workspace manifest graph missing package {package}"
- ));
- return;
- }
- let closure = dependency_closure_names(package, deps_by_package);
- if let Some(forbidden) = first_forbidden_platform_bridge_dependency(&closure) {
- failures.push(format!(
- "workspace manifest closure: package {package} depends on forbidden platform/render dependency {forbidden}"
- ));
- }
-}
-
-fn dependency_closure_names(
- root: &str,
- deps_by_package: &BTreeMap<String, BTreeSet<String>>,
-) -> BTreeSet<String> {
- let mut seen = BTreeSet::new();
- let mut names = BTreeSet::new();
- let mut stack = deps_by_package
- .get(root)
- .cloned()
- .unwrap_or_default()
- .into_iter()
- .collect::<Vec<_>>();
- while let Some(name) = stack.pop() {
- if !seen.insert(name.clone()) {
- continue;
- }
- names.insert(name.clone());
- if let Some(deps) = deps_by_package.get(&name) {
- stack.extend(deps.iter().cloned());
- }
- }
- names
-}
-
-fn validate_toolchain_policy(root: &Path, failures: &mut Vec<String>) -> Result<(), String> {
- let toolchain_path = root.join("rust-toolchain.toml");
- let toolchain_text = fs::read_to_string(&toolchain_path)
- .map_err(|err| format!("{}: {err}", toolchain_path.display()))?;
- let toolchain = toml::from_str::<RustToolchainToml>(&toolchain_text)
- .map_err(|err| format!("{}: invalid TOML: {err}", toolchain_path.display()))?;
- if toolchain.toolchain.channel != PINNED_RUST_TOOLCHAIN {
- failures.push(format!(
- "{}: toolchain channel must be exact {PINNED_RUST_TOOLCHAIN}",
- toolchain_path.display()
- ));
- }
- if !is_exact_rust_patch_version(&toolchain.toolchain.channel) {
- failures.push(format!(
- "{}: toolchain channel must include major.minor.patch, not a moving channel",
- toolchain_path.display()
- ));
- }
-
- let manifest_path = root.join("Cargo.toml");
- let manifest_text = fs::read_to_string(&manifest_path)
- .map_err(|err| format!("{}: {err}", manifest_path.display()))?;
- let manifest = toml::from_str::<WorkspaceManifestToml>(&manifest_text)
- .map_err(|err| format!("{}: invalid TOML: {err}", manifest_path.display()))?;
- if manifest.workspace.package.rust_version != WORKSPACE_MSRV {
- failures.push(format!(
- "{}: workspace.package.rust-version must be {WORKSPACE_MSRV}",
- manifest_path.display()
- ));
- }
- if !PINNED_RUST_TOOLCHAIN.starts_with(&format!("{}.", manifest.workspace.package.rust_version))
- {
- failures.push(format!(
- "{}: workspace.package.rust-version must match pinned toolchain major.minor",
- manifest_path.display()
- ));
- }
- Ok(())
-}
-
-fn is_exact_rust_patch_version(value: &str) -> bool {
- let parts = value.split('.').collect::<Vec<_>>();
- parts.len() == 3
- && parts
- .iter()
- .all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(deny_unknown_fields)]
-struct RustToolchainToml {
- toolchain: RustToolchainTable,
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(deny_unknown_fields)]
-struct RustToolchainTable {
- channel: String,
- #[allow(dead_code)]
- components: Option<Vec<String>>,
- #[allow(dead_code)]
- targets: Option<Vec<String>>,
-}
-
-#[derive(Debug, Deserialize)]
-struct WorkspaceManifestToml {
- workspace: WorkspaceTable,
-}
-
-#[derive(Debug, Deserialize)]
-struct WorkspaceTable {
- package: WorkspacePackageTable,
-}
-
-#[derive(Debug, Deserialize)]
-struct WorkspacePackageTable {
- #[serde(rename = "rust-version")]
- rust_version: String,
-}
-
-fn validate_lockfile(root: &Path, failures: &mut Vec<String>) {
- let lockfile = root.join("Cargo.lock");
- if !lockfile.is_file() {
- failures.push(format!(
- "{}: workspace lockfile is required for locked/offline builds",
- lockfile.display()
- ));
- }
-}
-
-fn validate_lockfile_supply_chain(root: &Path, failures: &mut Vec<String>) -> Result<(), String> {
- let lockfile = root.join("Cargo.lock");
- let packages = read_lockfile_packages(&lockfile)?;
- for package in packages {
- if let Some(source) = package.source.as_deref() {
- if source != APPROVED_REGISTRY_SOURCE {
- failures.push(format!(
- "{}: package {} {} uses unapproved source {source}",
- lockfile.display(),
- package.name,
- package.version
- ));
- }
- }
- if SUPPLY_CHAIN_BANNED_PACKAGES.contains(&package.name.as_str()) {
- failures.push(format!(
- "{}: package {} {} is banned by built-in supply-chain policy",
- lockfile.display(),
- package.name,
- package.version
- ));
- }
- }
- Ok(())
-}
-
-fn read_lockfile_packages(lockfile: &Path) -> Result<Vec<CargoLockPackage>, String> {
- let text =
- fs::read_to_string(lockfile).map_err(|err| format!("{}: {err}", lockfile.display()))?;
- let parsed = toml::from_str::<CargoLock>(&text)
- .map_err(|err| format!("{}: invalid Cargo.lock TOML: {err}", lockfile.display()))?;
- Ok(parsed.package)
-}
-
-#[derive(Debug, Deserialize)]
-struct CargoLock {
- package: Vec<CargoLockPackage>,
-}
-
-#[derive(Debug, Deserialize)]
-struct CargoLockPackage {
- name: String,
- version: String,
- source: Option<String>,
-}
-
-fn validate_workspace_license(root: &Path, failures: &mut Vec<String>) -> Result<(), String> {
- let manifest = root.join("Cargo.toml");
- let license = fs::read_to_string(root.join("LICENSE.txt"))
- .map_err(|err| format!("{}: {err}", root.join("LICENSE.txt").display()))?;
- let expected = if license.contains("GNU GENERAL PUBLIC LICENSE")
- && license.contains("Version 2, June 1991")
- {
- "GPL-2.0-only"
- } else {
- failures.push(format!(
- "{}: unsupported repository license text",
- root.join("LICENSE.txt").display()
- ));
- return Ok(());
- };
-
- let mut manifests = Vec::new();
- collect_cargo_manifests(root, &mut manifests)?;
- manifests.push(manifest);
- manifests.sort();
- manifests.dedup();
-
- for manifest in manifests {
- let policy = load_cargo_manifest_policy(&manifest)?;
- let explicit_license = policy.license();
- let is_root = manifest == root.join("Cargo.toml");
- if is_root {
- if explicit_license.as_deref() != Some(expected) {
- failures.push(format!(
- "{}: workspace.package license must be {expected}",
- manifest.display()
- ));
- }
- } else if let Some(license) = explicit_license {
- if license != expected {
- failures.push(format!(
- "{}: package license {license} does not match repository license {expected}",
- manifest.display()
- ));
- }
- }
- }
- Ok(())
-}
-
-fn validate_dependency_boundaries(root: &Path, failures: &mut Vec<String>) -> Result<(), String> {
- let mut manifests = Vec::new();
- collect_cargo_manifests(root, &mut manifests)?;
- for manifest in manifests {
- let policy = load_cargo_manifest_policy(&manifest)?;
- let Some(package) = policy.package_name() else {
- continue;
- };
- if is_removed_legacy_adapter_manifest(root, &manifest) {
- failures.push(format!(
- "{}: legacy SDL/OpenGL adapter crate must be removed: {package}",
- manifest.display()
- ));
- continue;
- }
- let dependencies = policy.dependency_names();
- if !is_adapter_like_package(package) && !is_app_package(package) {
- for dependency in &dependencies {
- if is_forbidden_gui_dependency(dependency) {
- failures.push(format!(
- "{}: package {package} depends on forbidden GUI/adapter package {dependency}",
- manifest.display()
- ));
- }
- }
- }
- if is_app_package(package) {
- if let Some(forbidden) = first_forbidden_parser_dependency(&dependencies) {
- failures.push(format!(
- "{}: app package {package} depends on parser crate {forbidden}",
- manifest.display()
- ));
- }
- }
- if package == "fparkan-headless" {
- if let Some(forbidden) = first_forbidden_platform_bridge_dependency(&dependencies) {
- failures.push(format!(
- "{}: headless package {package} depends on platform/render bridge dependency {forbidden}",
- manifest.display()
- ));
- }
- }
-
- if package == "fparkan-runtime" {
- if let Some(forbidden) = first_forbidden_parser_dependency(&dependencies) {
- failures.push(format!(
- "{}: runtime package {package} depends on parser crate {forbidden}",
- manifest.display()
- ));
- }
- if let Some(forbidden) = first_forbidden_platform_bridge_dependency(&dependencies) {
- failures.push(format!(
- "{}: runtime package {package} depends on forbidden platform/driver crate {forbidden}",
- manifest.display()
- ));
- }
- }
-
- if package == "fparkan-prototype" {
- if let Some(forbidden) = first_forbidden_visual_dependency(&dependencies) {
- failures.push(format!(
- "{}: prototype package {package} depends on forbidden visual parser {forbidden}",
- manifest.display()
- ));
- }
- }
- }
- Ok(())
-}
-
-fn is_app_package(package: &str) -> bool {
- matches!(
- package,
- "fparkan-cli"
- | "fparkan-game"
- | "fparkan-headless"
- | "fparkan-vulkan-smoke"
- | "fparkan-viewer"
- )
-}
-
-fn is_adapter_like_package(package: &str) -> bool {
- matches!(package, "fparkan-platform-winit" | "fparkan-render-vulkan")
-}
-
-fn first_forbidden_parser_dependency(dependencies: &BTreeSet<String>) -> Option<&str> {
- [
- "fparkan-msh",
- "fparkan-nres",
- "fparkan-rsli",
- "fparkan-terrain-format",
- "fparkan-texm",
- "fparkan-mission-format",
- "fparkan-material",
- "fparkan-fx",
- ]
- .iter()
- .find_map(|forbidden| {
- if dependencies.contains(*forbidden) {
- Some(*forbidden)
- } else {
- None
- }
- })
-}
-
-fn first_forbidden_visual_dependency(dependencies: &BTreeSet<String>) -> Option<&str> {
- [
- "fparkan-msh",
- "fparkan-material",
- "fparkan-texm",
- "fparkan-fx",
- "fparkan-terrain-format",
- ]
- .iter()
- .find_map(|forbidden| {
- if dependencies.contains(*forbidden) {
- Some(*forbidden)
- } else {
- None
- }
- })
-}
-
-fn first_forbidden_platform_bridge_dependency(dependencies: &BTreeSet<String>) -> Option<&str> {
- [
- "fparkan-platform-winit",
- "fparkan-render-vulkan",
- "winit",
- "ash",
- "ash-window",
- ]
- .iter()
- .find_map(|forbidden| {
- if dependencies.contains(*forbidden) {
- Some(*forbidden)
- } else {
- None
- }
- })
-}
-
-fn is_forbidden_domain_dependency(dependency: &str) -> bool {
- matches!(
- dependency,
- "fparkan-cli"
- | "fparkan-game"
- | "fparkan-headless"
- | "fparkan-viewer"
- | "fparkan-platform-sdl"
- | "fparkan-render-gl"
- | "sdl2"
- | "gl"
- | "glow"
- | "glium"
- | "glutin"
- )
-}
-
-fn is_forbidden_gui_dependency(dependency: &str) -> bool {
- is_forbidden_domain_dependency(dependency) || is_forbidden_platform_dependency(dependency)
-}
-
-fn is_forbidden_platform_dependency(dependency: &str) -> bool {
- matches!(
- dependency,
- "fparkan-platform-winit" | "fparkan-render-vulkan" | "winit" | "ash" | "ash-window"
- )
-}
-
-fn collect_cargo_manifests(dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
- let entries = fs::read_dir(dir).map_err(|err| format!("{}: {err}", dir.display()))?;
- for entry in entries {
- let entry = entry.map_err(|err| format!("{}: {err}", dir.display()))?;
- let path = entry.path();
- if should_skip_policy_path(&path) {
- continue;
- }
- let file_type = entry
- .file_type()
- .map_err(|err| format!("{}: {err}", path.display()))?;
- if file_type.is_dir() {
- collect_cargo_manifests(&path, out)?;
- } else if file_type.is_file()
- && path
- .file_name()
- .and_then(|name| name.to_str())
- .is_some_and(|name| name == "Cargo.toml")
- {
- out.push(path);
- }
- }
- Ok(())
-}
-
-/// Typed Cargo manifest subset used by the architecture policy.
-///
-/// Dependency values deliberately remain `toml::Value`: the policy only needs
-/// keys and Cargo accepts several value forms (version string, inline table,
-/// or workspace inheritance).
-#[derive(Debug, Default, Deserialize)]
-struct CargoManifestPolicy {
- package: Option<CargoPackagePolicy>,
- workspace: Option<CargoWorkspacePolicy>,
- dependencies: Option<BTreeMap<String, toml::Value>>,
- #[serde(rename = "dev-dependencies")]
- dev_dependencies: Option<BTreeMap<String, toml::Value>>,
- #[serde(rename = "build-dependencies")]
- build_dependencies: Option<BTreeMap<String, toml::Value>>,
-}
-
-#[derive(Debug, Deserialize)]
-struct CargoPackagePolicy {
- name: String,
- license: Option<toml::Value>,
-}
-
-#[derive(Debug, Deserialize)]
-struct CargoWorkspacePolicy {
- package: Option<CargoWorkspacePackagePolicy>,
-}
-
-#[derive(Debug, Deserialize)]
-struct CargoWorkspacePackagePolicy {
- license: Option<toml::Value>,
-}
-
-impl CargoManifestPolicy {
- fn package_name(&self) -> Option<&str> {
- self.package.as_ref().map(|package| package.name.as_str())
- }
-
- fn license(&self) -> Option<String> {
- self.package
- .as_ref()
- .and_then(|package| cargo_license_string(package.license.as_ref()))
- .or_else(|| {
- self.workspace
- .as_ref()
- .and_then(|workspace| workspace.package.as_ref())
- .and_then(|package| cargo_license_string(package.license.as_ref()))
- })
- }
-
- fn dependency_names(&self) -> BTreeSet<String> {
- [
- self.dependencies.as_ref(),
- self.dev_dependencies.as_ref(),
- self.build_dependencies.as_ref(),
- ]
- .into_iter()
- .flatten()
- .flat_map(|dependencies| dependencies.keys().cloned())
- .collect()
- }
-}
-
-fn cargo_license_string(value: Option<&toml::Value>) -> Option<String> {
- value.and_then(toml::Value::as_str).map(ToString::to_string)
-}
-
-fn load_cargo_manifest_policy(path: &Path) -> Result<CargoManifestPolicy, String> {
- let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
- toml::from_str(&text).map_err(|err| format!("{}: invalid Cargo TOML: {err}", path.display()))
-}
-
-fn is_removed_legacy_adapter_manifest(root: &Path, manifest: &Path) -> bool {
- let normalized = manifest.strip_prefix(root).unwrap_or(manifest);
- normalized.starts_with("adapters/fparkan-platform-sdl")
- || normalized.starts_with("adapters/fparkan-render-gl")
-}
-
-fn scan_policy_dir(dir: &Path, failures: &mut Vec<String>) -> Result<(), String> {
- let entries = match fs::read_dir(dir) {
- Ok(entries) => entries,
- Err(err) => return Err(format!("{}: {err}", dir.display())),
- };
- for entry in entries {
- let entry = entry.map_err(|err| format!("{}: {err}", dir.display()))?;
- let path = entry.path();
- if should_skip_policy_path(&path) {
- continue;
- }
- let file_type = entry
- .file_type()
- .map_err(|err| format!("{}: {err}", path.display()))?;
- if file_type.is_dir() {
- if is_forbidden_generic_crate_dir(&path) {
- failures.push(format!(
- "{}: package under crates/ must use the fparkan-* prefix",
- path.display()
- ));
- }
- scan_policy_dir(&path, failures)?;
- } else if file_type.is_file() {
- scan_repository_file_policy(&path, failures)?;
- if is_policy_source(&path) {
- scan_policy_file(&path, failures)?;
- }
- }
- }
- Ok(())
-}
-
-fn should_skip_policy_path(path: &Path) -> bool {
- path.file_name()
- .and_then(|name| name.to_str())
- .is_some_and(|name| {
- matches!(
- name,
- ".git" | "target" | "testdata" | ".idea" | ".vscode" | ".DS_Store"
- )
- })
-}
-
-fn is_policy_source(path: &Path) -> bool {
- path.extension()
- .and_then(|ext| ext.to_str())
- .is_some_and(|ext| matches!(ext, "rs" | "toml"))
-}
-
-fn is_forbidden_generic_crate_dir(path: &Path) -> bool {
- path.parent()
- .and_then(Path::file_name)
- .and_then(|name| name.to_str())
- .is_some_and(|name| name == "crates")
- && path
- .file_name()
- .and_then(|name| name.to_str())
- .is_some_and(|name| !name.starts_with("fparkan-"))
-}
-
-fn scan_repository_file_policy(path: &Path, failures: &mut Vec<String>) -> Result<(), String> {
- if path
- .extension()
- .and_then(|ext| ext.to_str())
- .is_some_and(|ext| ext == "py")
- {
- failures.push(format!(
- "{}: Python source file is forbidden",
- path.display()
- ));
- }
-
- let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
- if bytes.starts_with(b"#!") {
- let first_line = bytes
- .split(|byte| *byte == b'\n')
- .next()
- .unwrap_or_default()
- .to_ascii_lowercase();
- if first_line
- .windows("python".len())
- .any(|window| window == b"python")
- {
- failures.push(format!("{}: Python shebang is forbidden", path.display()));
- }
- }
- if is_workflow_file(path) {
- let text = String::from_utf8_lossy(&bytes).to_ascii_lowercase();
- if text.contains("python") {
- failures.push(format!("{}: Python CI step is forbidden", path.display()));
- }
- }
- Ok(())
-}
-
-fn is_workflow_file(path: &Path) -> bool {
- let mut previous = None;
- for component in path.components() {
- let name = component.as_os_str().to_string_lossy();
- if previous.as_deref() == Some(".github") && name == "workflows" {
- return true;
- }
- previous = Some(name.into_owned());
- }
- false
-}
-
-fn scan_policy_file(path: &Path, failures: &mut Vec<String>) -> Result<(), String> {
- let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
- let lower = text.to_ascii_lowercase();
- if lower.contains(concat!("app.", "notion.com")) || lower.contains(concat!("385e", "79f2")) {
- failures.push(format!(
- "{}: external knowledge-base reference in source",
- path.display()
- ));
- }
- validate_unsafe_module_attribute_policy(path, &text, failures);
- let mut previous_line_has_safety_comment = false;
- for (index, line) in text.lines().enumerate() {
- let trimmed = line.trim_start();
- if is_comment_line(trimmed) {
- previous_line_has_safety_comment = has_safety_comment(trimmed);
- continue;
- }
- if trimmed.is_empty() {
- previous_line_has_safety_comment = false;
- continue;
- }
- if contains_unsafe_construct(trimmed)
- && !is_authorized_unsafe_construct(path, trimmed, previous_line_has_safety_comment)
- {
- failures.push(format!(
- "{}:{}: unsafe construct in workspace source",
- path.display(),
- index + 1
- ));
- }
- previous_line_has_safety_comment = false;
- }
- Ok(())
-}
-
-fn validate_unsafe_module_attribute_policy(path: &Path, text: &str, failures: &mut Vec<String>) {
- let declares_unsafe_module_boundary = text
- .lines()
- .map(str::trim_start)
- .any(|line| line.starts_with("#![allow(unsafe_code)]"));
- match (
- is_audited_unsafe_source(path),
- declares_unsafe_module_boundary,
- ) {
- (true, false) => failures.push(format!(
- "{}: audited Vulkan FFI module must declare #![allow(unsafe_code)]",
- path.display()
- )),
- (false, true) => failures.push(format!(
- "{}: #![allow(unsafe_code)] is only permitted in audited Vulkan FFI modules",
- path.display()
- )),
- (true, true) | (false, false) => {}
- }
-}
-
-fn contains_unsafe_construct(line: &str) -> bool {
- line.contains(concat!("un", "safe {"))
- || line.contains(concat!("un", "safe fn"))
- || line.contains(concat!("un", "safe impl"))
- || line.contains(concat!("extern ", "\"C\""))
-}
-
-fn is_comment_line(line: &str) -> bool {
- line.starts_with("//") || line.starts_with("//!") || line.starts_with("///")
-}
-
-fn has_safety_comment(line: &str) -> bool {
- line.contains("SAFETY:")
-}
-
-const AUDITED_UNSAFE_SOURCE_FILES: &[&str] = &[
- "adapters/fparkan-render-vulkan/src/ffi/instance.rs",
- "adapters/fparkan-render-vulkan/src/ffi/capabilities.rs",
- "adapters/fparkan-render-vulkan/src/ffi/resources.rs",
- "adapters/fparkan-render-vulkan/src/ffi/runtime.rs",
- "adapters/fparkan-render-vulkan/src/ffi/smoke.rs",
- "adapters/fparkan-render-vulkan/src/ffi/swapchain.rs",
- "adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs",
- "adapters/fparkan-render-vulkan/src/ffi/surface.rs",
- "adapters/fparkan-render-vulkan/src/ffi/validation.rs",
-];
-
-fn is_audited_unsafe_source(path: &Path) -> bool {
- let as_path = path.as_os_str().to_string_lossy().replace('\\', "/");
- AUDITED_UNSAFE_SOURCE_FILES
- .iter()
- .any(|candidate| as_path.ends_with(candidate))
-}
-
-fn is_authorized_unsafe_construct(
- path: &Path,
- line: &str,
- previous_line_has_safety_comment: bool,
-) -> bool {
- if !is_audited_unsafe_source(path) {
- return false;
- }
- previous_line_has_safety_comment || has_safety_comment(line)
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-enum Stage {
- All,
- Number(u8),
-}
-
-impl Stage {
- fn parse(value: &str) -> Result<Self, String> {
- if value == "all" {
- return Ok(Self::All);
- }
- let stage = value
- .parse::<u8>()
- .map_err(|_| format!("invalid stage: {value}"))?;
- if stage <= 5 {
- Ok(Self::Number(stage))
- } else {
- Err(format!("stage out of range: {stage}"))
- }
- }
-}
-
-impl fmt::Display for Stage {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::All => f.write_str("all"),
- Self::Number(stage) => write!(f, "{stage}"),
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct TestOptions {
- stage: Stage,
- root: PathBuf,
- manifest: Option<PathBuf>,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-enum TestSuite {
- Licensed,
- Synthetic,
-}
-
-impl TestSuite {
- fn parse(value: &str) -> Result<Self, String> {
- match value {
- "licensed" => Ok(Self::Licensed),
- "synthetic" => Ok(Self::Synthetic),
- _ => Err(format!("unknown suite: {value}")),
- }
- }
-
- fn as_str(self) -> &'static str {
- match self {
- Self::Licensed => "licensed",
- Self::Synthetic => "synthetic",
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct AcceptanceOptions {
- suite: TestSuite,
- stage: Stage,
- root: PathBuf,
- manifest: Option<PathBuf>,
- out: PathBuf,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct AuditOptions {
- roadmap: PathBuf,
- coverage: PathBuf,
- out: PathBuf,
- strict: bool,
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct NativeSmokeAuditOptions {
- dir: PathBuf,
- expected_commit: String,
- expected_shader_manifest_hash: String,
-}
-
-#[derive(Debug, Deserialize)]
-struct ShaderManifestJson {
- target_env: String,
- compiler: ShaderToolManifestJson,
- validator: ShaderToolManifestJson,
- modules: Vec<ShaderModuleManifestJson>,
- manifest_hash: String,
-}
-
-#[derive(Debug, Deserialize)]
-struct ShaderToolManifestJson {
- name: String,
- version: String,
- binary_sha256: String,
-}
-
-#[derive(Debug, Deserialize)]
-struct ShaderModuleManifestJson {
- name: String,
- stage: String,
- entry_point: String,
- source_path: String,
- source_sha256: String,
- spirv_path: String,
- sha256: String,
-}
-
-fn parse_test_options(args: &[String], default_root: PathBuf) -> Result<TestOptions, String> {
- let mut options = TestOptions {
- stage: Stage::All,
- root: default_root,
- manifest: None,
- };
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- match arg.as_str() {
- "--stage" => {
- let value = iter
- .next()
- .ok_or_else(|| "--stage requires a value".to_string())?;
- options.stage = Stage::parse(value)?;
- }
- "--root" => {
- let value = iter
- .next()
- .ok_or_else(|| "--root requires a path".to_string())?;
- options.root = PathBuf::from(value);
- }
- "--manifest" => {
- let value = iter
- .next()
- .ok_or_else(|| "--manifest requires a path".to_string())?;
- options.manifest = Some(PathBuf::from(value));
- }
- _ => return Err(format!("unknown test option: {arg}")),
- }
- }
- Ok(options)
-}
-
-fn parse_acceptance_options(args: &[String]) -> Result<AcceptanceOptions, String> {
- let mut suite = None;
- let mut stage = Stage::All;
- let mut root = PathBuf::from("testdata");
- let mut manifest = None;
- let mut out = None;
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- match arg.as_str() {
- "--suite" => {
- let value = iter
- .next()
- .ok_or_else(|| "--suite requires a value".to_string())?;
- suite = Some(TestSuite::parse(value)?);
- }
- "--stage" => {
- let value = iter
- .next()
- .ok_or_else(|| "--stage requires a value".to_string())?;
- stage = Stage::parse(value)?;
- }
- "--root" => {
- let value = iter
- .next()
- .ok_or_else(|| "--root requires a path".to_string())?;
- root = PathBuf::from(value);
- }
- "--manifest" => {
- let value = iter
- .next()
- .ok_or_else(|| "--manifest requires a path".to_string())?;
- manifest = Some(PathBuf::from(value));
- }
- "--out" => {
- let value = iter
- .next()
- .ok_or_else(|| "--out requires a path".to_string())?;
- out = Some(PathBuf::from(value));
- }
- _ => return Err(format!("unknown acceptance option: {arg}")),
- }
- }
-
- let suite = suite.ok_or_else(|| "missing --suite".to_string())?;
- let out = out.unwrap_or_else(|| {
- PathBuf::from("target")
- .join("fparkan")
- .join("reports")
- .join("acceptance")
- .join(format!("{}-stage-{}.json", suite.as_str(), stage))
- });
- Ok(AcceptanceOptions {
- suite,
- stage,
- root,
- manifest,
- out,
- })
-}
-
-fn parse_audit_options(args: &[String]) -> Result<AuditOptions, String> {
- let mut roadmap = workspace_relative_path(CI_ACCEPTANCE_ROADMAP);
- let mut coverage = workspace_relative_path(CI_ACCEPTANCE_COVERAGE);
- let mut out = workspace_root_path()
- .join("target")
- .join("fparkan")
- .join("reports")
- .join("acceptance")
- .join("coverage-audit.json");
- let mut strict = false;
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- match arg.as_str() {
- "--roadmap" => {
- let value = iter
- .next()
- .ok_or_else(|| "--roadmap requires a path".to_string())?;
- roadmap = PathBuf::from(value);
- }
- "--coverage" => {
- let value = iter
- .next()
- .ok_or_else(|| "--coverage requires a path".to_string())?;
- coverage = PathBuf::from(value);
- }
- "--out" => {
- let value = iter
- .next()
- .ok_or_else(|| "--out requires a path".to_string())?;
- out = PathBuf::from(value);
- }
- "--strict" => strict = true,
- _ => return Err(format!("unknown audit option: {arg}")),
- }
- }
- Ok(AuditOptions {
- roadmap,
- coverage,
- out,
- strict,
- })
-}
-
-fn parse_native_smoke_audit_options(args: &[String]) -> Result<NativeSmokeAuditOptions, String> {
- let mut dir = None;
- let mut expected_commit = expected_native_smoke_commit()?;
- let mut expected_shader_manifest_hash = current_shader_manifest_hash()?;
- let mut iter = args.iter();
- while let Some(arg) = iter.next() {
- match arg.as_str() {
- "--dir" => {
- let value = iter
- .next()
- .ok_or_else(|| "--dir requires a path".to_string())?;
- dir = Some(PathBuf::from(value));
- }
- "--expected-commit" => {
- let value = iter
- .next()
- .ok_or_else(|| "--expected-commit requires a value".to_string())?;
- if !is_commit_sha(value) {
- return Err(format!(
- "--expected-commit must be a 40-character lowercase or uppercase hex string, found {value:?}"
- ));
- }
- expected_commit.clone_from(value);
- }
- "--expected-shader-manifest-hash" => {
- let value = iter.next().ok_or_else(|| {
- "--expected-shader-manifest-hash requires a value".to_string()
- })?;
- if value.trim().is_empty() {
- return Err("--expected-shader-manifest-hash must be non-empty".to_string());
- }
- expected_shader_manifest_hash.clone_from(value);
- }
- _ => return Err(format!("unknown native-smoke audit option: {arg}")),
- }
- }
- Ok(NativeSmokeAuditOptions {
- dir: dir.ok_or_else(|| "native-smoke audit requires --dir".to_string())?,
- expected_commit,
- expected_shader_manifest_hash,
- })
-}
-
-fn run_native_smoke_audit(options: &NativeSmokeAuditOptions) -> Result<(), String> {
- let reports = read_native_smoke_reports(&options.dir)?;
- let failures = audit_native_smoke_reports(
- &reports,
- &options.expected_commit,
- &options.expected_shader_manifest_hash,
- );
- if failures.is_empty() {
- println!("native smoke artifacts passed: {}", options.dir.display());
- Ok(())
- } else {
- Err(format!(
- "native smoke artifacts incomplete:\n{}",
- failures.join("\n")
- ))
- }
-}
-
-fn read_native_smoke_reports(dir: &Path) -> Result<BTreeMap<String, serde_json::Value>, String> {
- let mut reports = BTreeMap::new();
- let entries = fs::read_dir(dir).map_err(|err| format!("{}: {err}", dir.display()))?;
- for entry in entries {
- let entry = entry.map_err(|err| format!("{}: {err}", dir.display()))?;
- let path = entry.path();
- if path.extension().and_then(|value| value.to_str()) != Some("json") {
- continue;
- }
- let text = fs::read_to_string(&path).map_err(|err| format!("{}: {err}", path.display()))?;
- let json = serde_json::from_str::<serde_json::Value>(&text)
- .map_err(|err| format!("{}: {err}", path.display()))?;
- let platform = json_string_field(&json, "platform")
- .map_err(|err| format!("{}: {err}", path.display()))?;
- let platform = platform.to_string();
- if reports.insert(platform.clone(), json).is_some() {
- return Err(format!(
- "{}: duplicate native smoke report for platform {platform}",
- path.display()
- ));
- }
- }
- Ok(reports)
-}
-
-fn audit_native_smoke_reports(
- reports: &BTreeMap<String, serde_json::Value>,
- expected_commit: &str,
- expected_shader_manifest_hash: &str,
-) -> Vec<String> {
- let mut failures = Vec::new();
- let mut commit_shas = BTreeSet::new();
- let mut rust_toolchains = BTreeSet::new();
- for platform in REQUIRED_NATIVE_SMOKE_PLATFORMS {
- let Some(report) = reports.get(*platform) else {
- failures.push(format!("{platform}: missing native smoke report"));
- continue;
- };
- validate_native_smoke_report(platform, report, &mut failures);
- expect_string_field(
- platform,
- report,
- "commit_sha",
- expected_commit,
- &mut failures,
- );
- expect_string_field(
- platform,
- report,
- "shader_manifest_hash",
- expected_shader_manifest_hash,
- &mut failures,
- );
- if let Ok(commit_sha) = json_string_field(report, "commit_sha") {
- if commit_sha == "unknown" {
- failures.push(format!("{platform}: commit_sha must not be \"unknown\""));
- } else {
- commit_shas.insert(commit_sha.to_string());
- }
- }
- if let Ok(toolchain) = json_string_field(report, "rust_toolchain") {
- rust_toolchains.insert(toolchain.to_string());
- }
- }
- for platform in reports.keys() {
- if !REQUIRED_NATIVE_SMOKE_PLATFORMS.contains(&platform.as_str()) {
- failures.push(format!("{platform}: unexpected native smoke platform"));
- }
- }
- if commit_shas.len() > 1 {
- failures.push(format!(
- "native smoke reports disagree on commit_sha: {}",
- commit_shas.into_iter().collect::<Vec<_>>().join(", ")
- ));
- }
- if rust_toolchains.len() > 1 {
- failures.push(format!(
- "native smoke reports disagree on rust_toolchain: {}",
- rust_toolchains.into_iter().collect::<Vec<_>>().join(", ")
- ));
- }
- failures
-}
-
-fn expected_native_smoke_commit() -> Result<String, String> {
- if let Ok(commit_sha) = std::env::var("GITHUB_SHA") {
- if is_commit_sha(&commit_sha) {
- return Ok(commit_sha);
- }
- return Err(format!(
- "GITHUB_SHA must be a 40-character lowercase or uppercase hex string when set, found {commit_sha:?}"
- ));
- }
- let commit_sha = current_git_commit_sha();
- if is_commit_sha(&commit_sha) {
- Ok(commit_sha)
- } else {
- Err(
- "native-smoke audit could not resolve expected commit from GITHUB_SHA or git rev-parse HEAD"
- .to_string(),
- )
- }
-}
-
-fn current_shader_manifest_hash() -> Result<String, String> {
- let manifest_path = workspace_relative_path(SHADER_MANIFEST_REPORT);
- let manifest = load_shader_manifest(&manifest_path)?;
- Ok(manifest.manifest_hash)
-}
-
-fn validate_native_smoke_report(
- platform: &str,
- report: &serde_json::Value,
- failures: &mut Vec<String>,
-) {
- validate_native_smoke_status_fields(platform, report, failures);
- validate_native_smoke_provenance_fields(platform, report, failures);
- validate_native_smoke_runtime_fields(platform, report, failures);
-}
-
-fn validate_native_smoke_status_fields(
- platform: &str,
- report: &serde_json::Value,
- failures: &mut Vec<String>,
-) {
- expect_string_field(
- platform,
- report,
- "schema_version",
- "fparkan-native-smoke-v1",
- failures,
- );
- expect_string_field(platform, report, "status", "passed", failures);
- expect_string_field(
- platform,
- report,
- "vulkan_loader_status",
- "available",
- failures,
- );
- expect_string_field(
- platform,
- report,
- "vulkan_instance_status",
- "created",
- failures,
- );
- expect_string_field(platform, report, "window_status", "created", failures);
- expect_string_field(
- platform,
- report,
- "vulkan_surface_status",
- "created",
- failures,
- );
- expect_string_field(
- platform,
- report,
- "vulkan_device_status",
- "selected",
- failures,
- );
- expect_string_field(
- platform,
- report,
- "vulkan_logical_device_status",
- "created",
- failures,
- );
- expect_string_field(
- platform,
- report,
- "vulkan_swapchain_status",
- "created",
- failures,
- );
-}
-
-fn validate_native_smoke_provenance_fields(
- platform: &str,
- report: &serde_json::Value,
- failures: &mut Vec<String>,
-) {
- expect_commit_sha(platform, report, failures);
- expect_bool_field(platform, report, "git_dirty", failures);
- expect_bool_field_value(platform, report, "git_dirty", false, failures);
- expect_nonempty_string(platform, report, "runner_identity", failures);
- expect_nonempty_string(platform, report, "runner_architecture", failures);
- expect_runner_architecture_matches_platform(platform, report, failures);
- expect_string_field(
- platform,
- report,
- "rust_toolchain",
- &measured_rust_toolchain_version(),
- failures,
- );
- expect_string_field(platform, report, "platform", platform, failures);
- expect_nonempty_string(platform, report, "target_triple", failures);
- expect_target_triple_matches_platform(platform, report, failures);
-}
-
-fn validate_native_smoke_runtime_fields(
- platform: &str,
- report: &serde_json::Value,
- failures: &mut Vec<String>,
-) {
- expect_u64_at_least(platform, report, "frames", 300, failures);
- expect_u64_at_least(platform, report, "resize_count", 1, failures);
- expect_u64_at_least(platform, report, "swapchain_recreate_count", 1, failures);
- expect_u64_field(platform, report, "validation_warning_count", 0, failures);
- expect_u64_field(platform, report, "validation_error_count", 0, failures);
- expect_nonempty_string(platform, report, "shader_manifest_hash", failures);
- expect_nonempty_string(platform, report, "vulkan_device_name", failures);
- expect_u64_at_least(
- platform,
- report,
- "vulkan_logical_device_enabled_extension_count",
- 1,
- failures,
- );
- expect_u64_at_least(
- platform,
- report,
- "vulkan_logical_device_graphics_queue_family",
- 0,
- failures,
- );
- expect_u64_at_least(
- platform,
- report,
- "vulkan_logical_device_present_queue_family",
- 0,
- failures,
- );
- expect_u64_at_least(platform, report, "vulkan_swapchain_width", 1, failures);
- expect_u64_at_least(platform, report, "vulkan_swapchain_height", 1, failures);
- expect_u64_at_least(
- platform,
- report,
- "vulkan_swapchain_image_count",
- 2,
- failures,
- );
-}
-
-fn expect_string_field(
- platform: &str,
- report: &serde_json::Value,
- field: &str,
- expected: &str,
- failures: &mut Vec<String>,
-) {
- match json_string_field(report, field) {
- Ok(actual) if actual == expected => {}
- Ok(actual) => failures.push(format!(
- "{platform}: {field} expected {expected:?}, found {actual:?}"
- )),
- Err(err) => failures.push(format!("{platform}: {err}")),
- }
-}
-
-fn expect_nonempty_string(
- platform: &str,
- report: &serde_json::Value,
- field: &str,
- failures: &mut Vec<String>,
-) {
- match json_string_field(report, field) {
- Ok(value) if !value.trim().is_empty() => {}
- Ok(_) => failures.push(format!("{platform}: {field} must be non-empty")),
- Err(err) => failures.push(format!("{platform}: {err}")),
- }
-}
-
-fn expect_bool_field(
- platform: &str,
- report: &serde_json::Value,
- field: &str,
- failures: &mut Vec<String>,
-) {
- match report.get(field) {
- Some(serde_json::Value::Bool(_)) => {}
- Some(_) => failures.push(format!("{platform}: {field} must be a boolean")),
- None => failures.push(format!("{platform}: missing {field}")),
- }
-}
-
-fn expect_bool_field_value(
- platform: &str,
- report: &serde_json::Value,
- field: &str,
- expected: bool,
- failures: &mut Vec<String>,
-) {
- match report.get(field) {
- Some(serde_json::Value::Bool(actual)) if *actual == expected => {}
- Some(serde_json::Value::Bool(actual)) => {
- failures.push(format!(
- "{platform}: {field} expected {expected}, found {actual}"
- ));
- }
- Some(_) => failures.push(format!("{platform}: {field} must be a boolean")),
- None => failures.push(format!("{platform}: missing {field}")),
- }
-}
-
-fn expect_commit_sha(platform: &str, report: &serde_json::Value, failures: &mut Vec<String>) {
- match report.get("commit_sha") {
- Some(serde_json::Value::String(commit_sha))
- if commit_sha.len() == 40 && commit_sha.chars().all(|ch| ch.is_ascii_hexdigit()) => {}
- Some(serde_json::Value::String(_)) => failures.push(format!(
- "{platform}: commit_sha must be a 40-character lowercase or uppercase hex string"
- )),
- Some(_) => failures.push(format!("{platform}: commit_sha must be a string")),
- None => failures.push(format!("{platform}: missing commit_sha")),
- }
-}
-
-fn expect_target_triple_matches_platform(
- platform: &str,
- report: &serde_json::Value,
- failures: &mut Vec<String>,
-) {
- let Ok(target_triple) = json_string_field(report, "target_triple") else {
- return;
- };
- let matches_platform = match platform {
- "windows" => target_triple.contains("windows"),
- _ => true,
- };
- if !matches_platform {
- failures.push(format!(
- "{platform}: target_triple {target_triple:?} does not match platform"
- ));
- }
-}
-
-fn expect_runner_architecture_matches_platform(
- platform: &str,
- report: &serde_json::Value,
- failures: &mut Vec<String>,
-) {
- let Ok(runner_architecture) = json_string_field(report, "runner_architecture") else {
- return;
- };
- let matches_platform = !runner_architecture.trim().is_empty();
- if !matches_platform {
- failures.push(format!(
- "{platform}: runner_architecture {runner_architecture:?} does not match platform policy"
- ));
- }
-}
-
-fn expect_u64_at_least(
- platform: &str,
- report: &serde_json::Value,
- field: &str,
- minimum: u64,
- failures: &mut Vec<String>,
-) {
- match json_u64_field(report, field) {
- Ok(value) if value >= minimum => {}
- Ok(value) => failures.push(format!(
- "{platform}: {field} expected >= {minimum}, found {value}"
- )),
- Err(err) => failures.push(format!("{platform}: {err}")),
- }
-}
-
-fn expect_u64_field(
- platform: &str,
- report: &serde_json::Value,
- field: &str,
- expected: u64,
- failures: &mut Vec<String>,
-) {
- match json_u64_field(report, field) {
- Ok(value) if value == expected => {}
- Ok(value) => failures.push(format!(
- "{platform}: {field} expected {expected}, found {value}"
- )),
- Err(err) => failures.push(format!("{platform}: {err}")),
- }
-}
-
-fn json_string_field<'a>(json: &'a serde_json::Value, field: &str) -> Result<&'a str, String> {
- json.get(field)
- .and_then(serde_json::Value::as_str)
- .ok_or_else(|| format!("{field} must be a string"))
-}
-
-fn json_u64_field(json: &serde_json::Value, field: &str) -> Result<u64, String> {
- json.get(field)
- .and_then(serde_json::Value::as_u64)
- .ok_or_else(|| format!("{field} must be an unsigned integer"))
-}
-
-fn run_acceptance_audit(options: &AuditOptions) -> Result<(), String> {
- let roadmap_text = fs::read_to_string(&options.roadmap)
- .map_err(|err| format!("{}: {err}", options.roadmap.display()))?;
- let required = extract_acceptance_ids(&roadmap_text);
- let coverage = read_coverage_manifest(&options.coverage)?;
- let audit = build_acceptance_audit(&required, &coverage);
- if let Some(parent) = options.out.parent() {
- fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?;
- }
- let rendered = render_audit_json(&audit)?;
- fs::write(&options.out, rendered).map_err(|err| format!("{}: {err}", options.out.display()))?;
- println!("{}", options.out.display());
- let strict_failures = audit.strict_failures();
- if options.strict && (!strict_failures.is_empty() || !audit.unknown_coverage.is_empty()) {
- Err(format!(
- "acceptance coverage incomplete: {} strict failures, {} unknown",
- strict_failures.len(),
- audit.unknown_coverage.len()
- ))
- } else {
- Ok(())
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct CoverageEntry {
- status: CoverageStatus,
- evidence: String,
-}
-
-#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-enum CoverageStatus {
- Covered,
- CoveredPlanning,
- CoveredStub,
- CoveredGpu,
- Partial,
- Blocked,
- Omitted,
-}
-
-impl CoverageStatus {
- fn parse(value: &str) -> Result<Self, String> {
- match value {
- "covered" => Ok(Self::Covered),
- "covered-planning" => Ok(Self::CoveredPlanning),
- "covered-stub" => Ok(Self::CoveredStub),
- "covered-gpu" => Ok(Self::CoveredGpu),
- "partial" => Ok(Self::Partial),
- "blocked" => Ok(Self::Blocked),
- "omitted" => Ok(Self::Omitted),
- _ => Err(format!("unknown coverage status: {value}")),
- }
- }
-
- const fn as_str(self) -> &'static str {
- match self {
- Self::Covered => "covered",
- Self::CoveredPlanning => "covered-planning",
- Self::CoveredStub => "covered-stub",
- Self::CoveredGpu => "covered-gpu",
- Self::Partial => "partial",
- Self::Blocked => "blocked",
- Self::Omitted => "omitted",
- }
- }
-}
-
-#[derive(Clone, Debug, Eq, PartialEq)]
-struct AcceptanceAudit {
- commit_sha: String,
- git_dirty: bool,
- runner_identity: String,
- rust_toolchain: String,
- msrv: String,
- required_total: usize,
- covered: Vec<String>,
- covered_planning: Vec<String>,
- covered_stub: Vec<String>,
- covered_gpu: Vec<String>,
- partial: Vec<String>,
- blocked: Vec<String>,
- omitted: Vec<String>,
- missing: Vec<String>,
- invalid_coverage: Vec<String>,
- unknown_coverage: Vec<String>,
- coverage_evidence: BTreeMap<String, String>,
- by_stage: BTreeMap<String, usize>,
-}
-
-impl AcceptanceAudit {
- fn covered_total(&self) -> usize {
- self.covered.len()
- + self.covered_planning.len()
- + self.covered_stub.len()
- + self.covered_gpu.len()
- }
-
- fn unverified(&self) -> Vec<String> {
- self.partial
- .iter()
- .chain(&self.blocked)
- .chain(&self.omitted)
- .chain(&self.missing)
- .chain(&self.invalid_coverage)
- .cloned()
- .collect()
- }
-
- fn strict_failures(&self) -> Vec<String> {
- self.partial
- .iter()
- .chain(&self.blocked)
- .chain(&self.omitted)
- .chain(&self.missing)
- .chain(&self.invalid_coverage)
- .cloned()
- .collect()
- }
-}
-
-fn extract_acceptance_ids(text: &str) -> BTreeSet<String> {
- let mut ids = BTreeSet::new();
- for segment in text.split('`') {
- if is_acceptance_id(segment) {
- ids.insert(segment.to_string());
- }
- }
- ids
-}
-
-fn is_acceptance_id(value: &str) -> bool {
- let bytes = value.as_bytes();
- bytes.len() >= 9
- && matches!(bytes[0], b'S' | b'L')
- && matches!(bytes[1], b'0'..=b'5')
- && bytes[2] == b'-'
- && bytes.iter().all(|byte| {
- byte.is_ascii_uppercase() || byte.is_ascii_digit() || *byte == b'-' || *byte == b'_'
- })
-}
-
-fn read_coverage_manifest(path: &Path) -> Result<BTreeMap<String, CoverageEntry>, String> {
- if !path.exists() {
- return Ok(BTreeMap::new());
- }
- let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
- let mut entries = BTreeMap::new();
- for (line_number, line) in text.lines().enumerate() {
- let trimmed = line.trim();
- if trimmed.is_empty() || trimmed.starts_with('#') {
- continue;
- }
- let mut fields = trimmed.splitn(3, '\t');
- let id = fields
- .next()
- .ok_or_else(|| format!("{}:{}: missing id", path.display(), line_number + 1))?;
- let status = fields
- .next()
- .ok_or_else(|| format!("{}:{}: missing status", path.display(), line_number + 1))?;
- let evidence = fields
- .next()
- .ok_or_else(|| format!("{}:{}: missing evidence", path.display(), line_number + 1))?;
- if evidence.trim().is_empty() {
- return Err(format!(
- "{}:{}: empty evidence",
- path.display(),
- line_number + 1
- ));
- }
- if !is_acceptance_id(id) {
- return Err(format!(
- "{}:{}: invalid acceptance id: {id}",
- path.display(),
- line_number + 1
- ));
- }
- entries.insert(
- id.to_string(),
- CoverageEntry {
- status: CoverageStatus::parse(status)?,
- evidence: evidence.to_string(),
- },
- );
- }
- Ok(entries)
-}
-
-fn build_acceptance_audit(
- required: &BTreeSet<String>,
- coverage: &BTreeMap<String, CoverageEntry>,
-) -> AcceptanceAudit {
- let mut covered = Vec::new();
- let mut covered_planning = Vec::new();
- let mut covered_stub = Vec::new();
- let mut covered_gpu = Vec::new();
- let mut partial = Vec::new();
- let mut blocked = Vec::new();
- let mut omitted = Vec::new();
- let mut missing = Vec::new();
- let mut by_stage = BTreeMap::new();
- let mut coverage_evidence = BTreeMap::new();
- let required_scopes = required
- .iter()
- .filter_map(|id| id.get(0..2).map(ToString::to_string))
- .collect::<BTreeSet<_>>();
-
- for id in required {
- let stage = id
- .get(0..2)
- .map_or_else(|| "??".to_string(), ToString::to_string);
- *by_stage.entry(stage).or_insert(0) += 1;
- match coverage.get(id).map(|entry| entry.status) {
- Some(CoverageStatus::Covered) => covered.push(id.clone()),
- Some(CoverageStatus::CoveredPlanning) => covered_planning.push(id.clone()),
- Some(CoverageStatus::CoveredStub) => covered_stub.push(id.clone()),
- Some(CoverageStatus::CoveredGpu) => covered_gpu.push(id.clone()),
- Some(CoverageStatus::Partial) => partial.push(id.clone()),
- Some(CoverageStatus::Blocked) => blocked.push(id.clone()),
- Some(CoverageStatus::Omitted) => omitted.push(id.clone()),
- None => missing.push(id.clone()),
- }
- if let Some(entry) = coverage.get(id) {
- coverage_evidence.insert(id.clone(), entry.evidence.clone());
- }
- }
-
- let invalid_coverage = coverage
- .iter()
- .filter_map(|(id, entry)| coverage_semantic_failure(id, entry.status))
- .collect();
-
- let unknown_coverage = coverage
- .keys()
- .filter(|id| {
- !required.contains(*id)
- && id
- .get(0..2)
- .is_some_and(|scope| required_scopes.contains(scope))
- })
- .cloned()
- .collect();
-
- AcceptanceAudit {
- commit_sha: current_git_commit_sha(),
- git_dirty: current_git_dirty(),
- runner_identity: measured_runner_identity(),
- rust_toolchain: measured_rust_toolchain_version(),
- msrv: WORKSPACE_MSRV.to_string(),
- required_total: required.len(),
- covered,
- covered_planning,
- covered_stub,
- covered_gpu,
- partial,
- blocked,
- omitted,
- missing,
- invalid_coverage,
- unknown_coverage,
- coverage_evidence,
- by_stage,
- }
-}
-
-fn coverage_semantic_failure(id: &str, status: CoverageStatus) -> Option<String> {
- if is_stage3_or_later(id)
- && matches!(
- status,
- CoverageStatus::CoveredPlanning | CoverageStatus::CoveredStub
- )
- && has_gpu_delivery_token(id)
- {
- return Some(format!(
- "{id}: {} cannot satisfy Stage 3+ GPU/pixel acceptance; use covered-gpu or leave the row non-green",
- status.as_str()
- ));
- }
-
- if matches!(status, CoverageStatus::CoveredStub)
- && is_fx_lifecycle_compatibility_id(id)
- && !id.contains("STUB")
- {
- return Some(format!(
- "{id}: covered-stub is reserved for explicit STUB-only FX lifecycle acceptance rows"
- ));
- }
-
- None
-}
-
-fn is_stage3_or_later(id: &str) -> bool {
- id.as_bytes().get(1).is_some_and(|stage| *stage >= b'3')
-}
-
-fn has_gpu_delivery_token(id: &str) -> bool {
- id.split('-').any(|token| {
- matches!(
- token,
- "VK" | "GPU" | "DRAW" | "PIXEL" | "VALIDATION" | "RENDERED"
- )
- })
-}
-
-fn is_fx_lifecycle_compatibility_id(id: &str) -> bool {
- id.starts_with("S4-FX-") || id.starts_with("L4-P1-EFFECT-") || id.starts_with("L4-P2-EFFECT-")
-}
-
-#[derive(Serialize)]
-struct AcceptanceAuditJson<'a> {
- schema_version: &'static str,
- commit_sha: &'a str,
- git_dirty: bool,
- runner_identity: &'a str,
- rust_toolchain: &'a str,
- msrv: &'a str,
- required_total: usize,
- covered_total: usize,
- covered_planning_total: usize,
- covered_stub_total: usize,
- covered_gpu_total: usize,
- partial_total: usize,
- blocked_total: usize,
- omitted_total: usize,
- missing_total: usize,
- unverified_total: usize,
- invalid_coverage_total: usize,
- unknown_coverage_total: usize,
- by_stage: &'a BTreeMap<String, usize>,
- covered: &'a [String],
- covered_planning: &'a [String],
- covered_stub: &'a [String],
- covered_gpu: &'a [String],
- partial: &'a [String],
- blocked: &'a [String],
- omitted: &'a [String],
- missing: &'a [String],
- invalid_coverage: &'a [String],
- unknown_coverage: &'a [String],
- coverage_evidence: &'a BTreeMap<String, String>,
-}
-
-fn render_audit_json(audit: &AcceptanceAudit) -> Result<String, String> {
- let unverified = audit.unverified();
- let report = AcceptanceAuditJson {
- schema_version: "fparkan-acceptance-coverage-v1",
- commit_sha: &audit.commit_sha,
- git_dirty: audit.git_dirty,
- runner_identity: &audit.runner_identity,
- rust_toolchain: &audit.rust_toolchain,
- msrv: &audit.msrv,
- required_total: audit.required_total,
- covered_total: audit.covered_total(),
- covered_planning_total: audit.covered_planning.len(),
- covered_stub_total: audit.covered_stub.len(),
- covered_gpu_total: audit.covered_gpu.len(),
- partial_total: audit.partial.len(),
- blocked_total: audit.blocked.len(),
- omitted_total: audit.omitted.len(),
- missing_total: audit.missing.len(),
- unverified_total: unverified.len(),
- invalid_coverage_total: audit.invalid_coverage.len(),
- unknown_coverage_total: audit.unknown_coverage.len(),
- by_stage: &audit.by_stage,
- covered: &audit.covered,
- covered_planning: &audit.covered_planning,
- covered_stub: &audit.covered_stub,
- covered_gpu: &audit.covered_gpu,
- partial: &audit.partial,
- blocked: &audit.blocked,
- omitted: &audit.omitted,
- missing: &audit.missing,
- invalid_coverage: &audit.invalid_coverage,
- unknown_coverage: &audit.unknown_coverage,
- coverage_evidence: &audit.coverage_evidence,
- };
- serde_json::to_string_pretty(&report)
- .map(|json| format!("{json}\n"))
- .map_err(|err| format!("acceptance audit serialization failed: {err}"))
-}
-
-fn current_git_commit_sha() -> String {
- Command::new("git")
- .args(["rev-parse", "HEAD"])
- .output()
- .ok()
- .filter(|output| output.status.success())
- .and_then(|output| String::from_utf8(output.stdout).ok())
- .map(|value| value.trim().to_string())
- .filter(|value| value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit()))
- .unwrap_or_else(|| "unknown".to_string())
-}
-
-fn is_commit_sha(value: &str) -> bool {
- value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
-}
-
-fn current_git_dirty() -> bool {
- Command::new("git")
- .args(["status", "--short"])
- .output()
- .ok()
- .filter(|output| output.status.success())
- .and_then(|output| String::from_utf8(output.stdout).ok())
- .is_some_and(|output| !output.trim().is_empty())
-}
-
-fn measured_rust_toolchain_version() -> String {
- Command::new("rustc")
- .args(["-Vv"])
- .output()
- .ok()
- .filter(|output| output.status.success())
- .and_then(|output| String::from_utf8(output.stdout).ok())
- .and_then(|stdout| {
- stdout.lines().find_map(|line| {
- line.strip_prefix("release:")
- .map(str::trim)
- .map(ToString::to_string)
- })
- })
- .unwrap_or_else(|| PINNED_RUST_TOOLCHAIN.to_string())
-}
-
-fn measured_runner_identity() -> String {
- if std::env::var_os("GITHUB_ACTIONS").is_some() {
- let run_id = std::env::var("GITHUB_RUN_ID").unwrap_or_else(|_| "unknown-run".to_string());
- let job = std::env::var("GITHUB_JOB").unwrap_or_else(|_| "unknown-job".to_string());
- format!("github-actions/{run_id}/{job}")
- } else if std::env::var_os("CI").is_some() {
- let job = std::env::var("CI_JOB_NAME")
- .or_else(|_| std::env::var("BUILD_ID"))
- .unwrap_or_else(|_| "generic-ci".to_string());
- format!("ci/{job}")
- } else {
- format!("local/{}", std::env::consts::OS)
- }
-}
-
-fn run_acceptance_report(options: &AcceptanceOptions) -> Result<(), String> {
- let roots = if options.suite == TestSuite::Licensed {
- Some(load_licensed_roots(options.manifest.as_deref())?)
- } else {
- None
- };
- run_stage_tests(options.stage, options.suite, roots.as_ref())?;
-
- if let Some(parent) = options.out.parent() {
- fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?;
- }
- let report = render_acceptance_report(options)?;
- fs::write(&options.out, report).map_err(|err| format!("{}: {err}", options.out.display()))?;
- println!("{}", options.out.display());
- Ok(())
-}
-
-#[derive(Serialize)]
-struct AcceptanceLicensedCorpusReport<'a> {
- root: &'a str,
- parts: [&'a str; 2],
-}
-
-#[derive(Serialize)]
-struct AcceptanceReportJson {
- schema_version: &'static str,
- suite: String,
- stage: String,
- status: &'static str,
- #[serde(skip_serializing_if = "Option::is_none")]
- licensed_corpus: Option<AcceptanceLicensedCorpusReport<'static>>,
- packages: Vec<String>,
-}
-
-fn render_acceptance_report(options: &AcceptanceOptions) -> Result<String, String> {
- let report = AcceptanceReportJson {
- schema_version: "fparkan-acceptance-report-v1",
- suite: options.suite.as_str().to_string(),
- stage: options.stage.to_string(),
- status: "passed",
- licensed_corpus: if options.suite == TestSuite::Licensed {
- Some(AcceptanceLicensedCorpusReport {
- root: "redacted",
- parts: ["IS", "IS2"],
- })
- } else {
- None
- },
- packages: stage_report_packages(options.stage).unwrap_or_default(),
- };
- serde_json::to_string_pretty(&report)
- .map(|json| format!("{json}\n"))
- .map_err(|err| format!("acceptance report serialization failed: {err}"))
-}
-
-fn stage_report_packages(stage: Stage) -> Result<Vec<String>, String> {
- let workspace_root = workspace_root_path();
- match stage {
- Stage::All => workspace_package_names(&workspace_root)
- .map(|packages| packages.into_iter().collect::<Vec<_>>()),
- Stage::Number(number) => stage_packages(number),
- }
-}
-
-fn run_stage_tests(
- stage: Stage,
- suite: TestSuite,
- roots: Option<&LicensedCorpusRoots>,
-) -> Result<(), String> {
- let mut suffix = Vec::new();
- if suite == TestSuite::Licensed {
- suffix.extend(["--", "--ignored"]);
- }
- let envs = roots.map(LicensedCorpusRoots::envs);
- match stage {
- Stage::All => {
- let mut args = vec!["test", "--workspace", "--locked", "--offline"];
- args.extend(suffix);
- if let Some(envs) = envs {
- cargo_with_env(&args, &envs)
- } else {
- cargo(&args)
- }
- }
- Stage::Number(number) => {
- for package in stage_packages(number)? {
- let mut args = vec![
- "test".to_string(),
- "-p".to_string(),
- package,
- "--locked".to_string(),
- "--offline".to_string(),
- ];
- args.extend(suffix.iter().map(|value| (*value).to_string()));
- if let Some(envs) = envs {
- let borrowed = args.iter().map(String::as_str).collect::<Vec<_>>();
- cargo_with_env(&borrowed, &envs)?;
- } else {
- cargo_owned(&args)?;
- }
- }
- Ok(())
- }
- }
-}
-
-fn stage_packages(stage: u8) -> Result<Vec<String>, String> {
- let manifest_path = workspace_relative_path(STAGE_PACKAGE_MANIFEST);
- let manifest = load_stage_package_manifest(&manifest_path)?;
- let packages = manifest
- .stages
- .get(&stage.to_string())
- .cloned()
- .ok_or_else(|| format!("stage out of range: {stage}"))?;
- validate_stage_package_entries(
- &manifest,
- &workspace_package_names(&workspace_root_path())?,
- &manifest_path,
- )?;
- Ok(packages)
-}
-
-fn workspace_package_names(root: &Path) -> Result<BTreeSet<String>, String> {
- let metadata = workspace_metadata(root)?;
- Ok(metadata
- .workspace_packages()
- .iter()
- .map(|package| package.name.to_string())
- .collect())
-}
-
-fn workspace_metadata(root: &Path) -> Result<cargo_metadata::Metadata, String> {
- let manifest = root.join("Cargo.toml");
- MetadataCommand::new()
- .manifest_path(&manifest)
- .no_deps()
- .other_options(["--offline".to_string(), "--locked".to_string()])
- .exec()
- .map_err(|error| format!("{}: cargo metadata failed: {}", manifest.display(), error))
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(deny_unknown_fields)]
-struct StagePackageManifest {
- schema: Option<u8>,
- stages: BTreeMap<String, Vec<String>>,
-}
-
-fn load_stage_package_manifest(path: &Path) -> Result<StagePackageManifest, String> {
- let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?;
- let manifest = toml::from_str::<StagePackageManifest>(&text)
- .map_err(|err| format!("failed to parse {}: {err}", path.display()))?;
- if manifest.schema != Some(1) {
- return Err(format!(
- "{}: unsupported stage package manifest schema {:?} (expected 1)",
- path.display(),
- manifest.schema
- ));
- }
- Ok(manifest)
-}
-
-fn validate_stage_package_entries(
- manifest: &StagePackageManifest,
- workspace_packages: &BTreeSet<String>,
- path: &Path,
-) -> Result<(), String> {
- let required_stages = (0_u8..=5_u8)
- .map(|stage| stage.to_string())
- .collect::<BTreeSet<_>>();
- let declared_stages = manifest.stages.keys().cloned().collect::<BTreeSet<_>>();
- if declared_stages != required_stages {
- return Err(format!(
- "{}: stage package manifest must declare stages 0 through 5 exactly once",
- path.display()
- ));
- }
-
- let mut assigned = BTreeSet::new();
- for (stage, packages) in &manifest.stages {
- for package in packages {
- if !workspace_packages.contains(package) {
- return Err(format!(
- "{}: stage {stage} references unknown package {package}",
- path.display()
- ));
- }
- if !assigned.insert(package.clone()) {
- return Err(format!(
- "{}: package {package} is assigned to multiple stages",
- path.display()
- ));
- }
- }
- }
-
- let missing = workspace_packages
- .difference(&assigned)
- .cloned()
- .collect::<Vec<_>>();
- if !missing.is_empty() {
- return Err(format!(
- "{}: stage package manifest is missing workspace packages: {}",
- path.display(),
- missing.join(", ")
- ));
- }
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn strings(values: &[&str]) -> Vec<String> {
- values.iter().map(|value| (*value).to_string()).collect()
- }
-
- fn temp_dir(name: &str) -> PathBuf {
- let suffix = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .map_or(0, |duration| duration.as_nanos());
- std::env::temp_dir().join(format!("fparkan-xtask-{name}-{suffix}"))
- }
-
- #[test]
- fn parses_stage_and_root_options() {
- let args = strings(&["--stage", "3", "--root", "fixtures"]);
- let parsed = parse_test_options(&args, PathBuf::from("testdata"));
-
- assert_eq!(
- parsed,
- Ok(TestOptions {
- stage: Stage::Number(3),
- root: PathBuf::from("fixtures"),
- manifest: None,
- })
- );
- }
-
- #[test]
- fn parses_corpus_baseline_output_option() {
- let options = parse_corpus_baseline_options(&strings(&[
- "--root",
- "C:/GOG Games/Parkan - Iron Strategy",
- "--out",
- "target/fparkan/corpus/part1.json",
- ]));
-
- assert_eq!(
- options,
- Ok(CorpusBaselineOptions {
- root: PathBuf::from("C:/GOG Games/Parkan - Iron Strategy"),
- out: Some(PathBuf::from("target/fparkan/corpus/part1.json")),
- })
- );
- }
-
- #[test]
- fn parses_acceptance_report_options() {
- let parsed = parse_acceptance_options(&strings(&[
- "--suite",
- "licensed",
- "--stage",
- "5",
- "--manifest",
- "corpora.toml",
- "--out",
- "target/report.json",
- ]));
-
- assert_eq!(
- parsed,
- Ok(AcceptanceOptions {
- suite: TestSuite::Licensed,
- stage: Stage::Number(5),
- root: PathBuf::from("testdata"),
- manifest: Some(PathBuf::from("corpora.toml")),
- out: PathBuf::from("target/report.json"),
- })
- );
- }
-
- #[test]
- fn acceptance_report_redacts_licensed_root() {
- let options = AcceptanceOptions {
- suite: TestSuite::Licensed,
- stage: Stage::Number(0),
- root: PathBuf::from("/private/game"),
- manifest: Some(PathBuf::from("/private/corpora.toml")),
- out: PathBuf::from("target/report.json"),
- };
- let report =
- render_acceptance_report(&options).expect("acceptance report should serialize");
-
- assert!(report.contains("\"root\": \"redacted\""));
- assert!(!report.contains("/private/game"));
- assert!(report.contains("\"fparkan-corpus\""));
- }
-
- #[test]
- fn extracts_acceptance_ids_from_backticks_only() {
- let ids =
- extract_acceptance_ids("`S0-ARCH-001` text S0-ARCH-002 `L5-P1-MISSION-001` `bad`");
-
- assert!(ids.contains("S0-ARCH-001"));
- assert!(ids.contains("L5-P1-MISSION-001"));
- assert!(!ids.contains("S0-ARCH-002"));
- assert_eq!(ids.len(), 2);
- }
-
- #[test]
- fn builds_acceptance_audit_counts() {
- let required = ["S0-ARCH-001", "S0-ARCH-002", "L3-GPU-001", "L5-RG40-001"]
- .into_iter()
- .map(str::to_string)
- .collect::<BTreeSet<_>>();
- let coverage = [
- (
- "S0-ARCH-001".to_string(),
- CoverageEntry {
- status: CoverageStatus::Covered,
- evidence: "cargo xtask policy".to_string(),
- },
- ),
- (
- "L3-GPU-001".to_string(),
- CoverageEntry {
- status: CoverageStatus::CoveredPlanning,
- evidence: "planning-only evidence".to_string(),
- },
- ),
- (
- "L5-RG40-001".to_string(),
- CoverageEntry {
- status: CoverageStatus::Blocked,
- evidence: "device not attached".to_string(),
- },
- ),
- (
- "S0-ARCH-099".to_string(),
- CoverageEntry {
- status: CoverageStatus::Partial,
- evidence: "bad id".to_string(),
- },
- ),
- ]
- .into_iter()
- .collect::<BTreeMap<_, _>>();
-
- let audit = build_acceptance_audit(&required, &coverage);
-
- assert_eq!(audit.covered, ["S0-ARCH-001"]);
- assert_eq!(audit.covered_planning, ["L3-GPU-001"]);
- assert_eq!(audit.blocked, ["L5-RG40-001"]);
- assert_eq!(audit.missing, ["S0-ARCH-002"]);
- assert_eq!(audit.unknown_coverage, ["S0-ARCH-099"]);
- assert_eq!(audit.by_stage.get("S0"), Some(&2));
- assert_eq!(
- audit.strict_failures(),
- vec![
- "L5-RG40-001".to_string(),
- "S0-ARCH-002".to_string(),
- "L3-GPU-001: covered-planning cannot satisfy Stage 3+ GPU/pixel acceptance; use covered-gpu or leave the row non-green".to_string(),
- ]
- );
- }
-
- #[test]
- fn covered_stub_requires_explicit_stub_fx_acceptance_row() {
- let required = ["S4-FX-018"]
- .into_iter()
- .map(str::to_string)
- .collect::<BTreeSet<_>>();
- let coverage = [(
- "S4-FX-018".to_string(),
- CoverageEntry {
- status: CoverageStatus::CoveredStub,
- evidence: "reference stub output".to_string(),
- },
- )]
- .into_iter()
- .collect::<BTreeMap<_, _>>();
-
- let audit = build_acceptance_audit(&required, &coverage);
-
- assert_eq!(audit.covered_stub, ["S4-FX-018"]);
- assert_eq!(
- audit.invalid_coverage,
- vec![
- "S4-FX-018: covered-stub is reserved for explicit STUB-only FX lifecycle acceptance rows"
- .to_string()
- ]
- );
- }
-
- #[test]
- fn audit_json_escapes_evidence() {
- let mut audit = AcceptanceAudit {
- commit_sha: "0123456789abcdef0123456789abcdef01234567".to_string(),
- git_dirty: false,
- runner_identity: "windows-local/stage0".to_string(),
- rust_toolchain: PINNED_RUST_TOOLCHAIN.to_string(),
- msrv: WORKSPACE_MSRV.to_string(),
- required_total: 1,
- covered: vec!["S0-ARCH-001".to_string()],
- covered_planning: Vec::new(),
- covered_stub: Vec::new(),
- covered_gpu: Vec::new(),
- partial: Vec::new(),
- blocked: Vec::new(),
- omitted: Vec::new(),
- missing: Vec::new(),
- invalid_coverage: Vec::new(),
- unknown_coverage: Vec::new(),
- coverage_evidence: BTreeMap::new(),
- by_stage: BTreeMap::new(),
- };
- audit
- .coverage_evidence
- .insert("S0-ARCH-001".to_string(), "quoted \"value\"".to_string());
-
- let json = render_audit_json(&audit).expect("acceptance audit should serialize");
-
- assert!(json.contains("quoted \\\"value\\\""));
- assert!(json.contains("\"commit_sha\": \"0123456789abcdef0123456789abcdef01234567\""));
- assert!(json.contains("\"git_dirty\": false"));
- assert!(json.contains("\"runner_identity\": \"windows-local/stage0\""));
- assert!(json.contains("\"rust_toolchain\": \"1.97.1\""));
- assert!(json.contains("\"msrv\": \"1.97\""));
- }
-
- #[test]
- fn native_smoke_audit_accepts_complete_required_platform_pass() {
- let expected_commit = "0123456789abcdef0123456789abcdef01234567";
- let expected_shader_manifest_hash =
- "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c";
- let reports = ["windows"]
- .into_iter()
- .map(|platform| {
- let target_triple = match platform {
- "windows" => "x86_64-pc-windows-msvc",
- _ => "unknown-target",
- };
- (
- platform.to_string(),
- serde_json::json!({
- "schema_version": "fparkan-native-smoke-v1",
- "commit_sha": "0123456789abcdef0123456789abcdef01234567",
- "git_dirty": false,
- "runner_identity": "windows-local/stage0",
- "runner_architecture": "x86_64",
- "rust_toolchain": measured_rust_toolchain_version(),
- "target_triple": target_triple,
- "platform": platform,
- "status": "passed",
- "frames": 300,
- "resize_count": 1,
- "swapchain_recreate_count": 1,
- "validation_warning_count": 0,
- "validation_error_count": 0,
- "shader_manifest_hash": expected_shader_manifest_hash,
- "vulkan_loader_status": "available",
- "vulkan_instance_status": "created",
- "window_status": "created",
- "vulkan_surface_status": "created",
- "vulkan_device_status": "selected",
- "vulkan_device_name": format!("{platform} GPU"),
- "vulkan_logical_device_status": "created",
- "vulkan_logical_device_graphics_queue_family": 0,
- "vulkan_logical_device_present_queue_family": 0,
- "vulkan_logical_device_enabled_extension_count": 1,
- "vulkan_swapchain_status": "created",
- "vulkan_swapchain_width": 1280,
- "vulkan_swapchain_height": 720,
- "vulkan_swapchain_image_count": 3,
- "vulkan_portability_enumeration": false,
- "vulkan_portability_subset_enabled": false
- }),
- )
- })
- .collect::<BTreeMap<_, _>>();
-
- assert_eq!(
- audit_native_smoke_reports(&reports, expected_commit, expected_shader_manifest_hash,),
- Vec::<String>::new()
- );
- }
-
- #[test]
- fn native_smoke_audit_rejects_blocked_or_incomplete_reports() {
- let expected_commit = "0123456789abcdef0123456789abcdef01234567";
- let expected_shader_manifest_hash =
- "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c";
- let reports = [(
- "windows".to_string(),
- serde_json::json!({
- "schema_version": "fparkan-native-smoke-v1",
- "commit_sha": "unknown",
- "git_dirty": true,
- "runner_identity": "",
- "runner_architecture": "x86_64",
- "rust_toolchain": measured_rust_toolchain_version(),
- "target_triple": "x86_64-unknown-linux-gnu",
- "platform": "windows",
- "status": "blocked",
- "frames": 0,
- "resize_count": 0,
- "swapchain_recreate_count": 0,
- "validation_warning_count": null,
- "validation_error_count": null,
- "shader_manifest_hash": "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c",
- "vulkan_loader_status": "unavailable",
- "vulkan_instance_status": "skipped",
- "window_status": "planned",
- "vulkan_surface_status": "skipped",
- "vulkan_device_status": "skipped",
- "vulkan_swapchain_status": "skipped"
- }),
- )]
- .into_iter()
- .collect::<BTreeMap<_, _>>();
-
- let failures =
- audit_native_smoke_reports(&reports, expected_commit, expected_shader_manifest_hash);
-
- assert!(failures
- .contains(&"windows: status expected \"passed\", found \"blocked\"".to_string()));
- assert!(failures.contains(
- &"windows: commit_sha must be a 40-character lowercase or uppercase hex string"
- .to_string()
- ));
- assert!(failures.contains(&"windows: git_dirty expected false, found true".to_string()));
- assert!(failures.contains(&"windows: runner_identity must be non-empty".to_string()));
- assert!(failures.contains(
- &"windows: target_triple \"x86_64-unknown-linux-gnu\" does not match platform"
- .to_string()
- ));
- assert!(failures.contains(&"windows: frames expected >= 300, found 0".to_string()));
- assert!(failures
- .contains(&"windows: validation_error_count must be an unsigned integer".to_string()));
- }
-
- #[test]
- fn native_smoke_audit_rejects_stale_commit_sha() {
- let expected_shader_manifest_hash =
- "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c";
- let reports = [(
- "windows".to_string(),
- serde_json::json!({
- "schema_version": "fparkan-native-smoke-v1",
- "commit_sha": "fedcba98765432100123456789abcdef01234567",
- "git_dirty": false,
- "runner_identity": "windows-local/stage0",
- "runner_architecture": "x86_64",
- "rust_toolchain": measured_rust_toolchain_version(),
- "target_triple": "x86_64-pc-windows-msvc",
- "platform": "windows",
- "status": "passed",
- "frames": 300,
- "resize_count": 1,
- "swapchain_recreate_count": 1,
- "validation_warning_count": 0,
- "validation_error_count": 0,
- "shader_manifest_hash": expected_shader_manifest_hash,
- "vulkan_loader_status": "available",
- "vulkan_instance_status": "created",
- "window_status": "created",
- "vulkan_surface_status": "created",
- "vulkan_device_status": "selected",
- "vulkan_device_name": "Windows test GPU",
- "vulkan_logical_device_status": "created",
- "vulkan_logical_device_graphics_queue_family": 0,
- "vulkan_logical_device_present_queue_family": 0,
- "vulkan_logical_device_enabled_extension_count": 1,
- "vulkan_swapchain_status": "created",
- "vulkan_swapchain_width": 1280,
- "vulkan_swapchain_height": 720,
- "vulkan_swapchain_image_count": 3,
- "vulkan_portability_enumeration": false,
- "vulkan_portability_subset_enabled": false
- }),
- )]
- .into_iter()
- .collect::<BTreeMap<_, _>>();
-
- let failures = audit_native_smoke_reports(
- &reports,
- "0123456789abcdef0123456789abcdef01234567",
- expected_shader_manifest_hash,
- );
-
- assert!(failures.contains(
- &"windows: commit_sha expected \"0123456789abcdef0123456789abcdef01234567\", found \"fedcba98765432100123456789abcdef01234567\"".to_string()
- ));
- }
-
- #[test]
- fn native_smoke_audit_rejects_stale_shader_manifest_hash() {
- let expected_commit = "0123456789abcdef0123456789abcdef01234567";
- let reports = [(
- "windows".to_string(),
- serde_json::json!({
- "schema_version": "fparkan-native-smoke-v1",
- "commit_sha": expected_commit,
- "git_dirty": false,
- "runner_identity": "windows-local/stage0",
- "runner_architecture": "x86_64",
- "rust_toolchain": measured_rust_toolchain_version(),
- "target_triple": "x86_64-pc-windows-msvc",
- "platform": "windows",
- "status": "passed",
- "frames": 300,
- "resize_count": 1,
- "swapchain_recreate_count": 1,
- "validation_warning_count": 0,
- "validation_error_count": 0,
- "shader_manifest_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
- "vulkan_loader_status": "available",
- "vulkan_instance_status": "created",
- "window_status": "created",
- "vulkan_surface_status": "created",
- "vulkan_device_status": "selected",
- "vulkan_device_name": "Windows test GPU",
- "vulkan_logical_device_status": "created",
- "vulkan_logical_device_graphics_queue_family": 0,
- "vulkan_logical_device_present_queue_family": 0,
- "vulkan_logical_device_enabled_extension_count": 1,
- "vulkan_swapchain_status": "created",
- "vulkan_swapchain_width": 1280,
- "vulkan_swapchain_height": 720,
- "vulkan_swapchain_image_count": 3,
- "vulkan_portability_enumeration": false,
- "vulkan_portability_subset_enabled": false
- }),
- )]
- .into_iter()
- .collect::<BTreeMap<_, _>>();
-
- let failures = audit_native_smoke_reports(
- &reports,
- expected_commit,
- "dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c",
- );
-
- assert!(failures.contains(
- &"windows: shader_manifest_hash expected \"dd293e4ff08ffca1c037900d08b0ffd415db39f238b4fcdde46468fa049b679c\", found \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"".to_string()
- ));
- }
-
- #[test]
- fn defaults_to_all_stage_and_testdata_root() {
- let args = Vec::new();
- let parsed = parse_test_options(&args, PathBuf::from("testdata"));
-
- assert_eq!(
- parsed,
- Ok(TestOptions {
- stage: Stage::All,
- root: PathBuf::from("testdata"),
- manifest: None,
- })
- );
- }
-
- #[test]
- fn parses_licensed_corpora_manifest() -> Result<(), String> {
- let root = temp_dir("manifest");
- let part1 = root.join("IS");
- let part2 = root.join("IS2");
- fs::create_dir_all(&part1).map_err(|err| err.to_string())?;
- fs::create_dir_all(&part2).map_err(|err| err.to_string())?;
- let manifest = root.join("corpora.toml");
- fs::write(
- &manifest,
- format!(
- "schema = 1\n\n[[corpus]]\nid = \"part1-local\"\nkind = \"part1\"\nroot = '{}'\nexpected_profile = \"parkan-is-part1\"\n\n[[corpus]]\nid = \"part2-local\"\nkind = \"part2\"\nroot = '{}'\nexpected_profile = \"parkan-is-part2\"\n",
- part1.display(),
- part2.display()
- ),
- )
- .map_err(|err| err.to_string())?;
-
- assert_eq!(
- parse_licensed_manifest(&manifest)?,
- LicensedCorpusRoots { part1, part2 }
- );
- fs::remove_dir_all(root).map_err(|err| err.to_string())?;
- Ok(())
- }
-
- #[test]
- fn licensed_roots_require_manifest_configuration() {
- let previous = std::env::var_os(CORPORA_MANIFEST_ENV);
- std::env::remove_var(CORPORA_MANIFEST_ENV);
-
- assert_eq!(
- load_licensed_roots(None),
- Err(format!(
- "licensed tests require --manifest or {CORPORA_MANIFEST_ENV}=<absolute corpora.toml>"
- ))
- );
-
- if let Some(value) = previous {
- std::env::set_var(CORPORA_MANIFEST_ENV, value);
- }
- }
-
- #[test]
- fn rejects_unknown_stage() {
- assert_eq!(Stage::parse("6"), Err("stage out of range: 6".to_string()));
- assert_eq!(
- Stage::parse("assets"),
- Err("invalid stage: assets".to_string())
- );
- }
-
- #[test]
- fn maps_stage_packages() {
- assert!(stage_packages(0)
- .is_ok_and(|packages| packages.contains(&"fparkan-platform".to_string())));
- assert!(stage_packages(0)
- .is_ok_and(|packages| packages.contains(&"fparkan-vulkan-smoke".to_string())));
- assert!(stage_packages(1)
- .is_ok_and(|packages| packages.contains(&"fparkan-inspection".to_string())));
- assert!(stage_packages(5)
- .is_ok_and(|packages| packages.contains(&"fparkan-runtime".to_string())));
- assert!(
- stage_packages(5).is_ok_and(|packages| packages.contains(&"fparkan-game".to_string()))
- );
- assert_eq!(stage_packages(9), Err("stage out of range: 9".to_string()));
- }
-
- #[test]
- fn stage_package_manifest_covers_workspace_once() -> Result<(), String> {
- let manifest_path = workspace_relative_path(STAGE_PACKAGE_MANIFEST);
- let manifest = load_stage_package_manifest(&manifest_path)?;
- let workspace_packages = workspace_package_names(&workspace_root_path())?;
-
- validate_stage_package_entries(&manifest, &workspace_packages, &manifest_path)?;
-
- Ok(())
- }
-
- #[test]
- fn workspace_metadata_keeps_headless_closure_platform_free() -> Result<(), String> {
- let metadata = workspace_metadata(&workspace_root_path())?;
- let dependencies = workspace_dependency_names(&metadata);
- let mut failures = Vec::new();
-
- validate_package_closure_excludes("fparkan-headless", &dependencies, &mut failures);
-
- assert!(failures.is_empty(), "{failures:?}");
- Ok(())
- }
-
- #[test]
- fn parses_manifest_dependencies_for_arch_policy() {
- let manifest = r#"
-[package]
-name = "fparkan-example"
-
-[dependencies]
-fparkan-render = { path = "../fparkan-render" }
-"quoted-dep" = "1"
-
-[dev-dependencies]
-fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
-"#;
-
- let policy = toml::from_str::<CargoManifestPolicy>(manifest)
- .expect("typed policy manifest should parse");
- assert_eq!(policy.package_name(), Some("fparkan-example"));
- let deps = policy.dependency_names();
- assert!(deps.contains("fparkan-render"));
- assert!(deps.contains("quoted-dep"));
- assert!(deps.contains("fparkan-render-vulkan"));
- }
-
- #[test]
- fn typed_manifest_policy_accepts_workspace_inherited_license() {
- let manifest = r#"
-[package]
-name = "fparkan-example"
-license.workspace = true
-"#;
-
- let policy = toml::from_str::<CargoManifestPolicy>(manifest)
- .expect("workspace inheritance should be valid Cargo TOML");
-
- assert_eq!(policy.package_name(), Some("fparkan-example"));
- assert_eq!(policy.license(), None);
- }
-
- #[test]
- fn workspace_manifest_closure_detects_transitive_platform_bridge() {
- let deps_by_package = [
- (
- "fparkan-headless".to_string(),
- ["fparkan-runtime".to_string()].into_iter().collect(),
- ),
- (
- "fparkan-runtime".to_string(),
- ["fparkan-render-vulkan".to_string()].into_iter().collect(),
- ),
- ("fparkan-render-vulkan".to_string(), BTreeSet::new()),
- ]
- .into_iter()
- .collect::<BTreeMap<_, _>>();
-
- let closure = dependency_closure_names("fparkan-headless", &deps_by_package);
-
- assert!(closure.contains("fparkan-runtime"));
- assert_eq!(
- first_forbidden_platform_bridge_dependency(&closure),
- Some("fparkan-render-vulkan")
- );
- }
-
- #[test]
- fn toolchain_policy_rejects_moving_toolchain() -> Result<(), String> {
- let root = temp_dir("toolchain-moving");
- fs::create_dir_all(&root).map_err(|err| err.to_string())?;
- fs::write(
- root.join("rust-toolchain.toml"),
- "[toolchain]\nchannel = \"stable\"\n",
- )
- .map_err(|err| err.to_string())?;
- fs::write(
- root.join("Cargo.toml"),
- "[workspace]\n[workspace.package]\nrust-version = \"1.97\"\n",
- )
- .map_err(|err| err.to_string())?;
-
- let mut failures = Vec::new();
- validate_toolchain_policy(&root, &mut failures)?;
-
- assert_eq!(failures.len(), 2);
- assert!(failures[0].contains("must be exact"));
- assert!(failures[1].contains("major.minor.patch"));
- fs::remove_dir_all(root).map_err(|err| err.to_string())?;
- Ok(())
- }
-
- #[test]
- fn toolchain_policy_rejects_msrv_mismatch() -> Result<(), String> {
- let root = temp_dir("toolchain-msrv");
- fs::create_dir_all(&root).map_err(|err| err.to_string())?;
- fs::write(
- root.join("rust-toolchain.toml"),
- format!("[toolchain]\nchannel = \"{PINNED_RUST_TOOLCHAIN}\"\n"),
- )
- .map_err(|err| err.to_string())?;
- fs::write(
- root.join("Cargo.toml"),
- "[workspace]\n[workspace.package]\nrust-version = \"1.86\"\n",
- )
- .map_err(|err| err.to_string())?;
-
- let mut failures = Vec::new();
- validate_toolchain_policy(&root, &mut failures)?;
-
- assert_eq!(failures.len(), 2);
- assert!(failures[0].contains("rust-version must be"));
- assert!(failures[1].contains("must match pinned toolchain"));
- fs::remove_dir_all(root).map_err(|err| err.to_string())?;
- Ok(())
- }
-
- #[test]
- fn lockfile_supply_chain_rejects_unapproved_sources() -> Result<(), String> {
- let root = temp_dir("lockfile-source");
- fs::create_dir_all(&root).map_err(|err| err.to_string())?;
- fs::write(
- root.join("Cargo.lock"),
- r#"
-[[package]]
-name = "external"
-version = "1.0.0"
-source = "git+https://example.invalid/repo"
-"#,
- )
- .map_err(|err| err.to_string())?;
-
- let mut failures = Vec::new();
- validate_lockfile_supply_chain(&root, &mut failures)?;
-
- assert_eq!(failures.len(), 1);
- assert!(failures[0].contains("uses unapproved source"));
- fs::remove_dir_all(root).map_err(|err| err.to_string())?;
- Ok(())
- }
-
- #[test]
- fn lockfile_supply_chain_rejects_banned_packages() -> Result<(), String> {
- let root = temp_dir("lockfile-ban");
- fs::create_dir_all(&root).map_err(|err| err.to_string())?;
- fs::write(
- root.join("Cargo.lock"),
- format!(
- "[[package]]\nname = \"openssl\"\nversion = \"0.10.0\"\nsource = \"{APPROVED_REGISTRY_SOURCE}\"\n"
- ),
- )
- .map_err(|err| err.to_string())?;
-
- let mut failures = Vec::new();
- validate_lockfile_supply_chain(&root, &mut failures)?;
-
- assert_eq!(failures.len(), 1);
- assert!(failures[0].contains("is banned"));
- fs::remove_dir_all(root).map_err(|err| err.to_string())?;
- Ok(())
- }
-
- #[test]
- fn supply_chain_policy_config_must_exist() -> Result<(), String> {
- let root = temp_dir("supply-chain-config");
- fs::create_dir_all(&root).map_err(|err| err.to_string())?;
-
- let missing = root.join("deny.toml");
- assert!(validate_supply_chain_policy_config(&missing).is_err());
-
- fs::write(&missing, "[graph]\nall-features = true\n").map_err(|err| err.to_string())?;
- assert_eq!(validate_supply_chain_policy_config(&missing), Ok(()));
-
- fs::remove_dir_all(root).map_err(|err| err.to_string())?;
- Ok(())
- }
-
- #[test]
- fn ci_env_truthy_values_are_detected() {
- assert!(ci_env_value_is_active(Some("true")));
- assert!(ci_env_value_is_active(Some("1")));
- assert!(ci_env_value_is_active(Some("yes")));
- assert!(!ci_env_value_is_active(None));
- assert!(!ci_env_value_is_active(Some("")));
- assert!(!ci_env_value_is_active(Some("0")));
- assert!(!ci_env_value_is_active(Some("false")));
- assert!(!ci_env_value_is_active(Some(" FALSE ")));
- }
-
- #[test]
- fn detects_forbidden_domain_dependencies() {
- assert!(!is_forbidden_domain_dependency("fparkan-render-vulkan"));
- assert!(is_forbidden_domain_dependency("sdl2"));
- assert!(is_forbidden_domain_dependency("fparkan-platform-sdl"));
- assert!(!is_forbidden_domain_dependency("fparkan-render"));
- assert!(!is_forbidden_domain_dependency("fparkan-platform"));
- }
-
- #[test]
- fn parses_package_options() {
- assert_eq!(
- parse_package_options(&strings(&[
- "--target",
- "aarch64-apple-darwin",
- "--app",
- "viewer"
- ])),
- Ok(PackageOptions {
- target: "aarch64-apple-darwin".to_string(),
- app: AppPackage::Viewer,
- })
- );
- assert_eq!(
- parse_package_options(&strings(&["--target", "x", "--app", "bad"])),
- Err("unknown app: bad".to_string())
- );
- }
-
- #[test]
- fn app_packages_map_to_cargo_packages() {
- assert_eq!(AppPackage::Cli.package(), "fparkan-cli");
- assert_eq!(AppPackage::Game.package(), "fparkan-game");
- assert_eq!(AppPackage::Headless.package(), "fparkan-headless");
- assert_eq!(AppPackage::Viewer.package(), "fparkan-viewer");
- }
-
- #[test]
- fn policy_source_detection_is_scoped_to_code_files() {
- assert!(is_policy_source(Path::new("src/main.rs")));
- assert!(is_policy_source(Path::new("Cargo.toml")));
- assert!(!is_policy_source(Path::new("README.md")));
- assert!(should_skip_policy_path(Path::new("target")));
- assert!(should_skip_policy_path(Path::new("testdata")));
- assert!(!should_skip_policy_path(Path::new("crates/experimental")));
- assert!(!should_skip_policy_path(Path::new("crates/fparkan-render")));
- }
-
- #[test]
- fn unsafe_construct_detector_ignores_lints_and_comments() {
- assert!(contains_unsafe_construct(concat!(
- "un",
- "safe fn call() {}"
- )));
- assert!(contains_unsafe_construct(concat!(
- "let value = un",
- "safe { call() };"
- )));
- assert!(!contains_unsafe_construct("#![forbid(unsafe_code)]"));
- }
-
- #[test]
- fn unsafe_module_attributes_must_match_audited_ffi_allowlist() {
- let audited = Path::new("adapters/fparkan-render-vulkan/src/ffi/runtime.rs");
- let non_audited = Path::new("adapters/fparkan-render-vulkan/src/ffi.rs");
-
- let mut failures = Vec::new();
- validate_unsafe_module_attribute_policy(audited, "mod x;\n", &mut failures);
- assert_eq!(
- failures,
- vec![format!(
- "{}: audited Vulkan FFI module must declare #![allow(unsafe_code)]",
- audited.display()
- )]
- );
-
- failures.clear();
- validate_unsafe_module_attribute_policy(
- non_audited,
- "#![allow(unsafe_code)]\nmod runtime;\n",
- &mut failures,
- );
- assert_eq!(
- failures,
- vec![format!(
- "{}: #![allow(unsafe_code)] is only permitted in audited Vulkan FFI modules",
- non_audited.display()
- )]
- );
- }
-
- #[test]
- fn audited_unsafe_allowlist_accepts_windows_separators() {
- let path = Path::new(r".\adapters\fparkan-render-vulkan\src\ffi\runtime.rs");
-
- assert!(is_audited_unsafe_source(path));
- }
-
- #[test]
- fn shader_source_hash_normalizes_windows_line_endings() -> Result<(), String> {
- let root = temp_dir("shader-source-hash");
- fs::create_dir_all(&root).map_err(|err| err.to_string())?;
- let lf = root.join("lf.glsl");
- let crlf = root.join("crlf.glsl");
- fs::write(&lf, b"#version 450\nvoid main() {}\n").map_err(|err| err.to_string())?;
- fs::write(&crlf, b"#version 450\r\nvoid main() {}\r\n").map_err(|err| err.to_string())?;
-
- assert_eq!(
- sha256_canonical_text_file(&lf)?,
- sha256_canonical_text_file(&crlf)?
- );
-
- fs::remove_dir_all(root).map_err(|err| err.to_string())?;
- Ok(())
- }
-}