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 62e1e0a..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.98.1 - - name: Test backend-neutral crates - run: > - cargo test - -p fparkan-animation - -p fparkan-binary - -p fparkan-corpus - -p fparkan-diagnostics - -p fparkan-fx - -p fparkan-inspection - -p fparkan-material - -p fparkan-mission-format - -p fparkan-msh - -p fparkan-nres - -p fparkan-path - -p fparkan-platform - -p fparkan-prototype - -p fparkan-render - -p fparkan-resource - -p fparkan-rsli - -p fparkan-runtime - -p fparkan-terrain - -p fparkan-terrain-format - -p fparkan-texm - -p fparkan-vfs - -p fparkan-world - --all-targets - --locked - - stage0-matrix: - name: Stage 0 CI (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - include: - - os: macos-15 - smoke_platform: macos - runner_arch: arm64 - moltenvk_version: 1.4.1 - vulkan_sdk_version: 1.4.350.1 - env: - CARGO_TERM_COLOR: always - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 - with: - toolchain: 1.98.1 - components: clippy,rustfmt - - name: Provision macOS Vulkan runtime - shell: bash - run: | - set -euo pipefail - brew install molten-vk vulkan-loader vulkan-tools vulkan-validationlayers - test "$(uname -m)" = "${{ matrix.runner_arch }}" - ruby <<'RUBY' - require "json" - - expected = { - "molten-vk" => "${{ matrix.moltenvk_version }}", - "vulkan-loader" => "${{ matrix.vulkan_sdk_version }}", - "vulkan-tools" => "${{ matrix.vulkan_sdk_version }}", - "vulkan-validationlayers" => "${{ matrix.vulkan_sdk_version }}", - } - payload = JSON.parse(`brew info --json=v2 #{expected.keys.join(" ")}`) - actual = payload.fetch("formulae").to_h do |formula| - [formula.fetch("name"), formula.fetch("versions").fetch("stable")] - end - mismatches = expected.each_with_object({}) do |(name, version), out| - actual_version = actual[name] - next if actual_version == version - - out[name] = { - "expected" => version, - "actual" => actual_version, - } - end - unless mismatches.empty? - warn JSON.pretty_generate(mismatches) - abort "unexpected macOS Vulkan formula version" - end - RUBY - HOMEBREW_PREFIX="$(brew --prefix)" - VK_ICD_FILENAMES="$HOMEBREW_PREFIX/opt/molten-vk/etc/vulkan/icd.d/MoltenVK_icd.json" - VK_LAYER_PATH="$HOMEBREW_PREFIX/opt/vulkan-validationlayers/share/vulkan/explicit_layer.d" - DYLD_FALLBACK_LIBRARY_PATH="$HOMEBREW_PREFIX/opt/vulkan-loader/lib:$HOMEBREW_PREFIX/opt/molten-vk/lib" - test -f "$VK_ICD_FILENAMES" - test -d "$VK_LAYER_PATH" - echo "VK_ICD_FILENAMES=$VK_ICD_FILENAMES" >> "$GITHUB_ENV" - echo "VK_LAYER_PATH=$VK_LAYER_PATH" >> "$GITHUB_ENV" - echo "DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH" >> "$GITHUB_ENV" - - name: Install cargo-deny - run: cargo install cargo-deny --version 0.19.9 --locked - - name: Verify shader provenance - run: cargo xtask shader-provenance - - name: Run canonical CI gate - run: cargo xtask ci - - name: Run native Vulkan smoke - run: > - cargo run -p fparkan-vulkan-smoke --locked -- - --out "target/fparkan/native-smoke/${{ matrix.smoke_platform }}.json" - --timeout-seconds 120 - - name: Upload acceptance audit - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: stage-0-acceptance-${{ matrix.os }} - path: target/fparkan/acceptance/stage-0-audit.json - if-no-files-found: error - - name: Upload native smoke report - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 - with: - name: native-smoke-${{ matrix.smoke_platform }} - path: target/fparkan/native-smoke/*.json - if-no-files-found: error - - native-smoke-audit: - name: Native smoke audit - runs-on: ubuntu-latest - timeout-minutes: 15 - needs: stage0-matrix - env: - CARGO_TERM_COLOR: always - steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - - uses: dtolnay/rust-toolchain@67ef31d5b988238dd797d409d6f9574278e20537 - with: - toolchain: 1.98.1 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 - with: - pattern: native-smoke-* - path: target/fparkan/native-smoke-artifacts - merge-multiple: true - - name: Aggregate native smoke reports - run: > - cargo xtask native-smoke audit - --dir target/fparkan/native-smoke-artifacts 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 +# Local licensed game data and research output +/testdata/* +!/testdata/.gitignore +/target-research/ +*.capture +*.dump -# 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/ - -# 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 a2081a5..0000000 --- a/Cargo.lock +++ /dev/null @@ -1,1884 +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 = "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.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[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 = "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 = "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.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -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..02d4420 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,60 @@ # FParkan -Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»**. +Открытый движок *Паркан: Железная стратегия* на Rust и Vulkan. Он читает +ресурсы установленной оригинальной игры. Сейчас приложение загружает миссию +и показывает её ландшафт и модели в статическом окне Vulkan. -## Описание +## Запуск -Проект находится в активной разработке и включает: +Нужны Rust и драйвер видеокарты с поддержкой Vulkan. Игровые файлы в +репозиторий не входят. -- библиотеки для работы с форматами игровых архивов; -- спецификации форматов и сопутствующую документацию. - -## Установка - -Проект находится в начальной стадии, подробная инструкция по установке пока отсутствует. - -## Документация - -- локально: каталог [`docs/`](docs) -- сайт: - -## Библиотеки - -- [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 +```powershell +cargo run -p fparkan-game -- ` + --root 'C:\GOG Games\Parkan - Iron Strategy' ` + --mission 'MISSIONS\Autodemo.00\data.tma' --frames 240 ``` -Для дополнительного тестирования на реальных игровых ресурсах: +`--frames` задаёт число кадров. `--preview-roots N` ограничивает число объектов, +`--legacy-camera-capture` читает сохранённую матрицу камеры, +`--static-animation-frame` и `--static-material-phase` выбирают позу и фазу +материала. `--readback-out target/frame.raw` сохраняет последний кадр GPU. +Без сохранённой камеры используется вид карты сверху. -- используйте оригинальную копию игры (диск или [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 +```text +cargo fmt --all -- --check +cargo test --workspace +cargo check --workspace ``` -## Stage 0 Vulkan smoke +Алгоритмы и форматы проверяются обычными Rust-тестами рядом с кодом. +Тесты с оригинальными ресурсами помечены `#[ignore]`; пути к двум частям +установки задаются через `FPARKAN_CORPUS_PART1_ROOT` и +`FPARKAN_CORPUS_PART2_ROOT`. Зависимости разрешает Cargo, локальный +`Cargo.lock` не хранится в Git. -Локальный Stage 0 smoke запускает реальный `winit` lifecycle и Vulkan triangle path с включёнными validation layers. Успешный прогон обязан: +Готовые SPIR-V шейдеры находятся рядом с исходниками GLSL. +Vulkan SDK нужен только для их пересборки и проверки с `--validation`. +При доступных в PATH инструментах SDK: -- отрисовать 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 +```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. Перед запуском убедитесь, что установлен Vulkan runtime -от GPU vendor или LunarG Vulkan SDK и активный runtime предоставляет -`VK_LAYER_KHRONOS_validation`. Linux, macOS/MoltenVK и их smoke-пути не входят в -проектный scope. +## Книга и исходники -Для полного локального closure gate используйте: +[Книга об устройстве движка](docs/index.md) объясняет форматы, математику, +загрузку мира и связь подсистем. Её можно читать прямо в репозитории; +`mkdocs.yml` задаёт представление на [сайте проекта](https://fparkan.popov.link). -```bash -cargo xtask ci -``` - -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) - -Основная разработка ведётся в 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 { - 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 { - 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 { - 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/src/asset_mesh.rs b/adapters/fparkan-render-vulkan/src/asset_mesh.rs index 79a42a4..236361b 100644 --- a/adapters/fparkan-render-vulkan/src/asset_mesh.rs +++ b/adapters/fparkan-render-vulkan/src/asset_mesh.rs @@ -439,7 +439,6 @@ fn project_msh_to_static_mesh_in_world_space_with_node_poses( }) } -#[allow(clippy::too_many_arguments)] fn append_node_fallback_batch( model: &ModelAsset, batch: &fparkan_msh::Batch, diff --git a/adapters/fparkan-render-vulkan/src/ffi.rs b/adapters/fparkan-render-vulkan/src/ffi.rs index 79ca919..640551a 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. //! @@ -57,9 +38,8 @@ 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}; @@ -84,8 +64,8 @@ pub use self::smoke_types::{ #[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, 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, 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..f1fd579 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/resources.rs @@ -26,7 +26,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, @@ -249,7 +248,6 @@ pub(super) fn create_static_mesh_index_buffer( ) } -#[allow(clippy::too_many_lines)] pub(super) fn create_static_texture_image( instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, @@ -376,7 +374,6 @@ pub(super) fn create_static_texture_image( }) } -#[allow(clippy::too_many_lines)] fn upload_static_texture( device: &VulkanLogicalDeviceProbe, command_pool: vk::CommandPool, diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs index e41f0f9..e2f6c34 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/smoke.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/smoke.rs @@ -17,7 +17,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( @@ -103,7 +103,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 { @@ -120,8 +120,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); @@ -240,7 +239,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 +258,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() @@ -426,7 +423,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 { if let Some(extent) = self.pending_extent.take() { if extent.0 == 0 || extent.1 == 0 { @@ -632,7 +629,6 @@ impl VulkanSmokeRenderer { Ok(()) } - #[allow(clippy::too_many_lines)] fn record_command_buffer( &mut self, image_index: usize, diff --git a/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs b/adapters/fparkan-render-vulkan/src/ffi/smoke_types.rs index 4686eaf..217df3f 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)] @@ -536,7 +536,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 +562,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 +650,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 +693,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:?}") } 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 { // 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..c175f0d 100644 --- a/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs +++ b/adapters/fparkan-render-vulkan/src/ffi/swapchain_resources.rs @@ -45,7 +45,6 @@ struct PartialSwapchainResources { command_buffers: Vec, } -#[allow(clippy::too_many_arguments, clippy::too_many_lines)] pub(super) fn create_swapchain_resources( instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, @@ -205,7 +204,6 @@ fn create_swapchain_image_views( Ok(image_views) } -#[allow(clippy::type_complexity)] fn create_swapchain_pipeline_bundle( instance: &VulkanInstanceProbe, device: &VulkanLogicalDeviceProbe, @@ -433,7 +431,6 @@ fn create_pipeline_layout( }) } -#[allow(clippy::too_many_lines)] fn create_texture_descriptor_bundle( device: &VulkanLogicalDeviceProbe, textures: &[VulkanAllocatedImage], @@ -554,12 +551,10 @@ fn create_texture_descriptor_bundle( 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 +591,6 @@ fn create_graphics_pipeline_cache( Ok(pipelines) } -#[allow(clippy::too_many_lines)] fn create_graphics_pipeline( device: &VulkanLogicalDeviceProbe, render_pass: vk::RenderPass, diff --git a/adapters/fparkan-render-vulkan/src/ffi/tests.rs b/adapters/fparkan-render-vulkan/src/ffi/tests.rs index a842c73..740c13d 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() { @@ -390,38 +285,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!( @@ -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(), + ] ); } @@ -509,20 +378,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"); @@ -620,128 +475,31 @@ 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"); - - assert_eq!(report.schema, SHADER_MANIFEST_SCHEMA); - assert_eq!(report.target_env, SHADER_TARGET_ENV); +fn shader_container_rejects_invalid_spirv() { assert_eq!( - report.compiler, - VulkanShaderToolManifest { - name: SHADER_COMPILER_NAME, - version: SHADER_COMPILER_VERSION, - binary_sha256: SHADER_COMPILER_BINARY_SHA256, - } - ); - assert_eq!( - report.validator, - VulkanShaderToolManifest { - name: SPIRV_VALIDATOR_NAME, - version: SPIRV_VALIDATOR_VERSION, - binary_sha256: SPIRV_VALIDATOR_BINARY_SHA256, - } - ); - assert_eq!(report.modules.len(), 2); - assert_eq!(report.modules[0].name, "triangle.vert"); - assert_eq!(report.modules[0].stage, VulkanShaderStage::Vertex); - assert_eq!(report.modules[0].source_path, TRIANGLE_VERTEX_SOURCE_PATH); - assert_eq!( - report.modules[0].source_sha256, - TRIANGLE_VERTEX_SOURCE_SHA256 - ); - assert_eq!(report.modules[0].spirv_path, TRIANGLE_VERTEX_SPIRV_PATH); - assert_eq!(report.modules[0].word_count, 358); - assert_eq!( - report.modules[0].sha256, - "4e2051ac43b933a57e5557e596bcc95952b4efbfcb45315180f89581e094398d" - ); - assert_eq!(report.modules[0].descriptor_sets, 0); - assert_eq!(report.modules[0].push_constant_bytes, 64); - assert_eq!( - report.modules[0].compile_command, - TRIANGLE_VERTEX_COMPILE_COMMAND - ); - assert_eq!( - report.modules[0].validate_command, - TRIANGLE_VERTEX_VALIDATE_COMMAND - ); - assert!(!report.modules[0].interface_hash.is_empty()); - assert_eq!( - report.modules[1].sha256, - "8ab7bf835e166892b04b10fa1100258daf355d2f693ad311d014dbee22de5c7b" - ); - assert_eq!( - report.manifest_hash, - "cf471972978ddb5c8919711ad64c93d0d64e18b1f590a89ce014f1e2a743cfb5" - ); -} - -#[test] -fn shader_manifest_report_json_is_stable() { - let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest"); - let json = render_shader_manifest_report_json(&report); - - assert!(json.contains(SHADER_COMPILER_NAME)); - assert!(json.contains(SPIRV_VALIDATOR_NAME)); - assert!(json.contains(TRIANGLE_VERTEX_SOURCE_PATH)); - assert!(json.contains(TRIANGLE_VERTEX_COMPILE_COMMAND)); -} - -#[test] -fn checked_in_shader_manifest_matches_generated_report() { - let report = validate_shader_manifest(&triangle_shader_manifest()).expect("shader manifest"); - assert_eq!( - render_shader_manifest_report_json(&report), - include_str!("../../shaders/manifest.json").trim() - ); -} - -#[test] -fn shader_manifest_rejects_invalid_spirv_containers() { - let mut module = triangle_shader_manifest().remove(0); - module.words = &[0xFFFF_FFFF, SPIRV_VERSION_1_0, 0, 1, 0]; - assert_eq!( - validate_shader_manifest(&[module]), - Err(VulkanShaderManifestError::InvalidMagic { + 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, -} - -/// 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 { - 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..ce7b59e 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, @@ -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 { - 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(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!( "{}.{}.{}", 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, - /// 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 { - 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 { - 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 { - 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, -} - -#[derive(Serialize)] -struct PrototypeInspectOutput { - schema_version: &'static str, - key: String, - roots: usize, - node_count: usize, - edge_count: usize, - unit_component_records: Vec, - edges: Vec, - 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, -} - -#[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, -} - -#[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, - shade_pairs: usize, - #[serde(skip_serializing_if = "Option::is_none")] - shade_lookup_key_min: Option, - #[serde(skip_serializing_if = "Option::is_none")] - shade_lookup_key_max: Option, - 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, - #[serde(skip_serializing_if = "Option::is_none")] - last_material: Option, -} - -#[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, - #[serde(skip_serializing_if = "Option::is_none")] - animation_frame_count: Option, - node38: Vec, -} - -#[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, - #[serde(skip_serializing_if = "Option::is_none")] - resource: Option, -} - -#[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, - parent_edge: Option, - unit_component_index: Option, - archive: Option, - resource_raw_hex: Option, -} - -#[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, -} - -#[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 = 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 { - 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::::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 { - 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, 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 { - 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 { - 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 { - parse_option(args, names).ok_or_else(|| format!("missing {label}")) -} - -fn parse_option(args: &[String], names: &[&str]) -> Option { - 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 { - 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::>(); - 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::>(); - 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 { - 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, -) -> Result { - serialize_json(&ArchiveInspectOutput { - schema_version: ARCHIVE_INSPECT_SCHEMA, - path, - kind, - entries, - lookup_order_valid, - }) -} - -fn parse_archive_path(args: &[String]) -> Result { - parse_file_path(args, "archive inspect") -} - -fn parse_file_path(args: &[String], command: &str) -> Result { - match args { - [path] => Ok(PathBuf::from(path)), - [flag, path] if flag == "--file" => Ok(PathBuf::from(path)), - _ => Err(format!("{command} requires or --file ")), - } -} - -fn serialize_json(value: &T) -> Result { - 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 [--format json] | archive inspect [--format json] | terrain inspect [--format json] | wear inspect --root --archive --resource [--format json] | model inspect --root --archive --resource [--format json] | script inspect [--format json] | varset inspect [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn strings(values: &[&str]) -> Vec { - 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..780a21c 100644 --- a/apps/fparkan-game/Cargo.toml +++ b/apps/fparkan-game/Cargo.toml @@ -6,20 +6,14 @@ 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-assets = { path = "../../crates/fparkan-assets" } +fparkan-path = { path = "../../crates/fparkan-path" } +fparkan-render = { path = "../../crates/fparkan-render" } +fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } +fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } +fparkan-runtime = { path = "../../crates/fparkan-runtime" } +fparkan-terrain = { path = "../../crates/fparkan-terrain" } +fparkan-vfs = { path = "../../crates/fparkan-vfs" } 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/main.rs b/apps/fparkan-game/src/main.rs index af7fe1d..2130bf5 100644 --- a/apps/fparkan-game/src/main.rs +++ b/apps/fparkan-game/src/main.rs @@ -1,60 +1,32 @@ #![forbid(unsafe_code)] -#![cfg_attr( - test, - allow( - clippy::cast_possible_truncation, - clippy::cast_possible_wrap, - clippy::cast_precision_loss, - clippy::expect_used, - clippy::float_cmp, - clippy::identity_op, - clippy::too_many_lines, - clippy::uninlined_format_args, - clippy::map_unwrap_or, - clippy::needless_raw_string_hashes, - clippy::semicolon_if_nothing_returned, - clippy::type_complexity, - clippy::panic, - clippy::unwrap_used - ) -)] -#![allow(clippy::print_stderr, clippy::print_stdout)] + //! `FParkan` render-planning composition root. -use fparkan_assets::{PreparedTextureUsage, PreparedVisual}; -use fparkan_inspection::load_standalone_wear_material_texture_mip0_rgba8_from_root; -use fparkan_path::ResourceName; -use fparkan_platform::WindowPort; -use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; -use fparkan_render::{ - build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, - LegacyD3d7Projection, LegacyIron3dEulerTransform, RawCameraTransform, RenderBackend, - RenderCommand, RenderCommandList, RenderPhase, RenderProfile, RenderSnapshot, - RenderSnapshotDraw, +use fparkan_assets::{ + load_standalone_wear_material_texture_mip0_rgba8_from_root, PreparedTextureUsage, + PreparedVisual, }; +use fparkan_path::ResourceName; +use fparkan_platform_winit::{window_native_handles, WinitWindow, WinitWindowPlan}; +use fparkan_render::{LegacyD3d7Projection, LegacyIron3dEulerTransform, RawCameraTransform}; use fparkan_render_vulkan::{ 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_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_xy_frame_with_node_sampled_poses, VulkanSmokeFrameOutcome, + VulkanSmokeRenderer, VulkanSmokeRendererCreateInfo, VulkanStaticCamera, VulkanStaticMaterial, + VulkanStaticMesh, VulkanStaticTexture, VulkanStaticXyFrame, }; 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_vfs::DirectoryVfs; -#[cfg(test)] -use fparkan_world::OriginalObjectId; -use fparkan_world::{TransformState, WorldSnapshot}; use serde::Deserialize; use std::num::NonZeroUsize; use std::path::PathBuf; @@ -83,91 +55,42 @@ fn main() { fn run(args: &[String]) -> Result { 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 loaded = load_requested_mission(&mut engine, &args)?; - 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 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, + )?; + run_static_vulkan_mode( + preview, 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, + ) } fn load_requested_mission( @@ -177,62 +100,10 @@ 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); - } - } - }, - ) - } 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) - { - write_error = Some(err); - } - } - }) - } - .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() - ) - }) - .map_err(|err| format!("{}: {err}", progress_path.display()))?; - return Ok(loaded); - } - if args.backend == RenderBackendMode::StaticVulkan { - if args.preview_roots.get() == 1 { - load_mission_static_preview(engine, request) - } else { - load_mission_static_preview_roots(engine, request, args.preview_roots) - } + if args.preview_roots.get() == 1 { + load_mission_static_preview(engine, request) } else { - load_mission(engine, request) + load_mission_static_preview_roots(engine, request, args.preview_roots) } .map_err(|err| err.to_string()) } @@ -243,11 +114,8 @@ struct StaticPreviewScene { materials: Vec, camera: VulkanStaticCamera, camera_mode: &'static str, - clip_visible_vertices: usize, mesh_components: usize, terrain_components: usize, - animation_frame: Option, - material_phase: Option, } /// Projects the mission terrain plus every MSH component of the explicitly @@ -259,7 +127,7 @@ struct StaticPreviewScene { /// 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)] + fn static_preview_mesh_and_materials( assets: &MissionAssets, terrain: &TerrainWorld, @@ -366,7 +234,6 @@ fn static_preview_mesh_and_materials( } let camera = legacy_camera.unwrap_or_default(); Ok(StaticPreviewScene { - clip_visible_vertices: static_preview_clip_visible_vertices(&mesh, camera), mesh, materials, camera, @@ -377,51 +244,9 @@ fn static_preview_mesh_and_materials( }, 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, @@ -518,7 +343,7 @@ fn static_preview_terrain_base_materials( .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( + let image = load_standalone_wear_material_texture_mip0_rgba8_from_root( root, &wear_path, selection.material_index, @@ -535,9 +360,9 @@ fn static_preview_terrain_base_materials( materials.push(VulkanStaticMaterial { material_index: preview_selector, texture: VulkanStaticTexture { - width: selected.image.width, - height: selected.image.height, - rgba8: selected.image.rgba8, + width: image.width, + height: image.height, + rgba8: image.rgba8, }, }); Ok((packed_tag, preview_selector)) @@ -664,46 +489,23 @@ fn append_static_preview_component( 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 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 run_static_vulkan_mode( preview: StaticPreviewScene, - preview_roots: usize, target_frames: u64, mission: &str, object_count: usize, readback_out: Option<&std::path::Path>, + enable_validation: bool, ) -> Result { let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; event_loop.set_control_flow(ControlFlow::Poll); let mut app = StaticVulkanApp::new( preview, - preview_roots, target_frames, mission, object_count, readback_out.map(std::path::Path::to_path_buf), + enable_validation, ); if let Err(err) = event_loop.run_app(&mut app) { app.error = Some(format!("winit event loop: {err}")); @@ -716,16 +518,13 @@ struct StaticVulkanApp { materials: Vec, camera: VulkanStaticCamera, camera_mode: &'static str, - clip_visible_vertices: usize, mesh_components: usize, terrain_components: usize, - animation_frame: Option, - material_phase: Option, - preview_roots: usize, target_frames: u64, mission: String, object_count: usize, readback_out: Option, + enable_validation: bool, window_id: Option, window: Option, renderer: Option, @@ -737,27 +536,24 @@ struct StaticVulkanApp { impl StaticVulkanApp { fn new( preview: StaticPreviewScene, - preview_roots: usize, target_frames: u64, mission: &str, object_count: usize, readback_out: Option, + enable_validation: bool, ) -> Self { Self { mesh: preview.mesh, materials: preview.materials, camera: preview.camera, 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, window_id: None, window: None, renderer: None, @@ -788,7 +584,9 @@ 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 @@ -796,6 +594,11 @@ impl StaticVulkanApp { 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() { @@ -821,27 +624,16 @@ impl StaticVulkanApp { (None, _) => None, }; 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={}, validation={}, readback={}", + 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), + validation_status, + readback_path.as_deref().unwrap_or("none"), )); event_loop.exit(); } @@ -890,7 +682,7 @@ impl ApplicationHandler for StaticVulkanApp { 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(), @@ -920,8 +712,7 @@ 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::Resized(size) => { if let Some(renderer) = self.renderer.as_mut() { @@ -964,157 +755,12 @@ impl ApplicationHandler for StaticVulkanApp { } } -#[cfg(test)] -fn render_snapshot_commands(snapshot: &WorldSnapshot) -> Result { - 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 { - 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)] struct Args { root: PathBuf, mission: String, frames: u64, - backend: RenderBackendMode, - load_progress: Option, + validation: bool, readback_out: Option, preview_roots: NonZeroUsize, legacy_camera_capture: Option, @@ -1122,20 +768,12 @@ struct Args { static_material_phase: Option, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RenderBackendMode { - Planning, - StaticVulkan, -} - impl Args { - #[allow(clippy::too_many_lines)] fn parse(args: &[String]) -> Result { let mut root = None; let mut mission = None; let mut frames = 1; - let mut backend = RenderBackendMode::Planning; - let mut load_progress = 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 +807,8 @@ impl Args { .parse() .map_err(|_| "--frames must be an integer".to_string())?; } - "--backend" => { - backend = match 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()), - }; - } - "--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( @@ -1235,24 +858,11 @@ impl Args { 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, + validation, readback_out, preview_roots, legacy_camera_capture, @@ -1339,86 +949,18 @@ fn decode_legacy_camera_capture_json(bytes: &[u8]) -> Result { } } -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 --mission [--frames ] [--backend ] [--preview-roots ] [--legacy-camera-capture ] [--static-animation-frame ] [--static-material-phase ] [--readback-out ] [--load-progress ]".to_string() + "usage: fparkan-game --root --mission [--frames ] [--validation] [--preview-roots ] [--legacy-camera-capture ] [--static-animation-frame ] [--static-material-phase ] [--readback-out ]".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 { 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], - }, - ], - indices: Vec::new(), - draw_ranges: Vec::new(), - }; - assert_eq!( - static_preview_clip_visible_vertices(&mesh, VulkanStaticCamera::default()), - 2 - ); - } - #[test] fn parses_required_args() { assert_eq!( @@ -1434,8 +976,7 @@ mod tests { root: PathBuf::from("testdata/IS"), mission: "MISSIONS/Autodemo.00/data.tma".to_string(), frames: 3, - backend: RenderBackendMode::Planning, - load_progress: None, + validation: false, readback_out: None, preview_roots: NonZeroUsize::MAX, legacy_camera_capture: None, @@ -1446,29 +987,16 @@ mod tests { } #[test] - fn parses_static_vulkan_backend() { - 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, - }) - ); + 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] @@ -1478,8 +1006,6 @@ mod tests { "testdata/IS", "--mission", "MISSIONS/Autodemo.00/data.tma", - "--backend", - "static-vulkan", "--preview-roots", "2", ])) @@ -1491,26 +1017,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 +1031,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 +1045,12 @@ 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", - ]; - assert_eq!( - Args::parse(&strings(&common)), - Err("--static-material-phase requires --backend static-vulkan".to_string()) - ); + 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 +1075,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", ])) @@ -1690,38 +1134,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> { @@ -1773,267 +1185,4 @@ mod tests { assert_eq!(merged.indices, vec![65_536, 65_537, 65_538]); Ok(()) } - - #[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, - 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(), - }; - 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()); - }; - 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 - ] - ); - Ok(()) - } - - #[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, - 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()); - }; - 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\"" - ); - } - - #[derive(Clone, Copy)] - struct RenderCase { - root: &'static str, - mission: &'static str, - expected: &'static str, - } - - fn render_args(root: &Path, mission: &str) -> Vec { - 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() - ); - 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 = 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, - mission: Option, - ticks: u64, - reference_movement: Option, -} - -#[derive(Clone, Copy, Debug, PartialEq)] -struct ReferenceMovement { - original_id: u32, - target_xy: [f32; 2], - max_step: f32, -} - -impl Args { - fn parse(args: &[String]) -> Result { - 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 { - 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 --mission ] [--move-object ] [--ticks ]".to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn args(values: &[&str]) -> Vec { - 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::>(); - let code = match run(&args) { - Ok(json) => { - println!("{json}"); - 0 - } - Err(err) => { - eprintln!("{err}"); - 2 - } - }; - std::process::exit(code); -} - -fn run(args: &[String]) -> Result { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - parse_path_option(args, &["--file"], "--file") -} - -fn parse_limit(args: &[String]) -> Result { - parse_option(args, &["--limit"]) - .map(|value| { - value - .parse::() - .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 { - parse_option(args, names) - .map(PathBuf::from) - .ok_or_else(|| format!("missing {label}")) -} - -fn parse_option(args: &[String], names: &[&str]) -> Option { - 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 [--limit N] | model --root --archive --name | model --fixture synthetic/model-basic | texture --root --archive --name | map --file --kind land-msh|land-map".to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn strings(values: &[&str]) -> Vec { - 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 { - env::var_os("CARGO_MANIFEST_DIR") - .map(PathBuf::from) - .map(|manifest_dir| manifest_dir.join("../..")) -} - -fn env_commit_sha() -> Option { - ["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 { - 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 { - 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 { - 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 { - 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::>(); - let code = match run(&args) { - Ok(output) => { - println!("{output}"); - 0 - } - Err(err) => { - eprintln!("{err}"); - 2 - } - }; - std::process::exit(code); -} - -fn run(args: &[String]) -> Result { - 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, - progress: Arc, -) { - 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 { - 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::().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, - mesh_input: MeshInput, - texture_input: Option, -} - -#[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 { - 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 { - 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, 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::>(); - 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), - 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, - completed: Arc, - progress: Arc, - window_id: Option, - window: Option, - renderer: Option, - final_renderer: Option, - error: Option, - output: Option, - frames_presented: u32, - resize_count: u32, - resize_requested: bool, - last_size: Option<(u32, u32)>, - started_at: Instant, -} - -fn drop_renderer_before_window( - renderer: &mut Option, - window: &mut Option, -) { - drop(renderer.take()); - drop(window.take()); -} - -#[derive(Clone, Debug)] -struct RendererSnapshot { - report: VulkanSmokeRendererReport, - readback_artifact: Option, - swapchain_recreate_count: u32, - validation: VulkanValidationReport, -} - -impl From 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, - completed: Arc, - progress: Arc, - ) -> 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, - material_texture_names: Vec, - 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, - 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 { - 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>>, - } - - impl Drop for DropTracker { - fn drop(&mut self) { - self.log.borrow_mut().push(self.step); - } - } - - fn tracker(step: DropStep, log: &Rc>>) -> 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..c07c5b8 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 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..3193e3f 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/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 - ) -)] //! Asset manager ports and transactional preparation models. use fparkan_material::{ @@ -35,24 +16,24 @@ 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 fparkan_vfs::DirectoryVfs; use std::collections::{hash_map::Entry, HashMap, HashSet}; use std::fmt; use std::hash::{Hash, Hasher}; use std::marker::PhantomData; +use std::path::Path; use std::sync::Arc; const TEXTURES_ARCHIVE: &str = "textures.lib"; const LIGHTMAP_ARCHIVE: &str = "lightmap.lib"; -const VISUAL_DEPENDENCY_PROGRESS_INTERVAL: usize = 64; type WearValidationCache = HashMap<(NormalizedPath, Vec), Result>; -type TextureValidationCache = HashMap, Result<(), String>>; /// Canonical terrain archive paths derived from a mission land reference. #[derive(Clone, Debug, Eq, PartialEq)] @@ -188,6 +169,37 @@ pub fn decode_nres_payload( decode_nres(bytes, ReadProfile::Compatible) } +/// Loads a phase-zero diffuse texture from a map-local standalone WEAR file. +/// +/// The sidecar stays beside `Land.msh`; MAT0 and TEXM remain in the normal +/// game-root archives. +pub fn load_standalone_wear_material_texture_mip0_rgba8_from_root( + root: &Path, + wear_path: &Path, + material_index: u16, +) -> Result { + 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))); + 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 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:?}"))?; + let document = decode_texm(bytes).map_err(|err| err.to_string())?; + decode_mip_rgba8(&document, 0).map_err(|err| err.to_string()) +} + /// Decodes terrain documents and builds immutable terrain state. /// /// # Errors @@ -494,56 +506,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 +622,6 @@ impl AssetManager { 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], - prototypes: &[EffectivePrototype], - on_phase: impl FnMut(AssetPreparationPhase), - ) -> Result { - 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 +707,6 @@ pub fn prepare_mission_assets_with_repository( .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( - repository: &R, - root_prototype_spans: &[std::ops::Range], - prototypes: &[EffectivePrototype], - mut on_phase: impl FnMut(AssetPreparationPhase), -) -> Result { - 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 +719,24 @@ pub fn prepare_mission_assets_profiled_with_repository( 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( repository: &R, root_prototype_spans: &[std::ops::Range], 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 +776,6 @@ fn prepare_mission_assets_with_repository_internal( proto, identity_policy, &mut preparation_cache, - on_phase, )?; if bundle.visual.id != visual_id { // Defensive check. stable IDs are deterministic for the same inputs. @@ -1173,73 +1090,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( 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( - 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( - 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 +1119,6 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress { report.wear_resolved_count += 1; @@ -1343,35 +1185,7 @@ pub fn extend_graph_report_with_visual_dependencies_with_progress, Result>, - 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, @@ -1738,7 +1492,6 @@ pub fn prepare_visual_with_repository( proto, AssetIdentityPolicy::default(), &mut preparation_cache, - &mut |_| {}, )? .visual) } @@ -1761,13 +1514,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( repository: &R, proto: &EffectivePrototype, identity_policy: AssetIdentityPolicy, preparation_cache: &mut PreparationCache, - on_phase: &mut dyn FnMut(AssetPreparationPhase), ) -> Result { let PrototypeGeometry::Mesh(mesh_key) = &proto.geometry else { return Ok(PreparedVisualBundle { @@ -1779,7 +1531,6 @@ fn prepare_visual_with_repository_internal( }); }; - 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 +1546,6 @@ fn prepare_visual_with_repository_internal( 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 +1567,6 @@ fn prepare_visual_with_repository_internal( .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()) @@ -1847,7 +1596,6 @@ fn prepare_visual_with_repository_internal( }); for texture in texture_requests { - on_phase(AssetPreparationPhase::Textures); let prepared_texture = prepare_texture_cached( repository, &texture, @@ -1862,7 +1610,6 @@ fn prepare_visual_with_repository_internal( } for lightmap in &wear.lightmaps { - on_phase(AssetPreparationPhase::Textures); let prepared_lightmap = prepare_texture_cached( repository, &lightmap.lightmap, @@ -2450,66 +2197,6 @@ mod tests { use fparkan_vfs::{DirectoryVfs, MemoryVfs, Vfs}; 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, - }, - }, - ); - } - - 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, - }, - }, - ] - ); - } - #[test] fn wear_validation_cache_replays_archive_qualified_failure() { let repository = repository_with_archives(&[]); @@ -3315,7 +3002,6 @@ mod tests { &prototypes, policy, AssetPreparationLimits::default(), - &mut |_| {}, ) .expect_err("collision should fail"); 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, - /// Casefold collisions. - pub casefold_collisions: Vec>, -} - -/// 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, - /// Casefold collision count. - pub casefold_collisions: usize, - /// Manifest fingerprint. - pub fingerprint: Sha256Digest, - /// Per-file status records. - pub records: Vec, - /// 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, -} - -/// 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 { - 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, -) -> 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> { - let mut grouped: BTreeMap, BTreeSet> = 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 { - 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 { - 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, -) -> 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 { - 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) -> 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 { - 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, 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 { - 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 = (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, 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, - /// Redacted or logical path. - #[serde(skip_serializing_if = "Option::is_none")] - pub path: Option, - /// Archive entry name. - #[serde(skip_serializing_if = "Option::is_none")] - pub archive_entry: Option, - /// Object/prototype key. - #[serde(skip_serializing_if = "Option::is_none")] - pub object_key: Option, - /// Input span. - #[serde(skip_serializing_if = "Option::is_none")] - pub span: Option, -} - -/// 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, -} - -/// Creates a diagnostic with default error severity. -#[must_use] -pub fn diagnostic(code: DiagnosticCode, message: impl Into) -> 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/lib.rs b/crates/fparkan-fx/src/lib.rs index 44b1a59..3fa4aae 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 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, - }, - /// `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, - /// Number of decoded type-8 animation keys, when available. - pub animation_keys: Option, - /// Declared type-19 animation frame count, when available. - pub animation_frame_count: Option, -} - -/// 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, - /// Last material resource name, when present. - pub last_material: Option, -} - -/// 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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: "".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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, 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 { - 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, ""); - 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 { - 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 { - 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, value: u32) { - out.extend_from_slice(&value.to_le_bytes()); - } - - fn texm_argb8888_pixel(pixel: [u8; 4]) -> Vec { - 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..32d48a1 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; 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..2e38082 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; @@ -2065,7 +2046,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..5023df4 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; @@ -824,7 +805,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, 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..7e3c1f8 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,112 +317,6 @@ 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, - /// 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, -} - -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. @@ -531,546 +404,6 @@ impl From for PipelineKey { } } -/// 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, - /// Mesh. - pub mesh: GpuMeshId, - /// Material table after WEAR/MAT0 fallback resolution. - pub material_slots: Vec, - /// 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, -} - -/// 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, - /// 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, -} - -/// 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, - /// Exclusive upper bound for index ranges. - pub index_count: Option, -} - -/// 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 { - let mut draws = snapshot - .draws - .iter() - .filter(|draw| profile.include_ui || draw.phase != RenderPhase::Ui) - .collect::>(); - 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; -} - -/// 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 { - validate_command_list(commands)?; - Ok(FrameOutput) - } -} - -/// Backend that stores deterministic command captures for verification. -#[derive(Clone, Debug, Default)] -pub struct RecordingBackend { - captures: Vec>, -} - -impl RecordingBackend { - /// Returns all captures in submission order. - #[must_use] - pub fn captures(&self) -> &[Vec] { - &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 { - 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, 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 { - 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 }); - } - } - 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)] mod tests { use super::*; @@ -1097,49 +430,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 = [ @@ -1259,455 +549,4 @@ mod tests { None ); } - - 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, - 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), - ], - }; - 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 - }) - )); - } - - #[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, - ], - }; - assert!(matches!( - validate_command_list(&unordered), - Err(RenderError::PhaseOrderViolation { - draw_id: DrawId(2), - previous: RenderPhase::Transparent, - current: RenderPhase::Opaque - }) - )); - } - - #[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(()) - } } 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 { 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..2fead30 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, - /// 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, -} - /// 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, } /// Engine. pub struct Engine { - config: EngineConfig, services: EngineServices, world: World, loaded: Option, @@ -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 { +/// Creates an engine with the supplied services. +pub fn create(services: EngineServices) -> Result { Ok(Engine { - config, services, world: new_world(WorldConfig), loaded: None, @@ -744,7 +564,7 @@ pub fn load_mission( engine: &mut Engine, request: MissionRequest, ) -> Result { - 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,7 +583,14 @@ pub fn load_mission_static_preview( engine: &mut Engine, request: MissionRequest, ) -> Result { - load_mission_static_preview_with_progress(engine, request, |_| {}) + load_mission_with_options( + engine, + request, + MissionLoadOptions { + asset_scope: MissionAssetScope::FirstMeshPreview, + ..MissionLoadOptions::default() + }, + ) } /// Loads a static preview for the first requested non-zero count of mission roots. @@ -779,107 +606,25 @@ pub fn load_mission_static_preview_roots( request: MissionRequest, root_count: NonZeroUsize, ) -> Result { - 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 { - load_mission_with_options_and_progress( + load_mission_with_options( engine, request, MissionLoadOptions { asset_scope: MissionAssetScope::PreviewRoots(root_count), ..MissionLoadOptions::default() }, - Some(&mut on_phase), ) - .map(|(loaded, _trace)| loaded) } -/// Loads a static preview while synchronously reporting entered loading phases. -/// -/// This has the same bounded asset scope as [`load_mission_static_preview`]. -/// -/// # Errors -/// -/// Returns [`EngineError`] under the same conditions as -/// [`load_mission_static_preview`]. -pub fn load_mission_static_preview_with_progress( - engine: &mut Engine, - request: MissionRequest, - mut on_phase: impl FnMut(MissionLoadPhase), -) -> Result { - load_mission_with_options_and_progress( - engine, - request, - MissionLoadOptions { - asset_scope: MissionAssetScope::FirstMeshPreview, - ..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 { - 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 { 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( asset_manager: &AssetManager, root_spans: &[std::ops::Range], prototypes: &[fparkan_prototype::EffectivePrototype], - on_phase: &mut dyn FnMut(AssetPreparationPhase), ) -> Result { 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 { - 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 { - 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, - 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 { - 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 { 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, @@ -1778,30 +1345,6 @@ mod tests { assert_eq!(before.snapshot.objects, after.snapshot.objects); } - #[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(); @@ -1996,88 +1539,9 @@ 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) @@ -2093,61 +1557,12 @@ 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 = 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 = 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 = 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 = 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 { @@ -2440,13 +1836,7 @@ mod tests { fn run_headless_case(case: HeadlessCase) -> WorldSnapshot { let root = licensed_root(case.root); let vfs: Arc = 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 +1864,7 @@ mod tests { let vfs: Arc = 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..3c05cfe 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}; 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..7dced19 100644 --- a/crates/fparkan-terrain/src/lib.rs +++ b/crates/fparkan-terrain/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 - ) -)] //! Validated terrain runtime queries using XY ground coordinates and Z height. use fparkan_terrain_format::{FullSurfaceMask, LandMapDocument, LandMeshDocument}; 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, -} - -impl RenderBackend for RecordingRenderBackend { - fn execute(&mut self, commands: &RenderCommandList) -> Result { - 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 { Some(metadata.dev().rotate_left(32) ^ metadata.ino()) } #[cfg(windows)] -#[allow(clippy::unnecessary_wraps)] + fn file_identity(metadata: &fs::Metadata) -> Option { // 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..1888f35 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -52,18 +52,11 @@ number, size, operands, control flow, effects, errors и минимальный GOG `ai.dll` доказывает этот framing двумя consumer-ами: loader по `0x10001000` открывает `.scr`, `varset.var`, `.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 @@ -157,9 +150,8 @@ slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+ На границе mission runtime выбранный TMA clan `first_resource` теперь материализуется как отдельный `MissionScriptBundle`: loader нормализует `.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 не 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..25069a0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,72 +1,27 @@ # FParkan -FParkan -- самостоятельная техническая книга о восстановлении игрового движка -Iron3D из *Parkan: Iron Strategy*. Она ведёт от запуска оригинальной программы -и карты DLL к форматам ресурсов, загрузке миссии, геометрии, материалам, -рендеру, поведению, звуку, сети и плану чистой совместимой реализации. +Эта книга рассказывает, как устроен движок *Паркан: Железная стратегия* и как +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, адреса и конфигурация. -Каталог `docs/` — единственный рабочий источник истины для FParkan. 18 июля -2026 года его содержательно сопоставили с книгой FParkan в Notion; результат, -маршрутизация статей и сознательно исключённые цели зафиксированы в -[сверке](baseline/notion-reconciliation.md). Актуальные локальные материалы -содержат и последующие результаты разработки. Notion больше не -является местом внесения изменений: новые факты, исправления и решения -фиксируются только здесь, в томе, справочнике или evidence-документе, которому -они принадлежат. +В [глоссарии](appendices/glossary.md) собраны термины, а в +[открытых вопросах](appendices/knowledge-boundaries.md) — ещё не восстановленное +поведение. Описание алгоритма оригинала и возможности текущего приложения +различаются явно. Предположение остаётся предположением, пока его не +подтверждают игровые файлы или код оригинала. -При расхождении приоритет имеют более новое локальное доказательство и текущий -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 -сохранение. +Команды запуска и проверки приведены в [README](../README.md). diff --git a/docs/reference/msh.md b/docs/reference/msh.md index 774e388..2ef276c 100644 --- a/docs/reference/msh.md +++ b/docs/reference/msh.md @@ -47,7 +47,7 @@ Validated `ModelAsset` также сохраняет decoded type 8 keys и type `ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`, то есть доказанный static input. `parent_or_link == 0xFFFF` означает root; иначе это parent index, обязательно меньший индекса child. Этот контракт -подтверждён на licensed animation gates обеих частей и защищён fallback-ом: +подтверждён на тестах анимации с оригинальными ресурсами обеих частей и защищён fallback-ом: модель с нарушенным порядком не получает придуманную hierarchy. В legacy-camera static preview стандартный узел уже получает свой fallback pose @@ -58,10 +58,6 @@ pose применяется `Rz * Ry * Rx`, scale и mission translation. Гео быть нарисован разными node poses. Это static fallback hierarchy, а не полная animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей. -Для воспроизводимого исследования `fparkan-cli model inspect --root ---archive --resource ` выводит Node38 metadata, включая -parent index, fallback key и наличие LOD0/group0 geometry. - ## Slot and batch Type 2 содержит header `0x8C`, затем `Slot68`: 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/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. Путеводитель и методика +# 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 и связи с объектом мира. +`+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..586485b 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 @@ -842,1107 +840,80 @@ 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`. +Миссия хранит положение, три угла и масштаб объекта. Функция `iron3d.dll` +RVA `0x36610` строит `Rz(z) * Ry(y) * Rx(x)` с переносом в последнем столбце. +Точка модели преобразуется как `R * (scale * point) + position`. +Повороты и переносы узлов модели применяются до размещения объекта в миссии. -`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. +Горизонтальная плоскость карты — XY, высота — Z. У GOG AutoDemo диапазон XY +ландшафта составляет `0..1190.6976`. Высоты, сохранённые в `Land.msh`, переводятся +в единицы мира делением на 32. Это отдельное преобразование ландшафта; +применять его к координатам размещённых объектов нельзя. -### 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: `.readback-vkformat-.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 ` выполняет 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 ` 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=` 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`: +`Ngi32!niGet3DRender` (RVA `0x5640`) возвращает графический интерфейс. +Вызов Direct3D7 `SetTransform` устанавливает projection из RVA `0x7030` +и view из RVA `0x9450`. Для FOV `f`, ближней плоскости `n`, дальней `z` +и размеров viewport `w`, `h` projection в порядке строк имеет вид: ```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 ] +[ 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. +После деления на однородную координату получается обычная перспективная +проекция с глубиной `0..1`. Матрица view дополнительно меняет оси и знаки; +она не равна одному лишь обратному преобразованию камеры. +В проверенном GOG контексте Ngi32 использовались `n=0.5`, `z=700`, `f=1.3`. -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. +У GOG `Terrain.dll + 0x7355c` хранит указатель на активную внешнюю камеру. +Её selector 0 возвращает 16 слов начиная с `+0x20`, перенос находится в словах +3, 7, 11. Интерфейс `LoadCamera` расположен отдельно, по `+0x134` объекта. +Эти два указателя нельзя подменять друг другом. Формулы камеры и размещения +реализованы и проверяются в `fparkan-render`. -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. +Рядом с `Land.msh` лежат `Land1.wea` и `Land2.wea`. Старший байт +`TerrainFace28.material_tag` выбирает материал первой таблицы, младший — +второй. `0xff` в старшем байте означает отсутствие первого слоя. +В AutoDemo встречаются теги `0102`, `0203`, `ff01`, `ff02`, `ff03`. +Менеджер World3D получает селектор `(table_index << 16) | material_index` +и возвращает выбранную фазу MAT0 размером 76 байт. -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. +Type 18 в `Land.msh` оригинал называет microtexture mapping chunk; +он содержит четырёхбайтовые записи. Type 11 хранит пары `u16`, выбранные +таблицей terrain slots. Их первое поле — ключ shade cache, а не номер строки +WEAR. Флаг `0x0010` отделяет группы при обходе. `GetShade` использует собственный +материал `LIGHT1` из `system.rlb::Shade.wea`. -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. +Shade cache в GOG `Terrain.dll` имеет vtable RVA `0x643D0`. Его таблица по +`+316` состоит из восьмибайтовых записей: указатель и номер банка. Число записей +находится по `+320`; банки начинаются с `+332`, их шаг — 212 байт. Lookup +RVA `0x10910` возвращает временное представление по `+24`, а не саму запись. +Построение в RVA `0x10280` и `0x12e20` использует созданную загрузчиком геометрию. +Поэтому повторить terrain shader только разбором WEAR нельзя. Точная композиция +слоёв и микротекстур остаётся открытым вопросом. -`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. +## Реализация Vulkan -For controlled differential work, `fparkan-game --backend static-vulkan` now -accepts `--legacy-camera-capture `. 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. +`LegacyPipelineState` хранит blend, depth, cull и alpha-test. Из этих значений +получается ключ Vulkan pipeline. Alpha reference передаётся отдельно через +push constant; он не требует нового pipeline. -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. +Статический путь `fparkan-game` загружает ландшафт и MSH-компоненты выбранных +объектов. Локальные material slots разрешаются через WEAR и MAT0 перед +объединением геометрии. Индексы GPU имеют тип `u32`: вся карта может содержать +больше 65 535 вершин, даже если каждый исходный mesh использует `u16`. +Текущий путь берёт базовую текстуру Land2; свет, составные слои и атмосфера +пока не участвуют в этом статическом изображении. -`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 -``` - -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 `. 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 -``` - -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 -``` - -## Проверки паритета - -Главные риски совпадения кадра: - -- 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 `. 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 ` 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`. +Для сохранения кадра 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..ebb3a03 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -447,24 +447,6 @@ 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 нельзя считать побайтно тем diff --git a/docs/tomes/07-implementation.md b/docs/tomes/07-implementation.md index c48fea2..7701962 100644 --- a/docs/tomes/07-implementation.md +++ b/docs/tomes/07-implementation.md @@ -1,711 +1,67 @@ -# 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..21e3b8d 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, @@ -546,628 +519,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: \t\t -# 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 --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 --model-archive system.rlb --model-name MTCHECK.MSH --texture-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 --model-archive system.rlb --model-name MTCHECK.MSH --texture-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 --model-archive system.rlb --model-name MTCHECK.MSH --texture-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 --model-archive fortif.rlb --model-name FR_L_MTP.msh --wear-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 /DATA/MAPS/11/Land.msh --texture-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 --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..de6b320 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,13 @@ 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/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 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] ", - 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 b751b23..0000000 --- a/rust-toolchain.toml +++ /dev/null @@ -1,6 +0,0 @@ -[toolchain] -channel = "1.98.1" -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::>(); - 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 ] | acceptance audit [--roadmap ] [--coverage ] [--out ] [--strict] | native-smoke audit --dir [--expected-commit ] [--expected-shader-manifest-hash ] | package --target --app viewer|game|headless|cli | test synthetic|licensed [--stage 0..5|all] [--manifest corpora.toml] | corpus baseline --root [--out ]" - .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 { - let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?; - let manifest = serde_json::from_str::(&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 { - 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 { - 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 { - 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 { - 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::>(); - 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 { - 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 { - 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 { - 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 { - 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 { - 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}=" - ) - })?; - parse_licensed_manifest(&manifest) -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct LicensedManifest { - schema: Option, - #[serde(rename = "corpus")] - corpora: Vec, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CorpusEntry { - id: String, - kind: CorpusKind, - root: String, - expected_profile: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "lowercase")] -enum CorpusKind { - Part1, - Part2, -} - -fn parse_licensed_manifest(path: &Path) -> Result { - 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, - 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, -} - -fn parse_corpus_baseline_options(args: &[String]) -> Result { - 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 { - 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 { - 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) -> 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::>(); - 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, -) -> 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> { - 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>, - failures: &mut Vec, -) { - 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>, -) -> BTreeSet { - 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::>(); - 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) -> 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::(&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::(&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::>(); - 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>, - #[allow(dead_code)] - targets: Option>, -} - -#[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) { - 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) -> 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, String> { - let text = - fs::read_to_string(lockfile).map_err(|err| format!("{}: {err}", lockfile.display()))?; - let parsed = toml::from_str::(&text) - .map_err(|err| format!("{}: invalid Cargo.lock TOML: {err}", lockfile.display()))?; - Ok(parsed.package) -} - -#[derive(Debug, Deserialize)] -struct CargoLock { - package: Vec, -} - -#[derive(Debug, Deserialize)] -struct CargoLockPackage { - name: String, - version: String, - source: Option, -} - -fn validate_workspace_license(root: &Path, failures: &mut Vec) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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, - workspace: Option, - dependencies: Option>, - #[serde(rename = "dev-dependencies")] - dev_dependencies: Option>, - #[serde(rename = "build-dependencies")] - build_dependencies: Option>, -} - -#[derive(Debug, Deserialize)] -struct CargoPackagePolicy { - name: String, - license: Option, -} - -#[derive(Debug, Deserialize)] -struct CargoWorkspacePolicy { - package: Option, -} - -#[derive(Debug, Deserialize)] -struct CargoWorkspacePackagePolicy { - license: Option, -} - -impl CargoManifestPolicy { - fn package_name(&self) -> Option<&str> { - self.package.as_ref().map(|package| package.name.as_str()) - } - - fn license(&self) -> Option { - 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 { - [ - 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 { - value.and_then(toml::Value::as_str).map(ToString::to_string) -} - -fn load_cargo_manifest_policy(path: &Path) -> Result { - 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) -> 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) -> 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) -> 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) { - 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 { - if value == "all" { - return Ok(Self::All); - } - let stage = value - .parse::() - .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, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum TestSuite { - Licensed, - Synthetic, -} - -impl TestSuite { - fn parse(value: &str) -> Result { - 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, - 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, - 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 { - 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 { - 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 { - 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 { - 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, 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::(&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, - expected_commit: &str, - expected_shader_manifest_hash: &str, -) -> Vec { - 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::>().join(", ") - )); - } - if rust_toolchains.len() > 1 { - failures.push(format!( - "native smoke reports disagree on rust_toolchain: {}", - rust_toolchains.into_iter().collect::>().join(", ") - )); - } - failures -} - -fn expected_native_smoke_commit() -> Result { - 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 { - 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, -) { - 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, -) { - 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, -) { - 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, -) { - 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, -) { - 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, -) { - 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, -) { - 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, -) { - 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) { - 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, -) { - 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, -) { - 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, -) { - 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, -) { - 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 { - 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 { - 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, - covered_planning: Vec, - covered_stub: Vec, - covered_gpu: Vec, - partial: Vec, - blocked: Vec, - omitted: Vec, - missing: Vec, - invalid_coverage: Vec, - unknown_coverage: Vec, - coverage_evidence: BTreeMap, - by_stage: BTreeMap, -} - -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 { - self.partial - .iter() - .chain(&self.blocked) - .chain(&self.omitted) - .chain(&self.missing) - .chain(&self.invalid_coverage) - .cloned() - .collect() - } - - fn strict_failures(&self) -> Vec { - 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 { - 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, 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, - coverage: &BTreeMap, -) -> 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::>(); - - 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 { - 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, - 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, -} - -fn render_audit_json(audit: &AcceptanceAudit) -> Result { - 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>, - packages: Vec, -} - -fn render_acceptance_report(options: &AcceptanceOptions) -> Result { - 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, String> { - let workspace_root = workspace_root_path(); - match stage { - Stage::All => workspace_package_names(&workspace_root) - .map(|packages| packages.into_iter().collect::>()), - 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::>(); - cargo_with_env(&borrowed, &envs)?; - } else { - cargo_owned(&args)?; - } - } - Ok(()) - } - } -} - -fn stage_packages(stage: u8) -> Result, 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, String> { - let metadata = workspace_metadata(root)?; - Ok(metadata - .workspace_packages() - .iter() - .map(|package| package.name.to_string()) - .collect()) -} - -fn workspace_metadata(root: &Path) -> Result { - 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, - stages: BTreeMap>, -} - -fn load_stage_package_manifest(path: &Path) -> Result { - let text = fs::read_to_string(path).map_err(|err| format!("{}: {err}", path.display()))?; - let manifest = toml::from_str::(&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, - path: &Path, -) -> Result<(), String> { - let required_stages = (0_u8..=5_u8) - .map(|stage| stage.to_string()) - .collect::>(); - let declared_stages = manifest.stages.keys().cloned().collect::>(); - 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::>(); - 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 { - 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::>(); - 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::>(); - - 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::>(); - let coverage = [( - "S4-FX-018".to_string(), - CoverageEntry { - status: CoverageStatus::CoveredStub, - evidence: "reference stub output".to_string(), - }, - )] - .into_iter() - .collect::>(); - - 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::>(); - - assert_eq!( - audit_native_smoke_reports(&reports, expected_commit, expected_shader_manifest_hash,), - Vec::::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::>(); - - 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::>(); - - 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::>(); - - 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}=" - )) - ); - - 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::(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::(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::>(); - - 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(()) - } -}