Compare commits
233 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16027e7124 | |||
| 27af3806b3 | |||
|
021b1c8dac
|
|||
|
278567d6de
|
|||
|
7eced77483
|
|||
|
d41add32c4
|
|||
|
159731664f
|
|||
|
e6b7fa1896
|
|||
|
0e127117e9
|
|||
|
4d19728c39
|
|||
|
54f07ee3be
|
|||
|
ed2b540abf
|
|||
|
00ae9067d8
|
|||
|
c71e706d69
|
|||
|
aa2133d82b
|
|||
|
71ead678c0
|
|||
|
f15ea95bf2
|
|||
|
99bcbf388f
|
|||
|
227d95fc49
|
|||
|
dceea70122
|
|||
|
fd452f6016
|
|||
|
1d0244c3e4
|
|||
|
5d9e1cbe38
|
|||
|
0e76c2ed7c
|
|||
|
4c1edef21b
|
|||
|
e6778d43af
|
|||
|
ec8f6599fc
|
|||
|
f5fae8e84a
|
|||
|
a0a4089e4b
|
|||
|
dc7e72961a
|
|||
|
8ea1fd5c18
|
|||
|
69c032acca
|
|||
|
9cc24e715d
|
|||
|
f8e447ffee
|
|||
|
83d763dd70
|
|||
|
162de8ccab
|
|||
|
0b23cf48e7
|
|||
|
7356238ffb
|
|||
|
42441082f0
|
|||
|
ccd61c05b0
|
|||
|
813beec7be
|
|||
|
91c7a8a14e
|
|||
|
8b91a0bfbf
|
|||
|
fb97405e0c
|
|||
|
d579b696e6
|
|||
|
aa1b809bd8
|
|||
|
f69c893a40
|
|||
|
5436727961
|
|||
|
be41fa839f
|
|||
|
8e5e46b7b3
|
|||
|
d0bdbaa1ed
|
|||
| 7416fdc7e9 | |||
|
78fc5f1deb
|
|||
|
50c2cf4686
|
|||
| a63290fbc8 | |||
| 96a25b6c0e | |||
| f4262cf369 | |||
| 9b100b8fc3 | |||
| 9fceeb9a0a | |||
| 4b7f1a16b9 | |||
|
ada3b903ad
|
|||
| 31d849ddbf | |||
| 4ef08d0bf6 | |||
|
598137ed13
|
|||
|
cb0ca2f2f0
|
|||
|
7346e695c4
|
|||
|
bb827c3928
|
|||
|
efab61a45c
|
|||
| 0d7ae6a017 | |||
| a281ffa32e | |||
| 18d4c6cf9f | |||
| 0e19660eb5 | |||
|
8a69872576
|
|||
|
aa68906a3d
|
|||
|
8bf3b7b209
|
|||
|
669fb40a70
|
|||
|
9c0df3d299
|
|||
| 4c4f542fc2 | |||
| 4c9d772b03 | |||
| 097a915f35 | |||
|
c691de0dd0
|
|||
|
92818ce0c4
|
|||
|
6676cfdd8d
|
|||
|
8b639ee6c9
|
|||
|
a58dea5499
|
|||
|
615891d550
|
|||
|
481ff1c06d
|
|||
|
7702d800a0
|
|||
|
3c06e768d6
|
|||
|
70ed6480c2
|
|||
|
662b292b5b
|
|||
|
3410b54793
|
|||
|
041b1a6cb3
|
|||
|
5035d02220
|
|||
|
ba1789f106
|
|||
|
842f4a8569
|
|||
|
ce6e30f727
|
|||
|
4af183ad74
|
|||
|
ab413bd751
|
|||
|
b5e6fad3c3
|
|||
|
c69cad6a26
|
|||
|
a24910791e
|
|||
|
371a060eb6
|
|||
|
e08b5f3853
|
|||
|
5a97f2e429
|
|||
|
9e2dcb44a6
|
|||
|
828106ba81
|
|||
|
a7dd18fa1d
|
|||
|
f8cca32968
|
|||
|
ef93237724
|
|||
|
58a896221f
|
|||
|
3f48f53bd5
|
|||
|
2953f0c8c9
|
|||
|
022ec608f5
|
|||
|
54c94fddb5
|
|||
|
0def311fd1
|
|||
|
2f157d0972
|
|||
|
8f57a8f0f9
|
|||
|
40e7d88fd0
|
|||
|
afe6b9a29b
|
|||
| 6a46fe9825 | |||
|
7818a7ef3f
|
|||
| 15f2a73e95 | |||
| 2890b69678 | |||
| 27e9d2b39c | |||
| b283e2a8df | |||
|
9dcce90201
|
|||
| 7c876faf12 | |||
| 39c66e698e | |||
| abac84a008 | |||
| b44217d4af | |||
| c268e4c205 | |||
| 8aabe74eb2 | |||
| 84f2175fd2 | |||
| 307b9c6d90 | |||
| 7de26b16d4 | |||
| 52f2ad43e6 | |||
| c4dec3fe4c | |||
|
e51edcb561
|
|||
| 2273fd4263 | |||
| d4f104cf5e | |||
| 7f41a51f2a | |||
| e97610a8ac | |||
| ee02d922ae | |||
| dbd7b6bf33 | |||
| 949c0aa087 | |||
| 4f29af53b6 | |||
| 1d62740d59 | |||
|
d274602104
|
|||
|
8bc39d10b1
|
|||
| 88faa6e3ea | |||
| 66705ba4f0 | |||
| bb4c217ee2 | |||
| c83822e353 | |||
| 130ee8df5b | |||
|
8d8653133b
|
|||
|
94d2f8a512
|
|||
|
215a093344
|
|||
| 3de1575082 | |||
| aa8e1184bf | |||
| feb7ebe722 | |||
| becadef5ee | |||
| a4b36e1aea | |||
| c7b099b596 | |||
|
48a08445e7
|
|||
|
694de5edfa
|
|||
|
0dc37e9604
|
|||
| 3d2e970225 | |||
|
d90b9830bc
|
|||
| f91e1bda22 | |||
| e9a0fd718f | |||
| 509ce2d83d | |||
| 391756b77d | |||
| 035153c7c0 | |||
| 885a593829 | |||
| 7c3c8cc969 | |||
| 00c62a9909 | |||
| c2899d27af | |||
| e60fdd1958 | |||
| dd6d440ba5 | |||
| 36a082ba18 | |||
| 09689a937c | |||
| 39f6479415 | |||
| 01a2a47370 | |||
| 4cd42afa37 | |||
| 298aa954b9 | |||
| 910deb6c17 | |||
| 4a22e2177e | |||
| 729c972573 | |||
| 250d78a955 | |||
| 03f2d762bb | |||
| fcaa729544 | |||
| 8c2a6e2c19 | |||
| daa2efba89 | |||
| b5748505ef | |||
| d305b1f005 | |||
| 2cfba4891c | |||
| 777d3814d3 | |||
| 784ceeebdf | |||
| e3675555ea | |||
| 91104e214f | |||
| 9198b18652 | |||
| 1ad7949828 | |||
| b98f01a810 | |||
| fa88050a52 | |||
| 1123c8a56e | |||
| 2eb6333552 | |||
| c5224e006f | |||
| 79599f3cf4 | |||
| 7acf99b9d6 | |||
| ec542703b4 | |||
| ee1cdda38b | |||
| 293a1de413 | |||
| 6635d4da9a | |||
| f549769fcf | |||
| c0a56acc0c | |||
| a136dc5fa4 | |||
| 1b13f2acfc | |||
| 6c127ce028 | |||
| bc2e051741 | |||
| 9abd2a4558 | |||
| f267a56fd0 | |||
| 1d592418af | |||
| 3448f0f930 | |||
|
039ed238a6
|
|||
|
b7349f9df9
|
|||
|
12c7f0284e
|
|||
| 5c9a691495 | |||
| bf8be5c045 | |||
|
ee8a5fc02b
|
|||
|
a990de90fe
|
|||
|
3d48cd3f81
|
|||
|
78d6eca336
|
+2
-5
@@ -1,5 +1,2 @@
|
|||||||
[source.crates-io]
|
[alias]
|
||||||
replace-with = "vendored-sources"
|
xtask = "run -p xtask --"
|
||||||
|
|
||||||
[source.vendored-sources]
|
|
||||||
directory = "vendor"
|
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"image": "mcr.microsoft.com/devcontainers/rust:latest",
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": ["rust-lang.rust-analyzer"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Docs Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- devel
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy-docs:
|
||||||
|
name: Build and Deploy MkDocs
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.14"
|
||||||
|
|
||||||
|
- name: Install docs dependencies
|
||||||
|
run: pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Build MkDocs site
|
||||||
|
run: mkdocs build
|
||||||
|
|
||||||
|
- name: Install rsync
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y rsync openssh-client
|
||||||
|
|
||||||
|
- name: Prepare SSH key
|
||||||
|
env:
|
||||||
|
SSH_KEY_B64: ${{ secrets.ROOT_CI_KEY_B64 }}
|
||||||
|
run: |
|
||||||
|
umask 077
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s' "$SSH_KEY_B64" | base64 -d > ~/.ssh/id_root_ci
|
||||||
|
chmod 600 ~/.ssh/id_root_ci
|
||||||
|
|
||||||
|
- name: Deploy via rsync
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.FPARKAN_DEPLOY_HOST }}
|
||||||
|
DEPLOY_PORT: ${{ secrets.FPARKAN_DEPLOY_PORT }}
|
||||||
|
run: |
|
||||||
|
rsync -rlz --delete \
|
||||||
|
-e "ssh -p ${DEPLOY_PORT} -i ~/.ssh/id_root_ci -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
|
||||||
|
site/ "gitea-runner@${DEPLOY_HOST}:./"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
name: RenovateBot
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "@daily"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
container: ghcr.io/renovatebot/renovate:43
|
||||||
|
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 }}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
name: fparkan-ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
msrv-backend-neutral:
|
||||||
|
name: MSRV backend-neutral crates
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: 1.87.0
|
||||||
|
- name: Test backend-neutral crates
|
||||||
|
run: >
|
||||||
|
cargo test
|
||||||
|
-p fparkan-animation
|
||||||
|
-p fparkan-binary
|
||||||
|
-p fparkan-corpus
|
||||||
|
-p fparkan-diagnostics
|
||||||
|
-p fparkan-fx
|
||||||
|
-p fparkan-inspection
|
||||||
|
-p fparkan-material
|
||||||
|
-p fparkan-mission-format
|
||||||
|
-p fparkan-msh
|
||||||
|
-p fparkan-nres
|
||||||
|
-p fparkan-path
|
||||||
|
-p fparkan-platform
|
||||||
|
-p fparkan-prototype
|
||||||
|
-p fparkan-render
|
||||||
|
-p fparkan-resource
|
||||||
|
-p fparkan-rsli
|
||||||
|
-p fparkan-runtime
|
||||||
|
-p fparkan-terrain
|
||||||
|
-p fparkan-terrain-format
|
||||||
|
-p fparkan-texm
|
||||||
|
-p fparkan-vfs
|
||||||
|
-p fparkan-world
|
||||||
|
--all-targets
|
||||||
|
--locked
|
||||||
|
|
||||||
|
stage0-matrix:
|
||||||
|
name: Stage 0-2 CI (${{ matrix.os }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
smoke_platform: linux
|
||||||
|
- os: windows-latest
|
||||||
|
smoke_platform: windows
|
||||||
|
- os: macos-latest
|
||||||
|
smoke_platform: macos
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain-file: rust-toolchain.toml
|
||||||
|
- name: Install cargo-deny
|
||||||
|
run: cargo install cargo-deny --locked
|
||||||
|
- name: Run canonical CI gate
|
||||||
|
run: cargo xtask ci
|
||||||
|
- name: Record native Vulkan smoke status
|
||||||
|
if: always()
|
||||||
|
shell: bash
|
||||||
|
run: >
|
||||||
|
cargo run -p fparkan-vulkan-smoke --locked --
|
||||||
|
--platform "${{ matrix.smoke_platform }}"
|
||||||
|
--out "target/fparkan/native-smoke/${{ runner.os }}.json"
|
||||||
|
--status blocked
|
||||||
|
--probe-surface
|
||||||
|
--reason "native Vulkan smoke runner is not enabled on this CI lane yet"
|
||||||
|
- name: Upload acceptance evidence
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: stage-0-2-acceptance-${{ matrix.os }}
|
||||||
|
path: |
|
||||||
|
target/fparkan/acceptance/stage-0-2-audit.json
|
||||||
|
target/fparkan/native-smoke/*.json
|
||||||
|
if-no-files-found: ignore
|
||||||
+214
-1
@@ -1 +1,214 @@
|
|||||||
/target
|
*~
|
||||||
|
|
||||||
|
# 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
|
||||||
|
**/*.rs.bk
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
tmp/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# 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
|
||||||
|
.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
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"$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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Generated
+1918
-867
File diff suppressed because it is too large
Load Diff
+59
-6
@@ -1,13 +1,66 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "3"
|
||||||
members = [
|
members = [
|
||||||
"libnres",
|
"crates/fparkan-animation",
|
||||||
"nres-cli",
|
"crates/fparkan-assets",
|
||||||
"packer",
|
"crates/fparkan-binary",
|
||||||
"texture-decoder",
|
"crates/fparkan-corpus",
|
||||||
"unpacker",
|
"crates/fparkan-diagnostics",
|
||||||
|
"crates/fparkan-fx",
|
||||||
|
"crates/fparkan-inspection",
|
||||||
|
"crates/fparkan-material",
|
||||||
|
"crates/fparkan-mission-format",
|
||||||
|
"crates/fparkan-msh",
|
||||||
|
"crates/fparkan-nres",
|
||||||
|
"crates/fparkan-path",
|
||||||
|
"crates/fparkan-platform",
|
||||||
|
"crates/fparkan-prototype",
|
||||||
|
"crates/fparkan-render",
|
||||||
|
"crates/fparkan-resource",
|
||||||
|
"crates/fparkan-rsli",
|
||||||
|
"crates/fparkan-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.87"
|
||||||
|
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]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -1,11 +1,77 @@
|
|||||||
# Utilities for the game "Parkan: Iron Strategy"
|
# FParkan
|
||||||
|
|
||||||
This repository contains utilities, tools, and libraries for the game "Parkan: Iron Strategy."
|
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»**.
|
||||||
|
|
||||||
## List of projects
|
## Описание
|
||||||
|
|
||||||
- [unpacker](unpacker): Text-based utility for unpacking game resources in the NRres format. Allows unpacking 100% of game resources.
|
Проект находится в активной разработке и включает:
|
||||||
- [packer](packer): Text-based utility for packing game resources in the NRres format. Allows packing 100% of game resources.
|
|
||||||
- [texture-decoder](texture-decoder): (WIP) Decoder for game textures. Decodes approximately 20% of game textures.
|
- библиотеки для работы с форматами игровых архивов;
|
||||||
- [libnres](libnres): _(Deprecation)_ Library for NRes files.
|
- спецификации форматов и сопутствующую документацию.
|
||||||
- [nres-cli](nres-cli): _(Deprecation)_ Console tool for NRes files.
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Проект находится в начальной стадии, подробная инструкция по установке пока отсутствует.
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
- локально: каталог [`docs/`](docs)
|
||||||
|
- сайт: <https://fparkan.popov.link>
|
||||||
|
|
||||||
|
## Библиотеки
|
||||||
|
|
||||||
|
- [crates/fparkan-nres](crates/fparkan-nres) — strict/lossless модель архивов NRes.
|
||||||
|
- [crates/fparkan-rsli](crates/fparkan-rsli) — чтение, lookup и lossless roundtrip архивов RsLi.
|
||||||
|
- [crates/fparkan-msh](crates/fparkan-msh) — validated static MSH geometry.
|
||||||
|
- [crates/fparkan-runtime](crates/fparkan-runtime) — transactional mission loading и headless runtime foundation.
|
||||||
|
- [apps/fparkan-cli](apps/fparkan-cli), [apps/fparkan-viewer](apps/fparkan-viewer), [apps/fparkan-headless](apps/fparkan-headless), [apps/fparkan-game](apps/fparkan-game) — composition roots.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
Базовое тестирование проходит на синтетических тестах из репозитория:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo xtask ci
|
||||||
|
```
|
||||||
|
|
||||||
|
Для дополнительного тестирования на реальных игровых ресурсах:
|
||||||
|
|
||||||
|
- используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy));
|
||||||
|
- разместите игровые каталоги в [`testdata/`](testdata);
|
||||||
|
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
|
||||||
|
|
||||||
|
Локальный licensed gate использует некоммитимый manifest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat > /private/tmp/fparkan-corpora.toml <<'EOF'
|
||||||
|
schema = 1
|
||||||
|
|
||||||
|
[[corpus]]
|
||||||
|
id = "part1-local"
|
||||||
|
kind = "part1"
|
||||||
|
root = "/absolute/path/to/IS"
|
||||||
|
expected_profile = "parkan-is-part1"
|
||||||
|
|
||||||
|
[[corpus]]
|
||||||
|
id = "part2-local"
|
||||||
|
kind = "part2"
|
||||||
|
root = "/absolute/path/to/IS2"
|
||||||
|
expected_profile = "parkan-is-part2"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \
|
||||||
|
cargo xtask acceptance report --suite licensed --stage 5
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 репозитории.
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Проект распространяется под лицензией **[GNU GPL v2](LICENSE.txt)**.
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-platform-winit"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
winit = "0.30"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,509 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
//! Minimal `winit`-backed platform adapter shim.
|
||||||
|
|
||||||
|
use fparkan_platform::{
|
||||||
|
EventSource, MonotonicClock, MonotonicInstant, NativeWindowHandles, PhysicalSize,
|
||||||
|
PlatformError, PlatformEvent, RenderRequest, WindowHandle, WindowPort,
|
||||||
|
};
|
||||||
|
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
use winit::application::ApplicationHandler;
|
||||||
|
use winit::dpi::PhysicalSize as WinitPhysicalSize;
|
||||||
|
use winit::event::{Event, MouseButton, WindowEvent};
|
||||||
|
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||||
|
use winit::platform::scancode::PhysicalKeyExtScancode;
|
||||||
|
use winit::window::{Window, WindowId};
|
||||||
|
|
||||||
|
static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
const DEFAULT_SMOKE_WIDTH: u32 = 1280;
|
||||||
|
const DEFAULT_SMOKE_HEIGHT: u32 = 720;
|
||||||
|
|
||||||
|
fn next_window_id() -> u64 {
|
||||||
|
NEXT_WINDOW_HANDLE_ID.fetch_add(1, Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Simple monotonic clock for windowing abstractions.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct WinitClock;
|
||||||
|
|
||||||
|
impl MonotonicClock for WinitClock {
|
||||||
|
fn now(&self) -> MonotonicInstant {
|
||||||
|
let duration = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
MonotonicInstant(duration.as_millis().try_into().unwrap_or(u64::MAX))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Event source backed by pre-buffered platform events.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct WinitEventSource {
|
||||||
|
queue: VecDeque<PlatformEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WinitEventSource {
|
||||||
|
/// Creates an empty source.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
queue: VecDeque::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes a synthetic event (used by tests and smoke stubs).
|
||||||
|
pub fn push(&mut self, event: PlatformEvent) {
|
||||||
|
self.queue.push_back(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes a mapped native window event.
|
||||||
|
pub fn push_window_event(&mut self, event: &WindowEvent) {
|
||||||
|
match event {
|
||||||
|
WindowEvent::KeyboardInput { event, .. } => {
|
||||||
|
self.queue.push_back(PlatformEvent::KeyboardInput {
|
||||||
|
scancode: event.physical_key.to_scancode().unwrap_or(0),
|
||||||
|
pressed: event.state.is_pressed(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WindowEvent::MouseInput { state, button, .. } => {
|
||||||
|
self.queue.push_back(PlatformEvent::MouseInput {
|
||||||
|
button: mouse_button_code(*button),
|
||||||
|
pressed: state.is_pressed(),
|
||||||
|
x: 0.0,
|
||||||
|
y: 0.0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WindowEvent::CursorMoved { position, .. } => {
|
||||||
|
self.queue.push_back(PlatformEvent::CursorMoved {
|
||||||
|
x: position.x,
|
||||||
|
y: position.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WindowEvent::Resized(size) => {
|
||||||
|
self.queue.push_back(PlatformEvent::Resize {
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WindowEvent::Focused(focused) => {
|
||||||
|
self.queue
|
||||||
|
.push_back(PlatformEvent::FocusChanged { focused: *focused });
|
||||||
|
}
|
||||||
|
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
|
||||||
|
self.queue.push_back(PlatformEvent::DpiChanged {
|
||||||
|
scale: *scale_factor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
WindowEvent::CloseRequested => {
|
||||||
|
self.queue.push_back(PlatformEvent::QuitRequested);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes events from an event loop event.
|
||||||
|
pub fn push_event<T>(&mut self, event: &Event<T>) {
|
||||||
|
if let Event::WindowEvent { event, .. } = event {
|
||||||
|
self.push_window_event(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mouse_button_code(button: MouseButton) -> u16 {
|
||||||
|
match button {
|
||||||
|
MouseButton::Left => 0,
|
||||||
|
MouseButton::Right => 1,
|
||||||
|
MouseButton::Middle => 2,
|
||||||
|
MouseButton::Back => 3,
|
||||||
|
MouseButton::Forward => 4,
|
||||||
|
MouseButton::Other(index) => 100 + index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventSource for WinitEventSource {
|
||||||
|
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
|
||||||
|
while let Some(event) = self.queue.pop_front() {
|
||||||
|
out.push(event);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window creation plan for native smoke entrypoints.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct WinitWindowPlan {
|
||||||
|
/// Requested drawable width in physical pixels.
|
||||||
|
pub width: u32,
|
||||||
|
/// Requested drawable height in physical pixels.
|
||||||
|
pub height: u32,
|
||||||
|
/// Whether native window/display handles are required by the caller.
|
||||||
|
pub requires_native_handles: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WinitWindowPlan {
|
||||||
|
/// Returns the Stage 0 native smoke window plan.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn smoke() -> Self {
|
||||||
|
Self {
|
||||||
|
width: DEFAULT_SMOKE_WIDTH,
|
||||||
|
height: DEFAULT_SMOKE_HEIGHT,
|
||||||
|
requires_native_handles: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates the window plan before a native event loop is entered.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PlatformError`] when the drawable extent is zero.
|
||||||
|
pub fn validate(self) -> Result<Self, PlatformError> {
|
||||||
|
if self.width == 0 || self.height == 0 {
|
||||||
|
return Err(PlatformError::Backend {
|
||||||
|
context: "winit window plan",
|
||||||
|
message: "drawable extent must be non-zero".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native smoke window creation result.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct WinitSmokeWindowProbe {
|
||||||
|
/// Validated creation plan.
|
||||||
|
pub plan: WinitWindowPlan,
|
||||||
|
/// Captured window descriptor.
|
||||||
|
pub window: WinitWindow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WinitSmokeWindowProbe {
|
||||||
|
/// Returns raw native handles captured from the native window.
|
||||||
|
#[must_use]
|
||||||
|
pub fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||||
|
self.window.native_handles()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a native smoke window, captures raw handles, then exits the event loop.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PlatformError`] when the plan is invalid, the event loop/window
|
||||||
|
/// cannot be created, or raw native handles are unavailable.
|
||||||
|
pub fn probe_smoke_window() -> Result<WinitSmokeWindowProbe, PlatformError> {
|
||||||
|
let plan = WinitWindowPlan::smoke().validate()?;
|
||||||
|
let event_loop = EventLoop::new().map_err(|err| PlatformError::Backend {
|
||||||
|
context: "winit event loop",
|
||||||
|
message: err.to_string(),
|
||||||
|
})?;
|
||||||
|
let mut app = SmokeWindowApp::new(plan);
|
||||||
|
event_loop
|
||||||
|
.run_app(&mut app)
|
||||||
|
.map_err(|err| PlatformError::Backend {
|
||||||
|
context: "winit event loop",
|
||||||
|
message: err.to_string(),
|
||||||
|
})?;
|
||||||
|
app.into_probe()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SmokeWindowApp {
|
||||||
|
plan: WinitWindowPlan,
|
||||||
|
window: Option<WinitWindow>,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SmokeWindowApp {
|
||||||
|
const fn new(plan: WinitWindowPlan) -> Self {
|
||||||
|
Self {
|
||||||
|
plan,
|
||||||
|
window: None,
|
||||||
|
error: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_probe(self) -> Result<WinitSmokeWindowProbe, PlatformError> {
|
||||||
|
if let Some(message) = self.error {
|
||||||
|
return Err(PlatformError::Backend {
|
||||||
|
context: "winit smoke window",
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let window = self.window.ok_or_else(|| PlatformError::Backend {
|
||||||
|
context: "winit smoke window",
|
||||||
|
message: "event loop exited before creating a window".to_string(),
|
||||||
|
})?;
|
||||||
|
if self.plan.requires_native_handles && window.native_handles().is_none() {
|
||||||
|
return Err(PlatformError::Backend {
|
||||||
|
context: "winit smoke window",
|
||||||
|
message: "native window/display handles are unavailable".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(WinitSmokeWindowProbe {
|
||||||
|
plan: self.plan,
|
||||||
|
window,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApplicationHandler for SmokeWindowApp {
|
||||||
|
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||||
|
if self.window.is_some() || self.error.is_some() {
|
||||||
|
event_loop.exit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let attributes = Window::default_attributes()
|
||||||
|
.with_title("FParkan Vulkan smoke")
|
||||||
|
.with_inner_size(WinitPhysicalSize::new(self.plan.width, self.plan.height));
|
||||||
|
match event_loop.create_window(attributes) {
|
||||||
|
Ok(window) => {
|
||||||
|
self.window = Some(WinitWindow::from_window(&window));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
self.error = Some(err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event_loop.exit();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn window_event(
|
||||||
|
&mut self,
|
||||||
|
_event_loop: &ActiveEventLoop,
|
||||||
|
_window_id: WindowId,
|
||||||
|
_event: WindowEvent,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Minimal window view over a `winit` window.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct WinitWindow {
|
||||||
|
handle: WindowHandle,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
scale: f64,
|
||||||
|
focused: bool,
|
||||||
|
minimized: bool,
|
||||||
|
occluded: bool,
|
||||||
|
native_handles: Option<NativeWindowHandles>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WinitWindow {
|
||||||
|
/// Builds a stable descriptor from a `winit` window.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_window(window: &Window) -> Self {
|
||||||
|
let scale = window.scale_factor();
|
||||||
|
let size = window.inner_size();
|
||||||
|
Self {
|
||||||
|
handle: WindowHandle {
|
||||||
|
id: next_window_id(),
|
||||||
|
},
|
||||||
|
width: size.width,
|
||||||
|
height: size.height,
|
||||||
|
scale,
|
||||||
|
focused: true,
|
||||||
|
minimized: false,
|
||||||
|
occluded: false,
|
||||||
|
native_handles: native_handles(window),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns conservative defaults if a native window is not available yet.
|
||||||
|
#[must_use]
|
||||||
|
pub fn synthetic(width: u32, height: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
handle: WindowHandle {
|
||||||
|
id: next_window_id(),
|
||||||
|
},
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
scale: 1.0,
|
||||||
|
focused: true,
|
||||||
|
minimized: false,
|
||||||
|
occluded: false,
|
||||||
|
native_handles: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns requested default render profile for integration points.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn default_render_request() -> RenderRequest {
|
||||||
|
RenderRequest::conservative()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WindowPort for WinitWindow {
|
||||||
|
fn drawable_size(&self) -> PhysicalSize {
|
||||||
|
PhysicalSize {
|
||||||
|
width: self.width,
|
||||||
|
height: self.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dpi_scale(&self) -> f64 {
|
||||||
|
self.scale
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_focus(&self) -> bool {
|
||||||
|
self.focused
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_minimized(&self) -> bool {
|
||||||
|
self.minimized
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_occluded(&self) -> bool {
|
||||||
|
self.occluded
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle(&self) -> WindowHandle {
|
||||||
|
self.handle
|
||||||
|
}
|
||||||
|
|
||||||
|
fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||||
|
self.native_handles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn native_handles(window: &Window) -> Option<NativeWindowHandles> {
|
||||||
|
let display = window.display_handle().ok()?.as_raw();
|
||||||
|
let window = window.window_handle().ok()?.as_raw();
|
||||||
|
Some(NativeWindowHandles { display, window })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_source_buffers_synthetic_events() -> Result<(), PlatformError> {
|
||||||
|
let mut source = WinitEventSource::new();
|
||||||
|
source.push(PlatformEvent::Resumed);
|
||||||
|
source.push(PlatformEvent::QuitRequested);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
source.poll(&mut events)?;
|
||||||
|
assert_eq!(
|
||||||
|
events,
|
||||||
|
vec![PlatformEvent::Resumed, PlatformEvent::QuitRequested]
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_port_reports_default_request_profile() {
|
||||||
|
let window = WinitWindow::synthetic(640, 360);
|
||||||
|
let request = WinitWindow::default_render_request();
|
||||||
|
assert_eq!(
|
||||||
|
request.presentation,
|
||||||
|
fparkan_platform::PresentationMode::Fifo
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
window.drawable_size(),
|
||||||
|
PhysicalSize {
|
||||||
|
width: 640,
|
||||||
|
height: 360
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert!(window.native_handles().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smoke_window_plan_requires_native_handles_and_nonzero_extent() -> Result<(), PlatformError> {
|
||||||
|
let plan = WinitWindowPlan::smoke().validate()?;
|
||||||
|
|
||||||
|
assert_eq!(plan.width, DEFAULT_SMOKE_WIDTH);
|
||||||
|
assert_eq!(plan.height, DEFAULT_SMOKE_HEIGHT);
|
||||||
|
assert!(plan.requires_native_handles);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smoke_window_plan_rejects_zero_extent() {
|
||||||
|
let plan = WinitWindowPlan {
|
||||||
|
width: 0,
|
||||||
|
height: DEFAULT_SMOKE_HEIGHT,
|
||||||
|
requires_native_handles: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
plan.validate(),
|
||||||
|
Err(PlatformError::Backend {
|
||||||
|
context: "winit window plan",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smoke_window_app_requires_created_native_window() {
|
||||||
|
let app = SmokeWindowApp::new(WinitWindowPlan::smoke());
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.into_probe(),
|
||||||
|
Err(PlatformError::Backend {
|
||||||
|
context: "winit smoke window",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn smoke_window_app_rejects_synthetic_window_without_native_handles() {
|
||||||
|
let mut app = SmokeWindowApp::new(WinitWindowPlan::smoke());
|
||||||
|
app.window = Some(WinitWindow::synthetic(
|
||||||
|
DEFAULT_SMOKE_WIDTH,
|
||||||
|
DEFAULT_SMOKE_HEIGHT,
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
app.into_probe(),
|
||||||
|
Err(PlatformError::Backend {
|
||||||
|
context: "winit smoke window",
|
||||||
|
..
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_events_push_expected_platform_events() {
|
||||||
|
let mut source = WinitEventSource::new();
|
||||||
|
let size = winit::dpi::PhysicalSize::new(1024u32, 768u32);
|
||||||
|
|
||||||
|
source.push_window_event(&WindowEvent::Resized(size));
|
||||||
|
source.push_window_event(&WindowEvent::Focused(false));
|
||||||
|
source.push_window_event(&WindowEvent::CloseRequested);
|
||||||
|
|
||||||
|
let mut events = Vec::new();
|
||||||
|
source
|
||||||
|
.poll(&mut events)
|
||||||
|
.expect("platform event pump should never fail");
|
||||||
|
|
||||||
|
assert!(events.contains(&PlatformEvent::Resize {
|
||||||
|
width: 1024,
|
||||||
|
height: 768,
|
||||||
|
}));
|
||||||
|
assert!(events.contains(&PlatformEvent::FocusChanged { focused: false }));
|
||||||
|
assert!(events.contains(&PlatformEvent::QuitRequested));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SAFETY: no unsafe usage in this crate.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-render-vulkan"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ash = "0.38"
|
||||||
|
ash-window = "0.13"
|
||||||
|
fparkan-binary = { path = "../../crates/fparkan-binary" }
|
||||||
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
|
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||||
|
|
||||||
|
[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"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
|||||||
|
# ADR-0001: Modular Monolith
|
||||||
|
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
FParkan is implemented as one Cargo workspace with local crates grouped by domain. Binaries and adapters compose domain crates; domain crates do not import platform, windowing, OpenGL, GUI, or application packages.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ADR-0002: Behavior Compatibility, Not ABI Compatibility
|
||||||
|
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
The project targets clean-room behavior compatibility for formats, resource lookup, loading order, deterministic runtime behavior, and presentation command semantics. It does not reproduce original DLL boundaries, exports, calling conventions, object layouts, RVAs, or native singleton access patterns.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ADR-0003: Raw And Interpreted Data
|
||||||
|
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
Legacy data models keep raw bytes distinct from validated structure and interpreted domain views. Writers preserve raw data unless an explicit editing profile requests canonical rebuilding.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ADR-0004: Synthetic And Licensed Tests
|
||||||
|
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
Synthetic tests run everywhere and contain no proprietary data. Licensed corpus tests require an explicit local manifest and fail when requested without configuration. Reports contain metrics and fingerprints, not payload dumps or absolute game roots.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ADR-0005: Deterministic Reference Runtime
|
||||||
|
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
Stages 0-5 use a single-threaded deterministic reference profile. Stable ordering, explicit ticks, named random streams, and canonical captures are part of the contract. Wall-clock time, pointer addresses, hash iteration order, and GPU handles are not semantic inputs.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# ADR-0006: Error Policy
|
||||||
|
|
||||||
|
Status: accepted
|
||||||
|
|
||||||
|
Missing required resources, malformed bytes, unsupported documented branches, capability mismatches, and budget failures are structured errors. Runtime code must not silently skip mandatory objects or convert corrupted data into empty success values.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# ADR-0007: Safe SDL/OpenGL Boundary
|
||||||
|
|
||||||
|
Status: provisional
|
||||||
|
|
||||||
|
Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained
|
||||||
|
external crates behind a safe project API; local Objective-C/CGL/SDL/OpenGL FFI
|
||||||
|
inside FParkan is not an acceptable implementation strategy.
|
||||||
|
|
||||||
|
The current adapter crates are safe boundary stubs. They compile the intended
|
||||||
|
ports and deterministic command contracts, but they do not create SDL windows,
|
||||||
|
GL contexts, GPU resources, shaders, draw calls, swapchains, or presents. They
|
||||||
|
must not be treated as backend readiness evidence.
|
||||||
|
|
||||||
|
To close the macOS backend requirement, choose and vendor/lock a maintained
|
||||||
|
safe facade stack, then implement:
|
||||||
|
|
||||||
|
- SDL event source, window creation, GL context lifecycle, drawable size and
|
||||||
|
present;
|
||||||
|
- GL shader compile/link, buffer/texture upload, render state, draw calls and
|
||||||
|
diagnostics;
|
||||||
|
- game/viewer composition roots using those adapters;
|
||||||
|
- hidden-window/offscreen macOS smoke tests and licensed local model/terrain
|
||||||
|
frame captures.
|
||||||
|
|
||||||
|
Until those are implemented, Desktop GL evidence may document external probes
|
||||||
|
only; it does not satisfy the permanent adapter requirement.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-cli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||||
|
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
|
||||||
|
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
|
||||||
|
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
|
||||||
|
fparkan-resource = { path = "../../crates/fparkan-resource" }
|
||||||
|
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||||
|
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
#![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::extend_graph_report_with_visual_dependencies;
|
||||||
|
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
|
||||||
|
use fparkan_inspection::inspect_archive_file;
|
||||||
|
use fparkan_inspection::ArchiveInspection;
|
||||||
|
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;
|
||||||
|
use std::fmt::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let result = run(&args);
|
||||||
|
let code = exit_code(&result);
|
||||||
|
if let Err(err) = result {
|
||||||
|
eprintln!("{err}");
|
||||||
|
}
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: &[String]) -> Result<(), String> {
|
||||||
|
match args {
|
||||||
|
[domain, command, rest @ ..] if domain == "corpus" && command == "discover" => {
|
||||||
|
let rest = strip_format_json(rest)?;
|
||||||
|
let root = parse_root(&rest)?;
|
||||||
|
let manifest =
|
||||||
|
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
|
||||||
|
let report = report(&root, &manifest).map_err(|e| e.to_string())?;
|
||||||
|
println!("{}", render_report_json(&report));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
[domain, command, rest @ ..] if domain == "corpus" && command == "validate" => {
|
||||||
|
let rest = strip_format_json(rest)?;
|
||||||
|
let root = parse_root(&rest)?;
|
||||||
|
let manifest =
|
||||||
|
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
|
||||||
|
let report = report(&root, &manifest).map_err(|e| e.to_string())?;
|
||||||
|
if report.casefold_collisions > 0 {
|
||||||
|
return Err("casefold collisions found".to_string());
|
||||||
|
}
|
||||||
|
if report.failures > 0 {
|
||||||
|
return Err(format!("corpus report found {} failures", report.failures));
|
||||||
|
}
|
||||||
|
println!("{}", render_report_json(&report));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
[domain, command, rest @ ..] if domain == "archive" && command == "inspect" => {
|
||||||
|
let rest = strip_format_json(rest)?;
|
||||||
|
inspect_archive(&rest)
|
||||||
|
}
|
||||||
|
[domain, command, rest @ ..] if domain == "prototype" && command == "inspect" => {
|
||||||
|
let rest = strip_format_json(rest)?;
|
||||||
|
inspect_prototype(&rest)
|
||||||
|
}
|
||||||
|
[domain, command, rest @ ..] if domain == "mission" && command == "graph" => {
|
||||||
|
let rest = strip_format_json(rest)?;
|
||||||
|
graph_mission(&rest)
|
||||||
|
}
|
||||||
|
_ => Err(usage()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exit_code(result: &Result<(), String>) -> i32 {
|
||||||
|
if result.is_ok() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn strip_format_json(args: &[String]) -> Result<Vec<String>, String> {
|
||||||
|
let mut stripped = Vec::with_capacity(args.len());
|
||||||
|
let mut iter = args.iter();
|
||||||
|
while let Some(arg) = iter.next() {
|
||||||
|
if arg == "--format" {
|
||||||
|
let value = iter
|
||||||
|
.next()
|
||||||
|
.ok_or_else(|| "--format requires a value".to_string())?;
|
||||||
|
if value != "json" {
|
||||||
|
return Err(format!("unsupported output format: {value}"));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stripped.push(arg.clone());
|
||||||
|
}
|
||||||
|
Ok(stripped)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_root(args: &[String]) -> Result<PathBuf, String> {
|
||||||
|
let mut iter = args.iter();
|
||||||
|
while let Some(arg) = iter.next() {
|
||||||
|
if arg == "--root" {
|
||||||
|
return iter
|
||||||
|
.next()
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| "--root requires a path".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("missing --root".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_root_alias(args: &[String]) -> Result<PathBuf, String> {
|
||||||
|
parse_option(args, &["--root", "--game-root"])
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| "missing --root".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_required(args: &[String], names: &[&str], label: &str) -> Result<String, String> {
|
||||||
|
parse_option(args, names).ok_or_else(|| format!("missing {label}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
|
||||||
|
let mut iter = args.iter();
|
||||||
|
while let Some(arg) = iter.next() {
|
||||||
|
if names.iter().any(|name| arg == name) {
|
||||||
|
return iter.next().cloned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_prototype(args: &[String]) -> Result<(), String> {
|
||||||
|
let root = parse_root_alias(args)?;
|
||||||
|
let key = parse_required(args, &["--key"], "--key")?;
|
||||||
|
let vfs = Arc::new(DirectoryVfs::new(root));
|
||||||
|
let repository = CachedResourceRepository::new(vfs.clone());
|
||||||
|
let roots = [resource_name(key.as_bytes())];
|
||||||
|
let (graph, resolved, mut report) =
|
||||||
|
build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
|
||||||
|
extend_graph_report_with_visual_dependencies(&repository, &mut report, &graph, &resolved);
|
||||||
|
println!("{}", prototype_inspect_json(&key, &graph, &report));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prototype_inspect_json(
|
||||||
|
key: &str,
|
||||||
|
graph: &fparkan_prototype::PrototypeGraph,
|
||||||
|
report: &fparkan_prototype::PrototypeGraphReport,
|
||||||
|
) -> String {
|
||||||
|
format!(
|
||||||
|
"{{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":{},\"roots\":{},\"prototype_requests\":{},\"resolved\":{},\"unit_references\":{},\"unit_components\":{},\"direct_references\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
|
||||||
|
json_string(key),
|
||||||
|
report.root_count,
|
||||||
|
graph.prototype_requests.len(),
|
||||||
|
report.resolved_count,
|
||||||
|
report.unit_reference_count,
|
||||||
|
report.unit_component_count,
|
||||||
|
report.direct_reference_count,
|
||||||
|
report.wear_resolved_count,
|
||||||
|
report.material_resolved_count,
|
||||||
|
report.texture_resolved_count,
|
||||||
|
report.lightmap_resolved_count,
|
||||||
|
report.failures.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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!(
|
||||||
|
"{{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":{},\"objects\":{},\"paths\":{},\"clans\":{},\"extras\":{},\"roots\":{},\"direct_references\":{},\"unit_references\":{},\"unit_components\":{},\"prototype_requests\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
|
||||||
|
json_string(&mission),
|
||||||
|
loaded.object_count,
|
||||||
|
loaded.path_count,
|
||||||
|
loaded.clan_count,
|
||||||
|
loaded.extra_count,
|
||||||
|
loaded.graph_root_count,
|
||||||
|
loaded.graph_direct_reference_count,
|
||||||
|
loaded.graph_unit_reference_count,
|
||||||
|
loaded.graph_unit_component_count,
|
||||||
|
loaded.graph_resolved_count,
|
||||||
|
loaded.graph_wear_resolved_count,
|
||||||
|
loaded.graph_material_resolved_count,
|
||||||
|
loaded.graph_texture_resolved_count,
|
||||||
|
loaded.graph_lightmap_resolved_count,
|
||||||
|
loaded.graph_failure_count
|
||||||
|
);
|
||||||
|
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.to_string())?;
|
||||||
|
|
||||||
|
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 archive_inspect_json(
|
||||||
|
path: &str,
|
||||||
|
kind: &str,
|
||||||
|
entries: usize,
|
||||||
|
lookup_order_valid: Option<bool>,
|
||||||
|
) -> String {
|
||||||
|
let mut out = format!(
|
||||||
|
"{{\"schema_version\":\"fparkan-archive-inspect-v1\",\"path\":{},\"kind\":{},\"entries\":{}",
|
||||||
|
json_string(path),
|
||||||
|
json_string(kind),
|
||||||
|
entries
|
||||||
|
);
|
||||||
|
if let Some(valid) = lookup_order_valid {
|
||||||
|
let _ = write!(out, ",\"lookup_order_valid\":{valid}");
|
||||||
|
}
|
||||||
|
out.push('}');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
|
||||||
|
match args {
|
||||||
|
[path] => Ok(PathBuf::from(path)),
|
||||||
|
[flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
|
||||||
|
_ => Err("archive inspect requires <file> or --file <file>".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_string(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
c if c.is_control() => {
|
||||||
|
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||||
|
}
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() -> String {
|
||||||
|
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph --root <path> --mission <path> [--format json]".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn strings(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stable_exit_codes_are_mapped() {
|
||||||
|
assert_eq!(exit_code(&Ok(())), 0);
|
||||||
|
assert_eq!(exit_code(&Err("failure".to_string())), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_json_format_option() {
|
||||||
|
assert_eq!(
|
||||||
|
strip_format_json(&strings(&["--root", "testdata", "--format", "json"])),
|
||||||
|
Ok(strings(&["--root", "testdata"]))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
strip_format_json(&strings(&["--format", "text"])),
|
||||||
|
Err("unsupported output format: text".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn archive_json_has_schema_version() {
|
||||||
|
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
json,
|
||||||
|
"{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"failures\":0}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-game"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-assets = { path = "../../crates/fparkan-assets" }
|
||||||
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
|
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-vfs = { path = "../../crates/fparkan-vfs" }
|
||||||
|
fparkan-world = { path = "../../crates/fparkan-world" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
#![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` rendered game composition root.
|
||||||
|
|
||||||
|
use fparkan_assets::PreparedVisual;
|
||||||
|
use fparkan_platform::WindowPort;
|
||||||
|
use fparkan_platform_winit::WinitWindow;
|
||||||
|
use fparkan_render::{
|
||||||
|
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand,
|
||||||
|
RenderCommandList, RenderPhase,
|
||||||
|
};
|
||||||
|
use fparkan_render_vulkan::VulkanBackend;
|
||||||
|
use fparkan_runtime::{
|
||||||
|
create, frame, load_mission, loaded_mission_assets, EngineConfig, EngineMode, EngineServices,
|
||||||
|
MissionAssets, MissionRequest,
|
||||||
|
};
|
||||||
|
use fparkan_vfs::DirectoryVfs;
|
||||||
|
use fparkan_world::WorldSnapshot;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let raw_args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||||
|
let code = match run(&raw_args) {
|
||||||
|
Ok(output) => {
|
||||||
|
println!("{output}");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{err}");
|
||||||
|
2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: &[String]) -> Result<String, String> {
|
||||||
|
let args = Args::parse(args)?;
|
||||||
|
let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root)));
|
||||||
|
let mut engine = create(
|
||||||
|
EngineConfig {
|
||||||
|
mode: EngineMode::Rendered,
|
||||||
|
},
|
||||||
|
services,
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
let loaded = load_mission(
|
||||||
|
&mut engine,
|
||||||
|
MissionRequest {
|
||||||
|
key: args.mission.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
|
||||||
|
let mut backend = VulkanBackend::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 commands = render_snapshot_commands_with_assets(&result.snapshot, mission_assets);
|
||||||
|
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!(
|
||||||
|
"{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}",
|
||||||
|
json_string(&args.mission),
|
||||||
|
loaded.object_count,
|
||||||
|
args.frames,
|
||||||
|
last_tick,
|
||||||
|
last_draw_count,
|
||||||
|
capture_report.submissions,
|
||||||
|
capture_report.last_capture_size,
|
||||||
|
json_hash(&last_hash)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
|
||||||
|
render_snapshot_commands_with_assets(snapshot, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_snapshot_commands_with_assets(
|
||||||
|
snapshot: &WorldSnapshot,
|
||||||
|
mission_assets: Option<&MissionAssets>,
|
||||||
|
) -> RenderCommandList {
|
||||||
|
let mut commands = Vec::with_capacity(snapshot.objects.len() + 2);
|
||||||
|
commands.push(RenderCommand::BeginFrame);
|
||||||
|
for (index, handle) in snapshot.objects.iter().enumerate() {
|
||||||
|
let stable_order = u64::from(handle.slot);
|
||||||
|
let prepared = mission_assets.and_then(|assets| {
|
||||||
|
assets
|
||||||
|
.visual_for_object(index)
|
||||||
|
.and_then(|visual_id| assets.visual_by_id(visual_id))
|
||||||
|
});
|
||||||
|
let mesh = if let Some(visual) = prepared {
|
||||||
|
visual.mesh.as_ref().map_or_else(
|
||||||
|
|| GpuMeshId(u64::from(handle.slot) + 1),
|
||||||
|
|_| GpuMeshId(visual.id.raw()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
GpuMeshId(u64::from(handle.slot) + 1)
|
||||||
|
};
|
||||||
|
let material = prepared
|
||||||
|
.and_then(PreparedVisual::primary_material_id)
|
||||||
|
.map_or(GpuMaterialId(1), |material_id| {
|
||||||
|
GpuMaterialId(material_id.raw())
|
||||||
|
});
|
||||||
|
let draw_id = snapshot
|
||||||
|
.tick
|
||||||
|
.0
|
||||||
|
.wrapping_mul(1_000_003)
|
||||||
|
.wrapping_add(stable_order);
|
||||||
|
commands.push(RenderCommand::Draw(DrawCommand {
|
||||||
|
id: DrawId(draw_id),
|
||||||
|
phase: RenderPhase::Opaque,
|
||||||
|
object_id: None,
|
||||||
|
mesh,
|
||||||
|
material,
|
||||||
|
transform: identity_transform(index_to_f32(index)),
|
||||||
|
range: IndexRange { start: 0, count: 3 },
|
||||||
|
stable_order,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
commands.push(RenderCommand::EndFrame);
|
||||||
|
RenderCommandList { commands }
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
fn parse(args: &[String]) -> Result<Self, String> {
|
||||||
|
let mut root = None;
|
||||||
|
let mut mission = None;
|
||||||
|
let mut frames = 1;
|
||||||
|
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())?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"--mission" => {
|
||||||
|
mission = Some(
|
||||||
|
iter.next()
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| "--mission 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())?;
|
||||||
|
}
|
||||||
|
_ => return Err(usage()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let root = root.ok_or_else(|| "missing --root".to_string())?;
|
||||||
|
let mission = mission.ok_or_else(|| "missing --mission".to_string())?;
|
||||||
|
if frames == 0 {
|
||||||
|
return Err("--frames must be greater than zero".to_string());
|
||||||
|
}
|
||||||
|
Ok(Self {
|
||||||
|
root,
|
||||||
|
mission,
|
||||||
|
frames,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_string(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
c if c.is_control() => {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||||
|
}
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_hash(hash: &[u8; 32]) -> String {
|
||||||
|
let mut out = String::from("\"");
|
||||||
|
for byte in hash {
|
||||||
|
use std::fmt::Write as _;
|
||||||
|
let _ = write!(out, "{byte:02x}");
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() -> String {
|
||||||
|
"usage: fparkan-game --root <path> --mission <path> [--frames <n>]".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use fparkan_world::{ObjectHandle, StateHash, Tick};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
fn strings(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_required_args() {
|
||||||
|
assert_eq!(
|
||||||
|
Args::parse(&strings(&[
|
||||||
|
"--root",
|
||||||
|
"testdata/IS",
|
||||||
|
"--mission",
|
||||||
|
"MISSIONS/Autodemo.00/data.tma",
|
||||||
|
"--frames",
|
||||||
|
"3",
|
||||||
|
])),
|
||||||
|
Ok(Args {
|
||||||
|
root: PathBuf::from("testdata/IS"),
|
||||||
|
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
|
||||||
|
frames: 3,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
events: Vec::new(),
|
||||||
|
hash: StateHash([0; 32]),
|
||||||
|
};
|
||||||
|
|
||||||
|
let commands = render_snapshot_commands(&snapshot);
|
||||||
|
|
||||||
|
assert_eq!(commands.commands.len(), 4);
|
||||||
|
assert!(matches!(commands.commands[0], RenderCommand::BeginFrame));
|
||||||
|
assert!(matches!(commands.commands[3], RenderCommand::EndFrame));
|
||||||
|
let 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]
|
||||||
|
#[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: "{\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"captures\":1,\"last_capture_bytes\":810,\"hash\":\"ca17cc76e55c45e83c1c9c1c088e84bf1a698be91a7730943210fe27596af841\"}",
|
||||||
|
},
|
||||||
|
RenderCase {
|
||||||
|
root: "IS2",
|
||||||
|
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
|
||||||
|
expected: "{\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"captures\":1,\"last_capture_bytes\":235,\"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<String> {
|
||||||
|
vec![
|
||||||
|
"--root".to_string(),
|
||||||
|
root.to_str().expect("utf8 root").to_string(),
|
||||||
|
"--mission".to_string(),
|
||||||
|
mission.to_string(),
|
||||||
|
"--frames".to_string(),
|
||||||
|
"1".to_string(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn licensed_root(name: &str) -> PathBuf {
|
||||||
|
let variable = match name {
|
||||||
|
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
|
||||||
|
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
|
||||||
|
_ => panic!("unknown licensed corpus part: {name}"),
|
||||||
|
};
|
||||||
|
let root = std::env::var_os(variable)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
|
||||||
|
assert!(
|
||||||
|
root.is_dir(),
|
||||||
|
"licensed corpus root is missing: {}",
|
||||||
|
root.display()
|
||||||
|
);
|
||||||
|
root
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-headless"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
|
||||||
|
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
|
||||||
|
fparkan-world = { path = "../../crates/fparkan-world" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||||
|
//! `FParkan` headless runtime entrypoint.
|
||||||
|
|
||||||
|
use fparkan_runtime::{
|
||||||
|
create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest,
|
||||||
|
};
|
||||||
|
use fparkan_vfs::DirectoryVfs;
|
||||||
|
use fparkan_world::InputSnapshot;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if let Err(err) = run() {
|
||||||
|
eprintln!("{err}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<(), String> {
|
||||||
|
let raw_args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
|
let args = Args::parse(&raw_args)?;
|
||||||
|
let services = if let Some(root) = &args.root {
|
||||||
|
EngineServices::new(Arc::new(DirectoryVfs::new(root)))
|
||||||
|
} else {
|
||||||
|
EngineServices::default()
|
||||||
|
};
|
||||||
|
let mut engine = create(
|
||||||
|
EngineConfig {
|
||||||
|
mode: EngineMode::Headless,
|
||||||
|
},
|
||||||
|
services,
|
||||||
|
)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
if let Some(mission) = args.mission {
|
||||||
|
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
println!(
|
||||||
|
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} 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.graph_failure_count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Args {
|
||||||
|
root: Option<PathBuf>,
|
||||||
|
mission: Option<String>,
|
||||||
|
ticks: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
fn parse(args: &[String]) -> Result<Self, String> {
|
||||||
|
let mut parsed = Self {
|
||||||
|
root: None,
|
||||||
|
mission: None,
|
||||||
|
ticks: 1,
|
||||||
|
};
|
||||||
|
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())?;
|
||||||
|
}
|
||||||
|
_ => return Err(usage()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parsed.mission.is_some() && parsed.root.is_none() {
|
||||||
|
return Err("--mission requires --root".to_string());
|
||||||
|
}
|
||||||
|
Ok(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() -> String {
|
||||||
|
"usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_string()
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-viewer"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
|
||||||
|
fparkan-render = { path = "../../crates/fparkan-render" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
#![allow(clippy::print_stderr, clippy::print_stdout)]
|
||||||
|
//! `FParkan` asset viewer composition root.
|
||||||
|
|
||||||
|
use fparkan_inspection::{
|
||||||
|
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
|
||||||
|
LandFileKind, MapInspection, NresEntrySummary,
|
||||||
|
};
|
||||||
|
use fparkan_render::{
|
||||||
|
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
|
||||||
|
RenderProfile, RenderSnapshot, RenderSnapshotDraw,
|
||||||
|
};
|
||||||
|
use std::fmt::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||||
|
let code = match run(&args) {
|
||||||
|
Ok(json) => {
|
||||||
|
println!("{json}");
|
||||||
|
0
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("{err}");
|
||||||
|
2
|
||||||
|
}
|
||||||
|
};
|
||||||
|
std::process::exit(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(args: &[String]) -> Result<String, String> {
|
||||||
|
match args {
|
||||||
|
[domain, rest @ ..] if domain == "archive" => inspect_archive(rest),
|
||||||
|
[domain, rest @ ..] if domain == "model" => inspect_model(rest),
|
||||||
|
[domain, rest @ ..] if domain == "texture" => inspect_texture(rest),
|
||||||
|
[domain, rest @ ..] if domain == "map" => inspect_map(rest),
|
||||||
|
_ => Err(usage()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_archive(args: &[String]) -> Result<String, String> {
|
||||||
|
let file = parse_file(args)?;
|
||||||
|
let limit = parse_limit(args)?;
|
||||||
|
let inspection = fparkan_inspection::inspect_archive_file(&file, limit)?;
|
||||||
|
|
||||||
|
match inspection {
|
||||||
|
ArchiveInspection::Nres {
|
||||||
|
entries,
|
||||||
|
lookup_order_valid,
|
||||||
|
sample,
|
||||||
|
} => Ok(format!(
|
||||||
|
"{{\"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!(
|
||||||
|
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
|
||||||
|
json_string(&file.display().to_string()),
|
||||||
|
entries
|
||||||
|
)),
|
||||||
|
ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", file.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_model(args: &[String]) -> Result<String, String> {
|
||||||
|
if let Some(fixture) = parse_option(args, &["--fixture"]) {
|
||||||
|
return ViewerModelService::inspect_synthetic_model(&fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = parse_resource_query(args)?;
|
||||||
|
let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
|
||||||
|
json_string(&query.archive),
|
||||||
|
json_string(&query.name),
|
||||||
|
inspection.streams,
|
||||||
|
inspection.nodes,
|
||||||
|
inspection.slots,
|
||||||
|
inspection.positions,
|
||||||
|
inspection.indices,
|
||||||
|
inspection.batches
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct ViewerModelService;
|
||||||
|
|
||||||
|
impl ViewerModelService {
|
||||||
|
fn inspect_synthetic_model(fixture: &str) -> Result<String, String> {
|
||||||
|
if fixture != "synthetic/model-basic" {
|
||||||
|
return Err(format!("unknown model fixture: {fixture}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshot = RenderSnapshot {
|
||||||
|
camera: CameraSnapshot::default(),
|
||||||
|
draws: vec![RenderSnapshotDraw {
|
||||||
|
id: DrawId(1),
|
||||||
|
phase: RenderPhase::Opaque,
|
||||||
|
object_id: None,
|
||||||
|
mesh: GpuMeshId(1),
|
||||||
|
material_slots: vec![GpuMaterialId(7)],
|
||||||
|
material_index: 0,
|
||||||
|
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!(
|
||||||
|
"{{\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model\",\"draw_commands\":{draw_commands}}}",
|
||||||
|
json_string(fixture)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_texture(args: &[String]) -> Result<String, String> {
|
||||||
|
let query = parse_resource_query(args)?;
|
||||||
|
let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
|
||||||
|
|
||||||
|
Ok(format!(
|
||||||
|
"{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
|
||||||
|
json_string(&query.archive),
|
||||||
|
json_string(&query.name),
|
||||||
|
inspection.width,
|
||||||
|
inspection.height,
|
||||||
|
json_string(&inspection.format),
|
||||||
|
inspection.mips,
|
||||||
|
inspection.pages
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_map(args: &[String]) -> Result<String, String> {
|
||||||
|
let file = parse_file(args)?;
|
||||||
|
let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?;
|
||||||
|
let inspection = inspect_land_file(
|
||||||
|
&file,
|
||||||
|
match kind.as_str() {
|
||||||
|
"land-msh" => LandFileKind::LandMsh,
|
||||||
|
"land-map" => LandFileKind::LandMap,
|
||||||
|
_ => return Err(format!("unknown map kind: {kind}")),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(render_map_inspection_json(
|
||||||
|
&file.display().to_string(),
|
||||||
|
&kind,
|
||||||
|
&inspection,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
|
||||||
|
match kind {
|
||||||
|
"land-msh" => format!(
|
||||||
|
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
|
||||||
|
json_string(path),
|
||||||
|
inspection.streams,
|
||||||
|
inspection.positions,
|
||||||
|
inspection.faces,
|
||||||
|
inspection.slots
|
||||||
|
),
|
||||||
|
"land-map" => format!(
|
||||||
|
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
|
||||||
|
json_string(path),
|
||||||
|
inspection.areals,
|
||||||
|
inspection.declared_areals,
|
||||||
|
inspection.grid_width,
|
||||||
|
inspection.grid_height
|
||||||
|
),
|
||||||
|
_ => unreachable!("invalid land kind: {kind}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ResourceQuery {
|
||||||
|
root: PathBuf,
|
||||||
|
archive: String,
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> {
|
||||||
|
Ok(ResourceQuery {
|
||||||
|
root: parse_path_option(args, &["--root", "--game-root"], "--root")?,
|
||||||
|
archive: parse_option(args, &["--archive"])
|
||||||
|
.ok_or_else(|| "missing --archive".to_string())?,
|
||||||
|
name: parse_option(args, &["--name"]).ok_or_else(|| "missing --name".to_string())?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_file(args: &[String]) -> Result<PathBuf, String> {
|
||||||
|
parse_path_option(args, &["--file"], "--file")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_limit(args: &[String]) -> Result<usize, String> {
|
||||||
|
parse_option(args, &["--limit"])
|
||||||
|
.map(|value| {
|
||||||
|
value
|
||||||
|
.parse::<usize>()
|
||||||
|
.map_err(|_| format!("invalid --limit: {value}"))
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
.map(|value| value.unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_nres_entries(entries: &[NresEntrySummary]) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (index, entry) in entries.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
out.push(',');
|
||||||
|
}
|
||||||
|
let name = &entry.name;
|
||||||
|
let _ = write!(
|
||||||
|
out,
|
||||||
|
"{{\"name\":{},\"type\":{},\"size\":{}}}",
|
||||||
|
json_string(name),
|
||||||
|
entry.type_id,
|
||||||
|
entry.data_size
|
||||||
|
);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_path_option(args: &[String], names: &[&str], label: &str) -> Result<PathBuf, String> {
|
||||||
|
parse_option(args, names)
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.ok_or_else(|| format!("missing {label}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
|
||||||
|
let mut iter = args.iter();
|
||||||
|
while let Some(arg) = iter.next() {
|
||||||
|
if names.iter().any(|name| arg == name) {
|
||||||
|
return iter.next().cloned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn json_string(value: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(value.len() + 2);
|
||||||
|
out.push('"');
|
||||||
|
for ch in value.chars() {
|
||||||
|
match ch {
|
||||||
|
'"' => out.push_str("\\\""),
|
||||||
|
'\\' => out.push_str("\\\\"),
|
||||||
|
'\n' => out.push_str("\\n"),
|
||||||
|
'\r' => out.push_str("\\r"),
|
||||||
|
'\t' => out.push_str("\\t"),
|
||||||
|
c if c.is_control() => {
|
||||||
|
let _ = write!(out, "\\u{:04x}", c as u32);
|
||||||
|
}
|
||||||
|
c => out.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('"');
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn identity_transform() -> [f32; 16] {
|
||||||
|
[
|
||||||
|
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usage() -> String {
|
||||||
|
"usage: fparkan-viewer archive --file <archive> [--limit N] | model --root <game-root> --archive <archive> --name <msh> | model --fixture synthetic/model-basic | texture --root <game-root> --archive <archive> --name <texm> | map --file <Land.msh|Land.map> --kind land-msh|land-map".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn strings(values: &[&str]) -> Vec<String> {
|
||||||
|
values.iter().map(|value| (*value).to_string()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_resource_query() -> Result<(), String> {
|
||||||
|
let query = parse_resource_query(&strings(&[
|
||||||
|
"--root",
|
||||||
|
"testdata/IS",
|
||||||
|
"--archive",
|
||||||
|
"textures.lib",
|
||||||
|
"--name",
|
||||||
|
"grass.tex",
|
||||||
|
]))?;
|
||||||
|
|
||||||
|
assert_eq!(query.root, PathBuf::from("testdata/IS"));
|
||||||
|
assert_eq!(query.archive, "textures.lib");
|
||||||
|
assert_eq!(query.name, "grass.tex");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_string_escapes_controls() {
|
||||||
|
assert_eq!(json_string("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn usage_rejects_empty_args() {
|
||||||
|
assert_eq!(run(&[]), Err(usage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_limit() {
|
||||||
|
assert_eq!(parse_limit(&strings(&["--limit", "2"])), Ok(2));
|
||||||
|
assert_eq!(parse_limit(&[]), Ok(0));
|
||||||
|
assert_eq!(
|
||||||
|
parse_limit(&strings(&["--limit", "x"])),
|
||||||
|
Err("invalid --limit: x".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> {
|
||||||
|
assert_eq!(
|
||||||
|
run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?,
|
||||||
|
"{\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model\",\"draw_commands\":1}"
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-vulkan-smoke"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-platform = { path = "../../crates/fparkan-platform" }
|
||||||
|
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
|
||||||
|
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-animation"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-assets"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-material = { path = "../fparkan-material" }
|
||||||
|
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" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-binary"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
//! Bounded little-endian binary cursor and checked layout helpers.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
/// SHA-256 digest bytes.
|
||||||
|
pub type Sha256Digest = [u8; 32];
|
||||||
|
|
||||||
|
/// Parser limits shared by binary formats.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Limits {
|
||||||
|
/// Maximum file bytes.
|
||||||
|
pub max_file_bytes: u64,
|
||||||
|
/// Maximum entries.
|
||||||
|
pub max_entries: u32,
|
||||||
|
/// Maximum string bytes.
|
||||||
|
pub max_string_bytes: u32,
|
||||||
|
/// Maximum array items.
|
||||||
|
pub max_array_items: u32,
|
||||||
|
/// Maximum recursion depth.
|
||||||
|
pub max_recursion_depth: u16,
|
||||||
|
/// Maximum decoded bytes.
|
||||||
|
pub max_decoded_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Limits {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_file_bytes: 256 * 1024 * 1024,
|
||||||
|
max_entries: 1_000_000,
|
||||||
|
max_string_bytes: 64 * 1024,
|
||||||
|
max_array_items: 1_000_000,
|
||||||
|
max_recursion_depth: 64,
|
||||||
|
max_decoded_bytes: 512 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode error.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum DecodeError {
|
||||||
|
/// Input ended before requested bytes.
|
||||||
|
UnexpectedEof {
|
||||||
|
/// Offset where read was attempted.
|
||||||
|
offset: u64,
|
||||||
|
/// Required byte count.
|
||||||
|
needed: u64,
|
||||||
|
/// Remaining byte count.
|
||||||
|
remaining: u64,
|
||||||
|
},
|
||||||
|
/// Arithmetic overflow.
|
||||||
|
IntegerOverflow,
|
||||||
|
/// Count exceeds limit.
|
||||||
|
LimitExceeded {
|
||||||
|
/// Declared count.
|
||||||
|
count: u64,
|
||||||
|
/// Configured limit.
|
||||||
|
limit: u64,
|
||||||
|
},
|
||||||
|
/// Cursor did not end at EOF.
|
||||||
|
TrailingBytes {
|
||||||
|
/// Offset where EOF was expected.
|
||||||
|
offset: u64,
|
||||||
|
/// Remaining byte count.
|
||||||
|
remaining: u64,
|
||||||
|
},
|
||||||
|
/// Invalid data.
|
||||||
|
Invalid(&'static str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for DecodeError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::UnexpectedEof {
|
||||||
|
offset,
|
||||||
|
needed,
|
||||||
|
remaining,
|
||||||
|
} => write!(
|
||||||
|
f,
|
||||||
|
"unexpected EOF at {offset}: need {needed}, have {remaining}"
|
||||||
|
),
|
||||||
|
Self::IntegerOverflow => write!(f, "integer overflow"),
|
||||||
|
Self::LimitExceeded { count, limit } => {
|
||||||
|
write!(f, "count {count} exceeds limit {limit}")
|
||||||
|
}
|
||||||
|
Self::TrailingBytes { offset, remaining } => {
|
||||||
|
write!(f, "trailing bytes at {offset}: {remaining}")
|
||||||
|
}
|
||||||
|
Self::Invalid(reason) => write!(f, "invalid data: {reason}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for DecodeError {}
|
||||||
|
|
||||||
|
/// Cursor checkpoint.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Checkpoint(pub u64);
|
||||||
|
|
||||||
|
/// Bounded cursor.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Cursor<'a> {
|
||||||
|
bytes: &'a [u8],
|
||||||
|
offset: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> Cursor<'a> {
|
||||||
|
/// Creates a cursor.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(bytes: &'a [u8]) -> Self {
|
||||||
|
Self { bytes, offset: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current offset.
|
||||||
|
#[must_use]
|
||||||
|
pub fn offset(&self) -> u64 {
|
||||||
|
self.offset as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remaining bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn remaining(&self) -> usize {
|
||||||
|
self.bytes.len().saturating_sub(self.offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a checkpoint.
|
||||||
|
#[must_use]
|
||||||
|
pub fn checkpoint(&self) -> Checkpoint {
|
||||||
|
Checkpoint(self.offset())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads exact bytes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`DecodeError::IntegerOverflow`] if the requested end offset
|
||||||
|
/// overflows, or [`DecodeError::UnexpectedEof`] if there are not enough
|
||||||
|
/// bytes remaining.
|
||||||
|
pub fn read_exact(&mut self, len: usize) -> Result<&'a [u8], DecodeError> {
|
||||||
|
let end = self
|
||||||
|
.offset
|
||||||
|
.checked_add(len)
|
||||||
|
.ok_or(DecodeError::IntegerOverflow)?;
|
||||||
|
if end > self.bytes.len() {
|
||||||
|
return Err(DecodeError::UnexpectedEof {
|
||||||
|
offset: self.offset(),
|
||||||
|
needed: len as u64,
|
||||||
|
remaining: self.remaining() as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let out = &self.bytes[self.offset..end];
|
||||||
|
self.offset = end;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a little-endian u16.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`DecodeError`] if two bytes cannot be read.
|
||||||
|
pub fn read_u16_le(&mut self) -> Result<u16, DecodeError> {
|
||||||
|
let b = self.read_exact(2)?;
|
||||||
|
Ok(u16::from_le_bytes([b[0], b[1]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a little-endian u32.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`DecodeError`] if four bytes cannot be read.
|
||||||
|
pub fn read_u32_le(&mut self) -> Result<u32, DecodeError> {
|
||||||
|
let b = self.read_exact(4)?;
|
||||||
|
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a little-endian i32.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`DecodeError`] if four bytes cannot be read.
|
||||||
|
pub fn read_i32_le(&mut self) -> Result<i32, DecodeError> {
|
||||||
|
let b = self.read_exact(4)?;
|
||||||
|
Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a little-endian f32.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`DecodeError`] if four bytes cannot be read.
|
||||||
|
pub fn read_f32_le(&mut self) -> Result<f32, DecodeError> {
|
||||||
|
Ok(f32::from_bits(self.read_u32_le()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Requires exact EOF.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`DecodeError::TrailingBytes`] when unread bytes remain.
|
||||||
|
pub fn require_eof(&self) -> Result<(), DecodeError> {
|
||||||
|
if self.remaining() == 0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(DecodeError::TrailingBytes {
|
||||||
|
offset: self.offset(),
|
||||||
|
remaining: self.remaining() as u64,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates `count * stride <= remaining` and returns bytes as usize.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`DecodeError::IntegerOverflow`] on arithmetic or conversion
|
||||||
|
/// overflow, or [`DecodeError::UnexpectedEof`] when the declared byte count is
|
||||||
|
/// larger than the remaining bounded input.
|
||||||
|
pub fn checked_count_bytes(count: u64, stride: u64, remaining: u64) -> Result<usize, DecodeError> {
|
||||||
|
let bytes = count
|
||||||
|
.checked_mul(stride)
|
||||||
|
.ok_or(DecodeError::IntegerOverflow)?;
|
||||||
|
if bytes > remaining {
|
||||||
|
return Err(DecodeError::UnexpectedEof {
|
||||||
|
offset: 0,
|
||||||
|
needed: bytes,
|
||||||
|
remaining,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
usize::try_from(bytes).map_err(|_| DecodeError::IntegerOverflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates a declared allocation size before constructing the allocation.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`DecodeError::LimitExceeded`] when `declared` is larger than
|
||||||
|
/// `limit`, or [`DecodeError::IntegerOverflow`] when the accepted size cannot
|
||||||
|
/// be represented by the host `usize`.
|
||||||
|
pub fn checked_allocation_len(declared: u64, limit: u64) -> Result<usize, DecodeError> {
|
||||||
|
if declared > limit {
|
||||||
|
return Err(DecodeError::LimitExceeded {
|
||||||
|
count: declared,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
usize::try_from(declared).map_err(|_| DecodeError::IntegerOverflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads length-prefixed bytes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`DecodeError`] if the length cannot be read, exceeds `max`, or the
|
||||||
|
/// declared payload is truncated.
|
||||||
|
pub fn read_lp_bytes(cursor: &mut Cursor<'_>, max: u32) -> Result<Vec<u8>, DecodeError> {
|
||||||
|
let len = cursor.read_u32_le()?;
|
||||||
|
if len > max {
|
||||||
|
return Err(DecodeError::LimitExceeded {
|
||||||
|
count: u64::from(len),
|
||||||
|
limit: u64::from(max),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let len = checked_allocation_len(u64::from(len), u64::from(max))?;
|
||||||
|
Ok(cursor.read_exact(len)?.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes a SHA-256 content digest without external dependencies.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sha256(bytes: &[u8]) -> Sha256Digest {
|
||||||
|
const K: [u32; 64] = [
|
||||||
|
0x428a_2f98,
|
||||||
|
0x7137_4491,
|
||||||
|
0xb5c0_fbcf,
|
||||||
|
0xe9b5_dba5,
|
||||||
|
0x3956_c25b,
|
||||||
|
0x59f1_11f1,
|
||||||
|
0x923f_82a4,
|
||||||
|
0xab1c_5ed5,
|
||||||
|
0xd807_aa98,
|
||||||
|
0x1283_5b01,
|
||||||
|
0x2431_85be,
|
||||||
|
0x550c_7dc3,
|
||||||
|
0x72be_5d74,
|
||||||
|
0x80de_b1fe,
|
||||||
|
0x9bdc_06a7,
|
||||||
|
0xc19b_f174,
|
||||||
|
0xe49b_69c1,
|
||||||
|
0xefbe_4786,
|
||||||
|
0x0fc1_9dc6,
|
||||||
|
0x240c_a1cc,
|
||||||
|
0x2de9_2c6f,
|
||||||
|
0x4a74_84aa,
|
||||||
|
0x5cb0_a9dc,
|
||||||
|
0x76f9_88da,
|
||||||
|
0x983e_5152,
|
||||||
|
0xa831_c66d,
|
||||||
|
0xb003_27c8,
|
||||||
|
0xbf59_7fc7,
|
||||||
|
0xc6e0_0bf3,
|
||||||
|
0xd5a7_9147,
|
||||||
|
0x06ca_6351,
|
||||||
|
0x1429_2967,
|
||||||
|
0x27b7_0a85,
|
||||||
|
0x2e1b_2138,
|
||||||
|
0x4d2c_6dfc,
|
||||||
|
0x5338_0d13,
|
||||||
|
0x650a_7354,
|
||||||
|
0x766a_0abb,
|
||||||
|
0x81c2_c92e,
|
||||||
|
0x9272_2c85,
|
||||||
|
0xa2bf_e8a1,
|
||||||
|
0xa81a_664b,
|
||||||
|
0xc24b_8b70,
|
||||||
|
0xc76c_51a3,
|
||||||
|
0xd192_e819,
|
||||||
|
0xd699_0624,
|
||||||
|
0xf40e_3585,
|
||||||
|
0x106a_a070,
|
||||||
|
0x19a4_c116,
|
||||||
|
0x1e37_6c08,
|
||||||
|
0x2748_774c,
|
||||||
|
0x34b0_bcb5,
|
||||||
|
0x391c_0cb3,
|
||||||
|
0x4ed8_aa4a,
|
||||||
|
0x5b9c_ca4f,
|
||||||
|
0x682e_6ff3,
|
||||||
|
0x748f_82ee,
|
||||||
|
0x78a5_636f,
|
||||||
|
0x84c8_7814,
|
||||||
|
0x8cc7_0208,
|
||||||
|
0x90be_fffa,
|
||||||
|
0xa450_6ceb,
|
||||||
|
0xbef9_a3f7,
|
||||||
|
0xc671_78f2,
|
||||||
|
];
|
||||||
|
let mut h = [
|
||||||
|
0x6a09_e667,
|
||||||
|
0xbb67_ae85,
|
||||||
|
0x3c6e_f372,
|
||||||
|
0xa54f_f53a,
|
||||||
|
0x510e_527f,
|
||||||
|
0x9b05_688c,
|
||||||
|
0x1f83_d9ab,
|
||||||
|
0x5be0_cd19,
|
||||||
|
];
|
||||||
|
|
||||||
|
let bit_len = (bytes.len() as u64).wrapping_mul(8);
|
||||||
|
let mut chunks = bytes.chunks_exact(64);
|
||||||
|
for chunk in &mut chunks {
|
||||||
|
compress_sha256_chunk(&mut h, chunk, &K);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tail = chunks.remainder();
|
||||||
|
let mut block = [0u8; 128];
|
||||||
|
block[..tail.len()].copy_from_slice(tail);
|
||||||
|
block[tail.len()] = 0x80;
|
||||||
|
let padded_len = if tail.len() < 56 { 64 } else { 128 };
|
||||||
|
block[padded_len - 8..padded_len].copy_from_slice(&bit_len.to_be_bytes());
|
||||||
|
for chunk in block[..padded_len].chunks_exact(64) {
|
||||||
|
compress_sha256_chunk(&mut h, chunk, &K);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
for (idx, word) in h.iter().enumerate() {
|
||||||
|
out[idx * 4..idx * 4 + 4].copy_from_slice(&word.to_be_bytes());
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a SHA-256 digest as lowercase hexadecimal.
|
||||||
|
#[must_use]
|
||||||
|
pub fn sha256_hex(digest: &Sha256Digest) -> String {
|
||||||
|
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||||
|
let mut out = String::with_capacity(64);
|
||||||
|
for byte in digest {
|
||||||
|
out.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||||
|
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::many_single_char_names)]
|
||||||
|
fn compress_sha256_chunk(h: &mut [u32; 8], chunk: &[u8], k: &[u32; 64]) {
|
||||||
|
let mut w = [0u32; 64];
|
||||||
|
for (idx, word) in w.iter_mut().take(16).enumerate() {
|
||||||
|
let base = idx * 4;
|
||||||
|
*word = u32::from_be_bytes([
|
||||||
|
chunk[base],
|
||||||
|
chunk[base + 1],
|
||||||
|
chunk[base + 2],
|
||||||
|
chunk[base + 3],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
for idx in 16..64 {
|
||||||
|
let s0 = w[idx - 15].rotate_right(7) ^ w[idx - 15].rotate_right(18) ^ (w[idx - 15] >> 3);
|
||||||
|
let s1 = w[idx - 2].rotate_right(17) ^ w[idx - 2].rotate_right(19) ^ (w[idx - 2] >> 10);
|
||||||
|
w[idx] = w[idx - 16]
|
||||||
|
.wrapping_add(s0)
|
||||||
|
.wrapping_add(w[idx - 7])
|
||||||
|
.wrapping_add(s1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut a = h[0];
|
||||||
|
let mut b = h[1];
|
||||||
|
let mut c = h[2];
|
||||||
|
let mut d = h[3];
|
||||||
|
let mut e = h[4];
|
||||||
|
let mut f = h[5];
|
||||||
|
let mut g = h[6];
|
||||||
|
let mut hh = h[7];
|
||||||
|
|
||||||
|
for idx in 0..64 {
|
||||||
|
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
|
||||||
|
let ch = (e & f) ^ ((!e) & g);
|
||||||
|
let temp1 = hh
|
||||||
|
.wrapping_add(s1)
|
||||||
|
.wrapping_add(ch)
|
||||||
|
.wrapping_add(k[idx])
|
||||||
|
.wrapping_add(w[idx]);
|
||||||
|
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
|
||||||
|
let maj = (a & b) ^ (a & c) ^ (b & c);
|
||||||
|
let temp2 = s0.wrapping_add(maj);
|
||||||
|
|
||||||
|
hh = g;
|
||||||
|
g = f;
|
||||||
|
f = e;
|
||||||
|
e = d.wrapping_add(temp1);
|
||||||
|
d = c;
|
||||||
|
c = b;
|
||||||
|
b = a;
|
||||||
|
a = temp1.wrapping_add(temp2);
|
||||||
|
}
|
||||||
|
|
||||||
|
h[0] = h[0].wrapping_add(a);
|
||||||
|
h[1] = h[1].wrapping_add(b);
|
||||||
|
h[2] = h[2].wrapping_add(c);
|
||||||
|
h[3] = h[3].wrapping_add(d);
|
||||||
|
h[4] = h[4].wrapping_add(e);
|
||||||
|
h[5] = h[5].wrapping_add(f);
|
||||||
|
h[6] = h[6].wrapping_add(g);
|
||||||
|
h[7] = h[7].wrapping_add(hh);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_count_stride_overflow() {
|
||||||
|
assert_eq!(
|
||||||
|
checked_count_bytes(u64::MAX, 2, u64::MAX),
|
||||||
|
Err(DecodeError::IntegerOverflow)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_eof_reports_trailing() {
|
||||||
|
let mut cursor = Cursor::new(&[1, 2]);
|
||||||
|
assert_eq!(cursor.read_exact(1).expect("byte"), &[1]);
|
||||||
|
assert!(matches!(
|
||||||
|
cursor.require_eof(),
|
||||||
|
Err(DecodeError::TrailingBytes { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_oversized_declared_allocation_before_read() {
|
||||||
|
assert_eq!(
|
||||||
|
checked_allocation_len(1025, 1024),
|
||||||
|
Err(DecodeError::LimitExceeded {
|
||||||
|
count: 1025,
|
||||||
|
limit: 1024
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
let bytes = 2048u32.to_le_bytes();
|
||||||
|
let mut cursor = Cursor::new(&bytes);
|
||||||
|
assert_eq!(
|
||||||
|
read_lp_bytes(&mut cursor, 1024),
|
||||||
|
Err(DecodeError::LimitExceeded {
|
||||||
|
count: 2048,
|
||||||
|
limit: 1024
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert_eq!(cursor.offset(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sha256_matches_known_vectors() {
|
||||||
|
assert_eq!(
|
||||||
|
sha256_hex(&sha256(b"")),
|
||||||
|
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sha256_hex(&sha256(b"abc")),
|
||||||
|
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-corpus"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
fparkan-fx = { path = "../fparkan-fx" }
|
||||||
|
fparkan-material = { path = "../fparkan-material" }
|
||||||
|
fparkan-msh = { path = "../fparkan-msh" }
|
||||||
|
fparkan-mission-format = { path = "../fparkan-mission-format" }
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
fparkan-prototype = { path = "../fparkan-prototype" }
|
||||||
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||||
|
fparkan-texm = { path = "../fparkan-texm" }
|
||||||
|
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-diagnostics"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
//! Structured diagnostics shared by `FParkan` crates.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Diagnostic severity.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Severity {
|
||||||
|
/// Informational note.
|
||||||
|
Info,
|
||||||
|
/// Recoverable warning.
|
||||||
|
Warning,
|
||||||
|
/// Error for the current operation.
|
||||||
|
Error,
|
||||||
|
/// Fatal error for the current run.
|
||||||
|
Fatal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Evidence level for a contract or interpretation.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum EvidenceStatus {
|
||||||
|
/// Described by project documentation.
|
||||||
|
Documented,
|
||||||
|
/// Verified by synthetic fixtures.
|
||||||
|
SyntheticVerified,
|
||||||
|
/// Verified against the licensed corpus.
|
||||||
|
CorpusVerified,
|
||||||
|
/// Verified by runtime capture.
|
||||||
|
RuntimeCaptured,
|
||||||
|
/// Working hypothesis; not a runtime contract.
|
||||||
|
Hypothesis,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Operation phase where a diagnostic was produced.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Phase {
|
||||||
|
/// Discovery.
|
||||||
|
Discover,
|
||||||
|
/// Read.
|
||||||
|
Read,
|
||||||
|
/// Parse.
|
||||||
|
Parse,
|
||||||
|
/// Validate.
|
||||||
|
Validate,
|
||||||
|
/// Resolve.
|
||||||
|
Resolve,
|
||||||
|
/// Prepare.
|
||||||
|
Prepare,
|
||||||
|
/// Construct.
|
||||||
|
Construct,
|
||||||
|
/// Register.
|
||||||
|
Register,
|
||||||
|
/// Simulate.
|
||||||
|
Simulate,
|
||||||
|
/// Render.
|
||||||
|
Render,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Byte span in an input source.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct SourceSpan {
|
||||||
|
/// Start offset.
|
||||||
|
pub offset: u64,
|
||||||
|
/// Length in bytes.
|
||||||
|
pub length: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable diagnostic code.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
|
||||||
|
pub struct DiagnosticCode(pub &'static str);
|
||||||
|
|
||||||
|
/// Context attached to a diagnostic.
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct DiagnosticContext {
|
||||||
|
/// Phase.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phase: Option<Phase>,
|
||||||
|
/// Redacted or logical path.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub path: Option<String>,
|
||||||
|
/// Archive entry name.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub archive_entry: Option<String>,
|
||||||
|
/// Object/prototype key.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub object_key: Option<String>,
|
||||||
|
/// Input span.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub span: Option<SourceSpan>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structured diagnostic with cause chain.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
pub struct Diagnostic {
|
||||||
|
/// Stable code.
|
||||||
|
pub code: DiagnosticCode,
|
||||||
|
/// Severity.
|
||||||
|
pub severity: Severity,
|
||||||
|
/// Human message.
|
||||||
|
pub message: String,
|
||||||
|
/// Context.
|
||||||
|
pub context: DiagnosticContext,
|
||||||
|
/// Causes.
|
||||||
|
pub causes: Vec<Diagnostic>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a diagnostic with default error severity.
|
||||||
|
#[must_use]
|
||||||
|
pub fn diagnostic(code: DiagnosticCode, message: impl Into<String>) -> Diagnostic {
|
||||||
|
Diagnostic {
|
||||||
|
code,
|
||||||
|
severity: Severity::Error,
|
||||||
|
message: message.into(),
|
||||||
|
context: DiagnosticContext::default(),
|
||||||
|
causes: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Diagnostic {
|
||||||
|
/// Returns a copy with severity changed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_severity(mut self, severity: Severity) -> Self {
|
||||||
|
self.severity = severity;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a copy with context changed.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_context(mut self, context: DiagnosticContext) -> Self {
|
||||||
|
self.context = context;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adds a cause.
|
||||||
|
pub fn push_cause(&mut self, cause: Diagnostic) {
|
||||||
|
self.causes.push(cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders a compact human-readable diagnostic.
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_human(diagnostic: &Diagnostic) -> String {
|
||||||
|
let mut out = format!(
|
||||||
|
"{:?} {}: {}",
|
||||||
|
diagnostic.severity, diagnostic.code.0, diagnostic.message
|
||||||
|
);
|
||||||
|
if let Some(path) = &diagnostic.context.path {
|
||||||
|
out.push_str(" [");
|
||||||
|
out.push_str(path);
|
||||||
|
out.push(']');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders deterministic JSON using the typed diagnostic schema.
|
||||||
|
#[must_use]
|
||||||
|
pub fn render_json(diagnostic: &Diagnostic) -> String {
|
||||||
|
match serde_json::to_string(diagnostic) {
|
||||||
|
Ok(json) => json,
|
||||||
|
Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_is_stable() {
|
||||||
|
let d = diagnostic(DiagnosticCode("S0-DIAG-001"), "keeps context").with_context(
|
||||||
|
DiagnosticContext {
|
||||||
|
phase: Some(Phase::Parse),
|
||||||
|
..DiagnosticContext::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
render_json(&d),
|
||||||
|
"{\"code\":\"S0-DIAG-001\",\"severity\":\"error\",\"message\":\"keeps context\",\"context\":{\"phase\":\"parse\"},\"causes\":[]}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diagnostic_chain_preserves_context() {
|
||||||
|
let mut root = diagnostic(DiagnosticCode("ROOT"), "root").with_context(DiagnosticContext {
|
||||||
|
phase: Some(Phase::Resolve),
|
||||||
|
path: Some("archives/material.lib".to_string()),
|
||||||
|
archive_entry: Some("MATERIAL.MAT0".to_string()),
|
||||||
|
object_key: Some("unit/tank".to_string()),
|
||||||
|
span: Some(SourceSpan {
|
||||||
|
offset: 12,
|
||||||
|
length: 4,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
root.push_cause(diagnostic(DiagnosticCode("CAUSE"), "cause").with_context(
|
||||||
|
DiagnosticContext {
|
||||||
|
phase: Some(Phase::Parse),
|
||||||
|
path: Some("archives/material.lib".to_string()),
|
||||||
|
span: Some(SourceSpan {
|
||||||
|
offset: 16,
|
||||||
|
length: 8,
|
||||||
|
}),
|
||||||
|
..DiagnosticContext::default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
let json = render_json(&root);
|
||||||
|
|
||||||
|
assert!(json.contains("\"code\":\"ROOT\""));
|
||||||
|
assert!(json.contains("\"phase\":\"resolve\""));
|
||||||
|
assert!(json.contains("\"path\":\"archives/material.lib\""));
|
||||||
|
assert!(json.contains("\"archive_entry\":\"MATERIAL.MAT0\""));
|
||||||
|
assert!(json.contains("\"object_key\":\"unit/tank\""));
|
||||||
|
assert!(json.contains("\"span\":{\"offset\":12,\"length\":4}"));
|
||||||
|
assert!(json.contains("\"code\":\"CAUSE\""));
|
||||||
|
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn json_escapes_all_control_characters() {
|
||||||
|
let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
|
||||||
|
let json = render_json(&value);
|
||||||
|
assert!(json.contains("\\u0000"));
|
||||||
|
assert!(json.contains("\\t"));
|
||||||
|
assert!(!json.contains('\t'));
|
||||||
|
assert!(!json.contains('\r'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-fx"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-inspection"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-msh = { path = "../fparkan-msh" }
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||||
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
|
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||||
|
fparkan-texm = { path = "../fparkan-texm" }
|
||||||
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
#![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_msh::{decode_msh, validate_msh};
|
||||||
|
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
|
||||||
|
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};
|
||||||
|
use fparkan_texm::decode_texm;
|
||||||
|
use fparkan_vfs::DirectoryVfs;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Archive inspection variants.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ArchiveInspection {
|
||||||
|
/// `NRes` inspection summary.
|
||||||
|
Nres {
|
||||||
|
/// Archive entry count.
|
||||||
|
entries: usize,
|
||||||
|
/// Lookup order validity.
|
||||||
|
lookup_order_valid: bool,
|
||||||
|
/// Entry samples (subject to request limit).
|
||||||
|
sample: Vec<NresEntrySummary>,
|
||||||
|
},
|
||||||
|
/// `RsLi` inspection summary.
|
||||||
|
Rsli {
|
||||||
|
/// Archive entry count.
|
||||||
|
entries: usize,
|
||||||
|
},
|
||||||
|
/// Unknown/unsupported archive magic.
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `NRes` entry summary.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct NresEntrySummary {
|
||||||
|
/// ASCII/legacy resource name.
|
||||||
|
pub name: String,
|
||||||
|
/// Entry type identifier.
|
||||||
|
pub type_id: u32,
|
||||||
|
/// Declared entry payload size.
|
||||||
|
pub data_size: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Model inspection payload.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct ModelInspection {
|
||||||
|
/// Terrain stream/document stream count.
|
||||||
|
pub streams: usize,
|
||||||
|
/// Node count.
|
||||||
|
pub nodes: usize,
|
||||||
|
/// Slot count.
|
||||||
|
pub slots: usize,
|
||||||
|
/// Position count.
|
||||||
|
pub positions: usize,
|
||||||
|
/// Index count.
|
||||||
|
pub indices: usize,
|
||||||
|
/// Batch count.
|
||||||
|
pub batches: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Supported land file kinds.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum LandFileKind {
|
||||||
|
/// `land.msh` payload.
|
||||||
|
LandMsh,
|
||||||
|
/// `land.map` payload.
|
||||||
|
LandMap,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspects a format archive.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a string error when the archive cannot be read or decoded.
|
||||||
|
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
|
||||||
|
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||||
|
inspect_archive_bytes(&bytes, sample_limit, Some(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspects archive bytes and returns a typed summary.
|
||||||
|
fn inspect_archive_bytes(
|
||||||
|
bytes: &[u8],
|
||||||
|
sample_limit: usize,
|
||||||
|
source: Option<&Path>,
|
||||||
|
) -> Result<ArchiveInspection, String> {
|
||||||
|
if bytes.starts_with(b"NRes") {
|
||||||
|
let document = decode_nres(
|
||||||
|
Arc::from(bytes.to_vec().into_boxed_slice()),
|
||||||
|
ReadProfile::Compatible,
|
||||||
|
)
|
||||||
|
.map_err(|err| 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| err.to_string())?;
|
||||||
|
Ok(ArchiveInspection::Rsli {
|
||||||
|
entries: document.entries().len(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
match source {
|
||||||
|
Some(path) => Err(format!("{}: unsupported archive magic", path.display())),
|
||||||
|
None => Err("unsupported archive magic".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspects a model through repository-backed resource lookup.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a string error when the resource cannot be resolved or parsed as a
|
||||||
|
/// valid model payload.
|
||||||
|
pub fn inspect_model_from_root(
|
||||||
|
root: &Path,
|
||||||
|
archive: &str,
|
||||||
|
resource: &str,
|
||||||
|
) -> Result<ModelInspection, String> {
|
||||||
|
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||||
|
let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| 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())?;
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspects a texture through repository-backed resource lookup.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a string error when the resource cannot be resolved or parsed as a
|
||||||
|
/// valid texture payload.
|
||||||
|
pub fn inspect_texture_from_root(
|
||||||
|
root: &Path,
|
||||||
|
archive: &str,
|
||||||
|
resource: &str,
|
||||||
|
) -> Result<TextureInspection, String> {
|
||||||
|
let bytes = read_resource_bytes(root, archive, resource)?;
|
||||||
|
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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inspects a terrain land file by path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns a string error when the file cannot be read or parsed as the
|
||||||
|
/// requested terrain payload kind.
|
||||||
|
pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> {
|
||||||
|
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
|
||||||
|
let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
|
||||||
|
.map_err(|err| err.to_string())?;
|
||||||
|
match kind {
|
||||||
|
LandFileKind::LandMsh => inspect_land_msh(&document),
|
||||||
|
LandFileKind::LandMap => inspect_land_map(&document),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
|
||||||
|
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
|
||||||
|
Ok(MapInspection {
|
||||||
|
streams: land_msh.streams.len(),
|
||||||
|
slots: land_msh.slots.slots_raw.len(),
|
||||||
|
positions: land_msh.positions.len(),
|
||||||
|
faces: land_msh.faces.len(),
|
||||||
|
areals: 0,
|
||||||
|
declared_areals: 0,
|
||||||
|
grid_width: 0,
|
||||||
|
grid_height: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
|
||||||
|
let land_map = decode_land_map(document).map_err(|err| err.to_string())?;
|
||||||
|
Ok(MapInspection {
|
||||||
|
streams: 0,
|
||||||
|
slots: 0,
|
||||||
|
positions: 0,
|
||||||
|
faces: 0,
|
||||||
|
areals: land_map.areals.len(),
|
||||||
|
declared_areals: land_map.areal_count,
|
||||||
|
grid_width: land_map.grid.cells_x,
|
||||||
|
grid_height: land_map.grid.cells_y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> {
|
||||||
|
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
|
||||||
|
let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?;
|
||||||
|
let resource_name = resource_name(name.as_bytes());
|
||||||
|
let archive_handle = repository
|
||||||
|
.open_archive(&archive_path)
|
||||||
|
.map_err(|err| format!("{err}"))?;
|
||||||
|
let Some(handle) = repository
|
||||||
|
.find(archive_handle, &resource_name)
|
||||||
|
.map_err(|err| format!("{err}"))?
|
||||||
|
else {
|
||||||
|
return Err(format!(
|
||||||
|
"resource not found: {archive}/{}",
|
||||||
|
String::from_utf8_lossy(name.as_bytes())
|
||||||
|
));
|
||||||
|
};
|
||||||
|
let bytes = repository.read(handle).map_err(|err| format!("{err}"))?;
|
||||||
|
Ok(Arc::from(bytes.into_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-material"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-mission-format"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-msh"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
fparkan-animation = { path = "../fparkan-animation" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-nres"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-path"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
#![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::fmt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// Original bytes.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct OriginalPathBytes(pub Vec<u8>);
|
||||||
|
|
||||||
|
impl OriginalPathBytes {
|
||||||
|
/// Returns the preserved byte image.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_bytes(&self) -> &[u8] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the preserved byte image as an owned vector.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_vec(self) -> Vec<u8> {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalized relative path.
|
||||||
|
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct NormalizedPath {
|
||||||
|
raw: Vec<u8>,
|
||||||
|
display: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NormalizedPath {
|
||||||
|
/// Returns string view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.display
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns normalized byte view.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_bytes(&self) -> &[u8] {
|
||||||
|
&self.raw
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an OS path owned path buffer.
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_path(&self) -> PathBuf {
|
||||||
|
as_os_path_from_bytes(&self.raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalized path paired with its original byte image.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct NormalizedPathWithOriginal {
|
||||||
|
normalized: NormalizedPath,
|
||||||
|
original: OriginalPathBytes,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NormalizedPathWithOriginal {
|
||||||
|
/// Returns normalized path.
|
||||||
|
#[must_use]
|
||||||
|
pub fn normalized(&self) -> &NormalizedPath {
|
||||||
|
&self.normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns original path bytes.
|
||||||
|
#[must_use]
|
||||||
|
pub fn original(&self) -> &OriginalPathBytes {
|
||||||
|
&self.original
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits into normalized and original path parts.
|
||||||
|
#[must_use]
|
||||||
|
pub fn into_parts(self) -> (NormalizedPath, OriginalPathBytes) {
|
||||||
|
(self.normalized, self.original)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ASCII lookup key.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct LookupKey(pub Vec<u8>);
|
||||||
|
|
||||||
|
/// Resource name bytes.
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||||
|
pub struct ResourceName(pub Vec<u8>);
|
||||||
|
|
||||||
|
/// Path policy.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PathPolicy {
|
||||||
|
/// Strict legacy relative resource path.
|
||||||
|
StrictLegacy,
|
||||||
|
/// Host compatible relative path.
|
||||||
|
HostCompatible,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Path error.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PathError {
|
||||||
|
/// Empty path.
|
||||||
|
Empty,
|
||||||
|
/// Embedded NUL.
|
||||||
|
EmbeddedNul,
|
||||||
|
/// Absolute path.
|
||||||
|
Absolute,
|
||||||
|
/// Parent traversal.
|
||||||
|
ParentTraversal,
|
||||||
|
/// Host path escape.
|
||||||
|
EscapesRoot,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for PathError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Empty => write!(f, "path is empty"),
|
||||||
|
Self::EmbeddedNul => write!(f, "path contains an embedded NUL byte"),
|
||||||
|
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
|
||||||
|
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
|
||||||
|
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PathError {}
|
||||||
|
|
||||||
|
/// Normalizes a relative path.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PathError`] when the input is empty, absolute, contains an
|
||||||
|
/// embedded NUL, attempts parent traversal, or has an invalid drive prefix.
|
||||||
|
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
|
||||||
|
if raw.is_empty() {
|
||||||
|
return Err(PathError::Empty);
|
||||||
|
}
|
||||||
|
if raw.contains(&0) {
|
||||||
|
return Err(PathError::EmbeddedNul);
|
||||||
|
}
|
||||||
|
if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) {
|
||||||
|
return Err(PathError::Absolute);
|
||||||
|
}
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') {
|
||||||
|
if part.is_empty() || part == b"." {
|
||||||
|
if policy == PathPolicy::StrictLegacy {
|
||||||
|
return Err(PathError::ParentTraversal);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if part == b".." {
|
||||||
|
return Err(PathError::ParentTraversal);
|
||||||
|
}
|
||||||
|
if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
|
||||||
|
return Err(PathError::Absolute);
|
||||||
|
}
|
||||||
|
parts.push(part);
|
||||||
|
}
|
||||||
|
if parts.is_empty() {
|
||||||
|
return Err(PathError::Empty);
|
||||||
|
}
|
||||||
|
let mut normalized = Vec::new();
|
||||||
|
for (index, part) in parts.iter().enumerate() {
|
||||||
|
if index > 0 {
|
||||||
|
normalized.push(b'/');
|
||||||
|
}
|
||||||
|
normalized.extend_from_slice(part);
|
||||||
|
}
|
||||||
|
let display = String::from_utf8_lossy(&normalized).into_owned();
|
||||||
|
Ok(NormalizedPath {
|
||||||
|
raw: normalized,
|
||||||
|
display,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalizes a relative path while preserving its original bytes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PathError`] under the same conditions as [`normalize_relative`].
|
||||||
|
pub fn normalize_relative_with_original(
|
||||||
|
raw: &[u8],
|
||||||
|
policy: PathPolicy,
|
||||||
|
) -> Result<NormalizedPathWithOriginal, PathError> {
|
||||||
|
let normalized = normalize_relative(raw, policy)?;
|
||||||
|
Ok(NormalizedPathWithOriginal {
|
||||||
|
normalized,
|
||||||
|
original: OriginalPathBytes(raw.to_vec()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_drive_prefix(bytes: &[u8]) -> bool {
|
||||||
|
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds an ASCII-only casefold lookup key.
|
||||||
|
#[must_use]
|
||||||
|
pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
|
||||||
|
LookupKey(raw.iter().map(u8::to_ascii_uppercase).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensures relative path does not escape.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
|
||||||
|
/// to address a parent directory.
|
||||||
|
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
|
||||||
|
if rel
|
||||||
|
.as_bytes()
|
||||||
|
.split(|byte| *byte == b'/')
|
||||||
|
.any(|part| part == b"..")
|
||||||
|
{
|
||||||
|
Err(PathError::ParentTraversal)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins normalized path under root.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PathError`] if the normalized path fails the escape check.
|
||||||
|
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
|
||||||
|
reject_escape(rel)?;
|
||||||
|
Ok(root.join(rel.as_path()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
|
||||||
|
use std::ffi::OsString;
|
||||||
|
use std::os::unix::ffi::OsStringExt;
|
||||||
|
|
||||||
|
PathBuf::from(OsString::from_vec(raw.to_vec()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
|
||||||
|
PathBuf::from(String::from_utf8_lossy(raw).into_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn normalizes_separators() {
|
||||||
|
let p = normalize_relative(b"DATA\\MAPS/INTRO/Land.msh", PathPolicy::StrictLegacy)
|
||||||
|
.expect("path");
|
||||||
|
assert_eq!(p.as_str(), "DATA/MAPS/INTRO/Land.msh");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_escape() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relative(b"DATA/../secret", PathPolicy::StrictLegacy),
|
||||||
|
Err(PathError::ParentTraversal)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_absolute_drive_and_nul_paths() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relative(b"/DATA/MAPS", PathPolicy::StrictLegacy),
|
||||||
|
Err(PathError::Absolute)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relative(b"C:\\DATA\\MAPS", PathPolicy::StrictLegacy),
|
||||||
|
Err(PathError::Absolute)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relative(b"DATA\0MAPS", PathPolicy::StrictLegacy),
|
||||||
|
Err(PathError::EmbeddedNul)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn path_error_display_is_actionable() {
|
||||||
|
assert_eq!(
|
||||||
|
PathError::ParentTraversal.to_string(),
|
||||||
|
"path attempts to traverse outside its root"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
PathError::EmbeddedNul.to_string(),
|
||||||
|
"path contains an embedded NUL byte"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strict_legacy_rejects_host_only_segments() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relative(b"./DATA/MAPS", PathPolicy::StrictLegacy),
|
||||||
|
Err(PathError::ParentTraversal)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relative(b"DATA//MAPS", PathPolicy::StrictLegacy),
|
||||||
|
Err(PathError::ParentTraversal)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_relative(b"DATA/stream:name", PathPolicy::StrictLegacy),
|
||||||
|
Err(PathError::Absolute)
|
||||||
|
);
|
||||||
|
|
||||||
|
let host = normalize_relative(b"./DATA//MAPS", PathPolicy::HostCompatible).expect("host");
|
||||||
|
assert_eq!(host.as_str(), "DATA/MAPS");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_under_keeps_normalized_path_below_root() {
|
||||||
|
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
|
||||||
|
.expect("relative path");
|
||||||
|
let joined = join_under(Path::new("/game"), &rel).expect("join");
|
||||||
|
|
||||||
|
assert_eq!(joined, PathBuf::from("/game/DATA/MAPS/Land.map"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ascii_casefold_does_not_unicode_fold() {
|
||||||
|
assert_eq!(ascii_lookup_key(b"AbZ\xD0"), LookupKey(b"ABZ\xD0".to_vec()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_ascii_original_bytes_remain_stable() {
|
||||||
|
let raw = "DATA/Тест.bin".as_bytes();
|
||||||
|
let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy)
|
||||||
|
.expect("path with non-ASCII UTF-8");
|
||||||
|
|
||||||
|
assert_eq!(path.normalized().as_str().as_bytes(), raw);
|
||||||
|
assert_eq!(path.original().as_bytes(), raw);
|
||||||
|
assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_non_utf8_legacy_bytes() {
|
||||||
|
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
|
||||||
|
.expect("raw legacy bytes");
|
||||||
|
|
||||||
|
assert_eq!(path.as_str(), "DATA/\u{FFFD}.bin");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn original_separators_and_raw_bytes_are_preserved() {
|
||||||
|
let raw = b"DATA\\Maps/Intro\\Land.msh";
|
||||||
|
let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy).expect("path");
|
||||||
|
|
||||||
|
assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh");
|
||||||
|
assert_eq!(path.original().as_bytes(), raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-platform"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
raw-window-handle = "0.6"
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
//! Platform ports for clocks, event sources and window descriptors.
|
||||||
|
|
||||||
|
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
|
||||||
|
|
||||||
|
/// Monotonic instant measured in milliseconds since process start.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct MonotonicInstant(pub u64);
|
||||||
|
|
||||||
|
/// Platform clock.
|
||||||
|
pub trait MonotonicClock {
|
||||||
|
/// Current instant.
|
||||||
|
fn now(&self) -> MonotonicInstant;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Platform event.
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum PlatformEvent {
|
||||||
|
/// Window/application requested to quit.
|
||||||
|
QuitRequested,
|
||||||
|
/// Window focus changed.
|
||||||
|
FocusChanged {
|
||||||
|
/// Whether the window is focused.
|
||||||
|
focused: bool,
|
||||||
|
},
|
||||||
|
/// Window resize or move to a new drawable size.
|
||||||
|
Resize {
|
||||||
|
/// Drawable width in physical pixels.
|
||||||
|
width: u32,
|
||||||
|
/// Drawable height in physical pixels.
|
||||||
|
height: u32,
|
||||||
|
},
|
||||||
|
/// Device pixel ratio changed.
|
||||||
|
DpiChanged {
|
||||||
|
/// Logical-to-physical scale factor.
|
||||||
|
scale: f64,
|
||||||
|
},
|
||||||
|
/// Window minimized/hidden.
|
||||||
|
Minimized {
|
||||||
|
/// Whether the window is minimized.
|
||||||
|
minimized: bool,
|
||||||
|
},
|
||||||
|
/// Window occlusion state changed.
|
||||||
|
Occluded {
|
||||||
|
/// Whether the window is occluded.
|
||||||
|
occluded: bool,
|
||||||
|
},
|
||||||
|
/// Window is being suspended.
|
||||||
|
Suspended,
|
||||||
|
/// Window resumed from suspend.
|
||||||
|
Resumed,
|
||||||
|
/// Keyboard/scancode input.
|
||||||
|
KeyboardInput {
|
||||||
|
/// Platform scancode.
|
||||||
|
scancode: u32,
|
||||||
|
/// Pressed state.
|
||||||
|
pressed: bool,
|
||||||
|
},
|
||||||
|
/// Mouse button input.
|
||||||
|
MouseInput {
|
||||||
|
/// Mouse button code.
|
||||||
|
button: u16,
|
||||||
|
/// Pressed state.
|
||||||
|
pressed: bool,
|
||||||
|
/// X position in window coordinates.
|
||||||
|
x: f64,
|
||||||
|
/// Y position in window coordinates.
|
||||||
|
y: f64,
|
||||||
|
},
|
||||||
|
/// Mouse cursor movement.
|
||||||
|
CursorMoved {
|
||||||
|
/// Cursor x.
|
||||||
|
x: f64,
|
||||||
|
/// Cursor y.
|
||||||
|
y: f64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Platform error with optional source detail.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum PlatformError {
|
||||||
|
/// Backend/backend-specific failure.
|
||||||
|
Backend {
|
||||||
|
/// Operation or subsystem.
|
||||||
|
context: &'static str,
|
||||||
|
/// Human-readable details.
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PlatformError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Backend { context, message } => {
|
||||||
|
write!(f, "{context}: {message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PlatformError {}
|
||||||
|
|
||||||
|
/// Event source contract for polling platform events.
|
||||||
|
pub trait EventSource {
|
||||||
|
/// Polls events.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`PlatformError`] when the backend cannot collect events.
|
||||||
|
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical window size.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PhysicalSize {
|
||||||
|
/// Width.
|
||||||
|
pub width: u32,
|
||||||
|
/// Height.
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window identity as a stable opaque handle token.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
|
||||||
|
pub struct WindowHandle {
|
||||||
|
/// Opaque integer token.
|
||||||
|
pub id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Native raw window/display handles for render surface creation.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct NativeWindowHandles {
|
||||||
|
/// Raw display handle.
|
||||||
|
pub display: RawDisplayHandle,
|
||||||
|
/// Raw window handle.
|
||||||
|
pub window: RawWindowHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window presentation and lifecycle port.
|
||||||
|
///
|
||||||
|
/// Presentation is not owned by the window abstraction. Render adapters
|
||||||
|
/// own swapchain and present lifecycle.
|
||||||
|
pub trait WindowPort {
|
||||||
|
/// Current drawable size.
|
||||||
|
fn drawable_size(&self) -> PhysicalSize;
|
||||||
|
/// DPI scale for this window.
|
||||||
|
fn dpi_scale(&self) -> f64;
|
||||||
|
/// Whether the window is focused.
|
||||||
|
fn has_focus(&self) -> bool;
|
||||||
|
/// Whether the window is minimized.
|
||||||
|
fn is_minimized(&self) -> bool;
|
||||||
|
/// Whether the window is occluded.
|
||||||
|
fn is_occluded(&self) -> bool;
|
||||||
|
/// Opaque window identity.
|
||||||
|
fn handle(&self) -> WindowHandle;
|
||||||
|
/// Raw native handles for render surface creation, when backed by a native window.
|
||||||
|
fn native_handles(&self) -> Option<NativeWindowHandles> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render backend request contract.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct RenderRequest {
|
||||||
|
/// Preferred color-space profile.
|
||||||
|
pub color_space: ColorSpace,
|
||||||
|
/// Preferred presentation mode.
|
||||||
|
pub presentation: PresentationMode,
|
||||||
|
/// Requested depth/stencil format.
|
||||||
|
pub depth: DepthStencilSupport,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Color-space profile.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ColorSpace {
|
||||||
|
/// sRGB nonlinear.
|
||||||
|
Srgb,
|
||||||
|
/// Linear color-space.
|
||||||
|
Linear,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Presentation mode.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PresentationMode {
|
||||||
|
/// `VSync`.
|
||||||
|
Fifo,
|
||||||
|
/// No `VSync`.
|
||||||
|
Immediate,
|
||||||
|
/// Triple-buffer mailbox fallback.
|
||||||
|
Mailbox,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth/stencil support profile requested by the composition root.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct DepthStencilSupport {
|
||||||
|
/// Depth bits.
|
||||||
|
pub depth_bits: u8,
|
||||||
|
/// Stencil bits.
|
||||||
|
pub stencil_bits: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderRequest {
|
||||||
|
/// Returns a conservative default request.
|
||||||
|
#[must_use]
|
||||||
|
pub const fn conservative() -> Self {
|
||||||
|
Self {
|
||||||
|
color_space: ColorSpace::Srgb,
|
||||||
|
presentation: PresentationMode::Fifo,
|
||||||
|
depth: DepthStencilSupport {
|
||||||
|
depth_bits: 24,
|
||||||
|
stencil_bits: 8,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-prototype"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
encoding_rs = "0.8"
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
fparkan-resource = { path = "../fparkan-resource" }
|
||||||
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-render"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-world = { path = "../fparkan-world" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-resource"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
fparkan-rsli = { path = "../fparkan-rsli" }
|
||||||
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-rsli"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-runtime"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
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-resource = { path = "../fparkan-resource" }
|
||||||
|
fparkan-vfs = { path = "../fparkan-vfs" }
|
||||||
|
fparkan-world = { path = "../fparkan-world" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-terrain-format"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-terrain"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-test-support"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-render = { path = "../fparkan-render" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
//! Dev-only synthetic builders and fake ports.
|
||||||
|
|
||||||
|
use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
|
||||||
|
|
||||||
|
/// Fake clock.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct FakeClock {
|
||||||
|
/// Current tick.
|
||||||
|
pub tick: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recording backend.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct RecordingRenderBackend {
|
||||||
|
/// Recorded command lists.
|
||||||
|
pub captures: Vec<RenderCommandList>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderBackend for RecordingRenderBackend {
|
||||||
|
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
|
||||||
|
self.captures.push(commands.clone());
|
||||||
|
Ok(FrameOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-texm"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
fparkan-nres = { path = "../fparkan-nres" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-vfs"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
fparkan-path = { path = "../fparkan-path" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
#![forbid(unsafe_code)]
|
||||||
|
#![cfg_attr(
|
||||||
|
test,
|
||||||
|
allow(
|
||||||
|
clippy::cast_possible_truncation,
|
||||||
|
clippy::cast_possible_wrap,
|
||||||
|
clippy::cast_precision_loss,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::float_cmp,
|
||||||
|
clippy::identity_op,
|
||||||
|
clippy::too_many_lines,
|
||||||
|
clippy::uninlined_format_args,
|
||||||
|
clippy::map_unwrap_or,
|
||||||
|
clippy::needless_raw_string_hashes,
|
||||||
|
clippy::semicolon_if_nothing_returned,
|
||||||
|
clippy::type_complexity,
|
||||||
|
clippy::panic,
|
||||||
|
clippy::unwrap_used
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
//! Virtual filesystem ports for resource loading.
|
||||||
|
|
||||||
|
use fparkan_binary::{sha256, Sha256Digest};
|
||||||
|
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
#[cfg(unix)]
|
||||||
|
use std::os::unix::fs::MetadataExt;
|
||||||
|
#[cfg(windows)]
|
||||||
|
use std::os::windows::fs::MetadataExt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::SystemTime;
|
||||||
|
|
||||||
|
/// VFS metadata.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct VfsMetadata {
|
||||||
|
/// Byte length.
|
||||||
|
pub len: u64,
|
||||||
|
/// SHA-256 content fingerprint for cache invalidation.
|
||||||
|
pub fingerprint: Sha256Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VFS entry.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct VfsEntry {
|
||||||
|
/// Path.
|
||||||
|
pub path: NormalizedPath,
|
||||||
|
/// Metadata.
|
||||||
|
pub metadata: VfsMetadata,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// VFS error.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum VfsError {
|
||||||
|
/// Missing entry.
|
||||||
|
NotFound(String),
|
||||||
|
/// Ambiguous host path.
|
||||||
|
Ambiguous(String),
|
||||||
|
/// I/O error.
|
||||||
|
Io(std::io::Error),
|
||||||
|
/// Invalid path.
|
||||||
|
Path,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for VfsError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::NotFound(path) => write!(f, "not found: {path}"),
|
||||||
|
Self::Ambiguous(path) => write!(f, "ambiguous host path: {path}"),
|
||||||
|
Self::Io(err) => write!(f, "{err}"),
|
||||||
|
Self::Path => write!(f, "invalid path"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for VfsError {}
|
||||||
|
|
||||||
|
/// Resource VFS.
|
||||||
|
pub trait Vfs: Send + Sync {
|
||||||
|
/// Reads metadata.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`VfsError`] when the path is invalid, missing, or cannot be
|
||||||
|
/// inspected by the backing store.
|
||||||
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError>;
|
||||||
|
/// Reads bytes.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`VfsError`] when the path is invalid, missing, or cannot be
|
||||||
|
/// read by the backing store.
|
||||||
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError>;
|
||||||
|
/// Lists entries below prefix.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns [`VfsError`] when the prefix is invalid, missing, or cannot be
|
||||||
|
/// traversed by the backing store.
|
||||||
|
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host directory VFS.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct DirectoryVfs {
|
||||||
|
root: PathBuf,
|
||||||
|
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DirectoryVfs {
|
||||||
|
/// Creates a directory VFS.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||||
|
Self {
|
||||||
|
root: root.as_ref().to_path_buf(),
|
||||||
|
fingerprint_cache: Arc::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
|
||||||
|
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
|
||||||
|
resolve_casefolded(&self.root, path.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_from_host_file(&self, path: &Path) -> Result<VfsMetadata, VfsError> {
|
||||||
|
let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?;
|
||||||
|
metadata_from_host_file_with_cache(path, &metadata, &self.fingerprint_cache)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
struct CachedHostFingerprint {
|
||||||
|
len: u64,
|
||||||
|
modified: Option<SystemTime>,
|
||||||
|
identity: Option<u64>,
|
||||||
|
fingerprint: Sha256Digest,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vfs for DirectoryVfs {
|
||||||
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||||
|
self.metadata_from_host_file(&self.host_path(path)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||||
|
let host = self.host_path(path)?;
|
||||||
|
let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
|
||||||
|
if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() {
|
||||||
|
return Err(VfsError::Path);
|
||||||
|
}
|
||||||
|
let pre_identity = file_identity(&pre_metadata);
|
||||||
|
let pre_len = pre_metadata.len();
|
||||||
|
let pre_modified = pre_metadata.modified().ok();
|
||||||
|
let bytes = fs::read(&host).map_err(VfsError::Io)?;
|
||||||
|
let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
|
||||||
|
if post_metadata.file_type().is_symlink()
|
||||||
|
|| !post_metadata.is_file()
|
||||||
|
|| post_metadata.len() != pre_len
|
||||||
|
|| post_metadata.modified().ok() != pre_modified
|
||||||
|
|| file_identity(&post_metadata) != pre_identity
|
||||||
|
{
|
||||||
|
return Err(VfsError::Path);
|
||||||
|
}
|
||||||
|
Ok(Arc::from(bytes.into_boxed_slice()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||||
|
let base = self.host_path(prefix)?;
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
if base.is_file() {
|
||||||
|
let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
|
||||||
|
entries.push(VfsEntry {
|
||||||
|
path: prefix.clone(),
|
||||||
|
metadata: metadata_from_host_file_with_cache(
|
||||||
|
&base,
|
||||||
|
&metadata,
|
||||||
|
&self.fingerprint_cache,
|
||||||
|
)?,
|
||||||
|
});
|
||||||
|
return Ok(entries);
|
||||||
|
}
|
||||||
|
list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?;
|
||||||
|
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> {
|
||||||
|
let mut current = root.to_path_buf();
|
||||||
|
for segment in normalized.split('/') {
|
||||||
|
let read_dir = fs::read_dir(¤t).map_err(VfsError::Io)?;
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
for entry in read_dir {
|
||||||
|
let entry = entry.map_err(VfsError::Io)?;
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if name.eq_ignore_ascii_case(segment) {
|
||||||
|
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
|
||||||
|
return Err(VfsError::Path);
|
||||||
|
}
|
||||||
|
matches.push(entry.path());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = select_casefolded_match(normalized, ¤t, segment, matches)?;
|
||||||
|
}
|
||||||
|
Ok(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_casefolded_match(
|
||||||
|
normalized: &str,
|
||||||
|
current: &Path,
|
||||||
|
segment: &str,
|
||||||
|
mut matches: Vec<PathBuf>,
|
||||||
|
) -> Result<PathBuf, VfsError> {
|
||||||
|
matches.sort();
|
||||||
|
match matches.len() {
|
||||||
|
0 => Err(VfsError::NotFound(normalized.to_string())),
|
||||||
|
1 => Ok(matches.remove(0)),
|
||||||
|
_ => Err(VfsError::Ambiguous(format!(
|
||||||
|
"{}/{}",
|
||||||
|
current.display(),
|
||||||
|
segment
|
||||||
|
))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_recursive(
|
||||||
|
root: &Path,
|
||||||
|
dir: &Path,
|
||||||
|
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||||
|
out: &mut Vec<VfsEntry>,
|
||||||
|
) -> Result<(), VfsError> {
|
||||||
|
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
|
||||||
|
let mut children = Vec::new();
|
||||||
|
for entry in read_dir {
|
||||||
|
let entry = entry.map_err(VfsError::Io)?;
|
||||||
|
children.push(entry.path());
|
||||||
|
}
|
||||||
|
children.sort();
|
||||||
|
for child in children {
|
||||||
|
let metadata = fs::symlink_metadata(&child).map_err(VfsError::Io)?;
|
||||||
|
if metadata.file_type().is_symlink() {
|
||||||
|
return Err(VfsError::Path);
|
||||||
|
}
|
||||||
|
if metadata.is_dir() {
|
||||||
|
list_recursive(root, &child, fingerprint_cache, out)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !metadata.is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?;
|
||||||
|
let rel_text = rel.to_str().ok_or(VfsError::Path)?;
|
||||||
|
let path = fparkan_path::normalize_relative(
|
||||||
|
rel_text.as_bytes(),
|
||||||
|
fparkan_path::PathPolicy::HostCompatible,
|
||||||
|
)
|
||||||
|
.map_err(|_| VfsError::Path)?;
|
||||||
|
out.push(VfsEntry {
|
||||||
|
path,
|
||||||
|
metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metadata_from_host_file_with_cache(
|
||||||
|
path: &Path,
|
||||||
|
metadata: &fs::Metadata,
|
||||||
|
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
|
||||||
|
) -> Result<VfsMetadata, VfsError> {
|
||||||
|
if !metadata.is_file() {
|
||||||
|
return Err(VfsError::Path);
|
||||||
|
}
|
||||||
|
let len = metadata.len();
|
||||||
|
let modified = metadata.modified().ok();
|
||||||
|
if let Some(cached) = fingerprint_cache
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| VfsError::Path)?
|
||||||
|
.get(path)
|
||||||
|
.cloned()
|
||||||
|
.filter(|cached| {
|
||||||
|
cached.len == len
|
||||||
|
&& cached.modified == modified
|
||||||
|
&& cached.identity == file_identity(metadata)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Ok(VfsMetadata {
|
||||||
|
len,
|
||||||
|
fingerprint: cached.fingerprint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = fs::read(path).map_err(VfsError::Io)?;
|
||||||
|
let fingerprint = sha256(&bytes);
|
||||||
|
fingerprint_cache
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| VfsError::Path)?
|
||||||
|
.insert(
|
||||||
|
path.to_path_buf(),
|
||||||
|
CachedHostFingerprint {
|
||||||
|
len,
|
||||||
|
modified,
|
||||||
|
identity: file_identity(metadata),
|
||||||
|
fingerprint,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(VfsMetadata { len, fingerprint })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-memory VFS.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct MemoryVfs {
|
||||||
|
files: BTreeMap<Vec<u8>, Arc<[u8]>>,
|
||||||
|
lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemoryVfs {
|
||||||
|
/// Inserts a file.
|
||||||
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
|
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
|
||||||
|
let path = path.as_bytes().to_vec();
|
||||||
|
self.files.insert(path, bytes);
|
||||||
|
self.rebuild_lookup();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rebuild_lookup(&mut self) {
|
||||||
|
self.lookup.clear();
|
||||||
|
for path in self.files.keys() {
|
||||||
|
self.lookup
|
||||||
|
.entry(ascii_lookup_key(path).0)
|
||||||
|
.or_default()
|
||||||
|
.push(path.clone());
|
||||||
|
}
|
||||||
|
for paths in self.lookup.values_mut() {
|
||||||
|
paths.sort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> {
|
||||||
|
let key = ascii_lookup_key(path.as_bytes()).0;
|
||||||
|
let matches = self
|
||||||
|
.lookup
|
||||||
|
.get(&key)
|
||||||
|
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||||
|
match matches.as_slice() {
|
||||||
|
[single] => Ok(single.as_slice()),
|
||||||
|
[] => Err(VfsError::NotFound(path.as_str().to_string())),
|
||||||
|
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
|
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||||
|
Some(metadata.dev().rotate_left(32) ^ metadata.ino())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
#[allow(clippy::unnecessary_wraps)]
|
||||||
|
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
|
||||||
|
Some(
|
||||||
|
(metadata.volume_serial_number() as u64).rotate_left(40)
|
||||||
|
^ ((metadata.file_index_high() as u64) << 32)
|
||||||
|
^ metadata.file_index_low() as u64,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(unix, windows)))]
|
||||||
|
fn file_identity(_metadata: &fs::Metadata) -> Option<u64> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vfs for MemoryVfs {
|
||||||
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||||
|
let resolved = self.resolve_path(path)?;
|
||||||
|
let bytes = self
|
||||||
|
.files
|
||||||
|
.get(resolved)
|
||||||
|
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
|
||||||
|
Ok(VfsMetadata {
|
||||||
|
len: bytes.len() as u64,
|
||||||
|
fingerprint: sha256(bytes),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||||
|
let resolved = self.resolve_path(path)?;
|
||||||
|
self.files
|
||||||
|
.get(resolved)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (path, bytes) in &self.files {
|
||||||
|
if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
|
||||||
|
let normalized =
|
||||||
|
fparkan_path::normalize_relative(path, fparkan_path::PathPolicy::StrictLegacy)
|
||||||
|
.map_err(|_| VfsError::Path)?;
|
||||||
|
out.push(VfsEntry {
|
||||||
|
path: normalized,
|
||||||
|
metadata: VfsMetadata {
|
||||||
|
len: bytes.len() as u64,
|
||||||
|
fingerprint: sha256(bytes),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
||||||
|
if haystack.len() < needle.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if haystack.len() == needle.len() {
|
||||||
|
return haystack
|
||||||
|
.iter()
|
||||||
|
.zip(needle.iter())
|
||||||
|
.all(|(left, right)| left.eq_ignore_ascii_case(right));
|
||||||
|
}
|
||||||
|
if haystack[needle.len()] != b'/' {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
haystack[..needle.len()]
|
||||||
|
.iter()
|
||||||
|
.zip(needle.iter())
|
||||||
|
.all(|(left, right)| left.eq_ignore_ascii_case(right))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Layered VFS with deterministic first-layer precedence.
|
||||||
|
#[derive(Clone, Default)]
|
||||||
|
pub struct OverlayVfs {
|
||||||
|
layers: Vec<Arc<dyn Vfs>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for OverlayVfs {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("OverlayVfs")
|
||||||
|
.field("layers", &self.layers.len())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OverlayVfs {
|
||||||
|
/// Creates an empty overlay.
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates an overlay from ordered layers.
|
||||||
|
#[must_use]
|
||||||
|
pub fn from_layers(layers: Vec<Arc<dyn Vfs>>) -> Self {
|
||||||
|
Self { layers }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Appends a lower-priority layer.
|
||||||
|
pub fn push_layer(&mut self, layer: Arc<dyn Vfs>) {
|
||||||
|
self.layers.push(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vfs for OverlayVfs {
|
||||||
|
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
|
||||||
|
for layer in &self.layers {
|
||||||
|
match layer.metadata(path) {
|
||||||
|
Ok(metadata) => return Ok(metadata),
|
||||||
|
Err(VfsError::NotFound(_)) => {}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(VfsError::NotFound(path.as_str().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
|
||||||
|
for layer in &self.layers {
|
||||||
|
match layer.read(path) {
|
||||||
|
Ok(bytes) => return Ok(bytes),
|
||||||
|
Err(VfsError::NotFound(_)) => {}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(VfsError::NotFound(path.as_str().to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
|
||||||
|
let mut by_key = BTreeMap::new();
|
||||||
|
for layer in &self.layers {
|
||||||
|
match layer.list(prefix) {
|
||||||
|
Ok(entries) => {
|
||||||
|
for entry in entries {
|
||||||
|
let key = entry.path.as_str().to_ascii_uppercase();
|
||||||
|
by_key.entry(key).or_insert(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(VfsError::NotFound(_)) => {}
|
||||||
|
Err(err) => return Err(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut entries: Vec<_> = by_key.into_values().collect();
|
||||||
|
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use fparkan_path::{normalize_relative, PathPolicy};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_vfs_resolves_ascii_casefolded_segments() {
|
||||||
|
let root = unique_test_dir("casefold");
|
||||||
|
let dir = root.join("data").join("MAPS").join("Tut_1");
|
||||||
|
std::fs::create_dir_all(&dir).expect("mkdir");
|
||||||
|
std::fs::write(dir.join("Land.msh"), b"mesh").expect("write");
|
||||||
|
|
||||||
|
let vfs = DirectoryVfs::new(&root);
|
||||||
|
let path = normalize_relative(b"DATA/maps/tut_1/land.MSH", PathPolicy::StrictLegacy)
|
||||||
|
.expect("path");
|
||||||
|
assert_eq!(vfs.read(&path).expect("read").as_ref(), b"mesh");
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_vfs_reports_casefold_ambiguity_even_for_exact_host_path() {
|
||||||
|
let root = unique_test_dir("casefold-ambiguous");
|
||||||
|
std::fs::create_dir_all(root.join("Data")).expect("mkdir first");
|
||||||
|
std::fs::create_dir_all(root.join("data")).expect("mkdir second");
|
||||||
|
std::fs::write(root.join("Data").join("File.bin"), b"first").expect("write first");
|
||||||
|
std::fs::write(root.join("data").join("File.bin"), b"second").expect("write second");
|
||||||
|
let collision_count = std::fs::read_dir(&root)
|
||||||
|
.expect("read root")
|
||||||
|
.flatten()
|
||||||
|
.filter(|entry| {
|
||||||
|
entry
|
||||||
|
.file_name()
|
||||||
|
.to_str()
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case("data"))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
if collision_count < 2 {
|
||||||
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let vfs = DirectoryVfs::new(&root);
|
||||||
|
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
|
||||||
|
assert!(matches!(vfs.read(&path), Err(VfsError::Ambiguous(_))));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_vfs_lists_files_below_prefix() {
|
||||||
|
let root = unique_test_dir("list");
|
||||||
|
std::fs::create_dir_all(root.join("DATA").join("MAPS")).expect("mkdir");
|
||||||
|
std::fs::write(root.join("DATA").join("MAPS").join("Land.map"), b"map").expect("write");
|
||||||
|
std::fs::write(root.join("BuildDat.lst"), b"build").expect("write");
|
||||||
|
|
||||||
|
let vfs = DirectoryVfs::new(&root);
|
||||||
|
let prefix = normalize_relative(b"data", PathPolicy::StrictLegacy).expect("prefix");
|
||||||
|
let entries = vfs.list(&prefix).expect("list");
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert!(entries[0]
|
||||||
|
.path
|
||||||
|
.as_str()
|
||||||
|
.eq_ignore_ascii_case("DATA/MAPS/Land.map"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_vfs_list_prefix_is_boundary_safe() {
|
||||||
|
let mut vfs = MemoryVfs::default();
|
||||||
|
let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
let sibling =
|
||||||
|
normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
vfs.insert(exact.clone(), Arc::from(b"exact".as_slice()));
|
||||||
|
vfs.insert(sibling, Arc::from(b"sibling".as_slice()));
|
||||||
|
|
||||||
|
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||||
|
let entries = vfs.list(&prefix).expect("list");
|
||||||
|
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(entries[0].path.as_str(), exact.as_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn directory_vfs_fingerprint_changes_for_same_length_content() {
|
||||||
|
let root = unique_test_dir("content-fingerprint");
|
||||||
|
std::fs::create_dir_all(root.join("DATA")).expect("mkdir");
|
||||||
|
std::fs::write(root.join("DATA").join("File.bin"), b"before").expect("write before");
|
||||||
|
|
||||||
|
let vfs = DirectoryVfs::new(&root);
|
||||||
|
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
let before = vfs.metadata(&path).expect("before metadata");
|
||||||
|
std::fs::write(root.join("DATA").join("File.bin"), b"after!").expect("write after");
|
||||||
|
let after = vfs.metadata(&path).expect("after metadata");
|
||||||
|
|
||||||
|
assert_eq!(before.len, after.len);
|
||||||
|
assert_ne!(before.fingerprint, after.fingerprint);
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).expect("cleanup");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn directory_vfs_rejects_symlink_escape() {
|
||||||
|
let root = unique_test_dir("symlink-escape");
|
||||||
|
let outside = unique_test_dir("symlink-outside");
|
||||||
|
std::fs::create_dir_all(&root).expect("mkdir root");
|
||||||
|
std::fs::create_dir_all(&outside).expect("mkdir outside");
|
||||||
|
std::fs::write(outside.join("secret.bin"), b"secret").expect("write outside");
|
||||||
|
std::os::unix::fs::symlink(&outside, root.join("DATA")).expect("symlink");
|
||||||
|
|
||||||
|
let vfs = DirectoryVfs::new(&root);
|
||||||
|
let path = normalize_relative(b"DATA/secret.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||||
|
|
||||||
|
assert!(matches!(vfs.read(&path), Err(VfsError::Path)));
|
||||||
|
assert!(matches!(vfs.list(&prefix), Err(VfsError::Path)));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(root).expect("cleanup root");
|
||||||
|
std::fs::remove_dir_all(outside).expect("cleanup outside");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn casefold_selector_reports_ambiguous_segments() {
|
||||||
|
let err = select_casefolded_match(
|
||||||
|
"data/file.bin",
|
||||||
|
Path::new("/game"),
|
||||||
|
"data",
|
||||||
|
vec![PathBuf::from("/game/Data"), PathBuf::from("/game/DATA")],
|
||||||
|
)
|
||||||
|
.expect_err("ambiguous path");
|
||||||
|
|
||||||
|
assert!(matches!(err, VfsError::Ambiguous(_)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_vfs_uses_ascii_casefold_lookup() {
|
||||||
|
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
let mut vfs = MemoryVfs::default();
|
||||||
|
vfs.insert(path.clone(), Arc::from(b"payload".as_slice()));
|
||||||
|
|
||||||
|
assert_eq!(vfs.metadata(&path).expect("metadata").len, 7);
|
||||||
|
assert_eq!(vfs.read(&path).expect("read").as_ref(), b"payload");
|
||||||
|
|
||||||
|
let other_case =
|
||||||
|
normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
assert_eq!(
|
||||||
|
vfs.read(&other_case).expect("casefold read").as_ref(),
|
||||||
|
b"payload"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_vfs_reports_casefold_ambiguity() {
|
||||||
|
let first = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("first");
|
||||||
|
let second =
|
||||||
|
normalize_relative(b"DATA/file.BIN", PathPolicy::StrictLegacy).expect("second");
|
||||||
|
let query = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("query");
|
||||||
|
let mut vfs = MemoryVfs::default();
|
||||||
|
vfs.insert(first, Arc::from(b"first".as_slice()));
|
||||||
|
vfs.insert(second, Arc::from(b"second".as_slice()));
|
||||||
|
|
||||||
|
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn memory_vfs_distinguishes_non_utf8_path_bytes() {
|
||||||
|
let mut vfs = MemoryVfs::default();
|
||||||
|
let ascii =
|
||||||
|
normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible).expect("ascii path");
|
||||||
|
let binary =
|
||||||
|
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary path");
|
||||||
|
vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice()));
|
||||||
|
vfs.insert(binary.clone(), Arc::from(b"binary".as_slice()));
|
||||||
|
|
||||||
|
let binary_query =
|
||||||
|
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary query");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
vfs.read(&binary_query).expect("read binary").as_ref(),
|
||||||
|
b"binary"
|
||||||
|
);
|
||||||
|
assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overlay_vfs_uses_first_matching_layer() {
|
||||||
|
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
|
||||||
|
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
|
||||||
|
let mut high = MemoryVfs::default();
|
||||||
|
let mut low = MemoryVfs::default();
|
||||||
|
high.insert(path.clone(), Arc::from(b"high".as_slice()));
|
||||||
|
low.insert(path.clone(), Arc::from(b"low".as_slice()));
|
||||||
|
|
||||||
|
let overlay = OverlayVfs::from_layers(vec![Arc::new(high), Arc::new(low)]);
|
||||||
|
|
||||||
|
assert_eq!(overlay.read(&path).expect("read").as_ref(), b"high");
|
||||||
|
let entries = overlay.list(&prefix).expect("list");
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(entries[0].metadata.len, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unique_test_dir(name: &str) -> PathBuf {
|
||||||
|
let mut path = std::env::temp_dir();
|
||||||
|
path.push(format!("fparkan-vfs-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&path);
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "fparkan-world"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
fparkan-binary = { path = "../fparkan-binary" }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,200 @@
|
|||||||
|
# Глоссарий
|
||||||
|
|
||||||
|
Глоссарий объясняет термины в том смысле, в котором они используются в этой
|
||||||
|
книге. Короткое определение не заменяет профильную главу: практический контракт
|
||||||
|
понятия раскрывается в соответствующем томе или справочной странице.
|
||||||
|
|
||||||
|
## Бинарные файлы и ABI
|
||||||
|
|
||||||
|
**PE (Portable Executable)** -- формат исполняемых файлов Windows: EXE и DLL.
|
||||||
|
Он содержит заголовки, секции, таблицы импортов и экспортов, relocations и
|
||||||
|
адрес точки входа.
|
||||||
|
|
||||||
|
**Image base** -- предпочтительный адрес начала загруженного PE-образа.
|
||||||
|
**VA** -- виртуальный адрес в процессе. **RVA** -- адрес относительно image
|
||||||
|
base.
|
||||||
|
|
||||||
|
**Import** -- внешняя функция или переменная, которую модуль получает из другой
|
||||||
|
DLL. **Export** -- символ, предоставляемый другим модулям. Имя, ordinal и
|
||||||
|
calling convention вместе образуют часть binary contract.
|
||||||
|
|
||||||
|
**ABI** -- соглашение о двоичном взаимодействии: размещение аргументов, возврат
|
||||||
|
значений, очистка stack, layout структур, порядок virtual methods и правила
|
||||||
|
владения.
|
||||||
|
|
||||||
|
**Calling convention** -- часть ABI, определяющая передачу аргументов и очистку
|
||||||
|
stack. Для исследованного 32-bit code важны `__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** -- требование начинать данные на offset, кратном заданному числу.
|
||||||
|
|
||||||
|
**Little-endian** -- порядок, в котором младший byte многобайтного числа
|
||||||
|
расположен первым. Основные числовые поля форматов Iron3D используют этот
|
||||||
|
порядок.
|
||||||
|
|
||||||
|
**Fixed-size string** -- поле заранее известной длины. Полезная строка
|
||||||
|
заканчивается первым NUL, но оставшиеся bytes могут содержать служебный хвост и
|
||||||
|
должны сохраняться.
|
||||||
|
|
||||||
|
**Opaque field** -- поле с доказанными offset и size, но не установленным
|
||||||
|
предметным смыслом. Его безопасно читать и копировать, но нельзя очищать или
|
||||||
|
переосмысливать без эксперимента.
|
||||||
|
|
||||||
|
**Invariant** -- условие, которое обязано выполняться: range лежит внутри
|
||||||
|
payload, индекс указывает на существующий элемент, count соответствует размеру
|
||||||
|
секции.
|
||||||
|
|
||||||
|
**Strict reader** отклоняет любое нарушение контракта. **Compatibility reader**
|
||||||
|
дополнительно воспроизводит только известные особенности оригинала.
|
||||||
|
|
||||||
|
**Fallback** -- явно предписанный запасной путь, например material `DEFAULT`,
|
||||||
|
затем entry 0. **Heuristic** -- догадка по похожим данным; она не должна
|
||||||
|
незаметно заменять доказанный fallback.
|
||||||
|
|
||||||
|
**Roundtrip** -- последовательность decode -> encode. **Byte-identical
|
||||||
|
roundtrip** создаёт файл, полностью совпадающий с исходным. **Lossless editor**
|
||||||
|
может изменить известное поле, сохранив все остальные bytes и порядок записей.
|
||||||
|
|
||||||
|
## Ресурсы
|
||||||
|
|
||||||
|
**NRes** -- основной контейнер ресурсов с каталогом в конце файла.
|
||||||
|
|
||||||
|
**RsLi** -- библиотечный архив с каталогом в начале файла и несколькими методами
|
||||||
|
упаковки payload.
|
||||||
|
|
||||||
|
**TMA** -- mission data: paths, clans, placed objects, properties, land path и
|
||||||
|
extras.
|
||||||
|
|
||||||
|
**MSH** -- модель Iron3D, представленная как NRes с entries для geometry,
|
||||||
|
nodes, slots, batches, animation и auxiliary streams.
|
||||||
|
|
||||||
|
**WEAR** -- таблица внешнего вида модели, переводящая material index в MAT0
|
||||||
|
name и lightmap slots.
|
||||||
|
|
||||||
|
**MAT0** -- материал: phases, parameters, animation blocks и texture references.
|
||||||
|
|
||||||
|
**Texm** -- texture payload с header, palette, mip chain и optional Page atlas.
|
||||||
|
|
||||||
|
**FXID** -- ресурс эффектов: команды, references, lifetime, random/time modes и
|
||||||
|
runtime instances.
|
||||||
|
|
||||||
|
## Игровой runtime
|
||||||
|
|
||||||
|
**Engine** -- программная среда, которая загружает данные, ведёт время,
|
||||||
|
исполняет мир и формирует изображение/звук. **Game** -- правила, миссии и
|
||||||
|
content поверх engine services.
|
||||||
|
|
||||||
|
**World** -- долгоживущее состояние миссии: objects, terrain, время, кланы и
|
||||||
|
managers. **Scene** -- представление части мира для конкретной обработки,
|
||||||
|
обычно текущей камеры.
|
||||||
|
|
||||||
|
**Game object** -- сущность с идентичностью, transform, properties и lifecycle.
|
||||||
|
**Component/controller** -- специализированная часть поведения: animation,
|
||||||
|
physics, AI или rendering representation.
|
||||||
|
|
||||||
|
**Simulation** отвечает за изменение мира. **Tick** -- один расчётный шаг.
|
||||||
|
**Frame** -- одно подготовленное изображение. Число ticks и frames за единицу
|
||||||
|
времени не обязано совпадать.
|
||||||
|
|
||||||
|
**Event/message** -- типизированное сообщение между objects или subsystems.
|
||||||
|
**Queue traversal** -- стабильный обход зарегистрированных объектов.
|
||||||
|
**Deferred deletion** -- перенос фактического удаления до безопасной границы.
|
||||||
|
|
||||||
|
**Snapshot** -- согласованное состояние, которое renderer читает без изменения
|
||||||
|
simulation. **Determinism** -- одинаковый результат при одинаковом initial
|
||||||
|
state, input, времени и порядке событий.
|
||||||
|
|
||||||
|
**Authority** -- subsystem или network peer, которому разрешено окончательно
|
||||||
|
менять состояние объекта. **Mirror object** -- локальное представление объекта,
|
||||||
|
authority которого находится у другого player.
|
||||||
|
|
||||||
|
## Геометрия и рендеринг
|
||||||
|
|
||||||
|
**Vertex** -- вершина geometry. **Index** -- номер вершины. **Triangle** --
|
||||||
|
примитив из трёх индексов.
|
||||||
|
|
||||||
|
**Node** -- элемент hierarchy модели со своим local transform. **Slot** в MSH
|
||||||
|
-- выбранная геометрическая группа для комбинации node, LOD и group. **Batch**
|
||||||
|
-- непрерывный индексный диапазон с material slot и render state.
|
||||||
|
|
||||||
|
**Transform** переводит данные между coordinate spaces. **Matrix** задаёт
|
||||||
|
линейное преобразование и translation. Порядок умножения matrices является
|
||||||
|
частью контракта.
|
||||||
|
|
||||||
|
**Bounds** -- упрощённый объём для быстрых тестов. **AABB** -- min/max по осям.
|
||||||
|
**Bounding sphere** -- center и radius.
|
||||||
|
|
||||||
|
**Renderer** преобразует подготовленную сцену в изображение. **Backend** --
|
||||||
|
реализация поверх конкретного API или устройства.
|
||||||
|
|
||||||
|
**Draw call** -- команда нарисовать диапазон primitives. **Indexed draw**
|
||||||
|
использует index buffer и base vertex.
|
||||||
|
|
||||||
|
**Material phase** -- одно временное состояние анимированного материала.
|
||||||
|
**Texture** -- двумерный массив texels. **Mip chain** -- последовательность
|
||||||
|
уменьшенных уровней texture. **Atlas** -- texture с несколькими под-
|
||||||
|
изображениями.
|
||||||
|
|
||||||
|
**Fixed-function pipeline** -- старый graphics pipeline, где приложение
|
||||||
|
выбирает predefined transform, lighting, texture-stage и blend states вместо
|
||||||
|
пользовательских shaders.
|
||||||
|
|
||||||
|
**Depth test**, **culling**, **alpha test** и **blending** -- render states,
|
||||||
|
которые влияют на порядок и видимость fragments.
|
||||||
|
|
||||||
|
**Pixel parity** -- совпадение конечного изображения при фиксированных camera,
|
||||||
|
time, seed, resolution и device profile.
|
||||||
|
|
||||||
|
## Навигация, звук и сеть
|
||||||
|
|
||||||
|
**Areal** -- логическая область карты с границей, class/flags и связями с
|
||||||
|
соседями. **Areal graph** -- граф областей и переходов. **Cell grid** --
|
||||||
|
пространственный индекс для быстрых candidate queries.
|
||||||
|
|
||||||
|
**Pathfinding** -- поиск маршрута по graph. **Corridor** -- локальная полоса,
|
||||||
|
построенная из последовательности areals. **Local steering** корректирует
|
||||||
|
ближайший шаг внутри corridor.
|
||||||
|
|
||||||
|
**Collision proxy** -- упрощённое представление объекта для столкновений.
|
||||||
|
**Broad phase** быстро находит потенциальные пары. **Narrow phase** выполняет
|
||||||
|
точную проверку и вычисляет contact.
|
||||||
|
|
||||||
|
**Sample** -- декодированные звуковые данные. **Source** -- конкретный
|
||||||
|
экземпляр воспроизведения с position, gain, loop state и временем. **Listener**
|
||||||
|
-- положение и ориентация слушателя для 3D spatialization.
|
||||||
|
|
||||||
|
**Transport** -- механизм доставки bytes между peers. **Protocol** -- framing,
|
||||||
|
message types, порядок и правила подтверждения. **Wire compatibility** --
|
||||||
|
способность обмениваться данными с оригинальным клиентом.
|
||||||
|
|
||||||
|
**Serialization** -- преобразование typed state в byte sequence. **Framing** --
|
||||||
|
способ отделить одно сообщение от следующего. **Reliable delivery** гарантирует
|
||||||
|
доставку/порядок в пределах выбранной модели; **unreliable delivery** допускает
|
||||||
|
потери ради задержки.
|
||||||
|
|
||||||
|
**Player ID** транспорта и **game player number** -- разные идентичности.
|
||||||
|
**Ownership transfer** меняет authority объекта. **Replication** передаёт
|
||||||
|
состояние или события remote mirrors.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# Границы знания
|
||||||
|
|
||||||
|
Этот раздел перечисляет области, где контракт ещё не закрыт полностью. Они не
|
||||||
|
мешают безопасному чтению и lossless сохранению, но не должны превращаться в
|
||||||
|
authoring API без динамического подтверждения.
|
||||||
|
|
||||||
|
## Render state
|
||||||
|
|
||||||
|
Доказаны frame boundaries, world traversal, material resolve и крупные проходы.
|
||||||
|
Не доказаны символами точные имена renderer vtable slots, полный набор CShade
|
||||||
|
state transitions и окончательный порядок части transparent/FX/shadow subpasses.
|
||||||
|
|
||||||
|
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
|
||||||
|
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
|
||||||
|
log на минимальных сценах с одним типом материала.
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|
||||||
|
## Script VM
|
||||||
|
|
||||||
|
Доступны packages, symbols, event sections, variable declarations и version
|
||||||
|
checks. Полная instruction grammar `.scr`, semantics opcodes и serialization
|
||||||
|
state ещё не восстановлены.
|
||||||
|
|
||||||
|
Закрывающий эксперимент: найти dispatcher loop в `ai.dll`, сопоставить jump
|
||||||
|
table с instruction sizes, построить disassembler и сравнить выполнение
|
||||||
|
коротких scripts с оригиналом.
|
||||||
|
|
||||||
|
## Saves and campaign state
|
||||||
|
|
||||||
|
Найдены `saveslots.cfg` и `missions/dispatcher.ini`, но binary savegame payload,
|
||||||
|
serialization World3D/AI/script/RNG и migration rules не закрыты.
|
||||||
|
|
||||||
|
Нужны сохранения оригинала в контролируемых состояниях: старт миссии, изменение
|
||||||
|
позиции, здоровья, order/path, FX/timer, script variable, research/economy,
|
||||||
|
mission completion, pause и non-default game time.
|
||||||
|
|
||||||
|
## Physical/control formats
|
||||||
|
|
||||||
|
CTLD и связанные resources структурно читаются, count patterns и variants
|
||||||
|
известны. Не названы все секции, shape types, coefficients и точный contact
|
||||||
|
solver. То же относится к редким MSH auxiliary streams и части CTPT/NDPR flags.
|
||||||
|
|
||||||
|
Закрывающий эксперимент: трассировать `LoadControlSystem`,
|
||||||
|
`LoadPhysicalModel`, `CreateCollManager` и создание collision objects; связать
|
||||||
|
каждый изменяемый field с созданным shape, contact или реакцией на движение.
|
||||||
|
|
||||||
|
## DirectPlay wire
|
||||||
|
|
||||||
|
DirectPlay lifecycle и имена игровых messages известны. Wire framing, payload
|
||||||
|
schema, reliability flags и `netZipData` требуют записи обмена двух
|
||||||
|
оригинальных клиентов.
|
||||||
|
|
||||||
|
Native interoperability подтверждается только успешным обменом original client
|
||||||
|
<-> compatibility implementation в обе стороны.
|
||||||
|
|
||||||
|
## Shell, HUD, шрифты и локализация
|
||||||
|
|
||||||
|
Граница shell подтверждена exports `createShell/getIShell`, `IGUIServer`,
|
||||||
|
верхнеуровневым UI-pass и файлами `ui/*.cfg`, `DATA/TextRes.cfg`,
|
||||||
|
`gamefont.rlb` и `sprites.lib`. RsLi framing библиотек закрыт, но widget tree,
|
||||||
|
layout rules, glyph metrics, sprite command semantics, focus/navigation и HUD
|
||||||
|
state machine пока не восстановлены до field-level спецификации.
|
||||||
|
|
||||||
|
Закрывающий эксперимент: трассировать загрузку `shell_ctrls.cfg`,
|
||||||
|
`menu_resources.cfg`, `cursor.cfg`, `game_resources.cfg` и `hq.cfg`, сопоставить
|
||||||
|
GUI object factories и снять command/event captures для меню, HUD, briefing и
|
||||||
|
диалогов.
|
||||||
|
|
||||||
|
## Research, economy and properties
|
||||||
|
|
||||||
|
Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA
|
||||||
|
properties доказывают отдельный слой исследований, стоимости, добычи и
|
||||||
|
производственных параметров. Формулы стоимости, dependency graph технологий,
|
||||||
|
inventory/economy transitions и точная типизация всех 16-byte property values
|
||||||
|
не закрыты.
|
||||||
|
|
||||||
|
Закрывающий эксперимент: сопоставить research functions с ресурсами и UI,
|
||||||
|
снять изменения state на контролируемых покупках/исследованиях и построить
|
||||||
|
typed schema свойств по consumers, а не по одному имени.
|
||||||
|
|
||||||
|
## Rare branches
|
||||||
|
|
||||||
|
- `Land.map poly_count > 0`;
|
||||||
|
- RsLi adaptive methods `0x080` и `0x0A0`;
|
||||||
|
- Texm formats 556 и 88;
|
||||||
|
- FX opcode 6;
|
||||||
|
- редкие material flags и MSH auxiliary streams.
|
||||||
|
|
||||||
|
Такие ветки реализуются по бинарному коду и 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
|
||||||
|
|
||||||
|
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
|
||||||
|
Для локально воспроизводимого Desktop backend подтверждено только command/state trace
|
||||||
|
в существующем GL-воркфлоу:
|
||||||
|
|
||||||
|
- `fixtures/acceptance/macos-gl33-triangle-capture.json`;
|
||||||
|
|
||||||
|
`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный
|
||||||
|
`winit`+`fparkan-render-vulkan` path с real surface/present pipeline.
|
||||||
|
Для закрытия требования требуется постоянный workspace-владельческий backend на
|
||||||
|
`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным
|
||||||
|
surface/present pipeline, command/state parity и licensed frame capture.
|
||||||
|
|
||||||
|
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
|
||||||
|
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
|
||||||
|
command/state trace. Локальный Docker probe существующего Rust image не нашёл
|
||||||
|
`libGL`, `libEGL`, `libGLES` или `libOSMesa`, поэтому закрытие этого gate требует
|
||||||
|
отдельно предоставленного Docker image с Rust + Mesa/EGL/OSMesa либо разрешения
|
||||||
|
на установку соответствующего проверочного окружения.
|
||||||
|
|
||||||
|
Для текущей macOS-focused цели `S3-GL-002`, `L3-DEVICE-001` и `L5-RG40-001`
|
||||||
|
помечены как `omitted`: они остаются требованиями portable target scope, но не
|
||||||
|
блокируют локальный macOS acceptance-аудит. При возврате RG40XX/GLES2 в область
|
||||||
|
цели эти gates снова должны требовать внешнего evidence.
|
||||||
|
|
||||||
|
`L3-DEVICE-001` и `L5-RG40-001` не закрываются локально без RG40XX H или
|
||||||
|
эквивалентного удалённого runner-а. Требуемое доказательство: запуск выбранной
|
||||||
|
миссии при 640x480 на целевом профиле, сохранённые stdout/stderr, build
|
||||||
|
fingerprint, manifest игрового каталога, frame/tick budget, memory budget и
|
||||||
|
итоговый pass/fail report. Desktop/headless результаты не считаются заменой
|
||||||
|
on-device smoke.
|
||||||
|
|
||||||
|
## Closure criteria
|
||||||
|
|
||||||
|
Вопрос считается закрытым только при наличии build fingerprint, raw trace,
|
||||||
|
parser trace-а, минимального воспроизводимого input/resource/save/message,
|
||||||
|
формального контракта или явно ограниченной гипотезы, differential test для
|
||||||
|
изменённых DLL, обновления тематической главы и regression case, запускаемого
|
||||||
|
без ручного анализа.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Current Project Audit
|
||||||
|
|
||||||
|
Baseline command:
|
||||||
|
|
||||||
|
```text
|
||||||
|
cargo xtask ci
|
||||||
|
```
|
||||||
|
|
||||||
|
Result on 2026-06-23:
|
||||||
|
|
||||||
|
- canonical pipeline now uses a fixed MSRV/toolchain, policy checks,
|
||||||
|
full-format workspace test command, `clippy`/`doc`/`cargo deny` gates and
|
||||||
|
typed manifest parsing in `xtask`;
|
||||||
|
- `rpath`/offline mode is still useful for synthetic local checks;
|
||||||
|
- full online dependency resolution remains unavailable in the sandbox.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# FParkan
|
||||||
|
|
||||||
|
FParkan -- самостоятельная техническая книга о восстановлении игрового движка
|
||||||
|
Iron3D из *Parkan: Iron Strategy*. Она ведёт от запуска оригинальной программы
|
||||||
|
и карты DLL к форматам ресурсов, загрузке миссии, геометрии, материалам,
|
||||||
|
рендеру, поведению, звуку, сети и плану чистой совместимой реализации.
|
||||||
|
|
||||||
|
Сайт оформлен как онлайн-книга: тома читаются последовательно, а справочник
|
||||||
|
используется как быстрый доступ к форматам, проверочным правилам и границам
|
||||||
|
доказанного знания.
|
||||||
|
|
||||||
|
## Как читать
|
||||||
|
|
||||||
|
Если вы впервые разбираете игровой движок, начните с тома 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
|
||||||
|
сохранение.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# WEAR и MAT0
|
||||||
|
|
||||||
|
MSH batch хранит только `material_index`. WEAR переводит этот индекс в имя
|
||||||
|
материала, а MAT0 по этому имени описывает phases, parameters и texture
|
||||||
|
references.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Batch20.material_index
|
||||||
|
-> WEAR row
|
||||||
|
-> MAT0 entry
|
||||||
|
-> active phase
|
||||||
|
-> textureName
|
||||||
|
```
|
||||||
|
|
||||||
|
## WEAR
|
||||||
|
|
||||||
|
WEAR -- текстовый ресурс type ID `0x52414557`, обычно `*.wea` рядом с моделью.
|
||||||
|
|
||||||
|
```text
|
||||||
|
<wearCount>
|
||||||
|
<legacyId> <materialName>
|
||||||
|
...
|
||||||
|
|
||||||
|
[empty line]
|
||||||
|
[LIGHTMAPS
|
||||||
|
<lightmapCount>
|
||||||
|
<legacyId> <lightmapName>
|
||||||
|
...]
|
||||||
|
```
|
||||||
|
|
||||||
|
`legacyId` сохраняется, но выбор выполняется по позиции строки и имени. Между
|
||||||
|
основной таблицей и `LIGHTMAPS` нужен пустой разделитель.
|
||||||
|
|
||||||
|
## MAT0
|
||||||
|
|
||||||
|
MAT0 имеет type ID `0x3054414D`, обычно расположен в `Material.lib`. `attr1`
|
||||||
|
содержит runtime flags, `attr2` -- версию payload.
|
||||||
|
|
||||||
|
```c
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct Mat0PrefixV4Plus {
|
||||||
|
uint16_t phase_count;
|
||||||
|
uint16_t animation_block_count;
|
||||||
|
uint8_t metadata_a;
|
||||||
|
uint8_t metadata_b;
|
||||||
|
uint32_t metadata_c_raw;
|
||||||
|
uint32_t metadata_d_raw;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Phase34 {
|
||||||
|
uint8_t parameters[18];
|
||||||
|
char texture_name[16];
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
```
|
||||||
|
|
||||||
|
Versioned fields читаются только если версия их содержит. Для старых версий
|
||||||
|
используются runtime defaults, а raw values сохраняются.
|
||||||
|
|
||||||
|
## Fallback
|
||||||
|
|
||||||
|
Material resolve:
|
||||||
|
|
||||||
|
1. имя из WEAR;
|
||||||
|
2. `DEFAULT`;
|
||||||
|
3. entry с индексом 0.
|
||||||
|
|
||||||
|
Пустое texture name означает намеренно нетекстурированную поверхность. Lightmap
|
||||||
|
fallback отдельный: отсутствующий lightmap даёт slot `-1`.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# MSH
|
||||||
|
|
||||||
|
Файл `*.msh` является NRes-контейнером. Geometry, узлы, slots, batches,
|
||||||
|
animation и служебные streams лежат в entries с разными `type_id`.
|
||||||
|
|
||||||
|
## Entry map
|
||||||
|
|
||||||
|
```text
|
||||||
|
type 1 nodes and slot selection
|
||||||
|
type 2 header 0x8C + Slot68 records
|
||||||
|
type 3 positions float3
|
||||||
|
type 4 packed normals
|
||||||
|
type 5 packed UV0
|
||||||
|
type 6 index buffer u16
|
||||||
|
type 7 triangle descriptors
|
||||||
|
type 8 animation keys
|
||||||
|
type 9 service stream
|
||||||
|
type 10 strings and node names
|
||||||
|
type 13 Batch20 records
|
||||||
|
type 15 auxiliary stream
|
||||||
|
type 17 auxiliary data
|
||||||
|
type 18 rare stream
|
||||||
|
type 19 animation frame map
|
||||||
|
type 20 rare auxiliary table
|
||||||
|
```
|
||||||
|
|
||||||
|
Reader ищет entries по type, но сохраняет исходный порядок для roundtrip.
|
||||||
|
|
||||||
|
## Node and slot selection
|
||||||
|
|
||||||
|
Type 1 обычно состоит из records по 38 bytes:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct Node38 {
|
||||||
|
uint16_t hdr0;
|
||||||
|
uint16_t parent_or_link;
|
||||||
|
uint16_t anim_map_start;
|
||||||
|
uint16_t fallback_key;
|
||||||
|
uint16_t slot_index[15];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
|
||||||
|
отсутствие геометрии для комбинации LOD/group.
|
||||||
|
|
||||||
|
## Slot and batch
|
||||||
|
|
||||||
|
Type 2 содержит header `0x8C`, затем `Slot68`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct Slot68 {
|
||||||
|
uint16_t tri_start;
|
||||||
|
uint16_t tri_count;
|
||||||
|
uint16_t batch_start;
|
||||||
|
uint16_t batch_count;
|
||||||
|
float aabb_min[3];
|
||||||
|
float aabb_max[3];
|
||||||
|
float sphere_center[3];
|
||||||
|
float sphere_radius;
|
||||||
|
uint32_t opaque[5];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Type 13 задаёт draw ranges:
|
||||||
|
|
||||||
|
```c
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct Batch20 {
|
||||||
|
uint16_t batch_flags;
|
||||||
|
uint16_t material_index;
|
||||||
|
uint16_t opaque4;
|
||||||
|
uint16_t opaque6;
|
||||||
|
uint16_t index_count;
|
||||||
|
uint32_t index_start;
|
||||||
|
uint16_t opaque14;
|
||||||
|
uint32_t base_vertex;
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
```
|
||||||
|
|
||||||
|
Index check выполняется как `base_vertex + index < vertex_count` для всего
|
||||||
|
используемого slice.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# NRes
|
||||||
|
|
||||||
|
`NRes` -- основной контейнер ресурсов Iron3D. Он используется как внешний
|
||||||
|
архив и как внутренний контейнер модели `*.msh`.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Header: 16 bytes]
|
||||||
|
[Data region: payload with alignment]
|
||||||
|
[Directory: entry_count * 64 bytes]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Header
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct NResHeader16 {
|
||||||
|
char magic[4]; // "NRes"
|
||||||
|
uint32_t version; // 0x00000100
|
||||||
|
int32_t entry_count; // >= 0
|
||||||
|
uint32_t total_size; // equals file size
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`directory_offset = total_size - entry_count * 64`. Reader проверяет отсутствие
|
||||||
|
переполнений, `directory_offset >= 16` и точное окончание каталога на
|
||||||
|
`total_size`.
|
||||||
|
|
||||||
|
## Entry
|
||||||
|
|
||||||
|
```c
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct NResEntry64 {
|
||||||
|
uint32_t type_id;
|
||||||
|
uint32_t attr1;
|
||||||
|
uint32_t attr2;
|
||||||
|
uint32_t size;
|
||||||
|
uint32_t attr3;
|
||||||
|
char name[36];
|
||||||
|
uint32_t data_offset;
|
||||||
|
uint32_t sort_index;
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
```
|
||||||
|
|
||||||
|
Имя содержит bounded C-string до 35 полезных bytes. `sort_index` задаёт
|
||||||
|
отображение из sorted position в original entry index. В строгом режиме все
|
||||||
|
`sort_index` образуют перестановку `0..N-1`.
|
||||||
|
|
||||||
|
## Data region
|
||||||
|
|
||||||
|
Payload каждой записи лежит после header и до начала каталога. Игровые архивы
|
||||||
|
выравнивают следующий payload до 8 bytes нулями, но reader не должен требовать
|
||||||
|
плотного покрытия data region.
|
||||||
|
|
||||||
|
Различаются:
|
||||||
|
|
||||||
|
- active payload -- диапазон, на который указывает entry;
|
||||||
|
- gap/padding -- bytes между активными диапазонами;
|
||||||
|
- unindexed preserved region -- произвольные bytes, не принадлежащие entry.
|
||||||
|
|
||||||
|
Lossless editor сохраняет все три категории. Compact writer может исключить
|
||||||
|
unindexed regions только при явной операции repack.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Render frame
|
||||||
|
|
||||||
|
Кадр является последней стадией цикла, а не самостоятельной функцией renderer-а.
|
||||||
|
До draw calls уже накоплен input, рассчитан tick, применены отложенные операции,
|
||||||
|
выбрана камера и обновлён 3D sound listener.
|
||||||
|
|
||||||
|
## Frame skeleton
|
||||||
|
|
||||||
|
```text
|
||||||
|
system messages and input
|
||||||
|
-> simulation calculation
|
||||||
|
-> deferred object operations
|
||||||
|
-> animation and transforms
|
||||||
|
-> camera and sound listener
|
||||||
|
-> visibility and render queues
|
||||||
|
-> materials and draw passes
|
||||||
|
-> renderer completion
|
||||||
|
-> end-of-render callbacks and UI
|
||||||
|
```
|
||||||
|
|
||||||
|
В `World3D::stdRenderGame` доказан крупный порядок: camera передаётся Terrain,
|
||||||
|
настраиваются viewport/matrices, вызываются renderer boundary slots,
|
||||||
|
устанавливается `in_render`, выполняется traversal мира, закрывается world/shade
|
||||||
|
pass, вызывается renderer completion, снимается `in_render`, рассылается
|
||||||
|
end-of-render.
|
||||||
|
|
||||||
|
## Draw item
|
||||||
|
|
||||||
|
Подготовленный draw item содержит:
|
||||||
|
|
||||||
|
- node world matrix;
|
||||||
|
- batch flags and index range;
|
||||||
|
- WEAR material handle;
|
||||||
|
- MAT0 active phase and coefficients;
|
||||||
|
- texture handle;
|
||||||
|
- optional lightmap handle;
|
||||||
|
- render phase and sorting key;
|
||||||
|
- legacy pipeline state.
|
||||||
|
|
||||||
|
Подготовленный item должен ссылаться на immutable данные кадра. Изменение phase
|
||||||
|
или texture cache посреди прохода не должно менять уже собранную очередь.
|
||||||
|
|
||||||
|
## Parity risks
|
||||||
|
|
||||||
|
- x87 precision and rounding;
|
||||||
|
- scalar/SIMD `g_FastProc` differences;
|
||||||
|
- object, batch and transparent primitive order;
|
||||||
|
- depth, cull, alpha test and blend transitions;
|
||||||
|
- mip-skip, palette and Page coordinates;
|
||||||
|
- material fallback and phase selection;
|
||||||
|
- RNG sequence for FX and atmosphere;
|
||||||
|
- device capability fallback;
|
||||||
|
- simulation time quantization.
|
||||||
|
|
||||||
|
Для отладки нужен deterministic frame capture: camera state, visible object IDs,
|
||||||
|
draw-item list, pipeline keys, matrices и hashes промежуточных buffers.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# RsLi
|
||||||
|
|
||||||
|
`RsLi` -- библиотечный архив Iron3D с каталогом в начале файла и payloads после
|
||||||
|
него.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Header: 32 bytes]
|
||||||
|
[Entry table: entry_count * 32 bytes]
|
||||||
|
[Payloads]
|
||||||
|
[optional trailer]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Header fields
|
||||||
|
|
||||||
|
```text
|
||||||
|
+0x00 char[2] "NL"
|
||||||
|
+0x02 u8 reserved
|
||||||
|
+0x03 u8 version = 1
|
||||||
|
+0x04 i16 entry_count
|
||||||
|
+0x0E u16 presorted_flag = 0xABBA
|
||||||
|
+0x14 u32 xor_seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Остальные bytes сохраняются без нормализации.
|
||||||
|
|
||||||
|
## Entry
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct RsLiEntry32 {
|
||||||
|
char name[12];
|
||||||
|
uint8_t service[4];
|
||||||
|
int16_t flags;
|
||||||
|
int16_t sort_to_original;
|
||||||
|
uint32_t unpacked_size;
|
||||||
|
uint32_t data_offset_raw;
|
||||||
|
uint32_t packed_size;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Имя обычно хранится в uppercase ASCII. `sort_to_original` связывает sorted
|
||||||
|
position с исходной записью.
|
||||||
|
|
||||||
|
## Table transform
|
||||||
|
|
||||||
|
Entry table проходит обратимое потоковое XOR-преобразование. Начальное
|
||||||
|
состояние берётся из младших 16 bits `xor_seed` и продолжается через всю
|
||||||
|
таблицу, не сбрасываясь на границе записи.
|
||||||
|
|
||||||
|
## Storage methods
|
||||||
|
|
||||||
|
```text
|
||||||
|
0x000 raw block
|
||||||
|
0x020 byte transform only
|
||||||
|
0x040 LZSS
|
||||||
|
0x060 transform + LZSS
|
||||||
|
0x080 adaptive Huffman + LZSS
|
||||||
|
0x0A0 transform + adaptive Huffman + LZSS
|
||||||
|
0x100 raw Deflate
|
||||||
|
```
|
||||||
|
|
||||||
|
После любого пути должно получиться ровно `unpacked_size` bytes. Методы
|
||||||
|
`0x080` и `0x0A0` подтверждены decoder-кодом, но не живыми payload демоверсии
|
||||||
|
или обеих частей.
|
||||||
|
|
||||||
|
## Compatibility quirk
|
||||||
|
|
||||||
|
`sprites.lib::INTERF8.TEX` объявляет Deflate range на один byte дальше EOF.
|
||||||
|
Совместимый reader допускает `packed_size - 1` только для этого именованного
|
||||||
|
случая. Строгий режим сообщает `deflate_eof_plus_one`.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Texm
|
||||||
|
|
||||||
|
`Texm` -- основной формат изображений Iron3D. Payload содержит header,
|
||||||
|
необязательную палитру, mip chain и иногда `Page` chunk.
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct TexmHeader32 {
|
||||||
|
uint32_t magic; // 'Texm'
|
||||||
|
uint32_t width;
|
||||||
|
uint32_t height;
|
||||||
|
uint32_t mip_count;
|
||||||
|
uint32_t flags4;
|
||||||
|
uint32_t flags5;
|
||||||
|
uint32_t unknown6;
|
||||||
|
uint32_t format;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pixel formats
|
||||||
|
|
||||||
|
```text
|
||||||
|
0 Indexed8 + palette 256 * 4 bytes
|
||||||
|
565 R5 G6 B5
|
||||||
|
556 R5 G5 B6
|
||||||
|
4444 A4 R4 G4 B4
|
||||||
|
88 L8 A8
|
||||||
|
888 RGB8 in four-byte element
|
||||||
|
8888 A8 R8 G8 B8
|
||||||
|
```
|
||||||
|
|
||||||
|
Короткие каналы расширяются до 8 bits повторением значимых bits. Для 888
|
||||||
|
служебный четвёртый byte сохраняется при roundtrip.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
TexmHeader32
|
||||||
|
[palette 1024 bytes, only for format 0]
|
||||||
|
level 0 pixels
|
||||||
|
level 1 pixels
|
||||||
|
...
|
||||||
|
level mip_count-1 pixels
|
||||||
|
[optional Page chunk]
|
||||||
|
```
|
||||||
|
|
||||||
|
Размер mip level вычисляется через `max(1, width >> i)` и
|
||||||
|
`max(1, height >> i)`. Parser суммирует размеры с проверкой переполнения до
|
||||||
|
чтения данных.
|
||||||
|
|
||||||
|
## Page chunk
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct PageHeader8 {
|
||||||
|
uint32_t magic; // 'Page'
|
||||||
|
uint32_t rect_count;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PageRect8 {
|
||||||
|
int16_t x;
|
||||||
|
int16_t width;
|
||||||
|
int16_t y;
|
||||||
|
int16_t height;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Chunk обязан иметь размер `8 + rect_count * 8`. Rectangles находятся в pixel
|
||||||
|
space базового mip и масштабируются после mip-skip.
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# TMA
|
||||||
|
|
||||||
|
`data.tma` -- основное описание расстановки и логической конфигурации миссии.
|
||||||
|
Файл перечисляет paths, clans, objects, свойства, ссылку на ландшафт и extras.
|
||||||
|
|
||||||
|
## String primitive
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct LpString {
|
||||||
|
uint32_t byte_length;
|
||||||
|
uint8_t bytes[byte_length];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Reader продвигается ровно на `4 + byte_length`. Завершающий NUL не является
|
||||||
|
обязательной частью framing. Для человекочитаемого вида используется legacy
|
||||||
|
ANSI/CP1251 view, но исходные bytes сохраняются.
|
||||||
|
|
||||||
|
## Top level
|
||||||
|
|
||||||
|
```text
|
||||||
|
u32 format_version
|
||||||
|
u32 path_count
|
||||||
|
PathRecord paths[path_count]
|
||||||
|
u32 clan_section_version
|
||||||
|
u32 clan_count
|
||||||
|
ClanRecord clans[clan_count]
|
||||||
|
u32 object_section_version
|
||||||
|
u32 object_count
|
||||||
|
PlacedObject objects[object_count]
|
||||||
|
LpString land_path
|
||||||
|
u32 mission_flag
|
||||||
|
LpString description_raw
|
||||||
|
u32 extra_section_version
|
||||||
|
u32 extra_count
|
||||||
|
ExtraRecord28 extras[extra_count]
|
||||||
|
```
|
||||||
|
|
||||||
|
Все 60 TMA Частей 1 и 2 проходят parser до точного EOF. Версии стабильны:
|
||||||
|
верхний уровень `1`, clan section `6`, object section `10`, property schema
|
||||||
|
`1`, trailing section `1`.
|
||||||
|
|
||||||
|
## PlacedObject
|
||||||
|
|
||||||
|
```text
|
||||||
|
u32 raw_kind
|
||||||
|
u32 class_or_flags
|
||||||
|
LpString resource_name
|
||||||
|
u32 raw_after_resource
|
||||||
|
u32 identity_or_clan_raw
|
||||||
|
f32 position[3]
|
||||||
|
f32 orientation[3]
|
||||||
|
f32 scale[3]
|
||||||
|
LpString instance_name
|
||||||
|
u32 raw_after_name
|
||||||
|
i32 link0
|
||||||
|
i32 link1
|
||||||
|
u32 property_schema_version
|
||||||
|
u32 property_count
|
||||||
|
Property properties[property_count]
|
||||||
|
```
|
||||||
|
|
||||||
|
`Property` состоит из четырёх raw `u32` и имени. Typed views разрешены только
|
||||||
|
для доказанных property names и consumers.
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# 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 и связи с объектом мира.
|
||||||
|
|
||||||
|
## Движок как программа длительного действия
|
||||||
|
|
||||||
|
Обычная прикладная программа получает запрос, вычисляет результат и заканчивает
|
||||||
|
работу. Игра живёт в цикле: прочитать ввод, обновить состояние мира,
|
||||||
|
сформировать звук и изображение, показать кадр и повторить. Движок -- набор
|
||||||
|
подсистем и соглашений, которые делают этот цикл устойчивым.
|
||||||
|
|
||||||
|
**Simulation** отвечает на вопрос "что произошло в мире": куда переместился
|
||||||
|
объект, кого он видит, сколько у него здоровья, сработал ли эффект, изменился
|
||||||
|
ли маршрут или приказ. **Rendering** отвечает на другой вопрос: "как текущее
|
||||||
|
состояние показать". В корректной архитектуре рендер не решает игровые правила,
|
||||||
|
а читает подготовленное состояние.
|
||||||
|
|
||||||
|
**Tick** -- один шаг расчёта. **Frame** -- одно изображение. Они могут
|
||||||
|
выполняться с разной частотой: игра способна рассчитать несколько шагов между
|
||||||
|
двумя показами или временно не рисовать, не останавливая логику. Поэтому время,
|
||||||
|
накопление input, порядок callbacks и момент удаления объектов считаются частью
|
||||||
|
контракта.
|
||||||
|
|
||||||
|
## Мир, сцена и объект
|
||||||
|
|
||||||
|
**Мир** -- долгоживущее состояние миссии: ландшафт, объекты, время, погода,
|
||||||
|
принадлежность к кланам и глобальные сервисы. **Сцена** -- представление той
|
||||||
|
части мира, которую можно обработать для текущей камеры. **Игровой объект** --
|
||||||
|
сущность с идентификатором, положением, набором свойств и поведением.
|
||||||
|
|
||||||
|
В Iron3D объектами управляет World3D. Объекты регистрируются в общей очереди,
|
||||||
|
получают события, участвуют в расчёте и могут быть удалены отложенно, чтобы не
|
||||||
|
разрушить обход коллекции посреди шага. Это важнее, чем конкретный контейнер в
|
||||||
|
новой реализации: совместимость определяется моментом наблюдаемого добавления,
|
||||||
|
обновления и удаления.
|
||||||
|
|
||||||
|
Мир не равен renderer scene graph. Один объект может иметь runtime state,
|
||||||
|
controller, сетевой mirror, визуальную модель, collision bounds и script state.
|
||||||
|
Часть этих данных нужна для gameplay, часть -- для вывода, часть -- для
|
||||||
|
сохранения и воспроизведения.
|
||||||
|
|
||||||
|
## Ресурс, модель и материал
|
||||||
|
|
||||||
|
**Ресурс** -- именованный блок данных, который можно найти и загрузить. Архивы
|
||||||
|
`NRes` и `RsLi` содержат таблицы таких блоков. Имя, индекс, размер, offset,
|
||||||
|
compression method и fallback-правило являются частью контракта загрузки.
|
||||||
|
|
||||||
|
**Модель** описывает форму объекта. Она состоит из вершин, индексов, узлов,
|
||||||
|
групп треугольников, слотов материалов и auxiliary streams. **Vertex** хранит
|
||||||
|
положение и обычно дополнительные атрибуты: нормаль для освещения и
|
||||||
|
UV-координату для выборки texture. **Triangle** -- три вершины, образующие
|
||||||
|
примитив. **Index buffer** хранит номера вершин и позволяет переиспользовать их
|
||||||
|
между треугольниками. **Batch** -- непрерывный диапазон индексов, который
|
||||||
|
рисуется одним материалом и одним набором состояний.
|
||||||
|
|
||||||
|
**Материал** описывает способ отображения поверхности: texture references,
|
||||||
|
цвет, прозрачность, режимы смешивания и анимацию параметров. **Texture** --
|
||||||
|
изображение в памяти графической системы. **Mip-уровни** -- уменьшенные копии
|
||||||
|
изображения для дальних объектов. **Lightmap** -- дополнительная texture с
|
||||||
|
заранее рассчитанным освещением.
|
||||||
|
|
||||||
|
Runtime должен связывать эти уровни по цепочке: миссия выбирает объект, объект
|
||||||
|
ссылается на prototype, prototype приводит к модели, модель -- к WEAR,
|
||||||
|
материалам, textures и lightmaps. Ошибка на любом участке этой цепочки может
|
||||||
|
не проявиться в parser-е, но проявится в игровом кадре.
|
||||||
|
|
||||||
|
## Пространственные понятия
|
||||||
|
|
||||||
|
**Transform** переводит точку из локальных координат модели в координаты мира,
|
||||||
|
камеры и экрана. **Иерархия узлов** позволяет одному элементу наследовать
|
||||||
|
движение другого. **LOD** выбирает менее подробную геометрию вдали. **Culling**
|
||||||
|
отбрасывает то, что не видно. **Bounds** -- упрощённая оболочка объекта,
|
||||||
|
обычно сфера или AABB, используемая для быстрых тестов.
|
||||||
|
|
||||||
|
**Collision** отвечает на геометрические пересечения. **Navigation** ищет
|
||||||
|
допустимый маршрут. В Iron3D эти задачи разделены: Control обслуживает
|
||||||
|
физическую модель и столкновения, а ArealMap хранит пространственные области и
|
||||||
|
связи между ними.
|
||||||
|
|
||||||
|
Важно не смешивать визуальные и игровые упрощения. Render bounds могут быть
|
||||||
|
достаточны для отсечения, но не обязаны совпадать с collision shape. Навигация
|
||||||
|
может использовать areal graph, который не является ни mesh-ем модели, ни
|
||||||
|
геометрией ландшафта в renderer-е.
|
||||||
|
|
||||||
|
## Графический конвейер
|
||||||
|
|
||||||
|
Процессор выбирает видимые объекты, готовит матрицы, материалы и списки
|
||||||
|
примитивов. Графический backend передаёт вершины, индексы, textures и state
|
||||||
|
драйверу. Видеокарта преобразует вершины в координаты экрана, разбивает
|
||||||
|
треугольники на фрагменты, проверяет глубину, смешивает цвет и записывает
|
||||||
|
результат в буфер кадра. После завершения буфер становится видимым
|
||||||
|
пользователю.
|
||||||
|
|
||||||
|
Для совместимости важны не только данные draw call. Контракт включает frame
|
||||||
|
boundaries, viewport, camera state, порядок world traversal, material resolve,
|
||||||
|
shadow/transparent/FX subpasses, завершение renderer-а, восстановление state и
|
||||||
|
callbacks после рендера. Если часть имён vtable slots ещё не доказана, новая
|
||||||
|
реализация должна фиксировать крупный порядок операций и оставлять
|
||||||
|
детализацию проверяемой.
|
||||||
|
|
||||||
|
## Практический словарь реализации
|
||||||
|
|
||||||
|
**Handle** -- компактная ссылка на управляемый объект. **Cache** -- сохранённый
|
||||||
|
результат загрузки или декодирования. **Reference count** -- число владельцев
|
||||||
|
ресурса. **Fallback** -- предписанный запасной вариант при отсутствии данных.
|
||||||
|
**Invariant** -- условие, которое всегда должно быть истинным для корректного
|
||||||
|
файла или runtime-состояния. **Determinism** -- повторяемость результата при
|
||||||
|
одинаковых входных данных и порядке событий.
|
||||||
|
|
||||||
|
**Strict mode** -- режим parser-а, который принимает только корректный файл:
|
||||||
|
верные magic, версии, размеры, ranges, индексы и точный EOF. **Lossless mode**
|
||||||
|
-- режим чтения/записи, который сохраняет неизвестные поля, padding, gaps и raw
|
||||||
|
payload без нормализации. **Quirk** -- именованное отклонение, разрешённое
|
||||||
|
только после проверки на реальных данных или исполняемом коде.
|
||||||
|
|
||||||
|
Эти слова используются как технические термины. Если глава называет значение
|
||||||
|
fallback-ом, invariant-ом или quirk-ом, это должно иметь проверяемое
|
||||||
|
последствие в reader-е, writer-е или runtime.
|
||||||
|
|
||||||
|
## Как читать C/C++-схемы структур
|
||||||
|
|
||||||
|
Структуры в главах описывают байтовый layout, а не переносимый C++ object
|
||||||
|
model. Если поля на диске идут без padding, reader должен читать их по offsets
|
||||||
|
либо использовать явно проверенный packed layout. Прямое отображение native
|
||||||
|
struct допустимо только при доказанном размере, выравнивании и endian-правиле.
|
||||||
|
|
||||||
|
`sizeof` обязательно проверяется `static_assert` или эквивалентным compile-time
|
||||||
|
test. Это особенно важно для records, где 32-битное поле начинается после
|
||||||
|
нечётного числа 16-битных или 8-битных полей: стандартное выравнивание
|
||||||
|
современного compiler-а может вставить скрытые bytes и изменить offsets.
|
||||||
|
|
||||||
|
Для variable-length форматов предпочтителен bounded cursor:
|
||||||
|
|
||||||
|
1. Прочитать header и проверить минимальный размер.
|
||||||
|
2. Проверить, что offsets и sizes лежат внутри текущего блока.
|
||||||
|
3. Прочитать таблицы до объявленного count, не до "пока получается".
|
||||||
|
4. Проверить ссылки между таблицами.
|
||||||
|
5. Дойти до точного EOF или сохранить явно разрешённый trailing payload.
|
||||||
|
|
||||||
|
Writer пересчитывает только производные значения: размеры, offsets, число
|
||||||
|
записей, сортировочные таблицы и 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. Что должна сделать реализация, если правило нарушено?
|
||||||
|
|
||||||
|
Если на один из вопросов нет ответа, утверждение остаётся согласованным выводом
|
||||||
|
или открытым вопросом, а не закрытым контрактом.
|
||||||
@@ -0,0 +1,472 @@
|
|||||||
|
# II. Запуск, архитектура и игровой цикл
|
||||||
|
|
||||||
|
Этот том описывает путь от запуска `iron_3d.exe` до устойчивого кадра:
|
||||||
|
загрузку `iron3d.dll`, создание shell/game objects, поднятие платформенных
|
||||||
|
сервисов, запуск World3D, расчёт simulation step, безопасное удаление объектов,
|
||||||
|
рендер и завершение программы.
|
||||||
|
|
||||||
|
Главная особенность Iron3D -- это не один монолитный engine object, а связка
|
||||||
|
небольшого Win32 bootstrap и набора DLL, которые обмениваются фабриками,
|
||||||
|
singleton-интерфейсами и C++ vtable. Совместимая реализация может изменить
|
||||||
|
физическое деление на библиотеки, но не может произвольно менять порядок
|
||||||
|
инициализации, object identity, правила владения, fallback ресурсов и порядок
|
||||||
|
событий.
|
||||||
|
|
||||||
|
```text
|
||||||
|
iron_3d.exe
|
||||||
|
-> iron3d.dll
|
||||||
|
-> services.dll
|
||||||
|
-> World3D.dll
|
||||||
|
-> Terrain.dll
|
||||||
|
-> Ngi32.dll
|
||||||
|
-> AniMesh.dll / ArealMap.dll / Effect.dll
|
||||||
|
-> ai.dll / Behavior.dll / Wizard.dll
|
||||||
|
-> Control.dll / MisLoad.dll / Net.dll / Joystick.dll
|
||||||
|
```
|
||||||
|
|
||||||
|
## Карта модулей
|
||||||
|
|
||||||
|
Во внешней архитектуре обнаружено пятнадцать DLL. Экспортов сравнительно мало:
|
||||||
|
они обычно создают объект, возвращают singleton или дают доступ к уже поднятой
|
||||||
|
подсистеме. Основная работа выполняется через C++-интерфейсы, поэтому порядок
|
||||||
|
виртуальных слотов является частью ABI, особенно для compatibility shim эпохи
|
||||||
|
MSVC6.
|
||||||
|
|
||||||
|
```text
|
||||||
|
iron_3d.exe
|
||||||
|
|
|
||||||
|
v
|
||||||
|
iron3d.dll -- композиция игры, shell и главный цикл
|
||||||
|
|
|
||||||
|
+-- services.dll -- доступ к display, GUI, ресурсам, звуку, таймеру и сети
|
||||||
|
+-- World3D.dll -- объекты, очередь, время, камера и кадр
|
||||||
|
+-- Terrain.dll -- ландшафт, свет, атмосфера и визуальный слой мира
|
||||||
|
+-- ai.dll / Behavior.dll / Wizard.dll
|
||||||
|
+-- Control.dll / Effect.dll / MisLoad.dll
|
||||||
|
+-- Net.dll / Joystick.dll
|
||||||
|
+-- Ngi32.dll -- ресурсы, графика, звук, математика и CPU dispatch
|
||||||
|
```
|
||||||
|
|
||||||
|
Циклы импортов между DLL ожидаемы. Terrain создаёт визуальные объекты и
|
||||||
|
обращается к World3D, а World3D получает world-interface из Terrain. Это не
|
||||||
|
значит, что обе библиотеки совместно владеют всем состоянием. Реальные границы
|
||||||
|
задаются интерфейсами, refcount, очередью объектов и порядком shutdown.
|
||||||
|
|
||||||
|
Практичная новая структура может быть внутренним набором модулей `platform`,
|
||||||
|
`resources`, `world`, `mission`, `terrain`, `render`, `animation`, `effects`,
|
||||||
|
`behavior`, `physics`, `audio` и `network`. Важно сохранить не DLL-границы, а
|
||||||
|
контракты: имена ресурсов, порядок поиска, fallback-ветки, object ID, момент
|
||||||
|
создания mirror objects, численное поведение и последовательность событий.
|
||||||
|
|
||||||
|
## Роли модулей
|
||||||
|
|
||||||
|
`iron3d.dll` создаёт shell и game objects, читает `iron_3d.ini`, поднимает
|
||||||
|
display, sound, CD-audio, network и настройки World3D, загружает миссионные и
|
||||||
|
UI-конфигурации, содержит message pump и вызывает расчёт/рендер игры.
|
||||||
|
|
||||||
|
`services.dll` работает как service locator. Через него запрашиваются display,
|
||||||
|
GUI, network manager, resource manager, sound server и timer. Этот слой отделяет
|
||||||
|
высокоуровневую игру от деталей создания устройств.
|
||||||
|
|
||||||
|
`World3D.dll` -- центральный runtime: очередь объектов, идентификаторы,
|
||||||
|
события, отложенное удаление, game time, pause, manual input, камера,
|
||||||
|
material/texture/lightmap managers, сетевые mirrors, расчёт и 3D-проход.
|
||||||
|
|
||||||
|
`Terrain.dll` отвечает не только за землю. В его область входят ландшафт,
|
||||||
|
здания, визуальный слой мира, камера, shade/state layer, primitive buffers,
|
||||||
|
сортировочные слои, источники света, тени, microtextures, атмосфера, дождь,
|
||||||
|
молнии, солнце и flares.
|
||||||
|
|
||||||
|
`Ngi32.dll` содержит низкоуровневые сервисы: DirectDraw/Direct3D-era renderer,
|
||||||
|
DirectSound, readers `NRes`/`RsLi`, память, часы, математику, пересечения,
|
||||||
|
определение CPU и таблицу быстрых процедур `g_FastProc`.
|
||||||
|
|
||||||
|
Предметные DLL закрывают отдельные области. `AniMesh.dll` загружает модели и
|
||||||
|
агентов. `ArealMap.dll` строит spatial graph и маршруты. `Behavior.dll`
|
||||||
|
реализует поведение юнитов. `ai.dll` содержит стратегический AI и миссионные
|
||||||
|
сценарии. `Wizard.dll` корректирует локальное движение. `Control.dll`
|
||||||
|
обслуживает физическую модель и столкновения. `Effect.dll` создаёт runtime-FX.
|
||||||
|
`MisLoad.dll` читает миссионные данные. `Net.dll` инкапсулирует DirectPlay.
|
||||||
|
`Joystick.dll` работает через DirectInput.
|
||||||
|
|
||||||
|
## Поток данных
|
||||||
|
|
||||||
|
Миссия не создаёт готовый кадр напрямую. Данные проходят через несколько
|
||||||
|
уровней: описание объекта, прототипы, ресурсы, runtime-object, контроллеры,
|
||||||
|
simulation state, render items и только затем платформенный renderer.
|
||||||
|
|
||||||
|
```text
|
||||||
|
mission data
|
||||||
|
-> object identity and properties
|
||||||
|
-> prototype registry
|
||||||
|
-> model/material/texture/effect resources
|
||||||
|
-> World3D object + domain controllers
|
||||||
|
-> simulation state
|
||||||
|
-> visible render items
|
||||||
|
-> Ngi32 render interface
|
||||||
|
-> DirectX-era device
|
||||||
|
```
|
||||||
|
|
||||||
|
Этот поток объясняет, почему нельзя объединять физический архив, metadata entry,
|
||||||
|
декодированный payload и готовый runtime-кэш. У каждого уровня свой срок жизни,
|
||||||
|
собственный refcount и собственные ошибки. Детали ресурсного конвейера описаны
|
||||||
|
в [Томе III](03-resources.md), а сборка мира из миссии -- в [Томе IV](04-world.md).
|
||||||
|
|
||||||
|
## Bootstrap
|
||||||
|
|
||||||
|
`iron_3d.exe` -- небольшой PE32/x86 bootstrap размером 36 864 байта. Основная
|
||||||
|
игровая логика находится в `iron3d.dll`. Исполняемый файл создаёт Win32-процесс,
|
||||||
|
подготавливает окружение, загружает библиотеку и получает восемь публичных
|
||||||
|
точек входа:
|
||||||
|
|
||||||
|
```text
|
||||||
|
createShell deleteShell
|
||||||
|
createGame deleteGame
|
||||||
|
createSubsystems deleteSubsystems
|
||||||
|
getIGame getIShell
|
||||||
|
```
|
||||||
|
|
||||||
|
Эти функции образуют внешнюю границу игры. `createShell` создаёт оболочку
|
||||||
|
интерфейса и меню, `createGame` -- объект игровой логики, `createSubsystems` --
|
||||||
|
аппаратные и runtime-сервисы. Getter-функции возвращают уже созданные объекты.
|
||||||
|
|
||||||
|
Запуск удобно читать как конечный автомат:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PROCESS_CREATED
|
||||||
|
-> LIBRARY_READY
|
||||||
|
-> ENTRYPOINTS_READY
|
||||||
|
-> SHELL_CREATED
|
||||||
|
-> GAME_CREATED
|
||||||
|
-> SUBSYSTEMS_READY
|
||||||
|
-> MAIN_LOOP
|
||||||
|
-> SUBSYSTEMS_CLOSED
|
||||||
|
-> GAME_DELETED
|
||||||
|
-> SHELL_DELETED
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый переход имеет обратное действие. Если display, sound или другой
|
||||||
|
обязательный сервис не создан, главный цикл не начинается, но уже созданные
|
||||||
|
объекты освобождаются в обратном порядке. Новая оболочка запуска должна
|
||||||
|
работать из каталога оригинальной установки, сохранять смысл относительных
|
||||||
|
путей, создавать окно до графической подсистемы и закрывать частично поднятые
|
||||||
|
сервисы без предположения, что init дошёл до конца.
|
||||||
|
|
||||||
|
Bootstrap обеих полных частей побайтно одинаков, хотя файл второй части может
|
||||||
|
иметь другое имя:
|
||||||
|
|
||||||
|
```text
|
||||||
|
size 36 864
|
||||||
|
entry RVA 0x147E
|
||||||
|
SHA-256 f476af85c034a4b4f34f49d0806e4dff397b5da0ee26d382a7674231144979f7
|
||||||
|
```
|
||||||
|
|
||||||
|
Следовательно, различия полных частей начинаются после передачи управления DLL
|
||||||
|
и игровым данным. Адреса executable демоверсии относятся к другой binary
|
||||||
|
profile и не должны переноситься на полные версии без проверки hash.
|
||||||
|
|
||||||
|
## Инициализация подсистем
|
||||||
|
|
||||||
|
Iron3D разделяет создание высокоуровневых объектов и создание подсистем.
|
||||||
|
`createShell` конструирует оболочку пользовательского интерфейса, `createGame`
|
||||||
|
создаёт объект игры, а `createSubsystems` связывает их с display, sound,
|
||||||
|
network и World3D.
|
||||||
|
|
||||||
|
Высокоуровневая последовательность выглядит так:
|
||||||
|
|
||||||
|
```text
|
||||||
|
прочитать iron_3d.ini
|
||||||
|
-> получить display service
|
||||||
|
-> создать окно и графическое устройство
|
||||||
|
-> проверить доступность 3D-драйвера
|
||||||
|
-> выбрать CURRENT_D3DCARD
|
||||||
|
-> получить sound service и настроить громкость
|
||||||
|
-> создать network instance и передать application GUID
|
||||||
|
-> создать World3D game settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Ошибка отсутствующего 3D-устройства обрабатывается отдельно от ошибок ресурсов:
|
||||||
|
это разные стадии запуска. Конфигурация влияет не только на разрешение. В
|
||||||
|
runtime попадают графическая карта, громкость эффектов, CD-audio, режим
|
||||||
|
CD-sound, сетевое приложение и World3D settings. Application GUID сетевой
|
||||||
|
подсистемы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{3C1D1F01-A870-11D1-8400-000021B14415}
|
||||||
|
```
|
||||||
|
|
||||||
|
Один и тот же GUID передаётся сетевому объекту и service layer. Если он
|
||||||
|
разойдётся, экземпляры игры станут логически разными приложениями, даже при
|
||||||
|
исправном транспорте.
|
||||||
|
|
||||||
|
## `stdInitGame`
|
||||||
|
|
||||||
|
После платформенных сервисов World3D создаёт внутренний runtime:
|
||||||
|
|
||||||
|
1. Создаёт глобальную очередь объектов.
|
||||||
|
2. Сохраняет window handle и режим игры.
|
||||||
|
3. При нужном режиме ограничивает курсор областью окна.
|
||||||
|
4. Получает или создаёт 3D sound object.
|
||||||
|
5. Загружает реестр адресов компонентов из `Comp.ini`.
|
||||||
|
6. Получает или создаёт 3D renderer.
|
||||||
|
7. Читает профиль возможностей renderer.
|
||||||
|
8. Загружает component type 6.
|
||||||
|
9. Для multiplayer создаёт NetWatcher.
|
||||||
|
10. Получает world-interface из Terrain.
|
||||||
|
11. Устанавливает исходные параметры света и тумана.
|
||||||
|
|
||||||
|
Порядок важен. World objects не должны появляться до queue, ресурсы рендера --
|
||||||
|
до renderer, сетевые mirror objects -- до NetWatcher. В новой реализации у
|
||||||
|
каждого этапа должен быть явный признак успешного создания, чтобы shutdown мог
|
||||||
|
безопасно разобрать неполный init.
|
||||||
|
|
||||||
|
## Завершение
|
||||||
|
|
||||||
|
Shutdown идёт в обратном направлении: прекращаются игровые расчёты и сетевые
|
||||||
|
наблюдатели, разбираются отложенные операции, освобождаются world objects и
|
||||||
|
менеджеры, затем renderer и sound, затем game settings и platform services.
|
||||||
|
Ограничение курсора снимается, глобальные ссылки очищаются.
|
||||||
|
|
||||||
|
Полезный протокол завершения:
|
||||||
|
|
||||||
|
1. Запретить новые события и новые объекты.
|
||||||
|
2. Дождаться выхода из calculation/render traversal.
|
||||||
|
3. Разобрать очередь deferred operations.
|
||||||
|
4. Отсоединить объекты от очереди, контроллеров и менеджеров.
|
||||||
|
5. Освободить managers и singletons после их consumers.
|
||||||
|
6. Закрыть устройства и платформенные сервисы.
|
||||||
|
|
||||||
|
Такой порядок защищает от dangling-ссылок между World3D, Terrain, renderer,
|
||||||
|
sound и сетевым слоем.
|
||||||
|
|
||||||
|
## Главный цикл
|
||||||
|
|
||||||
|
Главный цикл -- не одна функция `update_and_render`, а расписание, связывающее
|
||||||
|
Win32 messages, input, игровые события, таймеры, сеть и renderer. Системная
|
||||||
|
очередь сообщает об активации окна, вводе, изменении состояния процесса и
|
||||||
|
выходе. Очередь World3D рассчитывает игровые объекты. У этих очередей разные
|
||||||
|
правила времени и владения, поэтому их нельзя смешивать в один контейнер.
|
||||||
|
|
||||||
|
Подтверждённые точки вызова в одном из профилей:
|
||||||
|
|
||||||
|
```text
|
||||||
|
stdCalculateGame RVA 0x5FA94, 0x604C1, 0x6086B
|
||||||
|
ClearManualEventsList RVA 0x6052F
|
||||||
|
stdRenderGame RVA 0x60B2F
|
||||||
|
UpdateManualEventsList в обработчике сообщений около RVA 0xA3759
|
||||||
|
```
|
||||||
|
|
||||||
|
Смысловой skeleton:
|
||||||
|
|
||||||
|
```c
|
||||||
|
while (running) {
|
||||||
|
stdCalculateGame();
|
||||||
|
clear_keyboard_snapshot();
|
||||||
|
update_shell_and_mode();
|
||||||
|
ClearManualEventsList();
|
||||||
|
process_window_messages();
|
||||||
|
update_timers_ui_gameplay_network();
|
||||||
|
if (mode_requires_extra_step) stdCalculateGame();
|
||||||
|
if (render_enabled) {
|
||||||
|
stdSetCurrentCamera(camera);
|
||||||
|
stdRenderGame(camera);
|
||||||
|
} else {
|
||||||
|
sleep_briefly();
|
||||||
|
}
|
||||||
|
update_post_render_state();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ввод из window messages накапливается между расчётными шагами. Если читать
|
||||||
|
клавиатуру только внутри рендера, события будут теряться при пропущенных кадрах
|
||||||
|
или отключённом выводе.
|
||||||
|
|
||||||
|
## `stdCalculateGame`
|
||||||
|
|
||||||
|
Calculation pass сначала очищает или подготавливает список manual events,
|
||||||
|
увеличивает внутренний depth/counter и опрашивает input device. Если устройство
|
||||||
|
временно потеряно, выполняется повторное получение доступа и чтение повторяется.
|
||||||
|
Затем при незамороженной игре выставляется признак `in_calculation` и вызывается
|
||||||
|
основной traversal очереди объектов.
|
||||||
|
|
||||||
|
```text
|
||||||
|
prepare input/events
|
||||||
|
-> enter calculation
|
||||||
|
-> dispatch queue events
|
||||||
|
-> objects update behavior and transforms
|
||||||
|
-> leave calculation
|
||||||
|
-> apply deferred operations
|
||||||
|
-> occasional cache maintenance
|
||||||
|
```
|
||||||
|
|
||||||
|
После traversal разбирается deferred-delete list. Объект может запросить
|
||||||
|
собственное удаление во время события, но память освобождается только после
|
||||||
|
завершения обхода. Периодически также очищаются давно неиспользуемые ресурсы и
|
||||||
|
объекты по порогам часов порядка 20 и 60 секунд.
|
||||||
|
|
||||||
|
Совместимый runtime должен иметь явный traversal depth или флаг
|
||||||
|
`in_calculation`. Нельзя полагаться на то, что контейнер выдержит удаление
|
||||||
|
текущего элемента из обработчика события.
|
||||||
|
|
||||||
|
## Жизненный цикл кадра
|
||||||
|
|
||||||
|
Рендер читает состояние, подготовленное расчётом. Кадр начинается до renderer-а:
|
||||||
|
message pump уже накопил ввод, World3D уже обновил объекты, отложенные операции
|
||||||
|
и анимации, после чего выбирается камера и обновляется listener звука.
|
||||||
|
|
||||||
|
```text
|
||||||
|
system messages and input
|
||||||
|
-> simulation calculation
|
||||||
|
-> deferred object operations
|
||||||
|
-> animation and transforms
|
||||||
|
-> camera and sound listener
|
||||||
|
-> visibility and render queues
|
||||||
|
-> materials and draw passes
|
||||||
|
-> renderer completion
|
||||||
|
-> end-of-render callbacks and UI
|
||||||
|
```
|
||||||
|
|
||||||
|
В `World3D::stdRenderGame` виден крупный каркас: установка camera/viewport,
|
||||||
|
renderer frame boundaries, traversal мира, завершение world/shade path,
|
||||||
|
renderer completion, снятие `in_render`, восстановление viewport и рассылка
|
||||||
|
end-of-render callbacks. Эти callbacks позволяют объектам безопасно обновить
|
||||||
|
временные ресурсы после того, как draw-команды больше их не используют.
|
||||||
|
|
||||||
|
Один calculation step не обязан соответствовать одному изображению. Главный
|
||||||
|
цикл допускает дополнительный вызов `stdCalculateGame` и режим, в котором
|
||||||
|
расчёт продолжается без вывода кадра. Поэтому нужно хранить отдельно:
|
||||||
|
|
||||||
|
1. монотонные платформенные часы;
|
||||||
|
2. игровое время с pause и масштабированием;
|
||||||
|
3. длительность текущего calculation step;
|
||||||
|
4. локальное время анимации и FX;
|
||||||
|
5. реальные часы обслуживания кэшей.
|
||||||
|
|
||||||
|
Игровую логику нельзя выводить из render delta: изменение частоты кадров тогда
|
||||||
|
изменит движение, камеру и сценарные таймеры. Подробности render item и рисков
|
||||||
|
кадровой совместимости вынесены в справочник [Render frame](../reference/render-frame.md).
|
||||||
|
|
||||||
|
## World3D
|
||||||
|
|
||||||
|
World3D связывает игровые объекты, события, время, ввод, камеру, сетевые
|
||||||
|
отражения и визуальное представление. Он не содержит всю предметную логику:
|
||||||
|
движение делегируется Behavior/Wizard, физика -- Control, мир -- Terrain. Его
|
||||||
|
задача -- общая идентичность, порядок вызовов и безопасный жизненный цикл.
|
||||||
|
|
||||||
|
`CreateQueue` создаёт singleton-объект размером 20 байт, а `GetQueue`
|
||||||
|
возвращает его. Очередь служит центральным маршрутизатором событий и операций
|
||||||
|
над объектами.
|
||||||
|
|
||||||
|
Публичный слой предоставляет отдельные функции для локальных и сетевых
|
||||||
|
объектов:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CreateObject
|
||||||
|
AddObjectToGame
|
||||||
|
AddNewObjectToGame
|
||||||
|
CreateMirrorObject
|
||||||
|
AddMirrorObjectToGame
|
||||||
|
AddNewMirrorToGame
|
||||||
|
```
|
||||||
|
|
||||||
|
Разделение "создать" и "добавить в игру" означает два этапа: сначала выделить и
|
||||||
|
настроить instance, затем зарегистрировать его в общей системе. Это позволяет
|
||||||
|
loader-у заполнить свойства до появления объекта в расчётной очереди.
|
||||||
|
|
||||||
|
## Идентичность объектов
|
||||||
|
|
||||||
|
Object ID кодирует не только порядковый номер. Проверки диапазонов показывают
|
||||||
|
разбиение на номер игрока, класс и индекс. Mirror object представляет объект,
|
||||||
|
владельцем которого является другой участник. Локальный runtime хранит его
|
||||||
|
видимое состояние, но источник авторитетных изменений находится удалённо.
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct ObjectId {
|
||||||
|
uint32_t raw;
|
||||||
|
uint16_t owner_player;
|
||||||
|
uint16_t class_and_index;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Точное битовое разбиение нужно брать из сетевых функций. На уровне API уже
|
||||||
|
сейчас полезно разделить логические свойства: `is_local`, `is_mirror`, `owner`,
|
||||||
|
`class` и `index`.
|
||||||
|
|
||||||
|
Минимальный runtime-object должен хранить identifier, type, owner, transform,
|
||||||
|
active state, ordered property bag, ссылки на controllers, участие в расчёте и
|
||||||
|
рендере, сетевой статус и флаг отложенного удаления. Специализированные DLL
|
||||||
|
могут быть представлены компонентами, но порядок их вызовов задаёт World3D.
|
||||||
|
|
||||||
|
## Отложенное удаление
|
||||||
|
|
||||||
|
`DeleteGameObject` проверяет, идёт ли calculation pass. Если обход активен и
|
||||||
|
удаление не принудительное, объект помещается в deferred list. `KillGameObject`
|
||||||
|
отправляет запрос через очередь, а не освобождает память напрямую.
|
||||||
|
|
||||||
|
```c
|
||||||
|
void request_delete(Object* o) {
|
||||||
|
if (world.in_calculation) {
|
||||||
|
world.deferred_delete.push_back(o);
|
||||||
|
o->pending_delete = true;
|
||||||
|
} else {
|
||||||
|
world.detach_and_release(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Это защищает итераторы, связи и текущий стек вызовов. Любая новая подсистема,
|
||||||
|
способная удалить объект из обработчика события, обязана пользоваться тем же
|
||||||
|
механизмом.
|
||||||
|
|
||||||
|
Регистрация в очереди и владение памятью -- разные понятия. Удаление из мира не
|
||||||
|
всегда означает немедленное освобождение instance: часть объектов и managers
|
||||||
|
использует intrusive reference count, а renderer, sound и resource managers
|
||||||
|
могут возвращать уже существующий singleton с увеличенным счётчиком. Поэтому
|
||||||
|
global manager закрывается после всех объектов, которые на него ссылаются.
|
||||||
|
|
||||||
|
## Детерминизм
|
||||||
|
|
||||||
|
Даже при одинаковых формулах результат зависит от порядка. Стабильный runtime
|
||||||
|
сохраняет последовательность queue traversal, момент формирования input
|
||||||
|
snapshot, порядок сетевых сообщений, обработку deferred operations и порядок
|
||||||
|
обращений к RNG. Оптимизация и многопоточность допустимы только при
|
||||||
|
детерминированном объединении результатов.
|
||||||
|
|
||||||
|
Для переносимой реализации полезно разделить scheduler phases и immutable
|
||||||
|
render snapshot. Это архитектурная рекомендация для новой реализации, а не
|
||||||
|
утверждение о точном layout исходных C++ classes.
|
||||||
|
|
||||||
|
## Стабильность между сборками
|
||||||
|
|
||||||
|
Внешняя архитектура полных Частей 1 и 2 сохраняет те же пятнадцать DLL, 313
|
||||||
|
exports, имена, ordinals и import sets. Побайтно идентичны:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ai.dll, Behavior.dll, Joystick.dll, MisLoad.dll, Net.dll,
|
||||||
|
Ngi32.dll, Terrain.dll, Wizard.dll, World3D.dll
|
||||||
|
```
|
||||||
|
|
||||||
|
Пересобраны:
|
||||||
|
|
||||||
|
```text
|
||||||
|
AniMesh.dll, ArealMap.dll, Control.dll, Effect.dll,
|
||||||
|
iron3d.dll, services.dll
|
||||||
|
```
|
||||||
|
|
||||||
|
Это разделяет переносимость выводов. World3D lifecycle, Terrain, NRes/RsLi
|
||||||
|
readers, mission loader, AI/Behavior/Wizard, DirectPlay wrapper и joystick
|
||||||
|
adapter подтверждаются одной машинной реализацией. Model/agent runtime,
|
||||||
|
collision, effects, shell/composition и service layer требуют отдельного
|
||||||
|
сравнения поведения Частей 1 и 2.
|
||||||
|
|
||||||
|
Для `World3D.dll` Частей 1 и 2 применим общий hash:
|
||||||
|
|
||||||
|
```text
|
||||||
|
World3D.dll SHA-256
|
||||||
|
17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e
|
||||||
|
```
|
||||||
|
|
||||||
|
RVA внутри `iron3d.dll` нельзя считать общими без проверки конкретного файла:
|
||||||
|
эта DLL пересобрана между частями, а демоверсия имеет отдельный binary profile.
|
||||||
|
Смысловая последовательность цикла переносится как контракт scheduler-а, но
|
||||||
|
адреса остаются build-specific.
|
||||||
@@ -0,0 +1,561 @@
|
|||||||
|
# III. Ресурсная система и форматы
|
||||||
|
|
||||||
|
Ресурсная система Iron3D переводит имена из миссий и прототипов в объекты,
|
||||||
|
которыми пользуются подсистемы мира, рендера, анимации, звука, эффектов и
|
||||||
|
управления. В этом пути участвуют несколько разных сущностей: файл на диске,
|
||||||
|
открытый архив, запись каталога, подготовленный payload и готовый runtime-объект.
|
||||||
|
Их нельзя смешивать, потому что у каждого уровня свой срок жизни, свои правила
|
||||||
|
кэширования и свой набор проверок.
|
||||||
|
|
||||||
|
Основной контейнер ресурсов -- [NRes](../reference/nres.md). Он используется как
|
||||||
|
внешний архив (`objects.rlb`, `Material.lib`, `Textures.lib`) и как внутренний
|
||||||
|
контейнер модели `*.msh`. Второй библиотечный формат -- [RsLi](../reference/rsli.md):
|
||||||
|
его каталог находится в начале файла, а payload может храниться raw, через
|
||||||
|
потоковое преобразование, LZSS, адаптивный Huffman + LZSS или raw Deflate.
|
||||||
|
Визуальная часть прототипа дальше проходит через [MSH](../reference/msh.md),
|
||||||
|
[WEAR/MAT0](../reference/materials.md) и [Texm](../reference/texm.md), но этот
|
||||||
|
том описывает именно ресурсный слой: как найти, проверить, раскрыть и сохранить
|
||||||
|
данные до передачи их предметным подсистемам.
|
||||||
|
|
||||||
|
```text
|
||||||
|
TMA или unit DAT
|
||||||
|
-> логический ключ
|
||||||
|
-> objects.rlb
|
||||||
|
-> archive.rlb :: model.msh
|
||||||
|
-> model.wea
|
||||||
|
-> Material.lib :: MAT0
|
||||||
|
-> Textures.lib / LightMap.lib :: Texm
|
||||||
|
```
|
||||||
|
|
||||||
|
На демо-корпусе эта цепочка проверена целиком для всех реально размещённых
|
||||||
|
объектов. При этом полная таблица прототипов может содержать ссылки на контент,
|
||||||
|
которого нет в урезанной поставке. Диагностика должна различать недостижимую
|
||||||
|
ссылку в общем реестре и ресурс, реально требуемый выбранной миссией.
|
||||||
|
|
||||||
|
## Ресурсный конвейер
|
||||||
|
|
||||||
|
Загрузка ресурса состоит из последовательных стадий:
|
||||||
|
|
||||||
|
1. Разрешить относительный путь с учётом глобального resource path и текущего
|
||||||
|
каталога игры.
|
||||||
|
2. Открыть архив или вернуть уже открытый archive object из кэша.
|
||||||
|
3. Найти запись каталога по имени, не меняя исходный порядок каталога.
|
||||||
|
4. Проверить bounds, размер payload и способ хранения.
|
||||||
|
5. Подготовить bytes: распаковать, применить потоковое преобразование или
|
||||||
|
вернуть raw-диапазон.
|
||||||
|
6. Разобрать предметный формат и создать объект подсистемы.
|
||||||
|
7. Сохранить готовый объект в отдельном кэше, если формат допускает повторное
|
||||||
|
использование.
|
||||||
|
|
||||||
|
Эти стадии дают четыре независимых уровня кэша:
|
||||||
|
|
||||||
|
1. Открытые архивы.
|
||||||
|
2. Каталоги имён, offsets и размеров.
|
||||||
|
3. Подготовленные блоки данных.
|
||||||
|
4. Кэши моделей, материалов, текстур, lightmaps, эффектов и служебных объектов.
|
||||||
|
|
||||||
|
Повторное открытие того же нормализованного пути возвращает существующий
|
||||||
|
archive object и увеличивает счётчик владельцев. Готовая texture или model при
|
||||||
|
этом может жить дольше file handle и иметь собственную политику удаления. Кэш
|
||||||
|
предметного объекта не должен напрямую закрывать архив: он зависит от данных,
|
||||||
|
но не владеет файлом как ресурсом операционной системы.
|
||||||
|
|
||||||
|
## Имена и пути
|
||||||
|
|
||||||
|
Большинство игровых имён сравнивается без учёта регистра в ASCII-диапазоне. Это
|
||||||
|
не Unicode case folding. Для совместимости достаточно нормализовать `A..Z` в
|
||||||
|
`a..z`, а для RsLi-поиска -- переводить запрос в uppercase ASCII и укладывать его
|
||||||
|
в фиксированный ключ.
|
||||||
|
|
||||||
|
Фиксированные строки читаются bounded parser-ом: строковая часть заканчивается
|
||||||
|
на первом NUL, но оставшийся хвост поля сохраняется. Нельзя очищать хвосты,
|
||||||
|
пересобирать регистр, заменять смешанные разделители или заранее переводить все
|
||||||
|
пути в абсолютные имена. Старые данные используют исторические имена библиотек,
|
||||||
|
разный регистр исходных путей и фиксированные поля, где после терминатора могут
|
||||||
|
оставаться значимые для roundtrip bytes.
|
||||||
|
|
||||||
|
## Строгий и совместимый режимы
|
||||||
|
|
||||||
|
Строгий reader нужен тестам, редактору и проверке корпуса. Он валидирует
|
||||||
|
структуру до выдачи любого `EntryView`: magic, версию, счётчики, арифметические
|
||||||
|
переполнения, bounds, sort permutation, alignment и точное завершение payload.
|
||||||
|
Если формат требует NUL-терминатор, строгий режим проверяет его именно в пределах
|
||||||
|
фиксированного поля.
|
||||||
|
|
||||||
|
Совместимый reader повторяет только известные особенности оригинала:
|
||||||
|
|
||||||
|
- линейный поиск при повреждённой сортировочной таблице;
|
||||||
|
- RsLi-исключение `deflate_eof_plus_one` для `sprites.lib::INTERF8.TEX`;
|
||||||
|
- material fallbacks, подтверждённые ресурсной цепочкой;
|
||||||
|
- отсутствие геометрии у системных и солнечных объектов, где mesh pass не
|
||||||
|
требуется.
|
||||||
|
|
||||||
|
Режим совместимости не должен скрывать произвольные ошибки. Каждое послабление
|
||||||
|
оформляется как именованное правило и покрывается отдельным тестом. Если quirk
|
||||||
|
применим только к Deflate-записи, он не распространяется на LZSS, Huffman или
|
||||||
|
raw-диапазоны.
|
||||||
|
|
||||||
|
## NRes
|
||||||
|
|
||||||
|
`NRes` хранит произвольные именованные payload и их атрибуты. Каталог расположен
|
||||||
|
в конце файла, поэтому начало каталога вычисляется из полного размера файла и
|
||||||
|
числа записей.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Header: 16 байт]
|
||||||
|
[Data region: payload с выравниванием]
|
||||||
|
[Directory: entry_count x 64 байта]
|
||||||
|
```
|
||||||
|
|
||||||
|
Все числа little-endian.
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct NResHeader16 {
|
||||||
|
char magic[4]; // "NRes"
|
||||||
|
uint32_t version; // 0x00000100
|
||||||
|
int32_t entry_count; // >= 0
|
||||||
|
uint32_t total_size; // равен фактическому размеру файла
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Производные значения:
|
||||||
|
|
||||||
|
```text
|
||||||
|
directory_size = entry_count * 64
|
||||||
|
directory_offset = total_size - directory_size
|
||||||
|
```
|
||||||
|
|
||||||
|
Reader проверяет, что `directory_offset >= 16`, умножение не переполнено, а
|
||||||
|
каталог заканчивается точно на `total_size`.
|
||||||
|
|
||||||
|
### Запись каталога NRes
|
||||||
|
|
||||||
|
```c
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct NResEntry64 {
|
||||||
|
uint32_t type_id; // +0x00
|
||||||
|
uint32_t attr1; // +0x04
|
||||||
|
uint32_t attr2; // +0x08
|
||||||
|
uint32_t size; // +0x0C
|
||||||
|
uint32_t attr3; // +0x10
|
||||||
|
char name[36]; // +0x14
|
||||||
|
uint32_t data_offset; // +0x38
|
||||||
|
uint32_t sort_index; // +0x3C
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
```
|
||||||
|
|
||||||
|
Имя содержит не более 35 полезных байт и завершающий ноль. Writer запрещает
|
||||||
|
внутренний NUL и слишком длинное имя, но сохраняет неизвестные атрибуты
|
||||||
|
`attr1`, `attr2`, `attr3` без нормализации. Их смысл зависит от конкретного
|
||||||
|
типа ресурса и не может быть выведен из контейнера.
|
||||||
|
|
||||||
|
Поле `sort_index` задаёт отображение из позиции в отсортированном списке в
|
||||||
|
исходный индекс записи. Каталог остаётся в исходном порядке. Поиск идёт по
|
||||||
|
отсортированному отображению, но возвращает исходную запись. При сохранении
|
||||||
|
writer строит массив исходных индексов, сортирует его по ASCII-case-insensitive
|
||||||
|
именам и записывает результат в `sort_index`. Если отображение нельзя использовать
|
||||||
|
или оно не является перестановкой в строгом режиме, совместимый путь переходит к
|
||||||
|
последовательному сравнению имён.
|
||||||
|
|
||||||
|
### Размещение данных NRes
|
||||||
|
|
||||||
|
Каждый active payload должен лежать после 16-байтового заголовка и полностью до
|
||||||
|
начала каталога. Канонические игровые файлы выравнивают начало следующего
|
||||||
|
payload до границы 8 байт нулевым заполнением.
|
||||||
|
|
||||||
|
Порядок canonical save:
|
||||||
|
|
||||||
|
1. Записать временный заголовок.
|
||||||
|
2. Записать payload всех записей в текущем порядке.
|
||||||
|
3. После каждого блока добавить нули до кратности 8.
|
||||||
|
4. Построить таблицу поиска имён.
|
||||||
|
5. Дописать каталог.
|
||||||
|
6. Записать окончательный `total_size`.
|
||||||
|
|
||||||
|
Строгий reader выполняет проверки до выдачи записи:
|
||||||
|
|
||||||
|
- `magic == "NRes"` и `version == 0x100`;
|
||||||
|
- `entry_count >= 0`, а `entry_count * 64` вычисляется без переполнения;
|
||||||
|
- `total_size` равен фактической длине файла;
|
||||||
|
- `directory_offset = total_size - entry_count * 64` не меньше 16;
|
||||||
|
- для каждой записи `data_offset >= 16` и `data_offset + size <= directory_offset`;
|
||||||
|
- поле имени содержит NUL в пределах 36 байт;
|
||||||
|
- каждый `sort_index < entry_count`;
|
||||||
|
- в строгом режиме все `sort_index` образуют перестановку `0..N-1`.
|
||||||
|
|
||||||
|
Нулевое заполнение до границы 8 байт -- подтверждённое поведение игровых
|
||||||
|
архивов и canonical writer-а. Reader не должен считать ненулевой gap частью
|
||||||
|
соседнего payload, но lossless-редактор сохраняет исходные bytes, если файл
|
||||||
|
открыт не в режиме канонической пересборки.
|
||||||
|
|
||||||
|
### Неплотная data region
|
||||||
|
|
||||||
|
Проверка 120 NRes-файлов / 6 804 entries Части 1 и 134 файлов / 8 171 entries
|
||||||
|
Части 2 не выявила нарушений magic, version, total size, bounds, sort
|
||||||
|
permutation, ASCII-order, 8-byte alignment или перекрытий активных payload.
|
||||||
|
Однако `Textures.lib` Части 2 содержит большой ненулевой диапазон в data region,
|
||||||
|
который не адресуется ни одной записью каталога. Первый активный payload
|
||||||
|
начинается значительно позже начала файла, а каталог и все активные entries
|
||||||
|
остаются корректными.
|
||||||
|
|
||||||
|
Следовательно, parser не должен требовать плотного покрытия data region. Нужно
|
||||||
|
различать три вида диапазонов:
|
||||||
|
|
||||||
|
- `active payload` -- bytes, на которые указывает запись каталога;
|
||||||
|
- `gap/padding` -- bytes между активными диапазонами;
|
||||||
|
- `unindexed preserved region` -- произвольные bytes, не принадлежащие ни одной
|
||||||
|
записи.
|
||||||
|
|
||||||
|
Canonical compact writer может исключить unindexed region только при явной
|
||||||
|
операции repack. Lossless editor сохраняет её побайтно вместе с исходным
|
||||||
|
порядком entries и gaps.
|
||||||
|
|
||||||
|
## RsLi
|
||||||
|
|
||||||
|
`RsLi` -- библиотечный архив с каталогом в начале файла. Записи могут храниться
|
||||||
|
в исходном виде или проходить один из поддержанных путей подготовки.
|
||||||
|
|
||||||
|
```text
|
||||||
|
[Header: 32 байта]
|
||||||
|
[Entry table: entry_count x 32 байта]
|
||||||
|
[Payloads]
|
||||||
|
[необязательный trailer]
|
||||||
|
```
|
||||||
|
|
||||||
|
Заголовок начинается с двух байт `NL`. Версия равна `1`, число записей хранится
|
||||||
|
как знаковое 16-битное значение. Поле по смещению `0x0E` может содержать
|
||||||
|
`0xABBA`: это означает, что отображение сортировки уже подготовлено.
|
||||||
|
|
||||||
|
Подтверждённые поля header:
|
||||||
|
|
||||||
|
```text
|
||||||
|
+0x00 char[2] "NL"
|
||||||
|
+0x02 u8 reserved, в корпусе 0
|
||||||
|
+0x03 u8 version, в корпусе 1
|
||||||
|
+0x04 i16 entry_count
|
||||||
|
+0x0E u16 presorted_flag, значение 0xABBA
|
||||||
|
+0x14 u32 xor_seed
|
||||||
|
```
|
||||||
|
|
||||||
|
Остальные bytes заголовка сохраняются без нормализации.
|
||||||
|
|
||||||
|
### Запись каталога RsLi
|
||||||
|
|
||||||
|
После подготовки таблицы каждая запись имеет layout 32 байта:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct RsLiEntry32 {
|
||||||
|
char name[12];
|
||||||
|
uint8_t service[4];
|
||||||
|
int16_t flags;
|
||||||
|
int16_t sort_to_original;
|
||||||
|
uint32_t unpacked_size;
|
||||||
|
uint32_t data_offset_raw;
|
||||||
|
uint32_t packed_size;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Имя обычно хранится в uppercase ASCII. Четыре служебных байта после имени
|
||||||
|
сохраняются без изменения. `sort_to_original` играет ту же роль, что и
|
||||||
|
`sort_index` в NRes: связывает отсортированную позицию с исходной записью.
|
||||||
|
|
||||||
|
Таблица на диске проходит обратимое побайтовое преобразование. Начальное
|
||||||
|
состояние берётся из младших 16 бит `xor_seed`. Если обозначить два байта
|
||||||
|
состояния как `lo` и `hi`, для каждого входного байта выполняется:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lo = hi XOR ((lo << 1) mod 256)
|
||||||
|
out = in XOR lo
|
||||||
|
hi = lo XOR (hi >> 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Операция симметрична: один и тот же цикл используется для подготовки и
|
||||||
|
восстановления. Состояние непрерывно проходит по всей таблице; его нельзя
|
||||||
|
перезапускать на каждой записи.
|
||||||
|
|
||||||
|
### Способы хранения RsLi
|
||||||
|
|
||||||
|
Способ определяется выражением `flags & 0x1E0`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0x000 исходный блок
|
||||||
|
0x020 только потоковое байтовое преобразование
|
||||||
|
0x040 LZSS
|
||||||
|
0x060 преобразование, затем LZSS
|
||||||
|
0x080 адаптивный Huffman, затем LZSS
|
||||||
|
0x0A0 преобразование, адаптивный Huffman и LZSS
|
||||||
|
0x100 raw Deflate без оболочки zlib
|
||||||
|
```
|
||||||
|
|
||||||
|
Reader обязан различать все значения, а неизвестную маску отклонять как
|
||||||
|
неподдерживаемую. После любого пути должно быть получено ровно `unpacked_size`
|
||||||
|
байт. Методы `0x080` и `0x0A0` подтверждены decoder-кодом и синтетическими
|
||||||
|
тестами, но живых payload этих веток в проверенных RsLi-файлах не найдено.
|
||||||
|
|
||||||
|
Параметры LZSS:
|
||||||
|
|
||||||
|
- размер кольцевого окна -- `4096`;
|
||||||
|
- начальное заполнение -- байт `0x20`;
|
||||||
|
- начальная позиция -- `0xFEE`;
|
||||||
|
- управляющие признаки читаются от младшего бита к старшему;
|
||||||
|
- двухбайтовая ссылка кодирует 12-битную позицию и длину `n + 3`;
|
||||||
|
- восстановленные bytes сразу записываются обратно в кольцевое окно.
|
||||||
|
|
||||||
|
В конце файла может находиться шестибайтовый media overlay trailer: два символа
|
||||||
|
`AO` и 32-битное значение `overlay`. В таком режиме фактическая позиция блока
|
||||||
|
равна `data_offset_raw + overlay`. Reader сначала проверяет, что overlay не
|
||||||
|
выходит за размер отображённого файла, затем проверяет весь диапазон записи.
|
||||||
|
|
||||||
|
### Поиск, кэш и проверки RsLi
|
||||||
|
|
||||||
|
Запрос имени переводится в uppercase ASCII и укладывается в фиксированный ключ.
|
||||||
|
При признаке `0xABBA` используется сохранённое отображение сортировки. Если
|
||||||
|
признака нет, loader строит его после чтения каталога. Некорректный индекс
|
||||||
|
приводит к последовательному поиску.
|
||||||
|
|
||||||
|
Файл открывается через memory mapping. Runtime-запись хранит указатель на
|
||||||
|
упакованный диапазон, размеры и необязательный указатель на подготовленные
|
||||||
|
данные. Первый обычный `load` создаёт буфер и сохраняет результат; повторный
|
||||||
|
возвращает его из кэша. Быстрый путь может вернуть указатель непосредственно в
|
||||||
|
mapped file только для исходного блока.
|
||||||
|
|
||||||
|
Reader проверяет:
|
||||||
|
|
||||||
|
- сигнатуру `NL`, служебный байт и версию;
|
||||||
|
- неотрицательное число записей;
|
||||||
|
- размещение всей таблицы в файле;
|
||||||
|
- что сохранённое отображение сортировки является перестановкой;
|
||||||
|
- что эффективный диапазон каждого блока не выходит за конец файла;
|
||||||
|
- что способ хранения известен;
|
||||||
|
- что после подготовки получено ровно `unpacked_size` байт.
|
||||||
|
|
||||||
|
В demo-каталоге и полных каталогах обеих частей наблюдаются два RsLi-файла:
|
||||||
|
|
||||||
|
```text
|
||||||
|
gamefont.rlb 2 entries, все 0x040 LZSS
|
||||||
|
sprites.lib 24 entries, все 0x100 raw Deflate
|
||||||
|
```
|
||||||
|
|
||||||
|
Последняя запись `sprites.lib::INTERF8.TEX` объявляет packed range, который
|
||||||
|
заканчивается на один байт после физического EOF. Совместимый путь читает на
|
||||||
|
один байт меньше; строгий путь регистрирует именованный quirk
|
||||||
|
`deflate_eof_plus_one`. Это исключение не распространяется на другие записи,
|
||||||
|
методы или произвольные выходы за конец файла.
|
||||||
|
|
||||||
|
Writer, который редактирует существующий архив, сохраняет все служебные bytes
|
||||||
|
заголовка и записей. Выбор оптимального способа упаковки для новых файлов
|
||||||
|
является отдельной политикой и не должен менять уже существующие entries без
|
||||||
|
явного запроса.
|
||||||
|
|
||||||
|
## Реестр объектов
|
||||||
|
|
||||||
|
Имя объекта в миссии является логическим ключом. Связь этого ключа с файлами
|
||||||
|
модели, материалов и служебных данных хранится в `objects.rlb`, который сам
|
||||||
|
использует формат NRes. Имя записи каталога -- ключ прототипа. Payload записи
|
||||||
|
состоит из записей по 64 байта:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct ObjectRef64 {
|
||||||
|
char archive_name[32];
|
||||||
|
char resource_name[32];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Payload каждой записи `objects.rlb` обязан быть кратен 64 байтам. Это
|
||||||
|
проверяется до чтения первой ссылки. Оба поля читаются как строки до первого
|
||||||
|
NUL, но полный 32-байтовый блок сохраняется при редактировании без очистки
|
||||||
|
хвоста.
|
||||||
|
|
||||||
|
Разрешение прототипа:
|
||||||
|
|
||||||
|
1. Найти entry реестра по логическому ключу без учёта ASCII-регистра.
|
||||||
|
2. Прочитать все `ObjectRef64` в исходном порядке.
|
||||||
|
3. Если ссылка указывает обратно в `objects.rlb`, рекурсивно раскрыть указанный
|
||||||
|
родительский prototype.
|
||||||
|
4. Объединить effective references родителя с локальными references дочерней
|
||||||
|
записи, сохранив порядок и происхождение.
|
||||||
|
5. Выбрать первую существующую ссылку с расширением `.msh`, открыть указанный
|
||||||
|
архив и найти модель по имени.
|
||||||
|
6. Загружать `.bas` как отдельный служебный ресурс сооружения, а не как замену
|
||||||
|
MSH.
|
||||||
|
7. Если effective prototype не содержит MSH, считать объект негеометрическим,
|
||||||
|
если это допускает его назначение.
|
||||||
|
|
||||||
|
Resolver обязан детектировать циклы наследования, ограничивать глубину и
|
||||||
|
кэшировать результат раскрытия. В обеих частях fortification-прототипы используют
|
||||||
|
явного родителя из `objects.rlb`: родитель предоставляет MSH/WEAR/CPT/NDP/CTL,
|
||||||
|
а дочерняя запись добавляет собственный BASE. Негеометрический объект не является
|
||||||
|
ошибкой сам по себе: системные и солнечные сущности могут участвовать в логике
|
||||||
|
или эффектах без mesh pass.
|
||||||
|
|
||||||
|
Контракт реализации:
|
||||||
|
|
||||||
|
- сохранять порядок ссылок внутри прототипа;
|
||||||
|
- не выводить имя модели из имени entry, если имеется явная ссылка;
|
||||||
|
- проверять существование указанного архива и ресурса независимо;
|
||||||
|
- отделять статус «негеометрический объект» от статуса «повреждённая ссылка»;
|
||||||
|
- кэшировать результат разрешения ключа, но инвалидировать его при замене архива;
|
||||||
|
- в diagnostic mode строить полный граф зависимостей и отмечать узлы, достижимые
|
||||||
|
из выбранной миссии.
|
||||||
|
|
||||||
|
В demo-варианте `objects.rlb` содержит 590 прототипов. У 554 есть прямая ссылка
|
||||||
|
на MSH; 549 таких ссылок разрешаются в доступных demo-архивах. Ещё 34 прототипа
|
||||||
|
раскрываются через родительскую запись `objects.rlb` и дополняются локальным
|
||||||
|
BASE. Семь записей не дают геометрию, а 41 ссылка всего реестра указывает на
|
||||||
|
контент, которого нет в урезанной поставке. Для 501 запросов прототипов,
|
||||||
|
порождаемых шестью demo-миссиями, найдены прототип, MSH и WEAR.
|
||||||
|
|
||||||
|
## Unit DAT
|
||||||
|
|
||||||
|
Запись миссии может ссылаться не на один ключ, а на unit-файл `*.dat`. Такой файл
|
||||||
|
перечисляет компоненты сложного игрового объекта.
|
||||||
|
|
||||||
|
```text
|
||||||
|
TMA object
|
||||||
|
-> путь к unit DAT
|
||||||
|
-> список component keys
|
||||||
|
-> несколько entries objects.rlb
|
||||||
|
-> модели, WEAR, control points, effects и другие ресурсы
|
||||||
|
```
|
||||||
|
|
||||||
|
Это объясняет, почему один размещённый unit может состоять из корпуса, башен,
|
||||||
|
оружия, эффектов и служебных частей. В демоверсии найдено 425 unit-файлов и
|
||||||
|
5 219 записей; все разобраны без ошибок. Наблюдаемый тип записи равен `1`, а
|
||||||
|
архив назначения -- `objects.rlb`. В 5 205 из 5 219 фиксированных полей имени
|
||||||
|
обнаружены ненулевые bytes после строкового терминатора; reader использует
|
||||||
|
строковую часть, а lossless writer сохраняет весь исходный блок.
|
||||||
|
|
||||||
|
Размер каждого unit DAT удовлетворяет формуле:
|
||||||
|
|
||||||
|
```text
|
||||||
|
file_size = 8 + record_count * 112
|
||||||
|
```
|
||||||
|
|
||||||
|
Первые два байта header равны `F1 F0`. Оставшиеся шесть bytes имеют несколько
|
||||||
|
наблюдаемых вариантов; их семантика пока не названа и они сохраняются как
|
||||||
|
`header_opaque[6]`.
|
||||||
|
|
||||||
|
```c
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct UnitDatRecord112 {
|
||||||
|
char archive_name[32]; // +0x00
|
||||||
|
char resource_name[32]; // +0x20
|
||||||
|
uint32_t kind; // +0x40, в корпусе всегда 1
|
||||||
|
int32_t parent_or_link; // +0x44
|
||||||
|
char description[32]; // +0x48
|
||||||
|
uint32_t tail0; // +0x68, opaque
|
||||||
|
uint32_t tail1; // +0x6C, opaque
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
```
|
||||||
|
|
||||||
|
Во всех проверенных records `archive_name == "objects.rlb"` и `kind == 1`.
|
||||||
|
Поле `parent_or_link` встречается как `-1`, `0`, `1` и другие небольшие индексы
|
||||||
|
и связывает компоненты составного unit; точная предметная классификация ссылки
|
||||||
|
ещё не закрыта. `description` -- человекочитаемое описание компонента. В Части 2
|
||||||
|
есть поля `description[32]`, полностью заполненные без NUL; это валидная bounded
|
||||||
|
string длиной 32 байта. Требование обязательного terminator применяется только
|
||||||
|
к полям, где оно доказано форматом. `tail0` и `tail1` нельзя нормализовать.
|
||||||
|
|
||||||
|
Проверено 425 файлов / 5 219 records Части 1 и 676 файлов / 8 145 records
|
||||||
|
Части 2. Все соответствуют формуле размера, `kind == 1` и
|
||||||
|
`archive_name == "objects.rlb"`.
|
||||||
|
|
||||||
|
## Вспомогательные форматы
|
||||||
|
|
||||||
|
MSH, материал и текстура отвечают за видимую форму. Полноценный прототип
|
||||||
|
дополнительно хранит точки крепления, зависимости, управляющие параметры,
|
||||||
|
области взаимодействия и ссылки на эффекты. Эти данные распределены между
|
||||||
|
несколькими небольшими форматами.
|
||||||
|
|
||||||
|
Для них действует строгая граница знания: framing, counts и валидность корпуса
|
||||||
|
могут быть подтверждены parser-ом, тогда как предметный смысл части полей
|
||||||
|
остаётся неизвестным. Reader предоставляет typed view для доказанных полей и
|
||||||
|
raw bytes для остальных. Инструмент должен показывать статус поля:
|
||||||
|
`layout-confirmed`, `consumer-inferred` или `opaque`.
|
||||||
|
|
||||||
|
### CTPT
|
||||||
|
|
||||||
|
В demo-корпусе найдено 284 CTPT-ресурса и 3 599 точек; все прочитаны без ошибок.
|
||||||
|
Имена показывают назначение слоя: `TurretCenter`, `TurretDirect`,
|
||||||
|
`CameraCenter`, `TargetDirect`, `Root`, `Sfx_1`, `Sign_Entrance1`, `Width`,
|
||||||
|
`Height`, `Dir`.
|
||||||
|
|
||||||
|
CTPT хранит локальные marker-точки модели. После применения transform такая точка
|
||||||
|
становится позицией или направлением в мире. Оружие может использовать её для
|
||||||
|
дула или оси башни, камера -- для привязки обзора, эффект -- для точки появления.
|
||||||
|
Конкретное назначение определяется именем и consumer-ом, а не одним общим флагом.
|
||||||
|
Первое 32-битное поле чаще равно `0`; встречаются `0x80000000` и редкий
|
||||||
|
вариант. До установления точной семантики оно хранится как `flags_raw`.
|
||||||
|
|
||||||
|
### NDPR
|
||||||
|
|
||||||
|
Проверено 494 NDPR-ресурса и 1 915 записей. Они ссылаются на `animals.rlb`,
|
||||||
|
`system.rlb`, `static.rlb`, `turrets.rlb`, `weapon.rlb` или используют пустое
|
||||||
|
имя архива. В 89 записях присутствует связанный эффект. Пустое имя архива
|
||||||
|
разрешается относительно текущего контекста. Reader хранит ссылку и остальные
|
||||||
|
параметры раздельно; writer сохраняет исходный порядок.
|
||||||
|
|
||||||
|
### EXPL и reference arrays
|
||||||
|
|
||||||
|
Проверено 144 ресурса EXPL: 26 используют версию 1, 54 -- версию 2, 64 --
|
||||||
|
версию 3. Reader выбирает layout по version field и требует точного завершения
|
||||||
|
payload. Полная field-level семантика всех версий пока не доказана, поэтому
|
||||||
|
version-specific opaque sections сохраняются.
|
||||||
|
|
||||||
|
Отдельная проверенная группа из 585 ресурсов содержит 2 956 однотипных
|
||||||
|
ссылочных records. Их границы и counts закрыты, однако единое предметное имя
|
||||||
|
всего семейства не подтверждено всеми consumers. В API безопаснее использовать
|
||||||
|
нейтральное `ReferenceArray` и конкретизировать назначение на уровне типа entry.
|
||||||
|
|
||||||
|
### SUND и CTLD
|
||||||
|
|
||||||
|
Два ресурса SUND содержат суммарно 12 ключей. Их следует загружать как параметры
|
||||||
|
системного объекта, а не как геометрию.
|
||||||
|
|
||||||
|
Для CTLD проверено 531 payload. Размеры и сочетания счётчиков сильно различаются,
|
||||||
|
поэтому parser должен быть версионно- и счётчик-ориентированным, а неизвестные
|
||||||
|
секции -- храниться в исходном виде.
|
||||||
|
|
||||||
|
### TRF, ANI и SKE
|
||||||
|
|
||||||
|
В демоверсии обнаружены 5 файлов TRF, 38 preload-записей, 8 ANI-ресурсов и
|
||||||
|
6 SKE-ресурсов. Все проходят структурный разбор. Эти семейства участвуют в
|
||||||
|
подготовке компонентов и анимационных или управляющих данных до создания
|
||||||
|
runtime-объекта.
|
||||||
|
|
||||||
|
Поскольку живой корпус невелик, редактор не должен синтезировать новые варианты
|
||||||
|
этих форматов по догадке. Безопасный режим -- читать доказанные счётчики и
|
||||||
|
ссылки, предоставлять raw-view неизвестных секций и обеспечивать побайтовое
|
||||||
|
сохранение неизменённых данных.
|
||||||
|
|
||||||
|
### BASE
|
||||||
|
|
||||||
|
Проверено 30 BASE-ресурсов; каждый содержит ровно один polygon record и проходит
|
||||||
|
структурную проверку. BASE payload и ссылка `.bas` в `objects.rlb` выполняют
|
||||||
|
связанные, но разные роли:
|
||||||
|
|
||||||
|
- наличие ссылки `.bas` позволяет registry resolver-у искать одноимённый
|
||||||
|
`<stem>.msh` в том же архиве;
|
||||||
|
- сам BASE payload загружается отдельной подсистемой сооружений и не заменяет
|
||||||
|
MSH geometry.
|
||||||
|
|
||||||
|
Resolver не должен интерпретировать bytes BASE как mesh. Writer сохраняет
|
||||||
|
polygon record и неизвестные поля 1:1, пока полный gameplay-контракт BASE не
|
||||||
|
подтверждён.
|
||||||
|
|
||||||
|
## Правило сохранения
|
||||||
|
|
||||||
|
Lossless editor сохраняет неизвестные поля, хвосты фиксированных строк,
|
||||||
|
служебные bytes, gaps, padding и unindexed regions. Writer пересчитывает только
|
||||||
|
явно производные значения: размеры, offsets, число записей, сортировочную
|
||||||
|
перестановку и padding. Такая дисциплина позволяет редактировать известную
|
||||||
|
часть ресурса, не разрушая данные, смысл которых пока не установлен.
|
||||||
|
|
||||||
|
Canonical repack допустим только как явная операция. Он может исключать
|
||||||
|
неиндексируемые диапазоны, пересортировывать таблицы и пересобирать padding, но
|
||||||
|
не должен быть побочным эффектом обычного редактирования. Если пользователь
|
||||||
|
открыл существующий архив и изменил один известный атрибут, все остальные bytes,
|
||||||
|
не являющиеся производными от этого изменения, должны пройти roundtrip без
|
||||||
|
потери.
|
||||||
@@ -0,0 +1,648 @@
|
|||||||
|
# IV. Мир, миссии и игровой runtime
|
||||||
|
|
||||||
|
Миссия в Iron3D не является готовым снимком мира. Она задаёт исходные данные:
|
||||||
|
маршруты, кланы, размещённые объекты, свойства, ссылку на ландшафт и
|
||||||
|
дополнительные записи. Runtime строит из этого карту, пространственные
|
||||||
|
структуры, очередь `World3D`, визуальные представления, controllers и связи с
|
||||||
|
ресурсной системой.
|
||||||
|
|
||||||
|
Для совместимой реализации важно не смешивать три слоя:
|
||||||
|
|
||||||
|
1. **Disk data** -- `data.tma`, `Land.msh`, `Land.map`, `BuildDat.lst` и
|
||||||
|
связанные resource archives.
|
||||||
|
2. **Prepared data** -- разобранные paths, clans, terrain streams, areal graph,
|
||||||
|
prototype graph, material и texture handles.
|
||||||
|
3. **Runtime objects** -- World3D instances, domain controllers, spatial
|
||||||
|
registration, AI/scripts, timers и расчётный tick.
|
||||||
|
|
||||||
|
Граница между этими слоями нужна для диагностики и отката. Ошибка в достижимой
|
||||||
|
цепочке размещённого объекта должна остановить создание миссии до публикации
|
||||||
|
объекта в очереди событий. Недостижимая запись общего архива может быть
|
||||||
|
inventory warning и не обязана блокировать текущую карту.
|
||||||
|
|
||||||
|
## `data.tma`: данные миссии
|
||||||
|
|
||||||
|
`data.tma` -- основное описание расстановки и логической конфигурации миссии.
|
||||||
|
Он не содержит всю геометрию, материалы или AI-код. Файл перечисляет paths,
|
||||||
|
clans, objects, свойства и ссылки на внешние прототипы. Подробный справочный
|
||||||
|
контракт формата вынесен в [TMA](../reference/tma.md), но глава использует его
|
||||||
|
как часть сквозного runtime pipeline.
|
||||||
|
|
||||||
|
TMA читается строго последовательно bounded cursor-ом. Записи имеют переменную
|
||||||
|
длину, поэтому offsets следующих секций получаются только после разбора
|
||||||
|
предыдущих. Секции нельзя искать по сигнатурам: порядок управляется счётчиками,
|
||||||
|
длинами и mode-dependent ветками.
|
||||||
|
|
||||||
|
Главный критерий корректности -- `cursor.offset == file_size` после последней
|
||||||
|
записи. Неописанный хвост, переполнение при вычислении размеров, отрицательный
|
||||||
|
или чрезмерный count и выход за bounds являются ошибками parser-а, а не
|
||||||
|
материалом для эвристического восстановления.
|
||||||
|
|
||||||
|
### Верхний уровень
|
||||||
|
|
||||||
|
Все переменные строки в проверенных TMA используют length-prefixed primitive:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct LpString {
|
||||||
|
uint32_t byte_length;
|
||||||
|
uint8_t bytes[byte_length];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Завершающий NUL не является обязательной частью framing. Reader продвигается
|
||||||
|
ровно на `4 + byte_length`. Текст можно декодировать как legacy ANSI/CP1251 для
|
||||||
|
человекочитаемого представления, но исходные bytes сохраняются для lossless
|
||||||
|
режима.
|
||||||
|
|
||||||
|
Подтверждённый верхний уровень:
|
||||||
|
|
||||||
|
```text
|
||||||
|
u32 format_version // 1
|
||||||
|
u32 path_count
|
||||||
|
PathRecord paths[path_count]
|
||||||
|
u32 clan_section_version // 6
|
||||||
|
u32 clan_count
|
||||||
|
ClanRecord clans[clan_count]
|
||||||
|
u32 object_section_version // 10
|
||||||
|
u32 object_count
|
||||||
|
PlacedObject objects[object_count]
|
||||||
|
LpString land_path
|
||||||
|
u32 mission_flag
|
||||||
|
LpString description_raw
|
||||||
|
u32 extra_section_version // 1
|
||||||
|
u32 extra_count
|
||||||
|
ExtraRecord28 extras[extra_count]
|
||||||
|
```
|
||||||
|
|
||||||
|
Имена `clan_section_version`, `object_section_version` и
|
||||||
|
`extra_section_version` описывают устойчивое положение полей в контракте. Они
|
||||||
|
не доказывают исходные имена C++-структур. Strict mode проверяет известные
|
||||||
|
значения, compatible mode сохраняет raw value и сообщает диагностический
|
||||||
|
контекст.
|
||||||
|
|
||||||
|
### Paths
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct PathRecord {
|
||||||
|
int32_t path_id;
|
||||||
|
uint32_t point_count;
|
||||||
|
float points[point_count][3];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Paths идут сразу после `path_count` без имён и padding. `path_id` не обязан
|
||||||
|
совпадать с физической позицией записи: script/gameplay reference должен
|
||||||
|
использовать сохранённый ID, а не индекс массива.
|
||||||
|
|
||||||
|
Перед выделением массива проверяются `point_count`, умножение `point_count *
|
||||||
|
12` и наличие всего диапазона в файле. Координаты хранятся как little-endian
|
||||||
|
`float32` triples в общей системе координат мира.
|
||||||
|
|
||||||
|
### Clans
|
||||||
|
|
||||||
|
Clan section задаёт участников миссии, их ресурсные связи, позиционные anchors
|
||||||
|
и таблицы отношений. Общая prefix-часть:
|
||||||
|
|
||||||
|
```text
|
||||||
|
LpString name
|
||||||
|
i32 raw_id
|
||||||
|
f32 anchor_x
|
||||||
|
f32 anchor_y
|
||||||
|
u32 mode
|
||||||
|
mode-dependent body
|
||||||
|
relation table
|
||||||
|
```
|
||||||
|
|
||||||
|
Для обычных modes `1..3` тело содержит две пары:
|
||||||
|
|
||||||
|
```text
|
||||||
|
LpString resource_path
|
||||||
|
i32 resource_tag
|
||||||
|
LpString resource_path
|
||||||
|
i32 resource_tag
|
||||||
|
```
|
||||||
|
|
||||||
|
После них идёт relation table:
|
||||||
|
|
||||||
|
```text
|
||||||
|
u32 relation_count
|
||||||
|
repeat relation_count:
|
||||||
|
LpString other_clan_name
|
||||||
|
i32 relation_value
|
||||||
|
```
|
||||||
|
|
||||||
|
Первая ресурсная строка обычно указывает на script/formula base, вторая -- на
|
||||||
|
TRF или пустой ресурс. Tags различаются между кланами и должны сохраняться как
|
||||||
|
raw-поля, пока их потребительская семантика не закрыта.
|
||||||
|
|
||||||
|
Mode `0` имеет отдельный count-driven layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
LpString first_resource
|
||||||
|
u32 spatial_group_count
|
||||||
|
repeat spatial_group_count:
|
||||||
|
u32 record_count
|
||||||
|
repeat record_count:
|
||||||
|
float raw_spatial[5]
|
||||||
|
LpString second_resource
|
||||||
|
i32 second_tag
|
||||||
|
u32 relation_count
|
||||||
|
relations...
|
||||||
|
```
|
||||||
|
|
||||||
|
Внутренний `record_count` в известных живых образцах равен `1`, но parser читает
|
||||||
|
объявленное значение. Нельзя разбирать mode `0` как обычные две resource
|
||||||
|
references: это сдвигает cursor и ломает последующую relation table.
|
||||||
|
|
||||||
|
### PlacedObject и свойства
|
||||||
|
|
||||||
|
Ключевое поле размещённого объекта -- `resource_name`. Оно имеет два рабочих
|
||||||
|
варианта:
|
||||||
|
|
||||||
|
1. прямой логический ключ прототипа, который ищется в `objects.rlb`;
|
||||||
|
2. путь к unit DAT, из которого получается список компонентных ключей.
|
||||||
|
|
||||||
|
Доказанное framing объектной записи:
|
||||||
|
|
||||||
|
```text
|
||||||
|
u32 raw_kind
|
||||||
|
u32 class_or_flags
|
||||||
|
LpString resource_name
|
||||||
|
u32 raw_after_resource
|
||||||
|
u32 identity_or_clan_raw
|
||||||
|
f32 position[3]
|
||||||
|
f32 orientation[3]
|
||||||
|
f32 scale[3]
|
||||||
|
LpString instance_name
|
||||||
|
u32 raw_after_name
|
||||||
|
i32 link0
|
||||||
|
i32 link1
|
||||||
|
u32 property_schema_version // 1
|
||||||
|
u32 property_count
|
||||||
|
Property properties[property_count]
|
||||||
|
```
|
||||||
|
|
||||||
|
`orientation[3]` названа по наблюдаемому использованию как transform-поле, но
|
||||||
|
точный Euler order должен подтверждаться pose/render parity. `scale` в
|
||||||
|
большинстве записей равен `(1,1,1)`. `instance_name` может быть пустым у
|
||||||
|
unit-ссылки или содержать stem размещённого прототипа.
|
||||||
|
|
||||||
|
Свойства хранятся как ordered property bag:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Property:
|
||||||
|
u32 raw_value[4]
|
||||||
|
LpString name
|
||||||
|
```
|
||||||
|
|
||||||
|
Порядок, повторяемость имени и raw 16-byte value важнее удобного словаря.
|
||||||
|
Разные consumers интерпретируют четыре слова как integer, float, default или
|
||||||
|
range data в зависимости от имени свойства. Typed view допустим только для
|
||||||
|
доказанных property names; базовый parser обязан сохранить исходный порядок.
|
||||||
|
|
||||||
|
В раннем проверенном корпусе на каждом из 201 размещённого объекта встречаются
|
||||||
|
`Invulnerability` и `Life state`. Для 48 unit-ссылок дополнительно наблюдаются
|
||||||
|
`LogicalID`, `ClanID`, `Type`, `MaxSpeedPercent`, `MaximumOre`, `CurrentOre`,
|
||||||
|
`ChargeRadius`, `FreeBotNum`, `FreeTechnoNum`, `FreeConstructionTime` и
|
||||||
|
`FreeResearchTime`. Имя `NOT USED` встречается массово и сохраняется как
|
||||||
|
обычное поле, несмотря на исторический смысл названия.
|
||||||
|
|
||||||
|
### Epilogue и extras
|
||||||
|
|
||||||
|
После объектов идут путь к ландшафту, флаг миссии, raw-описание и trailing
|
||||||
|
section. `description_raw` не всегда является чистым текстом: внутри
|
||||||
|
объявленной длины встречаются служебные bytes и остатки путей. Поэтому decoded
|
||||||
|
view является вспомогательным, а не каноническим представлением.
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct ExtraRecord28 {
|
||||||
|
float position[3];
|
||||||
|
uint32_t raw[4];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Последние четыре слова `ExtraRecord28` пока не нормализуются. Reader хранит их
|
||||||
|
как raw data и не позволяет extra record поглотить начало следующей секции или
|
||||||
|
файловый хвост.
|
||||||
|
|
||||||
|
Покрытие полных каталогов:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Часть 1: 29 TMA, 34 paths, 101 clans, 864 objects, 28 extra records
|
||||||
|
Часть 2: 31 TMA, 61 paths, 91 clans, 885 objects, 41 extra records
|
||||||
|
```
|
||||||
|
|
||||||
|
Версии стабильны: верхний уровень `1`, clan section `6`, object section `10`,
|
||||||
|
property schema `1`, trailing section `1`. У всех размещённых объектов
|
||||||
|
`class_or_flags == 0x80000002`.
|
||||||
|
|
||||||
|
## Сквозная загрузка миссии
|
||||||
|
|
||||||
|
`data.tma` описывает размещение, но видимый runtime-объект появляется только
|
||||||
|
после прохождения dependency graph. Простая загрузка файлов с похожим stem
|
||||||
|
работает на отдельных объектах, но ломается на составных unit DAT, изменённых
|
||||||
|
именах моделей и наследовании прототипов через `objects.rlb`.
|
||||||
|
|
||||||
|
Сквозная цепочка:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TMA object
|
||||||
|
-> direct prototype key или unit DAT
|
||||||
|
-> component key
|
||||||
|
-> objects.rlb entry
|
||||||
|
-> MSH и WEAR
|
||||||
|
-> material slots
|
||||||
|
-> MAT0 phases
|
||||||
|
-> Texm и lightmap
|
||||||
|
-> prepared World3D instance
|
||||||
|
```
|
||||||
|
|
||||||
|
Контейнеры и графические форматы описаны отдельно в [NRes](../reference/nres.md),
|
||||||
|
[MSH](../reference/msh.md), [WEAR и MAT0](../reference/materials.md) и
|
||||||
|
[Texm](../reference/texm.md). В этой главе они рассматриваются как ребра
|
||||||
|
создания мира.
|
||||||
|
|
||||||
|
### Фазы loader-а
|
||||||
|
|
||||||
|
1. **Mission context.** Выбрать каталог миссии, прочитать конфигурацию и
|
||||||
|
определить карту.
|
||||||
|
2. **World foundation.** Загрузить `Land.msh`, `Land.map`, `BuildDat.lst` и
|
||||||
|
создать spatial managers.
|
||||||
|
3. **Mission description.** Разобрать TMA, paths и clans, но пока не публиковать
|
||||||
|
объекты.
|
||||||
|
4. **Prototype resolution.** Для каждой размещённой сущности раскрыть прямой
|
||||||
|
ключ или unit DAT и построить component list.
|
||||||
|
5. **Resource preparation.** Открыть требуемые RLB/LIB, проверить MSH, WEAR,
|
||||||
|
MAT0, textures, lightmaps и effects.
|
||||||
|
6. **Instance construction.** Создать World3D objects и domain controllers,
|
||||||
|
заполнить transform, ownership и properties.
|
||||||
|
7. **Registration.** Только после успешной настройки добавить instances в
|
||||||
|
queue и spatial structures.
|
||||||
|
8. **Scenario start.** Подключить AI/scripts, активировать timers и разрешить
|
||||||
|
первый calculation tick.
|
||||||
|
|
||||||
|
Разделение construction и registration предотвращает появление наполовину
|
||||||
|
созданного объекта в очереди событий. Если ошибка возникает до регистрации,
|
||||||
|
pending objects освобождаются без рассылки gameplay-событий. После регистрации
|
||||||
|
откат выполняется через обычный lifecycle очереди.
|
||||||
|
|
||||||
|
### Статистика dependency graph
|
||||||
|
|
||||||
|
Для ранних шести миссий 201 размещённый объект даёт 48 ссылок на unit-файлы и
|
||||||
|
153 прямых ключа. Unit-файлы раскрываются в 348 компонентов. Всего получается
|
||||||
|
501 запрос прототипа; для каждого достижимого запроса найдены запись реестра,
|
||||||
|
MSH и WEAR.
|
||||||
|
|
||||||
|
Полный dependency graph частей 1 и 2:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Часть 1
|
||||||
|
864 placed objects
|
||||||
|
463 unit references -> 4 300 components
|
||||||
|
4 701 prototype/MSH/WEAR requests
|
||||||
|
36 954 material slots
|
||||||
|
48 806 texture requests + 139 lightmaps
|
||||||
|
failures 0
|
||||||
|
|
||||||
|
Часть 2
|
||||||
|
885 placed objects
|
||||||
|
561 unit references -> 5 521 components
|
||||||
|
5 845 prototype/MSH/WEAR requests
|
||||||
|
50 888 material slots
|
||||||
|
68 603 texture requests + 214 lightmaps
|
||||||
|
failures 0
|
||||||
|
```
|
||||||
|
|
||||||
|
`failures 0` означает, что для каждой достижимой ветви найдены prototype,
|
||||||
|
effective MSH/WEAR, MAT0, Texm и lightmap. Это не означает, что во всём
|
||||||
|
глобальном каталоге нет недостижимых или служебных записей.
|
||||||
|
|
||||||
|
Метрики нужно помечать областью. Чистая object chain шести ранних миссий даёт
|
||||||
|
3 873 material slots и 5 049 texture requests. Mission total включает по одной
|
||||||
|
environment WEAR-таблице на миссию и становится 3 879 material slots и 5 067
|
||||||
|
texture references.
|
||||||
|
|
||||||
|
### Диагностика ошибок
|
||||||
|
|
||||||
|
Ошибка привязывается к конкретному ребру графа:
|
||||||
|
|
||||||
|
- миссия ссылается на отсутствующий unit-файл;
|
||||||
|
- unit DAT раскрывается в component key, которого нет в реестре;
|
||||||
|
- prototype найден, но его MSH отсутствует в ожидаемом archive;
|
||||||
|
- WEAR указывает на неизвестный MAT0;
|
||||||
|
- MAT0 phase ссылается на отсутствующий Texm или lightmap;
|
||||||
|
- prepared object не прошёл валидацию transform/properties.
|
||||||
|
|
||||||
|
Сообщение вида `resource not found` недостаточно для восстановления каталога.
|
||||||
|
Диагностика должна содержать исходный placed object, раскрытый ключ, archive,
|
||||||
|
entry и тип связи.
|
||||||
|
|
||||||
|
## `Land.msh`: ландшафт как специализированная модель
|
||||||
|
|
||||||
|
`Land.msh` является [NRes](../reference/nres.md)-архивом, но его содержимое
|
||||||
|
отличается от обычной объектной MSH. Он хранит геометрию поверхности, таблицы
|
||||||
|
участков и ускорители пространственных запросов. Видимые buffers являются лишь
|
||||||
|
частью данных: CPU-подсистемам остаются нужны adjacency, surface classes и
|
||||||
|
cell accelerator streams.
|
||||||
|
|
||||||
|
Во всех проверенных картах порядок типов одинаков:
|
||||||
|
|
||||||
|
```text
|
||||||
|
1, 2, 3, 4, 5, 18, 14, 11, 21
|
||||||
|
```
|
||||||
|
|
||||||
|
Типы `1`, `3`, `4` и `5` совместимы по базовому представлению с узлами,
|
||||||
|
позициями, нормалями и UV обычной модели. Типы `11` и `21` специфичны для
|
||||||
|
terrain; `14` и `18` являются дополнительными потоками.
|
||||||
|
|
||||||
|
### Streams и размеры элементов
|
||||||
|
|
||||||
|
```text
|
||||||
|
type 1 38 байт node/slot mapping
|
||||||
|
type 3 12 байт float3 positions
|
||||||
|
type 4 4 байта packed normals
|
||||||
|
type 5 4 байта packed UV
|
||||||
|
type 11 4 байта cell accelerator data
|
||||||
|
type 14 4 байта auxiliary stream
|
||||||
|
type 18 4 байта auxiliary stream
|
||||||
|
type 21 28 байт terrain face
|
||||||
|
```
|
||||||
|
|
||||||
|
Для этих streams `attr1` соответствует числу элементов, а `attr3` -- stride.
|
||||||
|
Тип `2` начинается заголовком размером `0x8C`, после которого идут slot records
|
||||||
|
по 68 байт. Число slots вычисляется как `(size - 0x8C) / 68`; reader проверяет
|
||||||
|
делимость, bounds и отсутствие хвоста.
|
||||||
|
|
||||||
|
### `TerrainFace28`
|
||||||
|
|
||||||
|
Запись type `21` связывает triangles, соседей и surface metadata:
|
||||||
|
|
||||||
|
```text
|
||||||
|
+0x00 .. +0x07 flags и служебные поля
|
||||||
|
+0x08 u16 vertex0
|
||||||
|
+0x0A u16 vertex1
|
||||||
|
+0x0C u16 vertex2
|
||||||
|
+0x0E u16 neighbor0
|
||||||
|
+0x10 u16 neighbor1
|
||||||
|
+0x12 u16 neighbor2
|
||||||
|
+0x14 .. +0x1B material/class/edge fields
|
||||||
|
```
|
||||||
|
|
||||||
|
Каждый vertex index обязан быть меньше числа позиций type `3`. Neighbor равен
|
||||||
|
`0xFFFF` либо указывает на другой элемент type `21`. Последние восемь bytes
|
||||||
|
сохраняются без нормализации до полного закрытия предметной семантики.
|
||||||
|
|
||||||
|
### Маски поверхности
|
||||||
|
|
||||||
|
Runtime использует полную 32-битную маску face и два compact-представления.
|
||||||
|
Основное 16-битное поле собирается из отдельных битов полной маски; второе
|
||||||
|
шестибитное поле хранит material classes. Это не усечение младших битов.
|
||||||
|
|
||||||
|
Для совместимого writer-а нужны явные функции `full_to_compact()` и
|
||||||
|
`compact_to_full()`. Неизвестные биты полной маски сохраняются отдельно, иначе
|
||||||
|
обратное преобразование потеряет информацию.
|
||||||
|
|
||||||
|
Основное соответствие:
|
||||||
|
|
||||||
|
```text
|
||||||
|
full 00000001 -> compact 0001
|
||||||
|
full 00000008 -> compact 0002
|
||||||
|
full 00000010 -> compact 0004
|
||||||
|
full 00000020 -> compact 0008
|
||||||
|
full 00001000 -> compact 0010
|
||||||
|
full 00004000 -> compact 0020
|
||||||
|
full 00000002 -> compact 0040
|
||||||
|
full 00000400 -> compact 0080
|
||||||
|
full 00000800 -> compact 0100
|
||||||
|
full 00020000 -> compact 0200
|
||||||
|
full 00002000 -> compact 0400
|
||||||
|
full 00000200 -> compact 0800
|
||||||
|
full 00000004 -> compact 1000
|
||||||
|
full 00000040 -> compact 2000
|
||||||
|
full 00200000 -> compact 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Для шестибитного material-поля используются full-биты `0x100`, `0x8000`,
|
||||||
|
`0x10000`, `0x40000`, `0x80000` и `0x80`; они переходят соответственно в
|
||||||
|
compact-биты `1`, `2`, `4`, `8`, `0x10`, `0x20`.
|
||||||
|
|
||||||
|
### Проверенное покрытие
|
||||||
|
|
||||||
|
```text
|
||||||
|
AutoMAP 3 051 вершина, 3 174 faces
|
||||||
|
PROL 11 125 вершин, 9 234 faces
|
||||||
|
Tut_1 8 827 вершин, 8 290 faces
|
||||||
|
Tut_2 9 456 вершин, 8 996 faces
|
||||||
|
Tut_3 9 833 вершины, 8 560 faces
|
||||||
|
Tut_4 9 022 вершины, 8 612 faces
|
||||||
|
```
|
||||||
|
|
||||||
|
Расширенное покрытие:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Часть 1: 33 карты, 299 450 vertices, 275 882 faces
|
||||||
|
Часть 2: 32 карты, 188 024 vertices, 184 454 faces
|
||||||
|
```
|
||||||
|
|
||||||
|
Во всех 65 картах порядок типов равен `[1,2,3,4,5,18,14,11,21]`. Strides,
|
||||||
|
count-driven размеры, vertex indices, neighbor indices и payload bounds
|
||||||
|
валидны. Различия карт являются различиями данных, а не новым вариантом
|
||||||
|
loader-а.
|
||||||
|
|
||||||
|
## `Land.map` и ArealMap
|
||||||
|
|
||||||
|
`Land.map` хранит логическое разбиение пространства на связанные области. Это
|
||||||
|
NRes-архив с одной записью type `12`. Payload содержит переменное число
|
||||||
|
ареалов, links и grid быстрого поиска.
|
||||||
|
|
||||||
|
Ареал -- участок мира с геометрической границей и метаданными. Граф соседств
|
||||||
|
позволяет искать маршрут между крупными областями вместо обхода каждой
|
||||||
|
terrain-вершины. Grid отвечает на быстрый вопрос: какие области потенциально
|
||||||
|
находятся рядом с координатой.
|
||||||
|
|
||||||
|
### Prefix ареала
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct ArealPrefix56 {
|
||||||
|
float anchor_x;
|
||||||
|
float anchor_y;
|
||||||
|
float anchor_z;
|
||||||
|
float reserved_12;
|
||||||
|
float area_metric;
|
||||||
|
float normal_x;
|
||||||
|
float normal_y;
|
||||||
|
float normal_z;
|
||||||
|
uint32_t logic_flag;
|
||||||
|
uint32_t reserved_36;
|
||||||
|
uint32_t class_id;
|
||||||
|
uint32_t reserved_44;
|
||||||
|
uint32_t vertex_count;
|
||||||
|
uint32_t poly_count;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
После prefix идут `float3 vertices[vertex_count]`. Нормаль в проверенных
|
||||||
|
записях имеет длину, практически равную единице. Поля `reserved_12`,
|
||||||
|
`reserved_36` и `reserved_44` в живом корпусе равны нулю, но writer сохраняет
|
||||||
|
их без нормализации.
|
||||||
|
|
||||||
|
### Links и polygon blocks
|
||||||
|
|
||||||
|
За вершинами хранится массив:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct EdgeLink8 {
|
||||||
|
int32_t area_ref;
|
||||||
|
int32_t edge_ref;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Пара `(-1, -1)` означает отсутствие соседа. Иначе `area_ref` указывает на
|
||||||
|
другую область, а `edge_ref` -- на соответствующее ребро. Число пар равно
|
||||||
|
`vertex_count + 3 * poly_count`.
|
||||||
|
|
||||||
|
После links для каждого polygon читается `u32 n`, затем block размером
|
||||||
|
`4 * (3*n + 1)` bytes. Во всех 65 проверенных картах `poly_count == 0`.
|
||||||
|
Framing ветки восстановлен по loader path, но предметное поведение polygon
|
||||||
|
blocks не получает статус corpus-verified.
|
||||||
|
|
||||||
|
### Grid быстрого поиска
|
||||||
|
|
||||||
|
После всех ареалов записаны `cellsX` и `cellsY`. Далее для каждой ячейки идут
|
||||||
|
`u16 hitCount` и `hitCount` номеров областей. Runtime уплотняет это в одно
|
||||||
|
32-битное значение: старшие 10 бит содержат число попаданий, младшие 22 --
|
||||||
|
начальный индекс в общем пуле.
|
||||||
|
|
||||||
|
Grid не является точной геометрической проверкой. Он возвращает короткий список
|
||||||
|
candidates, после чего выполняется проверка принадлежности области. При
|
||||||
|
загрузке каждый area ID обязан быть меньше общего числа ареалов.
|
||||||
|
|
||||||
|
Покрытие:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Ранние шесть карт: 3 811 areals, grid 128 x 128
|
||||||
|
Часть 1: 33 карты, 34 662 areals, 197 698 areal vertices
|
||||||
|
Часть 2: 32 карты, 18 984 areals, 114 968 areal vertices
|
||||||
|
```
|
||||||
|
|
||||||
|
Во всех картах grid равен `128 x 128`. Максимальное число candidates в ячейке
|
||||||
|
-- 20 для Части 1 и 14 для Части 2. Все area/edge references находятся в
|
||||||
|
диапазоне, normals имеют единичную длину в пределах float32-погрешности, parser
|
||||||
|
заканчивается точно на конце payload.
|
||||||
|
|
||||||
|
## Пространственные задачи runtime
|
||||||
|
|
||||||
|
Движок решает три похожих, но независимых вопроса:
|
||||||
|
|
||||||
|
- **видимость** -- нужно ли рисовать объект для текущей камеры;
|
||||||
|
- **столкновение** -- пересекается ли движение с поверхностью или другим телом;
|
||||||
|
- **навигация** -- через какие области допустимо провести маршрут.
|
||||||
|
|
||||||
|
Terrain, Control и ArealMap используют общие координаты мира, но разные
|
||||||
|
структуры данных. Нельзя заменять навигационный граф видимыми triangles или
|
||||||
|
вычислять collision только по границе areal. Render frame описан отдельно в
|
||||||
|
[Render frame](../reference/render-frame.md); здесь важна подготовка world data,
|
||||||
|
которую renderer получает уже после загрузки миссии.
|
||||||
|
|
||||||
|
### Поиск области
|
||||||
|
|
||||||
|
Координата переводится в ячейку grid из `Land.map`. Ячейка даёт список
|
||||||
|
candidate areas, затем выполняется точная геометрическая проверка. Такой запрос
|
||||||
|
не перебирает все области карты и не зависит от количества terrain faces.
|
||||||
|
|
||||||
|
Если координата попадает в несколько candidates, выбор должен учитывать
|
||||||
|
геометрию boundary и class/logic flags, а не только первый ID из grid cell.
|
||||||
|
Если область не найдена, caller получает явный miss и решает, допустим ли
|
||||||
|
fallback к ближайшей области.
|
||||||
|
|
||||||
|
### Маршрут
|
||||||
|
|
||||||
|
После определения начальной и целевой областей маршрут строится по графу
|
||||||
|
соседств. Результат высокого уровня -- последовательность areal IDs. Из неё
|
||||||
|
формируется локальный corridor, внутри которого movement controller выбирает
|
||||||
|
конкретное движение по поверхности.
|
||||||
|
|
||||||
|
Такое разделение оставляет навигацию устойчивой к деталям terrain mesh:
|
||||||
|
изменение density triangles не должно менять high-level route, пока areal graph
|
||||||
|
и links остаются теми же.
|
||||||
|
|
||||||
|
### Категории зон объектов
|
||||||
|
|
||||||
|
`BuildDat.lst` связывает 12 имён категорий с 32-битными масками:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Bunker_Small 80010000
|
||||||
|
Bunker_Medium 80020000
|
||||||
|
Bunker_Large 80040000
|
||||||
|
Generator 80000002
|
||||||
|
Mine 80000004
|
||||||
|
Storage 80000008
|
||||||
|
Plant 80000010
|
||||||
|
Hangar 80000040
|
||||||
|
MainTeleport 80000200
|
||||||
|
Institute 80000400
|
||||||
|
Tower_Medium 80100000
|
||||||
|
Tower_Large 80200000
|
||||||
|
```
|
||||||
|
|
||||||
|
Файл читается секционно. Неизвестное имя, дублирование или нарушенная структура
|
||||||
|
не должны тихо превращаться в нулевую маску. Нулевая маска является
|
||||||
|
диагностируемым состоянием, а не универсальным default.
|
||||||
|
|
||||||
|
## Создание мира
|
||||||
|
|
||||||
|
Инициализация карты должна быть staged pipeline, а не набором независимых
|
||||||
|
autoload-ов:
|
||||||
|
|
||||||
|
1. открыть `Land.msh` и построить geometry/spatial данные terrain;
|
||||||
|
2. открыть `Land.map` и создать areals, links и cell grid;
|
||||||
|
3. загрузить категории `BuildDat.lst`;
|
||||||
|
4. создать world managers для поверхности, областей, света и атмосферы;
|
||||||
|
5. разобрать TMA, paths и clans;
|
||||||
|
6. раскрыть object resources через unit DAT и `objects.rlb`;
|
||||||
|
7. подготовить MSH, WEAR, MAT0, Texm, lightmap и FXID dependencies;
|
||||||
|
8. создать World3D objects и domain controllers в pending state;
|
||||||
|
9. проверить cross references между components, controllers и spatial data;
|
||||||
|
10. зарегистрировать visual, physical и behavior components;
|
||||||
|
11. подключить AI/scripts и разрешить первый calculation tick.
|
||||||
|
|
||||||
|
Минимальный псевдокод объектной части:
|
||||||
|
|
||||||
|
```c
|
||||||
|
for (const PlacedObject& placed : mission.objects) {
|
||||||
|
vector<string> keys = expand_resource_name(placed.resource_name);
|
||||||
|
|
||||||
|
for (const string& key : keys) {
|
||||||
|
Prototype p = registry.resolve(key);
|
||||||
|
PreparedVisual v = prepare_visual(p);
|
||||||
|
Object* o = construct_component(p, v, placed.properties);
|
||||||
|
|
||||||
|
o->set_world_transform(placed.transform);
|
||||||
|
pending_registration.push_back(o);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_cross_references(pending_registration);
|
||||||
|
register_all(pending_registration);
|
||||||
|
```
|
||||||
|
|
||||||
|
`prepare_visual` использует явные ссылки прототипа и правила fallback ресурсной
|
||||||
|
системы. Она не должна угадывать модель по имени placed object, если prototype
|
||||||
|
уже задаёт другой effective MSH/WEAR.
|
||||||
|
|
||||||
|
## Инварианты реализации
|
||||||
|
|
||||||
|
- Reader всех count-driven структур проверяет overflow до выделения памяти.
|
||||||
|
- Parser TMA, `Land.msh` и `Land.map` завершает работу точно на конце своего
|
||||||
|
payload.
|
||||||
|
- Неизвестные поля, reserved bytes, raw strings и property values сохраняются
|
||||||
|
lossless.
|
||||||
|
- Object properties остаются ordered property bag; сортировка имён запрещена.
|
||||||
|
- Clan relations и area links проверяются на диапазон, но физический порядок
|
||||||
|
записей сохраняется.
|
||||||
|
- Terrain vertex indices, face neighbors и areal references валидируются до
|
||||||
|
публикации spatial managers.
|
||||||
|
- Достижимый missing resource останавливает mission load до регистрации
|
||||||
|
объектов; недостижимая запись общего каталога остаётся диагностикой.
|
||||||
|
- Calculation tick включается только после успешной сборки terrain, areal graph,
|
||||||
|
managers, object queue и scenario bindings.
|
||||||
@@ -0,0 +1,863 @@
|
|||||||
|
# V. Геометрия, материалы и рендер
|
||||||
|
|
||||||
|
Этот том описывает путь от загруженного игрового состояния до pixels в back
|
||||||
|
buffer. Renderer не решает игровые правила: он получает transforms, geometry,
|
||||||
|
материалы, свет, эффекты, камеру и список видимых объектов, затем превращает
|
||||||
|
их в упорядоченный набор draw calls и fixed-function states.
|
||||||
|
|
||||||
|
Графический pipeline FParkan держится на нескольких слоях данных:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MSH node/slot/batch
|
||||||
|
-> Batch20.material_index
|
||||||
|
-> строка WEAR
|
||||||
|
-> имя MAT0
|
||||||
|
-> активная phase
|
||||||
|
-> textureName и lightmap slot
|
||||||
|
-> Texm payload
|
||||||
|
-> LegacyRenderState
|
||||||
|
-> draw item кадра
|
||||||
|
```
|
||||||
|
|
||||||
|
Важное практическое правило: форматы ресурсов, runtime-состояние renderer-а и
|
||||||
|
современный backend являются разными уровнями. Файл можно прочитать правильно и
|
||||||
|
всё равно получить неверный кадр из-за другой сортировки, другого mip-skip,
|
||||||
|
другой ветки material fallback или другого округления animation time.
|
||||||
|
|
||||||
|
## Контур рендера
|
||||||
|
|
||||||
|
Изображение является последней стадией длинного цикла. До renderer-а уже
|
||||||
|
накоплен ввод, рассчитан simulation step, применены отложенные операции,
|
||||||
|
обновлены animation states, выбрана camera и выставлен listener для 3D sound.
|
||||||
|
|
||||||
|
```text
|
||||||
|
system messages and input
|
||||||
|
-> simulation calculation
|
||||||
|
-> deferred object operations
|
||||||
|
-> animation and transforms
|
||||||
|
-> camera and sound listener
|
||||||
|
-> visibility and render queues
|
||||||
|
-> materials and draw passes
|
||||||
|
-> renderer completion
|
||||||
|
-> end-of-render callbacks and UI
|
||||||
|
```
|
||||||
|
|
||||||
|
CPU делает отбор объектов, сэмплирует animation, собирает matrices, выбирает
|
||||||
|
LOD/slot, группирует batches и готовит состояния. Графический pipeline
|
||||||
|
преобразует вершины из model space в screen space, rasterizes triangles,
|
||||||
|
проверяет depth, применяет texture stages, lighting, alpha test/blend и пишет
|
||||||
|
pixels.
|
||||||
|
|
||||||
|
Координатный путь вершины:
|
||||||
|
|
||||||
|
```text
|
||||||
|
local/model space
|
||||||
|
-> world space
|
||||||
|
-> view/camera space
|
||||||
|
-> clip space
|
||||||
|
-> normalized device coordinates
|
||||||
|
-> viewport pixels
|
||||||
|
```
|
||||||
|
|
||||||
|
Порядок умножения матриц и соглашение о layout должны быть едины во всём
|
||||||
|
движке. Ошибка транспонирования часто выглядит как сломанная анимация, хотя
|
||||||
|
ключи модели прочитаны верно.
|
||||||
|
|
||||||
|
## Граница Ngi32
|
||||||
|
|
||||||
|
`Ngi32.dll` является платформенной границей Iron3D-era renderer-а. Она создаёт
|
||||||
|
графический и звуковой interfaces, перечисляет устройства, хранит capability
|
||||||
|
profile, предоставляет память, часы и быстрые математические процедуры.
|
||||||
|
Высокоуровневые DLL должны обращаться к interface Ngi32, а не напрямую к
|
||||||
|
конкретному DirectDraw/Direct3D device.
|
||||||
|
|
||||||
|
`iron_3d.ini` задаёт выбранный `CURRENT_D3DCARD`. Display layer перечисляет
|
||||||
|
drivers и video modes, проверяет поддержку 3D, переводит native capabilities во
|
||||||
|
внутренний профиль и создаёт render object. `niCreate3DRender` принимает
|
||||||
|
выбранный driver/mode, window handle и flags владения, динамически получает
|
||||||
|
функции DirectDraw/Direct3D семейства 5-7 и публикует refcounted renderer.
|
||||||
|
`niGet3DRender` возвращает уже созданный объект и увеличивает число владельцев.
|
||||||
|
|
||||||
|
```text
|
||||||
|
enumerate adapters and video modes
|
||||||
|
-> choose CURRENT_D3DCARD
|
||||||
|
-> translate native capabilities
|
||||||
|
-> create DirectDraw surfaces and 3D interface
|
||||||
|
-> construct engine renderer
|
||||||
|
-> publish global refcounted pointer
|
||||||
|
```
|
||||||
|
|
||||||
|
Старый API работает как state machine. Перед draw подсистема terrain/shade
|
||||||
|
выбирает matrices, texture stages, filtering, depth test/write, culling, alpha
|
||||||
|
test, blending и vertex format. Современный backend может собрать это в
|
||||||
|
immutable pipeline key и реализовать через shaders, но compatibility layer
|
||||||
|
должен видеть исходную fixed-function модель.
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct LegacyRenderState {
|
||||||
|
Mat4 world, view, projection;
|
||||||
|
TextureStage stages[2];
|
||||||
|
BlendMode blend;
|
||||||
|
DepthMode depth;
|
||||||
|
CullMode cull;
|
||||||
|
bool alpha_test;
|
||||||
|
uint8_t alpha_ref;
|
||||||
|
VertexFormat vertex_format;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Эта структура является переносимой моделью наблюдаемого контракта, а не
|
||||||
|
утверждением о точном layout оригинального объекта renderer-а.
|
||||||
|
|
||||||
|
Отдельная часть ABI -- таблица `g_FastProc`. При запуске выбираются scalar,
|
||||||
|
MMX, Katmai/SSE, 3DNow или PPro-реализации процедур, а `niGetProcAddress(index)`
|
||||||
|
возвращает pointer из изменяемой таблицы. Номер slot является частью ABI:
|
||||||
|
signature менять нельзя. Различия scalar/SIMD округления способны менять
|
||||||
|
animation sampling, culling, particles и даже gameplay-adjacent decisions.
|
||||||
|
|
||||||
|
## MSH как граф модели
|
||||||
|
|
||||||
|
`*.msh` является nested NRes, а не одной монолитной структурой. Geometry,
|
||||||
|
nodes, slots, batches, animation и служебные streams лежат в отдельных entries
|
||||||
|
и связываются по `type_id`. Физический порядок entries сохраняется для
|
||||||
|
roundtrip, но reader не должен выводить из него смысловую связь.
|
||||||
|
|
||||||
|
Карта основных entries:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type 1 узлы и выбор slot, обычно stride 38
|
||||||
|
type 2 header 0x8C + slots по 68 байт
|
||||||
|
type 3 positions float3, stride 12
|
||||||
|
type 4 packed normals, stride 4
|
||||||
|
type 5 packed UV0, stride 4
|
||||||
|
type 6 index buffer, u16
|
||||||
|
type 7 triangle descriptors, stride 16
|
||||||
|
type 8 animation keys, stride 24
|
||||||
|
type 9 служебный поток модели
|
||||||
|
type 10 строки и имена узлов
|
||||||
|
type 13 draw batches, stride 20
|
||||||
|
type 15 дополнительный поток, stride 8
|
||||||
|
type 17 вспомогательные данные
|
||||||
|
type 18 редкий поток, stride 4
|
||||||
|
type 19 animation frame map, u16
|
||||||
|
type 20 редкая вспомогательная таблица
|
||||||
|
```
|
||||||
|
|
||||||
|
Базовый набор types стабилен для проверенных моделей Частей 1 и 2. Расширенный
|
||||||
|
вариант добавляет types 18 и 20. Редкий вариант `MTCHECK.MSH` имеет
|
||||||
|
альтернативный атрибут type 1; его payload нужно поддерживать copy-through до
|
||||||
|
закрытия layout.
|
||||||
|
|
||||||
|
### Узлы и slots
|
||||||
|
|
||||||
|
Type 1 обычно состоит из записей по 38 байт:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct Node38 {
|
||||||
|
uint16_t hdr0;
|
||||||
|
uint16_t parent_or_link;
|
||||||
|
uint16_t anim_map_start;
|
||||||
|
uint16_t fallback_key;
|
||||||
|
uint16_t slot_index[15];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`slot_index` образует матрицу `3 LOD x 5 groups`. Выбор выполняется как
|
||||||
|
`slot_index[lod * 5 + group]`; `0xFFFF` означает отсутствие geometry для этой
|
||||||
|
комбинации. Поле `parent_or_link` участвует в иерархии или связи узлов, но
|
||||||
|
название остаётся описательным.
|
||||||
|
|
||||||
|
Type 2 начинается с header `0x8C`, затем содержит slots по 68 байт:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct Slot68 {
|
||||||
|
uint16_t tri_start;
|
||||||
|
uint16_t tri_count;
|
||||||
|
uint16_t batch_start;
|
||||||
|
uint16_t batch_count;
|
||||||
|
float aabb_min[3];
|
||||||
|
float aabb_max[3];
|
||||||
|
float sphere_center[3];
|
||||||
|
float sphere_radius;
|
||||||
|
uint32_t opaque[5];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Slot связывает диапазон triangle descriptors, диапазон draw batches, AABB и
|
||||||
|
sphere bounds. AABB удобен для более точных осевых тестов, sphere -- для
|
||||||
|
быстрого отбрасывания. Последние пять слов сохраняются без интерпретации.
|
||||||
|
|
||||||
|
Обязательные проверки:
|
||||||
|
|
||||||
|
- `type 2` имеет размер не меньше `0x8C`;
|
||||||
|
- остаток после header кратен 68;
|
||||||
|
- каждый `slot_index` либо `0xFFFF`, либо меньше числа slots;
|
||||||
|
- `tri_start + tri_count` не выходит за type 7;
|
||||||
|
- `batch_start + batch_count` не выходит за type 13.
|
||||||
|
|
||||||
|
### Vertex streams, triangles и batches
|
||||||
|
|
||||||
|
Основные vertex streams:
|
||||||
|
|
||||||
|
```text
|
||||||
|
type 3: position = три float32
|
||||||
|
type 4: normal = четыре int8
|
||||||
|
type 5: UV0 = два int16
|
||||||
|
type 6: index = uint16
|
||||||
|
```
|
||||||
|
|
||||||
|
Normal XYZ декодируется как signed component / `127.0` с clamp в `[-1, 1]`.
|
||||||
|
Четвёртый byte normal stream не отбрасывается при roundtrip. UV декодируется
|
||||||
|
как `packed / 1024.0`. Index buffer адресует вершины относительно `base_vertex`
|
||||||
|
batch-а, поэтому проверка допустимости всегда использует
|
||||||
|
`base_vertex + index < vertex_count`.
|
||||||
|
|
||||||
|
Type 7 хранит descriptors triangles:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct TriDesc16 {
|
||||||
|
uint16_t tri_flags;
|
||||||
|
uint16_t link0;
|
||||||
|
uint16_t link1;
|
||||||
|
uint16_t link2;
|
||||||
|
int16_t nx;
|
||||||
|
int16_t ny;
|
||||||
|
int16_t nz;
|
||||||
|
uint16_t sel_packed;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Descriptors используются коллизией, выбором и связями triangles. `sel_packed`
|
||||||
|
содержит три двухбитовых selector-а; значение `3` преобразуется в отсутствие
|
||||||
|
ссылки (`0xFFFF`). Полная семантика links и flags не закрывается одним layout.
|
||||||
|
|
||||||
|
Type 13 задаёт draw ranges:
|
||||||
|
|
||||||
|
```c
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct Batch20 {
|
||||||
|
uint16_t batch_flags; // +0x00
|
||||||
|
uint16_t material_index; // +0x02
|
||||||
|
uint16_t opaque4; // +0x04
|
||||||
|
uint16_t opaque6; // +0x06
|
||||||
|
uint16_t index_count; // +0x08
|
||||||
|
uint32_t index_start; // +0x0A
|
||||||
|
uint16_t opaque14; // +0x0E
|
||||||
|
uint32_t base_vertex; // +0x10
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
static_assert(sizeof(Batch20) == 20);
|
||||||
|
```
|
||||||
|
|
||||||
|
`material_index` выбирает строку WEAR. `index_start`, `index_count` и
|
||||||
|
`base_vertex` описывают один indexed draw. Неизвестные поля могут влиять на
|
||||||
|
редкие проходы или state grouping, поэтому writer сохраняет их 1:1.
|
||||||
|
|
||||||
|
Типовой обход модели:
|
||||||
|
|
||||||
|
```c
|
||||||
|
for (Node& node : model.nodes) {
|
||||||
|
Matrix node_world = parent_world * local_transform(node);
|
||||||
|
uint16_t sid = node.slot_index[lod * 5 + group];
|
||||||
|
if (sid == 0xFFFF) continue;
|
||||||
|
|
||||||
|
Slot& slot = model.slots[sid];
|
||||||
|
if (camera.culls(transform(slot.bounds, node_world))) continue;
|
||||||
|
|
||||||
|
for (uint32_t i = 0; i < slot.batch_count; ++i) {
|
||||||
|
Batch& b = model.batches[slot.batch_start + i];
|
||||||
|
bind_wear_material(b.material_index);
|
||||||
|
draw_indexed(b.base_vertex, b.index_start, b.index_count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
В реальном кадре между culling и draw добавляются material resolve, lightmap,
|
||||||
|
render queues и сортировка, но связи данных остаются такими.
|
||||||
|
|
||||||
|
## Иерархия и анимация
|
||||||
|
|
||||||
|
Анимация MSH меняет локальный transform узлов. Geometry streams не изменяются:
|
||||||
|
для каждого узла на кадр строится matrix из position и quaternion. Дочерний
|
||||||
|
узел наследует transform родителя, поэтому изменение корпуса переносит башню,
|
||||||
|
точки крепления и все связанные slots.
|
||||||
|
|
||||||
|
Связка состоит из:
|
||||||
|
|
||||||
|
- type 8: пул animation keys;
|
||||||
|
- type 19: карта кадров;
|
||||||
|
- `anim_map_start` и `fallback_key` в `Node38`;
|
||||||
|
- parent links, задающих порядок умножения matrices.
|
||||||
|
|
||||||
|
Ключ type 8 занимает 24 байта:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct AnimKey24 {
|
||||||
|
float position[3];
|
||||||
|
float time;
|
||||||
|
int16_t qx;
|
||||||
|
int16_t qy;
|
||||||
|
int16_t qz;
|
||||||
|
int16_t qw;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Quaternion components декодируются как signed value / `32767.0`. На диске
|
||||||
|
порядок полей XYZ-W, но runtime math использует логическое `[w, x, y, z]`.
|
||||||
|
Безусловная современная нормализация после чтения не добавляется без parity
|
||||||
|
проверки: она может изменить крайние кадры.
|
||||||
|
|
||||||
|
Type 19 является массивом `uint16_t`; его `attr2` задаёт общее число кадров
|
||||||
|
timeline. Для конкретного узла `anim_map_start` указывает на блок длиной
|
||||||
|
`frame_count` либо равен `0xFFFF`.
|
||||||
|
|
||||||
|
Выбор ключа:
|
||||||
|
|
||||||
|
1. вычислить frame index из времени;
|
||||||
|
2. если frame вне диапазона, взять `fallback_key`;
|
||||||
|
3. если `anim_map_start == 0xFFFF`, взять `fallback_key`;
|
||||||
|
4. иначе прочитать `map_words[anim_map_start + frame]`;
|
||||||
|
5. если значение не меньше `fallback_key`, снова использовать fallback;
|
||||||
|
6. иначе использовать mapped key и следующий key для interpolation.
|
||||||
|
|
||||||
|
Fallback возвращается без interpolation. Это защищает статические узлы и конец
|
||||||
|
track-а.
|
||||||
|
|
||||||
|
Для времени между двумя keys:
|
||||||
|
|
||||||
|
```text
|
||||||
|
alpha = (t - k0.time) / (k1.time - k0.time)
|
||||||
|
position = lerp(k0.position, k1.position, alpha)
|
||||||
|
rotation = shortest-path quaternion blend
|
||||||
|
```
|
||||||
|
|
||||||
|
Перед quaternion blend проверяется dot product. Если стороны находятся в
|
||||||
|
противоположных полусферах, знак второй стороны меняется, чтобы пройти по
|
||||||
|
короткому пути. При точном совпадении времени возвращается соответствующий key
|
||||||
|
без вычисления alpha.
|
||||||
|
|
||||||
|
Объект может переходить между двумя animation states. Тогда для каждого узла
|
||||||
|
сэмплируются позы A и B, затем position смешивается линейно, а quaternion --
|
||||||
|
через shortest-path blend. Если одна сторона невалидна, используется другая.
|
||||||
|
|
||||||
|
```c
|
||||||
|
Pose sample_node(Node n, float t);
|
||||||
|
Pose blend_pose(Pose a, Pose b, float weight);
|
||||||
|
Mat4 local = quaternion_matrix(pose.rotation);
|
||||||
|
local.set_translation(pose.position);
|
||||||
|
world[n] = world[parent(n)] * local;
|
||||||
|
```
|
||||||
|
|
||||||
|
Для parity особенно важны x87-compatible округление при выборе frame index и
|
||||||
|
порядок операций. Одинаковая формула на SSE может выбрать соседний кадр возле
|
||||||
|
границы.
|
||||||
|
|
||||||
|
Проверки animation data:
|
||||||
|
|
||||||
|
- размер type 8 кратен 24;
|
||||||
|
- размер type 19 кратен 2;
|
||||||
|
- каждый `fallback_key` меньше числа keys;
|
||||||
|
- блок карты узла полностью помещается в type 19;
|
||||||
|
- времена keys внутри track возрастают;
|
||||||
|
- parent links не образуют cycle;
|
||||||
|
- quaternion components читаются как signed 16-bit.
|
||||||
|
|
||||||
|
## WEAR и MAT0
|
||||||
|
|
||||||
|
MSH batch хранит только числовой `material_index`. WEAR переводит позиционный
|
||||||
|
slot в имя материала. MAT0 по этому имени описывает phases, parameters,
|
||||||
|
texture names и animation blocks. Такое разделение позволяет одной geometry
|
||||||
|
использовать разные appearances.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Batch20.material_index
|
||||||
|
-> строка WEAR
|
||||||
|
-> имя MAT0
|
||||||
|
-> активная phase
|
||||||
|
-> textureName и render parameters
|
||||||
|
```
|
||||||
|
|
||||||
|
### WEAR
|
||||||
|
|
||||||
|
WEAR имеет type ID `0x52414557` и обычно хранится как `*.wea` рядом с моделью.
|
||||||
|
Формат текстовый:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<wearCount>
|
||||||
|
<legacyId> <materialName>
|
||||||
|
... wearCount строк
|
||||||
|
|
||||||
|
[пустая строка]
|
||||||
|
[LIGHTMAPS
|
||||||
|
<lightmapCount>
|
||||||
|
<legacyId> <lightmapName>
|
||||||
|
... lightmapCount строк]
|
||||||
|
```
|
||||||
|
|
||||||
|
`legacyId` читается и сохраняется, но material выбирается по позиции строки и
|
||||||
|
имени. Пустая строка перед `LIGHTMAPS` является частью совместимого framing:
|
||||||
|
parser paths по-разному обрабатывают переход, и отсутствие разделителя ломает
|
||||||
|
совместимость. Material handle кодируется как `(table_index << 16) |
|
||||||
|
wear_index`; manager поддерживает ограниченное число wear tables.
|
||||||
|
|
||||||
|
Fallback material resolve строго разделён:
|
||||||
|
|
||||||
|
1. имя из WEAR;
|
||||||
|
2. `DEFAULT`;
|
||||||
|
3. entry 0;
|
||||||
|
4. для lightmap отсутствие означает slot `-1`, а не замену обычной texture.
|
||||||
|
|
||||||
|
Пустое имя texture внутри phase означает намеренно untextured surface.
|
||||||
|
Lightmap ищется в отдельном cache и не подменяется diffuse texture.
|
||||||
|
|
||||||
|
### MAT0
|
||||||
|
|
||||||
|
MAT0 имеет type ID `0x3054414D` и обычно находится в `Material.lib`. `attr1`
|
||||||
|
содержит runtime flags, `attr2` -- версию payload. Versioned metadata читается
|
||||||
|
cursor-ом: старые версии получают runtime defaults, но reader не пытается
|
||||||
|
насильно читать поля новой версии.
|
||||||
|
|
||||||
|
```c
|
||||||
|
#pragma pack(push, 1)
|
||||||
|
struct Mat0PrefixV4Plus {
|
||||||
|
uint16_t phase_count; // +0x00
|
||||||
|
uint16_t animation_block_count; // +0x02, меньше 20
|
||||||
|
uint8_t metadata_a; // +0x04, attr2 >= 2
|
||||||
|
uint8_t metadata_b; // +0x05, attr2 >= 2
|
||||||
|
uint32_t metadata_c_raw; // +0x06, attr2 >= 3
|
||||||
|
uint32_t metadata_d_raw; // +0x0A, attr2 >= 4
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Phase34 {
|
||||||
|
uint8_t parameters[18];
|
||||||
|
char texture_name[16];
|
||||||
|
};
|
||||||
|
#pragma pack(pop)
|
||||||
|
static_assert(sizeof(Phase34) == 34);
|
||||||
|
```
|
||||||
|
|
||||||
|
Если `attr2 < 2`, metadata A/B получают default `255`; при `attr2 < 3`
|
||||||
|
значение C соответствует `1.0f`; при `attr2 < 4` D равно 0. C/D сохраняются
|
||||||
|
как raw 32-bit values до полного подтверждения интерпретации. Phase parameters
|
||||||
|
сохраняются как 18 raw bytes даже там, где часть bytes уже имеет понятный
|
||||||
|
смысл.
|
||||||
|
|
||||||
|
Каждая phase разворачивается в runtime-запись примерно 76 байт: коэффициенты
|
||||||
|
цвета, освещения и прозрачности, texture slot и служебные поля. Material time
|
||||||
|
выбирает одну или две phases; только часть полей интерполируется, остальные
|
||||||
|
копируются из активной записи.
|
||||||
|
|
||||||
|
Animation block MAT0 имеет плотный framing без 4-byte tail alignment:
|
||||||
|
|
||||||
|
```text
|
||||||
|
u32 header_raw
|
||||||
|
u16 key_count
|
||||||
|
repeat key_count:
|
||||||
|
u16 k0
|
||||||
|
u16 k1
|
||||||
|
u16 k2
|
||||||
|
```
|
||||||
|
|
||||||
|
Младшие три бита `header_raw` задают числовой mode, остальные образуют mask
|
||||||
|
interpolation. Наблюдаются modes 0, 1, 2 и 3, связанные с семействами loop,
|
||||||
|
ping-pong, one-shot/clamp и random-offset, но точные boundary cases остаются
|
||||||
|
предметом runtime parity. Поле `k2` сохраняется всегда.
|
||||||
|
|
||||||
|
Проверки MAT0:
|
||||||
|
|
||||||
|
- `animation_block_count < 20`;
|
||||||
|
- все versioned metadata помещаются в payload;
|
||||||
|
- секция phases имеет ровно `phase_count * 34` байта;
|
||||||
|
- `texture_name` ограничено 16 байтами;
|
||||||
|
- каждый animation block и его keys помещаются в payload;
|
||||||
|
- parser заканчивает чтение на точном конце записи.
|
||||||
|
|
||||||
|
Material manager кэширует разобранный MAT0 и texture handles. Current phase
|
||||||
|
лучше вычислять на экземпляр материала, если random offset или локальное время
|
||||||
|
различаются между объектами; immutable phase data остаются общими.
|
||||||
|
|
||||||
|
## Texm: текстуры, mip-уровни и атласы
|
||||||
|
|
||||||
|
`Texm` -- основной формат изображений. Он хранится в `Textures.lib`,
|
||||||
|
`LightMap.lib` и других NRes-архивах. Payload содержит header, необязательную
|
||||||
|
palette, mip chain и иногда `Page` chunk для atlas rectangles.
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct TexmHeader32 {
|
||||||
|
uint32_t magic; // 'Texm'
|
||||||
|
uint32_t width;
|
||||||
|
uint32_t height;
|
||||||
|
uint32_t mip_count;
|
||||||
|
uint32_t flags4;
|
||||||
|
uint32_t flags5;
|
||||||
|
uint32_t unknown6;
|
||||||
|
uint32_t format;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Подтверждённые formats:
|
||||||
|
|
||||||
|
```text
|
||||||
|
0 Indexed8 + palette 256 x 4 байта
|
||||||
|
565 R5 G6 B5
|
||||||
|
556 R5 G5 B6
|
||||||
|
4444 A4 R4 G4 B4
|
||||||
|
88 L8 A8
|
||||||
|
888 RGB8 в четырёхбайтовом element
|
||||||
|
8888 A8 R8 G8 B8
|
||||||
|
```
|
||||||
|
|
||||||
|
Formats 556 и 88 являются loader-confirmed, но не corpus-verified для
|
||||||
|
доступных игровых payload. CPU decoder расширяет короткие каналы до 8 bit через
|
||||||
|
повторение значимых bit, а не простым shift. Для 888 служебный четвёртый byte
|
||||||
|
сохраняется при roundtrip.
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```text
|
||||||
|
TexmHeader32
|
||||||
|
[palette 1024 байта, только для format 0]
|
||||||
|
level 0 pixels
|
||||||
|
level 1 pixels
|
||||||
|
...
|
||||||
|
level mip_count-1 pixels
|
||||||
|
[optional Page chunk]
|
||||||
|
```
|
||||||
|
|
||||||
|
Размер уровня `i` вычисляется из `max(1, width >> i)` и
|
||||||
|
`max(1, height >> i)`. Bytes per pixel: 1 для indexed; 2 для 565, 556, 4444 и
|
||||||
|
88; 4 для 888 и 8888. Parser суммирует размеры с проверкой overflow до чтения.
|
||||||
|
|
||||||
|
`Page` chunk:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct PageHeader8 {
|
||||||
|
uint32_t magic; // 'Page'
|
||||||
|
uint32_t rect_count;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct PageRect8 {
|
||||||
|
int16_t x;
|
||||||
|
int16_t width;
|
||||||
|
int16_t y;
|
||||||
|
int16_t height;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Chunk обязан иметь размер `8 + rect_count * 8`; произвольный tail не
|
||||||
|
допускается. Rectangles задаются в pixel space базового mip. Если loader
|
||||||
|
пропускает верхние mip-уровни, rectangles масштабируются вместе с новым base
|
||||||
|
level.
|
||||||
|
|
||||||
|
Mip-skip является поведением loader-а, а не offline-изменением файла. После
|
||||||
|
skip меняются runtime width, height, mip count и pointer на первый загружаемый
|
||||||
|
уровень. Современный renderer должен повторить выбор base level или
|
||||||
|
эквивалентно эмулировать его upload policy; использование полной texture при
|
||||||
|
тех же UV меняет резкость и atlas coordinates.
|
||||||
|
|
||||||
|
Indexed texture требует связанную palette. Часть palettes выбирается по suffix
|
||||||
|
имени: буква `A..Z` и вариант пустой или `0..9`, всего 286 возможных slots.
|
||||||
|
Невалидный suffix диагностируется явно.
|
||||||
|
|
||||||
|
Обычные textures и lightmaps находятся в разных managers. Обычный cache
|
||||||
|
отслеживает refcount и время неиспользования, а eviction выполняется
|
||||||
|
отложенно. Lightmap lifetime связан с world/mission и не должен попадать под
|
||||||
|
ту же политику удаления.
|
||||||
|
|
||||||
|
Строгий Texm parser проверяет положительные dimensions, положительный
|
||||||
|
`mip_count`, известный format, точный размер palette/mip chain, корректный
|
||||||
|
`Page` и отсутствие лишних bytes. `flags4`, `flags5` и `unknown6` сохраняются
|
||||||
|
1:1; участие `flags5` в mip-skip подтверждено, но полная семантика всех bits не
|
||||||
|
закрыта.
|
||||||
|
|
||||||
|
## Свет, тени, атмосфера и сортировка
|
||||||
|
|
||||||
|
Свет является отдельной world-подсистемой. Terrain layer создаёт
|
||||||
|
`LightManager`, `Shader` и primitive managers. Это не один глобальный
|
||||||
|
коэффициент яркости: world управляет point lights, lightmaps, shadows,
|
||||||
|
atmospheric objects и sort phases. Материал сообщает свойства поверхности, а
|
||||||
|
CShade превращает их в states renderer-а.
|
||||||
|
|
||||||
|
Подтверждённые точки: `CreateLightManager`, `CreateShader`,
|
||||||
|
`CreateAtmosphere`, `CreatePrimitives`, `CreatePrimitives2`,
|
||||||
|
`CShade::StartMeshRender`, `CShade::EndMeshRender` и
|
||||||
|
`CShade::ConfigureTextureAndAlphaBlendModes`.
|
||||||
|
|
||||||
|
CShade получает active MAT0 phase, capability profile устройства и pass
|
||||||
|
context. Он выбирает texture mode, alpha blending, depth/cull behavior и способ
|
||||||
|
освещения. Наличие fallback вроде `TEXTUREMODE_MODULATE not supported`
|
||||||
|
означает, что material нельзя напрямую преобразовать в современный PBR.
|
||||||
|
Сначала строится legacy state, затем он сопоставляется shader permutation.
|
||||||
|
|
||||||
|
CLightManager выдаёт numeric IDs источникам и проверяет допустимое количество.
|
||||||
|
Ветка `EmulatePointLights()` позволяет воспроизводить point lights даже при
|
||||||
|
ограничениях hardware lighting. Неизвестный type light должен давать отдельную
|
||||||
|
ошибку.
|
||||||
|
|
||||||
|
Lightmap не является обычной diffuse texture. WEAR содержит отдельный блок
|
||||||
|
`LIGHTMAPS`, manager открывает `LightMap.lib`, а shade path подаёт lightmap
|
||||||
|
отдельным slot или texture stage. Замена lightmap предварительным умножением в
|
||||||
|
diffuse texture ломает LOD, atlas coordinates и динамическую модуляцию.
|
||||||
|
|
||||||
|
Тени проходят отдельным render pass. Terrain содержит пути для теней зданий и
|
||||||
|
роботов, ограничения максимального числа, detail level и smoothing. Доказаны
|
||||||
|
shadow manager/pass, настройки detail/smoothing/count и зависимость от
|
||||||
|
Terrain/CShade; полная формула projection geometry для каждого caster требует
|
||||||
|
dynamic trace. Unknown settings из `shade.cfg` читаются и сохраняются по
|
||||||
|
именам, а не заменяются произвольными modern defaults.
|
||||||
|
|
||||||
|
Atmosphere manager создаёт world objects для фоновых и погодных явлений.
|
||||||
|
Отдельно подтверждены lightning, sun render, flare, `env_lightning`, rain
|
||||||
|
background sound и обязательные ссылки на lightning effect. Эти объекты
|
||||||
|
обновляются по игровому времени, но часть параметров зависит от camera: flare
|
||||||
|
требует screen position и occlusion test, rain -- области рядом с observer,
|
||||||
|
sound -- listener. Их нельзя один раз запечь в terrain.
|
||||||
|
|
||||||
|
RNG для lightning, atmosphere phases и FX должен иметь стабильный порядок.
|
||||||
|
Даже правильный средний интервал не даёт повторяемый кадр, если random values
|
||||||
|
запрашиваются в другой последовательности.
|
||||||
|
|
||||||
|
Согласованная модель sort phases:
|
||||||
|
|
||||||
|
```text
|
||||||
|
opaque terrain and models
|
||||||
|
-> lightmapped/state-grouped passes
|
||||||
|
-> shadows and projected primitives
|
||||||
|
-> alpha-tested surfaces
|
||||||
|
-> transparent objects/effects back-to-front
|
||||||
|
-> atmosphere, flares and overlays
|
||||||
|
```
|
||||||
|
|
||||||
|
Точный взаимный порядок отдельных FX, shadow и atmosphere subpasses требует
|
||||||
|
capture. Новый renderer должен хранить явный `RenderPhase` и стабильный
|
||||||
|
secondary sort key, а не сортировать всё только по material ID.
|
||||||
|
|
||||||
|
## FXID: система эффектов
|
||||||
|
|
||||||
|
FXID -- не готовая картинка, а описание небольшого runtime command stream.
|
||||||
|
Header задаёт lifetime, time mode, random shifts и transform. Затем идут
|
||||||
|
команды разных types. При создании manager превращает disk-команды в runtime
|
||||||
|
objects; во время кадра они обновляются и выпускают sounds, particles,
|
||||||
|
materials или projected primitives.
|
||||||
|
|
||||||
|
Type ID равен `0x44495846`. Header занимает 60 байт:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct FxHeader60 {
|
||||||
|
uint32_t command_count;
|
||||||
|
uint32_t time_mode;
|
||||||
|
float duration_seconds;
|
||||||
|
float phase_jitter;
|
||||||
|
uint32_t flags;
|
||||||
|
uint32_t settings_id;
|
||||||
|
float random_shift[3];
|
||||||
|
float pivot[3];
|
||||||
|
float scale[3];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Поток команд начинается строго с offset `0x3C`. `duration_seconds`
|
||||||
|
преобразуется runtime-ом во внутреннюю шкалу времени. `phase_jitter` и
|
||||||
|
`random_shift` используются только при соответствующих flags. Pivot задаёт
|
||||||
|
локальную точку опоры, scale -- базовый масштаб экземпляра. Unknown flags и
|
||||||
|
settings ID сохраняются.
|
||||||
|
|
||||||
|
Каждая команда начинается с `uint32_t command_word`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
opcode = command_word & 0xFF
|
||||||
|
enabled = (command_word >> 8) & 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Bits 9-31 являются частью данных и сохраняются. Между командами нет
|
||||||
|
выравнивания. Размер команды, включая word:
|
||||||
|
|
||||||
|
```text
|
||||||
|
opcode 1 224 байта
|
||||||
|
opcode 2 148 байт
|
||||||
|
opcode 3 200 байт
|
||||||
|
opcode 4 204 байта
|
||||||
|
opcode 5 112 байт
|
||||||
|
opcode 6 4 байта
|
||||||
|
opcode 7 208 байт
|
||||||
|
opcode 8 248 байт
|
||||||
|
opcode 9 208 байт
|
||||||
|
opcode 10 208 байт
|
||||||
|
```
|
||||||
|
|
||||||
|
Parser использует opcode только для выбора фиксированного размера. Неизвестный
|
||||||
|
opcode отклоняется: попытка угадать длину потеряет синхронизацию всего stream.
|
||||||
|
|
||||||
|
Opcodes 2, 3, 4, 5, 7, 8, 9 и 10 содержат pair fixed strings:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct FxResourceRef64 {
|
||||||
|
char archive[32];
|
||||||
|
char name[32];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Имена сравниваются case-insensitive по ASCII, а tail после первого nul byte
|
||||||
|
сохраняется. Resolve выполняется при создании command object или лениво при
|
||||||
|
первом запуске, но ошибка должна включать имя эффекта, номер команды, archive
|
||||||
|
и resource name.
|
||||||
|
|
||||||
|
Базовый normalized age:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tn = (now - start_time) / (end_time - start_time)
|
||||||
|
```
|
||||||
|
|
||||||
|
`time_mode` выбирает источник коэффициента: constant, forward/reverse age,
|
||||||
|
cyclic phase, external world state и варианты с ограничением относительно
|
||||||
|
предыдущего значения. Точные формулы редких modes являются parity-задачей.
|
||||||
|
Flags могут умножать alpha на lifetime, применять triangular remap, случайно
|
||||||
|
сдвигать phase/space, инвертировать active-state, фильтровать по времени суток
|
||||||
|
или включать manager gates.
|
||||||
|
|
||||||
|
Lifecycle:
|
||||||
|
|
||||||
|
```text
|
||||||
|
create instance
|
||||||
|
-> copy header and external transform
|
||||||
|
-> calculate end time and random offsets
|
||||||
|
-> create command objects in disk order
|
||||||
|
-> resolve required resources
|
||||||
|
-> Start
|
||||||
|
|
||||||
|
on each calculation/render frame
|
||||||
|
-> evaluate time coefficient and gates
|
||||||
|
-> update commands in stable order
|
||||||
|
-> emit active primitives or sounds
|
||||||
|
-> collect render batches
|
||||||
|
-> handle Stop / Restart / end-of-life
|
||||||
|
```
|
||||||
|
|
||||||
|
Update и emit разделяются. Simulation может продолжаться в кадре без render, а
|
||||||
|
emit не должен повторно менять игровое состояние. Для authoring безопасно
|
||||||
|
типизировать header и resource references, а body редких commands сохранять raw
|
||||||
|
до подтверждения field-level semantics.
|
||||||
|
|
||||||
|
## Полный кадр
|
||||||
|
|
||||||
|
Крупный вход в world render проходит через `World3D::stdRenderGame`. Доказан
|
||||||
|
следующий порядок boundary операций:
|
||||||
|
|
||||||
|
1. передать camera в Terrain через `stdSetCurrentCamera2` и сохранить её как
|
||||||
|
текущую;
|
||||||
|
2. получить camera/view/viewport interfaces через virtual queries;
|
||||||
|
3. обновить положение и ориентацию 3D sound listener;
|
||||||
|
4. настроить renderer viewport и matrices;
|
||||||
|
5. вызвать два renderer boundary slots перед traversal;
|
||||||
|
6. установить глобальный флаг `in_render`;
|
||||||
|
7. вызвать главный virtual метод camera/world traversal;
|
||||||
|
8. выполнить дополнительную post queue при включённом режиме;
|
||||||
|
9. завершить world/shade pass;
|
||||||
|
10. вызвать renderer completion slot;
|
||||||
|
11. снять `in_render`, восстановить viewport и разослать end-of-render.
|
||||||
|
|
||||||
|
Семантические имена нескольких slots перед и после traversal не подтверждены,
|
||||||
|
поэтому в compatibility code их лучше временно называть
|
||||||
|
`frame_boundary_0`, `frame_boundary_1`, `frame_boundary_2`.
|
||||||
|
|
||||||
|
Обход видимого мира:
|
||||||
|
|
||||||
|
```text
|
||||||
|
проверить active/visible state
|
||||||
|
-> выбрать LOD по расстоянию и настройкам
|
||||||
|
-> получить node matrices из animation state
|
||||||
|
-> выбрать slot для каждого node/group
|
||||||
|
-> преобразовать bounds в world space
|
||||||
|
-> выполнить culling
|
||||||
|
-> добавить batches в подходящую render queue
|
||||||
|
```
|
||||||
|
|
||||||
|
Material/texture resolve желательно выполнять после visibility и slot
|
||||||
|
selection, чтобы невидимые объекты не меняли порядок обращений к caches и не
|
||||||
|
создавали лишние side effects. Невидимость объекта и отсутствие slot являются
|
||||||
|
разными причинами пропуска и диагностируются отдельно.
|
||||||
|
|
||||||
|
Подготовленный draw item содержит:
|
||||||
|
|
||||||
|
```text
|
||||||
|
node world matrix
|
||||||
|
batch flags and index range
|
||||||
|
WEAR material handle
|
||||||
|
MAT0 active phase and coefficients
|
||||||
|
texture handle
|
||||||
|
optional lightmap handle
|
||||||
|
render phase and sorting key
|
||||||
|
legacy pipeline state
|
||||||
|
```
|
||||||
|
|
||||||
|
Draw item должен ссылаться на immutable данные кадра. Изменение phase или
|
||||||
|
texture cache посреди прохода не должно менять уже собранную очередь.
|
||||||
|
|
||||||
|
Согласованная декомпозиция внутренних render phases:
|
||||||
|
|
||||||
|
1. подготовка frame state, camera и viewport;
|
||||||
|
2. непрозрачный terrain;
|
||||||
|
3. непрозрачные object batches;
|
||||||
|
4. lightmap и дополнительные material passes;
|
||||||
|
5. projected primitives и тени;
|
||||||
|
6. alpha-tested geometry;
|
||||||
|
7. transparent objects и FX в сортировочных слоях;
|
||||||
|
8. atmosphere, sun, flare и weather;
|
||||||
|
9. renderer completion boundary;
|
||||||
|
10. end-of-render callbacks;
|
||||||
|
11. shell/UI и post-render state.
|
||||||
|
|
||||||
|
Точный взаимный порядок пунктов 4-8 и связь completion slot с физическим
|
||||||
|
DirectDraw flip/present требуют dynamic capture. Сортировка внутри каждой фазы
|
||||||
|
должна быть стабильной: для opaque первичен pipeline/material key, для
|
||||||
|
transparent -- distance layer и depth order, затем stable insertion ID.
|
||||||
|
|
||||||
|
Геометрический draw использует streams type 3/4/5, optional streams, index
|
||||||
|
buffer type 6, `base_vertex`, `index_start` и `index_count`. Матрица узла
|
||||||
|
устанавливается как world transform, затем CShade привязывает texture stages и
|
||||||
|
fixed-function state.
|
||||||
|
|
||||||
|
```c
|
||||||
|
set_world_matrix(item.node_world);
|
||||||
|
bind_vertex_streams(model.streams);
|
||||||
|
bind_index_buffer(model.indices);
|
||||||
|
apply_legacy_state(item.pipeline);
|
||||||
|
bind_texture(0, item.texture);
|
||||||
|
bind_texture(1, item.lightmap);
|
||||||
|
draw_indexed(item.batch.base_vertex,
|
||||||
|
item.batch.index_start,
|
||||||
|
item.batch.index_count);
|
||||||
|
```
|
||||||
|
|
||||||
|
После последнего 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 отдельно.
|
||||||
|
|
||||||
|
## Проверки паритета
|
||||||
|
|
||||||
|
Главные риски совпадения кадра:
|
||||||
|
|
||||||
|
- 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).
|
||||||
@@ -0,0 +1,769 @@
|
|||||||
|
# VI. Поведение, управление, звук и сеть
|
||||||
|
|
||||||
|
Шестой том описывает подсистемы, которые превращают загруженный мир в
|
||||||
|
реагирующую игру: AI, Behavior, Wizard, Control, ввод, камеру, звук и сеть.
|
||||||
|
Эти области нельзя восстанавливать только по структуре файлов. Для них важны
|
||||||
|
порядок кадра, ownership объектов, timing событий и доказуемые границы между
|
||||||
|
решением, движением, presentation и транспортом.
|
||||||
|
|
||||||
|
Ключевой принцип: reader compatibility не равна gameplay compatibility.
|
||||||
|
Корректно разобранный ресурс ещё не доказывает, что runtime выбирает ту же
|
||||||
|
цель, строит тот же маршрут, применяет ту же collision correction, создаёт тот
|
||||||
|
же sound event или отправляет тот же network payload. Поэтому все утверждения
|
||||||
|
ниже разделяют подтверждённую структуру, восстановленный архитектурный
|
||||||
|
контракт и открытые участки, требующие динамической трассировки.
|
||||||
|
|
||||||
|
```text
|
||||||
|
AI / mission script
|
||||||
|
-> стратегическая цель, условия, команды миссии
|
||||||
|
Behavior
|
||||||
|
-> состояние объекта, target, global/local path
|
||||||
|
Wizard
|
||||||
|
-> локальная коррекция траектории
|
||||||
|
Control
|
||||||
|
-> physical step, collision proxy, итоговый transform
|
||||||
|
World3D
|
||||||
|
-> очередь событий, ownership, deferred deletion
|
||||||
|
Render / Sound / Net
|
||||||
|
-> представление, listener, mirrors и сообщения
|
||||||
|
```
|
||||||
|
|
||||||
|
Связанные главы: [мир и миссии](04-world.md), [геометрия и рендер](05-render.md)
|
||||||
|
и справочный [render frame](../reference/render-frame.md).
|
||||||
|
|
||||||
|
## AI, Behavior и Wizard
|
||||||
|
|
||||||
|
Iron3D разделяет стратегическое принятие решений, поведение конкретного объекта
|
||||||
|
и локальную коррекцию движения. Это разделение должно сохраниться в новой
|
||||||
|
реализации: стратегический AI не меняет transform напрямую, а collision manager
|
||||||
|
не выбирает игровую цель.
|
||||||
|
|
||||||
|
```text
|
||||||
|
ai.dll / SuperAI
|
||||||
|
-> цель клана, миссии и группы
|
||||||
|
Behavior.dll
|
||||||
|
-> состояние юнита, target, global path, local corridor
|
||||||
|
Wizard.dll
|
||||||
|
-> ближайшая допустимая траектория
|
||||||
|
Control.dll
|
||||||
|
-> физическое движение и столкновения
|
||||||
|
```
|
||||||
|
|
||||||
|
### Behavior
|
||||||
|
|
||||||
|
`CreateBehaviour` создаёт controller для отдельного игрового объекта.
|
||||||
|
`CreateDistributor` восстановлен по consumers как посредник распределения
|
||||||
|
команд или ресурсов; это высокоуверенный архитектурный вывод, а не доказанное
|
||||||
|
имя внутреннего класса. Behavior получает `IArealMap` через AI/клановый
|
||||||
|
контекст, ведёт radar/target state, строит global path, превращает его в local
|
||||||
|
corridor и передаёт движение Wizard.
|
||||||
|
|
||||||
|
Ошибочные состояния проверяются явно:
|
||||||
|
|
||||||
|
1. отсутствует system map;
|
||||||
|
2. отсутствует terrain interface;
|
||||||
|
3. active behavior не имеет `IArealMap`;
|
||||||
|
4. объект попал в non-reachable area;
|
||||||
|
5. объект пытается выйти из non-walkable area;
|
||||||
|
6. path generator вошёл в infinite cycle.
|
||||||
|
|
||||||
|
Эти случаи являются fatal или diagnostic conditions. Совместимая реализация не
|
||||||
|
должна тихо исправлять их teleport-ом, потому что такое исправление скрывает
|
||||||
|
ошибку areal graph, terrain query или state machine.
|
||||||
|
|
||||||
|
### Параметры Behavior.ini
|
||||||
|
|
||||||
|
Подтверждены настройки:
|
||||||
|
|
||||||
|
```text
|
||||||
|
PathFind_BuildingHitDist
|
||||||
|
PathFind_BuildingNearestDist
|
||||||
|
PathFind_NearBuildSpeedPercent
|
||||||
|
PathFind_CorridorRadius
|
||||||
|
PathFind_NearDoorCoeff
|
||||||
|
PathFind_fStepOffBuilding
|
||||||
|
PathFind_MaxAccel
|
||||||
|
PathFind_MaxRotation
|
||||||
|
PathFind_fStepDist
|
||||||
|
PathFind_MinPointInTrajectory
|
||||||
|
Network_ResourceTransferMaxDelay
|
||||||
|
```
|
||||||
|
|
||||||
|
Они задают геометрию corridor, дистанции реакции на здания, снижение скорости
|
||||||
|
возле препятствий, пределы ускорения и поворота, дискретизацию trajectory и
|
||||||
|
сетевой timeout передачи ресурсов. Значения читаются как runtime-конфигурация,
|
||||||
|
а не компилируются в код. Parser должен поддерживать комментарии `//`, пробелы
|
||||||
|
вокруг `=` и CRLF.
|
||||||
|
|
||||||
|
Файл также содержит logging/debug switches: `Behavior.log`, уровни ошибок,
|
||||||
|
show vectors и z-buffer debug. Эти переключатели полезны не только для
|
||||||
|
совместимости, но и как модель современных trace flags.
|
||||||
|
|
||||||
|
### Wizard
|
||||||
|
|
||||||
|
Wizard получает желаемое направление и corridor, анализирует ближайшие
|
||||||
|
ограничения и выдаёт скорректированную локальную траекторию. Behavior может
|
||||||
|
очищать её через `ClearWizardPath` при смене цели, повреждении global path или
|
||||||
|
переходе объекта в неактивное состояние.
|
||||||
|
|
||||||
|
Нужно различать четыре уровня движения:
|
||||||
|
|
||||||
|
- **global path** -- последовательность areals;
|
||||||
|
- **local path** -- точки или сегменты внутри corridor;
|
||||||
|
- **wizard path** -- краткосрочное движение с учётом ближайших препятствий;
|
||||||
|
- **physical step** -- фактически разрешённое Control перемещение.
|
||||||
|
|
||||||
|
Хранение всего маршрута одним массивом лишает систему возможности локально
|
||||||
|
обойти препятствие без полного повторного поиска. Граница Behavior/Wizard
|
||||||
|
существует именно для того, чтобы краткосрочная геометрическая коррекция не
|
||||||
|
ломала стратегический path state.
|
||||||
|
|
||||||
|
### SuperAI и миссионные сценарии
|
||||||
|
|
||||||
|
`CreateSuperAI` создаёт центральный controller клана; `GetSuperAI` возвращает
|
||||||
|
его. AI загружает файлы из `MISSIONS\SCRIPTS\`, проверяет версию и пишет ошибки
|
||||||
|
в `ai.log`. Несовпадение версии является отдельной ошибкой, а не неизвестной
|
||||||
|
командой.
|
||||||
|
|
||||||
|
Сценарный корпус содержит binary `.scr`, formula exports `.fml`, таблицу
|
||||||
|
переменных `varset.var` и `.trf`-данные. `.scr` хранит именованные секции и
|
||||||
|
события, например `Init`, `Mission`, `Problems0`, `Fort_Task_Complete` и
|
||||||
|
`Hero_Teleported`, вместе с числовыми ссылками на compiled instructions.
|
||||||
|
`.fml` является текстовым экспортом formula set. `varset.var` декларативно
|
||||||
|
описывает типы, defaults, ranges и строки через макросоподобные формы
|
||||||
|
`VAR(...)` и `STRING(...)`.
|
||||||
|
|
||||||
|
Безопасная runtime-модель:
|
||||||
|
|
||||||
|
```text
|
||||||
|
load script bundle
|
||||||
|
-> validate version and symbol tables
|
||||||
|
-> create global/formula variables
|
||||||
|
-> bind named events to instruction offsets
|
||||||
|
-> instantiate SuperAI per clan
|
||||||
|
-> dispatch MISSION_START and object events
|
||||||
|
-> update timers/conditions each simulation tick
|
||||||
|
-> enqueue game commands through World3D/Behavior
|
||||||
|
```
|
||||||
|
|
||||||
|
Сценарий не должен владеть игровым объектом напрямую. Он хранит logical/object
|
||||||
|
IDs и отправляет команды через игровые interfaces, чтобы удаление объекта или
|
||||||
|
сетевой mirror не оставили dangling pointer.
|
||||||
|
|
||||||
|
Полная grammar compiled instructions и точное значение всех opcodes остаются
|
||||||
|
открытым направлением. До появления decompiler-а `.scr` binary body сохраняется
|
||||||
|
lossless, а доказанные symbol/event tables документируются отдельно.
|
||||||
|
|
||||||
|
### TRF и preload-данные
|
||||||
|
|
||||||
|
TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
|
||||||
|
variants имеют сигнатуру [NRes](../reference/nres.md) и содержат большие
|
||||||
|
таблицы имён игровых прототипов: оружия, башен, сооружений и других объектов.
|
||||||
|
Также найдены preload-записи, ANI и SKE resources.
|
||||||
|
|
||||||
|
По содержимому, порядку загрузки и consumers TRF с высокой вероятностью
|
||||||
|
предоставляет AI/сценарному слою заранее подготовленную таблицу типов и
|
||||||
|
связанных данных. Framing и имена подтверждены corpus-ом, но полная семантика
|
||||||
|
каждой TRF-записи ещё не закрыта. Имена должны разрешаться через тот же
|
||||||
|
resource registry, что и миссионные объекты.
|
||||||
|
|
||||||
|
### Стабильность AI-слоя
|
||||||
|
|
||||||
|
`ai.dll`, `Behavior.dll` и `Wizard.dll` побайтно идентичны в Частях 1 и 2. Это
|
||||||
|
подтверждает, что разделение SuperAI -> Behavior -> Wizard и бинарная
|
||||||
|
реализация этих трёх уровней не менялись.
|
||||||
|
|
||||||
|
Сценарный корпус:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Часть 1: 58 SCR, 58 FML, 29 TRF
|
||||||
|
Часть 2: 59 SCR, 59 FML, 44 TRF
|
||||||
|
```
|
||||||
|
|
||||||
|
Все TRF являются структурно валидными NRes. Неизменность DLL усиливает вывод о
|
||||||
|
стабильной VM, но не закрывает instruction grammar `.scr`: для неё нужен
|
||||||
|
dispatcher/jump-table decompiler. Дополнительные сценарные данные расширяют
|
||||||
|
differential corpus, но не заменяют анализ VM.
|
||||||
|
|
||||||
|
## Control, физика и коллизии
|
||||||
|
|
||||||
|
Control превращает желаемое движение в физически допустимое изменение
|
||||||
|
состояния. World3D владеет жизненным циклом объекта; Terrain предоставляет
|
||||||
|
поверхность и world queries; Behavior/Wizard задают намерение; Control создаёт
|
||||||
|
physical controller и collision representation.
|
||||||
|
|
||||||
|
Публичная поверхность:
|
||||||
|
|
||||||
|
```text
|
||||||
|
InitializeSettings
|
||||||
|
LoadControlSystem
|
||||||
|
LoadPhysicalModel
|
||||||
|
CreateCollManager
|
||||||
|
CreateCollObject
|
||||||
|
```
|
||||||
|
|
||||||
|
Модуль импортирует World3D queue/object functions, `Terrain::GetWorld`, часы,
|
||||||
|
тригонометрию и `g_FastProc`. Это подтверждает его положение между gameplay
|
||||||
|
object и геометрией мира.
|
||||||
|
|
||||||
|
### Control system и physical model
|
||||||
|
|
||||||
|
`LoadControlSystem` загружает настройки controller-а: ограничения скорости,
|
||||||
|
ускорения, поворота и режимы управления. `LoadPhysicalModel` загружает форму и
|
||||||
|
параметры, используемые для столкновений. Visible MSH не обязан совпадать с
|
||||||
|
collision representation: для физики часто нужна более простая и устойчивая
|
||||||
|
форма.
|
||||||
|
|
||||||
|
Практичная runtime-модель:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct PhysicalState {
|
||||||
|
Transform transform;
|
||||||
|
Vec3 linear_velocity;
|
||||||
|
Vec3 angular_velocity;
|
||||||
|
float requested_speed;
|
||||||
|
float requested_turn;
|
||||||
|
uint32_t flags;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CollisionProxy {
|
||||||
|
ObjectId owner;
|
||||||
|
ShapeSet shapes;
|
||||||
|
Bounds broad_phase_bounds;
|
||||||
|
uint32_t category_mask;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Названия полей здесь описывают контракт совместимой реализации, а не точный
|
||||||
|
layout исходного C++-объекта.
|
||||||
|
|
||||||
|
### Collision pipeline
|
||||||
|
|
||||||
|
Один расчётный шаг удобно разделить так:
|
||||||
|
|
||||||
|
1. controller получает желаемые `speed`/`turn` от Behavior или manual input;
|
||||||
|
2. вычисляет кандидатный transform на основе `dt`;
|
||||||
|
3. обновляет broad-phase bounds collision object;
|
||||||
|
4. collision manager находит потенциальные пары и terrain candidates;
|
||||||
|
5. narrow phase вычисляет контакт или допустимый остаток перемещения;
|
||||||
|
6. physical state корректируется;
|
||||||
|
7. World3D получает итоговый transform;
|
||||||
|
8. событие `GMSG_COLLISION_DETECTED` отправляется в согласованной фазе.
|
||||||
|
|
||||||
|
Позиция collision event после narrow phase является рекомендуемой фазой
|
||||||
|
реализации и согласуется с назначением сообщения, но точный call-site
|
||||||
|
относительно всех correction steps требует динамической трассировки Control.
|
||||||
|
Удаление объекта из обработчика остаётся отложенным по правилам World3D.
|
||||||
|
Collision manager не должен хранить прямую незащищённую ссылку на объект,
|
||||||
|
который уже pending-delete.
|
||||||
|
|
||||||
|
### CTLD и physical resources
|
||||||
|
|
||||||
|
Реестр прототипов ссылается на `*.ctl`, `*.cpt` и связанные control resources.
|
||||||
|
В Части 1 структурно проверен 531 CTLD payload без ошибок. Размеры и пять
|
||||||
|
внутренних счётчиков образуют множество вариантов: наиболее частый размер
|
||||||
|
392 байта с pattern `(0,0,0,1,0)`, но встречаются блоки от примерно 212 до
|
||||||
|
1868 байт и более сложные комбинации.
|
||||||
|
|
||||||
|
CTLD является составным count-driven форматом, а не фиксированной struct.
|
||||||
|
Parser должен:
|
||||||
|
|
||||||
|
- прочитать prefix и все счётчики с проверкой переполнения;
|
||||||
|
- вычислить границы секций по их counts;
|
||||||
|
- сохранять неизвестные records в typed raw containers;
|
||||||
|
- требовать точного завершения payload;
|
||||||
|
- не использовать размер одного популярного варианта как универсальный layout.
|
||||||
|
|
||||||
|
Полная предметная семантика всех секций ещё не доказана, но существующие файлы
|
||||||
|
можно безопасно читать, индексировать и сохранять.
|
||||||
|
|
||||||
|
### Terrain queries и movement handoff
|
||||||
|
|
||||||
|
Control получает world-interface Terrain и использует поверхность, faces и
|
||||||
|
ускорители для высоты, нормали и пересечений. Навигационный маршрут сообщает,
|
||||||
|
куда двигаться, но итоговый transform определяется по физической поверхности.
|
||||||
|
При переходе через склон controller должен согласовать горизонтальный шаг,
|
||||||
|
высоту и ориентацию с terrain normal.
|
||||||
|
|
||||||
|
Порядок операций должен быть детерминированным: пары collision objects
|
||||||
|
сортируются по стабильному ID, contacts обрабатываются в фиксированной
|
||||||
|
последовательности, а интеграция использует одну политику `dt` и округления.
|
||||||
|
Иначе одинаковая миссия постепенно расходится даже без сети.
|
||||||
|
|
||||||
|
### Различия Control в Части 2
|
||||||
|
|
||||||
|
`Control.dll` пересобрана при неизменных размере, imports и пяти именах/ordinals
|
||||||
|
exports; RVA всех пяти exports изменились. Форматы и cross-module boundary
|
||||||
|
сохранились, но точное physical/collision behavior нельзя считать побайтно тем
|
||||||
|
же.
|
||||||
|
|
||||||
|
CTLD-корпус расширен с 531 до 623 payload. Новых framing errors не найдено;
|
||||||
|
большинство общих CTLD изменено вместе с переработанными моделями. Это
|
||||||
|
подтверждает count-driven parser, но не закрывает предметную семантику shape
|
||||||
|
records и contact solver.
|
||||||
|
|
||||||
|
Differential test обеих частей должен воспроизводить движение без препятствий,
|
||||||
|
slope following, pair collision, timing collision event и удаление объекта в
|
||||||
|
callback. Сравниваются transforms и contact events по tick, а не только факт
|
||||||
|
успешной загрузки.
|
||||||
|
|
||||||
|
## Ввод, камера и управление
|
||||||
|
|
||||||
|
World3D нормализует клавиатуру, мышь и joystick в общие scan codes и manual
|
||||||
|
commands. Win32 message handler вызывает `UpdateManualEventsList`; перед
|
||||||
|
обработкой новой порции сообщений основной цикл вызывает
|
||||||
|
`ClearManualEventsList`. Снимок клавиатуры очищается отдельно через
|
||||||
|
`stdClearKeyboard`.
|
||||||
|
|
||||||
|
Публичная поверхность включает `WinMsg2ScanCode`, converters для
|
||||||
|
keyboard/mouse/joystick/predicate, `ScanCode2Str`, `ManualCommand2Str`,
|
||||||
|
`stdIsKeyPressed`, lock/unlock keyboard и чтение mouse shift. Это позволяет
|
||||||
|
хранить конфигурацию управления независимо от физического устройства.
|
||||||
|
|
||||||
|
### Event, state и axis
|
||||||
|
|
||||||
|
Ввод имеет минимум три семантики:
|
||||||
|
|
||||||
|
- **edge event** -- нажатие или отпускание в текущей порции сообщений;
|
||||||
|
- **held state** -- клавиша остаётся нажатой между кадрами;
|
||||||
|
- **analog value** -- смещение мыши или положение joystick axis.
|
||||||
|
|
||||||
|
Manual command дополняет источник коэффициентом, режимом wrap, dead
|
||||||
|
zone/threshold и временной характеристикой. Строки camera bindings показывают
|
||||||
|
команды `MCMD_STATE`, `MCMD_ANGLE_X`, `MCMD_ANGLE_Y`, режимы `MAN_WRAP` и
|
||||||
|
`MAN_NOTWRAP`, а также параметры ускорения в миллисекундах.
|
||||||
|
|
||||||
|
Simulation читает подготовленный input snapshot. Renderer не должен
|
||||||
|
самостоятельно опрашивать OS, иначе одно и то же нажатие будет зависеть от
|
||||||
|
частоты кадров.
|
||||||
|
|
||||||
|
### Joystick через DirectInput
|
||||||
|
|
||||||
|
`Joystick.dll` экспортирует:
|
||||||
|
|
||||||
|
```text
|
||||||
|
QueryJoy
|
||||||
|
CreateJoy
|
||||||
|
ReleaseJoy
|
||||||
|
SetJoyRange
|
||||||
|
PeekJoyMessage
|
||||||
|
GetJoyCaps
|
||||||
|
```
|
||||||
|
|
||||||
|
`QueryJoy` обнаруживает устройство, `CreateJoy` получает интерфейс DirectInput,
|
||||||
|
`SetJoyRange` нормализует оси в диапазон движка, `PeekJoyMessage` выдаёт
|
||||||
|
очередное унифицированное событие.
|
||||||
|
|
||||||
|
При потере устройства чтение может вернуть ошибку acquired state. Интерфейс
|
||||||
|
следует повторно получить, очистить устаревшее состояние и продолжить.
|
||||||
|
Hot-unplug не должен оставлять последнюю ось навсегда отклонённой.
|
||||||
|
`GetInstalledJoyNames` и `SetActiveJoy` в World3D связывают device list с
|
||||||
|
game-facing выбором.
|
||||||
|
|
||||||
|
### Два camera interface
|
||||||
|
|
||||||
|
World3D предоставляет `stdSetCurrentCamera`/`stdGetCurrentCamera`: это камера
|
||||||
|
как часть игрового состояния. Terrain имеет
|
||||||
|
`stdSetCurrentCamera2`/`stdGetCurrentCamera2`: concrete camera, которую world
|
||||||
|
renderer использует для matrices, viewport и visibility.
|
||||||
|
|
||||||
|
`LoadCamera` экспортирован обоими модулями. По call graph World3D-вариант
|
||||||
|
играет роль component bridge, а Terrain-вариант связан с concrete
|
||||||
|
camera/world implementation. Это архитектурный вывод: точные class names и
|
||||||
|
layout не восстановлены.
|
||||||
|
|
||||||
|
Минимальные данные камеры:
|
||||||
|
|
||||||
|
```text
|
||||||
|
world position and orientation
|
||||||
|
view matrix
|
||||||
|
projection parameters / field of view
|
||||||
|
near and far planes
|
||||||
|
viewport rectangle
|
||||||
|
camera mode and target object
|
||||||
|
manual angles/state
|
||||||
|
```
|
||||||
|
|
||||||
|
Такая граница позволяет game code работать с абстрактной камерой, не зная
|
||||||
|
внутреннего renderer representation.
|
||||||
|
|
||||||
|
### Camera commands и порядок кадра
|
||||||
|
|
||||||
|
Подтверждены команды `CMD_CAMERA_LEFT`, `CMD_CAMERA_RIGHT`, `CMD_CAMERA_UP`,
|
||||||
|
`CMD_CAMERA_DOWN`, `CMD_CAMERA_CENTER`, `CMD_CAMERA_INFRARED`, а также
|
||||||
|
spotlight и внешние/миссионные camera modes. Горизонтальный угол использует
|
||||||
|
wrap, вертикальный -- ограниченный диапазон. Center плавно возвращает обе оси к
|
||||||
|
заданному значению.
|
||||||
|
|
||||||
|
Порядок кадра:
|
||||||
|
|
||||||
|
1. собрать manual events;
|
||||||
|
2. обновить camera controller во время calculation;
|
||||||
|
3. вычислить итоговый transform и ограничения;
|
||||||
|
4. перед render установить current camera;
|
||||||
|
5. передать её Terrain и sound listener;
|
||||||
|
6. после кадра сохранить mode-specific state.
|
||||||
|
|
||||||
|
Camera smoothing должно использовать игровое время или специально
|
||||||
|
подтверждённые часы. Привязка к render delta делает управление разным при 30 и
|
||||||
|
144 FPS.
|
||||||
|
|
||||||
|
## Звуковая подсистема
|
||||||
|
|
||||||
|
Ngi32 создаёт низкоуровневый DirectSound backend. `services.dll` публикует
|
||||||
|
`ISoundServer`. Game, Terrain и FX работают уже через эти интерфейсы:
|
||||||
|
воспроизводят 2D/3D sources, меняют volume и связывают listener с camera.
|
||||||
|
|
||||||
|
Публичные функции Ngi32:
|
||||||
|
|
||||||
|
```text
|
||||||
|
niCreate3DSound
|
||||||
|
niGet3DSound
|
||||||
|
niGet3DSoundCaps
|
||||||
|
niMuteSound
|
||||||
|
```
|
||||||
|
|
||||||
|
Backend динамически вызывает `DirectSoundEnumerateA` и `DirectSoundCreate`;
|
||||||
|
параметр `DisableDSound` может полностью отключить этот путь.
|
||||||
|
|
||||||
|
### Устройство и capabilities
|
||||||
|
|
||||||
|
Конфигурация учитывает `3D Sound`, качество, reverse sound, частоту buffer,
|
||||||
|
режим постоянного воспроизведения и автоматический выбор лучшего устройства.
|
||||||
|
Эти значения преобразуются во внутренний capability/profile object до создания
|
||||||
|
sources.
|
||||||
|
|
||||||
|
Код содержит отдельный no-device state и строку `3D Sound was not initialized`.
|
||||||
|
Отсутствие 3D sound обрабатывается отдельно от ошибок simulation/resources.
|
||||||
|
Новый runtime не должен позволять отсутствию звука разрушать simulation и
|
||||||
|
обязан возвращать звуковым командам явный no-device result.
|
||||||
|
|
||||||
|
Общий sound object разделяется между подсистемами и использует счётчик
|
||||||
|
владельцев. Закрывать DirectSound следует после остановки всех sources и
|
||||||
|
atmosphere/FX managers.
|
||||||
|
|
||||||
|
### Sound resources и SWAV
|
||||||
|
|
||||||
|
Основная библиотека называется `sounds.lib`; `mission.cfg` также создаёт
|
||||||
|
именованные sound resources и variations. Legacy API `rsLoadWave` загружает
|
||||||
|
waveform из archive. Импорт `MSACM32` подтверждает путь преобразования сжатых
|
||||||
|
wave-данных в формат playback buffer.
|
||||||
|
|
||||||
|
Resource identity состоит из library и name. Один sound asset может иметь
|
||||||
|
несколько runtime sources с различными position, volume, pitch/flags и временем
|
||||||
|
запуска. Поэтому кэшировать следует decoded sample/buffer, а source object
|
||||||
|
создавать на событие.
|
||||||
|
|
||||||
|
FX opcode 2 хранит `archive[32] + name[32]` и обычно создаёт sound command.
|
||||||
|
Atmosphere использует отдельные loop/variation sources, например rain
|
||||||
|
background. Миссионный слой содержит voice events для завершения или провала
|
||||||
|
задания.
|
||||||
|
|
||||||
|
Проверенный SWAV-корпус:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Часть 1: 399 — 306 MS ADPCM, 93 PCM
|
||||||
|
Часть 2: 540 — 446 MS ADPCM, 93 PCM, 1 empty entry
|
||||||
|
```
|
||||||
|
|
||||||
|
Все непустые записи имеют RIFF/WAVE framing и частоту 22 050 Hz. В Части 2
|
||||||
|
entry `ALIEN_ME.WAV` имеет размер 0. Это присутствующий archive key без
|
||||||
|
decodable waveform.
|
||||||
|
|
||||||
|
Sound loader должен различать:
|
||||||
|
|
||||||
|
- `entry_missing`;
|
||||||
|
- `entry_empty`;
|
||||||
|
- `wave_invalid`;
|
||||||
|
- `decoded_sample`.
|
||||||
|
|
||||||
|
Нулевой payload не передаётся RIFF parser-у и не должен приводить к чтению
|
||||||
|
header за границей.
|
||||||
|
|
||||||
|
### 3D listener и sources
|
||||||
|
|
||||||
|
Перед world traversal `stdRenderGame` обновляет listener из camera transform.
|
||||||
|
Listener содержит position, orientation и, при наличии, velocity. Source
|
||||||
|
содержит world position и параметры затухания. Spatialization выполняется
|
||||||
|
backend-ом либо совместимой программной моделью.
|
||||||
|
|
||||||
|
```text
|
||||||
|
camera transform
|
||||||
|
-> listener position/front/up
|
||||||
|
object or effect transform
|
||||||
|
-> source position
|
||||||
|
sample + source parameters
|
||||||
|
-> DirectSound 3D buffer
|
||||||
|
```
|
||||||
|
|
||||||
|
Прямо подтверждено обновление listener в начале `stdRenderGame`, до world
|
||||||
|
traversal. Sound events могут создаваться и в calculation/FX path, поэтому
|
||||||
|
нельзя утверждать, что listener предшествует созданию каждого source. Важно,
|
||||||
|
что spatial backend получает camera state текущего отображаемого кадра до
|
||||||
|
завершения его обработки. Перенос listener update после world render создаст
|
||||||
|
как минимум однокадровое рассогласование presentation.
|
||||||
|
|
||||||
|
### Громкость, mute и CD-аудио
|
||||||
|
|
||||||
|
`iron3d.dll` применяет отдельные настройки эффектов и CD sound. Параметр
|
||||||
|
`FORCE_CD_SOUND` меняет политику выбора музыкального источника. `niMuteSound`
|
||||||
|
должен временно остановить вывод без разрушения sample cache и logical playback
|
||||||
|
state.
|
||||||
|
|
||||||
|
В новой реализации полезно разделить buses: master, effects, ambient, voice и
|
||||||
|
music/CD. Это проектное решение совместимого backend-а, а не доказанный layout
|
||||||
|
оригинального mixer-а. Оно позволяет применять старые коэффициенты, не
|
||||||
|
переписывая individual source volume.
|
||||||
|
|
||||||
|
### Граница service layer
|
||||||
|
|
||||||
|
`Ngi32.dll` с DirectSound/backend code не изменилась между Частями 1 и 2, но
|
||||||
|
`services.dll` пересобрана и уменьшилась на 4 096 байт. Поэтому low-level
|
||||||
|
decoder/device path подтверждается одной машинной реализацией, а service
|
||||||
|
lifecycle, GUI/audio wiring и defaults требуют раздельной трассировки обеих
|
||||||
|
частей.
|
||||||
|
|
||||||
|
## Сетевая подсистема
|
||||||
|
|
||||||
|
Net инкапсулирует DirectPlay4A и lobby/service-provider API. World3D строит над
|
||||||
|
транспортом player identity, mirror objects и игровые сообщения. Эти уровни
|
||||||
|
следует разделять: DirectPlay отвечает за доставку bytes между players,
|
||||||
|
World3D -- за смысл сообщения и владение объектом.
|
||||||
|
|
||||||
|
Application GUID:
|
||||||
|
|
||||||
|
```text
|
||||||
|
{3C1D1F01-A870-11D1-8400-000021B14415}
|
||||||
|
```
|
||||||
|
|
||||||
|
Он передаётся network instance и service layer. Экземпляры с другим GUID не
|
||||||
|
принадлежат одному логическому приложению.
|
||||||
|
|
||||||
|
### Lifecycle соединения
|
||||||
|
|
||||||
|
Публичные функции Net покрывают полный цикл:
|
||||||
|
|
||||||
|
```text
|
||||||
|
CreateNetworkInstance
|
||||||
|
-> select/use service provider
|
||||||
|
-> setup connection
|
||||||
|
-> enumerate or create session
|
||||||
|
-> join/create session
|
||||||
|
-> create local player
|
||||||
|
-> send/receive messages and player data
|
||||||
|
-> destroy player
|
||||||
|
-> close session
|
||||||
|
-> close connection
|
||||||
|
```
|
||||||
|
|
||||||
|
Поддерживаются providers эпохи DirectPlay: TCP/IP, IPX и modem/lobby варианты,
|
||||||
|
если они установлены в системе. Функции явно проверяют, что DirectPlay enabled
|
||||||
|
до enumeration, session и player operations. Неверный порядок вызовов должен
|
||||||
|
возвращать понятную ошибку, а не разыменовывать пустой interface.
|
||||||
|
|
||||||
|
### Sessions, players и адреса
|
||||||
|
|
||||||
|
Net предоставляет enumeration service providers и sessions, выбор host/join,
|
||||||
|
player name/password/data, latency, максимальный размер сообщения, размер
|
||||||
|
очереди, server player info и provider address. Lobby launch обрабатывается
|
||||||
|
отдельной веткой.
|
||||||
|
|
||||||
|
Внутренняя модель должна хранить как минимум:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct NetPlayer {
|
||||||
|
TransportPlayerId transport_id;
|
||||||
|
uint16_t game_player_number;
|
||||||
|
string name;
|
||||||
|
RawBytes player_data;
|
||||||
|
bool is_local;
|
||||||
|
bool is_host;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Transport ID нельзя использовать как постоянный `ObjectId`. NetWatcher связывает
|
||||||
|
временный DirectPlay identifier с номером игрока и World3D entities.
|
||||||
|
|
||||||
|
### Игровые сообщения World3D
|
||||||
|
|
||||||
|
Подтверждённые имена message surface:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GMSG_CREATE_REMOTE_PLAYER
|
||||||
|
GMSG_APPEND_RESOURCE
|
||||||
|
GMSG_CHANGE_OBJECT_OWNER
|
||||||
|
GMSG_SET_PLAYER_DATA
|
||||||
|
GMSG_MISSION_DATA_PATH
|
||||||
|
GMSG_TAKE_OBJECT
|
||||||
|
GMSG_TEXT_FOR_PLAYER
|
||||||
|
GMSG_SYNC_STATE
|
||||||
|
GMSG_CREATE_MIRROR
|
||||||
|
GMSG_PAUSE_REMOTE_PLAYER
|
||||||
|
GMSG_CONFIRM_PLAYER_DATA
|
||||||
|
GMSG_KILL_PLAYER
|
||||||
|
SYSMSG_SET_TIME
|
||||||
|
SYSMSG_SET_PLAYER_NUMBER
|
||||||
|
GMSG_END_MESSAGE_SEQ
|
||||||
|
GMSG_REMOVE_RESOURCE
|
||||||
|
```
|
||||||
|
|
||||||
|
`GMSG_COLLISION_DETECTED` относится к общей очереди, но не обязательно
|
||||||
|
передаётся по сети. Message ID, payload size и delivery policy должны быть
|
||||||
|
частью явной schema. Нельзя сериализовать C++ pointers или native padding.
|
||||||
|
|
||||||
|
### Mirror objects и ownership
|
||||||
|
|
||||||
|
Удалённо принадлежащий объект представлен local mirror instance. Он участвует в
|
||||||
|
рендере и spatial queries, но authority над его созданием, ключевыми properties
|
||||||
|
и удалением находится у owner player. Сообщение смены владельца обновляет эту
|
||||||
|
границу; оно не должно создавать второй объект с тем же ID.
|
||||||
|
|
||||||
|
Типовой путь:
|
||||||
|
|
||||||
|
```text
|
||||||
|
remote create message
|
||||||
|
-> validate player and ObjectId
|
||||||
|
-> resolve prototype/resources
|
||||||
|
-> CreateMirrorObject
|
||||||
|
-> apply initial state
|
||||||
|
-> AddMirrorObjectToGame
|
||||||
|
-> subsequent sync messages update mirror
|
||||||
|
```
|
||||||
|
|
||||||
|
При потере player NetWatcher инициирует предписанное удаление или transfer
|
||||||
|
ownership через World3D queue. Мгновенное освобождение во время receive callback
|
||||||
|
запрещено по тем же причинам, что и в calculation pass.
|
||||||
|
|
||||||
|
### Сжатие и wire compatibility
|
||||||
|
|
||||||
|
`netZipData` и `netUnZipData` образуют встроенный слой упаковки payload. Он
|
||||||
|
находится выше транспорта: переход с DirectPlay на UDP/ENet не отменяет
|
||||||
|
необходимость воспроизводить формат упакованного сообщения, если требуется
|
||||||
|
соединение с оригинальной игрой.
|
||||||
|
|
||||||
|
Полный wire schema, framing и алгоритм сжатия пока не доказаны packet
|
||||||
|
capture-ом. Поэтому нужны два режима:
|
||||||
|
|
||||||
|
- **native compatibility** -- отдельный adapter, реализуемый после трассировки
|
||||||
|
оригинальных packets;
|
||||||
|
- **modern multiplayer** -- новая versioned protocol schema, использующая ту же
|
||||||
|
game-message семантику, но не заявляющая совместимость с DirectPlay client.
|
||||||
|
|
||||||
|
Эти режимы нельзя незаметно смешивать. До доказательства native wire
|
||||||
|
compatibility современный transport должен быть versioned и отделён от слоя,
|
||||||
|
который претендует на совместимость с оригинальным клиентом.
|
||||||
|
|
||||||
|
### Стабильность сетевого слоя
|
||||||
|
|
||||||
|
`Net.dll` и `World3D.dll` побайтно идентичны в обеих частях. Application GUID,
|
||||||
|
DirectPlay wrapper, mirror-object API и World3D message surface относятся к
|
||||||
|
одной машинной реализации.
|
||||||
|
|
||||||
|
Это подтверждает отсутствие отдельной сетевой реализации для Части 2, но не
|
||||||
|
закрывает wire schema: без packet/send-receive capture по-прежнему неизвестны
|
||||||
|
точное framing, reliability flags, payload layouts и алгоритм `netZipData` для
|
||||||
|
native interoperability.
|
||||||
|
|
||||||
|
Для binary regression достаточно одного профиля неизменённых DLL, но message
|
||||||
|
captures должны включать контент обеих частей, потому что prototype/resource IDs
|
||||||
|
и mission data различаются.
|
||||||
|
|
||||||
|
## Контракты реализации
|
||||||
|
|
||||||
|
Совместимая реализация должна фиксировать не только результат, но и момент его
|
||||||
|
появления в кадре. Для Behavior, Control, input, sound и network особенно важны
|
||||||
|
tick boundaries: одна и та же команда, применённая на один tick раньше или
|
||||||
|
позже, меняет дальнейшую симуляцию.
|
||||||
|
|
||||||
|
### Trace-события
|
||||||
|
|
||||||
|
Минимальный trace для этого тома:
|
||||||
|
|
||||||
|
- input snapshot: edge events, held state, analog values;
|
||||||
|
- camera state: mode, target, angles, matrices, viewport;
|
||||||
|
- Behavior: target, areal, global path revision, local corridor;
|
||||||
|
- Wizard: requested vector, constraints, wizard path;
|
||||||
|
- Control: candidate transform, contacts, correction, final transform;
|
||||||
|
- World3D queue: message name, ObjectId, dispatch phase, deferred deletion;
|
||||||
|
- sound: sample key, source owner, position, event tick, listener state;
|
||||||
|
- network: player mapping, message ID, payload length, delivery policy.
|
||||||
|
|
||||||
|
Для рендера это связывается с [render frame](../reference/render-frame.md):
|
||||||
|
camera и listener должны попадать в trace до world traversal, иначе нельзя
|
||||||
|
отделить ошибку presentation от ошибки управления.
|
||||||
|
|
||||||
|
### Проверки Behavior и сценариев
|
||||||
|
|
||||||
|
- script version mismatch даёт отдельную ошибку;
|
||||||
|
- event table читается lossless;
|
||||||
|
- VM body сохраняется без потери неизвестных bytes;
|
||||||
|
- отсутствующий `IArealMap` не замалчивается;
|
||||||
|
- non-walkable/non-reachable states дают diagnostic condition;
|
||||||
|
- одинаковый input log воспроизводит одинаковый sequence Behavior commands;
|
||||||
|
- resource names из TRF разрешаются через общий registry.
|
||||||
|
|
||||||
|
### Проверки Control
|
||||||
|
|
||||||
|
- движение без препятствий;
|
||||||
|
- slope/terrain-following;
|
||||||
|
- симметричные pair-collision tests с переставленными IDs;
|
||||||
|
- contact event отправляется один раз в предписанной фазе;
|
||||||
|
- удаление объекта в collision callback безопасно;
|
||||||
|
- replay одинакового input log даёт одинаковые transforms;
|
||||||
|
- collision proxy перестраивается после смены component/model state.
|
||||||
|
|
||||||
|
### Проверки input и камеры
|
||||||
|
|
||||||
|
- edge event не повторяется как held state;
|
||||||
|
- mouse/joystick axis сбрасывается по правилам snapshot;
|
||||||
|
- hot-unplug joystick не оставляет старое отклонение;
|
||||||
|
- camera horizontal angle wraps, vertical angle clamps;
|
||||||
|
- center command использует подтверждённое время, а не render FPS;
|
||||||
|
- Terrain и sound получают одну и ту же camera frame.
|
||||||
|
|
||||||
|
### Проверки звука
|
||||||
|
|
||||||
|
- backend может отсутствовать без нарушения simulation;
|
||||||
|
- один decoded sample переиспользуется несколькими sources;
|
||||||
|
- `entry_missing`, `entry_empty` и `wave_invalid` различаются;
|
||||||
|
- listener совпадает с camera frame;
|
||||||
|
- loop source корректно переживает pause/resume;
|
||||||
|
- mute не сбрасывает position и time;
|
||||||
|
- missing sound resource содержит полную диагностическую цепочку;
|
||||||
|
- deterministic test сравнивает список sound events, а не waveform устройства.
|
||||||
|
|
||||||
|
### Проверки сети
|
||||||
|
|
||||||
|
- нельзя создавать queue с активной сетью и нулевым player ID;
|
||||||
|
- session/player operations до enable/setup возвращают ошибку;
|
||||||
|
- сообщения проверяют длину до чтения payload;
|
||||||
|
- sequence/end markers обрабатываются в стабильном порядке;
|
||||||
|
- duplicate create mirror не создаёт второй instance;
|
||||||
|
- ownership change атомарно обновляет routing;
|
||||||
|
- pause/time messages применяются в одной simulation boundary;
|
||||||
|
- resource transfer имеет timeout `Network_ResourceTransferMaxDelay`;
|
||||||
|
- disconnect не оставляет objects с несуществующим owner;
|
||||||
|
- replay записанного message log даёт одинаковое World3D state.
|
||||||
|
|
||||||
|
`resnet.log` и `NetWatch.log` следует поддерживать как отдельные каналы: первый
|
||||||
|
относится к transport/resource exchange, второй -- к связи players и game
|
||||||
|
objects.
|
||||||
|
|
||||||
|
## Границы знания
|
||||||
|
|
||||||
|
Подтверждены внешние interfaces, часть runtime order, значимые строки,
|
||||||
|
конфигурационные параметры, corpus-level counts и стабильность ряда DLL между
|
||||||
|
двумя частями. Открытыми остаются:
|
||||||
|
|
||||||
|
- instruction grammar `.scr` и semantics всех VM opcodes;
|
||||||
|
- точная семантика всех TRF-записей;
|
||||||
|
- полный layout CTLD shape records;
|
||||||
|
- contact solver и порядок всех correction steps;
|
||||||
|
- class layout камер, контроллеров, sound service и network watcher;
|
||||||
|
- DirectPlay wire framing, reliability flags и payload schema;
|
||||||
|
- алгоритм `netZipData`/`netUnZipData`;
|
||||||
|
- точные defaults service layer там, где DLL пересобраны.
|
||||||
|
|
||||||
|
Эти границы должны оставаться видимыми в документации и тестах. Если новая
|
||||||
|
реализация вводит удобный современный abstraction layer, он обязан быть
|
||||||
|
отделён от утверждений о native compatibility и покрыт отдельным trace.
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
# 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, а не готовую игру.
|
||||||
|
|
||||||
|
### Этап 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.
|
||||||
|
|
||||||
|
### Этап 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
|
||||||
|
|
||||||
|
Записывается начальная миссия, 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. Утверждение без проверяемого критерия остаётся
|
||||||
|
исследовательской заметкой, а не контрактом.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,409 @@
|
|||||||
|
# Acceptance coverage manifest.
|
||||||
|
# Format: <acceptance-id>\t<covered|partial|blocked|omitted>\t<evidence>
|
||||||
|
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 or built-in supply-chain fallback, and strict acceptance audit
|
||||||
|
S0-ARCH-008 covered cargo xtask policy rejects moving Rust toolchains and workspace rust-version drift
|
||||||
|
S0-ARCH-009 covered .github/workflows/ci.yml runs a pinned MSRV backend-neutral crate job
|
||||||
|
S0-ARCH-010 covered cargo xtask acceptance audit emits commit_sha, rust_toolchain, and msrv metadata into the JSON artifact
|
||||||
|
S0-ARCH-011 blocked cargo run -p fparkan-vulkan-smoke emits explicit per-platform blocked artifacts until real Vulkan 300-frame validation=0 runner is available
|
||||||
|
S0-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 cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
|
||||||
|
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
|
||||||
|
S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_report_json_is_stable
|
||||||
|
S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
|
||||||
|
S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable
|
||||||
|
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
|
||||||
|
S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status
|
||||||
|
S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version
|
||||||
|
S0-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe
|
||||||
|
S0-VK-026 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_window_probe rejects_passed_without_surface_probe parses_surface_probe_as_instance_probe
|
||||||
|
S0-VK-027 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_swapchain_recreation blocked_report_includes_shader_manifest_and_bootstrap_status
|
||||||
|
S0-VK-028 covered cargo test -p fparkan-vulkan-smoke --offline reports_rustc_host_triple blocked_report_includes_shader_manifest_and_bootstrap_status
|
||||||
|
S0-VK-029 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
|
||||||
|
S0-VK-030 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_with_failed_surface
|
||||||
|
S0-VK-031 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_selected_device
|
||||||
|
S0-VK-032 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_swapchain
|
||||||
|
S0-VK-033 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_created_logical_device
|
||||||
|
S0-VK-034 covered cargo test -p xtask --offline native_smoke_audit_accepts_complete_three_platform_pass native_smoke_audit_rejects_blocked_or_incomplete_reports
|
||||||
|
S0-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-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-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-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-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
|
||||||
|
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-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
|
||||||
|
L3-P1-CAPTURE-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
||||||
|
L3-P2-CAPTURE-001 covered 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
|
||||||
|
S3-RENDER-001 covered cargo test -p fparkan-render --offline one_snapshot_draw_produces_one_draw_command
|
||||||
|
S3-RENDER-002 covered cargo test -p fparkan-render --offline material_index_maps_through_resolved_material_slots
|
||||||
|
S3-RENDER-003 covered cargo test -p fparkan-render --offline node_transform_is_retained
|
||||||
|
S3-RENDER-004 covered cargo test -p fparkan-render --offline command_order_uses_phase_then_stable_key
|
||||||
|
S3-RENDER-005 covered cargo test -p fparkan-render --offline command_capture_independent_of_snapshot_construction_order
|
||||||
|
S3-RENDER-006 covered cargo test -p fparkan-render --offline invalid_range_returns_contextual_error
|
||||||
|
S3-RENDER-007 covered cargo test -p fparkan-render --offline capture_is_stable
|
||||||
|
S3-RENDER-008 covered cargo test -p fparkan-render --offline recording_backend_stores_captures
|
||||||
|
S3-RENDER-009 covered cargo xtask policy
|
||||||
|
S3-GL-001 omitted permanent macOS Desktop GL 3.3 adapter is not implemented; historical CGL probe is retained as external evidence only
|
||||||
|
S3-GL-002 omitted outside the current macOS-focused goal scope; GLES2 remains documented for portable/non-macOS targets
|
||||||
|
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 in the macOS-focused scope
|
||||||
|
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 cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
||||||
|
L5-P2-RENDER-001 covered cargo test -p fparkan-game --offline selected_is_and_is2_missions_produce_approved_render_captures
|
||||||
|
L3-DEVICE-001 omitted outside the current macOS-focused goal scope; RG40XX-capable device/profile evidence remains documented for the portable target scope
|
||||||
|
L5-RG40-001 omitted outside the current macOS-focused goal scope; RG40XX 640x480 on-device mission smoke/performance/memory evidence remains documented for the portable target scope
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user