aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.devcontainer/devcontainer.json12
-rw-r--r--.gitea/workflows/docs-deploy.yml48
-rw-r--r--.gitea/workflows/renovate.yml25
-rw-r--r--.gitea/workflows/test.yml27
-rw-r--r--.github/dependabot.yml14
-rw-r--r--.github/workflows/mirror.yml15
-rw-r--r--.github/workflows/test.yml12
-rw-r--r--.gitignore219
-rw-r--r--.renovaterc (renamed from .github/renovate.json)0
-rw-r--r--Cargo.lock1705
-rw-r--r--Cargo.toml10
-rw-r--r--README.md60
-rw-r--r--crates/common/Cargo.toml6
-rw-r--r--crates/common/src/lib.rs44
-rw-r--r--crates/nres/Cargo.toml10
-rw-r--r--crates/nres/README.md42
-rw-r--r--crates/nres/src/error.rs110
-rw-r--r--crates/nres/src/lib.rs702
-rw-r--r--crates/nres/src/tests.rs996
-rw-r--r--crates/rsli/Cargo.toml8
-rw-r--r--crates/rsli/README.md58
-rw-r--r--crates/rsli/src/compress/deflate.rs14
-rw-r--r--crates/rsli/src/compress/lzh.rs298
-rw-r--r--crates/rsli/src/compress/lzss.rs79
-rw-r--r--crates/rsli/src/compress/mod.rs9
-rw-r--r--crates/rsli/src/compress/xor.rs29
-rw-r--r--crates/rsli/src/error.rs140
-rw-r--r--crates/rsli/src/lib.rs411
-rw-r--r--crates/rsli/src/parse.rs267
-rw-r--r--crates/rsli/src/tests.rs1337
-rw-r--r--docs/specs/ai.md5
-rw-r--r--docs/specs/arealmap.md5
-rw-r--r--docs/specs/behavior.md5
-rw-r--r--docs/specs/control.md5
-rw-r--r--docs/specs/fxid.md834
-rw-r--r--docs/specs/materials-texm.md874
-rw-r--r--docs/specs/missions.md5
-rw-r--r--docs/specs/msh-animation.md105
-rw-r--r--docs/specs/msh-core.md492
-rw-r--r--docs/specs/msh-notes.md277
-rw-r--r--docs/specs/msh.md22
-rw-r--r--docs/specs/network.md5
-rw-r--r--docs/specs/nres.md718
-rw-r--r--docs/specs/runtime-pipeline.md123
-rw-r--r--docs/specs/sound.md5
-rw-r--r--docs/specs/terrain-map-loading.md32
-rw-r--r--docs/specs/ui.md5
-rw-r--r--libnres/Cargo.toml16
-rw-r--r--libnres/README.md25
-rw-r--r--libnres/src/converter.rs33
-rw-r--r--libnres/src/error.rs45
-rw-r--r--libnres/src/lib.rs24
-rw-r--r--libnres/src/reader.rs227
-rw-r--r--mkdocs.yml26
-rw-r--r--nres-cli/Cargo.toml20
-rw-r--r--nres-cli/README.md6
-rw-r--r--nres-cli/src/main.rs198
-rw-r--r--packer/Cargo.toml9
-rw-r--r--packer/README.md27
-rw-r--r--packer/src/main.rs175
-rw-r--r--renovate.config.cjs6
-rw-r--r--testdata/nres/.gitignore2
-rw-r--r--testdata/rsli/.gitignore2
-rw-r--r--texture-decoder/Cargo.toml8
-rw-r--r--texture-decoder/README.md13
-rw-r--r--texture-decoder/src/main.rs41
-rw-r--r--tools/README.md201
-rw-r--r--tools/archive_roundtrip_validator.py944
-rw-r--r--tools/init_testdata.py204
-rw-r--r--tools/msh_doc_validator.py1000
-rw-r--r--tools/msh_export_obj.py357
-rw-r--r--tools/msh_preview_renderer.py481
-rw-r--r--unpacker/Cargo.toml9
-rw-r--r--unpacker/README.md41
-rw-r--r--unpacker/src/main.rs124
75 files changed, 11673 insertions, 2815 deletions
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 6cef205..f884a10 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -2,14 +2,8 @@
"image": "mcr.microsoft.com/devcontainers/rust:latest",
"customizations": {
"vscode": {
- "extensions": [
- "rust-lang.rust-analyzer"
- ]
+ "extensions": ["rust-lang.rust-analyzer"]
}
},
- "runArgs": [
- "--cap-add=SYS_PTRACE",
- "--security-opt",
- "seccomp=unconfined"
- ]
-} \ No newline at end of file
+ "runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"]
+}
diff --git a/.gitea/workflows/docs-deploy.yml b/.gitea/workflows/docs-deploy.yml
new file mode 100644
index 0000000..7656a88
--- /dev/null
+++ b/.gitea/workflows/docs-deploy.yml
@@ -0,0 +1,48 @@
+name: Docs Deploy
+
+on:
+ push:
+ branches:
+ - master
+
+jobs:
+ deploy-docs:
+ name: Build and Deploy MkDocs
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v6
+
+ - 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}:./"
diff --git a/.gitea/workflows/renovate.yml b/.gitea/workflows/renovate.yml
new file mode 100644
index 0000000..9b407ca
--- /dev/null
+++ b/.gitea/workflows/renovate.yml
@@ -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@v6
+
+ - name: Run renovate
+ run: |
+ renovate
+ env:
+ GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
+ LOG_LEVEL: ${{ vars.RENOVATE_LOG_LEVEL }}
+ RENOVATE_CONFIG_FILE: renovate.config.cjs
+ RENOVATE_LOG_LEVEL: ${{ vars.RENOVATE_LOG_LEVEL }}
+ RENOVATE_REPOSITORIES: ${{ gitea.repository }}
+ RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
new file mode 100644
index 0000000..cf314cb
--- /dev/null
+++ b/.gitea/workflows/test.yml
@@ -0,0 +1,27 @@
+name: Test
+
+on: [push, pull_request]
+
+jobs:
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v6
+ - 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@v6
+ - uses: dtolnay/rust-toolchain@stable
+ - name: Cargo test
+ run: cargo test --workspace --all-features -- --nocapture
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
deleted file mode 100644
index cf918a7..0000000
--- a/.github/dependabot.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-version: 2
-updates:
- - package-ecosystem: "cargo"
- directory: "/"
- schedule:
- interval: "weekly"
- - package-ecosystem: "github-actions"
- directory: "/"
- schedule:
- interval: "weekly"
- - package-ecosystem: "devcontainers"
- directory: "/"
- schedule:
- interval: "weekly"
diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml
deleted file mode 100644
index 0ec3a82..0000000
--- a/.github/workflows/mirror.yml
+++ /dev/null
@@ -1,15 +0,0 @@
-name: Mirror
-on: [push]
-
-jobs:
- mirror:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- - uses: yesolutions/mirror-action@master
- with:
- REMOTE: 'https://git.popov.link/fparkan.git'
- GIT_USERNAME: ${{ secrets.GIT_USERNAME }}
- GIT_PASSWORD: ${{ secrets.GIT_PASSWORD }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
deleted file mode 100644
index 01581b8..0000000
--- a/.github/workflows/test.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-name: Test
-on: [push, pull_request]
-
-jobs:
- test:
- name: cargo test
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: dtolnay/rust-toolchain@stable
- - run: cargo check --all
- - run: cargo test --all-features
diff --git a/.gitignore b/.gitignore
index ea8c4bf..2c15862 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,218 @@
-/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/
+
+# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
+# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
+Cargo.lock
+
+# These are backup files generated by rustfmt
+**/*.rs.bk
+
+# 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 \ No newline at end of file
diff --git a/.github/renovate.json b/.renovaterc
index bc629bb..bc629bb 100644
--- a/.github/renovate.json
+++ b/.renovaterc
diff --git a/Cargo.lock b/Cargo.lock
deleted file mode 100644
index 131e105..0000000
--- a/Cargo.lock
+++ /dev/null
@@ -1,1705 +0,0 @@
-# This file is automatically @generated by Cargo.
-# It is not intended for manual editing.
-version = 4
-
-[[package]]
-name = "addr2line"
-version = "0.24.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
-dependencies = [
- "gimli",
-]
-
-[[package]]
-name = "adler2"
-version = "2.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627"
-
-[[package]]
-name = "aligned-vec"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1"
-
-[[package]]
-name = "anstream"
-version = "0.6.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b"
-dependencies = [
- "anstyle",
- "anstyle-parse",
- "anstyle-query",
- "anstyle-wincon",
- "colorchoice",
- "is_terminal_polyfill",
- "utf8parse",
-]
-
-[[package]]
-name = "anstyle"
-version = "1.0.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9"
-
-[[package]]
-name = "anstyle-parse"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9"
-dependencies = [
- "utf8parse",
-]
-
-[[package]]
-name = "anstyle-query"
-version = "1.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c"
-dependencies = [
- "windows-sys",
-]
-
-[[package]]
-name = "anstyle-wincon"
-version = "3.0.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ca3534e77181a9cc07539ad51f2141fe32f6c3ffd4df76db8ad92346b003ae4e"
-dependencies = [
- "anstyle",
- "once_cell",
- "windows-sys",
-]
-
-[[package]]
-name = "anyhow"
-version = "1.0.96"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4"
-
-[[package]]
-name = "arbitrary"
-version = "1.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dde20b3d026af13f561bdd0f15edf01fc734f0dafcedbaf42bba506a9517f223"
-
-[[package]]
-name = "arg_enum_proc_macro"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "arrayvec"
-version = "0.7.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
-
-[[package]]
-name = "autocfg"
-version = "1.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26"
-
-[[package]]
-name = "av1-grain"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6678909d8c5d46a42abcf571271e15fdbc0a225e3646cf23762cd415046c78bf"
-dependencies = [
- "anyhow",
- "arrayvec",
- "log",
- "nom",
- "num-rational",
- "v_frame",
-]
-
-[[package]]
-name = "avif-serialize"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e335041290c43101ca215eed6f43ec437eb5a42125573f600fc3fa42b9bddd62"
-dependencies = [
- "arrayvec",
-]
-
-[[package]]
-name = "backtrace"
-version = "0.3.74"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a"
-dependencies = [
- "addr2line",
- "cfg-if",
- "libc",
- "miniz_oxide",
- "object",
- "rustc-demangle",
- "windows-targets",
-]
-
-[[package]]
-name = "backtrace-ext"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50"
-dependencies = [
- "backtrace",
-]
-
-[[package]]
-name = "bit_field"
-version = "0.10.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61"
-
-[[package]]
-name = "bitflags"
-version = "1.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
-
-[[package]]
-name = "bitflags"
-version = "2.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36"
-
-[[package]]
-name = "bitstream-io"
-version = "2.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
-
-[[package]]
-name = "built"
-version = "0.7.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56ed6191a7e78c36abdb16ab65341eefd73d64d303fffccdbb00d51e4205967b"
-
-[[package]]
-name = "bumpalo"
-version = "3.17.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf"
-
-[[package]]
-name = "bytemuck"
-version = "1.21.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3"
-
-[[package]]
-name = "byteorder"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
-
-[[package]]
-name = "byteorder-lite"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
-
-[[package]]
-name = "cc"
-version = "1.2.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af"
-dependencies = [
- "jobserver",
- "libc",
- "shlex",
-]
-
-[[package]]
-name = "cfg-expr"
-version = "0.15.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
-dependencies = [
- "smallvec",
- "target-lexicon",
-]
-
-[[package]]
-name = "cfg-if"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
-
-[[package]]
-name = "clap"
-version = "4.5.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "027bb0d98429ae334a8698531da7077bdf906419543a35a55c2cb1b66437d767"
-dependencies = [
- "clap_builder",
- "clap_derive",
-]
-
-[[package]]
-name = "clap_builder"
-version = "4.5.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5589e0cba072e0f3d23791efac0fd8627b49c829c196a492e88168e6a669d863"
-dependencies = [
- "anstream",
- "anstyle",
- "clap_lex",
- "strsim",
-]
-
-[[package]]
-name = "clap_derive"
-version = "4.5.28"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf4ced95c6f4a675af3da73304b9ac4ed991640c36374e4b46795c49e17cf1ed"
-dependencies = [
- "heck",
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "clap_lex"
-version = "0.7.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
-
-[[package]]
-name = "color_quant"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
-
-[[package]]
-name = "colorchoice"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
-
-[[package]]
-name = "console"
-version = "0.15.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b"
-dependencies = [
- "encode_unicode",
- "libc",
- "once_cell",
- "unicode-width 0.2.0",
- "windows-sys",
-]
-
-[[package]]
-name = "crc32fast"
-version = "1.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
-dependencies = [
- "cfg-if",
-]
-
-[[package]]
-name = "crossbeam-deque"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
-dependencies = [
- "crossbeam-epoch",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-epoch"
-version = "0.9.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
-dependencies = [
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-utils"
-version = "0.8.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
-
-[[package]]
-name = "crunchy"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43da5946c66ffcc7745f48db692ffbb10a83bfe0afd96235c5c2a4fb23994929"
-
-[[package]]
-name = "dialoguer"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de"
-dependencies = [
- "console",
- "shell-words",
- "tempfile",
- "thiserror 1.0.69",
- "zeroize",
-]
-
-[[package]]
-name = "either"
-version = "1.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0"
-
-[[package]]
-name = "encode_unicode"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
-
-[[package]]
-name = "equivalent"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
-
-[[package]]
-name = "errno"
-version = "0.3.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d"
-dependencies = [
- "libc",
- "windows-sys",
-]
-
-[[package]]
-name = "exr"
-version = "1.73.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0"
-dependencies = [
- "bit_field",
- "half",
- "lebe",
- "miniz_oxide",
- "rayon-core",
- "smallvec",
- "zune-inflate",
-]
-
-[[package]]
-name = "fastrand"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
-
-[[package]]
-name = "fdeflate"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
-dependencies = [
- "simd-adler32",
-]
-
-[[package]]
-name = "flate2"
-version = "1.0.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c936bfdafb507ebbf50b8074c54fa31c5be9a1e7e5f467dd659697041407d07c"
-dependencies = [
- "crc32fast",
- "miniz_oxide",
-]
-
-[[package]]
-name = "fuchsia-cprng"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
-
-[[package]]
-name = "getrandom"
-version = "0.2.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
-dependencies = [
- "cfg-if",
- "libc",
- "wasi 0.11.0+wasi-snapshot-preview1",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43a49c392881ce6d5c3b8cb70f98717b7c07aabbdff06687b9030dbfbe2725f8"
-dependencies = [
- "cfg-if",
- "libc",
- "wasi 0.13.3+wasi-0.2.2",
- "windows-targets",
-]
-
-[[package]]
-name = "gif"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2"
-dependencies = [
- "color_quant",
- "weezl",
-]
-
-[[package]]
-name = "gimli"
-version = "0.31.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
-
-[[package]]
-name = "half"
-version = "2.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888"
-dependencies = [
- "cfg-if",
- "crunchy",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.15.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289"
-
-[[package]]
-name = "heck"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
-
-[[package]]
-name = "image"
-version = "0.25.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd6f44aed642f18953a158afeb30206f4d50da59fbc66ecb53c66488de73563b"
-dependencies = [
- "bytemuck",
- "byteorder-lite",
- "color_quant",
- "exr",
- "gif",
- "image-webp",
- "num-traits",
- "png",
- "qoi",
- "ravif",
- "rayon",
- "rgb",
- "tiff",
- "zune-core",
- "zune-jpeg",
-]
-
-[[package]]
-name = "image-webp"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b77d01e822461baa8409e156015a1d91735549f0f2c17691bd2d996bef238f7f"
-dependencies = [
- "byteorder-lite",
- "quick-error",
-]
-
-[[package]]
-name = "imgref"
-version = "1.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
-
-[[package]]
-name = "indexmap"
-version = "2.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652"
-dependencies = [
- "equivalent",
- "hashbrown",
-]
-
-[[package]]
-name = "indicatif"
-version = "0.17.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
-dependencies = [
- "console",
- "number_prefix",
- "portable-atomic",
- "unicode-width 0.2.0",
- "web-time",
-]
-
-[[package]]
-name = "interpolate_name"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "is_ci"
-version = "1.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
-
-[[package]]
-name = "is_terminal_polyfill"
-version = "1.70.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
-
-[[package]]
-name = "itertools"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itoa"
-version = "1.0.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674"
-
-[[package]]
-name = "jobserver"
-version = "0.1.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48d1dbcbbeb6a7fec7e059840aa538bd62aaccf972c7346c4d9d2059312853d0"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "jpeg-decoder"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0"
-
-[[package]]
-name = "js-sys"
-version = "0.3.77"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
-dependencies = [
- "once_cell",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "lebe"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8"
-
-[[package]]
-name = "libc"
-version = "0.2.170"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828"
-
-[[package]]
-name = "libfuzzer-sys"
-version = "0.4.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf78f52d400cf2d84a3a973a78a592b4adc535739e0a5597a0da6f0c357adc75"
-dependencies = [
- "arbitrary",
- "cc",
-]
-
-[[package]]
-name = "libnres"
-version = "0.1.4"
-dependencies = [
- "byteorder",
- "log",
- "miette",
- "thiserror 2.0.11",
-]
-
-[[package]]
-name = "linux-raw-sys"
-version = "0.4.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
-
-[[package]]
-name = "log"
-version = "0.4.26"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e"
-
-[[package]]
-name = "loop9"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
-dependencies = [
- "imgref",
-]
-
-[[package]]
-name = "maybe-rayon"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
-dependencies = [
- "cfg-if",
- "rayon",
-]
-
-[[package]]
-name = "memchr"
-version = "2.7.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
-
-[[package]]
-name = "miette"
-version = "7.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a955165f87b37fd1862df2a59547ac542c77ef6d17c666f619d1ad22dd89484"
-dependencies = [
- "backtrace",
- "backtrace-ext",
- "cfg-if",
- "miette-derive",
- "owo-colors",
- "supports-color",
- "supports-hyperlinks",
- "supports-unicode",
- "terminal_size",
- "textwrap",
- "thiserror 1.0.69",
- "unicode-width 0.1.14",
-]
-
-[[package]]
-name = "miette-derive"
-version = "7.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf45bf44ab49be92fd1227a3be6fc6f617f1a337c06af54981048574d8783147"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "minimal-lexical"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
-
-[[package]]
-name = "miniz_oxide"
-version = "0.8.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5"
-dependencies = [
- "adler2",
- "simd-adler32",
-]
-
-[[package]]
-name = "new_debug_unreachable"
-version = "1.0.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
-
-[[package]]
-name = "nom"
-version = "7.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
-dependencies = [
- "memchr",
- "minimal-lexical",
-]
-
-[[package]]
-name = "noop_proc_macro"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
-
-[[package]]
-name = "nres-cli"
-version = "0.2.3"
-dependencies = [
- "byteorder",
- "clap",
- "console",
- "dialoguer",
- "indicatif",
- "libnres",
- "miette",
- "tempdir",
-]
-
-[[package]]
-name = "num-bigint"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
-dependencies = [
- "num-integer",
- "num-traits",
-]
-
-[[package]]
-name = "num-derive"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "num-integer"
-version = "0.1.46"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "num-rational"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
-dependencies = [
- "num-bigint",
- "num-integer",
- "num-traits",
-]
-
-[[package]]
-name = "num-traits"
-version = "0.2.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "number_prefix"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
-
-[[package]]
-name = "object"
-version = "0.36.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "once_cell"
-version = "1.20.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e"
-
-[[package]]
-name = "owo-colors"
-version = "4.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1036865bb9422d3300cf723f657c2851d0e9ab12567854b1f4eba3d77decf564"
-
-[[package]]
-name = "packer"
-version = "0.1.0"
-dependencies = [
- "byteorder",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "paste"
-version = "1.0.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
-
-[[package]]
-name = "pkg-config"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
-
-[[package]]
-name = "png"
-version = "0.17.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
-dependencies = [
- "bitflags 1.3.2",
- "crc32fast",
- "fdeflate",
- "flate2",
- "miniz_oxide",
-]
-
-[[package]]
-name = "portable-atomic"
-version = "1.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6"
-
-[[package]]
-name = "ppv-lite86"
-version = "0.2.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04"
-dependencies = [
- "zerocopy",
-]
-
-[[package]]
-name = "proc-macro2"
-version = "1.0.93"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60946a68e5f9d28b0dc1c21bb8a97ee7d018a8b322fa57838ba31cc878e22d99"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "profiling"
-version = "1.0.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "afbdc74edc00b6f6a218ca6a5364d6226a259d4b8ea1af4a0ea063f27e179f4d"
-dependencies = [
- "profiling-procmacros",
-]
-
-[[package]]
-name = "profiling-procmacros"
-version = "1.0.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a65f2e60fbf1063868558d69c6beacf412dc755f9fc020f514b7955fc914fe30"
-dependencies = [
- "quote",
- "syn",
-]
-
-[[package]]
-name = "qoi"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
-dependencies = [
- "bytemuck",
-]
-
-[[package]]
-name = "quick-error"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
-
-[[package]]
-name = "quote"
-version = "1.0.38"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc"
-dependencies = [
- "proc-macro2",
-]
-
-[[package]]
-name = "rand"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293"
-dependencies = [
- "fuchsia-cprng",
- "libc",
- "rand_core 0.3.1",
- "rdrand",
- "winapi",
-]
-
-[[package]]
-name = "rand"
-version = "0.8.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
-dependencies = [
- "libc",
- "rand_chacha",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b"
-dependencies = [
- "rand_core 0.4.2",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc"
-
-[[package]]
-name = "rand_core"
-version = "0.6.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
-dependencies = [
- "getrandom 0.2.15",
-]
-
-[[package]]
-name = "rav1e"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd87ce80a7665b1cce111f8a16c1f3929f6547ce91ade6addf4ec86a8dda5ce9"
-dependencies = [
- "arbitrary",
- "arg_enum_proc_macro",
- "arrayvec",
- "av1-grain",
- "bitstream-io",
- "built",
- "cfg-if",
- "interpolate_name",
- "itertools",
- "libc",
- "libfuzzer-sys",
- "log",
- "maybe-rayon",
- "new_debug_unreachable",
- "noop_proc_macro",
- "num-derive",
- "num-traits",
- "once_cell",
- "paste",
- "profiling",
- "rand 0.8.5",
- "rand_chacha",
- "simd_helpers",
- "system-deps",
- "thiserror 1.0.69",
- "v_frame",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "ravif"
-version = "0.11.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2413fd96bd0ea5cdeeb37eaf446a22e6ed7b981d792828721e74ded1980a45c6"
-dependencies = [
- "avif-serialize",
- "imgref",
- "loop9",
- "quick-error",
- "rav1e",
- "rayon",
- "rgb",
-]
-
-[[package]]
-name = "rayon"
-version = "1.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa"
-dependencies = [
- "either",
- "rayon-core",
-]
-
-[[package]]
-name = "rayon-core"
-version = "1.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2"
-dependencies = [
- "crossbeam-deque",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "rdrand"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2"
-dependencies = [
- "rand_core 0.3.1",
-]
-
-[[package]]
-name = "remove_dir_all"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7"
-dependencies = [
- "winapi",
-]
-
-[[package]]
-name = "rgb"
-version = "0.8.50"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a"
-
-[[package]]
-name = "rustc-demangle"
-version = "0.1.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
-
-[[package]]
-name = "rustix"
-version = "0.38.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
-dependencies = [
- "bitflags 2.8.0",
- "errno",
- "libc",
- "linux-raw-sys",
- "windows-sys",
-]
-
-[[package]]
-name = "rustversion"
-version = "1.0.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4"
-
-[[package]]
-name = "ryu"
-version = "1.0.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ea1a2d0a644769cc99faa24c3ad26b379b786fe7c36fd3c546254801650e6dd"
-
-[[package]]
-name = "serde"
-version = "1.0.218"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60"
-dependencies = [
- "serde_derive",
-]
-
-[[package]]
-name = "serde_derive"
-version = "1.0.218"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "serde_json"
-version = "1.0.139"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6"
-dependencies = [
- "itoa",
- "memchr",
- "ryu",
- "serde",
-]
-
-[[package]]
-name = "serde_spanned"
-version = "0.6.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1"
-dependencies = [
- "serde",
-]
-
-[[package]]
-name = "shell-words"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde"
-
-[[package]]
-name = "shlex"
-version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
-
-[[package]]
-name = "simd-adler32"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
-
-[[package]]
-name = "simd_helpers"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
-dependencies = [
- "quote",
-]
-
-[[package]]
-name = "smallvec"
-version = "1.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7fcf8323ef1faaee30a44a340193b1ac6814fd9b7b4e88e9d4519a3e4abe1cfd"
-
-[[package]]
-name = "strsim"
-version = "0.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
-
-[[package]]
-name = "supports-color"
-version = "3.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
-dependencies = [
- "is_ci",
-]
-
-[[package]]
-name = "supports-hyperlinks"
-version = "3.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "804f44ed3c63152de6a9f90acbea1a110441de43006ea51bcce8f436196a288b"
-
-[[package]]
-name = "supports-unicode"
-version = "3.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2"
-
-[[package]]
-name = "syn"
-version = "2.0.98"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36147f1a48ae0ec2b5b3bc5b537d267457555a10dc06f3dbc8cb11ba3006d3b1"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "system-deps"
-version = "6.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
-dependencies = [
- "cfg-expr",
- "heck",
- "pkg-config",
- "toml",
- "version-compare",
-]
-
-[[package]]
-name = "target-lexicon"
-version = "0.12.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
-
-[[package]]
-name = "tempdir"
-version = "0.3.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8"
-dependencies = [
- "rand 0.4.6",
- "remove_dir_all",
-]
-
-[[package]]
-name = "tempfile"
-version = "3.17.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230"
-dependencies = [
- "cfg-if",
- "fastrand",
- "getrandom 0.3.1",
- "once_cell",
- "rustix",
- "windows-sys",
-]
-
-[[package]]
-name = "terminal_size"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5352447f921fda68cf61b4101566c0bdb5104eff6804d0678e5227580ab6a4e9"
-dependencies = [
- "rustix",
- "windows-sys",
-]
-
-[[package]]
-name = "texture-decoder"
-version = "0.1.0"
-dependencies = [
- "byteorder",
- "image",
-]
-
-[[package]]
-name = "textwrap"
-version = "0.16.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23d434d3f8967a09480fb04132ebe0a3e088c173e6d0ee7897abbdf4eab0f8b9"
-dependencies = [
- "unicode-linebreak",
- "unicode-width 0.1.14",
-]
-
-[[package]]
-name = "thiserror"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
-dependencies = [
- "thiserror-impl 1.0.69",
-]
-
-[[package]]
-name = "thiserror"
-version = "2.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
-dependencies = [
- "thiserror-impl 2.0.11",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "2.0.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "tiff"
-version = "0.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
-dependencies = [
- "flate2",
- "jpeg-decoder",
- "weezl",
-]
-
-[[package]]
-name = "toml"
-version = "0.8.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd87a5cdd6ffab733b2f74bc4fd7ee5fff6634124999ac278c35fc78c6120148"
-dependencies = [
- "serde",
- "serde_spanned",
- "toml_datetime",
- "toml_edit",
-]
-
-[[package]]
-name = "toml_datetime"
-version = "0.6.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41"
-dependencies = [
- "serde",
-]
-
-[[package]]
-name = "toml_edit"
-version = "0.22.24"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474"
-dependencies = [
- "indexmap",
- "serde",
- "serde_spanned",
- "toml_datetime",
- "winnow",
-]
-
-[[package]]
-name = "unicode-ident"
-version = "1.0.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe"
-
-[[package]]
-name = "unicode-linebreak"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
-
-[[package]]
-name = "unicode-width"
-version = "0.1.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
-
-[[package]]
-name = "unicode-width"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
-
-[[package]]
-name = "unpacker"
-version = "0.1.1"
-dependencies = [
- "byteorder",
- "serde",
- "serde_json",
-]
-
-[[package]]
-name = "utf8parse"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
-
-[[package]]
-name = "v_frame"
-version = "0.3.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6f32aaa24bacd11e488aa9ba66369c7cd514885742c9fe08cfe85884db3e92b"
-dependencies = [
- "aligned-vec",
- "num-traits",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "version-compare"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "852e951cb7832cb45cb1169900d19760cfa39b82bc0ea9c0e5a14ae88411c98b"
-
-[[package]]
-name = "wasi"
-version = "0.11.0+wasi-snapshot-preview1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
-
-[[package]]
-name = "wasi"
-version = "0.13.3+wasi-0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26816d2e1a4a36a2940b96c5296ce403917633dff8f3440e9b236ed6f6bacad2"
-dependencies = [
- "wit-bindgen-rt",
-]
-
-[[package]]
-name = "wasm-bindgen"
-version = "0.2.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
-dependencies = [
- "cfg-if",
- "once_cell",
- "rustversion",
- "wasm-bindgen-macro",
-]
-
-[[package]]
-name = "wasm-bindgen-backend"
-version = "0.2.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
-dependencies = [
- "bumpalo",
- "log",
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-macro"
-version = "0.2.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
-dependencies = [
- "quote",
- "wasm-bindgen-macro-support",
-]
-
-[[package]]
-name = "wasm-bindgen-macro-support"
-version = "0.2.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-backend",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-shared"
-version = "0.2.100"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "web-time"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "weezl"
-version = "0.1.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082"
-
-[[package]]
-name = "winapi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-
-[[package]]
-name = "windows-sys"
-version = "0.59.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
-dependencies = [
- "windows-targets",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
-dependencies = [
- "windows_aarch64_gnullvm",
- "windows_aarch64_msvc",
- "windows_i686_gnu",
- "windows_i686_gnullvm",
- "windows_i686_msvc",
- "windows_x86_64_gnu",
- "windows_x86_64_gnullvm",
- "windows_x86_64_msvc",
-]
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-
-[[package]]
-name = "winnow"
-version = "0.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "wit-bindgen-rt"
-version = "0.33.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
-dependencies = [
- "bitflags 2.8.0",
-]
-
-[[package]]
-name = "zerocopy"
-version = "0.7.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0"
-dependencies = [
- "byteorder",
- "zerocopy-derive",
-]
-
-[[package]]
-name = "zerocopy-derive"
-version = "0.7.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "zeroize"
-version = "1.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
-
-[[package]]
-name = "zune-core"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
-
-[[package]]
-name = "zune-inflate"
-version = "0.2.54"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
-dependencies = [
- "simd-adler32",
-]
-
-[[package]]
-name = "zune-jpeg"
-version = "0.4.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "99a5bab8d7dedf81405c4bb1f2b83ea057643d9cb28778cea9eecddeedd2e028"
-dependencies = [
- "zune-core",
-]
diff --git a/Cargo.toml b/Cargo.toml
index dce8213..34c501a 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,12 +1,6 @@
[workspace]
-resolver = "2"
-members = [
- "libnres",
- "nres-cli",
- "packer",
- "texture-decoder",
- "unpacker",
-]
+resolver = "3"
+members = ["crates/*"]
[profile.release]
codegen-units = 1
diff --git a/README.md b/README.md
index f5b45a0..86e525a 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,55 @@
-# Utilities for the game "Parkan: Iron Strategy"
+# FParkan
-This repository contains utilities, tools, and libraries for the game "Parkan: Iron Strategy."
+Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования.
-## 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>
+
+## Инструменты
+
+Вспомогательные инструменты находятся в каталоге [`tools/`](tools).
+
+- [tools/archive_roundtrip_validator.py](tools/archive_roundtrip_validator.py) — инструмент верификации документации по архивам `NRes`/`RsLi` на реальных файлах (включая `unpack -> repack -> byte-compare`).
+- [tools/init_testdata.py](tools/init_testdata.py) — подготовка тестовых данных по сигнатурам с раскладкой по каталогам.
+
+## Библиотеки
+
+- [crates/nres](crates/nres) — библиотека для работы с файлами архивов NRes (чтение, поиск, редактирование, сохранение).
+- [crates/rsli](crates/rsli) — библиотека для работы с файлами архивов RsLi (чтение, поиск, загрузка/распаковка поддерживаемых методов).
+
+## Тестирование
+
+Базовое тестирование проходит на синтетических тестах из репозитория.
+
+Для дополнительного тестирования на реальных игровых ресурсах:
+
+- используйте [tools/init_testdata.py](tools/init_testdata.py) для подготовки локального набора;
+- используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy));
+- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
+
+## 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)**.
diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml
new file mode 100644
index 0000000..e020b17
--- /dev/null
+++ b/crates/common/Cargo.toml
@@ -0,0 +1,6 @@
+[package]
+name = "common"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs
new file mode 100644
index 0000000..69796d3
--- /dev/null
+++ b/crates/common/src/lib.rs
@@ -0,0 +1,44 @@
+use std::io;
+
+/// Resource payload that can be either borrowed from mapped bytes or owned.
+#[derive(Clone, Debug)]
+pub enum ResourceData<'a> {
+ Borrowed(&'a [u8]),
+ Owned(Vec<u8>),
+}
+
+impl<'a> ResourceData<'a> {
+ pub fn as_slice(&self) -> &[u8] {
+ match self {
+ Self::Borrowed(slice) => slice,
+ Self::Owned(buf) => buf.as_slice(),
+ }
+ }
+
+ pub fn into_owned(self) -> Vec<u8> {
+ match self {
+ Self::Borrowed(slice) => slice.to_vec(),
+ Self::Owned(buf) => buf,
+ }
+ }
+}
+
+impl AsRef<[u8]> for ResourceData<'_> {
+ fn as_ref(&self) -> &[u8] {
+ self.as_slice()
+ }
+}
+
+/// Output sink used by `read_into`/`load_into` APIs.
+pub trait OutputBuffer {
+ /// Writes the full payload to the sink, replacing any previous content.
+ fn write_exact(&mut self, data: &[u8]) -> io::Result<()>;
+}
+
+impl OutputBuffer for Vec<u8> {
+ fn write_exact(&mut self, data: &[u8]) -> io::Result<()> {
+ self.clear();
+ self.extend_from_slice(data);
+ Ok(())
+ }
+}
diff --git a/crates/nres/Cargo.toml b/crates/nres/Cargo.toml
new file mode 100644
index 0000000..38b8822
--- /dev/null
+++ b/crates/nres/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "nres"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+common = { path = "../common" }
+
+[target.'cfg(windows)'.dependencies]
+windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }
diff --git a/crates/nres/README.md b/crates/nres/README.md
new file mode 100644
index 0000000..8b9dfb5
--- /dev/null
+++ b/crates/nres/README.md
@@ -0,0 +1,42 @@
+# nres
+
+Rust-библиотека для работы с архивами формата **NRes**.
+
+## Что умеет
+
+- Открытие архива из файла (`open_path`) и из памяти (`open_bytes`).
+- Поддержка `raw_mode` (весь файл как единый ресурс).
+- Чтение метаданных и итерация по записям.
+- Поиск по имени без учёта регистра (`find`).
+- Чтение данных ресурса (`read`, `read_into`, `raw_slice`).
+- Редактирование архива через `Editor`:
+- `add`, `replace_data`, `remove`.
+- `commit` с пересчётом `sort_index`, выравниванием по 8 байт и атомарной записью файла.
+
+## Модель ошибок
+
+Библиотека возвращает типизированные ошибки (`InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `DirectoryOutOfBounds`, `EntryDataOutOfBounds`, и др.) без паник в production-коде.
+
+## Покрытие тестами
+
+### Реальные файлы
+
+- Рекурсивный прогон по `testdata/nres/**`.
+- Сейчас в наборе: **120 архивов**.
+- Для каждого архива проверяется:
+- чтение всех записей;
+- `read`/`read_into`/`raw_slice`;
+- `find`;
+- `unpack -> repack (Editor::commit)` с проверкой **byte-to-byte**.
+
+### Синтетические тесты
+
+- Проверка основных сценариев редактирования (`add/replace/remove/commit`).
+- Проверка валидации и ошибок:
+- `InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `InvalidEntryCount`, `DirectoryOutOfBounds`, `NameTooLong`, `EntryDataOutOfBounds`, `EntryIdOutOfRange`, `NameContainsNul`.
+
+## Быстрый запуск тестов
+
+```bash
+cargo test -p nres -- --nocapture
+```
diff --git a/crates/nres/src/error.rs b/crates/nres/src/error.rs
new file mode 100644
index 0000000..9a3c651
--- /dev/null
+++ b/crates/nres/src/error.rs
@@ -0,0 +1,110 @@
+use core::fmt;
+
+#[derive(Debug)]
+#[non_exhaustive]
+pub enum Error {
+ Io(std::io::Error),
+
+ InvalidMagic {
+ got: [u8; 4],
+ },
+ UnsupportedVersion {
+ got: u32,
+ },
+ TotalSizeMismatch {
+ header: u32,
+ actual: u64,
+ },
+
+ InvalidEntryCount {
+ got: i32,
+ },
+ TooManyEntries {
+ got: usize,
+ },
+ DirectoryOutOfBounds {
+ directory_offset: u64,
+ directory_len: u64,
+ file_len: u64,
+ },
+
+ EntryIdOutOfRange {
+ id: u32,
+ entry_count: u32,
+ },
+ EntryDataOutOfBounds {
+ id: u32,
+ offset: u64,
+ size: u32,
+ directory_offset: u64,
+ },
+ NameTooLong {
+ got: usize,
+ max: usize,
+ },
+ NameContainsNul,
+ BadNameEncoding,
+
+ IntegerOverflow,
+
+ RawModeDisallowsOperation(&'static str),
+}
+
+impl From<std::io::Error> for Error {
+ fn from(value: std::io::Error) -> Self {
+ Self::Io(value)
+ }
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Error::Io(e) => write!(f, "I/O error: {e}"),
+ Error::InvalidMagic { got } => write!(f, "invalid NRes magic: {got:02X?}"),
+ Error::UnsupportedVersion { got } => {
+ write!(f, "unsupported NRes version: {got:#x}")
+ }
+ Error::TotalSizeMismatch { header, actual } => {
+ write!(f, "NRes total_size mismatch: header={header}, actual={actual}")
+ }
+ Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
+ Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
+ Error::DirectoryOutOfBounds {
+ directory_offset,
+ directory_len,
+ file_len,
+ } => write!(
+ f,
+ "directory out of bounds: off={directory_offset}, len={directory_len}, file={file_len}"
+ ),
+ Error::EntryIdOutOfRange { id, entry_count } => {
+ write!(f, "entry id out of range: id={id}, count={entry_count}")
+ }
+ Error::EntryDataOutOfBounds {
+ id,
+ offset,
+ size,
+ directory_offset,
+ } => write!(
+ f,
+ "entry data out of bounds: id={id}, off={offset}, size={size}, dir_off={directory_offset}"
+ ),
+ Error::NameTooLong { got, max } => write!(f, "name too long: {got} > {max}"),
+ Error::NameContainsNul => write!(f, "name contains NUL byte"),
+ Error::BadNameEncoding => write!(f, "bad name encoding"),
+ Error::IntegerOverflow => write!(f, "integer overflow"),
+ Error::RawModeDisallowsOperation(op) => {
+ write!(f, "operation not allowed in raw mode: {op}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Io(err) => Some(err),
+ _ => None,
+ }
+ }
+}
diff --git a/crates/nres/src/lib.rs b/crates/nres/src/lib.rs
new file mode 100644
index 0000000..e0631e3
--- /dev/null
+++ b/crates/nres/src/lib.rs
@@ -0,0 +1,702 @@
+pub mod error;
+
+use crate::error::Error;
+use common::{OutputBuffer, ResourceData};
+use core::ops::Range;
+use std::cmp::Ordering;
+use std::fs::{self, OpenOptions as FsOpenOptions};
+use std::io::Write;
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+pub type Result<T> = core::result::Result<T, Error>;
+
+#[derive(Clone, Debug, Default)]
+pub struct OpenOptions {
+ pub raw_mode: bool,
+ pub sequential_hint: bool,
+ pub prefetch_pages: bool,
+}
+
+#[derive(Clone, Debug, Default)]
+pub enum OpenMode {
+ #[default]
+ ReadOnly,
+ ReadWrite,
+}
+
+#[derive(Debug)]
+pub struct Archive {
+ bytes: Arc<[u8]>,
+ entries: Vec<EntryRecord>,
+ raw_mode: bool,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
+pub struct EntryId(pub u32);
+
+#[derive(Clone, Debug)]
+pub struct EntryMeta {
+ pub kind: u32,
+ pub attr1: u32,
+ pub attr2: u32,
+ pub attr3: u32,
+ pub name: String,
+ pub data_offset: u64,
+ pub data_size: u32,
+ pub sort_index: u32,
+}
+
+#[derive(Copy, Clone, Debug)]
+pub struct EntryRef<'a> {
+ pub id: EntryId,
+ pub meta: &'a EntryMeta,
+}
+
+#[derive(Clone, Debug)]
+struct EntryRecord {
+ meta: EntryMeta,
+ name_raw: [u8; 36],
+}
+
+impl Archive {
+ pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
+ Self::open_path_with(path, OpenMode::ReadOnly, OpenOptions::default())
+ }
+
+ pub fn open_path_with(
+ path: impl AsRef<Path>,
+ _mode: OpenMode,
+ opts: OpenOptions,
+ ) -> Result<Self> {
+ let bytes = fs::read(path.as_ref())?;
+ let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
+ Self::open_bytes(arc, opts)
+ }
+
+ pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> {
+ let (entries, _) = parse_archive(&bytes, opts.raw_mode)?;
+ if opts.prefetch_pages {
+ prefetch_pages(&bytes);
+ }
+ Ok(Self {
+ bytes,
+ entries,
+ raw_mode: opts.raw_mode,
+ })
+ }
+
+ pub fn entry_count(&self) -> usize {
+ self.entries.len()
+ }
+
+ pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
+ self.entries
+ .iter()
+ .enumerate()
+ .map(|(idx, entry)| EntryRef {
+ id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
+ meta: &entry.meta,
+ })
+ }
+
+ pub fn find(&self, name: &str) -> Option<EntryId> {
+ if self.entries.is_empty() {
+ return None;
+ }
+
+ if !self.raw_mode {
+ let mut low = 0usize;
+ let mut high = self.entries.len();
+ while low < high {
+ let mid = low + (high - low) / 2;
+ let Ok(target_idx) = usize::try_from(self.entries[mid].meta.sort_index) else {
+ break;
+ };
+ if target_idx >= self.entries.len() {
+ break;
+ }
+ let cmp = cmp_name_case_insensitive(
+ name.as_bytes(),
+ entry_name_bytes(&self.entries[target_idx].name_raw),
+ );
+ match cmp {
+ Ordering::Less => high = mid,
+ Ordering::Greater => low = mid + 1,
+ Ordering::Equal => {
+ return Some(EntryId(
+ u32::try_from(target_idx).expect("entry count validated at parse"),
+ ))
+ }
+ }
+ }
+ }
+
+ self.entries.iter().enumerate().find_map(|(idx, entry)| {
+ if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
+ == Ordering::Equal
+ {
+ Some(EntryId(
+ u32::try_from(idx).expect("entry count validated at parse"),
+ ))
+ } else {
+ None
+ }
+ })
+ }
+
+ pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
+ let idx = usize::try_from(id.0).ok()?;
+ let entry = self.entries.get(idx)?;
+ Some(EntryRef {
+ id,
+ meta: &entry.meta,
+ })
+ }
+
+ pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> {
+ let range = self.entry_range(id)?;
+ Ok(ResourceData::Borrowed(&self.bytes[range]))
+ }
+
+ pub fn read_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> {
+ let range = self.entry_range(id)?;
+ out.write_exact(&self.bytes[range.clone()])?;
+ Ok(range.len())
+ }
+
+ pub fn raw_slice(&self, id: EntryId) -> Result<Option<&[u8]>> {
+ let range = self.entry_range(id)?;
+ Ok(Some(&self.bytes[range]))
+ }
+
+ pub fn edit_path(path: impl AsRef<Path>) -> Result<Editor> {
+ let path_buf = path.as_ref().to_path_buf();
+ let bytes = fs::read(&path_buf)?;
+ let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
+ let (entries, _) = parse_archive(&arc, false)?;
+ let mut editable = Vec::with_capacity(entries.len());
+ for entry in &entries {
+ let range = checked_range(entry.meta.data_offset, entry.meta.data_size, arc.len())?;
+ editable.push(EditableEntry {
+ meta: entry.meta.clone(),
+ name_raw: entry.name_raw,
+ data: EntryData::Borrowed(range), // Copy-on-write: only store range
+ });
+ }
+ Ok(Editor {
+ path: path_buf,
+ source: arc,
+ entries: editable,
+ })
+ }
+
+ fn entry_range(&self, id: EntryId) -> Result<Range<usize>> {
+ let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
+ let Some(entry) = self.entries.get(idx) else {
+ return Err(Error::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
+ });
+ };
+ checked_range(
+ entry.meta.data_offset,
+ entry.meta.data_size,
+ self.bytes.len(),
+ )
+ }
+}
+
+pub struct Editor {
+ path: PathBuf,
+ source: Arc<[u8]>,
+ entries: Vec<EditableEntry>,
+}
+
+#[derive(Clone, Debug)]
+enum EntryData {
+ Borrowed(Range<usize>),
+ Modified(Vec<u8>),
+}
+
+#[derive(Clone, Debug)]
+struct EditableEntry {
+ meta: EntryMeta,
+ name_raw: [u8; 36],
+ data: EntryData,
+}
+
+impl EditableEntry {
+ fn data_slice<'a>(&'a self, source: &'a Arc<[u8]>) -> &'a [u8] {
+ match &self.data {
+ EntryData::Borrowed(range) => &source[range.clone()],
+ EntryData::Modified(vec) => vec.as_slice(),
+ }
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct NewEntry<'a> {
+ pub kind: u32,
+ pub attr1: u32,
+ pub attr2: u32,
+ pub attr3: u32,
+ pub name: &'a str,
+ pub data: &'a [u8],
+}
+
+impl Editor {
+ pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
+ self.entries
+ .iter()
+ .enumerate()
+ .map(|(idx, entry)| EntryRef {
+ id: EntryId(u32::try_from(idx).expect("entry count validated at add")),
+ meta: &entry.meta,
+ })
+ }
+
+ pub fn add(&mut self, entry: NewEntry<'_>) -> Result<EntryId> {
+ let name_raw = encode_name_field(entry.name)?;
+ let id_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
+ let data_size = u32::try_from(entry.data.len()).map_err(|_| Error::IntegerOverflow)?;
+ self.entries.push(EditableEntry {
+ meta: EntryMeta {
+ kind: entry.kind,
+ attr1: entry.attr1,
+ attr2: entry.attr2,
+ attr3: entry.attr3,
+ name: decode_name(entry_name_bytes(&name_raw)),
+ data_offset: 0,
+ data_size,
+ sort_index: 0,
+ },
+ name_raw,
+ data: EntryData::Modified(entry.data.to_vec()),
+ });
+ Ok(EntryId(id_u32))
+ }
+
+ pub fn replace_data(&mut self, id: EntryId, data: &[u8]) -> Result<()> {
+ let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
+ let Some(entry) = self.entries.get_mut(idx) else {
+ return Err(Error::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
+ });
+ };
+ entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?;
+ // Replace with new data (triggers copy-on-write if borrowed)
+ entry.data = EntryData::Modified(data.to_vec());
+ Ok(())
+ }
+
+ pub fn remove(&mut self, id: EntryId) -> Result<()> {
+ let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
+ if idx >= self.entries.len() {
+ return Err(Error::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
+ });
+ }
+ self.entries.remove(idx);
+ Ok(())
+ }
+
+ pub fn commit(mut self) -> Result<()> {
+ let count_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
+
+ // Pre-calculate capacity to avoid reallocations
+ let total_data_size: usize = self
+ .entries
+ .iter()
+ .map(|e| e.data_slice(&self.source).len())
+ .sum();
+ let padding_estimate = self.entries.len() * 8; // Max 8 bytes padding per entry
+ let directory_size = self.entries.len() * 64; // 64 bytes per entry
+ let capacity = 16 + total_data_size + padding_estimate + directory_size;
+
+ let mut out = Vec::with_capacity(capacity);
+ out.resize(16, 0); // Header
+
+ // Keep reference to source for copy-on-write
+ let source = &self.source;
+
+ for entry in &mut self.entries {
+ entry.meta.data_offset =
+ u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?;
+
+ // Calculate size and get slice separately to avoid borrow conflicts
+ let data_len = entry.data_slice(source).len();
+ entry.meta.data_size = u32::try_from(data_len).map_err(|_| Error::IntegerOverflow)?;
+
+ // Now get the slice again for writing
+ let data_slice = entry.data_slice(source);
+ out.extend_from_slice(data_slice);
+
+ let padding = (8 - (out.len() % 8)) % 8;
+ if padding > 0 {
+ out.resize(out.len() + padding, 0);
+ }
+ }
+
+ let mut sort_order: Vec<usize> = (0..self.entries.len()).collect();
+ sort_order.sort_by(|a, b| {
+ cmp_name_case_insensitive(
+ entry_name_bytes(&self.entries[*a].name_raw),
+ entry_name_bytes(&self.entries[*b].name_raw),
+ )
+ });
+
+ for (idx, entry) in self.entries.iter_mut().enumerate() {
+ entry.meta.sort_index =
+ u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?;
+ }
+
+ for entry in &self.entries {
+ let data_offset_u32 =
+ u32::try_from(entry.meta.data_offset).map_err(|_| Error::IntegerOverflow)?;
+ push_u32(&mut out, entry.meta.kind);
+ push_u32(&mut out, entry.meta.attr1);
+ push_u32(&mut out, entry.meta.attr2);
+ push_u32(&mut out, entry.meta.data_size);
+ push_u32(&mut out, entry.meta.attr3);
+ out.extend_from_slice(&entry.name_raw);
+ push_u32(&mut out, data_offset_u32);
+ push_u32(&mut out, entry.meta.sort_index);
+ }
+
+ let total_size_u32 = u32::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?;
+ out[0..4].copy_from_slice(b"NRes");
+ out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ out[8..12].copy_from_slice(&count_u32.to_le_bytes());
+ out[12..16].copy_from_slice(&total_size_u32.to_le_bytes());
+
+ write_atomic(&self.path, &out)
+ }
+}
+
+fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)> {
+ if raw_mode {
+ let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
+ let entry = EntryRecord {
+ meta: EntryMeta {
+ kind: 0,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: String::from("RAW"),
+ data_offset: 0,
+ data_size,
+ sort_index: 0,
+ },
+ name_raw: {
+ let mut name = [0u8; 36];
+ let bytes_name = b"RAW";
+ name[..bytes_name.len()].copy_from_slice(bytes_name);
+ name
+ },
+ };
+ return Ok((
+ vec![entry],
+ u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
+ ));
+ }
+
+ if bytes.len() < 16 {
+ let mut got = [0u8; 4];
+ let copy_len = bytes.len().min(4);
+ got[..copy_len].copy_from_slice(&bytes[..copy_len]);
+ return Err(Error::InvalidMagic { got });
+ }
+
+ let mut magic = [0u8; 4];
+ magic.copy_from_slice(&bytes[0..4]);
+ if &magic != b"NRes" {
+ return Err(Error::InvalidMagic { got: magic });
+ }
+
+ let version = read_u32(bytes, 4)?;
+ if version != 0x100 {
+ return Err(Error::UnsupportedVersion { got: version });
+ }
+
+ let entry_count_i32 = i32::from_le_bytes(
+ bytes[8..12]
+ .try_into()
+ .map_err(|_| Error::IntegerOverflow)?,
+ );
+ if entry_count_i32 < 0 {
+ return Err(Error::InvalidEntryCount {
+ got: entry_count_i32,
+ });
+ }
+ let entry_count = usize::try_from(entry_count_i32).map_err(|_| Error::IntegerOverflow)?;
+
+ // Validate entry_count fits in u32 (required for EntryId)
+ if entry_count > u32::MAX as usize {
+ return Err(Error::TooManyEntries { got: entry_count });
+ }
+
+ let total_size = read_u32(bytes, 12)?;
+ let actual_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
+ if u64::from(total_size) != actual_size {
+ return Err(Error::TotalSizeMismatch {
+ header: total_size,
+ actual: actual_size,
+ });
+ }
+
+ let directory_len = u64::try_from(entry_count)
+ .map_err(|_| Error::IntegerOverflow)?
+ .checked_mul(64)
+ .ok_or(Error::IntegerOverflow)?;
+ let directory_offset =
+ u64::from(total_size)
+ .checked_sub(directory_len)
+ .ok_or(Error::DirectoryOutOfBounds {
+ directory_offset: 0,
+ directory_len,
+ file_len: actual_size,
+ })?;
+
+ if directory_offset < 16 || directory_offset + directory_len > actual_size {
+ return Err(Error::DirectoryOutOfBounds {
+ directory_offset,
+ directory_len,
+ file_len: actual_size,
+ });
+ }
+
+ let mut entries = Vec::with_capacity(entry_count);
+ for index in 0..entry_count {
+ let base = usize::try_from(directory_offset)
+ .map_err(|_| Error::IntegerOverflow)?
+ .checked_add(index.checked_mul(64).ok_or(Error::IntegerOverflow)?)
+ .ok_or(Error::IntegerOverflow)?;
+
+ let kind = read_u32(bytes, base)?;
+ let attr1 = read_u32(bytes, base + 4)?;
+ let attr2 = read_u32(bytes, base + 8)?;
+ let data_size = read_u32(bytes, base + 12)?;
+ let attr3 = read_u32(bytes, base + 16)?;
+
+ let mut name_raw = [0u8; 36];
+ let name_slice = bytes
+ .get(base + 20..base + 56)
+ .ok_or(Error::IntegerOverflow)?;
+ name_raw.copy_from_slice(name_slice);
+
+ let name_bytes = entry_name_bytes(&name_raw);
+ if name_bytes.len() > 35 {
+ return Err(Error::NameTooLong {
+ got: name_bytes.len(),
+ max: 35,
+ });
+ }
+
+ let data_offset = u64::from(read_u32(bytes, base + 56)?);
+ let sort_index = read_u32(bytes, base + 60)?;
+
+ let end = data_offset
+ .checked_add(u64::from(data_size))
+ .ok_or(Error::IntegerOverflow)?;
+ if data_offset < 16 || end > directory_offset {
+ return Err(Error::EntryDataOutOfBounds {
+ id: u32::try_from(index).map_err(|_| Error::IntegerOverflow)?,
+ offset: data_offset,
+ size: data_size,
+ directory_offset,
+ });
+ }
+
+ entries.push(EntryRecord {
+ meta: EntryMeta {
+ kind,
+ attr1,
+ attr2,
+ attr3,
+ name: decode_name(name_bytes),
+ data_offset,
+ data_size,
+ sort_index,
+ },
+ name_raw,
+ });
+ }
+
+ Ok((entries, directory_offset))
+}
+
+fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> {
+ let start = usize::try_from(offset).map_err(|_| Error::IntegerOverflow)?;
+ let len = usize::try_from(size).map_err(|_| Error::IntegerOverflow)?;
+ let end = start.checked_add(len).ok_or(Error::IntegerOverflow)?;
+ if end > bytes_len {
+ return Err(Error::IntegerOverflow);
+ }
+ Ok(start..end)
+}
+
+fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> {
+ let data = bytes
+ .get(offset..offset + 4)
+ .ok_or(Error::IntegerOverflow)?;
+ let arr: [u8; 4] = data.try_into().map_err(|_| Error::IntegerOverflow)?;
+ Ok(u32::from_le_bytes(arr))
+}
+
+fn push_u32(out: &mut Vec<u8>, value: u32) {
+ out.extend_from_slice(&value.to_le_bytes());
+}
+
+fn encode_name_field(name: &str) -> Result<[u8; 36]> {
+ let bytes = name.as_bytes();
+ if bytes.contains(&0) {
+ return Err(Error::NameContainsNul);
+ }
+ if bytes.len() > 35 {
+ return Err(Error::NameTooLong {
+ got: bytes.len(),
+ max: 35,
+ });
+ }
+
+ let mut out = [0u8; 36];
+ out[..bytes.len()].copy_from_slice(bytes);
+ Ok(out)
+}
+
+fn entry_name_bytes(raw: &[u8; 36]) -> &[u8] {
+ let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
+ &raw[..len]
+}
+
+fn decode_name(name: &[u8]) -> String {
+ name.iter().map(|b| char::from(*b)).collect()
+}
+
+fn cmp_name_case_insensitive(a: &[u8], b: &[u8]) -> Ordering {
+ let mut idx = 0usize;
+ let min_len = a.len().min(b.len());
+ while idx < min_len {
+ let left = ascii_lower(a[idx]);
+ let right = ascii_lower(b[idx]);
+ if left != right {
+ return left.cmp(&right);
+ }
+ idx += 1;
+ }
+ a.len().cmp(&b.len())
+}
+
+fn ascii_lower(value: u8) -> u8 {
+ if value.is_ascii_uppercase() {
+ value + 32
+ } else {
+ value
+ }
+}
+
+fn prefetch_pages(bytes: &[u8]) {
+ use std::sync::atomic::{compiler_fence, Ordering};
+
+ let mut cursor = 0usize;
+ let mut sink = 0u8;
+ while cursor < bytes.len() {
+ sink ^= bytes[cursor];
+ cursor = cursor.saturating_add(4096);
+ }
+ compiler_fence(Ordering::SeqCst);
+ let _ = sink;
+}
+
+fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
+ let file_name = path
+ .file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("archive");
+ let parent = path.parent().unwrap_or_else(|| Path::new("."));
+
+ let mut temp_path = None;
+ for attempt in 0..128u32 {
+ let name = format!(
+ ".{}.tmp.{}.{}.{}",
+ file_name,
+ std::process::id(),
+ unix_time_nanos(),
+ attempt
+ );
+ let candidate = parent.join(name);
+ let opened = FsOpenOptions::new()
+ .create_new(true)
+ .write(true)
+ .open(&candidate);
+ if let Ok(mut file) = opened {
+ file.write_all(content)?;
+ file.sync_all()?;
+ temp_path = Some((candidate, file));
+ break;
+ }
+ }
+
+ let Some((tmp_path, mut file)) = temp_path else {
+ return Err(Error::Io(std::io::Error::new(
+ std::io::ErrorKind::AlreadyExists,
+ "failed to create temporary file for atomic write",
+ )));
+ };
+
+ file.flush()?;
+ drop(file);
+
+ if let Err(err) = replace_file_atomically(&tmp_path, path) {
+ let _ = fs::remove_file(&tmp_path);
+ return Err(Error::Io(err));
+ }
+
+ Ok(())
+}
+
+#[cfg(not(windows))]
+fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
+ fs::rename(src, dst)
+}
+
+#[cfg(windows)]
+fn replace_file_atomically(src: &Path, dst: &Path) -> std::io::Result<()> {
+ use std::iter;
+ use std::os::windows::ffi::OsStrExt;
+ use windows_sys::Win32::Storage::FileSystem::{
+ MoveFileExW, MOVEFILE_REPLACE_EXISTING, MOVEFILE_WRITE_THROUGH,
+ };
+
+ let src_wide: Vec<u16> = src.as_os_str().encode_wide().chain(iter::once(0)).collect();
+ let dst_wide: Vec<u16> = dst.as_os_str().encode_wide().chain(iter::once(0)).collect();
+
+ // Replace destination in one OS call, avoiding remove+rename gaps on Windows.
+ let ok = unsafe {
+ MoveFileExW(
+ src_wide.as_ptr(),
+ dst_wide.as_ptr(),
+ MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH,
+ )
+ };
+
+ if ok == 0 {
+ Err(std::io::Error::last_os_error())
+ } else {
+ Ok(())
+ }
+}
+
+fn unix_time_nanos() -> u128 {
+ match SystemTime::now().duration_since(UNIX_EPOCH) {
+ Ok(duration) => duration.as_nanos(),
+ Err(_) => 0,
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/nres/src/tests.rs b/crates/nres/src/tests.rs
new file mode 100644
index 0000000..6de02e5
--- /dev/null
+++ b/crates/nres/src/tests.rs
@@ -0,0 +1,996 @@
+use super::*;
+use std::any::Any;
+use std::fs;
+use std::panic::{catch_unwind, AssertUnwindSafe};
+
+#[derive(Clone)]
+struct SyntheticEntry<'a> {
+ kind: u32,
+ attr1: u32,
+ attr2: u32,
+ attr3: u32,
+ name: &'a str,
+ data: &'a [u8],
+}
+
+fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
+ let Ok(entries) = fs::read_dir(root) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_files_recursive(&path, out);
+ } else if path.is_file() {
+ out.push(path);
+ }
+ }
+}
+
+fn nres_test_files() -> Vec<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("..")
+ .join("..")
+ .join("testdata")
+ .join("nres");
+ let mut files = Vec::new();
+ collect_files_recursive(&root, &mut files);
+ files.sort();
+ files
+ .into_iter()
+ .filter(|path| {
+ fs::read(path)
+ .map(|data| data.get(0..4) == Some(b"NRes"))
+ .unwrap_or(false)
+ })
+ .collect()
+}
+
+fn make_temp_copy(original: &Path, bytes: &[u8]) -> PathBuf {
+ let mut path = std::env::temp_dir();
+ let file_name = original
+ .file_name()
+ .and_then(|v| v.to_str())
+ .unwrap_or("archive");
+ path.push(format!(
+ "nres-test-{}-{}-{}",
+ std::process::id(),
+ unix_time_nanos(),
+ file_name
+ ));
+ fs::write(&path, bytes).expect("failed to create temp file");
+ path
+}
+
+fn panic_message(payload: Box<dyn Any + Send>) -> String {
+ let any = payload.as_ref();
+ if let Some(message) = any.downcast_ref::<String>() {
+ return message.clone();
+ }
+ if let Some(message) = any.downcast_ref::<&str>() {
+ return (*message).to_string();
+ }
+ String::from("panic without message")
+}
+
+fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
+ let slice = bytes
+ .get(offset..offset + 4)
+ .expect("u32 read out of bounds in test");
+ let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test");
+ u32::from_le_bytes(arr)
+}
+
+fn read_i32_le(bytes: &[u8], offset: usize) -> i32 {
+ let slice = bytes
+ .get(offset..offset + 4)
+ .expect("i32 read out of bounds in test");
+ let arr: [u8; 4] = slice.try_into().expect("i32 conversion failed in test");
+ i32::from_le_bytes(arr)
+}
+
+fn name_field_bytes(raw: &[u8; 36]) -> Option<&[u8]> {
+ let nul = raw.iter().position(|value| *value == 0)?;
+ Some(&raw[..nul])
+}
+
+fn build_nres_bytes(entries: &[SyntheticEntry<'_>]) -> Vec<u8> {
+ let mut out = vec![0u8; 16];
+ let mut offsets = Vec::with_capacity(entries.len());
+
+ for entry in entries {
+ offsets.push(u32::try_from(out.len()).expect("offset overflow"));
+ out.extend_from_slice(entry.data);
+ let padding = (8 - (out.len() % 8)) % 8;
+ if padding > 0 {
+ out.resize(out.len() + padding, 0);
+ }
+ }
+
+ let mut sort_order: Vec<usize> = (0..entries.len()).collect();
+ sort_order.sort_by(|a, b| {
+ cmp_name_case_insensitive(entries[*a].name.as_bytes(), entries[*b].name.as_bytes())
+ });
+
+ for (index, entry) in entries.iter().enumerate() {
+ let mut name_raw = [0u8; 36];
+ let name_bytes = entry.name.as_bytes();
+ assert!(name_bytes.len() <= 35, "name too long in fixture");
+ name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
+
+ push_u32(&mut out, entry.kind);
+ push_u32(&mut out, entry.attr1);
+ push_u32(&mut out, entry.attr2);
+ push_u32(
+ &mut out,
+ u32::try_from(entry.data.len()).expect("data size overflow"),
+ );
+ push_u32(&mut out, entry.attr3);
+ out.extend_from_slice(&name_raw);
+ push_u32(&mut out, offsets[index]);
+ push_u32(
+ &mut out,
+ u32::try_from(sort_order[index]).expect("sort index overflow"),
+ );
+ }
+
+ out[0..4].copy_from_slice(b"NRes");
+ out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ out[8..12].copy_from_slice(
+ &u32::try_from(entries.len())
+ .expect("count overflow")
+ .to_le_bytes(),
+ );
+ let total_size = u32::try_from(out.len()).expect("size overflow");
+ out[12..16].copy_from_slice(&total_size.to_le_bytes());
+ out
+}
+
+#[test]
+fn nres_docs_structural_invariants_all_files() {
+ let files = nres_test_files();
+ if files.is_empty() {
+ eprintln!(
+ "skipping nres_docs_structural_invariants_all_files: no NRes archives in testdata/nres"
+ );
+ return;
+ }
+
+ for path in files {
+ let bytes = fs::read(&path).unwrap_or_else(|err| {
+ panic!("failed to read {}: {err}", path.display());
+ });
+
+ assert!(
+ bytes.len() >= 16,
+ "NRes header too short in {}",
+ path.display()
+ );
+ assert_eq!(&bytes[0..4], b"NRes", "bad magic in {}", path.display());
+ assert_eq!(
+ read_u32_le(&bytes, 4),
+ 0x100,
+ "bad version in {}",
+ path.display()
+ );
+ assert_eq!(
+ usize::try_from(read_u32_le(&bytes, 12)).expect("size overflow"),
+ bytes.len(),
+ "header.total_size mismatch in {}",
+ path.display()
+ );
+
+ let entry_count_i32 = read_i32_le(&bytes, 8);
+ assert!(
+ entry_count_i32 >= 0,
+ "negative entry_count={} in {}",
+ entry_count_i32,
+ path.display()
+ );
+ let entry_count = usize::try_from(entry_count_i32).expect("entry_count overflow");
+ let directory_len = entry_count.checked_mul(64).expect("directory_len overflow");
+ let directory_offset = bytes
+ .len()
+ .checked_sub(directory_len)
+ .unwrap_or_else(|| panic!("directory underflow in {}", path.display()));
+ assert!(
+ directory_offset >= 16,
+ "directory offset before data area in {}",
+ path.display()
+ );
+ assert_eq!(
+ directory_offset + directory_len,
+ bytes.len(),
+ "directory not at file end in {}",
+ path.display()
+ );
+
+ let mut sort_indices = Vec::with_capacity(entry_count);
+ let mut entries = Vec::with_capacity(entry_count);
+ for index in 0..entry_count {
+ let base = directory_offset + index * 64;
+ let size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow");
+ let data_offset =
+ usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow");
+ let sort_index =
+ usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort_index overflow");
+
+ let mut name_raw = [0u8; 36];
+ name_raw.copy_from_slice(
+ bytes
+ .get(base + 20..base + 56)
+ .expect("name field out of bounds in test"),
+ );
+ let name_bytes = name_field_bytes(&name_raw).unwrap_or_else(|| {
+ panic!(
+ "name field without NUL terminator in {} entry #{index}",
+ path.display()
+ )
+ });
+ assert!(
+ name_bytes.len() <= 35,
+ "name longer than 35 bytes in {} entry #{index}",
+ path.display()
+ );
+
+ sort_indices.push(sort_index);
+ entries.push((name_bytes.to_vec(), data_offset, size));
+ }
+
+ let mut expected_sort: Vec<usize> = (0..entry_count).collect();
+ expected_sort.sort_by(|a, b| cmp_name_case_insensitive(&entries[*a].0, &entries[*b].0));
+ assert_eq!(
+ sort_indices,
+ expected_sort,
+ "sort_index table mismatch in {}",
+ path.display()
+ );
+
+ let mut data_regions: Vec<(usize, usize)> =
+ entries.iter().map(|(_, off, size)| (*off, *size)).collect();
+ data_regions.sort_by_key(|(off, _)| *off);
+
+ for (idx, (data_offset, size)) in data_regions.iter().enumerate() {
+ assert_eq!(
+ data_offset % 8,
+ 0,
+ "data offset is not 8-byte aligned in {} (region #{idx})",
+ path.display()
+ );
+ assert!(
+ *data_offset >= 16,
+ "data offset before header end in {} (region #{idx})",
+ path.display()
+ );
+ assert!(
+ data_offset.checked_add(*size).unwrap_or(usize::MAX) <= directory_offset,
+ "data region overlaps directory in {} (region #{idx})",
+ path.display()
+ );
+ }
+
+ for pair in data_regions.windows(2) {
+ let (start, size) = pair[0];
+ let (next_start, _) = pair[1];
+ let end = start
+ .checked_add(size)
+ .unwrap_or_else(|| panic!("size overflow in {}", path.display()));
+ assert!(
+ end <= next_start,
+ "overlapping data regions in {}: [{start}, {end}) and next at {next_start}",
+ path.display()
+ );
+
+ for (offset, value) in bytes[end..next_start].iter().enumerate() {
+ assert_eq!(
+ *value,
+ 0,
+ "non-zero alignment padding in {} at offset {}",
+ path.display(),
+ end + offset
+ );
+ }
+ }
+ }
+}
+
+#[test]
+fn nres_read_and_roundtrip_all_files() {
+ let files = nres_test_files();
+ if files.is_empty() {
+ eprintln!("skipping nres_read_and_roundtrip_all_files: no NRes archives in testdata/nres");
+ return;
+ }
+
+ let checked = files.len();
+ let mut success = 0usize;
+ let mut failures = Vec::new();
+
+ for path in files {
+ let display_path = path.display().to_string();
+ let result = catch_unwind(AssertUnwindSafe(|| {
+ let original = fs::read(&path).expect("failed to read archive");
+ let archive = Archive::open_path(&path)
+ .unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display()));
+
+ let count = archive.entry_count();
+ assert_eq!(
+ count,
+ archive.entries().count(),
+ "entry count mismatch: {}",
+ path.display()
+ );
+
+ for idx in 0..count {
+ let id = EntryId(idx as u32);
+ let entry = archive
+ .get(id)
+ .unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display()));
+
+ let payload = archive.read(id).unwrap_or_else(|err| {
+ panic!("read failed for {} entry #{idx}: {err}", path.display())
+ });
+
+ let mut out = Vec::new();
+ let written = archive.read_into(id, &mut out).unwrap_or_else(|err| {
+ panic!(
+ "read_into failed for {} entry #{idx}: {err}",
+ path.display()
+ )
+ });
+ assert_eq!(
+ written,
+ payload.as_slice().len(),
+ "size mismatch in {} entry #{idx}",
+ path.display()
+ );
+ assert_eq!(
+ out.as_slice(),
+ payload.as_slice(),
+ "payload mismatch in {} entry #{idx}",
+ path.display()
+ );
+
+ let raw = archive
+ .raw_slice(id)
+ .unwrap_or_else(|err| {
+ panic!(
+ "raw_slice failed for {} entry #{idx}: {err}",
+ path.display()
+ )
+ })
+ .expect("raw_slice must return Some for file-backed archive");
+ assert_eq!(
+ raw,
+ payload.as_slice(),
+ "raw slice mismatch in {} entry #{idx}",
+ path.display()
+ );
+
+ let found = archive.find(&entry.meta.name).unwrap_or_else(|| {
+ panic!(
+ "find failed for name '{}' in {}",
+ entry.meta.name,
+ path.display()
+ )
+ });
+ let found_meta = archive.get(found).expect("find returned invalid id");
+ assert!(
+ found_meta.meta.name.eq_ignore_ascii_case(&entry.meta.name),
+ "find returned unrelated entry in {}",
+ path.display()
+ );
+ }
+
+ let temp_copy = make_temp_copy(&path, &original);
+ let mut editor = Archive::edit_path(&temp_copy)
+ .unwrap_or_else(|err| panic!("edit_path failed for {}: {err}", path.display()));
+
+ for idx in 0..count {
+ let data = archive
+ .read(EntryId(idx as u32))
+ .unwrap_or_else(|err| {
+ panic!(
+ "read before replace failed for {} entry #{idx}: {err}",
+ path.display()
+ )
+ })
+ .into_owned();
+ editor
+ .replace_data(EntryId(idx as u32), &data)
+ .unwrap_or_else(|err| {
+ panic!(
+ "replace_data failed for {} entry #{idx}: {err}",
+ path.display()
+ )
+ });
+ }
+
+ editor
+ .commit()
+ .unwrap_or_else(|err| panic!("commit failed for {}: {err}", path.display()));
+ let rebuilt = fs::read(&temp_copy).expect("failed to read rebuilt archive");
+ let _ = fs::remove_file(&temp_copy);
+
+ assert_eq!(
+ original,
+ rebuilt,
+ "byte-to-byte roundtrip mismatch for {}",
+ path.display()
+ );
+ }));
+
+ match result {
+ Ok(()) => success += 1,
+ Err(payload) => {
+ failures.push(format!("{}: {}", display_path, panic_message(payload)));
+ }
+ }
+ }
+
+ let failed = failures.len();
+ eprintln!(
+ "NRes summary: checked={}, success={}, failed={}",
+ checked, success, failed
+ );
+ if !failures.is_empty() {
+ panic!(
+ "NRes validation failed.\nsummary: checked={}, success={}, failed={}\n{}",
+ checked,
+ success,
+ failed,
+ failures.join("\n")
+ );
+ }
+}
+
+#[test]
+fn nres_raw_mode_exposes_whole_file() {
+ let files = nres_test_files();
+ let Some(first) = files.first() else {
+ eprintln!("skipping nres_raw_mode_exposes_whole_file: no NRes archives in testdata/nres");
+ return;
+ };
+ let original = fs::read(first).expect("failed to read archive");
+ let arc: Arc<[u8]> = Arc::from(original.clone().into_boxed_slice());
+
+ let archive = Archive::open_bytes(
+ arc,
+ OpenOptions {
+ raw_mode: true,
+ sequential_hint: false,
+ prefetch_pages: false,
+ },
+ )
+ .expect("raw mode open failed");
+
+ assert_eq!(archive.entry_count(), 1);
+ let data = archive.read(EntryId(0)).expect("raw read failed");
+ assert_eq!(data.as_slice(), original.as_slice());
+}
+
+#[test]
+fn nres_raw_mode_accepts_non_nres_bytes() {
+ let payload = b"not-an-nres-archive".to_vec();
+ let bytes: Arc<[u8]> = Arc::from(payload.clone().into_boxed_slice());
+
+ match Archive::open_bytes(bytes.clone(), OpenOptions::default()) {
+ Err(Error::InvalidMagic { .. }) => {}
+ other => panic!("expected InvalidMagic without raw_mode, got {other:?}"),
+ }
+
+ let archive = Archive::open_bytes(
+ bytes,
+ OpenOptions {
+ raw_mode: true,
+ sequential_hint: false,
+ prefetch_pages: false,
+ },
+ )
+ .expect("raw_mode should accept any bytes");
+
+ assert_eq!(archive.entry_count(), 1);
+ assert_eq!(archive.find("raw"), Some(EntryId(0)));
+ assert_eq!(
+ archive
+ .read(EntryId(0))
+ .expect("raw read failed")
+ .as_slice(),
+ payload.as_slice()
+ );
+}
+
+#[test]
+fn nres_open_options_hints_do_not_change_payload() {
+ let payload: Vec<u8> = (0..70_000u32).map(|v| (v % 251) as u8).collect();
+ let src = build_nres_bytes(&[SyntheticEntry {
+ kind: 7,
+ attr1: 70,
+ attr2: 700,
+ attr3: 7000,
+ name: "big.bin",
+ data: &payload,
+ }]);
+ let arc: Arc<[u8]> = Arc::from(src.into_boxed_slice());
+
+ let baseline = Archive::open_bytes(arc.clone(), OpenOptions::default())
+ .expect("baseline open should succeed");
+ let hinted = Archive::open_bytes(
+ arc,
+ OpenOptions {
+ raw_mode: false,
+ sequential_hint: true,
+ prefetch_pages: true,
+ },
+ )
+ .expect("open with hints should succeed");
+
+ assert_eq!(baseline.entry_count(), 1);
+ assert_eq!(hinted.entry_count(), 1);
+ assert_eq!(baseline.find("BIG.BIN"), Some(EntryId(0)));
+ assert_eq!(hinted.find("big.bin"), Some(EntryId(0)));
+ assert_eq!(
+ baseline
+ .read(EntryId(0))
+ .expect("baseline read failed")
+ .as_slice(),
+ hinted
+ .read(EntryId(0))
+ .expect("hinted read failed")
+ .as_slice()
+ );
+}
+
+#[test]
+fn nres_commit_empty_archive_has_minimal_layout() {
+ let mut path = std::env::temp_dir();
+ path.push(format!(
+ "nres-empty-commit-{}-{}.lib",
+ std::process::id(),
+ unix_time_nanos()
+ ));
+ fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed");
+
+ Archive::edit_path(&path)
+ .expect("edit_path failed for empty archive")
+ .commit()
+ .expect("commit failed for empty archive");
+
+ let bytes = fs::read(&path).expect("failed to read committed archive");
+ assert_eq!(bytes.len(), 16, "empty archive must contain only header");
+ assert_eq!(&bytes[0..4], b"NRes");
+ assert_eq!(read_u32_le(&bytes, 4), 0x100);
+ assert_eq!(read_u32_le(&bytes, 8), 0);
+ assert_eq!(read_u32_le(&bytes, 12), 16);
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn nres_commit_recomputes_header_directory_and_sort_table() {
+ let mut path = std::env::temp_dir();
+ path.push(format!(
+ "nres-commit-layout-{}-{}.lib",
+ std::process::id(),
+ unix_time_nanos()
+ ));
+ fs::write(&path, build_nres_bytes(&[])).expect("write empty archive failed");
+
+ let mut editor = Archive::edit_path(&path).expect("edit_path failed");
+ editor
+ .add(NewEntry {
+ kind: 10,
+ attr1: 1,
+ attr2: 2,
+ attr3: 3,
+ name: "Zulu",
+ data: b"aaaaa",
+ })
+ .expect("add #0 failed");
+ editor
+ .add(NewEntry {
+ kind: 11,
+ attr1: 4,
+ attr2: 5,
+ attr3: 6,
+ name: "alpha",
+ data: b"bbbbbbbb",
+ })
+ .expect("add #1 failed");
+ editor
+ .add(NewEntry {
+ kind: 12,
+ attr1: 7,
+ attr2: 8,
+ attr3: 9,
+ name: "Beta",
+ data: b"cccc",
+ })
+ .expect("add #2 failed");
+ editor.commit().expect("commit failed");
+
+ let bytes = fs::read(&path).expect("failed to read committed archive");
+ assert_eq!(&bytes[0..4], b"NRes");
+ assert_eq!(read_u32_le(&bytes, 4), 0x100);
+
+ let entry_count = usize::try_from(read_u32_le(&bytes, 8)).expect("entry_count overflow");
+ let total_size = usize::try_from(read_u32_le(&bytes, 12)).expect("total_size overflow");
+ assert_eq!(entry_count, 3);
+ assert_eq!(total_size, bytes.len());
+
+ let directory_offset = total_size
+ .checked_sub(entry_count * 64)
+ .expect("invalid directory offset");
+ assert!(directory_offset >= 16);
+
+ let mut sort_indices = Vec::new();
+ let mut prev_data_end = 16usize;
+ for idx in 0..entry_count {
+ let base = directory_offset + idx * 64;
+ let data_size = usize::try_from(read_u32_le(&bytes, base + 12)).expect("size overflow");
+ let data_offset = usize::try_from(read_u32_le(&bytes, base + 56)).expect("offset overflow");
+ let sort_index =
+ usize::try_from(read_u32_le(&bytes, base + 60)).expect("sort index overflow");
+
+ assert_eq!(
+ data_offset % 8,
+ 0,
+ "entry #{idx} data offset must be 8-byte aligned"
+ );
+ assert!(
+ data_offset >= prev_data_end,
+ "entry #{idx} offset regressed"
+ );
+ assert!(
+ data_offset + data_size <= directory_offset,
+ "entry #{idx} overlaps directory"
+ );
+ prev_data_end = data_offset + data_size;
+ sort_indices.push(sort_index);
+ }
+
+ let names = ["Zulu", "alpha", "Beta"];
+ let mut expected_sort: Vec<usize> = (0..names.len()).collect();
+ expected_sort
+ .sort_by(|a, b| cmp_name_case_insensitive(names[*a].as_bytes(), names[*b].as_bytes()));
+ assert_eq!(
+ sort_indices, expected_sort,
+ "sort table must contain original indexes in case-insensitive alphabetical order"
+ );
+
+ let archive = Archive::open_path(&path).expect("re-open failed");
+ assert_eq!(archive.find("zulu"), Some(EntryId(0)));
+ assert_eq!(archive.find("ALPHA"), Some(EntryId(1)));
+ assert_eq!(archive.find("beta"), Some(EntryId(2)));
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn nres_synthetic_read_find_and_edit() {
+ let payload_a = b"alpha";
+ let payload_b = b"B";
+ let payload_c = b"";
+ let src = build_nres_bytes(&[
+ SyntheticEntry {
+ kind: 1,
+ attr1: 10,
+ attr2: 20,
+ attr3: 30,
+ name: "Alpha.TXT",
+ data: payload_a,
+ },
+ SyntheticEntry {
+ kind: 2,
+ attr1: 11,
+ attr2: 21,
+ attr3: 31,
+ name: "beta.bin",
+ data: payload_b,
+ },
+ SyntheticEntry {
+ kind: 3,
+ attr1: 12,
+ attr2: 22,
+ attr3: 32,
+ name: "Gamma",
+ data: payload_c,
+ },
+ ]);
+
+ let archive = Archive::open_bytes(
+ Arc::from(src.clone().into_boxed_slice()),
+ OpenOptions::default(),
+ )
+ .expect("open synthetic nres failed");
+
+ assert_eq!(archive.entry_count(), 3);
+ assert_eq!(archive.find("alpha.txt"), Some(EntryId(0)));
+ assert_eq!(archive.find("BETA.BIN"), Some(EntryId(1)));
+ assert_eq!(archive.find("gAmMa"), Some(EntryId(2)));
+ assert_eq!(archive.find("missing"), None);
+
+ assert_eq!(
+ archive.read(EntryId(0)).expect("read #0 failed").as_slice(),
+ payload_a
+ );
+ assert_eq!(
+ archive.read(EntryId(1)).expect("read #1 failed").as_slice(),
+ payload_b
+ );
+ assert_eq!(
+ archive.read(EntryId(2)).expect("read #2 failed").as_slice(),
+ payload_c
+ );
+
+ let mut path = std::env::temp_dir();
+ path.push(format!(
+ "nres-synth-edit-{}-{}.lib",
+ std::process::id(),
+ unix_time_nanos()
+ ));
+ fs::write(&path, &src).expect("write temp synthetic archive failed");
+
+ let mut editor = Archive::edit_path(&path).expect("edit_path on synthetic archive failed");
+ editor
+ .replace_data(EntryId(1), b"replaced")
+ .expect("replace_data failed");
+ let added = editor
+ .add(NewEntry {
+ kind: 4,
+ attr1: 13,
+ attr2: 23,
+ attr3: 33,
+ name: "delta",
+ data: b"new payload",
+ })
+ .expect("add failed");
+ assert_eq!(added, EntryId(3));
+ editor.remove(EntryId(2)).expect("remove failed");
+ editor.commit().expect("commit failed");
+
+ let edited = Archive::open_path(&path).expect("re-open edited archive failed");
+ assert_eq!(edited.entry_count(), 3);
+ assert_eq!(
+ edited
+ .read(edited.find("beta.bin").expect("find beta.bin failed"))
+ .expect("read beta.bin failed")
+ .as_slice(),
+ b"replaced"
+ );
+ assert_eq!(
+ edited
+ .read(edited.find("delta").expect("find delta failed"))
+ .expect("read delta failed")
+ .as_slice(),
+ b"new payload"
+ );
+ assert_eq!(edited.find("gamma"), None);
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn nres_max_name_length_roundtrip() {
+ let max_name = "12345678901234567890123456789012345";
+ assert_eq!(max_name.len(), 35);
+
+ let src = build_nres_bytes(&[SyntheticEntry {
+ kind: 9,
+ attr1: 1,
+ attr2: 2,
+ attr3: 3,
+ name: max_name,
+ data: b"payload",
+ }]);
+
+ let archive = Archive::open_bytes(Arc::from(src.into_boxed_slice()), OpenOptions::default())
+ .expect("open synthetic nres failed");
+
+ assert_eq!(archive.entry_count(), 1);
+ assert_eq!(archive.find(max_name), Some(EntryId(0)));
+ assert_eq!(
+ archive.find(&max_name.to_ascii_lowercase()),
+ Some(EntryId(0))
+ );
+
+ let entry = archive.get(EntryId(0)).expect("missing entry 0");
+ assert_eq!(entry.meta.name, max_name);
+ assert_eq!(
+ archive
+ .read(EntryId(0))
+ .expect("read payload failed")
+ .as_slice(),
+ b"payload"
+ );
+}
+
+#[test]
+fn nres_find_falls_back_when_sort_index_is_out_of_range() {
+ let mut bytes = build_nres_bytes(&[
+ SyntheticEntry {
+ kind: 1,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "Alpha",
+ data: b"a",
+ },
+ SyntheticEntry {
+ kind: 2,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "Beta",
+ data: b"b",
+ },
+ SyntheticEntry {
+ kind: 3,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "Gamma",
+ data: b"c",
+ },
+ ]);
+
+ let entry_count = 3usize;
+ let directory_offset = bytes
+ .len()
+ .checked_sub(entry_count * 64)
+ .expect("directory offset underflow");
+ let mid_entry_sort_index = directory_offset + 64 + 60;
+ bytes[mid_entry_sort_index..mid_entry_sort_index + 4].copy_from_slice(&u32::MAX.to_le_bytes());
+
+ let archive = Archive::open_bytes(Arc::from(bytes.into_boxed_slice()), OpenOptions::default())
+ .expect("open archive with corrupted sort index failed");
+
+ assert_eq!(archive.find("alpha"), Some(EntryId(0)));
+ assert_eq!(archive.find("BETA"), Some(EntryId(1)));
+ assert_eq!(archive.find("gamma"), Some(EntryId(2)));
+ assert_eq!(archive.find("missing"), None);
+}
+
+#[test]
+fn nres_validation_error_cases() {
+ let valid = build_nres_bytes(&[SyntheticEntry {
+ kind: 1,
+ attr1: 2,
+ attr2: 3,
+ attr3: 4,
+ name: "ok",
+ data: b"1234",
+ }]);
+
+ let mut invalid_magic = valid.clone();
+ invalid_magic[0..4].copy_from_slice(b"FAIL");
+ match Archive::open_bytes(
+ Arc::from(invalid_magic.into_boxed_slice()),
+ OpenOptions::default(),
+ ) {
+ Err(Error::InvalidMagic { .. }) => {}
+ other => panic!("expected InvalidMagic, got {other:?}"),
+ }
+
+ let mut invalid_version = valid.clone();
+ invalid_version[4..8].copy_from_slice(&0x200_u32.to_le_bytes());
+ match Archive::open_bytes(
+ Arc::from(invalid_version.into_boxed_slice()),
+ OpenOptions::default(),
+ ) {
+ Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 0x200),
+ other => panic!("expected UnsupportedVersion, got {other:?}"),
+ }
+
+ let mut bad_total = valid.clone();
+ bad_total[12..16].copy_from_slice(&0_u32.to_le_bytes());
+ match Archive::open_bytes(
+ Arc::from(bad_total.into_boxed_slice()),
+ OpenOptions::default(),
+ ) {
+ Err(Error::TotalSizeMismatch { .. }) => {}
+ other => panic!("expected TotalSizeMismatch, got {other:?}"),
+ }
+
+ let mut bad_count = valid.clone();
+ bad_count[8..12].copy_from_slice(&(-1_i32).to_le_bytes());
+ match Archive::open_bytes(
+ Arc::from(bad_count.into_boxed_slice()),
+ OpenOptions::default(),
+ ) {
+ Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1),
+ other => panic!("expected InvalidEntryCount, got {other:?}"),
+ }
+
+ let mut bad_dir = valid.clone();
+ bad_dir[8..12].copy_from_slice(&1000_u32.to_le_bytes());
+ match Archive::open_bytes(
+ Arc::from(bad_dir.into_boxed_slice()),
+ OpenOptions::default(),
+ ) {
+ Err(Error::DirectoryOutOfBounds { .. }) => {}
+ other => panic!("expected DirectoryOutOfBounds, got {other:?}"),
+ }
+
+ let mut long_name = valid.clone();
+ let entry_base = long_name.len() - 64;
+ for b in &mut long_name[entry_base + 20..entry_base + 56] {
+ *b = b'X';
+ }
+ match Archive::open_bytes(
+ Arc::from(long_name.into_boxed_slice()),
+ OpenOptions::default(),
+ ) {
+ Err(Error::NameTooLong { .. }) => {}
+ other => panic!("expected NameTooLong, got {other:?}"),
+ }
+
+ let mut bad_data = valid.clone();
+ bad_data[entry_base + 56..entry_base + 60].copy_from_slice(&12_u32.to_le_bytes());
+ bad_data[entry_base + 12..entry_base + 16].copy_from_slice(&32_u32.to_le_bytes());
+ match Archive::open_bytes(
+ Arc::from(bad_data.into_boxed_slice()),
+ OpenOptions::default(),
+ ) {
+ Err(Error::EntryDataOutOfBounds { .. }) => {}
+ other => panic!("expected EntryDataOutOfBounds, got {other:?}"),
+ }
+
+ let archive = Archive::open_bytes(Arc::from(valid.into_boxed_slice()), OpenOptions::default())
+ .expect("open valid archive failed");
+ match archive.read(EntryId(99)) {
+ Err(Error::EntryIdOutOfRange { .. }) => {}
+ other => panic!("expected EntryIdOutOfRange, got {other:?}"),
+ }
+}
+
+#[test]
+fn nres_editor_validation_error_cases() {
+ let mut path = std::env::temp_dir();
+ path.push(format!(
+ "nres-editor-errors-{}-{}.lib",
+ std::process::id(),
+ unix_time_nanos()
+ ));
+ let src = build_nres_bytes(&[]);
+ fs::write(&path, src).expect("write empty archive failed");
+
+ let mut editor = Archive::edit_path(&path).expect("edit_path failed");
+
+ let long_name = "X".repeat(36);
+ match editor.add(NewEntry {
+ kind: 0,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: &long_name,
+ data: b"",
+ }) {
+ Err(Error::NameTooLong { .. }) => {}
+ other => panic!("expected NameTooLong, got {other:?}"),
+ }
+
+ match editor.add(NewEntry {
+ kind: 0,
+ attr1: 0,
+ attr2: 0,
+ attr3: 0,
+ name: "bad\0name",
+ data: b"",
+ }) {
+ Err(Error::NameContainsNul) => {}
+ other => panic!("expected NameContainsNul, got {other:?}"),
+ }
+
+ match editor.replace_data(EntryId(0), b"x") {
+ Err(Error::EntryIdOutOfRange { .. }) => {}
+ other => panic!("expected EntryIdOutOfRange, got {other:?}"),
+ }
+
+ match editor.remove(EntryId(0)) {
+ Err(Error::EntryIdOutOfRange { .. }) => {}
+ other => panic!("expected EntryIdOutOfRange, got {other:?}"),
+ }
+
+ let _ = fs::remove_file(&path);
+}
diff --git a/crates/rsli/Cargo.toml b/crates/rsli/Cargo.toml
new file mode 100644
index 0000000..faad224
--- /dev/null
+++ b/crates/rsli/Cargo.toml
@@ -0,0 +1,8 @@
+[package]
+name = "rsli"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+common = { path = "../common" }
+flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
diff --git a/crates/rsli/README.md b/crates/rsli/README.md
new file mode 100644
index 0000000..27816d6
--- /dev/null
+++ b/crates/rsli/README.md
@@ -0,0 +1,58 @@
+# rsli
+
+Rust-библиотека для чтения архивов формата **RsLi**.
+
+## Что умеет
+
+- Открытие библиотеки из файла (`open_path`, `open_path_with`).
+- Дешифрование таблицы записей (XOR stream cipher).
+- Поддержка AO-трейлера и media overlay (`allow_ao_trailer`).
+- Поддержка quirk для Deflate `EOF+1` (`allow_deflate_eof_plus_one`).
+- Поиск по имени (`find`, c приведением запроса к uppercase).
+- Загрузка данных:
+- `load`, `load_into`, `load_packed`, `unpack`, `load_fast`.
+
+## Поддерживаемые методы упаковки
+
+- `0x000` None
+- `0x020` XorOnly
+- `0x040` Lzss
+- `0x060` XorLzss
+- `0x080` LzssHuffman
+- `0x0A0` XorLzssHuffman
+- `0x100` Deflate
+
+## Модель ошибок
+
+Типизированные ошибки без паник в production-коде (`InvalidMagic`, `UnsupportedVersion`, `EntryTableOutOfBounds`, `PackedSizePastEof`, `DeflateEofPlusOneQuirkRejected`, `UnsupportedMethod`, и др.).
+
+## Покрытие тестами
+
+### Реальные файлы
+
+- Рекурсивный прогон по `testdata/rsli/**`.
+- Сейчас в наборе: **2 архива**.
+- На реальных данных подтверждены и проходят byte-to-byte проверки методы:
+- `0x040` (LZSS)
+- `0x100` (Deflate)
+- Для каждого архива проверяется:
+- `load`/`load_into`/`load_packed`/`unpack`/`load_fast`;
+- `find`;
+- пересборка и сравнение **byte-to-byte**.
+
+### Синтетические тесты
+
+Из-за отсутствия реальных файлов для части методов добавлены синтетические архивы и тесты:
+
+- Методы:
+- `0x000`, `0x020`, `0x060`, `0x080`, `0x0A0`.
+- Спецкейсы формата:
+ - AO trailer + overlay;
+ - Deflate `EOF+1` (оба режима: accepted/rejected);
+- некорректные заголовки/таблицы/смещения/методы.
+
+## Быстрый запуск тестов
+
+```bash
+cargo test -p rsli -- --nocapture
+```
diff --git a/crates/rsli/src/compress/deflate.rs b/crates/rsli/src/compress/deflate.rs
new file mode 100644
index 0000000..6b8ea73
--- /dev/null
+++ b/crates/rsli/src/compress/deflate.rs
@@ -0,0 +1,14 @@
+use crate::error::Error;
+use crate::Result;
+use flate2::read::DeflateDecoder;
+use std::io::Read;
+
+/// Decode raw Deflate (RFC 1951) payload.
+pub fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>> {
+ let mut out = Vec::new();
+ let mut decoder = DeflateDecoder::new(packed);
+ decoder
+ .read_to_end(&mut out)
+ .map_err(|_| Error::DecompressionFailed("deflate"))?;
+ Ok(out)
+}
diff --git a/crates/rsli/src/compress/lzh.rs b/crates/rsli/src/compress/lzh.rs
new file mode 100644
index 0000000..07dc0c5
--- /dev/null
+++ b/crates/rsli/src/compress/lzh.rs
@@ -0,0 +1,298 @@
+use super::xor::XorState;
+use crate::error::Error;
+use crate::Result;
+
+pub(crate) const LZH_N: usize = 4096;
+pub(crate) const LZH_F: usize = 60;
+pub(crate) const LZH_THRESHOLD: usize = 2;
+pub(crate) const LZH_N_CHAR: usize = 256 - LZH_THRESHOLD + LZH_F;
+pub(crate) const LZH_T: usize = LZH_N_CHAR * 2 - 1;
+pub(crate) const LZH_R: usize = LZH_T - 1;
+pub(crate) const LZH_MAX_FREQ: u16 = 0x8000;
+
+/// LZSS-Huffman decompression with optional on-the-fly XOR decryption.
+pub fn lzss_huffman_decompress(
+ data: &[u8],
+ expected_size: usize,
+ xor_key: Option<u16>,
+) -> Result<Vec<u8>> {
+ let mut decoder = LzhDecoder::new(data, xor_key);
+ decoder.decode(expected_size)
+}
+
+struct LzhDecoder<'a> {
+ bit_reader: BitReader<'a>,
+ text: [u8; LZH_N],
+ freq: [u16; LZH_T + 1],
+ parent: [usize; LZH_T + LZH_N_CHAR],
+ son: [usize; LZH_T],
+ d_code: [u8; 256],
+ d_len: [u8; 256],
+ ring_pos: usize,
+}
+
+impl<'a> LzhDecoder<'a> {
+ fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
+ let mut decoder = Self {
+ bit_reader: BitReader::new(data, xor_key),
+ text: [0x20u8; LZH_N],
+ freq: [0u16; LZH_T + 1],
+ parent: [0usize; LZH_T + LZH_N_CHAR],
+ son: [0usize; LZH_T],
+ d_code: [0u8; 256],
+ d_len: [0u8; 256],
+ ring_pos: LZH_N - LZH_F,
+ };
+ decoder.init_tables();
+ decoder.start_huff();
+ decoder
+ }
+
+ fn decode(&mut self, expected_size: usize) -> Result<Vec<u8>> {
+ let mut out = Vec::with_capacity(expected_size);
+
+ while out.len() < expected_size {
+ let c = self.decode_char()?;
+ if c < 256 {
+ let byte = c as u8;
+ out.push(byte);
+ self.text[self.ring_pos] = byte;
+ self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
+ } else {
+ let mut offset = self.decode_position()?;
+ offset = (self.ring_pos.wrapping_sub(offset).wrapping_sub(1)) & (LZH_N - 1);
+ let mut length = c.saturating_sub(253);
+
+ while length > 0 && out.len() < expected_size {
+ let byte = self.text[offset];
+ out.push(byte);
+ self.text[self.ring_pos] = byte;
+ self.ring_pos = (self.ring_pos + 1) & (LZH_N - 1);
+ offset = (offset + 1) & (LZH_N - 1);
+ length -= 1;
+ }
+ }
+ }
+
+ if out.len() != expected_size {
+ return Err(Error::DecompressionFailed("lzss-huffman"));
+ }
+ Ok(out)
+ }
+
+ fn init_tables(&mut self) {
+ let d_code_group_counts = [1usize, 3, 8, 12, 24, 16];
+ let d_len_group_counts = [32usize, 48, 64, 48, 48, 16];
+
+ let mut group_index = 0u8;
+ let mut idx = 0usize;
+ let mut run = 32usize;
+ for count in d_code_group_counts {
+ for _ in 0..count {
+ for _ in 0..run {
+ self.d_code[idx] = group_index;
+ idx += 1;
+ }
+ group_index = group_index.wrapping_add(1);
+ }
+ run >>= 1;
+ }
+
+ let mut len = 3u8;
+ idx = 0;
+ for count in d_len_group_counts {
+ for _ in 0..count {
+ self.d_len[idx] = len;
+ idx += 1;
+ }
+ len = len.saturating_add(1);
+ }
+ }
+
+ fn start_huff(&mut self) {
+ for i in 0..LZH_N_CHAR {
+ self.freq[i] = 1;
+ self.son[i] = i + LZH_T;
+ self.parent[i + LZH_T] = i;
+ }
+
+ let mut i = 0usize;
+ let mut j = LZH_N_CHAR;
+ while j <= LZH_R {
+ self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]);
+ self.son[j] = i;
+ self.parent[i] = j;
+ self.parent[i + 1] = j;
+ i += 2;
+ j += 1;
+ }
+
+ self.freq[LZH_T] = u16::MAX;
+ self.parent[LZH_R] = 0;
+ }
+
+ fn decode_char(&mut self) -> Result<usize> {
+ let mut node = self.son[LZH_R];
+ while node < LZH_T {
+ let bit = usize::from(self.bit_reader.read_bit()?);
+ node = self.son[node + bit];
+ }
+
+ let c = node - LZH_T;
+ self.update(c);
+ Ok(c)
+ }
+
+ fn decode_position(&mut self) -> Result<usize> {
+ let i = self.bit_reader.read_bits(8)? as usize;
+ let mut c = usize::from(self.d_code[i]) << 6;
+ let mut j = usize::from(self.d_len[i]).saturating_sub(2);
+
+ while j > 0 {
+ j -= 1;
+ c |= usize::from(self.bit_reader.read_bit()?) << j;
+ }
+
+ Ok(c | (i & 0x3F))
+ }
+
+ fn update(&mut self, c: usize) {
+ if self.freq[LZH_R] == LZH_MAX_FREQ {
+ self.reconstruct();
+ }
+
+ let mut current = self.parent[c + LZH_T];
+ loop {
+ self.freq[current] = self.freq[current].saturating_add(1);
+ let freq = self.freq[current];
+
+ if current + 1 < self.freq.len() && freq > self.freq[current + 1] {
+ let mut swap_idx = current + 1;
+ while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] {
+ swap_idx += 1;
+ }
+
+ self.freq.swap(current, swap_idx);
+
+ let left = self.son[current];
+ let right = self.son[swap_idx];
+ self.son[current] = right;
+ self.son[swap_idx] = left;
+
+ self.parent[left] = swap_idx;
+ if left < LZH_T {
+ self.parent[left + 1] = swap_idx;
+ }
+
+ self.parent[right] = current;
+ if right < LZH_T {
+ self.parent[right + 1] = current;
+ }
+
+ current = swap_idx;
+ }
+
+ current = self.parent[current];
+ if current == 0 {
+ break;
+ }
+ }
+ }
+
+ fn reconstruct(&mut self) {
+ let mut j = 0usize;
+ for i in 0..LZH_T {
+ if self.son[i] >= LZH_T {
+ self.freq[j] = (self.freq[i].saturating_add(1)) / 2;
+ self.son[j] = self.son[i];
+ j += 1;
+ }
+ }
+
+ let mut i = 0usize;
+ let mut current = LZH_N_CHAR;
+ while current < LZH_T {
+ let sum = self.freq[i].saturating_add(self.freq[i + 1]);
+ self.freq[current] = sum;
+
+ let mut insert_at = current;
+ while insert_at > 0 && sum < self.freq[insert_at - 1] {
+ insert_at -= 1;
+ }
+
+ for move_idx in (insert_at..current).rev() {
+ self.freq[move_idx + 1] = self.freq[move_idx];
+ self.son[move_idx + 1] = self.son[move_idx];
+ }
+
+ self.freq[insert_at] = sum;
+ self.son[insert_at] = i;
+
+ i += 2;
+ current += 1;
+ }
+
+ for idx in 0..LZH_T {
+ let node = self.son[idx];
+ self.parent[node] = idx;
+ if node < LZH_T {
+ self.parent[node + 1] = idx;
+ }
+ }
+
+ self.freq[LZH_T] = u16::MAX;
+ self.parent[LZH_R] = 0;
+ }
+}
+
+struct BitReader<'a> {
+ data: &'a [u8],
+ byte_pos: usize,
+ bit_mask: u8,
+ current_byte: u8,
+ xor_state: Option<XorState>,
+}
+
+impl<'a> BitReader<'a> {
+ fn new(data: &'a [u8], xor_key: Option<u16>) -> Self {
+ Self {
+ data,
+ byte_pos: 0,
+ bit_mask: 0x80,
+ current_byte: 0,
+ xor_state: xor_key.map(XorState::new),
+ }
+ }
+
+ fn read_bit(&mut self) -> Result<u8> {
+ if self.bit_mask == 0x80 {
+ let Some(mut byte) = self.data.get(self.byte_pos).copied() else {
+ return Err(Error::DecompressionFailed("lzss-huffman: unexpected EOF"));
+ };
+ if let Some(state) = &mut self.xor_state {
+ byte = state.decrypt_byte(byte);
+ }
+ self.current_byte = byte;
+ }
+
+ let bit = if (self.current_byte & self.bit_mask) != 0 {
+ 1
+ } else {
+ 0
+ };
+ self.bit_mask >>= 1;
+ if self.bit_mask == 0 {
+ self.bit_mask = 0x80;
+ self.byte_pos = self.byte_pos.saturating_add(1);
+ }
+ Ok(bit)
+ }
+
+ fn read_bits(&mut self, bits: usize) -> Result<u32> {
+ let mut value = 0u32;
+ for _ in 0..bits {
+ value = (value << 1) | u32::from(self.read_bit()?);
+ }
+ Ok(value)
+ }
+}
diff --git a/crates/rsli/src/compress/lzss.rs b/crates/rsli/src/compress/lzss.rs
new file mode 100644
index 0000000..d30345c
--- /dev/null
+++ b/crates/rsli/src/compress/lzss.rs
@@ -0,0 +1,79 @@
+use super::xor::XorState;
+use crate::error::Error;
+use crate::Result;
+
+/// Simple LZSS decompression with optional on-the-fly XOR decryption
+pub fn lzss_decompress_simple(
+ data: &[u8],
+ expected_size: usize,
+ xor_key: Option<u16>,
+) -> Result<Vec<u8>> {
+ let mut ring = [0x20u8; 0x1000];
+ let mut ring_pos = 0xFEEusize;
+ let mut out = Vec::with_capacity(expected_size);
+ let mut in_pos = 0usize;
+
+ let mut control = 0u8;
+ let mut bits_left = 0u8;
+
+ // XOR state for on-the-fly decryption
+ let mut xor_state = xor_key.map(XorState::new);
+
+ // Helper to read byte with optional XOR decryption
+ let read_byte = |pos: usize, state: &mut Option<XorState>| -> Option<u8> {
+ let encrypted = data.get(pos).copied()?;
+ Some(if let Some(ref mut s) = state {
+ s.decrypt_byte(encrypted)
+ } else {
+ encrypted
+ })
+ };
+
+ while out.len() < expected_size {
+ if bits_left == 0 {
+ let byte = read_byte(in_pos, &mut xor_state)
+ .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
+ control = byte;
+ in_pos += 1;
+ bits_left = 8;
+ }
+
+ if (control & 1) != 0 {
+ let byte = read_byte(in_pos, &mut xor_state)
+ .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
+ in_pos += 1;
+
+ out.push(byte);
+ ring[ring_pos] = byte;
+ ring_pos = (ring_pos + 1) & 0x0FFF;
+ } else {
+ let low = read_byte(in_pos, &mut xor_state)
+ .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
+ let high = read_byte(in_pos + 1, &mut xor_state)
+ .ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
+ in_pos += 2;
+
+ let offset = usize::from(low) | (usize::from(high & 0xF0) << 4);
+ let length = usize::from((high & 0x0F) + 3);
+
+ for step in 0..length {
+ let byte = ring[(offset + step) & 0x0FFF];
+ out.push(byte);
+ ring[ring_pos] = byte;
+ ring_pos = (ring_pos + 1) & 0x0FFF;
+ if out.len() >= expected_size {
+ break;
+ }
+ }
+ }
+
+ control >>= 1;
+ bits_left -= 1;
+ }
+
+ if out.len() != expected_size {
+ return Err(Error::DecompressionFailed("lzss-simple"));
+ }
+
+ Ok(out)
+}
diff --git a/crates/rsli/src/compress/mod.rs b/crates/rsli/src/compress/mod.rs
new file mode 100644
index 0000000..bd23143
--- /dev/null
+++ b/crates/rsli/src/compress/mod.rs
@@ -0,0 +1,9 @@
+pub mod deflate;
+pub mod lzh;
+pub mod lzss;
+pub mod xor;
+
+pub use deflate::decode_deflate;
+pub use lzh::lzss_huffman_decompress;
+pub use lzss::lzss_decompress_simple;
+pub use xor::{xor_stream, XorState};
diff --git a/crates/rsli/src/compress/xor.rs b/crates/rsli/src/compress/xor.rs
new file mode 100644
index 0000000..c4c3d7d
--- /dev/null
+++ b/crates/rsli/src/compress/xor.rs
@@ -0,0 +1,29 @@
+/// XOR cipher state for RsLi format
+pub struct XorState {
+ lo: u8,
+ hi: u8,
+}
+
+impl XorState {
+ /// Create new XOR state from 16-bit key
+ pub fn new(key16: u16) -> Self {
+ Self {
+ lo: (key16 & 0xFF) as u8,
+ hi: ((key16 >> 8) & 0xFF) as u8,
+ }
+ }
+
+ /// Decrypt a single byte and update state
+ pub fn decrypt_byte(&mut self, encrypted: u8) -> u8 {
+ self.lo = self.hi ^ self.lo.wrapping_shl(1);
+ let decrypted = encrypted ^ self.lo;
+ self.hi = self.lo ^ (self.hi >> 1);
+ decrypted
+ }
+}
+
+/// Decrypt entire buffer with XOR stream cipher
+pub fn xor_stream(data: &[u8], key16: u16) -> Vec<u8> {
+ let mut state = XorState::new(key16);
+ data.iter().map(|&b| state.decrypt_byte(b)).collect()
+}
diff --git a/crates/rsli/src/error.rs b/crates/rsli/src/error.rs
new file mode 100644
index 0000000..5a36101
--- /dev/null
+++ b/crates/rsli/src/error.rs
@@ -0,0 +1,140 @@
+use core::fmt;
+
+#[derive(Debug)]
+#[non_exhaustive]
+pub enum Error {
+ Io(std::io::Error),
+
+ InvalidMagic {
+ got: [u8; 2],
+ },
+ UnsupportedVersion {
+ got: u8,
+ },
+ InvalidEntryCount {
+ got: i16,
+ },
+ TooManyEntries {
+ got: usize,
+ },
+
+ EntryTableOutOfBounds {
+ table_offset: u64,
+ table_len: u64,
+ file_len: u64,
+ },
+ EntryTableDecryptFailed,
+ CorruptEntryTable(&'static str),
+
+ EntryIdOutOfRange {
+ id: u32,
+ entry_count: u32,
+ },
+ EntryDataOutOfBounds {
+ id: u32,
+ offset: u64,
+ size: u32,
+ file_len: u64,
+ },
+
+ AoTrailerInvalid,
+ MediaOverlayOutOfBounds {
+ overlay: u32,
+ file_len: u64,
+ },
+
+ UnsupportedMethod {
+ raw: u32,
+ },
+ PackedSizePastEof {
+ id: u32,
+ offset: u64,
+ packed_size: u32,
+ file_len: u64,
+ },
+ DeflateEofPlusOneQuirkRejected {
+ id: u32,
+ },
+
+ DecompressionFailed(&'static str),
+ OutputSizeMismatch {
+ expected: u32,
+ got: u32,
+ },
+
+ IntegerOverflow,
+}
+
+impl From<std::io::Error> for Error {
+ fn from(value: std::io::Error) -> Self {
+ Self::Io(value)
+ }
+}
+
+impl fmt::Display for Error {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Error::Io(e) => write!(f, "I/O error: {e}"),
+ Error::InvalidMagic { got } => write!(f, "invalid RsLi magic: {got:02X?}"),
+ Error::UnsupportedVersion { got } => write!(f, "unsupported RsLi version: {got:#x}"),
+ Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
+ Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
+ Error::EntryTableOutOfBounds {
+ table_offset,
+ table_len,
+ file_len,
+ } => write!(
+ f,
+ "entry table out of bounds: off={table_offset}, len={table_len}, file={file_len}"
+ ),
+ Error::EntryTableDecryptFailed => write!(f, "failed to decrypt entry table"),
+ Error::CorruptEntryTable(s) => write!(f, "corrupt entry table: {s}"),
+ Error::EntryIdOutOfRange { id, entry_count } => {
+ write!(f, "entry id out of range: id={id}, count={entry_count}")
+ }
+ Error::EntryDataOutOfBounds {
+ id,
+ offset,
+ size,
+ file_len,
+ } => write!(
+ f,
+ "entry data out of bounds: id={id}, off={offset}, size={size}, file={file_len}"
+ ),
+ Error::AoTrailerInvalid => write!(f, "invalid AO trailer"),
+ Error::MediaOverlayOutOfBounds { overlay, file_len } => {
+ write!(
+ f,
+ "media overlay out of bounds: overlay={overlay}, file={file_len}"
+ )
+ }
+ Error::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
+ Error::PackedSizePastEof {
+ id,
+ offset,
+ packed_size,
+ file_len,
+ } => write!(
+ f,
+ "packed range past EOF: id={id}, off={offset}, size={packed_size}, file={file_len}"
+ ),
+ Error::DeflateEofPlusOneQuirkRejected { id } => {
+ write!(f, "deflate EOF+1 quirk rejected for entry {id}")
+ }
+ Error::DecompressionFailed(s) => write!(f, "decompression failed: {s}"),
+ Error::OutputSizeMismatch { expected, got } => {
+ write!(f, "output size mismatch: expected={expected}, got={got}")
+ }
+ Error::IntegerOverflow => write!(f, "integer overflow"),
+ }
+ }
+}
+
+impl std::error::Error for Error {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Io(err) => Some(err),
+ _ => None,
+ }
+ }
+}
diff --git a/crates/rsli/src/lib.rs b/crates/rsli/src/lib.rs
new file mode 100644
index 0000000..ef29f41
--- /dev/null
+++ b/crates/rsli/src/lib.rs
@@ -0,0 +1,411 @@
+pub mod compress;
+pub mod error;
+pub mod parse;
+
+use crate::compress::{
+ decode_deflate, lzss_decompress_simple, lzss_huffman_decompress, xor_stream,
+};
+use crate::error::Error;
+use crate::parse::{c_name_bytes, cmp_c_string, parse_library};
+use common::{OutputBuffer, ResourceData};
+use std::cmp::Ordering;
+use std::fs;
+use std::path::Path;
+use std::sync::Arc;
+
+pub type Result<T> = core::result::Result<T, Error>;
+
+#[derive(Clone, Debug)]
+pub struct OpenOptions {
+ pub allow_ao_trailer: bool,
+ pub allow_deflate_eof_plus_one: bool,
+}
+
+impl Default for OpenOptions {
+ fn default() -> Self {
+ Self {
+ allow_ao_trailer: true,
+ allow_deflate_eof_plus_one: true,
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct Library {
+ bytes: Arc<[u8]>,
+ entries: Vec<EntryRecord>,
+ #[cfg(test)]
+ pub(crate) header_raw: [u8; 32],
+ #[cfg(test)]
+ pub(crate) table_plain_original: Vec<u8>,
+ #[cfg(test)]
+ pub(crate) xor_seed: u32,
+ #[cfg(test)]
+ pub(crate) source_size: usize,
+ #[cfg(test)]
+ pub(crate) trailer_raw: Option<[u8; 6]>,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
+pub struct EntryId(pub u32);
+
+#[derive(Clone, Debug)]
+pub struct EntryMeta {
+ pub name: String,
+ pub flags: i32,
+ pub method: PackMethod,
+ pub data_offset: u64,
+ pub packed_size: u32,
+ pub unpacked_size: u32,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum PackMethod {
+ None,
+ XorOnly,
+ Lzss,
+ XorLzss,
+ LzssHuffman,
+ XorLzssHuffman,
+ Deflate,
+ Unknown(u32),
+}
+
+#[derive(Copy, Clone, Debug)]
+pub struct EntryRef<'a> {
+ pub id: EntryId,
+ pub meta: &'a EntryMeta,
+}
+
+pub struct PackedResource {
+ pub meta: EntryMeta,
+ pub packed: Vec<u8>,
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct EntryRecord {
+ pub(crate) meta: EntryMeta,
+ pub(crate) name_raw: [u8; 12],
+ pub(crate) sort_to_original: i16,
+ pub(crate) key16: u16,
+ #[cfg(test)]
+ pub(crate) data_offset_raw: u32,
+ pub(crate) packed_size_declared: u32,
+ pub(crate) packed_size_available: usize,
+ pub(crate) effective_offset: usize,
+}
+
+impl Library {
+ pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
+ Self::open_path_with(path, OpenOptions::default())
+ }
+
+ pub fn open_path_with(path: impl AsRef<Path>, opts: OpenOptions) -> Result<Self> {
+ let bytes = fs::read(path.as_ref())?;
+ let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
+ parse_library(arc, opts)
+ }
+
+ pub fn entry_count(&self) -> usize {
+ self.entries.len()
+ }
+
+ pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
+ self.entries
+ .iter()
+ .enumerate()
+ .map(|(idx, entry)| EntryRef {
+ id: EntryId(u32::try_from(idx).expect("entry count validated at parse")),
+ meta: &entry.meta,
+ })
+ }
+
+ pub fn find(&self, name: &str) -> Option<EntryId> {
+ if self.entries.is_empty() {
+ return None;
+ }
+
+ const MAX_INLINE_NAME: usize = 12;
+
+ // Fast path: use stack allocation for short ASCII names (95% of cases)
+ if name.len() <= MAX_INLINE_NAME && name.is_ascii() {
+ let mut buf = [0u8; MAX_INLINE_NAME];
+ for (i, &b) in name.as_bytes().iter().enumerate() {
+ buf[i] = b.to_ascii_uppercase();
+ }
+ return self.find_impl(&buf[..name.len()]);
+ }
+
+ // Slow path: heap allocation for long or non-ASCII names
+ let query = name.to_ascii_uppercase();
+ self.find_impl(query.as_bytes())
+ }
+
+ fn find_impl(&self, query_bytes: &[u8]) -> Option<EntryId> {
+ // Binary search
+ let mut low = 0usize;
+ let mut high = self.entries.len();
+ while low < high {
+ let mid = low + (high - low) / 2;
+ let idx = self.entries[mid].sort_to_original;
+ if idx < 0 {
+ break;
+ }
+ let idx = usize::try_from(idx).ok()?;
+ if idx >= self.entries.len() {
+ break;
+ }
+
+ let cmp = cmp_c_string(query_bytes, c_name_bytes(&self.entries[idx].name_raw));
+ match cmp {
+ Ordering::Less => high = mid,
+ Ordering::Greater => low = mid + 1,
+ Ordering::Equal => {
+ return Some(EntryId(
+ u32::try_from(idx).expect("entry count validated at parse"),
+ ))
+ }
+ }
+ }
+
+ // Linear fallback search
+ self.entries.iter().enumerate().find_map(|(idx, entry)| {
+ if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal {
+ Some(EntryId(
+ u32::try_from(idx).expect("entry count validated at parse"),
+ ))
+ } else {
+ None
+ }
+ })
+ }
+
+ pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
+ let idx = usize::try_from(id.0).ok()?;
+ let entry = self.entries.get(idx)?;
+ Some(EntryRef {
+ id,
+ meta: &entry.meta,
+ })
+ }
+
+ pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
+ let entry = self.entry_by_id(id)?;
+ let packed = self.packed_slice(id, entry)?;
+ decode_payload(
+ packed,
+ entry.meta.method,
+ entry.key16,
+ entry.meta.unpacked_size,
+ )
+ }
+
+ pub fn load_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> {
+ let decoded = self.load(id)?;
+ out.write_exact(&decoded)?;
+ Ok(decoded.len())
+ }
+
+ pub fn load_packed(&self, id: EntryId) -> Result<PackedResource> {
+ let entry = self.entry_by_id(id)?;
+ let packed = self.packed_slice(id, entry)?.to_vec();
+ Ok(PackedResource {
+ meta: entry.meta.clone(),
+ packed,
+ })
+ }
+
+ pub fn unpack(&self, packed: &PackedResource) -> Result<Vec<u8>> {
+ let key16 = self.resolve_key_for_meta(&packed.meta).unwrap_or(0);
+
+ let method = packed.meta.method;
+ if needs_xor_key(method) && self.resolve_key_for_meta(&packed.meta).is_none() {
+ return Err(Error::CorruptEntryTable(
+ "cannot resolve XOR key for packed resource",
+ ));
+ }
+
+ decode_payload(&packed.packed, method, key16, packed.meta.unpacked_size)
+ }
+
+ pub fn load_fast(&self, id: EntryId) -> Result<ResourceData<'_>> {
+ let entry = self.entry_by_id(id)?;
+ if entry.meta.method == PackMethod::None {
+ let packed = self.packed_slice(id, entry)?;
+ let size =
+ usize::try_from(entry.meta.unpacked_size).map_err(|_| Error::IntegerOverflow)?;
+ if packed.len() < size {
+ return Err(Error::OutputSizeMismatch {
+ expected: entry.meta.unpacked_size,
+ got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
+ });
+ }
+ return Ok(ResourceData::Borrowed(&packed[..size]));
+ }
+ Ok(ResourceData::Owned(self.load(id)?))
+ }
+
+ fn entry_by_id(&self, id: EntryId) -> Result<&EntryRecord> {
+ let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
+ self.entries
+ .get(idx)
+ .ok_or_else(|| Error::EntryIdOutOfRange {
+ id: id.0,
+ entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
+ })
+ }
+
+ fn packed_slice<'a>(&'a self, id: EntryId, entry: &EntryRecord) -> Result<&'a [u8]> {
+ let start = entry.effective_offset;
+ let end = start
+ .checked_add(entry.packed_size_available)
+ .ok_or(Error::IntegerOverflow)?;
+ self.bytes
+ .get(start..end)
+ .ok_or(Error::EntryDataOutOfBounds {
+ id: id.0,
+ offset: u64::try_from(start).unwrap_or(u64::MAX),
+ size: entry.packed_size_declared,
+ file_len: u64::try_from(self.bytes.len()).unwrap_or(u64::MAX),
+ })
+ }
+
+ fn resolve_key_for_meta(&self, meta: &EntryMeta) -> Option<u16> {
+ self.entries
+ .iter()
+ .find(|entry| {
+ entry.meta.name == meta.name
+ && entry.meta.flags == meta.flags
+ && entry.meta.data_offset == meta.data_offset
+ && entry.meta.packed_size == meta.packed_size
+ && entry.meta.unpacked_size == meta.unpacked_size
+ && entry.meta.method == meta.method
+ })
+ .map(|entry| entry.key16)
+ }
+
+ #[cfg(test)]
+ pub(crate) fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> {
+ let trailer_len = usize::from(self.trailer_raw.is_some()) * 6;
+ let pre_trailer_size = self
+ .source_size
+ .checked_sub(trailer_len)
+ .ok_or(Error::IntegerOverflow)?;
+
+ let count = self.entries.len();
+ let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
+ let table_end = 32usize
+ .checked_add(table_len)
+ .ok_or(Error::IntegerOverflow)?;
+ if pre_trailer_size < table_end {
+ return Err(Error::EntryTableOutOfBounds {
+ table_offset: 32,
+ table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?,
+ file_len: u64::try_from(pre_trailer_size).map_err(|_| Error::IntegerOverflow)?,
+ });
+ }
+
+ let mut out = vec![0u8; pre_trailer_size];
+ out[0..32].copy_from_slice(&self.header_raw);
+ let encrypted_table =
+ xor_stream(&self.table_plain_original, (self.xor_seed & 0xFFFF) as u16);
+ out[32..table_end].copy_from_slice(&encrypted_table);
+
+ let mut occupied = vec![false; pre_trailer_size];
+ for byte in occupied.iter_mut().take(table_end) {
+ *byte = true;
+ }
+
+ for (idx, entry) in self.entries.iter().enumerate() {
+ let packed = self
+ .load_packed(EntryId(
+ u32::try_from(idx).expect("entry count validated at parse"),
+ ))?
+ .packed;
+ let start =
+ usize::try_from(entry.data_offset_raw).map_err(|_| Error::IntegerOverflow)?;
+ for (offset, byte) in packed.iter().copied().enumerate() {
+ let pos = start.checked_add(offset).ok_or(Error::IntegerOverflow)?;
+ if pos >= out.len() {
+ return Err(Error::PackedSizePastEof {
+ id: u32::try_from(idx).expect("entry count validated at parse"),
+ offset: u64::from(entry.data_offset_raw),
+ packed_size: entry.packed_size_declared,
+ file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?,
+ });
+ }
+ if occupied[pos] && out[pos] != byte {
+ return Err(Error::CorruptEntryTable("packed payload overlap conflict"));
+ }
+ out[pos] = byte;
+ occupied[pos] = true;
+ }
+ }
+
+ if let Some(trailer) = self.trailer_raw {
+ out.extend_from_slice(&trailer);
+ }
+ Ok(out)
+ }
+}
+
+fn decode_payload(
+ packed: &[u8],
+ method: PackMethod,
+ key16: u16,
+ unpacked_size: u32,
+) -> Result<Vec<u8>> {
+ let expected = usize::try_from(unpacked_size).map_err(|_| Error::IntegerOverflow)?;
+
+ let out = match method {
+ PackMethod::None => {
+ if packed.len() < expected {
+ return Err(Error::OutputSizeMismatch {
+ expected: unpacked_size,
+ got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
+ });
+ }
+ packed[..expected].to_vec()
+ }
+ PackMethod::XorOnly => {
+ if packed.len() < expected {
+ return Err(Error::OutputSizeMismatch {
+ expected: unpacked_size,
+ got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
+ });
+ }
+ xor_stream(&packed[..expected], key16)
+ }
+ PackMethod::Lzss => lzss_decompress_simple(packed, expected, None)?,
+ PackMethod::XorLzss => {
+ // Optimized: XOR on-the-fly during decompression instead of creating temp buffer
+ lzss_decompress_simple(packed, expected, Some(key16))?
+ }
+ PackMethod::LzssHuffman => lzss_huffman_decompress(packed, expected, None)?,
+ PackMethod::XorLzssHuffman => {
+ // Optimized: XOR on-the-fly during decompression
+ lzss_huffman_decompress(packed, expected, Some(key16))?
+ }
+ PackMethod::Deflate => decode_deflate(packed)?,
+ PackMethod::Unknown(raw) => return Err(Error::UnsupportedMethod { raw }),
+ };
+
+ if out.len() != expected {
+ return Err(Error::OutputSizeMismatch {
+ expected: unpacked_size,
+ got: u32::try_from(out.len()).unwrap_or(u32::MAX),
+ });
+ }
+
+ Ok(out)
+}
+
+fn needs_xor_key(method: PackMethod) -> bool {
+ matches!(
+ method,
+ PackMethod::XorOnly | PackMethod::XorLzss | PackMethod::XorLzssHuffman
+ )
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/rsli/src/parse.rs b/crates/rsli/src/parse.rs
new file mode 100644
index 0000000..9a916dc
--- /dev/null
+++ b/crates/rsli/src/parse.rs
@@ -0,0 +1,267 @@
+use crate::compress::xor::xor_stream;
+use crate::error::Error;
+use crate::{EntryMeta, EntryRecord, Library, OpenOptions, PackMethod, Result};
+use std::cmp::Ordering;
+use std::sync::Arc;
+
+pub fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
+ if bytes.len() < 32 {
+ return Err(Error::EntryTableOutOfBounds {
+ table_offset: 32,
+ table_len: 0,
+ file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
+ });
+ }
+
+ let mut header_raw = [0u8; 32];
+ header_raw.copy_from_slice(&bytes[0..32]);
+
+ if &bytes[0..2] != b"NL" {
+ let mut got = [0u8; 2];
+ got.copy_from_slice(&bytes[0..2]);
+ return Err(Error::InvalidMagic { got });
+ }
+ if bytes[3] != 0x01 {
+ return Err(Error::UnsupportedVersion { got: bytes[3] });
+ }
+
+ let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
+ if entry_count < 0 {
+ return Err(Error::InvalidEntryCount { got: entry_count });
+ }
+ let count = usize::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?;
+
+ // Validate entry_count fits in u32 (required for EntryId)
+ if count > u32::MAX as usize {
+ return Err(Error::TooManyEntries { got: count });
+ }
+
+ let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
+
+ let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
+ let table_offset = 32usize;
+ let table_end = table_offset
+ .checked_add(table_len)
+ .ok_or(Error::IntegerOverflow)?;
+ if table_end > bytes.len() {
+ return Err(Error::EntryTableOutOfBounds {
+ table_offset: u64::try_from(table_offset).map_err(|_| Error::IntegerOverflow)?,
+ table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?,
+ file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
+ });
+ }
+
+ let table_enc = &bytes[table_offset..table_end];
+ let table_plain_original = xor_stream(table_enc, (xor_seed & 0xFFFF) as u16);
+ if table_plain_original.len() != table_len {
+ return Err(Error::EntryTableDecryptFailed);
+ }
+
+ let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?;
+ #[cfg(not(test))]
+ let _ = trailer_raw;
+
+ let mut entries = Vec::with_capacity(count);
+ for idx in 0..count {
+ let row = &table_plain_original[idx * 32..(idx + 1) * 32];
+
+ let mut name_raw = [0u8; 12];
+ name_raw.copy_from_slice(&row[0..12]);
+
+ let flags_signed = i16::from_le_bytes([row[16], row[17]]);
+ let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
+ let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
+ let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
+ let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
+
+ let method_raw = (flags_signed as u16 as u32) & 0x1E0;
+ let method = parse_method(method_raw);
+
+ let effective_offset_u64 = u64::from(data_offset_raw)
+ .checked_add(u64::from(overlay))
+ .ok_or(Error::IntegerOverflow)?;
+ let effective_offset =
+ usize::try_from(effective_offset_u64).map_err(|_| Error::IntegerOverflow)?;
+
+ let packed_size_usize =
+ usize::try_from(packed_size_declared).map_err(|_| Error::IntegerOverflow)?;
+ let mut packed_size_available = packed_size_usize;
+
+ let end = effective_offset_u64
+ .checked_add(u64::from(packed_size_declared))
+ .ok_or(Error::IntegerOverflow)?;
+ let file_len_u64 = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
+
+ if end > file_len_u64 {
+ if method_raw == 0x100 && end == file_len_u64 + 1 {
+ if opts.allow_deflate_eof_plus_one {
+ packed_size_available = packed_size_available
+ .checked_sub(1)
+ .ok_or(Error::IntegerOverflow)?;
+ } else {
+ return Err(Error::DeflateEofPlusOneQuirkRejected {
+ id: u32::try_from(idx).expect("entry count validated at parse"),
+ });
+ }
+ } else {
+ return Err(Error::PackedSizePastEof {
+ id: u32::try_from(idx).expect("entry count validated at parse"),
+ offset: effective_offset_u64,
+ packed_size: packed_size_declared,
+ file_len: file_len_u64,
+ });
+ }
+ }
+
+ let available_end = effective_offset
+ .checked_add(packed_size_available)
+ .ok_or(Error::IntegerOverflow)?;
+ if available_end > bytes.len() {
+ return Err(Error::EntryDataOutOfBounds {
+ id: u32::try_from(idx).expect("entry count validated at parse"),
+ offset: effective_offset_u64,
+ size: packed_size_declared,
+ file_len: file_len_u64,
+ });
+ }
+
+ let name = decode_name(c_name_bytes(&name_raw));
+
+ entries.push(EntryRecord {
+ meta: EntryMeta {
+ name,
+ flags: i32::from(flags_signed),
+ method,
+ data_offset: effective_offset_u64,
+ packed_size: packed_size_declared,
+ unpacked_size,
+ },
+ name_raw,
+ sort_to_original,
+ key16: sort_to_original as u16,
+ #[cfg(test)]
+ data_offset_raw,
+ packed_size_declared,
+ packed_size_available,
+ effective_offset,
+ });
+ }
+
+ let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
+ if presorted_flag == 0xABBA {
+ let mut seen = vec![false; count];
+ for entry in &entries {
+ let idx = i32::from(entry.sort_to_original);
+ if idx < 0 {
+ return Err(Error::CorruptEntryTable(
+ "sort_to_original is not a valid permutation index",
+ ));
+ }
+ let idx = usize::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
+ if idx >= count {
+ return Err(Error::CorruptEntryTable(
+ "sort_to_original is not a valid permutation index",
+ ));
+ }
+ if seen[idx] {
+ return Err(Error::CorruptEntryTable(
+ "sort_to_original is not a permutation",
+ ));
+ }
+ seen[idx] = true;
+ }
+ if seen.iter().any(|value| !*value) {
+ return Err(Error::CorruptEntryTable(
+ "sort_to_original is not a permutation",
+ ));
+ }
+ } else {
+ let mut sorted: Vec<usize> = (0..count).collect();
+ sorted.sort_by(|a, b| {
+ cmp_c_string(
+ c_name_bytes(&entries[*a].name_raw),
+ c_name_bytes(&entries[*b].name_raw),
+ )
+ });
+ for (idx, entry) in entries.iter_mut().enumerate() {
+ entry.sort_to_original =
+ i16::try_from(sorted[idx]).map_err(|_| Error::IntegerOverflow)?;
+ entry.key16 = entry.sort_to_original as u16;
+ }
+ }
+
+ #[cfg(test)]
+ let source_size = bytes.len();
+
+ Ok(Library {
+ bytes,
+ entries,
+ #[cfg(test)]
+ header_raw,
+ #[cfg(test)]
+ table_plain_original,
+ #[cfg(test)]
+ xor_seed,
+ #[cfg(test)]
+ source_size,
+ #[cfg(test)]
+ trailer_raw,
+ })
+}
+
+fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>)> {
+ if !allow || bytes.len() < 6 {
+ return Ok((0, None));
+ }
+
+ if &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
+ return Ok((0, None));
+ }
+
+ let mut trailer = [0u8; 6];
+ trailer.copy_from_slice(&bytes[bytes.len() - 6..]);
+ let overlay = u32::from_le_bytes([trailer[2], trailer[3], trailer[4], trailer[5]]);
+
+ if u64::from(overlay) > u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)? {
+ return Err(Error::MediaOverlayOutOfBounds {
+ overlay,
+ file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
+ });
+ }
+
+ Ok((overlay, Some(trailer)))
+}
+
+pub fn parse_method(raw: u32) -> PackMethod {
+ match raw {
+ 0x000 => PackMethod::None,
+ 0x020 => PackMethod::XorOnly,
+ 0x040 => PackMethod::Lzss,
+ 0x060 => PackMethod::XorLzss,
+ 0x080 => PackMethod::LzssHuffman,
+ 0x0A0 => PackMethod::XorLzssHuffman,
+ 0x100 => PackMethod::Deflate,
+ other => PackMethod::Unknown(other),
+ }
+}
+
+fn decode_name(name: &[u8]) -> String {
+ name.iter().map(|b| char::from(*b)).collect()
+}
+
+pub fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
+ let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
+ &raw[..len]
+}
+
+pub fn cmp_c_string(a: &[u8], b: &[u8]) -> Ordering {
+ let min_len = a.len().min(b.len());
+ let mut idx = 0usize;
+ while idx < min_len {
+ if a[idx] != b[idx] {
+ return a[idx].cmp(&b[idx]);
+ }
+ idx += 1;
+ }
+ a.len().cmp(&b.len())
+}
diff --git a/crates/rsli/src/tests.rs b/crates/rsli/src/tests.rs
new file mode 100644
index 0000000..07807d3
--- /dev/null
+++ b/crates/rsli/src/tests.rs
@@ -0,0 +1,1337 @@
+use super::*;
+use crate::compress::lzh::{LZH_MAX_FREQ, LZH_N_CHAR, LZH_R, LZH_T};
+use crate::compress::xor::xor_stream;
+use flate2::write::DeflateEncoder;
+use flate2::write::ZlibEncoder;
+use flate2::Compression;
+use std::any::Any;
+use std::fs;
+use std::io::Write as _;
+use std::panic::{catch_unwind, AssertUnwindSafe};
+use std::path::PathBuf;
+
+#[derive(Clone, Debug)]
+struct SyntheticRsliEntry {
+ name: String,
+ method_raw: u16,
+ plain: Vec<u8>,
+ declared_packed_size: Option<u32>,
+}
+
+#[derive(Clone, Debug)]
+struct RsliBuildOptions {
+ seed: u32,
+ presorted: bool,
+ overlay: u32,
+ add_ao_trailer: bool,
+}
+
+impl Default for RsliBuildOptions {
+ fn default() -> Self {
+ Self {
+ seed: 0x1234_5678,
+ presorted: true,
+ overlay: 0,
+ add_ao_trailer: false,
+ }
+ }
+}
+
+fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
+ let Ok(entries) = fs::read_dir(root) else {
+ return;
+ };
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.is_dir() {
+ collect_files_recursive(&path, out);
+ } else if path.is_file() {
+ out.push(path);
+ }
+ }
+}
+
+fn rsli_test_files() -> Vec<PathBuf> {
+ let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("..")
+ .join("..")
+ .join("testdata")
+ .join("rsli");
+ let mut files = Vec::new();
+ collect_files_recursive(&root, &mut files);
+ files.sort();
+ files
+ .into_iter()
+ .filter(|path| {
+ fs::read(path)
+ .map(|data| data.get(0..4) == Some(b"NL\0\x01"))
+ .unwrap_or(false)
+ })
+ .collect()
+}
+
+fn panic_message(payload: Box<dyn Any + Send>) -> String {
+ let any = payload.as_ref();
+ if let Some(message) = any.downcast_ref::<String>() {
+ return message.clone();
+ }
+ if let Some(message) = any.downcast_ref::<&str>() {
+ return (*message).to_string();
+ }
+ String::from("panic without message")
+}
+
+fn write_temp_file(prefix: &str, bytes: &[u8]) -> PathBuf {
+ let mut path = std::env::temp_dir();
+ path.push(format!(
+ "{}-{}-{}.bin",
+ prefix,
+ std::process::id(),
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ ));
+ fs::write(&path, bytes).expect("failed to write temp archive");
+ path
+}
+
+fn deflate_raw(data: &[u8]) -> Vec<u8> {
+ let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
+ encoder
+ .write_all(data)
+ .expect("deflate encoder write failed");
+ encoder.finish().expect("deflate encoder finish failed")
+}
+
+fn deflate_zlib(data: &[u8]) -> Vec<u8> {
+ let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
+ encoder.write_all(data).expect("zlib encoder write failed");
+ encoder.finish().expect("zlib encoder finish failed")
+}
+
+fn lzss_pack_literals(data: &[u8]) -> Vec<u8> {
+ let mut out = Vec::new();
+ for chunk in data.chunks(8) {
+ let mask = if chunk.len() == 8 {
+ 0xFF
+ } else {
+ (1u16
+ .checked_shl(u32::try_from(chunk.len()).expect("chunk len overflow"))
+ .expect("shift overflow")
+ - 1) as u8
+ };
+ out.push(mask);
+ out.extend_from_slice(chunk);
+ }
+ out
+}
+
+struct BitWriter {
+ bytes: Vec<u8>,
+ current: u8,
+ mask: u8,
+}
+
+impl BitWriter {
+ fn new() -> Self {
+ Self {
+ bytes: Vec::new(),
+ current: 0,
+ mask: 0x80,
+ }
+ }
+
+ fn write_bit(&mut self, bit: u8) {
+ if bit != 0 {
+ self.current |= self.mask;
+ }
+ self.mask >>= 1;
+ if self.mask == 0 {
+ self.bytes.push(self.current);
+ self.current = 0;
+ self.mask = 0x80;
+ }
+ }
+
+ fn finish(mut self) -> Vec<u8> {
+ if self.mask != 0x80 {
+ self.bytes.push(self.current);
+ }
+ self.bytes
+ }
+}
+
+struct LzhLiteralModel {
+ freq: [u16; LZH_T + 1],
+ parent: [usize; LZH_T + LZH_N_CHAR],
+ son: [usize; LZH_T + 1],
+}
+
+impl LzhLiteralModel {
+ fn new() -> Self {
+ let mut model = Self {
+ freq: [0; LZH_T + 1],
+ parent: [0; LZH_T + LZH_N_CHAR],
+ son: [0; LZH_T + 1],
+ };
+ model.start_huff();
+ model
+ }
+
+ fn encode_literal(&mut self, literal: u8, writer: &mut BitWriter) {
+ let target = usize::from(literal) + LZH_T;
+ let mut path = Vec::new();
+ let mut visited = [false; LZH_T + 1];
+ let found = self.find_path(self.son[LZH_R], target, &mut path, &mut visited);
+ assert!(found, "failed to encode literal {literal}");
+ for bit in path {
+ writer.write_bit(bit);
+ }
+
+ self.update(usize::from(literal));
+ }
+
+ fn find_path(
+ &self,
+ node: usize,
+ target: usize,
+ path: &mut Vec<u8>,
+ visited: &mut [bool; LZH_T + 1],
+ ) -> bool {
+ if node == target {
+ return true;
+ }
+ if node >= LZH_T {
+ return false;
+ }
+ if visited[node] {
+ return false;
+ }
+ visited[node] = true;
+
+ for bit in [0u8, 1u8] {
+ let child = self.son[node + usize::from(bit)];
+ path.push(bit);
+ if self.find_path(child, target, path, visited) {
+ visited[node] = false;
+ return true;
+ }
+ path.pop();
+ }
+
+ visited[node] = false;
+ false
+ }
+
+ fn start_huff(&mut self) {
+ for i in 0..LZH_N_CHAR {
+ self.freq[i] = 1;
+ self.son[i] = i + LZH_T;
+ self.parent[i + LZH_T] = i;
+ }
+
+ let mut i = 0usize;
+ let mut j = LZH_N_CHAR;
+ while j <= LZH_R {
+ self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]);
+ self.son[j] = i;
+ self.parent[i] = j;
+ self.parent[i + 1] = j;
+ i += 2;
+ j += 1;
+ }
+
+ self.freq[LZH_T] = u16::MAX;
+ self.parent[LZH_R] = 0;
+ }
+
+ fn update(&mut self, c: usize) {
+ if self.freq[LZH_R] == LZH_MAX_FREQ {
+ self.reconstruct();
+ }
+
+ let mut current = self.parent[c + LZH_T];
+ loop {
+ self.freq[current] = self.freq[current].saturating_add(1);
+ let freq = self.freq[current];
+
+ if current + 1 < self.freq.len() && freq > self.freq[current + 1] {
+ let mut swap_idx = current + 1;
+ while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] {
+ swap_idx += 1;
+ }
+
+ self.freq.swap(current, swap_idx);
+
+ let left = self.son[current];
+ let right = self.son[swap_idx];
+ self.son[current] = right;
+ self.son[swap_idx] = left;
+
+ self.parent[left] = swap_idx;
+ if left < LZH_T {
+ self.parent[left + 1] = swap_idx;
+ }
+
+ self.parent[right] = current;
+ if right < LZH_T {
+ self.parent[right + 1] = current;
+ }
+
+ current = swap_idx;
+ }
+
+ current = self.parent[current];
+ if current == 0 {
+ break;
+ }
+ }
+ }
+
+ fn reconstruct(&mut self) {
+ let mut j = 0usize;
+ for i in 0..LZH_T {
+ if self.son[i] >= LZH_T {
+ self.freq[j] = self.freq[i].div_ceil(2);
+ self.son[j] = self.son[i];
+ j += 1;
+ }
+ }
+
+ let mut i = 0usize;
+ let mut current = LZH_N_CHAR;
+ while current < LZH_T {
+ let sum = self.freq[i].saturating_add(self.freq[i + 1]);
+ self.freq[current] = sum;
+
+ let mut insert_at = current;
+ while insert_at > 0 && sum < self.freq[insert_at - 1] {
+ insert_at -= 1;
+ }
+
+ for move_idx in (insert_at..current).rev() {
+ self.freq[move_idx + 1] = self.freq[move_idx];
+ self.son[move_idx + 1] = self.son[move_idx];
+ }
+
+ self.freq[insert_at] = sum;
+ self.son[insert_at] = i;
+ i += 2;
+ current += 1;
+ }
+
+ for idx in 0..LZH_T {
+ let node = self.son[idx];
+ self.parent[node] = idx;
+ if node < LZH_T {
+ self.parent[node + 1] = idx;
+ }
+ }
+
+ self.freq[LZH_T] = u16::MAX;
+ self.parent[LZH_R] = 0;
+ }
+}
+
+fn lzh_pack_literals(data: &[u8]) -> Vec<u8> {
+ let mut writer = BitWriter::new();
+ let mut model = LzhLiteralModel::new();
+ for byte in data {
+ model.encode_literal(*byte, &mut writer);
+ }
+ writer.finish()
+}
+
+fn packed_for_method(method_raw: u16, plain: &[u8], key16: u16) -> Vec<u8> {
+ match (u32::from(method_raw)) & 0x1E0 {
+ 0x000 => plain.to_vec(),
+ 0x020 => xor_stream(plain, key16),
+ 0x040 => lzss_pack_literals(plain),
+ 0x060 => xor_stream(&lzss_pack_literals(plain), key16),
+ 0x080 => lzh_pack_literals(plain),
+ 0x0A0 => xor_stream(&lzh_pack_literals(plain), key16),
+ 0x100 => deflate_raw(plain),
+ _ => plain.to_vec(),
+ }
+}
+
+fn build_rsli_bytes(entries: &[SyntheticRsliEntry], opts: &RsliBuildOptions) -> Vec<u8> {
+ let count = entries.len();
+ let mut rows_plain = vec![0u8; count * 32];
+ let table_end = 32 + rows_plain.len();
+
+ let mut sort_lookup: Vec<usize> = (0..count).collect();
+ sort_lookup.sort_by(|a, b| entries[*a].name.as_bytes().cmp(entries[*b].name.as_bytes()));
+
+ let mut packed_blobs = Vec::with_capacity(count);
+ for index in 0..count {
+ let key16 = u16::try_from(sort_lookup[index]).expect("sort index overflow");
+ let packed = packed_for_method(entries[index].method_raw, &entries[index].plain, key16);
+ packed_blobs.push(packed);
+ }
+
+ let overlay = usize::try_from(opts.overlay).expect("overlay overflow");
+ let mut cursor = table_end + overlay;
+ let mut output = vec![0u8; cursor];
+
+ let mut data_offsets = Vec::with_capacity(count);
+ for (index, packed) in packed_blobs.iter().enumerate() {
+ let raw_offset = cursor
+ .checked_sub(overlay)
+ .expect("overlay larger than cursor");
+ data_offsets.push(raw_offset);
+
+ let end = cursor.checked_add(packed.len()).expect("cursor overflow");
+ if output.len() < end {
+ output.resize(end, 0);
+ }
+ output[cursor..end].copy_from_slice(packed);
+ cursor = end;
+
+ let base = index * 32;
+ let mut name_raw = [0u8; 12];
+ let uppercase = entries[index].name.to_ascii_uppercase();
+ let name_bytes = uppercase.as_bytes();
+ assert!(name_bytes.len() <= 12, "name too long in synthetic fixture");
+ name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
+
+ rows_plain[base..base + 12].copy_from_slice(&name_raw);
+
+ let sort_field: i16 = if opts.presorted {
+ i16::try_from(sort_lookup[index]).expect("sort field overflow")
+ } else {
+ 0
+ };
+
+ let packed_size = entries[index]
+ .declared_packed_size
+ .unwrap_or_else(|| u32::try_from(packed.len()).expect("packed size overflow"));
+
+ rows_plain[base + 16..base + 18].copy_from_slice(&entries[index].method_raw.to_le_bytes());
+ rows_plain[base + 18..base + 20].copy_from_slice(&sort_field.to_le_bytes());
+ rows_plain[base + 20..base + 24].copy_from_slice(
+ &u32::try_from(entries[index].plain.len())
+ .expect("unpacked size overflow")
+ .to_le_bytes(),
+ );
+ rows_plain[base + 24..base + 28].copy_from_slice(
+ &u32::try_from(data_offsets[index])
+ .expect("data offset overflow")
+ .to_le_bytes(),
+ );
+ rows_plain[base + 28..base + 32].copy_from_slice(&packed_size.to_le_bytes());
+ }
+
+ if output.len() < table_end {
+ output.resize(table_end, 0);
+ }
+
+ output[0..2].copy_from_slice(b"NL");
+ output[2] = 0;
+ output[3] = 1;
+ output[4..6].copy_from_slice(
+ &i16::try_from(count)
+ .expect("entry count overflow")
+ .to_le_bytes(),
+ );
+
+ let presorted_flag = if opts.presorted { 0xABBA_u16 } else { 0_u16 };
+ output[14..16].copy_from_slice(&presorted_flag.to_le_bytes());
+ output[20..24].copy_from_slice(&opts.seed.to_le_bytes());
+
+ let encrypted_table = xor_stream(&rows_plain, (opts.seed & 0xFFFF) as u16);
+ output[32..table_end].copy_from_slice(&encrypted_table);
+
+ if opts.add_ao_trailer {
+ output.extend_from_slice(b"AO");
+ output.extend_from_slice(&opts.overlay.to_le_bytes());
+ }
+
+ output
+}
+
+fn read_u32_le(bytes: &[u8], offset: usize) -> u32 {
+ let slice = bytes
+ .get(offset..offset + 4)
+ .expect("u32 read out of bounds in test");
+ let arr: [u8; 4] = slice.try_into().expect("u32 conversion failed in test");
+ u32::from_le_bytes(arr)
+}
+
+#[test]
+fn rsli_read_unpack_and_repack_all_files() {
+ let files = rsli_test_files();
+ if files.is_empty() {
+ eprintln!(
+ "skipping rsli_read_unpack_and_repack_all_files: no RsLi archives in testdata/rsli"
+ );
+ return;
+ }
+
+ let checked = files.len();
+ let mut success = 0usize;
+ let mut failures = Vec::new();
+
+ for path in files {
+ let display_path = path.display().to_string();
+ let result = catch_unwind(AssertUnwindSafe(|| {
+ let original = fs::read(&path).expect("failed to read archive");
+ let library = Library::open_path(&path)
+ .unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display()));
+
+ let count = library.entry_count();
+ assert_eq!(
+ count,
+ library.entries().count(),
+ "entry count mismatch: {}",
+ path.display()
+ );
+
+ for idx in 0..count {
+ let id = EntryId(idx as u32);
+ let meta_ref = library
+ .get(id)
+ .unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display()));
+
+ let loaded = library.load(id).unwrap_or_else(|err| {
+ panic!("load failed for {} entry #{idx}: {err}", path.display())
+ });
+
+ let packed = library.load_packed(id).unwrap_or_else(|err| {
+ panic!(
+ "load_packed failed for {} entry #{idx}: {err}",
+ path.display()
+ )
+ });
+ let unpacked = library.unpack(&packed).unwrap_or_else(|err| {
+ panic!("unpack failed for {} entry #{idx}: {err}", path.display())
+ });
+ assert_eq!(
+ loaded,
+ unpacked,
+ "load != unpack in {} entry #{idx}",
+ path.display()
+ );
+
+ let mut out = Vec::new();
+ let written = library.load_into(id, &mut out).unwrap_or_else(|err| {
+ panic!(
+ "load_into failed for {} entry #{idx}: {err}",
+ path.display()
+ )
+ });
+ assert_eq!(
+ written,
+ loaded.len(),
+ "load_into size mismatch in {} entry #{idx}",
+ path.display()
+ );
+ assert_eq!(
+ out,
+ loaded,
+ "load_into payload mismatch in {} entry #{idx}",
+ path.display()
+ );
+
+ let fast = library.load_fast(id).unwrap_or_else(|err| {
+ panic!(
+ "load_fast failed for {} entry #{idx}: {err}",
+ path.display()
+ )
+ });
+ assert_eq!(
+ fast.as_slice(),
+ loaded.as_slice(),
+ "load_fast mismatch in {} entry #{idx}",
+ path.display()
+ );
+
+ let found = library.find(&meta_ref.meta.name).unwrap_or_else(|| {
+ panic!(
+ "find failed for '{}' in {}",
+ meta_ref.meta.name,
+ path.display()
+ )
+ });
+ let found_meta = library.get(found).expect("find returned invalid entry id");
+ assert_eq!(
+ found_meta.meta.name,
+ meta_ref.meta.name,
+ "find returned a different entry in {}",
+ path.display()
+ );
+ }
+
+ let rebuilt = library
+ .rebuild_from_parsed_metadata()
+ .unwrap_or_else(|err| panic!("rebuild failed for {}: {err}", path.display()));
+ assert_eq!(
+ rebuilt,
+ original,
+ "byte-to-byte roundtrip mismatch for {}",
+ path.display()
+ );
+ }));
+
+ match result {
+ Ok(()) => success += 1,
+ Err(payload) => failures.push(format!("{}: {}", display_path, panic_message(payload))),
+ }
+ }
+
+ let failed = failures.len();
+ eprintln!(
+ "RsLi summary: checked={}, success={}, failed={}",
+ checked, success, failed
+ );
+ if !failures.is_empty() {
+ panic!(
+ "RsLi validation failed.\nsummary: checked={}, success={}, failed={}\n{}",
+ checked,
+ success,
+ failed,
+ failures.join("\n")
+ );
+ }
+}
+
+#[test]
+fn rsli_docs_structural_invariants_all_files() {
+ let files = rsli_test_files();
+ if files.is_empty() {
+ eprintln!(
+ "skipping rsli_docs_structural_invariants_all_files: no RsLi archives in testdata/rsli"
+ );
+ return;
+ }
+
+ let mut deflate_eof_plus_one_quirks = Vec::new();
+
+ for path in files {
+ let bytes = fs::read(&path).unwrap_or_else(|err| {
+ panic!("failed to read {}: {err}", path.display());
+ });
+
+ assert!(
+ bytes.len() >= 32,
+ "RsLi header too short in {}",
+ path.display()
+ );
+ assert_eq!(&bytes[0..2], b"NL", "bad magic in {}", path.display());
+ assert_eq!(
+ bytes[2],
+ 0,
+ "reserved header byte must be zero in {}",
+ path.display()
+ );
+ assert_eq!(bytes[3], 1, "bad version in {}", path.display());
+
+ let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
+ assert!(
+ entry_count >= 0,
+ "negative entry_count={} in {}",
+ entry_count,
+ path.display()
+ );
+ let count = usize::try_from(entry_count).expect("entry_count overflow");
+ let table_size = count.checked_mul(32).expect("table_size overflow");
+ let table_end = 32usize.checked_add(table_size).expect("table_end overflow");
+ assert!(
+ table_end <= bytes.len(),
+ "table out of bounds in {}",
+ path.display()
+ );
+
+ let seed = read_u32_le(&bytes, 20);
+ let table_plain = xor_stream(&bytes[32..table_end], (seed & 0xFFFF) as u16);
+ assert_eq!(
+ table_plain.len(),
+ table_size,
+ "decrypted table size mismatch in {}",
+ path.display()
+ );
+
+ let mut overlay = 0u32;
+ if bytes.len() >= 6 && &bytes[bytes.len() - 6..bytes.len() - 4] == b"AO" {
+ overlay = read_u32_le(&bytes, bytes.len() - 4);
+ assert!(
+ usize::try_from(overlay).expect("overlay overflow") <= bytes.len(),
+ "overlay beyond EOF in {}",
+ path.display()
+ );
+ }
+
+ let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
+ let mut sort_values = Vec::with_capacity(count);
+
+ for index in 0..count {
+ let base = index * 32;
+ let row = &table_plain[base..base + 32];
+ let flags_signed = i16::from_le_bytes([row[16], row[17]]);
+ let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
+ let data_offset = u64::from(read_u32_le(row, 24));
+ let packed_size = u64::from(read_u32_le(row, 28));
+
+ let method = (flags_signed as u16 as u32) & 0x1E0;
+ let effective_offset = data_offset + u64::from(overlay);
+ let end = effective_offset + packed_size;
+ let file_len = u64::try_from(bytes.len()).expect("file size overflow");
+
+ if end > file_len {
+ assert!(
+ method == 0x100 && end == file_len + 1,
+ "packed range out of bounds in {} entry #{index}: method=0x{method:03X}, range=[{effective_offset}, {end}), file={file_len}",
+ path.display()
+ );
+ deflate_eof_plus_one_quirks.push((path.display().to_string(), index));
+ }
+
+ sort_values.push(sort_to_original);
+ }
+
+ if presorted_flag == 0xABBA {
+ let mut sorted = sort_values;
+ sorted.sort_unstable();
+ let expected: Vec<i16> = (0..count)
+ .map(|idx| i16::try_from(idx).expect("too many entries for i16"))
+ .collect();
+ assert_eq!(
+ sorted,
+ expected,
+ "sort_to_original is not a permutation in {}",
+ path.display()
+ );
+ }
+ }
+
+ if !deflate_eof_plus_one_quirks.is_empty() {
+ assert!(
+ deflate_eof_plus_one_quirks
+ .iter()
+ .all(|(file, idx)| file.ends_with("sprites.lib") && *idx == 23),
+ "unexpected deflate EOF+1 quirks: {:?}",
+ deflate_eof_plus_one_quirks
+ );
+ }
+}
+
+#[test]
+fn rsli_synthetic_all_methods_roundtrip() {
+ let entries = vec![
+ SyntheticRsliEntry {
+ name: "M_NONE".to_string(),
+ method_raw: 0x000,
+ plain: b"plain-data".to_vec(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "M_XOR".to_string(),
+ method_raw: 0x020,
+ plain: b"xor-only".to_vec(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "M_LZSS".to_string(),
+ method_raw: 0x040,
+ plain: b"lzss literals payload".to_vec(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "M_XLZS".to_string(),
+ method_raw: 0x060,
+ plain: b"xor lzss payload".to_vec(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "M_LZHU".to_string(),
+ method_raw: 0x080,
+ plain: b"huffman literals payload".to_vec(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "M_XLZH".to_string(),
+ method_raw: 0x0A0,
+ plain: b"xor huffman payload".to_vec(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "M_DEFL".to_string(),
+ method_raw: 0x100,
+ plain: b"deflate payload with repetition repetition repetition".to_vec(),
+ declared_packed_size: None,
+ },
+ ];
+
+ let bytes = build_rsli_bytes(
+ &entries,
+ &RsliBuildOptions {
+ seed: 0xA1B2_C3D4,
+ presorted: false,
+ overlay: 0,
+ add_ao_trailer: false,
+ },
+ );
+ let path = write_temp_file("rsli-all-methods", &bytes);
+
+ let library = Library::open_path(&path).expect("open synthetic rsli failed");
+ assert_eq!(library.entry_count(), entries.len());
+
+ for entry in &entries {
+ let id = library
+ .find(&entry.name)
+ .unwrap_or_else(|| panic!("find failed for {}", entry.name));
+ let loaded = library
+ .load(id)
+ .unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name));
+ assert_eq!(
+ loaded, entry.plain,
+ "decoded payload mismatch for {}",
+ entry.name
+ );
+
+ let packed = library
+ .load_packed(id)
+ .unwrap_or_else(|err| panic!("load_packed failed for {}: {err}", entry.name));
+ let unpacked = library
+ .unpack(&packed)
+ .unwrap_or_else(|err| panic!("unpack failed for {}: {err}", entry.name));
+ assert_eq!(unpacked, entry.plain, "unpack mismatch for {}", entry.name);
+ }
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_empty_archive_roundtrip() {
+ let bytes = build_rsli_bytes(&[], &RsliBuildOptions::default());
+ let path = write_temp_file("rsli-empty", &bytes);
+
+ let library = Library::open_path(&path).expect("open empty rsli failed");
+ assert_eq!(library.entry_count(), 0);
+ assert_eq!(library.find("ANYTHING"), None);
+
+ let rebuilt = library
+ .rebuild_from_parsed_metadata()
+ .expect("rebuild empty rsli failed");
+ assert_eq!(rebuilt, bytes, "empty rsli roundtrip mismatch");
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_max_name_length_without_nul_roundtrip() {
+ let max_name = "NAME12345678";
+ assert_eq!(max_name.len(), 12);
+
+ let bytes = build_rsli_bytes(
+ &[SyntheticRsliEntry {
+ name: max_name.to_string(),
+ method_raw: 0x000,
+ plain: b"payload".to_vec(),
+ declared_packed_size: None,
+ }],
+ &RsliBuildOptions::default(),
+ );
+ let path = write_temp_file("rsli-max-name", &bytes);
+
+ let library = Library::open_path(&path).expect("open max-name rsli failed");
+ assert_eq!(library.entry_count(), 1);
+ assert_eq!(library.find(max_name), Some(EntryId(0)));
+ assert_eq!(
+ library.find(&max_name.to_ascii_lowercase()),
+ Some(EntryId(0))
+ );
+ assert_eq!(
+ library.entries[0]
+ .name_raw
+ .iter()
+ .position(|byte| *byte == 0),
+ None,
+ "name_raw must occupy full 12 bytes without NUL"
+ );
+
+ let entry = library.get(EntryId(0)).expect("missing entry");
+ assert_eq!(entry.meta.name, max_name);
+ assert_eq!(
+ library.load(EntryId(0)).expect("load failed"),
+ b"payload",
+ "payload mismatch"
+ );
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_lzss_large_payload_over_4k_roundtrip() {
+ let plain: Vec<u8> = (0..10_000u32).map(|v| (v % 251) as u8).collect();
+ let entries = vec![
+ SyntheticRsliEntry {
+ name: "LZSS4K".to_string(),
+ method_raw: 0x040,
+ plain: plain.clone(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "XLZS4K".to_string(),
+ method_raw: 0x060,
+ plain: plain.clone(),
+ declared_packed_size: None,
+ },
+ ];
+ let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default());
+ let path = write_temp_file("rsli-lzss-4k", &bytes);
+
+ let library = Library::open_path(&path).expect("open large-lzss rsli failed");
+ assert_eq!(library.entry_count(), entries.len());
+
+ for entry in &entries {
+ let id = library
+ .find(&entry.name)
+ .unwrap_or_else(|| panic!("find failed for {}", entry.name));
+ let loaded = library
+ .load(id)
+ .unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name));
+ assert_eq!(loaded, plain, "payload mismatch for {}", entry.name);
+ }
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_find_falls_back_when_sort_table_corrupted_in_memory() {
+ let entries = vec![
+ SyntheticRsliEntry {
+ name: "AAA".to_string(),
+ method_raw: 0x000,
+ plain: b"a".to_vec(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "BBB".to_string(),
+ method_raw: 0x000,
+ plain: b"b".to_vec(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "CCC".to_string(),
+ method_raw: 0x000,
+ plain: b"c".to_vec(),
+ declared_packed_size: None,
+ },
+ ];
+ let bytes = build_rsli_bytes(
+ &entries,
+ &RsliBuildOptions {
+ presorted: true,
+ ..RsliBuildOptions::default()
+ },
+ );
+ let path = write_temp_file("rsli-find-fallback", &bytes);
+
+ let mut library = Library::open_path(&path).expect("open synthetic rsli failed");
+ library.entries[1].sort_to_original = -1;
+
+ assert_eq!(library.find("AAA"), Some(EntryId(0)));
+ assert_eq!(library.find("bbb"), Some(EntryId(1)));
+ assert_eq!(library.find("CcC"), Some(EntryId(2)));
+ assert_eq!(library.find("missing"), None);
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_deflate_method_rejects_zlib_wrapped_stream() {
+ let plain = b"payload".to_vec();
+ let zlib_payload = deflate_zlib(&plain);
+ let entries = vec![SyntheticRsliEntry {
+ name: "ZLIB".to_string(),
+ method_raw: 0x100,
+ plain,
+ declared_packed_size: Some(
+ u32::try_from(zlib_payload.len()).expect("zlib payload size overflow"),
+ ),
+ }];
+ let mut bytes = build_rsli_bytes(
+ &entries,
+ &RsliBuildOptions {
+ presorted: true,
+ ..RsliBuildOptions::default()
+ },
+ );
+
+ let table_end = 32 + entries.len() * 32;
+ let data_offset = table_end;
+ let data_end = data_offset + zlib_payload.len();
+ if bytes.len() < data_end {
+ bytes.resize(data_end, 0);
+ }
+ bytes[data_offset..data_end].copy_from_slice(&zlib_payload);
+
+ let path = write_temp_file("rsli-zlib-reject", &bytes);
+ let library = Library::open_path(&path).expect("open zlib-wrapped rsli failed");
+ match library.load(EntryId(0)) {
+ Err(Error::DecompressionFailed(reason)) => {
+ assert_eq!(reason, "deflate");
+ }
+ other => panic!("expected deflate decompression error, got {other:?}"),
+ }
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_lzss_huffman_reports_unexpected_eof() {
+ let entries = vec![SyntheticRsliEntry {
+ name: "TRUNC".to_string(),
+ method_raw: 0x080,
+ plain: b"this payload is long enough".to_vec(),
+ declared_packed_size: None,
+ }];
+ let mut bytes = build_rsli_bytes(
+ &entries,
+ &RsliBuildOptions {
+ presorted: true,
+ ..RsliBuildOptions::default()
+ },
+ );
+
+ let seed = read_u32_le(&bytes, 20);
+ let mut table_plain = xor_stream(&bytes[32..64], (seed & 0xFFFF) as u16);
+ let original_packed_size = u32::from_le_bytes([
+ table_plain[28],
+ table_plain[29],
+ table_plain[30],
+ table_plain[31],
+ ]);
+ assert!(
+ original_packed_size > 4,
+ "packed payload too small for truncation"
+ );
+ let truncated_size = original_packed_size - 3;
+ table_plain[28..32].copy_from_slice(&truncated_size.to_le_bytes());
+ let encrypted_table = xor_stream(&table_plain, (seed & 0xFFFF) as u16);
+ bytes[32..64].copy_from_slice(&encrypted_table);
+
+ let path = write_temp_file("rsli-lzh-truncated", &bytes);
+ let library = Library::open_path(&path).expect("open truncated lzh rsli failed");
+ match library.load(EntryId(0)) {
+ Err(Error::DecompressionFailed(reason)) => {
+ assert_eq!(reason, "lzss-huffman: unexpected EOF");
+ }
+ other => panic!("expected lzss-huffman EOF error, got {other:?}"),
+ }
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_presorted_flag_requires_permutation() {
+ let entries = vec![
+ SyntheticRsliEntry {
+ name: "AAA".to_string(),
+ method_raw: 0x000,
+ plain: b"a".to_vec(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "BBB".to_string(),
+ method_raw: 0x000,
+ plain: b"b".to_vec(),
+ declared_packed_size: None,
+ },
+ ];
+ let mut bytes = build_rsli_bytes(
+ &entries,
+ &RsliBuildOptions {
+ presorted: true,
+ ..RsliBuildOptions::default()
+ },
+ );
+
+ let seed = read_u32_le(&bytes, 20);
+ let mut table_plain = xor_stream(&bytes[32..32 + entries.len() * 32], (seed & 0xFFFF) as u16);
+
+ // Corrupt sort_to_original: duplicate index 0, so the table is not a permutation.
+ table_plain[18..20].copy_from_slice(&0i16.to_le_bytes());
+ table_plain[50..52].copy_from_slice(&0i16.to_le_bytes());
+
+ let table_encrypted = xor_stream(&table_plain, (seed & 0xFFFF) as u16);
+ bytes[32..32 + table_encrypted.len()].copy_from_slice(&table_encrypted);
+
+ let path = write_temp_file("rsli-bad-presorted-perm", &bytes);
+ match Library::open_path(&path) {
+ Err(Error::CorruptEntryTable(message)) => {
+ assert!(
+ message.contains("permutation"),
+ "unexpected error message: {message}"
+ );
+ }
+ other => panic!("expected CorruptEntryTable for invalid permutation, got {other:?}"),
+ }
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_load_reports_correct_entry_id_on_range_failure() {
+ let entries = vec![
+ SyntheticRsliEntry {
+ name: "ONE".to_string(),
+ method_raw: 0x000,
+ plain: b"one".to_vec(),
+ declared_packed_size: None,
+ },
+ SyntheticRsliEntry {
+ name: "TWO".to_string(),
+ method_raw: 0x000,
+ plain: b"two".to_vec(),
+ declared_packed_size: None,
+ },
+ ];
+ let bytes = build_rsli_bytes(
+ &entries,
+ &RsliBuildOptions {
+ presorted: true,
+ ..RsliBuildOptions::default()
+ },
+ );
+ let path = write_temp_file("rsli-entry-id-error", &bytes);
+
+ let mut library = Library::open_path(&path).expect("open synthetic rsli failed");
+ library.entries[1].packed_size_available = usize::MAX;
+
+ match library.load(EntryId(1)) {
+ Err(Error::IntegerOverflow) => {}
+ other => panic!("expected IntegerOverflow, got {other:?}"),
+ }
+
+ library.entries[1].packed_size_available = library.bytes.len();
+ match library.load(EntryId(1)) {
+ Err(Error::EntryDataOutOfBounds { id, .. }) => assert_eq!(id, 1),
+ other => panic!("expected EntryDataOutOfBounds with id=1, got {other:?}"),
+ }
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_xorlzss_huffman_on_the_fly_roundtrip() {
+ let plain: Vec<u8> = (0..512u16).map(|i| b'A' + (i % 26) as u8).collect();
+ let entries = vec![SyntheticRsliEntry {
+ name: "XLZH_ONFLY".to_string(),
+ method_raw: 0x0A0,
+ plain: plain.clone(),
+ declared_packed_size: None,
+ }];
+
+ let bytes = build_rsli_bytes(
+ &entries,
+ &RsliBuildOptions {
+ seed: 0x0BAD_C0DE,
+ presorted: true,
+ overlay: 0,
+ add_ao_trailer: false,
+ },
+ );
+ let path = write_temp_file("rsli-xorlzh-onfly", &bytes);
+
+ let library = Library::open_path(&path).expect("open synthetic XLZH archive failed");
+ let id = library
+ .find("XLZH_ONFLY")
+ .expect("find XLZH_ONFLY entry failed");
+
+ let loaded = library.load(id).expect("load XLZH_ONFLY failed");
+ assert_eq!(loaded, plain);
+
+ let packed = library
+ .load_packed(id)
+ .expect("load_packed XLZH_ONFLY failed");
+ let unpacked = library.unpack(&packed).expect("unpack XLZH_ONFLY failed");
+ assert_eq!(unpacked, loaded);
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_synthetic_overlay_and_ao_trailer() {
+ let entries = vec![SyntheticRsliEntry {
+ name: "OVERLAY".to_string(),
+ method_raw: 0x040,
+ plain: b"overlay-data".to_vec(),
+ declared_packed_size: None,
+ }];
+
+ let bytes = build_rsli_bytes(
+ &entries,
+ &RsliBuildOptions {
+ seed: 0x4433_2211,
+ presorted: true,
+ overlay: 128,
+ add_ao_trailer: true,
+ },
+ );
+ let path = write_temp_file("rsli-overlay", &bytes);
+
+ let library = Library::open_path_with(
+ &path,
+ OpenOptions {
+ allow_ao_trailer: true,
+ allow_deflate_eof_plus_one: true,
+ },
+ )
+ .expect("open with AO trailer enabled failed");
+
+ let id = library.find("OVERLAY").expect("find overlay entry failed");
+ let payload = library.load(id).expect("load overlay entry failed");
+ assert_eq!(payload, b"overlay-data");
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_deflate_eof_plus_one_quirk() {
+ let plain = b"quirk deflate payload".to_vec();
+ let packed = deflate_raw(&plain);
+ let declared = u32::try_from(packed.len() + 1).expect("declared size overflow");
+
+ let entries = vec![SyntheticRsliEntry {
+ name: "QUIRK".to_string(),
+ method_raw: 0x100,
+ plain,
+ declared_packed_size: Some(declared),
+ }];
+ let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default());
+ let path = write_temp_file("rsli-deflate-quirk", &bytes);
+
+ let lib_ok = Library::open_path_with(
+ &path,
+ OpenOptions {
+ allow_ao_trailer: true,
+ allow_deflate_eof_plus_one: true,
+ },
+ )
+ .expect("open with EOF+1 quirk enabled failed");
+ let loaded = lib_ok
+ .load(lib_ok.find("QUIRK").expect("find quirk entry failed"))
+ .expect("load quirk entry failed");
+ assert_eq!(loaded, b"quirk deflate payload");
+
+ match Library::open_path_with(
+ &path,
+ OpenOptions {
+ allow_ao_trailer: true,
+ allow_deflate_eof_plus_one: false,
+ },
+ ) {
+ Err(Error::DeflateEofPlusOneQuirkRejected { id }) => assert_eq!(id, 0),
+ other => panic!("expected DeflateEofPlusOneQuirkRejected, got {other:?}"),
+ }
+
+ let _ = fs::remove_file(&path);
+}
+
+#[test]
+fn rsli_validation_error_cases() {
+ let valid = build_rsli_bytes(
+ &[SyntheticRsliEntry {
+ name: "BASE".to_string(),
+ method_raw: 0x000,
+ plain: b"abc".to_vec(),
+ declared_packed_size: None,
+ }],
+ &RsliBuildOptions::default(),
+ );
+
+ let mut bad_magic = valid.clone();
+ bad_magic[0..2].copy_from_slice(b"XX");
+ let path = write_temp_file("rsli-bad-magic", &bad_magic);
+ match Library::open_path(&path) {
+ Err(Error::InvalidMagic { .. }) => {}
+ other => panic!("expected InvalidMagic, got {other:?}"),
+ }
+ let _ = fs::remove_file(&path);
+
+ let mut bad_version = valid.clone();
+ bad_version[3] = 2;
+ let path = write_temp_file("rsli-bad-version", &bad_version);
+ match Library::open_path(&path) {
+ Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 2),
+ other => panic!("expected UnsupportedVersion, got {other:?}"),
+ }
+ let _ = fs::remove_file(&path);
+
+ let mut bad_count = valid.clone();
+ bad_count[4..6].copy_from_slice(&(-1_i16).to_le_bytes());
+ let path = write_temp_file("rsli-bad-count", &bad_count);
+ match Library::open_path(&path) {
+ Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1),
+ other => panic!("expected InvalidEntryCount, got {other:?}"),
+ }
+ let _ = fs::remove_file(&path);
+
+ let mut bad_table = valid.clone();
+ bad_table[4..6].copy_from_slice(&100_i16.to_le_bytes());
+ let path = write_temp_file("rsli-bad-table", &bad_table);
+ match Library::open_path(&path) {
+ Err(Error::EntryTableOutOfBounds { .. }) => {}
+ other => panic!("expected EntryTableOutOfBounds, got {other:?}"),
+ }
+ let _ = fs::remove_file(&path);
+
+ let mut unknown_method = build_rsli_bytes(
+ &[SyntheticRsliEntry {
+ name: "UNK".to_string(),
+ method_raw: 0x120,
+ plain: b"x".to_vec(),
+ declared_packed_size: None,
+ }],
+ &RsliBuildOptions::default(),
+ );
+ // Force truly unknown method by writing 0x1C0 mask bits.
+ let row = 32;
+ unknown_method[row + 16..row + 18].copy_from_slice(&(0x1C0_u16).to_le_bytes());
+ // Re-encrypt table with the same seed.
+ let seed = u32::from_le_bytes([
+ unknown_method[20],
+ unknown_method[21],
+ unknown_method[22],
+ unknown_method[23],
+ ]);
+ let mut plain_row = vec![0u8; 32];
+ plain_row.copy_from_slice(&unknown_method[32..64]);
+ plain_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16);
+ plain_row[16..18].copy_from_slice(&(0x1C0_u16).to_le_bytes());
+ let encrypted_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16);
+ unknown_method[32..64].copy_from_slice(&encrypted_row);
+
+ let path = write_temp_file("rsli-unknown-method", &unknown_method);
+ let lib = Library::open_path(&path).expect("open archive with unknown method failed");
+ match lib.load(EntryId(0)) {
+ Err(Error::UnsupportedMethod { raw }) => assert_eq!(raw, 0x1C0),
+ other => panic!("expected UnsupportedMethod, got {other:?}"),
+ }
+ let _ = fs::remove_file(&path);
+
+ let mut bad_packed = valid.clone();
+ bad_packed[32 + 28..32 + 32].copy_from_slice(&0xFFFF_FFF0_u32.to_le_bytes());
+ let path = write_temp_file("rsli-bad-packed", &bad_packed);
+ match Library::open_path(&path) {
+ Err(Error::PackedSizePastEof { .. }) => {}
+ other => panic!("expected PackedSizePastEof, got {other:?}"),
+ }
+ let _ = fs::remove_file(&path);
+
+ let mut with_bad_overlay = valid;
+ with_bad_overlay.extend_from_slice(b"AO");
+ with_bad_overlay.extend_from_slice(&0xFFFF_FFFF_u32.to_le_bytes());
+ let path = write_temp_file("rsli-bad-overlay", &with_bad_overlay);
+ match Library::open_path_with(
+ &path,
+ OpenOptions {
+ allow_ao_trailer: true,
+ allow_deflate_eof_plus_one: true,
+ },
+ ) {
+ Err(Error::MediaOverlayOutOfBounds { .. }) => {}
+ other => panic!("expected MediaOverlayOutOfBounds, got {other:?}"),
+ }
+ let _ = fs::remove_file(&path);
+}
diff --git a/docs/specs/ai.md b/docs/specs/ai.md
new file mode 100644
index 0000000..545c07b
--- /dev/null
+++ b/docs/specs/ai.md
@@ -0,0 +1,5 @@
+# AI system
+
+Документ описывает подсистему искусственного интеллекта: принятие решений, pathfinding и стратегическое поведение противников.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ai.dll`.
diff --git a/docs/specs/arealmap.md b/docs/specs/arealmap.md
new file mode 100644
index 0000000..cac2743
--- /dev/null
+++ b/docs/specs/arealmap.md
@@ -0,0 +1,5 @@
+# ArealMap
+
+Документ описывает формат и структуру карты мира: зоны/сектора, координаты, размещение объектов и связь с terrain и миссиями.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ArealMap.dll`.
diff --git a/docs/specs/behavior.md b/docs/specs/behavior.md
new file mode 100644
index 0000000..9ffd2dc
--- /dev/null
+++ b/docs/specs/behavior.md
@@ -0,0 +1,5 @@
+# Behavior system
+
+Документ описывает поведенческую логику юнитов: state machine/behavior-паттерны, взаимодействия и базовые правила боевого поведения.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Behavior.dll`.
diff --git a/docs/specs/control.md b/docs/specs/control.md
new file mode 100644
index 0000000..a2d3d44
--- /dev/null
+++ b/docs/specs/control.md
@@ -0,0 +1,5 @@
+# Control system
+
+Документ описывает подсистему управления: mapping ввода (клавиатура, мышь, геймпад), обработку событий и буферизацию команд.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Control.dll`.
diff --git a/docs/specs/fxid.md b/docs/specs/fxid.md
new file mode 100644
index 0000000..7dd1d4b
--- /dev/null
+++ b/docs/specs/fxid.md
@@ -0,0 +1,834 @@
+# FXID
+
+Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для:
+
+- 1:1 загрузки и исполнения в совместимом runtime;
+- построения валидатора payload;
+- создания lossless-конвертера (`binary -> IR -> binary`);
+- создания редактора с безопасным редактированием полей.
+
+Связанный контейнер: [NRes / RsLi](nres.md).
+
+---
+
+## 1. Источники и статус восстановления
+
+Спецификация восстановлена по:
+
+- `tmp/disassembler1/Effect.dll.c`;
+- `tmp/disassembler2/Effect.dll.asm`;
+- интеграционным вызовам из `tmp/disassembler1/Terrain.dll.c`;
+- проверке реальных архивов `testdata/nres`.
+
+Ключевые функции:
+
+- parser FXID: `Effect.dll!sub_10007650`;
+- runtime loop: `sub_10003D30(case 28)`, `sub_10006170`, `sub_10008120`, `sub_10007D10`;
+- alpha/time: `sub_10005C60`;
+- exports: `CreateFxManager`, `InitializeSettings`.
+
+Проверка по данным:
+
+- `923/923` FXID payload валидны в `testdata/nres`.
+
+---
+
+## 2. Контейнер и runtime API
+
+### 2.1. NRes entry
+
+FXID хранится как NRes-entry:
+
+- `type_id = 0x44495846` (`"FXID"`).
+
+Наблюдение по датасету (923 эффекта):
+
+- `attr1 = 0`, `attr2 = 0`, `attr3 = 1`.
+
+### 2.2. Export API `Effect.dll`
+
+Экспортируются:
+
+- `CreateFxManager(int a1, int a2, int owner)`;
+- `InitializeSettings()`.
+
+`CreateFxManager` создаёт manager-объект (`0xB8` байт), инициализирует через `sub_10003AE0`, возвращает интерфейсный указатель (`base + 4`).
+
+### 2.3. Интерфейс менеджера
+
+Рабочая vtable (`off_1001E478`):
+
+| Смещение | Функция | Назначение |
+|---|---|---|
+| +0x08 | `sub_10003D30` | Event dispatcher (`4/20/23/24/28`) |
+| +0x10 | `sub_10004320` | Открыть/закэшировать FX resource |
+| +0x14 | `sub_10004590` | Создать runtime instance |
+| +0x18 | `sub_10004780` | Удалить instance |
+| +0x1C | `sub_100047B0` | Установить time/interp mode |
+| +0x20 | `sub_100047D0` | Установить scale |
+| +0x24 | `sub_10004830` | Установить позицию |
+| +0x28 | `sub_10004930` | Установить matrix transform |
+| +0x2C | `sub_10004B00` | Restart/retime |
+| +0x38 | `sub_10004BA0` | Duration modifier |
+| +0x3C | `sub_10004BD0` | Start/Enable |
+| +0x40 | `sub_10004C10` | Stop/Disable |
+| +0x44 | `sub_10004C50` | Bind emitter/context |
+| +0x48 | `sub_10004D50` | Сброс frame flags |
+
+`Terrain.dll` использует `QueryInterface(id=19)` для получения рабочего интерфейса.
+
+---
+
+## 3. Бинарный формат FXID payload
+
+Все значения little-endian.
+
+### 3.1. Header (60 байт, `0x3C`)
+
+```c
+struct FxHeader60 {
+ uint32_t cmd_count; // 0x00
+ uint32_t time_mode; // 0x04
+ float duration_sec; // 0x08
+ float phase_jitter; // 0x0C
+ uint32_t flags; // 0x10
+ uint32_t settings_id; // 0x14
+ float rand_shift_x; // 0x18
+ float rand_shift_y; // 0x1C
+ float rand_shift_z; // 0x20
+ float pivot_x; // 0x24
+ float pivot_y; // 0x28
+ float pivot_z; // 0x2C
+ float scale_x; // 0x30
+ float scale_y; // 0x34
+ float scale_z; // 0x38
+};
+```
+
+Командный поток начинается строго с `offset = 0x3C`.
+
+### 3.2. Header-поля (подтвержденная семантика)
+
+- `cmd_count`: число команд (engine итерирует ровно столько шагов).
+- `time_mode`: базовый режим вычисления alpha/time (`sub_10005C60`).
+- `duration_sec`: в runtime -> `duration_ms = duration_sec * 1000`.
+- `phase_jitter`: используется при `flags & 0x1`.
+- `flags`: runtime-gating/alpha/visibility (см. ниже).
+- `settings_id`: в `sub_1000EC40` используется `settings_id & 0xFF`.
+- `rand_shift_*`: используется при `flags & 0x8`.
+- `pivot_*`: используется в ветках `sub_10007D10`.
+- `scale_*`: копируется в runtime scale и влияет на матрицы.
+
+### 3.3. `flags` (битовая карта)
+
+| Бит | Маска | Наблюдаемое поведение |
+|---|---:|---|
+| 0 | `0x0001` | Random phase jitter (`phase_jitter`) |
+| 3 | `0x0008` | Random positional shift (`rand_shift_*`) |
+| 4 | `0x0010` | Visibility/occlusion ветки |
+| 5 | `0x0020` | Triangular remap в `sub_10005C60` |
+| 6 | `0x0040` | Инверсия начального active-state |
+| 7 | `0x0080` | Day/night filter (ветка A) |
+| 8 | `0x0100` | Day/night filter (ветка B, инверсия) |
+| 9 | `0x0200` | Alpha *= normalized lifetime |
+| 10 | `0x0400` | Установка manager bit1 (`+0xA0`) |
+| 11 | `0x0800` | Изменение gating в `sub_10007D10` |
+| 12 | `0x1000` | Установка manager-state bit `0x10` |
+
+Нерасшифрованные биты должны сохраняться 1:1.
+
+### 3.4. `time_mode` (`0..17`)
+
+Обозначения (`sub_10005C60`):
+
+- `t0 = instance.start_ms`, `t1 = instance.end_ms`;
+- `tn = (now_ms - t0) / (t1 - t0)`;
+- `prev = instance.cached_alpha` (`v4+52` в дизассембле).
+
+Режимы:
+
+- `0`: constant (`instance.alpha_const`, поле `v4+40`);
+- `1`: `tn`;
+- `2`: `fract(tn)`;
+- `3`: `1 - tn`;
+- `4`: external value из queue/world API (manager `+36`, id из `this+104[a2]`);
+- `5`: `|param33.xyz| / |param17.vecA.xyz|`;
+- `6`: `param33.x / param17.vecA.x`;
+- `7`: `param33.y / param17.vecA.y`;
+- `8`: `param33.z / param17.vecA.z`;
+- `9`: `|param36.xyz| / |param17.vecB.xyz|`;
+- `10`: `param36.x / param17.vecB.x`;
+- `11`: `param36.y / param17.vecB.y`;
+- `12`: `param36.z / param17.vecB.z`;
+- `13`: `1 - external_resource_value`;
+- `14`: `1 - queue_param(49)`;
+- `15`: `max(norm(param33/vecA), norm(param36/vecB))`;
+- `16`: external (`mode 4`) с нижним clamp к `prev` (`0` не зажимается);
+- `17`: external (`mode 4`) с верхним clamp к `prev` (`1` не зажимается).
+
+Post-обработка после mode:
+
+- если `flags & 0x200`: `alpha *= tn`;
+- если `flags & 0x20`: triangular remap (`alpha = (alpha < 0.5 ? alpha : 1-alpha) * 2`).
+
+---
+
+## 4. Командный поток
+
+### 4.1. Общий формат команды
+
+Каждая команда:
+
+- `uint32 cmd_word`;
+- далее body фиксированного размера по opcode.
+
+`cmd_word`:
+
+- `opcode = cmd_word & 0xFF`;
+- `enabled = (cmd_word >> 8) & 1`;
+- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1.
+
+Выравнивания между командами нет.
+
+### 4.2. Размеры
+
+| Opcode | Размер записи |
+|---:|---:|
+| 1 | 224 |
+| 2 | 148 |
+| 3 | 200 |
+| 4 | 204 |
+| 5 | 112 |
+| 6 | 4 |
+| 7 | 208 |
+| 8 | 248 |
+| 9 | 208 |
+| 10 | 208 |
+
+### 4.3. Opcode -> runtime-класс (vtable)
+
+| Opcode | `new(size)` | vtable |
+|---:|---:|---|
+| 1 | `0xF0` | `off_1001E78C` |
+| 2 | `0xA0` | `off_1001F048` |
+| 3 | `0xFC` | `off_1001E770` |
+| 4 | `0x104` | `off_1001E754` |
+| 5 | `0x54` | `off_1001E360` |
+| 6 | `0x1C` | `off_1001E738` |
+| 7 | `0x48` | `off_1001E228` |
+| 8 | `0xAC` | `off_1001E71C` |
+| 9 | `0x100` | `off_1001E700` |
+| 10 | `0x48` | `off_1001E24C` |
+
+### 4.4. Общий вызовной контракт команды
+
+После создания команды (`sub_10007650`):
+
+1. `cmd->enabled = cmd_word.bit8`.
+2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`).
+3. команда добавляется в список инстанса.
+
+В runtime cycle:
+
+- `vfunc +8`: update/compute (bool);
+- `vfunc +12`: emission/render callback;
+- `vfunc +20`: toggle active;
+- `vfunc +16`/`+24`: служебные функции (зависят от opcode).
+
+---
+
+## 5. Загрузка FXID (engine-accurate)
+
+`sub_10007650`:
+
+```c
+void FxLoad(FxInstance* fx, uint8_t* payload) {
+ FxHeader60* h = (FxHeader60*)payload;
+
+ fx->raw_header = h;
+ fx->mode = h->time_mode;
+ fx->end_ms = fx->start_ms + h->duration_sec * 1000.0f;
+ fx->scale = {h->scale_x, h->scale_y, h->scale_z};
+ fx->active_default = ((h->flags & 0x40) == 0);
+
+ uint8_t* ptr = payload + 0x3C;
+ for (uint32_t i = 0; i < h->cmd_count; ++i) {
+ uint32_t w = *(uint32_t*)ptr;
+ uint8_t op = (uint8_t)(w & 0xFF);
+
+ Command* cmd = CreateByOpcode(op, ptr); // может вернуть null
+ if (cmd) {
+ cmd->enabled = (w >> 8) & 1;
+
+ if (h->flags & 0x400) fx->manager_flags |= 0x0100;
+ if ((h->flags & 0x400) || cmd->enabled) fx->manager_flags |= 0x0010;
+
+ cmd->Init(fx->queue, fx);
+ fx->commands.push_back(cmd);
+ }
+
+ ptr += size_by_opcode(op); // без bounds checks в оригинале
+ }
+}
+```
+
+Критичные edge-case оригинала:
+
+- bounds checks отсутствуют;
+- при unknown opcode `ptr` не двигается (`advance = 0`);
+- при `new == null` команда пропускается, но `ptr` двигается.
+
+Фактический `advance` в `sub_10007650` задан hardcoded в DWORD:
+
+- `op1:+56`, `op2:+37`, `op3:+50`, `op4:+51`, `op5:+28`,
+- `op6:+1`, `op7:+52`, `op8:+62`, `op9:+52`, `op10:+52`,
+- `default:+0`.
+
+---
+
+## 6. Runtime lifecycle
+
+- `sub_10007470`: ctor instance.
+- `sub_10003D30(case 28)`: per-frame update manager.
+- `sub_10006170`: gate + alpha/time + command updates.
+- `sub_10008120` / `sub_10007D10`: update/render branches.
+- Start/Stop: `sub_10004BD0` / `sub_10004C10`.
+
+Event-codes `sub_10003D30`:
+
+- `4`: bootstrap/time init;
+- `20`: range-removal + index repair;
+- `23`: set manager bit0;
+- `24`: clear manager bit0;
+- `28`: main tick.
+
+---
+
+## 7. Общий тип `ResourceRef64`
+
+Для opcode `2/3/4/5/7/8/9/10` присутствует ссылка вида:
+
+```c
+struct ResourceRef64 {
+ char archive[32]; // null-terminated ASCII, case-insensitive compare
+ char name[32]; // null-terminated ASCII
+};
+```
+
+Поведение loader'а:
+
+- оба имени обязаны быть непустыми;
+- кэширование по `(_strcmpi archive, _strcmpi name)`;
+- загрузка/резолв через manager resource API.
+
+Наблюдение по данным:
+
+- для `opcode 2`: обычно `sounds.lib` + `*.wav`;
+- для остальных: обычно `material.lib` + material name.
+
+---
+
+## 8. Полная карта body по opcode (field-level)
+
+Смещения указаны от начала команды (включая `cmd_word`).
+
+### 8.1. Opcode 1 (`off_1001E78C`, size=224)
+
+Основные методы:
+
+- init: `sub_1000F4B0`;
+- update: `sub_1000F6E0`;
+- emit: `nullsub_2`;
+- toggle: `sub_1000F490`.
+
+```c
+struct FxCmd01 {
+ uint32_t word; // +0
+ uint32_t mode; // +4 (enum, см. ниже)
+ float t_start; // +8
+ float t_end; // +12
+
+ float p0_min[3]; // +16..24
+ float p0_max[3]; // +28..36
+
+ float p1_min[3]; // +40..48
+ float p1_max[3]; // +52..60
+
+ float q0_min[4]; // +64..76
+ float q0_max[4]; // +80..92
+
+ float q0_rand_span[4]; // +96..108 (все 4 читаются в sub_1000F6E0)
+
+ float scalar_min; // +112
+ float scalar_max; // +116
+ float scalar_rand_amp; // +120
+
+ float color_rgb[3]; // +124..132 (вызов manager+16)
+
+ float opaque_tail6[6]; // +136..156 (сохранять 1:1; в датасете почти всегда 0)
+
+ char opt_archive[32]; // +160..191 (редко, напр. "material.lib")
+ char opt_name[32]; // +192..223 (редко, напр. "light_w")
+};
+```
+
+Замечания по полям op1:
+
+- `+108` не резерв: участвует в random-выборке как 4-я компонента блока `+96..108`;
+- `+136..156` не читается vtable-методами класса `off_1001E78C` в `Effect.dll` (init/update/toggle/accessor), но должно сохраняться 1:1;
+- редкий кейс с ненулевыми `+136..156` и строками `+160/+192` зафиксирован в `effects.rlb:r_lightray_w`.
+
+`mode` (`+4`) -> параметры вызова manager (`sub_1000F4B0`):
+
+- `1 -> create_kind=1, flags=0x80000000`;
+- `2/5 -> create_kind=1, flags=0x00000000`;
+- `3 -> create_kind=3, flags=0x00000000`;
+- `4 -> create_kind=4, flags=0x00000000`;
+- `6 -> create_kind=1, flags=0xA0000000`;
+- `7 -> create_kind=1, flags=0x20000000`.
+
+### 8.2. Opcode 2 (`off_1001F048`, size=148)
+
+Основные методы:
+
+- init: `sub_10012D10`;
+- update: `sub_10012EB0`;
+- emit: `nullsub_2`;
+- toggle: `sub_10013170`.
+
+```c
+struct FxCmd02 {
+ uint32_t word; // +0
+ uint32_t mode; // +4 (0..3; влияет на sub_100065A0 mapping)
+ float t_start; // +8
+ float t_end; // +12
+
+ float a_min[3]; // +16..24
+ float a_max[3]; // +28..36
+
+ float b_min[3]; // +40..48
+ float b_max[3]; // +52..60
+
+ float c0_base; // +64
+ float c1_base; // +68
+ float c2_base; // +72
+ float c2_max; // +76
+
+ uint32_t param_910; // +80 (передаётся в manager cmd=910)
+
+ ResourceRef64 ref; // +84..147 (обычно sounds.lib + wav)
+};
+```
+
+`mode` -> внутренний map в `sub_100065A0`:
+
+- `0 -> 0`, `1 -> 512`, `2 -> 2`, `3 -> 514`.
+
+### 8.3. Opcode 3 (`off_1001E770`, size=200)
+
+Методы:
+
+- init: `sub_100103B0`;
+- update: `sub_100105F0`;
+- emit: `sub_100106C0`.
+
+```c
+struct FxCmd03 {
+ uint32_t word; // +0
+ uint32_t mode; // +4
+
+ float alpha_source; // +8 (>=0: norm time, <0: global time)
+ float alpha_pow_a; // +12
+ float alpha_pow_b; // +16
+
+ float out_min; // +20
+ float out_max; // +24
+ float out_pow; // +28
+
+ float active_t0; // +32
+ float active_t1; // +36
+
+ float v0_min[3]; // +40..48
+ float v0_max[3]; // +52..60
+
+ float pow0[3]; // +64..72
+
+ float v1_min[3]; // +76..84
+ float v1_max[3]; // +88..96
+
+ float v2_min[3]; // +100..108
+ float v2_max[3]; // +112..120
+
+ float pow1[3]; // +124..132
+
+ ResourceRef64 ref; // +136..199
+};
+```
+
+### 8.4. Opcode 4 (`off_1001E754`, size=204)
+
+Layout как opcode 3 + последний коэффициент:
+
+```c
+struct FxCmd04 {
+ FxCmd03 base; // +0..199
+ float dist_norm_inv_base; // +200 (используется в sub_100108C0/100109B0)
+};
+```
+
+`sub_100108C0`: `obj->inv = 1.0 / raw[200]`.
+
+### 8.5. Opcode 5 (`off_1001E360`, size=112)
+
+Методы:
+
+- init: `sub_100028A0`;
+- update: `sub_10002A20`;
+- emit: `sub_10002BE0`;
+- context update: `sub_10003070`.
+
+```c
+struct FxCmd05 {
+ uint32_t word; // +0
+ uint32_t mode; // +4 (в данных обычно 1)
+ uint32_t unused_08; // +8 (в текущем коде opcode5 не читается)
+ uint32_t unused_0C; // +12 (в текущем коде opcode5 не читается)
+
+ float active_t0; // +16
+ uint32_t max_segments; // +20
+ float active_t1_min; // +24
+ float active_t1_max; // +28
+
+ float step_norm; // +32
+ float segment_len; // +36
+ float alpha_source; // +40 (>=0 norm, <0 random)
+ float alpha_pow; // +44
+
+ ResourceRef64 ref; // +48..111
+};
+```
+
+### 8.6. Opcode 6 (`off_1001E738`, size=4)
+
+Только `cmd_word`:
+
+```c
+struct FxCmd06 {
+ uint32_t word; // +0
+};
+```
+
+`init/update/emit` фактически no-op (`sub_100030B0` возвращает `0`).
+
+### 8.7. Opcode 7 (`off_1001E228`, size=208)
+
+Методы:
+
+- init: `sub_10001720`;
+- update: `sub_10001230`;
+- emit: `sub_10001300`;
+- element accessor: `sub_10002780`.
+
+```c
+struct FxCmd07 {
+ uint32_t word; // +0
+ uint32_t mode; // +4
+
+ float eval_min; // +8
+ float eval_max; // +12
+ float eval_pow; // +16
+
+ float active_t0; // +20
+ float active_t1; // +24
+
+ float phase_span; // +28
+ float phase_rate; // +32
+
+ uint32_t count_a; // +36
+ uint32_t count_b; // +40
+
+ float set0_min[3]; // +44..52
+ float set0_max[3]; // +56..64
+ float set0_rand[3]; // +68..76
+ float set0_pow[3]; // +80..88
+
+ float set1_min[3]; // +92..100
+ float set1_max[3]; // +104..112
+ float set1_rand[3]; // +116..124
+ float set1_pow[3]; // +128..136
+
+ float gravity_or_drag_k; // +140
+
+ ResourceRef64 ref; // +144..207
+};
+```
+
+### 8.8. Opcode 8 (`off_1001E71C`, size=248)
+
+Методы:
+
+- init: `sub_10011230`;
+- update: `sub_100115C0`;
+- emit: `sub_10012030`.
+
+```c
+struct FxCmd08 {
+ uint32_t word; // +0
+ uint32_t mode; // +4
+
+ float eval_t0; // +8
+ float eval_t1; // +12
+
+ float gate_t0; // +16
+ float gate_t1; // +20
+
+ float period_min; // +24
+ float period_max; // +28
+ float phase_pow; // +32
+
+ uint32_t slots; // +36
+
+ float set0_min[3]; // +40..48
+ float set0_max[3]; // +52..60
+ float set0_rand[3]; // +64..72
+
+ float set1_min[3]; // +76..84
+ float set1_max[3]; // +88..96
+ float set1_rand[3]; // +100..108
+
+ float set2_rand[3]; // +112..120
+ float set2_pow[3]; // +124..132
+
+ float rmax_set0[3]; // +136..144 (bound/radius calc)
+ float rmax_set1[3]; // +148..156 (bound/radius calc)
+ float rmax_set2[3]; // +160..168 (bound/radius calc)
+
+ float render_pow[3]; // +172..180
+
+ ResourceRef64 ref; // +184..247
+};
+```
+
+### 8.9. Opcode 9 (`off_1001E700`, size=208)
+
+Layout как opcode 3 с двумя final-полями:
+
+```c
+struct FxCmd09 {
+ FxCmd03 base; // +0..199
+ uint32_t render_kind; // +200 (0/1/2 -> 3/5/6 in sub_100138C0)
+ uint32_t render_flag; // +204 (0 -> добавляет bit 0x08000000)
+};
+```
+
+Методы:
+
+- init/update как у opcode 3 (`sub_100103B0`, `sub_100105F0`);
+- emit: `sub_100138C0` -> формирует код рендера и вызывает `sub_100106C0`.
+
+### 8.10. Opcode 10 (`off_1001E24C`, size=208)
+
+Body-layout совпадает с opcode 7 (`FxCmd07`), но другой runtime класс.
+
+- init: `sub_10001A40`;
+- update: `sub_10001230`;
+- emit: `sub_10001300`;
+- element accessor: `sub_10002830`.
+
+Наблюдение по данным:
+
+- `mode` (`+4`) встречается как `16` или `32`.
+
+---
+
+## 9. Runtime-специфика по opcode (важные отличия)
+
+### 9.1. Opcode 1
+
+- создаёт handle через manager (`vfunc +48`);
+- задаёт флаги handle (`vfunc +52`);
+- в update пушит:
+ - позиционный вектор 1 (`vfunc +32`),
+ - позиционный вектор 2 (`vfunc +36`),
+ - 4-компонентный параметр (`vfunc +12`),
+ - scalar+rgb (`vfunc +16`).
+
+### 9.2. Opcode 2
+
+- `ResourceRef64` резолвится через `sub_100065A0` (режим-зависимая загрузка, в данных обычно `sounds.lib`/`wav`);
+- использует manager-команду id `910`.
+
+### 9.3. Opcode 3/4/9
+
+- общий core-emitter в `sub_100106C0`;
+- opcode 4 добавляет нормализацию по `raw+200`;
+- opcode 9 добавляет переключение render-кода (`raw+200/+204`).
+
+### 9.4. Opcode 5
+
+- держит массив внутренних сегментов (`332` байта/элемент, ctor `sub_100099F0`);
+- context-matrix приходит через `vfunc +24` (`sub_10003070`).
+
+### 9.5. Opcode 7/10
+
+- общий update/render (`sub_10001230`, `sub_10001300`);
+- разные внутренние element-форматы:
+ - opcode 7: `204` байта/элемент (`sub_100092D0`),
+ - opcode 10: `492` байта/элемент (`sub_1000BB40`).
+
+### 9.6. Opcode 8
+
+- самый тяжёлый спавнер, хранит ring/slot-структуры;
+- emit фаза (`sub_10012030`) использует `mode`, `render_pow`, per-slot transforms.
+
+---
+
+## 10. Спецификация инструментов
+
+### 10.1. Reader (strict)
+
+Алгоритм:
+
+1. `len(payload) >= 60`;
+2. читаем `cmd_count`;
+3. `ptr = 0x3C`;
+4. цикл `cmd_count`:
+ - `ptr + 4 <= len`;
+ - `opcode in 1..10`;
+ - `ptr + size(opcode) <= len`;
+ - `ptr += size(opcode)`;
+5. strict-tail: `ptr == len(payload)`.
+
+### 10.2. Reader (engine-compatible)
+
+Legacy-режим (опасный, только при необходимости byte-совместимости):
+
+- без bounds-check;
+- tolerant к unknown opcode как в оригинале.
+
+### 10.3. Writer (canonical)
+
+1. записать `FxHeader60`;
+2. `cmd_count = commands.len()`;
+3. команды сериализуются как `cmd_word + fixed-body`;
+4. размер payload: `0x3C + sum(size(op_i))`;
+5. без хвостовых байт.
+
+### 10.4. Editor (lossless)
+
+Правила:
+
+- все поля little-endian;
+- не менять fixed size команды;
+- не добавлять padding;
+- сохранять неизвестные биты (`cmd_word`, `header.flags`) copy-through;
+- для частично-известных полей поддерживать режим `opaque`.
+
+### 10.5. IR/JSON (рекомендуемая форма)
+
+```json
+{
+ "header": {
+ "time_mode": 1,
+ "duration_sec": 2.5,
+ "phase_jitter": 0.2,
+ "flags": 22,
+ "settings_id": 785,
+ "rand_shift": [0.0, 0.0, 0.0],
+ "pivot": [0.0, 0.0, 0.0],
+ "scale": [1.0, 1.0, 1.0]
+ },
+ "commands": [
+ {
+ "opcode": 8,
+ "word_raw": 264,
+ "enabled": 1,
+ "fields": {
+ "mode": 1065353216,
+ "eval_t0": 0.0,
+ "eval_t1": 1.0,
+ "resource": {"archive": "material.lib", "name": "fire_smoke"}
+ },
+ "opaque_extra_hex": "..."
+ }
+ ]
+}
+```
+
+---
+
+## 11. Проверка на реальных данных
+
+`testdata/nres`:
+
+- FXID payload: `923`;
+- валидация parser'а: `923/923 valid`.
+
+Распределение opcode:
+
+- `1: 618`
+- `2: 517`
+- `3: 1545`
+- `4: 202`
+- `5: 31`
+- `6: 0` (в датасете не встречен, но поддержан)
+- `7: 1161`
+- `8: 237`
+- `9: 266`
+- `10: 160`
+
+Подтверждённые `ResourceRef64` оффсеты:
+
+- op2 `+84`, op3/4/9 `+136`, op5 `+48`, op7/10 `+144`, op8 `+184`.
+
+Для op1 найден редкий расширенный хвост (`+160/+192`) в `effects.rlb:r_lightray_w`:
+
+- `material.lib` / `light_w`.
+
+---
+
+## 12. Практический чек-лист 1:1
+
+Для runtime-порта:
+
+- реализовать `FxHeader60` и parser `sub_10007650`;
+- реализовать opcode-классы с методами как в vtable;
+- учитывать start/stop/restart контракт manager API;
+- воспроизвести `sub_10005C60` + post-flags (`0x20`, `0x200`);
+- воспроизвести event loop `sub_10003D30(case 28)`.
+
+Для toolchain:
+
+- strict validator по разделу 10.1;
+- canonical writer по разделу 10.3;
+- field-aware editor + opaque fallback для неизвестных зон.
+
+---
+
+## 13. Что считать «полной» совместимостью
+
+Практический критерий завершения:
+
+1. Парсер и writer дают byte-identical round-trip для всех 923 FXID.
+2. Runtime-порт выдаёт совпадающие state transitions на одинаковом `dt/seed` (по ключевым полям instance + command state).
+3. Все opcode `1..10` поддержаны (включая `6`, даже если отсутствует в текущем датасете).
+4. `ResourceRef64` и mode-ветки (`op1`, `op2`, `op9`) совпадают с оригиналом.
+
+Эта страница покрывает весь наблюдаемый контракт формата/рантайма и полную карту body-полей по всем opcode.
+
+---
+
+## 14. Что осталось до «абсолютных 100%»
+
+Для практического 1:1 (парсер/writer/runtime на известном контенте) покрытие уже достаточно.
+Для «абсолютных 100%» на любых входах и во всех краевых режимах остаются 3 пункта:
+
+1. FP-детерминизм: оригинал опирается на x87-style вычисления; SSE/fast-math могут давать расхождения в alpha/таймингах.
+2. RNG parity: используется `sub_10002220` (16-bit генератор) и глобальные seed-состояния; для bit-exact воспроизведения нужны контрольные трассы оригинала.
+3. Редкие ветки данных: в текущем датасете нет opcode `6`, и почти не встречаются хвосты op1 (`+136..223`); для исчерпывающей валидации нужны дополнительные FXID-образцы.
+
+Что нужно собрать, чтобы закрыть это полностью:
+
+- frame-by-frame dump из оригинального runtime (alpha, manager flags, per-command state);
+- контрольные прогоны при фиксированном `dt` и seed;
+- минимум по одному ресурсу на каждую редкую ветку (`op6`, op1-tail с ненулевыми `+136..223`).
diff --git a/docs/specs/materials-texm.md b/docs/specs/materials-texm.md
new file mode 100644
index 0000000..baa80ae
--- /dev/null
+++ b/docs/specs/materials-texm.md
@@ -0,0 +1,874 @@
+# Materials, WEAR, MAT0 и Texm
+
+Документ описывает материальную подсистему движка (World3D/Ngi32) на уровне, достаточном для:
+
+- реализации runtime 1:1;
+- создания инструментов чтения/валидации;
+- создания инструментов конвертации и редактирования с lossless round-trip.
+
+Источник: дизассемблированные `tmp/disassembler1/*.c` и `tmp/disassembler2/*.asm`, плюс проверка на `tmp/gamedata`.
+
+---
+
+## 1. Идентификаторы и сущности
+
+| Сущность | ID (LE uint32) | ASCII | Где используется |
+|---|---:|---|---|
+| Material resource | `0x3054414D` | `MAT0` | `Material.lib` |
+| Wear resource | `0x52414557` | `WEAR` | `.wea` записи в world/mission `.rlb` |
+| Texture resource | `0x6D786554` | `Texm` | `Textures.lib`, `lightmap.lib`, другие `.lib/.rlb` |
+| Atlas tail chunk | `0x65676150` | `Page` | хвост payload `Texm` |
+
+Дополнительно: палитры загружаются отдельным путём (через `SetPalettesLib` + `sub_10002B40`) и не являются `Texm`.
+
+---
+
+## 2. Архитектура подсистемы
+
+### 2.1 Экспортируемые точки входа (World3D)
+
+- `LoadMatManager`
+- `SetPalettesLib`
+- `SetTexturesLib`
+- `SetMaterialLib`
+- `SetLightMapLib`
+- `SetGameTime`
+- `UnloadAllTextures`
+
+`Set*Lib` просто копируют строки путей в глобальные буферы; валидации пути нет.
+
+### 2.2 Дефолтные библиотеки (из `iron3d.dll`)
+
+- `Textures.lib`
+- `Material.lib`
+- `LightMap.lib`
+- `palettes.lib` (строка собирается как `'p' + "alettes.lib"`)
+
+### 2.3 Ключевые runtime-хранилища
+
+1. Менеджер материалов (`LoadMatManager`) — объект `0x470` байт.
+2. Кэш текстурных объектов.
+3. Кэш lightmap-объектов.
+4. Банк загруженных палитр.
+5. Глобальный пул определений материалов (`MAT0`).
+
+---
+
+## 3. Layout `MatManager` (0x470)
+
+Объект содержит 70 таблиц wear/lightmaps (не 140).
+
+```c
+// int-индексы относительно this (DWORD*), размер 284 DWORD = 0x470
+// [0] vtable
+// [1] callback iface
+// [2] callback data
+// [3..72] wearTablePtrs[70] // ptr на массив по 8 байт
+// [73..142] wearCounts[70]
+// [143] tableCount
+// [144..213] lightmapTablePtrs[70] // ptr на массив по 4 байта
+// [214..283] lightmapCounts[70]
+```
+
+### 3.1 Vtable методов (`off_100209E4`)
+
+| Индекс | Функция | Назначение |
+|---:|---|---|
+| 0 | `loc_10002CE0` | служебный/RTTI-заглушка |
+| 1 | `sub_10002D10` | деструктор + освобождение таблиц |
+| 2 | `PreLoadAllTextures` | экспорт, но фактически `retn 4` (заглушка) |
+| 3 | `sub_100031F0` | получить материал-фазу по `gameTime` |
+| 4 | `sub_10003AE0` | сбросить startTime записи wear к `SetGameTime()` |
+| 5 | `sub_10003680` | получить материал-фазу по нормализованному `t` |
+| 6 | `sub_10003B10` | загрузить wear/lightmaps (файл/ресурс) |
+| 7 | `sub_10003F80` | загрузить wear/lightmaps из буфера |
+| 8 | `sub_100031A0` | получить указатель на lightmap texture object |
+| 9 | `sub_10003AB0` | получить runtime-метаданные материала |
+| 10 | `sub_100031D0` | получить `wearCount` для таблицы |
+
+### 3.2 Кодирование material-handle
+
+`uint32 handle = (tableIndex << 16) | wearIndex`.
+
+- `HIWORD(handle)` -> индекс таблицы `0..69`
+- `LOWORD(handle)` -> индекс материала в wear-таблице
+
+---
+
+## 4. Глобальные кэши и их ёмкость
+
+Ёмкости подтверждены границами циклов/адресов в дизассемблере.
+
+### 4.1 Кэш текстур (`dword_1014E910`...)
+
+- Размер слота: `5 DWORD` (20 байт)
+- Ёмкость: `777`
+
+```c
+struct TextureSlot {
+ int32_t resIndex; // +0 индекс записи в NRes (не hash), -1 = свободно
+ void* textureObject; // +4
+ int32_t refCount; // +8
+ uint32_t lastZeroRefTime;// +12 время, когда refCount стал 0
+ uint32_t loadFlags; // +16 флаги загрузки
+};
+```
+
+`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC.
+
+### 4.2 Кэш lightmaps (`dword_10029C98`...)
+
+- Тот же layout `5 DWORD`
+- Ёмкость: `100`
+
+Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается.
+
+### 4.3 Пул материалов (`dword_100669F0`...)
+
+- Шаг: `92 DWORD` (`368` байт)
+- Ёмкость: `700`
+
+Фиксированные поля на шаг `i*92`:
+
+| DWORD offset | Byte offset | Поле |
+|---:|---:|---|
+| 0 | 0 | `nameResIndex` (`MAT0` entry index), `-1` = free |
+| 1 | 4 | `refCount` |
+| 2 | 8 | `phaseCount` |
+| 3 | 12 | `phaseArrayPtr` (`phaseCount * 76`) |
+| 4 | 16 | `animBlockCount` (`< 20`) |
+| 5..84 | 20..339 | `animBlocks[20]` по 16 байт |
+| 85 | 340 | metaA (`dword_10066B44`) |
+| 86 | 344 | metaB (`dword_10066B48`) |
+| 87 | 348 | metaC (`dword_10066B4C`) |
+| 88 | 352 | metaD (`dword_10066B50`) |
+| 89 | 356 | flagA (`dword_10066B54`) |
+| 90 | 360 | nibbleMode (`dword_10066B58`) |
+| 91 | 364 | flagB (`dword_10066B5C`) |
+
+### 4.4 Банк палитр
+
+- `dword_1013DA58[]`
+- Загружается до `286` элементов (26 букв * 11 вариантов)
+
+---
+
+## 5. Загрузка палитр (`sub_10002B40`)
+
+### 5.1 Генерация имён
+
+Движок перебирает:
+
+- буквы `'A'..'Z'`
+- суффиксы: `""`, `"0"`, `"1"`, ..., `"9"`
+
+И формирует имя:
+
+- `<Letter><Suffix>.PAL`
+- примеры: `A.PAL`, `A0.PAL`, ..., `Z9.PAL`
+
+### 5.2 Индекс палитры
+
+`paletteIndex = letterIndex * 11 + variantIndex`
+
+- `letterIndex = 0..25`
+- `variantIndex = 0..10` (`""`=0, `"0"`=1, ..., `"9"`=10)
+
+### 5.3 Поведение
+
+- Если запись не найдена: `paletteSlots[idx] = 0`
+- Если найдена: payload отдаётся в рендер (`render->method+60`)
+
+---
+
+## 6. Формат `MAT0` (`Material.lib`)
+
+### 6.1 Атрибуты NRes entry
+
+`sub_10004310` использует:
+
+- `entry.type` = `MAT0`
+- `entry.attr1` (bitfield runtime-флагов)
+- `entry.attr2` (версия/вариант заголовка payload)
+- `entry.attr3` не используется в runtime-парсере
+
+Маппинг `attr1`:
+
+- bit0 (`0x01`) -> добавить флаг `0x200000` в загрузку текстур фазы
+- bit1 (`0x02`) -> `flagA=1`; при некоторых HW-условиях дополнительно OR `0x80000`
+- bits2..5 -> `nibbleMode = (attr1 >> 2) & 0xF`
+- bit6 (`0x40`) -> `flagB=1`
+
+### 6.2 Payload layout
+
+```c
+struct Mat0Payload {
+ uint16_t phaseCount;
+ uint16_t animBlockCount; // должно быть < 20, иначе "Too many animations for material."
+
+ // Если attr2 >= 2:
+ uint8_t metaA8;
+ uint8_t metaB8;
+ // Если attr2 >= 3:
+ uint32_t metaC32;
+ // Если attr2 >= 4:
+ uint32_t metaD32;
+
+ PhaseRecordByte34 phases[phaseCount];
+ AnimBlockRaw anim[animBlockCount];
+};
+```
+
+Если `attr2 < 2`, runtime-значения по умолчанию:
+
+- `metaA = 255`
+- `metaB = 255`
+- `metaC = 1.0f` (`0x3F800000`)
+- `metaD = 0`
+
+### 6.3 `PhaseRecordByte34` -> runtime `76 bytes`
+
+Сырые 34 байта:
+
+```c
+struct PhaseRecordByte34 {
+ uint8_t p[18]; // параметры
+ char textureName[16];// если textureName[0]==0, текстуры нет
+};
+```
+
+Преобразование в runtime-структуру (точный порядок):
+
+| Из `p[i]` | В offset runtime | Преобразование |
+|---:|---:|---|
+| `p[0]` | `+16` | `p[0] / 255.0f` |
+| `p[1]` | `+20` | `p[1] / 255.0f` |
+| `p[2]` | `+24` | `p[2] / 255.0f` |
+| `p[3]` | `+28` | `p[3] * 0.01f` |
+| `p[4]` | `+0` | `p[4] / 255.0f` |
+| `p[5]` | `+4` | `p[5] / 255.0f` |
+| `p[6]` | `+8` | `p[6] / 255.0f` |
+| `p[7]` | `+12` | `p[7] / 255.0f` |
+| `p[8]` | `+32` | `p[8] / 255.0f` |
+| `p[9]` | `+36` | `p[9] / 255.0f` |
+| `p[10]` | `+40` | `p[10] / 255.0f` |
+| `p[11]` | `+44` | `p[11] / 255.0f` |
+| `p[12]` | `+48` | `p[12] / 255.0f` |
+| `p[13]` | `+52` | `p[13] / 255.0f` |
+| `p[14]` | `+56` | `p[14] / 255.0f` |
+| `p[15]` | `+60` | `p[15] / 255.0f` |
+| `p[16]` | `+64` | `uint32 = p[16]` |
+| `p[17]` | `+72` | `int32 = p[17]` |
+
+Текстура:
+
+- `textureName[0] == 0` -> `runtime[+68] = -1` и `runtime[+72] = -1`
+- иначе `runtime[+68] = LoadTexture(textureName, flags)`
+
+### 6.4 Runtime-запись фазы (76 байт)
+
+```c
+struct MaterialPhase76 {
+ float f0; // +0
+ float f1; // +4
+ float f2; // +8
+ float f3; // +12
+ float f4; // +16
+ float f5; // +20
+ float f6; // +24
+ float f7; // +28
+ float f8; // +32
+ float f9; // +36
+ float f10; // +40
+ float f11; // +44
+ float f12; // +48
+ float f13; // +52
+ float f14; // +56
+ float f15; // +60
+ uint32_t u16; // +64
+ int32_t texSlot; // +68 (индекс в texture cache, либо -1)
+ int32_t i18; // +72
+};
+```
+
+### 6.5 Анимационные блоки (`animBlockCount`, максимум 19)
+
+Каждый блок в payload:
+
+```c
+struct AnimBlockRaw {
+ uint32_t headerRaw; // mode = headerRaw & 7; interpMask = headerRaw >> 3
+ uint16_t keyCount;
+ struct KeyRaw {
+ uint16_t k0;
+ uint16_t k1;
+ uint16_t k2;
+ } keys[keyCount];
+};
+```
+
+Runtime-представление блока = 16 байт:
+
+```c
+struct AnimBlockRuntime {
+ uint32_t mode; // headerRaw & 7
+ uint32_t interpMask;// headerRaw >> 3
+ int32_t keyCount;
+ void* keysPtr; // массив keyCount * 8
+};
+```
+
+Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`).
+
+`k2` в `sub_100031F0/sub_10003680` не используется.
+Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате.
+
+### 6.6 Поиск и fallback
+
+При `LoadMaterial(name)`:
+
+- сначала точный поиск в `Material.lib`;
+- при промахе лог: `"Material %s not found."`;
+- fallback на `DEFAULT`;
+- если и `DEFAULT` не найден, берётся индекс `0`.
+
+---
+
+## 7. Выбор текущей material-фазы
+
+### 7.1 Интерполяция (`sub_10003030`)
+
+Интерполируются только следующие поля (по `interpMask`):
+
+- bit `0x02`: `+4,+8,+12`
+- bit `0x01`: `+20,+24,+28`
+- bit `0x04`: `+36,+40,+44`
+- bit `0x08`: `+52,+56,+60`
+- bit `0x10`: `+32`
+
+Не интерполируются и копируются из «текущей» фазы:
+
+- `+0,+16,+48,+64,+68,+72`
+
+### 7.2 Выбор по времени (`sub_100031F0`)
+
+Вход:
+
+- `handle` (`tableIndex|wearIndex`)
+- `animBlockIndex`
+- глобальное время `SetGameTime()` (`dword_10032A38`)
+
+Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte`).
+
+Режимы `mode = headerRaw & 7`:
+
+- `0`: loop
+- `1`: ping-pong
+- `2`: one-shot clamp
+- `3`: random (`rand() % cycleLength`)
+
+Важные детали 1:1:
+
+- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением);
+- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную).
+- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case).
+
+После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300`), который возвращается через out-параметр.
+
+### 7.3 Выбор по нормализованному `t` (`sub_10003680`)
+
+Аналогично `sub_100031F0`, но time берётся как `t * cycleLength`.
+
+Перед вычислением времени применяется runtime-нормализация:
+
+- если `t < 0.0` или `t > 1.0`, используется `t = 0.5`.
+
+### 7.4 Сброс времени записи
+
+`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()`.
+
+---
+
+## 8. Формат `WEAR` (текст)
+
+`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557`), обычно имя `*.wea`.
+
+### 8.1 Грамматика
+
+```text
+<wearCount:int>\n
+<legacyId:int> <materialName>\n // повторить wearCount раз
+
+[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка
+[LIGHTMAPS\n
+<lightmapCount:int>\n
+<legacyId:int> <lightmapName>\n // повторить lightmapCount раз]
+```
+
+- `<legacyId>` читается, но как ключ не используется.
+- Идентификатором реально является имя (`materialName` / `lightmapName`).
+
+### 8.2 Парсеры
+
+1. `sub_10003B10`: файл/ресурсный режим.
+2. `sub_10003F80`: парсер из строкового буфера.
+
+Различие важно для совместимости:
+
+- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf`.
+- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n`; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS`, иначе парсинг может съехать.
+
+### 8.3 Поведение и ошибки
+
+- `wearCount <= 0` (в текстовом файловом режиме) -> `"Illegal wear length."`
+- при невозможности открыть wear-файл/entry -> `"Wear <%s> doesn't exist."`
+- если найден блок `LIGHTMAPS` и `lightmapCount <= 0` -> `"Illegal lightmaps length."`
+- отсутствующий материал -> `"Material %s not found."` + fallback `DEFAULT`
+- отсутствующая lightmap -> `"LightMap %s not found."` и slot `-1`
+- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения.
+
+### 8.4 Ограничения runtime
+
+- Таблиц в `MatManager`: максимум 70 (физический layout).
+- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет.
+
+Инструментам нужно явно валидировать `tableCount < 70`.
+
+---
+
+## 9. Загрузка texture/lightmap по имени
+
+Общие функции:
+
+- `sub_10004B10` — texture (`Textures.lib`)
+- `sub_10004CB0` — lightmap (`LightMap.lib`)
+
+### 9.1 Валидация имени
+
+Алгоритм требует наличие `'.'` в позиции `0..16`.
+
+Иначе:
+
+- `"Bad texture name."`
+- возврат `-1`
+
+### 9.2 Palette index из суффикса
+
+После точки разбирается:
+
+- `L = toupper(name[dot+1])`
+- `D = name[dot+2]` (опционально)
+- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)`
+
+Если `idx < 0`, палитра не подставляется (`0`).
+Верхняя граница `idx` в runtime не проверяется.
+
+Практически в стоковых ассетах имена часто вида `NAME.0`; это даёт `idx < 0`, т.е. без палитровой привязки.
+Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива.
+
+### 9.3 Кэширование
+
+- Дедупликация по `resIndex`.
+- При повторном запросе увеличивается `refCount`, `lastZeroRefTime` сбрасывается в `0`.
+- При освобождении материала `refCount` texture/lightmap уменьшается.
+- texture: при `refCount -> 0` запоминается `lastZeroRefTime`; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд.
+- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor).
+
+---
+
+## 10. Формат `Texm`
+
+### 10.1 Заголовок 32 байта
+
+```c
+struct TexmHeader32 {
+ uint32_t magic; // 'Texm' = 0x6D786554
+ uint32_t width;
+ uint32_t height;
+ uint32_t mipCount;
+ uint32_t flags4;
+ uint32_t flags5;
+ uint32_t unk6;
+ uint32_t format;
+};
+```
+
+### 10.2 Поддерживаемые `format`
+
+Подтверждённые в данных:
+
+- `0` (палитровый 8-bit)
+- `565`
+- `4444`
+- `888`
+- `8888`
+
+Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации):
+
+- `556`
+- `88`
+
+### 10.3 Layout payload
+
+1. `TexmHeader32`
+2. если `format == 0`: palette table `256 * 4 = 1024` байта
+3. mip-chain пикселей
+4. опциональный `Page` chunk
+
+Расчёт:
+
+```c
+bytesPerPixel =
+ (format == 0) ? 1 :
+ (format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
+ 4;
+
+pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i));
+sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount;
+```
+
+### 10.4 `Page` chunk
+
+```c
+struct PageChunk {
+ uint32_t magic; // 'Page'
+ uint32_t rectCount;
+ struct Rect16 {
+ int16_t x;
+ int16_t w;
+ int16_t y;
+ int16_t h;
+ } rects[rectCount];
+};
+```
+
+Runtime конвертирует `Rect16` в:
+
+- пиксельные прямоугольники;
+- UV-границы с учётом возможного `mipSkip`.
+
+Формулы (`s = mipSkip`):
+
+- `x0 = x << s`, `x1 = (x + w) << s`
+- `y0 = y << s`, `y1 = (y + h) << s`
+- `u0 = x / (width << s)`, `du = w / (width << s)`
+- `v0 = y / (height << s)`, `dv = h / (height << s)`
+
+Также всегда добавляется базовый rect `[0]` на всю текстуру: пиксели `(0,0,width,height)`, UV `(0,0,1,1)`.
+
+### 10.5 Loader-поведение (`sub_1000FB30`)
+
+- Читает header в внутренние поля (`+56..+84`) напрямую:
+ - `+56 magic`, `+60 width`, `+64 height`, `+68 mipCount`,
+ - `+72 flags4`, `+76 flags5`, `+80 unk6`, `+84 format`.
+- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу.
+- Считает `sizeCore`, находит tail.
+- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page`.
+- Может уменьшать стартовый mip (`sub_1000F580`) в зависимости от размеров/формата/флагов.
+- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime.
+
+### 10.6 Политика `mipSkip` (`sub_1000F580`)
+
+`mipSkip` зависит от `flags5 & 0x72000000`, `width`, `height`, `mipCount`:
+
+- если `mipCount <= 1` -> `0`
+- если `flags5Mask == 0x02000000` -> `2` при `mipCount > 2`, иначе `1`
+- если `flags5Mask == 0x10000000` -> `1`
+- если `flags5Mask == 0x20000000`:
+ - `1`, если `width >= 256` или `height >= 256`
+ - иначе `0`
+- если `flags5Mask == 0x40000000`:
+ - если `width > 128` и `height > 128`: `2` при `mipCount > 2`, иначе `1`
+ - если `width == 128` или `height == 128`: `1`
+ - иначе `0`
+- иначе `0`
+
+Применение в loader:
+
+- `mipCount -= mipSkip`
+- `width >>= mipSkip`, `height >>= mipSkip`
+- `pixelDataOffset += bytesPerPixel * origWidth * origHeight` для `mipSkip==1`
+- `pixelDataOffset += bytesPerPixel * origWidth * origHeight * 1.25` для `mipSkip==2` (первые два уровня)
+
+---
+
+## 11. Флаги профиля/рендера (Ngi32)
+
+Ключ реестра: `HKCU\Software\Nikita\NgiTool`.
+
+Подтверждённые значения:
+
+- `Disable MultiTexturing`
+- `DisableMipmap`
+- `Force 16-bit textures`
+- `UseFirstCard`
+- `DisableD3DCalls`
+- `DisableDSound`
+- `ForceCpu`
+
+Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки.
+
+---
+
+## 12. Спецификация для toolchain (read/edit/write)
+
+### 12.1 Каноническая модель данных
+
+1. `MAT0`:
+- хранить исходные `attr1/attr2/attr3`;
+- хранить сырой payload + декодированную структуру;
+- при записи сохранять порядок/размеры секций точно.
+
+2. `WEAR`:
+- хранить строки wear/lightmaps как текст;
+- сохранять порядок строк;
+- допускать отсутствие блока `LIGHTMAPS`.
+- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`.
+
+3. `Texm`:
+- хранить header поля как есть (`flags4/flags5/unk6` не нормализовать);
+- хранить palette (если есть), mip data, `Page`.
+
+### 12.2 Правила lossless записи
+
+- Не менять значения `flags4/flags5/unk6` без явной причины.
+- Не менять `NRes` entry attrs, если цель — бинарный round-trip.
+- Для `MAT0`:
+ - `animBlockCount < 20`.
+ - `phaseCount` и фактический размер секции должны совпадать.
+ - textureName в фазе всегда укладывать в 16 байт и NUL-терминировать.
+- Для `Texm`:
+ - `magic == 'Texm'`.
+ - `mipCount > 0`, `width>0`, `height>0`.
+ - tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт.
+ - при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`.
+
+### 12.3 Рекомендованные валидации редактора
+
+- `WEAR`:
+ - `wearCount > 0`.
+ - число строк wear соответствует `wearCount`.
+ - если есть `LIGHTMAPS`, то `lightmapCount > 0` и число строк совпадает.
+ - для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS`.
+- `MAT0`:
+ - не выходить за payload при распаковке.
+ - все ссылки фаз/keys проверять на диапазоны.
+- `Texm`:
+ - `sizeCore <= payload_size`.
+ - проверка `Page` как `8 + rectCount*8`.
+ - предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime.
+
+---
+
+## 13. Проверка на реальных данных (`tmp/gamedata`)
+
+### 13.1 `Material.lib`
+
+- `905` entries, все `type=MAT0`
+- `attr2 = 6` у всех
+- `attr3 = 0` у всех
+- `phaseCount` до `29`
+- `animBlockCount` до `8` (ограничение runtime `<20` соблюдается)
+
+### 13.2 `Textures.lib`
+
+- `393` entries, все `type=Texm`
+- форматы: `8888(237), 888(52), 565(47), 4444(42), 0(15)`
+- `flags4`: `32(361), 0(32)`
+- `flags5`: `0(312), 0x04000000(81)`
+- `Page` chunk присутствует у `65` текстур
+
+### 13.3 `lightmap.lib`
+
+- `25` entries, все `Texm`
+- формат: `565`
+- `mipCount=1`
+- `flags5`: в основном `0`, встречается `0x00800000`
+
+### 13.4 `WEAR`
+
+- `439` entries `type=WEAR`
+- `attr1=0, attr2=0, attr3=1`
+- `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`)
+- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`.
+
+---
+
+## 14. Opaque-поля и границы знания
+
+Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`:
+
+- `MAT0`:
+ - `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений);
+ - `metaA/metaB/metaC/metaD` (в `World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено).
+- `Texm`:
+ - `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1).
+
+Это не блокирует реализацию движка/конвертеров 1:1.
+
+---
+
+## 15. Минимальные псевдокоды для реализации
+
+### 15.1 `parse_mat0(payload, attr2)`
+
+```python
+def parse_mat0(payload: bytes, attr2: int):
+ cur = 0
+ phase_count = u16(payload, cur); cur += 2
+ anim_count = u16(payload, cur); cur += 2
+ if anim_count >= 20:
+ raise ValueError("Too many animations for material")
+
+ if attr2 < 2:
+ metaA, metaB, metaC, metaD = 255, 255, 0x3F800000, 0
+ else:
+ metaA = u8(payload, cur); cur += 1
+ metaB = u8(payload, cur); cur += 1
+ metaC = u32(payload, cur) if attr2 >= 3 else 0x3F800000
+ cur += 4 if attr2 >= 3 else 0
+ metaD = u32(payload, cur) if attr2 >= 4 else 0
+ cur += 4 if attr2 >= 4 else 0
+
+ phases = [payload[cur + i*34 : cur + (i+1)*34] for i in range(phase_count)]
+ cur += 34 * phase_count
+
+ anim = []
+ for _ in range(anim_count):
+ raw = u32(payload, cur); cur += 4
+ key_count = u16(payload, cur); cur += 2
+ keys = [payload[cur + k*6 : cur + (k+1)*6] for k in range(key_count)]
+ cur += 6 * key_count
+ anim.append((raw, keys))
+
+ if cur != len(payload):
+ raise ValueError("MAT0 tail bytes")
+
+ return phase_count, anim_count, metaA, metaB, metaC, metaD, phases, anim
+```
+
+### 15.2 `parse_texm(payload)`
+
+```python
+def parse_texm(payload: bytes):
+ magic, w, h, mips, f4, f5, unk6, fmt = unpack_u32x8(payload, 0)
+ if magic != 0x6D786554:
+ raise ValueError("not Texm")
+
+ bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444, 88) else 4)
+ pix = 0
+ mw, mh = w, h
+ for _ in range(mips):
+ pix += mw * mh
+ mw = max(1, mw >> 1)
+ mh = max(1, mh >> 1)
+
+ core = 32 + (1024 if fmt == 0 else 0) + bpp * pix
+ if core > len(payload):
+ raise ValueError("truncated")
+
+ page = None
+ if core < len(payload):
+ if core + 8 > len(payload) or payload[core:core+4] != b"Page":
+ raise ValueError("tail without Page")
+ n = u32(payload, core + 4)
+ need = 8 + n * 8
+ if core + need != len(payload):
+ raise ValueError("invalid Page size")
+ page = [unpack_i16x4(payload, core + 8 + i*8) for i in range(n)]
+
+ return (w, h, mips, fmt, f4, f5, unk6, page)
+```
+
+### 15.3 `mip_skip_policy(flags5, width, height, mip_count)`
+
+```python
+def mip_skip_policy(flags5: int, width: int, height: int, mip_count: int) -> int:
+ if mip_count <= 1:
+ return 0
+
+ m = flags5 & 0x72000000
+ if m == 0x02000000:
+ return 2 if mip_count > 2 else 1
+ if m == 0x10000000:
+ return 1
+ if m == 0x20000000:
+ return 1 if (width >= 256 or height >= 256) else 0
+ if m == 0x40000000:
+ if width > 128 and height > 128:
+ return 2 if mip_count > 2 else 1
+ if width == 128 or height == 128:
+ return 1
+ return 0
+```
+
+### 15.4 `parse_wear_buffer_compatible(text)`
+
+```python
+def parse_wear_buffer_compatible(text: str):
+ lines = text.splitlines()
+ i = 0
+
+ wear_count = int(lines[i].strip()); i += 1
+ if wear_count <= 0:
+ raise ValueError("Illegal wear length.")
+
+ wear = []
+ for _ in range(wear_count):
+ legacy, name = lines[i].split(maxsplit=1)
+ wear.append((int(legacy), name.strip()))
+ i += 1
+
+ lightmaps = []
+ tail = lines[i:] if i < len(lines) else []
+ if tail and tail[0].strip() == "":
+ # sub_10003F80-совместимый разделитель перед LIGHTMAPS
+ i += 1
+ tail = lines[i:]
+
+ if tail and tail[0].strip().upper() == "LIGHTMAPS":
+ i += 1
+ if i >= len(lines):
+ raise ValueError("Illegal lightmaps length.")
+ light_count = int(lines[i].strip()); i += 1
+ if light_count <= 0:
+ raise ValueError("Illegal lightmaps length.")
+ for _ in range(light_count):
+ legacy, name = lines[i].split(maxsplit=1)
+ lightmaps.append((int(legacy), name.strip()))
+ i += 1
+
+ return wear, lightmaps
+```
+
+### 15.5 `select_phase_time_1to1(...)`
+
+```python
+def select_phase_time_1to1(game_time: int, start_time: int, keys, mode: int):
+ # keys: list[(phase_index, t_start, t_end)], t_end последнего = cycle_len
+ cycle_len = keys[-1][2]
+ if cycle_len <= 0:
+ return 0, 0.0
+
+ # unsigned div/mod как в runtime
+ delta = (game_time - start_time) & 0xFFFFFFFF
+ q = delta // cycle_len
+ r = delta % cycle_len
+
+ if mode == 1: # ping-pong
+ if q & 1:
+ r = cycle_len - r
+ elif mode == 2: # one-shot
+ if q > 0:
+ k = len(keys) - 1
+ return k, 0.0
+ elif mode == 3: # random
+ r = rand32() % cycle_len
+ start_time = r # side effect как в sub_100031F0
+
+ k = find_segment(keys, r) # t_start <= r < t_end
+ kn = 0 if (k + 1 == len(keys)) else (k + 1)
+ t0, t1 = keys[k][1], keys[k][2]
+ alpha = 0.0 if t1 == t0 else (r - t0) / float(t1 - t0)
+ return (k, kn), alpha
+```
diff --git a/docs/specs/missions.md b/docs/specs/missions.md
new file mode 100644
index 0000000..6f351d0
--- /dev/null
+++ b/docs/specs/missions.md
@@ -0,0 +1,5 @@
+# Missions
+
+Документ описывает формат миссий и сценариев: начальное состояние, триггеры и связь миссий с картой мира.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `MisLoad.dll`.
diff --git a/docs/specs/msh-animation.md b/docs/specs/msh-animation.md
new file mode 100644
index 0000000..811fa00
--- /dev/null
+++ b/docs/specs/msh-animation.md
@@ -0,0 +1,105 @@
+# MSH animation
+
+Документ описывает анимационные ресурсы MSH: `Res8`, `Res19` и runtime-интерполяцию.
+
+---
+
+## 1.13. Ресурсы анимации: Res8 и Res19
+
+- **Res8** — массив анимационных ключей фиксированного размера 24 байта.
+- **Res19** — `uint16` mapping‑массив «frame → keyIndex` (с per-node смещением).
+
+### 1.13.1. Формат Res8 (ключ 24 байта)
+
+```c
+struct AnimKey24 {
+ float posX; // +0x00
+ float posY; // +0x04
+ float posZ; // +0x08
+ float time; // +0x0C
+ int16_t qx; // +0x10
+ int16_t qy; // +0x12
+ int16_t qz; // +0x14
+ int16_t qw; // +0x16
+};
+```
+
+Декодирование quaternion-компонент:
+
+```c
+q = s16 * (1.0f / 32767.0f)
+```
+
+### 1.13.2. Формат Res19
+
+Res19 читается как непрерывный массив `uint16`:
+
+```c
+uint16_t map[]; // размер = size(Res19)/2
+```
+
+Per-node управление mapping'ом берётся из заголовка узла Res1:
+
+- `node.hdr2` (`Res1 + 0x04`) = `mapStart` (`0xFFFF` => map отсутствует);
+- `node.hdr3` (`Res1 + 0x06`) = `fallbackKeyIndex` и одновременно верхняя граница валидного `map`‑значения.
+
+### 1.13.3. Выбор ключа для времени `t` (`sub_10012880`)
+
+1) Вычислить frame‑индекс:
+
+```c
+frame = (int64)(t - 0.5f); // x87 FISTP-путь
+```
+
+Для строгой 1:1 эмуляции используйте именно поведение x87 `FISTP` (а не «упрощённый floor»), т.к. путь в оригинале опирается на FPU rounding mode.
+
+2) Проверка условий fallback:
+
+- `frame >= model.animFrameCount` (`model+0x9C`, из `NResEntry(Res19).attr2`);
+- `mapStart == 0xFFFF`;
+- `map[mapStart + frame] >= fallbackKeyIndex`.
+
+Если любое условие истинно:
+
+```c
+keyIndex = fallbackKeyIndex;
+```
+
+Иначе:
+
+```c
+keyIndex = map[mapStart + frame];
+```
+
+3) Сэмплирование:
+
+- `k0 = Res8[keyIndex]`
+- `k1 = Res8[keyIndex + 1]` (для интерполяции сегмента)
+
+Пути:
+
+- если `t == k0.time` → взять `k0`;
+- если `t == k1.time` → взять `k1`;
+- иначе `alpha = (t - k0.time) / (k1.time - k0.time)`, `pos = lerp(k0.pos, k1.pos, alpha)`, rotation смешивается через fastproc‑интерполятор quaternion.
+
+### 1.13.4. Межкадровое смешивание (`sub_10012560`)
+
+Функция смешивает два сэмпла (например, из двух animation time-позиций) с коэффициентом `blend`:
+
+1) получить два `(quat, pos)` через `sub_10012880`;
+2) выполнить shortest‑path коррекцию знака quaternion:
+
+```c
+if (|q0 + q1|^2 < |q0 - q1|^2) q1 = -q1;
+```
+
+3) смешать quaternion (fastproc) и построить orientation‑матрицу;
+4) translation писать отдельно как `lerp(pos0, pos1, blend)` в ячейки `m[3], m[7], m[11]`.
+
+### 1.13.5. Что хранится в `Res19.attr2`
+
+При загрузке `sub_10015FD0` записывает `NResEntry(Res19).attr2` в `model+0x9C`.
+Это поле используется как верхняя граница frame‑индекса в п.1.13.3.
+
+---
+
diff --git a/docs/specs/msh-core.md b/docs/specs/msh-core.md
new file mode 100644
index 0000000..82aec18
--- /dev/null
+++ b/docs/specs/msh-core.md
@@ -0,0 +1,492 @@
+# MSH core
+
+Документ описывает core-часть формата MSH: геометрию, узлы, батчи, LOD и slot-матрицу.
+
+Связанный формат контейнера: [NRes / RsLi](nres.md).
+
+---
+
+## 1.1. Общая архитектура
+
+Модель состоит из набора именованных ресурсов внутри одного NRes‑архива. Каждый ресурс идентифицируется **целочисленным типом** (`resource_type`), который передаётся API функции `niReadData` (vtable‑метод `+0x18`) через связку `niFind` (vtable‑метод `+0x0C`, `+0x20`).
+
+Рендер‑модель использует **rigid‑скининг по узлам** (нет per‑vertex bone weights). Каждый batch геометрии привязан к одному узлу и рисуется с матрицей этого узла.
+
+## 1.2. Общая структура файла модели
+
+```
+┌────────────────────────────────────┐
+│ NRes‑заголовок (16 байт) │
+├────────────────────────────────────┤
+│ Ресурсы (произвольный порядок): │
+│ Res1 — Node table │
+│ Res2 — Model header + Slots │
+│ Res3 — Vertex positions │
+│ Res4 — Packed normals │
+│ Res5 — Packed UV0 │
+│ Res6 — Index buffer │
+│ Res7 — Triangle descriptors │
+│ Res8 — Keyframe data │
+│ Res10 — String table │
+│ Res13 — Batch table │
+│ Res19 — Animation mapping │
+│ [Res15] — UV1 / доп. поток │
+│ [Res16] — Tangent/Bitangent │
+│ [Res18] — Vertex color │
+│ [Res20] — Доп. таблица │
+├────────────────────────────────────┤
+│ NRes‑каталог │
+└────────────────────────────────────┘
+```
+
+Ресурсы в квадратных скобках — **опциональные**. Загрузчик проверяет их наличие перед чтением (`niFindRes` возвращает `−1` при отсутствии).
+
+## 1.3. Порядок загрузки ресурсов (из `sub_10015FD0` в AniMesh.dll)
+
+Функция `sub_10015FD0` выполняет инициализацию внутренней структуры модели размером **0xA4** (164 байта). Ниже приведён точный порядок загрузки и маппинг ресурсов на поля структуры:
+
+| Шаг | Тип ресурса | Поле структуры | Описание |
+|-----|-------------|----------------|-----------------------------------------|
+| 1 | 1 | `+0x00` | Node table (Res1) |
+| 2 | 2 | `+0x04` | Model header (Res2) |
+| 3 | 3 | `+0x0C` | Vertex positions (Res3) |
+| 4 | 4 | `+0x10` | Packed normals (Res4) |
+| 5 | 5 | `+0x14` | Packed UV0 (Res5) |
+| 6 | 10 (0x0A) | `+0x20` | String table (Res10) |
+| 7 | 8 | `+0x18` | Keyframe / animation track data (Res8) |
+| 8 | 19 (0x13) | `+0x1C` | Animation mapping (Res19) |
+| 9 | 7 | `+0x24` | Triangle descriptors (Res7) |
+| 10 | 13 (0x0D) | `+0x28` | Batch table (Res13) |
+| 11 | 6 | `+0x2C` | Index buffer (Res6) |
+| 12 | 15 (0x0F) | `+0x34` | Доп. vertex stream (Res15), опционально |
+| 13 | 16 (0x10) | `+0x38` | Доп. vertex stream (Res16), опционально |
+| 14 | 18 (0x12) | `+0x64` | Vertex color (Res18), опционально |
+| 15 | 20 (0x14) | `+0x30` | Доп. таблица (Res20), опционально |
+
+### Производные поля (вычисляются после загрузки)
+
+| Поле | Формула | Описание |
+|---------|-------------------------|------------------------------------------------------------------------------------------------|
+| `+0x08` | `Res2_ptr + 0x8C` | Указатель на slot table (140 байт от начала Res2) |
+| `+0x3C` | `= Res3_ptr` | Копия указателя positions (stream ptr) |
+| `+0x40` | `= 0x0C` (12) | Stride позиций: `sizeof(float3)` |
+| `+0x44` | `= Res4_ptr` | Копия указателя normals (stream ptr) |
+| `+0x48` | `= 4` | Stride нормалей: 4 байта |
+| `+0x4C` | `Res16_ptr` или `0` | Stream A Res16 (tangent) |
+| `+0x50` | `= 8` если `+0x4C != 0` | Stride stream A (используется только при наличии Res16) |
+| `+0x54` | `Res16_ptr + 4` или `0` | Stream B Res16 (bitangent) |
+| `+0x58` | `= 8` если `+0x54 != 0` | Stride stream B (используется только при наличии Res16) |
+| `+0x5C` | `= Res5_ptr` | Копия указателя UV0 (stream ptr) |
+| `+0x60` | `= 4` | Stride UV0: 4 байта |
+| `+0x68` | `= 4` или `0` | Stride Res18 (если найден) |
+| `+0x8C` | `= Res15_ptr` | Копия указателя Res15 |
+| `+0x90` | `= 8` | Stride Res15: 8 байт |
+| `+0x94` | `= 0` | Зарезервировано/unk94: инициализируется нулём при загрузке; не является флагом Res18 |
+| `+0x9C` | NRes entry Res19 `+8` | Метаданные из каталожной записи Res19 |
+| `+0xA0` | NRes entry Res20 `+4` | Метаданные из каталожной записи Res20 (заполняется только если Res20 найден и открыт, иначе 0) |
+
+**Примечание к метаданным:** поле `+0x9C` читается из каталожной записи NRes для ресурса 19 (смещение `+8` в записи каталога, т.е. `attribute_2`). Поле `+0xA0` — из каталожной записи для ресурса 20 (смещение `+4`, т.е. `attribute_1`) **только если Res20 найден и `niOpenRes` вернул ненулевой указатель**; иначе `+0xA0 = 0`. Индекс записи определяется как `entry_index * 64`, после чего считывается поле.
+
+---
+
+### 1.3.1. Ссылки на функции и паттерны вызовов (для проверки реверса)
+
+- `AniMesh.dll!sub_10015FD0` — загрузка ресурсов модели через vtable интерфейса NRes:
+ - `niFindRes(type, ...)` вызывается через `call [vtable+0x20]`
+ - `niOpenRes(...)` / чтение указателя — через `call [vtable+0x18]`
+- `AniMesh.dll!sub_10015FD0` выставляет производные поля (`Res2_ptr+0x8C`, stride'ы), обнуляет `model+0x94`, и при отсутствии Res16 обнуляет только указатели потоков (`+0x4C`, `+0x54`).
+- `AniMesh.dll!sub_10004840` / `sub_10004870` / `sub_100048A0` — использование runtime mapping‑таблицы (`+0x18`, индекс `boneId*4`) и таблицы указателей треков (`+0x08`) после построения анимационного объекта.
+
+
+## 1.4. Ресурс Res2 — Model Header (140 байт) + Slot Table
+
+Ресурс Res2 содержит:
+
+```
+┌───────────────────────────────────┐ Смещение 0
+│ Model Header (140 байт = 0x8C) │
+├───────────────────────────────────┤ Смещение 140 (0x8C)
+│ Slot Table │
+│ (slot_count × 68 байт) │
+└───────────────────────────────────┘
+```
+
+### 1.4.1. Model Header (первые 140 байт)
+
+Поле `Res2[0x00..0x8B]` используется как **35 float** (без внутренних таблиц/индексов). Это подтверждено прямыми копированиями в `AniMesh.dll!sub_1000A460`:
+
+- `qmemcpy(this+0x54, Res2+0x00, 0x60)` — первые 24 float;
+- копирование `Res2+0x60` размером `0x10` — ещё 4 float;
+- `qmemcpy(this+0x134, Res2+0x70, 0x1C)` — ещё 7 float.
+
+Итоговая раскладка:
+
+| Диапазон | Размер | Тип | Семантика |
+|--------------|--------|-------------|----------------------------------------------------------------------|
+| `0x00..0x5F` | `0x60` | `float[24]` | 8 вершин глобального bounding‑hull (`vec3[8]`) |
+| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная bounding‑sphere: `center.xyz + radius` |
+| `0x70..0x8B` | `0x1C` | `float[7]` | Глобальный «капсульный»/сегментный bound: `A.xyz`, `B.xyz`, `radius` |
+
+Для рендера и broadphase движок использует как слот‑bounds (`Res2 slot`), так и этот глобальный набор bounds (в зависимости от контекста вызова/LOD и наличия слота).
+
+### 1.4.2. Slot Table (массив записей по 68 байт)
+
+Slot — ключевая структура, связывающая узел иерархии с конкретной геометрией для конкретного LOD и группы. Каждая запись — **68 байт** (0x44).
+
+**Важно:** смещения в таблице ниже указаны в **десятичном формате** (байты). В скобках приведён hex‑эквивалент (например, 48 (0x30)).
+
+
+| Смещение | Размер | Тип | Описание |
+|-----------|--------|----------|-----------------------------------------------------|
+| 0 | 2 | uint16 | `triStart` — индекс первого треугольника в Res7 |
+| 2 | 2 | uint16 | `triCount` — длина диапазона треугольников (`Res7`) |
+| 4 | 2 | uint16 | `batchStart` — индекс первого batch'а в Res13 |
+| 6 | 2 | uint16 | `batchCount` — количество batch'ей |
+| 8 | 4 | float | `aabbMin.x` |
+| 12 | 4 | float | `aabbMin.y` |
+| 16 | 4 | float | `aabbMin.z` |
+| 20 | 4 | float | `aabbMax.x` |
+| 24 | 4 | float | `aabbMax.y` |
+| 28 | 4 | float | `aabbMax.z` |
+| 32 | 4 | float | `sphereCenter.x` |
+| 36 | 4 | float | `sphereCenter.y` |
+| 40 | 4 | float | `sphereCenter.z` |
+| 44 (0x2C) | 4 | float | `sphereRadius` |
+| 48 (0x30) | 20 | 5×uint32 | Хвостовые поля: `unk30..unk40` (см. §1.4.2.1) |
+
+**AABB** — axis‑aligned bounding box в локальных координатах узла.
+**Bounding Sphere** — описанная сфера в локальных координатах узла.
+
+#### 1.4.2.1. Точная семантика `triStart/triCount`
+
+В `AniMesh.dll!sub_1000B2C0` слот считается «владельцем» треугольника `triId`, если:
+
+```c
+triId >= slot.triStart && triId < slot.triStart + slot.triCount
+```
+
+Это прямое доказательство, что `slot +0x02` — именно **count диапазона**, а не флаги.
+
+#### 1.4.2.2. Хвост слота (20 байт = 5×uint32)
+
+Последние 20 байт записи слота трактуем как 5 последовательных 32‑битных значений (little‑endian). Их назначение пока не подтверждено; для инструментов рекомендуется сохранять и восстанавливать их «как есть».
+
+- `+48 (0x30)`: `unk30` (uint32)
+- `+52 (0x34)`: `unk34` (uint32)
+- `+56 (0x38)`: `unk38` (uint32)
+- `+60 (0x3C)`: `unk3C` (uint32)
+- `+64 (0x40)`: `unk40` (uint32)
+
+Для culling при рендере: AABB/sphere трансформируются матрицей узла и инстанса. При неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (подтверждено по коду).
+
+---
+
+### 1.4.3. Восстановление счётчиков элементов по размерам ресурсов (практика для инструментов)
+
+Для toolchain надёжнее считать count'ы по размерам ресурсов (а не по дублирующим полям других таблиц). Это полностью совпадает с тем, как рантайм использует fixed stride'ы в `sub_10015FD0`.
+
+Берите **unpacked_size** (или фактический размер распакованного блока) соответствующего ресурса и вычисляйте:
+
+- `node_count` = `size(Res1) / 38`
+- `vertex_count` = `size(Res3) / 12`
+- `normals_count` = `size(Res4) / 4`
+- `uv0_count` = `size(Res5) / 4`
+- `index_count` = `size(Res6) / 2`
+- `tri_count` = `index_count / 3` (если примитивы — список треугольников)
+- `tri_desc_count` = `size(Res7) / 16`
+- `batch_count` = `size(Res13) / 20`
+- `slot_count` = `(size(Res2) - 0x8C) / 0x44`
+- `anim_key_count` = `size(Res8) / 24`
+- `anim_map_count` = `size(Res19) / 2`
+- `uv1_count` = `size(Res15) / 8` (если Res15 присутствует)
+- `tbn_count` = `size(Res16) / 8` (если Res16 присутствует; tangent/bitangent по 4 байта, stride 8)
+- `color_count` = `size(Res18) / 4` (если Res18 присутствует)
+
+**Валидация:**
+
+- Любое деление должно быть **без остатка**; иначе ресурс повреждён или stride неверно угадан.
+- Если присутствуют Res4/Res5/Res15/Res16/Res18, их count'ы по смыслу должны совпадать с `vertex_count` (или быть ≥ него, если формат допускает хвостовые данные — пока не наблюдалось).
+- Для `slot_count` дополнительно проверьте, что `size(Res2) >= 0x8C`.
+
+**Проверка на реальных данных (435 MSH):**
+
+- `Res2.attr1 == (size-140)/68`, `Res2.attr2 == 0`, `Res2.attr3 == 68`;
+- `Res7.attr1 == size/16`, `Res7.attr3 == 16`;
+- `Res8.attr1 == size/24`, `Res8.attr3 == 4`;
+- `Res19.attr1 == size/2`, `Res19.attr3 == 2`;
+- для `Res1` почти всегда `attr3 == 38` (один служебный outlier: `MTCHECK.MSH` с `attr3 == 24`).
+
+Эти формулы достаточны, чтобы реализовать распаковщик/просмотрщик геометрии и батчей даже без полного понимания полей заголовка Res2.
+
+## 1.5. Ресурс Res1 — Node Table (38 байт на узел)
+
+Node table — компактная карта слотов по уровням LOD и группам. Каждый узел занимает **38 байт** (19 × `uint16`).
+
+### Адресация слота
+
+Движок вычисляет индекс слова в таблице:
+
+```
+word_index = nodeIndex × 19 + lod × 5 + group + 4
+slot_index = node_table[word_index] // uint16, 0xFFFF = нет слота
+```
+
+Параметры:
+
+- `lod`: 0..2 (три уровня детализации). Значение `−1` → подставляется `current_lod` из инстанса.
+- `group`: 0..4 (пять групп). На практике чаще всего используется `group = 0`.
+
+### Раскладка записи узла (38 байт)
+
+```
+┌───────────────────────────────────────────────────────┐
+│ Header: 4 × uint16 (8 байт) │
+│ hdr0, hdr1, hdr2, hdr3 │
+├───────────────────────────────────────────────────────┤
+│ SlotIndex matrix: 3 LOD × 5 groups = 15 × uint16 │
+│ LOD 0: group[0..4] │
+│ LOD 1: group[0..4] │
+│ LOD 2: group[0..4] │
+└───────────────────────────────────────────────────────┘
+```
+
+| Смещение | Размер | Тип | Описание |
+|----------|--------|------------|-----------------------------------------|
+| 0 | 8 | uint16[4] | Заголовок узла (`hdr0..hdr3`, см. ниже) |
+| 8 | 30 | uint16[15] | Матрица слотов: `slotIndex[lod][group]` |
+
+`slotIndex = 0xFFFF` означает «слот отсутствует» — узел при данном LOD и группе не рисуется.
+
+Подтверждённые семантики полей `hdr*`:
+
+- `hdr1` (`+0x02`) — parent/index-link при построении инстанса (в `sub_1000A460` читается как индекс связанного узла, `0xFFFF` = нет связи).
+- `hdr2` (`+0x04`) — `mapStart` для Res19 (`0xFFFF` = нет карты; fallback по `hdr3`).
+- `hdr3` (`+0x06`) — `fallbackKeyIndex`/верхняя граница для map‑значений (используется в `sub_10012880`).
+
+`hdr0` (`+0x00`) по коду участвует в битовых проверках (`&0x40`, `byte+1 & 8`) и несёт флаги узла.
+
+**Группы (group 0..4):** в рантайме это ортогональный индекс к LOD (матрица 5×3 на узел). Имена групп в оригинальных ресурсах не подписаны; для 1:1 нужно сохранять группы как «сырой» индекс 0..4 без переинтерпретации.
+
+---
+
+## 1.6. Ресурс Res3 — Vertex Positions
+
+**Формат:** массив `float3` (IEEE 754 single‑precision).
+**Stride:** 12 байт.
+
+```c
+struct Position {
+ float x; // +0
+ float y; // +4
+ float z; // +8
+};
+```
+
+Чтение: `pos = *(float3*)(res3_data + 12 * vertexIndex)`.
+
+---
+
+## 1.7. Ресурс Res4 — Packed Normals
+
+**Формат:** 4 байта на вершину.
+**Stride:** 4 байта.
+
+```c
+struct PackedNormal {
+ int8_t nx; // +0
+ int8_t ny; // +1
+ int8_t nz; // +2
+ int8_t nw; // +3 (назначение не подтверждено: паддинг / знак / индекс)
+};
+```
+
+### Алгоритм декодирования (подтверждено по AniMesh.dll)
+
+> В движке используется делитель **127.0**, а не 128.0 (см. константу `127.0` рядом с `1024.0`/`32767.0`).
+
+```
+normal.x = clamp((float)nx / 127.0, -1.0, 1.0)
+normal.y = clamp((float)ny / 127.0, -1.0, 1.0)
+normal.z = clamp((float)nz / 127.0, -1.0, 1.0)
+```
+
+**Множитель:** `1.0 / 127.0 ≈ 0.0078740157`.
+**Диапазон входных значений:** −128..+127 → выход ≈ −1.007874..+1.0 → **после клампа** −1.0..+1.0.
+**Почему нужен кламп:** значение `-128` при делении на `127.0` даёт модуль чуть больше 1.
+**4‑й байт (nw):** используется ли он как часть нормали, как индекс или просто как выравнивание — не подтверждено. Рекомендация: игнорировать при первичном импорте.
+
+---
+
+## 1.8. Ресурс Res5 — Packed UV0
+
+**Формат:** 4 байта на вершину (два `int16`).
+**Stride:** 4 байта.
+
+```c
+struct PackedUV {
+ int16_t u; // +0
+ int16_t v; // +2
+};
+```
+
+### Алгоритм декодирования
+
+```
+uv.u = (float)u / 1024.0
+uv.v = (float)v / 1024.0
+```
+
+**Множитель:** `1.0 / 1024.0 = 0.0009765625`.
+**Диапазон входных значений:** −32768..+32767 → выход ≈ −32.0..+31.999.
+Значения >1.0 или <0.0 означают wrapping/repeat текстурных координат.
+
+### Алгоритм кодирования (для экспортёра)
+
+```
+packed_u = (int16_t)round(uv.u * 1024.0)
+packed_v = (int16_t)round(uv.v * 1024.0)
+```
+
+Результат обрезается (clamp) до диапазона `int16` (−32768..+32767).
+
+---
+
+## 1.9. Ресурс Res6 — Index Buffer
+
+**Формат:** массив `uint16` (беззнаковые 16‑битные индексы).
+**Stride:** 2 байта.
+
+Максимальное число вершин в одном batch: 65535.
+Индексы используются совместно с `baseVertex` из batch table:
+
+```
+actual_vertex_index = index_buffer[indexStart + i] + baseVertex
+```
+
+---
+
+## 1.10. Ресурс Res7 — Triangle Descriptors
+
+**Формат:** массив записей по 16 байт. Одна запись на треугольник.
+
+| Смещение | Размер | Тип | Описание |
+|----------|--------|----------|---------------------------------------------|
+| `+0x00` | 2 | `uint16` | `triFlags` — фильтрация/материал tri‑уровня |
+| `+0x02` | 2 | `uint16` | `linkTri0` — tri‑ref для связанного обхода |
+| `+0x04` | 2 | `uint16` | `linkTri1` — tri‑ref для связанного обхода |
+| `+0x06` | 2 | `uint16` | `linkTri2` — tri‑ref для связанного обхода |
+| `+0x08` | 2 | `int16` | `nX` (packed, scale `1/32767`) |
+| `+0x0A` | 2 | `int16` | `nY` (packed, scale `1/32767`) |
+| `+0x0C` | 2 | `int16` | `nZ` (packed, scale `1/32767`) |
+| `+0x0E` | 2 | `uint16` | `selPacked` — 3 селектора по 2 бита |
+
+Расшифровка `selPacked` (`AniMesh.dll!sub_10013680`):
+
+```c
+sel0 = selPacked & 0x3; if (sel0 == 3) sel0 = 0xFFFF;
+sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF;
+sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF;
+```
+
+`linkTri*` передаются в `sub_1000B2C0` и используются для построения соседнего набора треугольников при коллизии/пикинге.
+
+**Важно:** дескрипторы не хранят индексы вершин треугольника. Индексы берутся из Res6 (index buffer) через `indexStart`/`indexCount` соответствующего batch'а.
+
+Дескрипторы используются при обходе треугольников для коллизии и пикинга. `triStart` из slot table указывает, с какого дескриптора начинать обход для данного слота.
+
+---
+
+## 1.11. Ресурс Res13 — Batch Table
+
+**Формат:** массив записей по 20 байт. Batch — минимальная единица отрисовки.
+
+| Смещение | Размер | Тип | Описание |
+|----------|--------|--------|---------------------------------------------------------|
+| 0 | 2 | uint16 | `batchFlags` — битовая маска для фильтрации |
+| 2 | 2 | uint16 | `materialIndex` — индекс материала |
+| 4 | 2 | uint16 | `unk4` — неподтверждённое поле |
+| 6 | 2 | uint16 | `unk6` — вероятный `nodeIndex` (привязка batch к кости) |
+| 8 | 2 | uint16 | `indexCount` — число индексов (кратно 3) |
+| 10 | 4 | uint32 | `indexStart` — стартовый индекс в Res6 (в элементах) |
+| 14 | 2 | uint16 | `unk14` — неподтверждённое поле |
+| 16 | 4 | uint32 | `baseVertex` — смещение вершинного индекса |
+
+### Использование при рендере
+
+```
+for i in 0 .. indexCount-1:
+ raw_index = index_buffer[indexStart + i]
+ vertex_index = raw_index + baseVertex
+ position = res3[vertex_index]
+ normal = decode_normal(res4[vertex_index])
+ uv = decode_uv(res5[vertex_index])
+```
+
+**Примечание:** движок читает `indexStart` как `uint32` и умножает на 2 для получения байтового смещения в массиве `uint16`.
+
+---
+
+## 1.12. Ресурс Res10 — String Table
+
+Res10 — это **последовательность записей, индексируемых по `nodeIndex`** (см. `AniMesh.dll!sub_10012530`).
+
+Формат одной записи:
+
+```c
+struct Res10Record {
+ uint32_t len; // число символов без терминирующего '\0'
+ char text[]; // если len > 0: хранится len+1 байт (включая '\0')
+ // если len == 0: payload отсутствует
+};
+```
+
+Переход к следующей записи:
+
+```c
+next = cur + 4 + (len ? (len + 1) : 0);
+```
+
+`sub_10012530` возвращает:
+
+- `NULL`, если `len == 0`;
+- `record + 4`, если `len > 0` (указатель на C‑строку).
+
+Это значение используется в `sub_1000A460` для проверки имени текущего узла (например, поиск подстроки `"central"` при обработке node‑флагов).
+
+---
+
+
+---
+
+## 1.14. Опциональные vertex streams
+
+### Res15 — Дополнительный vertex stream (stride 8)
+
+- **Stride:** 8 байт на вершину.
+- **Кандидаты:** `float2 uv1` (lightmap / second UV layer), 4 × `int16` (2 UV‑пары), либо иной формат.
+- Загружается условно — если ресурс 15 отсутствует, указатель равен `NULL`.
+
+### Res16 — Tangent / Bitangent (stride 8, split 2×4)
+
+- **Stride:** 8 байт на вершину (2 подпотока по 4 байта).
+- При загрузке движок создаёт **два перемежающихся (interleaved) подпотока**:
+ - Stream A: `base + 0`, stride 8 — 4 байта (кандидат: packed tangent, `int8 × 4`)
+ - Stream B: `base + 4`, stride 8 — 4 байта (кандидат: packed bitangent, `int8 × 4`)
+- Если ресурс 16 отсутствует, оба указателя обнуляются.
+- **Важно:** в оригинальном `sub_10015FD0` при отсутствии Res16 страйды `+0x50/+0x58` явным образом не обнуляются; это безопасно, потому что оба указателя равны `NULL` и код не должен обращаться к потокам без проверки указателя.
+- Декодирование предположительно аналогично нормалям: `component / 127.0` (как Res4), но требует подтверждения; при импорте — кламп в [-1..1].
+
+### Res18 — Vertex Color (stride 4)
+
+- **Stride:** 4 байта на вершину.
+- **Кандидаты:** `D3DCOLOR` (BGRA), packed параметры освещения, vertex AO.
+- Загружается условно (через проверку `niFindRes` на возврат `−1`).
+
+### Res20 — Дополнительная таблица
+
+- Присутствует не всегда.
+- Из каталожной записи NRes считывается поле `attribute_1` (смещение `+4`) и сохраняется как метаданные.
+- **Кандидаты:** vertex remap, дополнительные данные для эффектов/деформаций.
+
+---
+
diff --git a/docs/specs/msh-notes.md b/docs/specs/msh-notes.md
new file mode 100644
index 0000000..1bd4808
--- /dev/null
+++ b/docs/specs/msh-notes.md
@@ -0,0 +1,277 @@
+# 3D implementation notes
+
+Контрольные заметки, сводки алгоритмов и остаточные семантические вопросы по 3D-подсистемам.
+
+---
+
+## 5.1. Порядок байт
+
+Все значения хранятся в **little‑endian** порядке (платформа x86/Win32).
+
+## 5.2. Выравнивание
+
+- **NRes‑ресурсы:** данные каждого ресурса внутри NRes‑архива выровнены по границе **8 байт** (0‑padding).
+- **Внутренняя структура ресурсов:** таблицы Res1/Res2/Res7/Res13 не имеют межзаписевого выравнивания — записи идут подряд.
+- **Vertex streams:** stride'ы фиксированы (12/4/8 байт) — вершинные данные идут подряд без паддинга.
+
+## 5.3. Размеры записей на диске
+
+| Ресурс | Запись | Размер (байт) | Stride |
+|--------|-----------|---------------|-------------------------|
+| Res1 | Node | 38 | 38 (19×u16) |
+| Res2 | Slot | 68 | 68 |
+| Res3 | Position | 12 | 12 (3×f32) |
+| Res4 | Normal | 4 | 4 (4×s8) |
+| Res5 | UV0 | 4 | 4 (2×s16) |
+| Res6 | Index | 2 | 2 (u16) |
+| Res7 | TriDesc | 16 | 16 |
+| Res8 | AnimKey | 24 | 24 |
+| Res10 | StringRec | переменный | `4 + (len ? len+1 : 0)` |
+| Res13 | Batch | 20 | 20 |
+| Res19 | AnimMap | 2 | 2 (u16) |
+| Res15 | VtxStr | 8 | 8 |
+| Res16 | VtxStr | 8 | 8 (2×4) |
+| Res18 | VtxStr | 4 | 4 |
+
+## 5.4. Вычисление количества элементов
+
+Количество записей вычисляется из размера ресурса:
+
+```
+count = resource_data_size / record_stride
+```
+
+Например:
+
+- `vertex_count = res3_size / 12`
+- `index_count = res6_size / 2`
+- `batch_count = res13_size / 20`
+- `slot_count = (res2_size - 140) / 68`
+- `node_count = res1_size / 38`
+- `tri_desc_count = res7_size / 16`
+- `anim_key_count = res8_size / 24`
+- `anim_map_count = res19_size / 2`
+
+Для Res10 нет фиксированного stride: нужно последовательно проходить записи `u32 len` + `(len ? len+1 : 0)` байт.
+
+## 5.5. Идентификация ресурсов в NRes
+
+Ресурсы модели идентифицируются по полю `type` (смещение 0) в каталожной записи NRes. Загрузчик использует `niFindRes(archive, type, subtype)` для поиска, где `type` — число (1, 2, 3, ... 20), а `subtype` (byte) — уточнение (из аргумента загрузчика).
+
+## 5.6. Минимальный набор для рендера
+
+Для статической модели без анимации достаточно:
+
+| Ресурс | Обязательность |
+|--------|------------------------------------------------|
+| Res1 | Да |
+| Res2 | Да |
+| Res3 | Да |
+| Res4 | Рекомендуется |
+| Res5 | Рекомендуется |
+| Res6 | Да |
+| Res7 | Для коллизии |
+| Res13 | Да |
+| Res10 | Желательно (узловые имена/поведенческие ветки) |
+| Res8 | Нет (анимация) |
+| Res19 | Нет (анимация) |
+| Res15 | Нет |
+| Res16 | Нет |
+| Res18 | Нет |
+| Res20 | Нет |
+
+## 5.7. Сводка алгоритмов декодирования
+
+### Позиции (Res3)
+
+```python
+def decode_position(data, vertex_index):
+ offset = vertex_index * 12
+ x = struct.unpack_from('<f', data, offset)[0]
+ y = struct.unpack_from('<f', data, offset + 4)[0]
+ z = struct.unpack_from('<f', data, offset + 8)[0]
+ return (x, y, z)
+```
+
+### Нормали (Res4)
+
+```python
+def decode_normal(data, vertex_index):
+ offset = vertex_index * 4
+ nx = struct.unpack_from('<b', data, offset)[0] # int8
+ ny = struct.unpack_from('<b', data, offset + 1)[0]
+ nz = struct.unpack_from('<b', data, offset + 2)[0]
+ # nw = data[offset + 3] # не используется
+ return (
+ max(-1.0, min(1.0, nx / 127.0)),
+ max(-1.0, min(1.0, ny / 127.0)),
+ max(-1.0, min(1.0, nz / 127.0)),
+ )
+```
+
+### UV‑координаты (Res5)
+
+```python
+def decode_uv(data, vertex_index):
+ offset = vertex_index * 4
+ u = struct.unpack_from('<h', data, offset)[0] # int16
+ v = struct.unpack_from('<h', data, offset + 2)[0]
+ return (u / 1024.0, v / 1024.0)
+```
+
+### Кодирование нормали (для экспортёра)
+
+```python
+def encode_normal(nx, ny, nz):
+ return (
+ max(-128, min(127, int(round(nx * 127.0)))),
+ max(-128, min(127, int(round(ny * 127.0)))),
+ max(-128, min(127, int(round(nz * 127.0)))),
+ 0 # nw = 0 (безопасное значение)
+ )
+```
+
+### Кодирование UV (для экспортёра)
+
+```python
+def encode_uv(u, v):
+ return (
+ max(-32768, min(32767, int(round(u * 1024.0)))),
+ max(-32768, min(32767, int(round(v * 1024.0))))
+ )
+```
+
+### Строки узлов (Res10)
+
+```python
+def parse_res10_for_nodes(buf: bytes, node_count: int) -> list[str | None]:
+ out = []
+ off = 0
+ for _ in range(node_count):
+ ln = struct.unpack_from('<I', buf, off)[0]
+ off += 4
+ if ln == 0:
+ out.append(None)
+ continue
+ raw = buf[off:off + ln + 1] # len + '\0'
+ out.append(raw[:-1].decode('ascii', errors='replace'))
+ off += ln + 1
+ return out
+```
+
+### Ключ анимации (Res8) и mapping (Res19)
+
+```python
+def decode_anim_key24(buf: bytes, idx: int):
+ o = idx * 24
+ px, py, pz, t = struct.unpack_from('<4f', buf, o)
+ qx, qy, qz, qw = struct.unpack_from('<4h', buf, o + 16)
+ s = 1.0 / 32767.0
+ return (px, py, pz), t, (qx * s, qy * s, qz * s, qw * s)
+```
+
+### Эффектный поток (FXID)
+
+```python
+FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208}
+
+def parse_fx_payload(raw: bytes):
+ cmd_count = struct.unpack_from('<I', raw, 0)[0]
+ ptr = 0x3C
+ cmds = []
+ for _ in range(cmd_count):
+ w = struct.unpack_from('<I', raw, ptr)[0]
+ op = w & 0xFF
+ enabled = (w >> 8) & 1
+ size = FX_CMD_SIZE[op]
+ cmds.append((op, enabled, ptr, size))
+ ptr += size
+ if ptr != len(raw):
+ raise ValueError('tail bytes after command stream')
+ return cmds
+```
+
+### Texm (header + mips + Page)
+
+```python
+def parse_texm(raw: bytes):
+ magic, w, h, mips, f4, f5, unk6, fmt = struct.unpack_from('<8I', raw, 0)
+ assert magic == 0x6D786554 # 'Texm'
+ bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4)
+ pix_sum = 0
+ mw, mh = w, h
+ for _ in range(mips):
+ pix_sum += mw * mh
+ mw = max(1, mw >> 1)
+ mh = max(1, mh >> 1)
+ off = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum
+ page = None
+ if off + 8 <= len(raw) and raw[off:off+4] == b'Page':
+ n = struct.unpack_from('<I', raw, off + 4)[0]
+ page = [struct.unpack_from('<4h', raw, off + 8 + i * 8) for i in range(n)]
+ return (w, h, mips, fmt, f4, f5, unk6, page)
+```
+
+---
+
+# Часть 6. Остаточные семантические вопросы
+
+Пункты ниже **не блокируют 1:1-парсинг/рендер/интерполяцию** (все бинарные структуры уже определены), но их человеко‑читаемая трактовка может быть уточнена дополнительно.
+
+## 6.1. Batch table — смысл `unk4/unk6/unk14`
+
+Физическое расположение полей известно, но доменное имя/назначение не зафиксировано:
+
+- `unk4` (`+0x04`)
+- `unk6` (`+0x06`)
+- `unk14` (`+0x0E`)
+
+## 6.2. Node flags и имена групп
+
+- Биты в `Res1.hdr0` используются в ряде рантайм‑веток, но их «геймдизайн‑имена» неизвестны.
+- Для group‑индекса `0..4` не найдено текстовых label'ов в ресурсах; для совместимости нужно сохранять числовой индекс как есть.
+
+## 6.3. Slot tail `unk30..unk40`
+
+Хвост слота (`+0x30..+0x43`, `5×uint32`) стабильно присутствует в формате, но движок не делает явной семантической декомпозиции этих пяти слов в path'ах загрузки/рендера/коллизии.
+
+## 6.4. Effect command payload semantics
+
+Container/stream формально полностью восстановлен (header, opcode, размеры, инстанцирование). Остаётся необязательная задача: дать «человеко‑читаемые» имена каждому полю внутри payload конкретных opcode.
+
+## 6.5. Поля `TexmHeader.flags4/flags5/unk6`
+
+Бинарный layout и декодер известны, но значения этих трёх полей в контенте используются контекстно; для 1:1 достаточно хранить/восстанавливать их без модификации.
+
+## 6.6. Что пока не хватает для полноценного обратного экспорта (`OBJ -> MSH/NRes`)
+
+Ниже перечислено то, что нужно закрыть для **lossless round-trip** и 1:1‑поведения при импорте внешней геометрии обратно в формат игры.
+
+### A) Неполная «авторская» семантика бинарных таблиц
+
+1. `Res2` header (`первые 0x8C`): не зафиксированы все поля и правила их вычисления при генерации нового файла (а не copy-through из оригинала).
+2. `Res7` tri-descriptor: для 16‑байтной записи декодирован базовый каркас, но остаётся неформализованной часть служебных бит/полей, нужных для стабильной генерации adjacency/служебной топологии.
+3. `Res13` поля `unk4/unk6/unk14`: для парсинга достаточно, но для генерации «канонических» значений из голого `OBJ` правила не определены.
+4. `Res2` slot tail (`unk30..unk40`): семантика не разложена, поэтому при экспорте новых ассетов нет детерминированной формулы заполнения.
+
+### B) Анимационный path ещё не закрыт как writer
+
+1. Нужен полный writer для `Res8/Res19`:
+ - точная спецификация байтового формата на запись;
+ - правила генерации mapping (`Res19`) по узлам/кадрам;
+ - жёсткая фиксация округления как в x87 path (включая edge-case на границах кадра).
+2. Правила биндинга узлов/строк (`Res10`) и `slotFlags` к runtime‑сущностям пока описаны частично и требуют формализации именно для импорта новых данных.
+
+### C) Материалы, текстуры, эффекты для «полного ассета»
+
+1. Для `Texm` не завершён writer, покрывающий все используемые режимы (включая palette path, mip-chain, `Page`, и правила заполнения служебных полей).
+2. Для `FXID` известен контейнер/длины команд, но не завершена field-level семантика payload всех opcode для генерации новых эффектов, эквивалентных оригинальному пайплайну.
+3. Экспорт только `OBJ` покрывает геометрию; для игрового ассета нужен sidecar-слой (материалы/текстуры/эффекты/анимация), иначе импорт неизбежно неполный.
+
+### D) Что это означает на практике
+
+1. `OBJ -> MSH` сейчас реалистичен как **ограниченный static-экспорт** (позиции/индексы/часть batch/slot структуры).
+2. `OBJ -> полноценный игровой ресурс` (без потерь, с поведением 1:1) пока недостижим без закрытия пунктов A/B/C.
+3. До закрытия пунктов A/B/C рекомендуется использовать режим:
+ - геометрия экспортируется из `OBJ`;
+ - неизвестные/служебные поля берутся copy-through из референсного оригинального ассета той же структуры.
diff --git a/docs/specs/msh.md b/docs/specs/msh.md
new file mode 100644
index 0000000..e2623f8
--- /dev/null
+++ b/docs/specs/msh.md
@@ -0,0 +1,22 @@
+# Форматы 3D-ресурсов движка NGI
+
+Этот документ теперь является обзором и точкой входа в набор отдельных спецификаций.
+
+## Структура спецификаций
+
+1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица.
+2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция.
+3. [Materials + Texm](materials-texm.md) — материалы, текстуры, палитры, `WEAR`, `LIGHTMAPS`, `Texm`.
+4. [FXID](fxid.md) — контейнер эффекта и команды runtime-потока.
+5. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
+6. [Runtime pipeline](runtime-pipeline.md) — межмодульное поведение движка в кадре.
+7. [3D implementation notes](msh-notes.md) — контрольные заметки, декодирование и открытые вопросы.
+
+## Связанные спецификации
+
+- [NRes / RsLi](nres.md)
+
+## Принцип декомпозиции
+
+- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
+- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько DLL и не является форматом на диске.
diff --git a/docs/specs/network.md b/docs/specs/network.md
new file mode 100644
index 0000000..1950e8a
--- /dev/null
+++ b/docs/specs/network.md
@@ -0,0 +1,5 @@
+# Network system
+
+Документ описывает сетевую подсистему: протокол обмена, синхронизацию состояния и сетевую архитектуру (client-server/P2P).
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Net.dll`.
diff --git a/docs/specs/nres.md b/docs/specs/nres.md
new file mode 100644
index 0000000..32ccb1b
--- /dev/null
+++ b/docs/specs/nres.md
@@ -0,0 +1,718 @@
+# Форматы игровых ресурсов
+
+## Обзор
+
+Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов:
+
+1. **NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей.
+
+2. **RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.
+
+---
+
+## Часть 1. Формат NRes
+
+### 1.1. Общая структура файла
+
+```
+┌──────────────────────────┐ Смещение 0
+│ Заголовок (16 байт) │
+├──────────────────────────┤ Смещение 16
+│ │
+│ Данные ресурсов │
+│ (выровнены по 8 байт) │
+│ │
+├──────────────────────────┤ Смещение = total_size - entry_count × 64
+│ Каталог записей │
+│ (entry_count × 64 байт) │
+└──────────────────────────┘ Смещение = total_size
+```
+
+### 1.2. Заголовок файла (16 байт)
+
+| Смещение | Размер | Тип | Значение | Описание |
+| -------- | ------ | ------- | ------------------- | ------------------------------------ |
+| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) |
+| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
+| 8 | 4 | int32 | — | Количество записей в каталоге |
+| 12 | 4 | int32 | — | Полный размер файла в байтах |
+
+**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
+
+### 1.3. Положение каталога в файле
+
+Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
+
+```
+directory_offset = total_size - entry_count × 64
+```
+
+Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
+
+### 1.4. Запись каталога (64 байта)
+
+Каждая запись каталога занимает ровно **64 байта** (0x40):
+
+| Смещение | Размер | Тип | Описание |
+| -------- | ------ | -------- | ------------------------------------------------- |
+| 0 | 4 | uint32 | Тип / идентификатор ресурса |
+| 4 | 4 | uint32 | Атрибут 1 (например, формат, дата, категория) |
+| 8 | 4 | uint32 | Атрибут 2 (например, подтип, метка времени) |
+| 12 | 4 | uint32 | Размер данных ресурса в байтах |
+| 16 | 4 | uint32 | Атрибут 3 (дополнительный параметр) |
+| 20 | 36 | char[36] | Имя файла (null-terminated, макс. 35 символов) |
+| 56 | 4 | uint32 | Смещение данных от начала файла |
+| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
+
+#### Поле «Имя файла» (смещение 20, 36 байт)
+
+- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
+- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
+- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
+
+#### Поле «Индекс сортировки» (смещение 60)
+
+Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
+
+**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
+
+#### Поле «Смещение данных» (смещение 56)
+
+Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
+
+### 1.5. Выравнивание данных
+
+При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
+
+```c
+padding = ((data_size + 7) & ~7) - data_size;
+// Если padding > 0, записываются нулевые байты
+```
+
+Таким образом, каждый блок данных начинается с адреса, кратного 8.
+
+При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
+
+### 1.6. Создание файла (API `niCreateResFile`)
+
+При создании нового файла:
+
+1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
+2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
+
+При закрытии файла (`sub_100122D0`):
+
+1. Заголовок переписывается в начало файла (16 байт).
+2. Вычисляется `total_size = data_end_offset + entry_count × 64`.
+3. Индексы сортировки пересчитываются.
+4. Каталог записей записывается в конец файла.
+
+### 1.7. Режимы сортировки каталога
+
+Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11):
+
+| Режим | Порядок сортировки |
+| ----- | --------------------------------- |
+| 0 | Без сортировки (сброс) |
+| 1 | По атрибуту 1 (смещение 4) |
+| 2 | По атрибуту 2 (смещение 8) |
+| 3 | По (атрибут 1, атрибут 2) |
+| 4 | По типу ресурса (смещение 0) |
+| 5 | По (тип, атрибут 1) |
+| 6 | По (тип, атрибут 1) — идентичен 5 |
+| 7 | По (тип, атрибут 1, атрибут 2) |
+| 8 | По имени (регистронезависимо) |
+| 9 | По (тип, имя) |
+| 10 | По (атрибут 1, имя) |
+| 11 | По (атрибут 2, имя) |
+
+### 1.8. Операция `niOpenResFileEx` — флаги открытия
+
+Второй параметр — битовые флаги:
+
+| Бит | Маска | Описание |
+| --- | ----- | ----------------------------------------------------------------------------------- |
+| 0 | 0x01 | Sequential scan hint (`FILE_FLAG_SEQUENTIAL_SCAN` вместо `FILE_FLAG_RANDOM_ACCESS`) |
+| 1 | 0x02 | Открыть для записи (read-write). Без флага — только чтение |
+| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
+| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
+
+### 1.9. Виртуальное касание страниц
+
+Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
+
+```
+for (result = 0x10000; result < size; result += 4096);
+```
+
+Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память.
+
+---
+
+## Часть 2. Формат RsLi
+
+### 2.1. Общая структура файла
+
+```
+┌───────────────────────────────┐ Смещение 0
+│ Заголовок файла (32 байта) │
+├───────────────────────────────┤ Смещение 32
+│ Таблица записей (зашифрована)│
+│ (entry_count × 32 байт) │
+├───────────────────────────────┤ Смещение 32 + entry_count × 32
+│ │
+│ Данные ресурсов │
+│ │
+├───────────────────────────────┤
+│ [Опциональный трейлер — 6 б] │
+└───────────────────────────────┘
+```
+
+### 2.2. Заголовок файла (32 байта)
+
+| Смещение | Размер | Тип | Значение | Описание |
+| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
+| 0 | 2 | char[2] | `NL` (0x4C4E) | Магическая сигнатура |
+| 2 | 1 | uint8 | `0x00` | Зарезервировано (должно быть 0) |
+| 3 | 1 | uint8 | `0x01` | Версия формата |
+| 4 | 2 | int16 | — | Количество записей (sign-extended при чтении) |
+| 6 | 8 | — | — | Зарезервировано / не используется |
+| 14 | 2 | uint16 | `0xABBA` или иное | Флаг предсортировки (см. ниже) |
+| 16 | 4 | — | — | Зарезервировано |
+| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
+| 24 | 8 | — | — | Зарезервировано |
+
+#### Флаг предсортировки (смещение 14)
+
+- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XOR‑ключ для данных).
+- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XOR‑ключ (младшие 16 бит).
+
+### 2.3. XOR-шифр таблицы записей
+
+Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
+
+#### Начальное состояние
+
+```
+seed = *(uint32*)(header + 20)
+lo = seed & 0xFF // Младший байт
+hi = (seed >> 8) & 0xFF // Второй байт
+```
+
+#### Алгоритм дешифровки (побайтовый)
+
+Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
+
+```
+step 1: lo = hi ^ ((lo << 1) & 0xFF) // Сдвиг lo влево на 1, XOR с hi
+step 2: decrypted[i] = lo ^ encrypted[i] // Расшифровка байта
+step 3: hi = lo ^ ((hi >> 1) & 0xFF) // Сдвиг hi вправо на 1, XOR с lo
+```
+
+**Пример реализации:**
+
+```python
+def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
+ lo = seed & 0xFF
+ hi = (seed >> 8) & 0xFF
+ result = bytearray(len(encrypted_data))
+ for i in range(len(encrypted_data)):
+ lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
+ result[i] = lo ^ encrypted_data[i]
+ hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
+ return bytes(result)
+```
+
+Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
+
+### 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
+
+После дешифровки каждая запись имеет следующую структуру:
+
+| Смещение | Размер | Тип | Описание |
+| -------- | ------ | -------- | -------------------------------------------------------------- |
+| 0 | 12 | char[12] | Имя ресурса (ASCII, обычно uppercase; строка читается до `\0`) |
+| 12 | 4 | — | Зарезервировано (движком игнорируется) |
+| 16 | 2 | int16 | **Флаги** (метод сжатия и атрибуты) |
+| 18 | 2 | int16 | **`sort_to_original[i]` / XOR‑ключ** (см. ниже) |
+| 20 | 4 | uint32 | **Размер распакованных данных** (`unpacked_size`) |
+| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) |
+| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) |
+
+#### Имена ресурсов
+
+- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`.
+- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно.
+- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён.
+
+#### Поле `sort_to_original[i]` (смещение 18)
+
+Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
+
+- Таблица реализована “внутри записей”: значение берётся как `entry[i].sort_to_original` (где `i` — позиция двоичного поиска), а реальная запись для сравнения берётся как `entry[ sort_to_original[i] ]`.
+- Тем же значением (младшие 16 бит) инициализируется XOR‑шифр данных для методов, где он используется (0x20/0x60/0xA0). Поэтому при упаковке/шифровании данных ключ должен совпадать с итоговым `sort_to_original[i]` (см. флаг 0xABBA в разделе 2.2).
+
+Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
+
+### 2.5. Поле флагов (смещение 16 записи)
+
+Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
+
+```
+Биты [8:5] (маска 0x1E0): Метод сжатия/шифрования
+Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
+```
+
+#### Методы сжатия (биты 8–5, маска 0x1E0)
+
+| Значение | Hex | Описание |
+| -------- | ----- | --------------------------------------- |
+| 0x000 | 0x00 | Без сжатия (копирование) |
+| 0x020 | 0x20 | Только XOR-шифр |
+| 0x040 | 0x40 | LZSS (простой вариант) |
+| 0x060 | 0x60 | XOR-шифр + LZSS (простой вариант) |
+| 0x080 | 0x80 | LZSS с адаптивным кодированием Хаффмана |
+| 0x0A0 | 0xA0 | XOR-шифр + LZSS с Хаффманом |
+| 0x100 | 0x100 | Deflate (аналог zlib/RFC 1951) |
+
+Примечание: `rsGetPackMethod()` возвращает `flags & 0x1C0` (без бита 0x20). Поэтому:
+
+- для 0x20 вернётся 0x00,
+- для 0x60 вернётся 0x40,
+- для 0xA0 вернётся 0x80.
+
+#### Бит 0x40 (выделение +0x12 и последующее `realloc`)
+
+Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
+
+Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
+
+### 2.6. Размеры данных
+
+В каждой записи на диске хранятся оба значения:
+
+- `unpacked_size` (смещение 20) — размер распакованных данных.
+- `packed_size` (смещение 28) — размер упакованных данных (байт во входном потоке для выбранного метода).
+
+Для метода 0x00 (без сжатия) обычно `packed_size == unpacked_size`.
+
+`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
+
+Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
+
+### 2.7. Опциональный трейлер медиа (6 байт)
+
+При открытии с флагом `a2 & 2`:
+
+| Смещение от конца | Размер | Тип | Описание |
+| ----------------- | ------ | ------- | ----------------------- |
+| −6 | 2 | char[2] | Сигнатура `AO` (0x4F41) |
+| −4 | 4 | uint32 | Смещение медиа-оверлея |
+
+Если трейлер присутствует, все смещения данных в записях корректируются: `effective_offset = entry_offset + media_overlay_offset`.
+
+---
+
+## Часть 3. Алгоритмы сжатия (формат RsLi)
+
+### 3.1. XOR-шифр данных (метод 0x20)
+
+Алгоритм идентичен XOR‑шифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
+
+Важно про размер входа:
+
+- В ветке **0x20** движок XOR‑ит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
+- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
+
+#### Инициализация
+
+```
+key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
+lo = key16 & 0xFF
+hi = (key16 >> 8) & 0xFF
+```
+
+#### Дешифровка (псевдокод)
+
+```
+for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
+ lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
+ out[i] = in[i] ^ lo
+ hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
+```
+
+### 3.2. LZSS — простой вариант (метод 0x40)
+
+Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
+
+#### Параметры
+
+| Параметр | Значение |
+| ----------------------------- | ------------------ |
+| Размер кольцевого буфера | 4096 байт (0x1000) |
+| Начальная позиция записи | 4078 (0xFEE) |
+| Начальное заполнение | 0x20 (пробел) |
+| Минимальная длина совпадения | 3 |
+| Максимальная длина совпадения | 18 (4 бита + 3) |
+
+#### Алгоритм декомпрессии
+
+```
+Инициализация:
+ ring_buffer[0..4095] = 0x20 (заполнить пробелами)
+ ring_pos = 4078
+ flags_byte = 0
+ flags_bits_remaining = 0
+
+Цикл (пока не заполнен выходной буфер И не исчерпан входной):
+
+ 1. Если flags_bits_remaining == 0:
+ - Прочитать 1 байт из входного потока → flags_byte
+ - flags_bits_remaining = 8
+
+ Декодировать как:
+ - Старший бит устанавливается в 0x7F (маркер)
+ - Оставшиеся 7 бит — флаги текущей группы
+
+ Реально в коде: control_word = (flags_byte) | (0x7F << 8)
+ Каждый бит проверяется сдвигом вправо.
+
+ 2. Проверить младший бит control_word:
+
+ Если бит = 1 (литерал):
+ - Прочитать 1 байт из входного потока → byte
+ - ring_buffer[ring_pos] = byte
+ - ring_pos = (ring_pos + 1) & 0xFFF
+ - Записать byte в выходной буфер
+
+ Если бит = 0 (ссылка):
+ - Прочитать 2 байта: low_byte, high_byte
+ - offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит
+ - length = (high_byte & 0x0F) + 3 // 4 бита + 3
+ - Скопировать length байт из ring_buffer[offset...]:
+ для j от 0 до length-1:
+ byte = ring_buffer[(offset + j) & 0xFFF]
+ ring_buffer[ring_pos] = byte
+ ring_pos = (ring_pos + 1) & 0xFFF
+ записать byte в выходной буфер
+
+ 3. Сдвинуть control_word вправо на 1 бит
+ 4. flags_bits_remaining -= 1
+```
+
+#### Подробная раскладка пары ссылки (2 байта)
+
+```
+Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
+Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина − 3
+
+offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095
+length = (high & 0x0F) + 3 // Диапазон: 3–18
+```
+
+### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
+
+Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
+
+#### Параметры
+
+| Параметр | Значение |
+| -------------------------------- | ------------------------------ |
+| Размер кольцевого буфера | 4096 байт |
+| Начальная позиция записи | **4036** (0xFC4) |
+| Начальное заполнение | 0x20 (пробел) |
+| Количество листовых узлов дерева | 314 |
+| Символы литералов | 0–255 (байты) |
+| Символы длин | 256–313 (длина = символ − 253) |
+| Начальная длина | 3 (при символе 256) |
+| Максимальная длина | 60 (при символе 313) |
+
+#### Дерево Хаффмана
+
+Дерево строится как **адаптивное** (dynamic, self-adjusting):
+
+- **627 узлов**: 314 листовых + 313 внутренних.
+- Все листья изначально имеют **вес 1**.
+- Корень дерева — узел с индексом 0 (в массиве `parent`).
+- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
+- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
+
+#### Кодирование позиции
+
+Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
+
+- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов.
+- Из потока считываются дополнительные биты, которые объединяются с базовым значением.
+- Финальная позиция: `pos = (ring_pos − 1 − decoded_position) & 0xFFF`
+
+**Таблицы инициализации** (d-коды):
+
+```
+Таблица базовых значений — byte_100371D0[6]:
+ { 0x01, 0x03, 0x08, 0x0C, 0x18, 0x10 }
+
+Таблица дополнительных битов — byte_100371D6[6]:
+ { 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
+```
+
+#### Алгоритм декомпрессии (высокоуровневый)
+
+```
+Инициализация:
+ ring_buffer[0..4095] = 0x20
+ ring_pos = 4036
+ Инициализировать дерево Хаффмана (314 листьев, все веса = 1)
+ Инициализировать таблицы d-кодов
+
+Цикл:
+ 1. Декодировать символ из потока по дереву Хаффмана:
+ - Начать с корня
+ - Читать биты, спускаться по дереву (0 = левый, 1 = правый)
+ - Пока не достигнут лист → символ = лист − 627
+
+ 2. Обновить дерево Хаффмана для декодированного символа
+
+ 3. Если символ < 256 (литерал):
+ - ring_buffer[ring_pos] = символ
+ - ring_pos = (ring_pos + 1) & 0xFFF
+ - Записать символ в выходной буфер
+
+ 4. Если символ >= 256 (ссылка):
+ - length = символ − 253
+ - Декодировать позицию через d-код:
+ a) Прочитать 8 бит из потока
+ b) Найти d-код и дополнительные биты по таблице
+ c) Прочитать дополнительные биты
+ d) position = (ring_pos − 1 − full_position) & 0xFFF
+ - Скопировать length байт из ring_buffer[position...]
+
+ 5. Если выходной буфер заполнен → завершить
+```
+
+### 3.4. XOR + LZSS (методы 0x60 и 0xA0)
+
+Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
+
+#### Алгоритм
+
+1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
+2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
+3. Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной.
+4. Освободить временный буфер.
+
+- **0x60** — XOR + простой LZSS (раздел 3.2)
+- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
+
+#### Начальное состояние XOR для данных
+
+При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
+
+### 3.5. Deflate (метод 0x100)
+
+Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
+
+#### Общая структура
+
+Данные состоят из последовательности блоков. Каждый блок начинается с:
+
+- **1 бит** — `is_final`: признак последнего блока
+- **2 бита** — `block_type`: тип блока
+
+#### Типы блоков
+
+| block_type | Описание | Функция |
+| ---------- | --------------------------- | ---------------- |
+| 0 | Без сжатия (stored) | `sub_1001A750` |
+| 1 | Фиксированные коды Хаффмана | `sub_1001A8C0` |
+| 2 | Динамические коды Хаффмана | `sub_1001AA30` |
+| 3 | Зарезервировано (ошибка) | Возвращает код 2 |
+
+#### Блок типа 0 (stored)
+
+1. Отбросить оставшиеся биты до границы байта (выравнивание).
+2. Прочитать 16 бит — `LEN` (длина блока).
+3. Прочитать 16 бит — `NLEN` (дополнение длины, `NLEN == ~LEN & 0xFFFF`).
+4. Проверить: `LEN == (uint16)(~NLEN)`. При несовпадении — ошибка.
+5. Скопировать `LEN` байт из входного потока в выходной.
+
+Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
+
+#### Блок типа 1 (фиксированные коды)
+
+Стандартные коды Deflate:
+
+- Литералы/длины: 288 кодов
+ - 0–143: 8-битные коды
+ - 144–255: 9-битные коды
+ - 256–279: 7-битные коды
+ - 280–287: 8-битные коды
+- Дистанции: 30 кодов, все 5-битные
+
+Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
+
+#### Блок типа 2 (динамические коды)
+
+1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257–286.
+2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30.
+3. Прочитать 4 бита → `HCLEN` (количество кодов длин − 4). Диапазон: 4–19.
+4. Прочитать `HCLEN` × 3 бит — длины кодов для алфавита длин.
+5. Построить дерево Хаффмана для алфавита длин (19 символов).
+6. С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций.
+7. Построить два дерева Хаффмана: для литералов/длин и для дистанций.
+8. Декодировать данные.
+
+**Порядок кодов длин** (стандартный Deflate):
+
+```
+{ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }
+```
+
+Хранится в `dword_10037060`.
+
+#### Валидации
+
+- `HLIT + 257 <= 286` (max 0x11E)
+- `HDIST + 1 <= 30` (max 0x1E)
+- При нарушении — возвращается ошибка 1.
+
+### 3.6. Метод 0x00 (без сжатия)
+
+Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
+
+---
+
+## Часть 4. Внутренние структуры в памяти
+
+### 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
+
+```c
+struct NResArchive { // Размер: 0x68 (104 байта)
+ void* vtable; // +0: Указатель на таблицу виртуальных методов
+ int32_t entry_count; // +4: Количество записей
+ void* mapped_base; // +8: Базовый адрес mapped view
+ void* directory_ptr; // +12: Указатель на каталог записей в памяти
+ char* filename; // +16: Путь к файлу (_strdup)
+ int32_t ref_count; // +20: Счётчик ссылок
+ uint32_t last_release_time; // +24: timeGetTime() при последнем Release
+ // +28..+91: Для raw-режима — встроенная запись (единственный File entry)
+ NResArchive* next; // +92: Следующий архив в связном списке
+ uint8_t is_writable; // +100: Файл открыт для записи
+ uint8_t is_cacheable; // +101: Не выгружать при refcount = 0
+};
+```
+
+### 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
+
+```c
+struct RsLibHeader { // 56 байт (14 DWORD)
+ uint32_t magic; // +0: 'RsLi' (0x694C7352)
+ int32_t entry_count; // +4: Количество записей
+ uint32_t media_offset; // +8: Смещение медиа-оверлея
+ uint32_t reserved_0C; // +12: 0
+ HANDLE file_handle_2; // +16: -1 (дополнительный хэндл)
+ uint32_t reserved_14; // +20: 0
+ uint32_t reserved_18; // +24: —
+ uint32_t reserved_1C; // +28: 0
+ HANDLE mapping_handle_2; // +32: -1
+ uint32_t reserved_24; // +36: 0
+ uint32_t flag_28; // +40: (flags >> 7) & 1
+ HANDLE file_handle; // +44: Хэндл файла
+ HANDLE mapping_handle; // +48: Хэндл файлового маппинга
+ void* mapped_view; // +52: Указатель на mapped view
+};
+// Далее следуют entry_count записей по 64 байта каждая
+```
+
+#### Внутренняя запись RsLi (64 байта)
+
+```c
+struct RsLibEntry { // 64 байта (16 DWORD)
+ char name[16]; // +0: Имя (12 из файла + 4 нуля)
+ int32_t flags; // +16: Флаги (sign-extended из int16)
+ int32_t sort_index; // +20: sort_to_original[i] (таблица индексов / XOR‑ключ)
+ uint32_t uncompressed_size; // +24: Размер несжатых данных (из поля 20 записи)
+ void* data_ptr; // +28: Указатель на данные в mapped view
+ uint32_t compressed_size; // +32: Размер сжатых данных (из поля 28 записи)
+ uint32_t reserved_24; // +36: 0
+ uint32_t reserved_28; // +40: 0
+ uint32_t reserved_2C; // +44: 0
+ void* loaded_data; // +48: Указатель на декомпрессированные данные
+ // +52..+63: дополнительные поля
+};
+```
+
+---
+
+## Часть 5. Экспортируемые API-функции
+
+### 5.1. NRes API
+
+| Функция | Описание |
+| ------------------------------ | ------------------------------------------------------------------------- |
+| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` |
+| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами |
+| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
+| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
+
+### 5.2. RsLi API
+
+| Функция | Описание |
+| ------------------------------- | -------------------------------------------------------- |
+| `rsOpenLib(path, flags)` | Открыть RsLi-библиотеку |
+| `rsCloseLib(lib)` | Закрыть библиотеку |
+| `rsLibNum(lib)` | Получить количество записей |
+| `rsFind(lib, name)` | Найти запись по имени (→ индекс или −1) |
+| `rsLoad(lib, index)` | Загрузить и декомпрессировать ресурс |
+| `rsLoadFast(lib, index, flags)` | Быстрая загрузка (без декомпрессии если возможно) |
+| `rsLoadPacked(lib, index)` | Загрузить в «упакованном» виде (отложенная декомпрессия) |
+| `rsLoadByName(lib, name)` | `rsFind` + `rsLoad` |
+| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса |
+| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) |
+| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс |
+| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) |
+| `ngiFree(ptr)` | Освободить память |
+| `ngiGetMemSize(ptr)` | Получить размер выделенного блока |
+
+---
+
+## Часть 6. Контрольные заметки для реализации
+
+### 6.1. Кодировки и регистр
+
+- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
+- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
+
+### 6.2. Порядок байт
+
+Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
+
+### 6.3. Выравнивание
+
+- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
+- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
+
+### 6.4. Размер записей на диске
+
+- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
+- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
+
+### 6.5. Кэширование и memory mapping
+
+Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
+
+### 6.6. Размер seed XOR
+
+- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
+- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
+- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
+
+### 6.7. Эмпирическая проверка на данных игры
+
+- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
+- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
+- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`.
+
+Подтверждённые нюансы:
+
+- Для LZSS (метод `0x040`) рабочая раскладка нибблов в ссылке: `OOOO LLLL`, а не `LLLL OOOO`.
+- Для Deflate (метод `0x100`) возможен случай `packed_size == фактический_конец + 1` на последней записи файла.
diff --git a/docs/specs/runtime-pipeline.md b/docs/specs/runtime-pipeline.md
new file mode 100644
index 0000000..7021c82
--- /dev/null
+++ b/docs/specs/runtime-pipeline.md
@@ -0,0 +1,123 @@
+# Runtime pipeline
+
+Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов.
+
+---
+
+## 1.15. Алгоритм рендера модели (реконструкция)
+
+```
+Вход: model, instanceTransform, cameraFrustum
+
+1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам).
+
+2. Для каждого node (nodeIndex = 0 .. nodeCount−1):
+ a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform
+
+ b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0]
+ если slotIndex == 0xFFFF → пропустить узел
+
+ c. slot = slotTable[slotIndex]
+
+ d. // Frustum culling:
+ transformedAABB = transform(slot.aabb, nodeTransform)
+ если transformedAABB вне cameraFrustum → пропустить
+
+ // Альтернативно по сфере:
+ transformedCenter = nodeTransform × slot.sphereCenter
+ scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ)
+ если сфера вне frustum → пропустить
+
+ e. Для i = 0 .. slot.batchCount − 1:
+ batch = batchTable[slot.batchStart + i]
+
+ // Фильтрация по batchFlags (если нужна)
+
+ // Установить материал:
+ setMaterial(batch.materialIndex)
+
+ // Установить transform:
+ setWorldMatrix(nodeTransform)
+
+ // Нарисовать:
+ DrawIndexedPrimitive(
+ baseVertex = batch.baseVertex,
+ indexStart = batch.indexStart,
+ indexCount = batch.indexCount,
+ primitiveType = TRIANGLE_LIST
+ )
+```
+
+---
+
+## 1.16. Алгоритм обхода треугольников (коллизия / пикинг)
+
+```
+Вход: model, nodeIndex, lod, group, filterMask, callback
+
+1. slotIndex = nodeTable[nodeIndex].slotMatrix[lod][group]
+ если slotIndex == 0xFFFF → выход
+
+2. slot = slotTable[slotIndex]
+ triDescIndex = slot.triStart
+
+3. Для каждого batch в диапазоне [slot.batchStart .. slot.batchStart + slot.batchCount − 1]:
+ batch = batchTable[batchIndex]
+ triCount = batch.indexCount / 3 // округление: (indexCount + 2) / 3
+
+ Для t = 0 .. triCount − 1:
+ triDesc = triDescTable[triDescIndex]
+
+ // Фильтрация:
+ если (triDesc.triFlags & filterMask) → пропустить
+
+ // Получить индексы вершин:
+ idx0 = indexBuffer[batch.indexStart + t*3 + 0] + batch.baseVertex
+ idx1 = indexBuffer[batch.indexStart + t*3 + 1] + batch.baseVertex
+ idx2 = indexBuffer[batch.indexStart + t*3 + 2] + batch.baseVertex
+
+ // Получить позиции:
+ p0 = positions[idx0]
+ p1 = positions[idx1]
+ p2 = positions[idx2]
+
+ callback(triDesc, idx0, idx1, idx2, p0, p1, p2)
+
+ triDescIndex += 1
+```
+
+---
+
+
+---
+
+## 3.1. Архитектурный обзор
+
+Подсистема эффектов реализована в `Effect.dll` и интегрирована в рендер через `Terrain.dll`.
+
+### Экспорты Effect.dll
+
+| Функция | Описание |
+|----------------------|--------------------------------------------------------|
+| `CreateFxManager` | Создать менеджер эффектов (3 параметра: int, int, int) |
+| `InitializeSettings` | Инициализировать настройки эффектов |
+
+`CreateFxManager` возвращает объект‑менеджер, который регистрируется в движке и управляет всеми эффектами.
+
+### Телеметрия из Terrain.dll
+
+Terrain.dll содержит отладочную статистику рендера:
+
+```
+"Rendered meshes : %d"
+"Rendered primitives : %d"
+"Rendered faces : %d"
+"Rendered particles/batches : %d/%d"
+```
+
+Из этого следует:
+
+- Частицы рендерятся **батчами** (группами).
+- Статистика частиц отделена от статистики мешей.
+- Частицы интегрированы в общий 3D‑рендер‑пайплайн.
+
diff --git a/docs/specs/sound.md b/docs/specs/sound.md
new file mode 100644
index 0000000..da2a6ee
--- /dev/null
+++ b/docs/specs/sound.md
@@ -0,0 +1,5 @@
+# Sound system
+
+Документ описывает аудиоподсистему: форматы звуковых ресурсов, воспроизведение эффектов и голосов, а также интеграцию со звуковым API.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга звуковых модулей движка.
diff --git a/docs/specs/terrain-map-loading.md b/docs/specs/terrain-map-loading.md
new file mode 100644
index 0000000..0fb6e1f
--- /dev/null
+++ b/docs/specs/terrain-map-loading.md
@@ -0,0 +1,32 @@
+# Terrain + map loading
+
+Документ описывает подсистему ландшафта и привязку terrain-данных к миру.
+
+---
+
+## 4.1. Обзор
+
+`Terrain.dll` отвечает за рендер ландшафта (terrain), включая:
+
+- Рендер мешей ландшафта (`"Rendered meshes"`, `"Rendered primitives"`, `"Rendered faces"`).
+- Рендер частиц (`"Rendered particles/batches"`).
+- Создание текстур (`"CTexture::CTexture()"` — конструктор текстуры).
+- Микротекстуры (`"Unable to find microtexture mapping"`).
+
+## 4.2. Текстуры ландшафта
+
+В Terrain.dll присутствует конструктор текстуры `CTexture::CTexture()` со следующими проверками:
+
+- Валидация размера текстуры (`"Unsupported texture size"`).
+- Создание D3D‑текстуры (`"Unable to create texture"`).
+
+Ландшафт использует **микротекстуры** (micro‑texture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности.
+
+## 4.3. Защита от пустых примитивов
+
+Terrain.dll содержит проверки:
+
+- `"Rendering empty primitive!"` — перед первым вызовом отрисовки.
+- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки.
+
+Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта).
diff --git a/docs/specs/ui.md b/docs/specs/ui.md
new file mode 100644
index 0000000..9d71dfd
--- /dev/null
+++ b/docs/specs/ui.md
@@ -0,0 +1,5 @@
+# UI system
+
+Документ описывает интерфейсную подсистему: ресурсы UI, шрифты, minimap, layout и обработку пользовательского ввода в интерфейсе.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга UI-компонентов движка.
diff --git a/libnres/Cargo.toml b/libnres/Cargo.toml
deleted file mode 100644
index 99539e7..0000000
--- a/libnres/Cargo.toml
+++ /dev/null
@@ -1,16 +0,0 @@
-[package]
-name = "libnres"
-version = "0.1.4"
-description = "Library for NRes files"
-authors = ["Valentin Popov <valentin@popov.link>"]
-homepage = "https://git.popov.link/valentineus/fparkan"
-repository = "https://git.popov.link/valentineus/fparkan.git"
-license = "GPL-2.0"
-edition = "2021"
-keywords = ["gamedev", "library", "nres"]
-
-[dependencies]
-byteorder = "1.4"
-log = "0.4"
-miette = "7.0"
-thiserror = "2.0"
diff --git a/libnres/README.md b/libnres/README.md
deleted file mode 100644
index 065bd40..0000000
--- a/libnres/README.md
+++ /dev/null
@@ -1,25 +0,0 @@
-# Library for NRes files (Deprecated)
-
-Library for viewing and retrieving game resources of the game **"Parkan: Iron Strategy"**.
-All versions of the game are supported: Demo, IS, IS: Part 1, IS: Part 2.
-Supports files with `lib`, `trf`, `rlb` extensions.
-
-The files `gamefont.rlb` and `sprites.lib` are not supported.
-This files have an unknown signature.
-
-## Example
-
-Example of extracting game resources:
-
-```rust
-fn main() {
- let file = std::fs::File::open("./voices.lib").unwrap();
- // Extracting the list of files
- let list = libnres::reader::get_list(&file).unwrap();
-
- for element in list {
- // Extracting the contents of the file
- let data = libnres::reader::get_file(&file, &element).unwrap();
- }
-}
-```
diff --git a/libnres/src/converter.rs b/libnres/src/converter.rs
deleted file mode 100644
index bbf0535..0000000
--- a/libnres/src/converter.rs
+++ /dev/null
@@ -1,33 +0,0 @@
-use crate::error::ConverterError;
-
-/// Method for converting u32 to u64.
-pub fn u32_to_u64(value: u32) -> Result<u64, ConverterError> {
- match u64::try_from(value) {
- Err(error) => Err(ConverterError::Infallible(error)),
- Ok(result) => Ok(result),
- }
-}
-
-/// Method for converting u32 to usize.
-pub fn u32_to_usize(value: u32) -> Result<usize, ConverterError> {
- match usize::try_from(value) {
- Err(error) => Err(ConverterError::TryFromIntError(error)),
- Ok(result) => Ok(result),
- }
-}
-
-/// Method for converting u64 to u32.
-pub fn u64_to_u32(value: u64) -> Result<u32, ConverterError> {
- match u32::try_from(value) {
- Err(error) => Err(ConverterError::TryFromIntError(error)),
- Ok(result) => Ok(result),
- }
-}
-
-/// Method for converting usize to u32.
-pub fn usize_to_u32(value: usize) -> Result<u32, ConverterError> {
- match u32::try_from(value) {
- Err(error) => Err(ConverterError::TryFromIntError(error)),
- Ok(result) => Ok(result),
- }
-}
diff --git a/libnres/src/error.rs b/libnres/src/error.rs
deleted file mode 100644
index 440ab06..0000000
--- a/libnres/src/error.rs
+++ /dev/null
@@ -1,45 +0,0 @@
-extern crate miette;
-extern crate thiserror;
-
-use miette::Diagnostic;
-use thiserror::Error;
-
-#[derive(Error, Diagnostic, Debug)]
-pub enum ConverterError {
- #[error("error converting an value")]
- #[diagnostic(code(libnres::infallible))]
- Infallible(#[from] std::convert::Infallible),
-
- #[error("error converting an value")]
- #[diagnostic(code(libnres::try_from_int_error))]
- TryFromIntError(#[from] std::num::TryFromIntError),
-}
-
-#[derive(Error, Diagnostic, Debug)]
-pub enum ReaderError {
- #[error(transparent)]
- #[diagnostic(code(libnres::convert_error))]
- ConvertValue(#[from] ConverterError),
-
- #[error("incorrect header format")]
- #[diagnostic(code(libnres::list_type_error))]
- IncorrectHeader,
-
- #[error("incorrect file size (expected {expected:?} bytes, received {received:?} bytes)")]
- #[diagnostic(code(libnres::file_size_error))]
- IncorrectSizeFile { expected: u32, received: u32 },
-
- #[error(
- "incorrect size of the file list (not a multiple of {expected:?}, received {received:?})"
- )]
- #[diagnostic(code(libnres::list_size_error))]
- IncorrectSizeList { expected: u32, received: u32 },
-
- #[error("resource file reading error")]
- #[diagnostic(code(libnres::io_error))]
- ReadFile(#[from] std::io::Error),
-
- #[error("file is too small (must be at least {expected:?} bytes, received {received:?} byte)")]
- #[diagnostic(code(libnres::file_size_error))]
- SmallFile { expected: u32, received: u32 },
-}
diff --git a/libnres/src/lib.rs b/libnres/src/lib.rs
deleted file mode 100644
index 40c0b32..0000000
--- a/libnres/src/lib.rs
+++ /dev/null
@@ -1,24 +0,0 @@
-/// First constant value of the NRes file ("NRes" characters in numeric)
-pub const FILE_TYPE_1: u32 = 1936020046;
-/// Second constant value of the NRes file
-pub const FILE_TYPE_2: u32 = 256;
-/// Size of the element item (in bytes)
-pub const LIST_ELEMENT_SIZE: u32 = 64;
-/// Minimum allowed file size (in bytes)
-pub const MINIMUM_FILE_SIZE: u32 = 16;
-
-static DEBUG: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
-
-mod converter;
-mod error;
-pub mod reader;
-
-/// Get debug status value
-pub fn get_debug() -> bool {
- DEBUG.load(std::sync::atomic::Ordering::Relaxed)
-}
-
-/// Change debug status value
-pub fn set_debug(value: bool) {
- DEBUG.store(value, std::sync::atomic::Ordering::Relaxed)
-}
diff --git a/libnres/src/reader.rs b/libnres/src/reader.rs
deleted file mode 100644
index 2a450ee..0000000
--- a/libnres/src/reader.rs
+++ /dev/null
@@ -1,227 +0,0 @@
-use std::io::{Read, Seek};
-
-use byteorder::ByteOrder;
-
-use crate::error::ReaderError;
-use crate::{converter, FILE_TYPE_1, FILE_TYPE_2, LIST_ELEMENT_SIZE, MINIMUM_FILE_SIZE};
-
-#[derive(Debug)]
-pub struct ListElement {
- /// Unknown parameter
- _unknown0: i32,
- /// Unknown parameter
- _unknown1: i32,
- /// Unknown parameter
- _unknown2: i32,
- /// File extension
- pub extension: String,
- /// Identifier or sequence number
- pub index: u32,
- /// File name
- pub name: String,
- /// Position in the file
- pub position: u32,
- /// File size (in bytes)
- pub size: u32,
-}
-
-impl ListElement {
- /// Get full name of the file
- pub fn get_filename(&self) -> String {
- format!("{}.{}", self.name, self.extension)
- }
-}
-
-#[derive(Debug)]
-pub struct FileHeader {
- /// File size
- size: u32,
- /// Number of files
- total: u32,
- /// First constant value
- type1: u32,
- /// Second constant value
- type2: u32,
-}
-
-/// Get a packed file data
-pub fn get_file(file: &std::fs::File, element: &ListElement) -> Result<Vec<u8>, ReaderError> {
- let size = get_file_size(file)?;
- check_file_size(size)?;
-
- let header = get_file_header(file)?;
- check_file_header(&header, size)?;
-
- let data = get_element_data(file, element)?;
- Ok(data)
-}
-
-/// Get a list of packed files
-pub fn get_list(file: &std::fs::File) -> Result<Vec<ListElement>, ReaderError> {
- let mut list: Vec<ListElement> = Vec::new();
-
- let size = get_file_size(file)?;
- check_file_size(size)?;
-
- let header = get_file_header(file)?;
- check_file_header(&header, size)?;
-
- get_file_list(file, &header, &mut list)?;
-
- Ok(list)
-}
-
-fn check_file_header(header: &FileHeader, size: u32) -> Result<(), ReaderError> {
- if header.type1 != FILE_TYPE_1 || header.type2 != FILE_TYPE_2 {
- return Err(ReaderError::IncorrectHeader);
- }
-
- if header.size != size {
- return Err(ReaderError::IncorrectSizeFile {
- expected: size,
- received: header.size,
- });
- }
-
- Ok(())
-}
-
-fn check_file_size(size: u32) -> Result<(), ReaderError> {
- if size < MINIMUM_FILE_SIZE {
- return Err(ReaderError::SmallFile {
- expected: MINIMUM_FILE_SIZE,
- received: size,
- });
- }
-
- Ok(())
-}
-
-fn get_element_data(file: &std::fs::File, element: &ListElement) -> Result<Vec<u8>, ReaderError> {
- let position = converter::u32_to_u64(element.position)?;
- let size = converter::u32_to_usize(element.size)?;
-
- let mut reader = std::io::BufReader::new(file);
- let mut buffer = vec![0u8; size];
-
- if let Err(error) = reader.seek(std::io::SeekFrom::Start(position)) {
- return Err(ReaderError::ReadFile(error));
- };
-
- if let Err(error) = reader.read_exact(&mut buffer) {
- return Err(ReaderError::ReadFile(error));
- };
-
- Ok(buffer)
-}
-
-fn get_element_position(index: u32) -> Result<(usize, usize), ReaderError> {
- let from = converter::u32_to_usize(index * LIST_ELEMENT_SIZE)?;
- let to = converter::u32_to_usize((index * LIST_ELEMENT_SIZE) + LIST_ELEMENT_SIZE)?;
- Ok((from, to))
-}
-
-fn get_file_header(file: &std::fs::File) -> Result<FileHeader, ReaderError> {
- let mut reader = std::io::BufReader::new(file);
- let mut buffer = vec![0u8; MINIMUM_FILE_SIZE as usize];
-
- if let Err(error) = reader.seek(std::io::SeekFrom::Start(0)) {
- return Err(ReaderError::ReadFile(error));
- };
-
- if let Err(error) = reader.read_exact(&mut buffer) {
- return Err(ReaderError::ReadFile(error));
- };
-
- let header = FileHeader {
- size: byteorder::LittleEndian::read_u32(&buffer[12..16]),
- total: byteorder::LittleEndian::read_u32(&buffer[8..12]),
- type1: byteorder::LittleEndian::read_u32(&buffer[0..4]),
- type2: byteorder::LittleEndian::read_u32(&buffer[4..8]),
- };
-
- buffer.clear();
- Ok(header)
-}
-
-fn get_file_list(
- file: &std::fs::File,
- header: &FileHeader,
- list: &mut Vec<ListElement>,
-) -> Result<(), ReaderError> {
- let (start_position, list_size) = get_list_position(header)?;
- let mut reader = std::io::BufReader::new(file);
- let mut buffer = vec![0u8; list_size];
-
- if let Err(error) = reader.seek(std::io::SeekFrom::Start(start_position)) {
- return Err(ReaderError::ReadFile(error));
- };
-
- if let Err(error) = reader.read_exact(&mut buffer) {
- return Err(ReaderError::ReadFile(error));
- }
-
- let buffer_size = converter::usize_to_u32(buffer.len())?;
-
- if buffer_size % LIST_ELEMENT_SIZE != 0 {
- return Err(ReaderError::IncorrectSizeList {
- expected: LIST_ELEMENT_SIZE,
- received: buffer_size,
- });
- }
-
- for i in 0..(buffer_size / LIST_ELEMENT_SIZE) {
- let (from, to) = get_element_position(i)?;
- let chunk: &[u8] = &buffer[from..to];
-
- let element = get_list_element(chunk)?;
- list.push(element);
- }
-
- buffer.clear();
- Ok(())
-}
-
-fn get_file_size(file: &std::fs::File) -> Result<u32, ReaderError> {
- let metadata = match file.metadata() {
- Err(error) => return Err(ReaderError::ReadFile(error)),
- Ok(value) => value,
- };
-
- let result = converter::u64_to_u32(metadata.len())?;
- Ok(result)
-}
-
-fn get_list_element(buffer: &[u8]) -> Result<ListElement, ReaderError> {
- let index = byteorder::LittleEndian::read_u32(&buffer[60..64]);
- let position = byteorder::LittleEndian::read_u32(&buffer[56..60]);
- let size = byteorder::LittleEndian::read_u32(&buffer[12..16]);
- let unknown0 = byteorder::LittleEndian::read_i32(&buffer[4..8]);
- let unknown1 = byteorder::LittleEndian::read_i32(&buffer[8..12]);
- let unknown2 = byteorder::LittleEndian::read_i32(&buffer[16..20]);
-
- let extension = String::from_utf8_lossy(&buffer[0..4])
- .trim_matches(char::from(0))
- .to_string();
-
- let name = String::from_utf8_lossy(&buffer[20..56])
- .trim_matches(char::from(0))
- .to_string();
-
- Ok(ListElement {
- _unknown0: unknown0,
- _unknown1: unknown1,
- _unknown2: unknown2,
- extension,
- index,
- name,
- position,
- size,
- })
-}
-
-fn get_list_position(header: &FileHeader) -> Result<(u64, usize), ReaderError> {
- let position = converter::u32_to_u64(header.size - (header.total * LIST_ELEMENT_SIZE))?;
- let size = converter::u32_to_usize(header.total * LIST_ELEMENT_SIZE)?;
- Ok((position, size))
-}
diff --git a/mkdocs.yml b/mkdocs.yml
index fc95499..6c9724e 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -10,15 +10,37 @@ repo_name: valentineus/fparkan
repo_url: https://github.com/valentineus/fparkan
# Copyright
-copyright: Copyright &copy; 2023 &mdash; 2024 Valentin Popov
+copyright: Copyright &copy; 2023 &mdash; 2026 Valentin Popov
# Configuration
theme:
name: material
- language: en
+ language: ru
palette:
scheme: slate
+# Navigation
+nav:
+ - Home: index.md
+ - Specs:
+ - 3D implementation notes: specs/msh-notes.md
+ - AI system: specs/ai.md
+ - ArealMap: specs/arealmap.md
+ - Behavior system: specs/behavior.md
+ - Control system: specs/control.md
+ - FXID: specs/fxid.md
+ - Materials + Texm: specs/materials-texm.md
+ - Missions: specs/missions.md
+ - MSH animation: specs/msh-animation.md
+ - MSH core: specs/msh-core.md
+ - Network system: specs/network.md
+ - NRes / RsLi: specs/nres.md
+ - Runtime pipeline: specs/runtime-pipeline.md
+ - Sound system: specs/sound.md
+ - Terrain + map loading: specs/terrain-map-loading.md
+ - UI system: specs/ui.md
+ - Форматы 3D‑ресурсов (обзор): specs/msh.md
+
# Additional configuration
extra:
social:
diff --git a/nres-cli/Cargo.toml b/nres-cli/Cargo.toml
deleted file mode 100644
index a6d863c..0000000
--- a/nres-cli/Cargo.toml
+++ /dev/null
@@ -1,20 +0,0 @@
-[package]
-name = "nres-cli"
-version = "0.2.3"
-description = "Console tool for NRes files"
-authors = ["Valentin Popov <valentin@popov.link>"]
-homepage = "https://git.popov.link/valentineus/fparkan"
-repository = "https://git.popov.link/valentineus/fparkan.git"
-license = "GPL-2.0"
-edition = "2021"
-keywords = ["cli", "gamedev", "nres"]
-
-[dependencies]
-byteorder = "1.4"
-clap = { version = "4.2", features = ["derive"] }
-console = "0.15"
-dialoguer = { version = "0.11", features = ["completion"] }
-indicatif = "0.17"
-libnres = { version = "0.1", path = "../libnres" }
-miette = { version = "7.0", features = ["fancy"] }
-tempdir = "0.3"
diff --git a/nres-cli/README.md b/nres-cli/README.md
deleted file mode 100644
index 65a6602..0000000
--- a/nres-cli/README.md
+++ /dev/null
@@ -1,6 +0,0 @@
-# Console tool for NRes files (Deprecated)
-
-## Commands
-
-- `extract` - Extract game resources from a "NRes" file.
-- `ls` - Get a list of files in a "NRes" file.
diff --git a/nres-cli/src/main.rs b/nres-cli/src/main.rs
deleted file mode 100644
index 85086cb..0000000
--- a/nres-cli/src/main.rs
+++ /dev/null
@@ -1,198 +0,0 @@
-extern crate core;
-extern crate libnres;
-
-use std::io::Write;
-
-use clap::{Parser, Subcommand};
-use miette::{IntoDiagnostic, Result};
-
-#[derive(Parser, Debug)]
-#[command(name = "NRes CLI")]
-#[command(about, author, version, long_about = None)]
-struct Cli {
- #[command(subcommand)]
- command: Commands,
-}
-
-#[derive(Subcommand, Debug)]
-enum Commands {
- /// Check if the "NRes" file can be extract
- Check {
- /// "NRes" file
- file: String,
- },
- /// Print debugging information on the "NRes" file
- #[command(arg_required_else_help = true)]
- Debug {
- /// "NRes" file
- file: String,
- /// Filter results by file name
- #[arg(long)]
- name: Option<String>,
- },
- /// Extract files or a file from the "NRes" file
- #[command(arg_required_else_help = true)]
- Extract {
- /// "NRes" file
- file: String,
- /// Overwrite files
- #[arg(short, long, default_value_t = false, value_name = "TRUE|FALSE")]
- force: bool,
- /// Outbound directory
- #[arg(short, long, value_name = "DIR")]
- out: String,
- },
- /// Print a list of files in the "NRes" file
- #[command(arg_required_else_help = true)]
- Ls {
- /// "NRes" file
- file: String,
- },
-}
-
-pub fn main() -> Result<()> {
- let stdout = console::Term::stdout();
- let cli = Cli::parse();
-
- match cli.command {
- Commands::Check { file } => command_check(stdout, file)?,
- Commands::Debug { file, name } => command_debug(stdout, file, name)?,
- Commands::Extract { file, force, out } => command_extract(stdout, file, out, force)?,
- Commands::Ls { file } => command_ls(stdout, file)?,
- }
-
- Ok(())
-}
-
-fn command_check(_stdout: console::Term, file: String) -> Result<()> {
- let file = std::fs::File::open(file).into_diagnostic()?;
- let list = libnres::reader::get_list(&file).into_diagnostic()?;
- let tmp = tempdir::TempDir::new("nres").into_diagnostic()?;
- let bar = indicatif::ProgressBar::new(list.len() as u64);
-
- bar.set_style(get_bar_style()?);
-
- for element in list {
- bar.set_message(element.get_filename());
-
- let path = tmp.path().join(element.get_filename());
- let mut output = std::fs::File::create(path).into_diagnostic()?;
- let mut buffer = libnres::reader::get_file(&file, &element).into_diagnostic()?;
-
- output.write_all(&buffer).into_diagnostic()?;
- buffer.clear();
- bar.inc(1);
- }
-
- bar.finish();
-
- Ok(())
-}
-
-fn command_debug(stdout: console::Term, file: String, name: Option<String>) -> Result<()> {
- let file = std::fs::File::open(file).into_diagnostic()?;
- let mut list = libnres::reader::get_list(&file).into_diagnostic()?;
-
- let mut total_files_size: u32 = 0;
- let mut total_files_gap: u32 = 0;
- let mut total_files: u32 = 0;
-
- for (index, item) in list.iter().enumerate() {
- total_files_size += item.size;
- total_files += 1;
- let mut gap = 0;
-
- if index > 1 {
- let previous_item = &list[index - 1];
- gap = item.position - (previous_item.position + previous_item.size);
- }
-
- total_files_gap += gap;
- }
-
- if let Some(name) = name {
- list.retain(|item| item.name.contains(&name));
- };
-
- for (index, item) in list.iter().enumerate() {
- let mut gap = 0;
-
- if index > 1 {
- let previous_item = &list[index - 1];
- gap = item.position - (previous_item.position + previous_item.size);
- }
-
- let text = format!("Index: {};\nGap: {};\nItem: {:#?};\n", index, gap, item);
- stdout.write_line(&text).into_diagnostic()?;
- }
-
- let text = format!(
- "Total files: {};\nTotal files gap: {} (bytes);\nTotal files size: {} (bytes);",
- total_files, total_files_gap, total_files_size
- );
-
- stdout.write_line(&text).into_diagnostic()?;
-
- Ok(())
-}
-
-fn command_extract(_stdout: console::Term, file: String, out: String, force: bool) -> Result<()> {
- let file = std::fs::File::open(file).into_diagnostic()?;
- let list = libnres::reader::get_list(&file).into_diagnostic()?;
- let bar = indicatif::ProgressBar::new(list.len() as u64);
-
- bar.set_style(get_bar_style()?);
-
- for element in list {
- bar.set_message(element.get_filename());
-
- let path = format!("{}/{}", out, element.get_filename());
-
- if !force && is_exist_file(&path) {
- let message = format!("File \"{}\" exists. Overwrite it?", path);
-
- if !dialoguer::Confirm::new()
- .with_prompt(message)
- .interact()
- .into_diagnostic()?
- {
- continue;
- }
- }
-
- let mut output = std::fs::File::create(path).into_diagnostic()?;
- let mut buffer = libnres::reader::get_file(&file, &element).into_diagnostic()?;
-
- output.write_all(&buffer).into_diagnostic()?;
- buffer.clear();
- bar.inc(1);
- }
-
- bar.finish();
-
- Ok(())
-}
-
-fn command_ls(stdout: console::Term, file: String) -> Result<()> {
- let file = std::fs::File::open(file).into_diagnostic()?;
- let list = libnres::reader::get_list(&file).into_diagnostic()?;
-
- for element in list {
- stdout.write_line(&element.name).into_diagnostic()?;
- }
-
- Ok(())
-}
-
-fn get_bar_style() -> Result<indicatif::ProgressStyle> {
- Ok(
- indicatif::ProgressStyle::with_template("[{bar:32}] {pos:>7}/{len:7} {msg}")
- .into_diagnostic()?
- .progress_chars("=>-"),
- )
-}
-
-fn is_exist_file(path: &String) -> bool {
- let metadata = std::path::Path::new(path);
- metadata.exists()
-}
diff --git a/packer/Cargo.toml b/packer/Cargo.toml
deleted file mode 100644
index cbf418c..0000000
--- a/packer/Cargo.toml
+++ /dev/null
@@ -1,9 +0,0 @@
-[package]
-name = "packer"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-byteorder = "1.4.3"
-serde = { version = "1.0.160", features = ["derive"] }
-serde_json = "1.0.96"
diff --git a/packer/README.md b/packer/README.md
deleted file mode 100644
index 9edb048..0000000
--- a/packer/README.md
+++ /dev/null
@@ -1,27 +0,0 @@
-# NRes Game Resource Packer
-
-At the moment, this is a demonstration of the NRes game resource packing algorithm in action.
-It packs 100% of the NRes game resources for the game "Parkan: Iron Strategy".
-The hash sums of the resulting files match the original game files.
-
-__Attention!__
-This is a test version of the utility. It overwrites the specified final file without asking.
-
-## Building
-
-To build the tools, you need to run the following command in the root directory:
-
-```bash
-cargo build --release
-```
-
-## Running
-
-You can run the utility with the following command:
-
-```bash
-./target/release/packer /path/to/unpack /path/to/file.ex
-```
-
-- `/path/to/unpack`: This is the directory with the resources unpacked by the [unpacker](../unpacker) utility.
-- `/path/to/file.ex`: This is the final file that will be created.
diff --git a/packer/src/main.rs b/packer/src/main.rs
deleted file mode 100644
index a5526e3..0000000
--- a/packer/src/main.rs
+++ /dev/null
@@ -1,175 +0,0 @@
-use std::env;
-use std::{
- fs::{self, File},
- io::{BufReader, Read},
-};
-
-use byteorder::{ByteOrder, LittleEndian};
-use serde::{Deserialize, Serialize};
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ImportListElement {
- pub extension: String,
- pub index: u32,
- pub name: String,
- pub unknown0: u32,
- pub unknown1: u32,
- pub unknown2: u32,
-}
-
-#[derive(Debug)]
-pub struct ListElement {
- pub extension: String,
- pub index: u32,
- pub name: String,
- pub position: u32,
- pub size: u32,
- pub unknown0: u32,
- pub unknown1: u32,
- pub unknown2: u32,
-}
-
-fn main() {
- let args: Vec<String> = env::args().collect();
-
- let input = &args[1];
- let output = &args[2];
-
- pack(String::from(input), String::from(output));
-}
-
-fn pack(input: String, output: String) {
- // Загружаем индекс-файл
- let index_file = format!("{}/{}", input, "index.json");
- let data = fs::read_to_string(index_file).unwrap();
- let list: Vec<ImportListElement> = serde_json::from_str(&data).unwrap();
-
- // Общий буфер хранения файлов
- let mut content_buffer: Vec<u8> = Vec::new();
- let mut list_buffer: Vec<u8> = Vec::new();
-
- // Общее количество файлов
- let total_files: u32 = list.len() as u32;
-
- for (index, item) in list.iter().enumerate() {
- // Открываем дескриптор файла
- let path = format!("{}/{}.{}", input, item.name, item.index);
- let file = File::open(path).unwrap();
- let metadata = file.metadata().unwrap();
-
- // Считываем файл в буфер
- let mut reader = BufReader::new(file);
- let mut file_buffer: Vec<u8> = Vec::new();
- reader.read_to_end(&mut file_buffer).unwrap();
-
- // Выравнивание буфера
- if index != 0 {
- while content_buffer.len() % 8 != 0 {
- content_buffer.push(0);
- }
- }
-
- // Получение позиции файла
- let position = content_buffer.len() + 16;
-
- // Записываем файл в буфер
- content_buffer.extend(file_buffer);
-
- // Формируем элемент
- let element = ListElement {
- extension: item.extension.to_string(),
- index: item.index,
- name: item.name.to_string(),
- position: position as u32,
- size: metadata.len() as u32,
- unknown0: item.unknown0,
- unknown1: item.unknown1,
- unknown2: item.unknown2,
- };
-
- // Создаем буфер из элемента
- let mut element_buffer: Vec<u8> = Vec::new();
-
- // Пишем тип файла
- let mut extension_buffer: [u8; 4] = [0; 4];
- let mut file_extension_buffer = element.extension.into_bytes();
- file_extension_buffer.resize(4, 0);
- extension_buffer.copy_from_slice(&file_extension_buffer);
- element_buffer.extend(extension_buffer);
-
- // Пишем неизвестное значение #1
- let mut unknown0_buffer: [u8; 4] = [0; 4];
- LittleEndian::write_u32(&mut unknown0_buffer, element.unknown0);
- element_buffer.extend(unknown0_buffer);
-
- // Пишем неизвестное значение #2
- let mut unknown1_buffer: [u8; 4] = [0; 4];
- LittleEndian::write_u32(&mut unknown1_buffer, element.unknown1);
- element_buffer.extend(unknown1_buffer);
-
- // Пишем размер файла
- let mut file_size_buffer: [u8; 4] = [0; 4];
- LittleEndian::write_u32(&mut file_size_buffer, element.size);
- element_buffer.extend(file_size_buffer);
-
- // Пишем неизвестное значение #3
- let mut unknown2_buffer: [u8; 4] = [0; 4];
- LittleEndian::write_u32(&mut unknown2_buffer, element.unknown2);
- element_buffer.extend(unknown2_buffer);
-
- // Пишем название файла
- let mut name_buffer: [u8; 36] = [0; 36];
- let mut file_name_buffer = element.name.into_bytes();
- file_name_buffer.resize(36, 0);
- name_buffer.copy_from_slice(&file_name_buffer);
- element_buffer.extend(name_buffer);
-
- // Пишем позицию файла
- let mut position_buffer: [u8; 4] = [0; 4];
- LittleEndian::write_u32(&mut position_buffer, element.position);
- element_buffer.extend(position_buffer);
-
- // Пишем индекс файла
- let mut index_buffer: [u8; 4] = [0; 4];
- LittleEndian::write_u32(&mut index_buffer, element.index);
- element_buffer.extend(index_buffer);
-
- // Добавляем итоговый буфер в буфер элементов списка
- list_buffer.extend(element_buffer);
- }
-
- // Выравнивание буфера
- while content_buffer.len() % 8 != 0 {
- content_buffer.push(0);
- }
-
- let mut header_buffer: Vec<u8> = Vec::new();
-
- // Пишем первый тип файла
- let mut header_type_1 = [0; 4];
- LittleEndian::write_u32(&mut header_type_1, 1936020046_u32);
- header_buffer.extend(header_type_1);
-
- // Пишем второй тип файла
- let mut header_type_2 = [0; 4];
- LittleEndian::write_u32(&mut header_type_2, 256_u32);
- header_buffer.extend(header_type_2);
-
- // Пишем количество файлов
- let mut header_total_files = [0; 4];
- LittleEndian::write_u32(&mut header_total_files, total_files);
- header_buffer.extend(header_total_files);
-
- // Пишем общий размер файла
- let mut header_total_size = [0; 4];
- let total_size: u32 = ((content_buffer.len() + 16) as u32) + (total_files * 64);
- LittleEndian::write_u32(&mut header_total_size, total_size);
- header_buffer.extend(header_total_size);
-
- let mut result_buffer: Vec<u8> = Vec::new();
- result_buffer.extend(header_buffer);
- result_buffer.extend(content_buffer);
- result_buffer.extend(list_buffer);
-
- fs::write(output, result_buffer).unwrap();
-}
diff --git a/renovate.config.cjs b/renovate.config.cjs
new file mode 100644
index 0000000..9d56531
--- /dev/null
+++ b/renovate.config.cjs
@@ -0,0 +1,6 @@
+module.exports = {
+ endpoint: "https://code.popov.link",
+ gitAuthor: "renovate[bot] <renovatebot@noreply.localhost>",
+ optimizeForDisabled: true,
+ platform: "gitea",
+}; \ No newline at end of file
diff --git a/testdata/nres/.gitignore b/testdata/nres/.gitignore
new file mode 100644
index 0000000..c96a04f
--- /dev/null
+++ b/testdata/nres/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore \ No newline at end of file
diff --git a/testdata/rsli/.gitignore b/testdata/rsli/.gitignore
new file mode 100644
index 0000000..c96a04f
--- /dev/null
+++ b/testdata/rsli/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore \ No newline at end of file
diff --git a/texture-decoder/Cargo.toml b/texture-decoder/Cargo.toml
deleted file mode 100644
index 0d11da6..0000000
--- a/texture-decoder/Cargo.toml
+++ /dev/null
@@ -1,8 +0,0 @@
-[package]
-name = "texture-decoder"
-version = "0.1.0"
-edition = "2021"
-
-[dependencies]
-byteorder = "1.4.3"
-image = "0.25.0"
diff --git a/texture-decoder/README.md b/texture-decoder/README.md
deleted file mode 100644
index 8fca059..0000000
--- a/texture-decoder/README.md
+++ /dev/null
@@ -1,13 +0,0 @@
-# Декодировщик текстур
-
-Сборка:
-
-```bash
-cargo build --release
-```
-
-Запуск:
-
-```bash
-./target/release/texture-decoder ./out/AIM_02.0 ./out/AIM_02.0.png
-``` \ No newline at end of file
diff --git a/texture-decoder/src/main.rs b/texture-decoder/src/main.rs
deleted file mode 100644
index 26c7edd..0000000
--- a/texture-decoder/src/main.rs
+++ /dev/null
@@ -1,41 +0,0 @@
-use std::io::Read;
-
-use byteorder::ReadBytesExt;
-use image::Rgba;
-
-fn decode_texture(file_path: &str, output_path: &str) -> Result<(), std::io::Error> {
- // Читаем файл
- let mut file = std::fs::File::open(file_path)?;
- let mut buffer: Vec<u8> = Vec::new();
- file.read_to_end(&mut buffer)?;
-
- // Декодируем метаданные
- let mut cursor = std::io::Cursor::new(&buffer[4..]);
- let img_width = cursor.read_u32::<byteorder::LittleEndian>()?;
- let img_height = cursor.read_u32::<byteorder::LittleEndian>()?;
-
- // Пропустить оставшиеся байты метаданных
- cursor.set_position(20);
-
- // Извлекаем данные изображения
- let image_data = buffer[cursor.position() as usize..].to_vec();
- let img =
- image::ImageBuffer::<Rgba<u8>, _>::from_raw(img_width, img_height, image_data.to_vec())
- .expect("Failed to decode image");
-
- // Сохраняем изображение
- img.save(output_path).unwrap();
-
- Ok(())
-}
-
-fn main() {
- let args: Vec<String> = std::env::args().collect();
-
- let input = &args[1];
- let output = &args[2];
-
- if let Err(err) = decode_texture(input, output) {
- eprintln!("Error: {}", err)
- }
-}
diff --git a/tools/README.md b/tools/README.md
new file mode 100644
index 0000000..2418567
--- /dev/null
+++ b/tools/README.md
@@ -0,0 +1,201 @@
+# Инструменты в каталоге `tools`
+
+## `archive_roundtrip_validator.py`
+
+Скрипт предназначен для **валидации документации по форматам NRes и RsLi на реальных данных игры**.
+
+Что делает утилита:
+
+- находит архивы по сигнатуре заголовка (а не по расширению файла);
+- распаковывает архивы в структуру `manifest.json + entries/*`;
+- собирает архивы обратно из `manifest.json`;
+- выполняет проверку `unpack -> repack -> byte-compare`;
+- формирует отчёт о расхождениях со спецификацией.
+
+Скрипт не изменяет оригинальные файлы игры. Рабочие файлы создаются только в указанном `--workdir` (или во временной папке).
+
+## Поддерживаемые сигнатуры
+
+- `NRes` (`4E 52 65 73`)
+- `RsLi` в файловом формате библиотеки: `NL 00 01`
+
+## Основные команды
+
+Сканирование архива по сигнатурам:
+
+```bash
+python3 tools/archive_roundtrip_validator.py scan --input tmp/gamedata
+```
+
+Распаковка/упаковка одного NRes:
+
+```bash
+python3 tools/archive_roundtrip_validator.py nres-unpack \
+ --archive tmp/gamedata/sounds.lib \
+ --output tmp/work/nres_sounds
+
+python3 tools/archive_roundtrip_validator.py nres-pack \
+ --manifest tmp/work/nres_sounds/manifest.json \
+ --output tmp/work/sounds.repacked.lib
+```
+
+Распаковка/упаковка одного RsLi:
+
+```bash
+python3 tools/archive_roundtrip_validator.py rsli-unpack \
+ --archive tmp/gamedata/sprites.lib \
+ --output tmp/work/rsli_sprites
+
+python3 tools/archive_roundtrip_validator.py rsli-pack \
+ --manifest tmp/work/rsli_sprites/manifest.json \
+ --output tmp/work/sprites.repacked.lib
+```
+
+Полная валидация документации на всём наборе данных:
+
+```bash
+python3 tools/archive_roundtrip_validator.py validate \
+ --input tmp/gamedata \
+ --workdir tmp/validation_work \
+ --report tmp/validation_report.json \
+ --fail-on-diff
+```
+
+## Формат распаковки
+
+Для каждого архива создаются:
+
+- `manifest.json` — все поля заголовка, записи, индексы, смещения, контрольные суммы;
+- `entries/*.bin` — payload-файлы.
+
+Имена файлов в `entries` включают индекс записи, поэтому коллизии одинаковых имён внутри архива обрабатываются корректно.
+
+## `init_testdata.py`
+
+Скрипт инициализирует тестовые данные по сигнатурам архивов из спецификации:
+
+- `NRes` (`4E 52 65 73`);
+- `RsLi` (`NL 00 01`).
+
+Что делает утилита:
+
+- рекурсивно сканирует все файлы в `--input`;
+- копирует найденные `NRes` в `--output/nres/`;
+- копирует найденные `RsLi` в `--output/rsli/`;
+- сохраняет относительный путь исходного файла внутри целевого каталога;
+- создаёт целевые каталоги автоматически, если их нет.
+
+Базовый запуск:
+
+```bash
+python3 tools/init_testdata.py --input tmp/gamedata --output testdata
+```
+
+Если целевой файл уже существует, скрипт спрашивает подтверждение перезаписи (`yes/no/all/quit`).
+
+Для перезаписи без вопросов используйте `--force`:
+
+```bash
+python3 tools/init_testdata.py --input tmp/gamedata --output testdata --force
+```
+
+Проверки надёжности:
+
+- `--input` должен существовать и быть каталогом;
+- если `--output` указывает на существующий файл, скрипт завершится с ошибкой;
+- если `--output` расположен внутри `--input`, каталог вывода исключается из сканирования;
+- если `stdin` неинтерактивный и требуется перезапись, нужно явно указать `--force`.
+
+## `msh_doc_validator.py`
+
+Скрипт валидирует ключевые инварианты из документации `/Users/valentineus/Developer/personal/fparkan/docs/specs/msh.md` на реальных данных.
+
+Проверяемые группы:
+
+- модели `*.msh` (вложенные `NRes` в архивах `NRes`);
+- текстуры `Texm` (`type_id = 0x6D786554`);
+- эффекты `FXID` (`type_id = 0x44495846`).
+
+Что проверяет для моделей:
+
+- обязательные ресурсы (`Res1/2/3/6/13`) и известные опциональные (`Res4/5/7/8/10/15/16/18/19`);
+- `size/attr1/attr3` и шаги структур по таблицам;
+- диапазоны индексов, батчей и ссылок между таблицами;
+- разбор `Res10` как `len + bytes + NUL` для каждого узла;
+- матрицу слотов в `Res1` (LOD/group) и границы по `Res2/Res7/Res13/Res19`.
+
+Быстрый запуск:
+
+```bash
+python3 tools/msh_doc_validator.py scan --input testdata/nres
+python3 tools/msh_doc_validator.py validate --input testdata/nres --print-limit 20
+```
+
+С отчётом в JSON:
+
+```bash
+python3 tools/msh_doc_validator.py validate \
+ --input testdata/nres \
+ --report tmp/msh_validation_report.json \
+ --fail-on-warnings
+```
+
+## `msh_preview_renderer.py`
+
+Примитивный программный рендерер моделей `*.msh` без внешних зависимостей.
+
+- вход: архив `NRes` (например `animals.rlb`) или прямой payload модели;
+- выход: изображение `PPM` (`P6`);
+- использует `Res3` (позиции), `Res6` (индексы), `Res13` (батчи), `Res1/Res2` (выбор слотов по `lod/group`).
+
+Показать доступные модели в архиве:
+
+```bash
+python3 tools/msh_preview_renderer.py list-models --archive testdata/nres/animals.rlb
+```
+
+Сгенерировать тестовый рендер:
+
+```bash
+python3 tools/msh_preview_renderer.py render \
+ --archive testdata/nres/animals.rlb \
+ --model A_L_01.msh \
+ --output tmp/renders/A_L_01.ppm \
+ --width 800 \
+ --height 600 \
+ --lod 0 \
+ --group 0 \
+ --wireframe
+```
+
+Ограничения:
+
+- инструмент предназначен для smoke-теста геометрии, а не для пиксельно-точного рендера движка;
+- текстуры/материалы/эффектные проходы не эмулируются.
+
+## `msh_export_obj.py`
+
+Экспортирует геометрию `*.msh` в `Wavefront OBJ`, чтобы открыть модель в Blender/MeshLab.
+
+- вход: `NRes` архив (например `animals.rlb`) или прямой payload модели;
+- выбор геометрии: через `Res1` slot matrix (`lod/group`) как в рендерере;
+- опция `--all-batches` экспортирует все батчи, игнорируя slot matrix.
+
+Показать модели в архиве:
+
+```bash
+python3 tools/msh_export_obj.py list-models --archive testdata/nres/animals.rlb
+```
+
+Экспорт в OBJ:
+
+```bash
+python3 tools/msh_export_obj.py export \
+ --archive testdata/nres/animals.rlb \
+ --model A_L_01.msh \
+ --output tmp/renders/A_L_01.obj \
+ --lod 0 \
+ --group 0
+```
+
+Файл `OBJ` можно открыть напрямую в Blender (`File -> Import -> Wavefront (.obj)`).
diff --git a/tools/archive_roundtrip_validator.py b/tools/archive_roundtrip_validator.py
new file mode 100644
index 0000000..073fd9b
--- /dev/null
+++ b/tools/archive_roundtrip_validator.py
@@ -0,0 +1,944 @@
+#!/usr/bin/env python3
+"""
+Roundtrip tools for NRes and RsLi archives.
+
+The script can:
+1) scan archives by header signature (ignores file extensions),
+2) unpack / pack NRes archives,
+3) unpack / pack RsLi archives,
+4) validate docs assumptions by full roundtrip and byte-to-byte comparison.
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import re
+import shutil
+import struct
+import tempfile
+import zlib
+from pathlib import Path
+from typing import Any
+
+MAGIC_NRES = b"NRes"
+MAGIC_RSLI = b"NL\x00\x01"
+
+
+class ArchiveFormatError(RuntimeError):
+ pass
+
+
+def sha256_hex(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+def safe_component(value: str, fallback: str = "item", max_len: int = 80) -> str:
+ clean = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._-")
+ if not clean:
+ clean = fallback
+ return clean[:max_len]
+
+
+def first_diff(a: bytes, b: bytes) -> tuple[int | None, str | None]:
+ if a == b:
+ return None, None
+ limit = min(len(a), len(b))
+ for idx in range(limit):
+ if a[idx] != b[idx]:
+ return idx, f"{a[idx]:02x}!={b[idx]:02x}"
+ return limit, f"len {len(a)}!={len(b)}"
+
+
+def load_json(path: Path) -> dict[str, Any]:
+ with path.open("r", encoding="utf-8") as handle:
+ return json.load(handle)
+
+
+def dump_json(path: Path, payload: dict[str, Any]) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2, ensure_ascii=False)
+ handle.write("\n")
+
+
+def xor_stream(data: bytes, key16: int) -> bytes:
+ lo = key16 & 0xFF
+ hi = (key16 >> 8) & 0xFF
+ out = bytearray(len(data))
+ for i, value in enumerate(data):
+ lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
+ out[i] = value ^ lo
+ hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
+ return bytes(out)
+
+
+def lzss_decompress_simple(data: bytes, expected_size: int) -> bytes:
+ ring = bytearray([0x20] * 0x1000)
+ ring_pos = 0xFEE
+ out = bytearray()
+ in_pos = 0
+ control = 0
+ bits_left = 0
+
+ while len(out) < expected_size and in_pos < len(data):
+ if bits_left == 0:
+ control = data[in_pos]
+ in_pos += 1
+ bits_left = 8
+
+ if control & 1:
+ if in_pos >= len(data):
+ break
+ byte = data[in_pos]
+ in_pos += 1
+ out.append(byte)
+ ring[ring_pos] = byte
+ ring_pos = (ring_pos + 1) & 0x0FFF
+ else:
+ if in_pos + 1 >= len(data):
+ break
+ low = data[in_pos]
+ high = data[in_pos + 1]
+ in_pos += 2
+ # Real files indicate nibble layout opposite to common LZSS variant:
+ # high nibble extends offset, low nibble stores (length - 3).
+ offset = low | ((high & 0xF0) << 4)
+ length = (high & 0x0F) + 3
+ for step in range(length):
+ byte = ring[(offset + step) & 0x0FFF]
+ out.append(byte)
+ ring[ring_pos] = byte
+ ring_pos = (ring_pos + 1) & 0x0FFF
+ if len(out) >= expected_size:
+ break
+
+ control >>= 1
+ bits_left -= 1
+
+ if len(out) != expected_size:
+ raise ArchiveFormatError(
+ f"LZSS size mismatch: expected {expected_size}, got {len(out)}"
+ )
+ return bytes(out)
+
+
+def decode_rsli_payload(
+ packed: bytes, method: int, sort_to_original: int, unpacked_size: int
+) -> bytes:
+ key16 = sort_to_original & 0xFFFF
+
+ if method == 0x000:
+ out = packed
+ elif method == 0x020:
+ if len(packed) < unpacked_size:
+ raise ArchiveFormatError(
+ f"method 0x20 packed too short: {len(packed)} < {unpacked_size}"
+ )
+ out = xor_stream(packed[:unpacked_size], key16)
+ elif method == 0x040:
+ out = lzss_decompress_simple(packed, unpacked_size)
+ elif method == 0x060:
+ out = lzss_decompress_simple(xor_stream(packed, key16), unpacked_size)
+ elif method == 0x100:
+ try:
+ out = zlib.decompress(packed, -15)
+ except zlib.error:
+ out = zlib.decompress(packed)
+ else:
+ raise ArchiveFormatError(f"unsupported RsLi method: 0x{method:03X}")
+
+ if len(out) != unpacked_size:
+ raise ArchiveFormatError(
+ f"unpacked_size mismatch: expected {unpacked_size}, got {len(out)}"
+ )
+ return out
+
+
+def detect_archive_type(path: Path) -> str | None:
+ try:
+ with path.open("rb") as handle:
+ magic = handle.read(4)
+ except OSError:
+ return None
+
+ if magic == MAGIC_NRES:
+ return "nres"
+ if magic == MAGIC_RSLI:
+ return "rsli"
+ return None
+
+
+def scan_archives(root: Path) -> list[dict[str, Any]]:
+ found: list[dict[str, Any]] = []
+ for path in sorted(root.rglob("*")):
+ if not path.is_file():
+ continue
+ archive_type = detect_archive_type(path)
+ if not archive_type:
+ continue
+ found.append(
+ {
+ "path": str(path),
+ "relative_path": str(path.relative_to(root)),
+ "type": archive_type,
+ "size": path.stat().st_size,
+ }
+ )
+ return found
+
+
+def parse_nres(data: bytes, source: str = "<memory>") -> dict[str, Any]:
+ if len(data) < 16:
+ raise ArchiveFormatError(f"{source}: NRes too short ({len(data)} bytes)")
+
+ magic, version, entry_count, total_size = struct.unpack_from("<4sIII", data, 0)
+ if magic != MAGIC_NRES:
+ raise ArchiveFormatError(f"{source}: invalid NRes magic")
+
+ issues: list[str] = []
+ if total_size != len(data):
+ issues.append(
+ f"header.total_size={total_size} != actual_size={len(data)} (spec 1.2)"
+ )
+ if version != 0x100:
+ issues.append(f"version=0x{version:08X} != 0x00000100 (spec 1.2)")
+
+ directory_offset = total_size - entry_count * 64
+ if directory_offset < 16 or directory_offset > len(data):
+ raise ArchiveFormatError(
+ f"{source}: invalid directory offset {directory_offset} for entry_count={entry_count}"
+ )
+ if directory_offset + entry_count * 64 != len(data):
+ issues.append(
+ "directory_offset + entry_count*64 != file_size (spec 1.3)"
+ )
+
+ entries: list[dict[str, Any]] = []
+ for index in range(entry_count):
+ offset = directory_offset + index * 64
+ if offset + 64 > len(data):
+ raise ArchiveFormatError(f"{source}: truncated directory entry {index}")
+
+ (
+ type_id,
+ attr1,
+ attr2,
+ size,
+ attr3,
+ name_raw,
+ data_offset,
+ sort_index,
+ ) = struct.unpack_from("<IIIII36sII", data, offset)
+ name_bytes = name_raw.split(b"\x00", 1)[0]
+ name = name_bytes.decode("latin1", errors="replace")
+ entries.append(
+ {
+ "index": index,
+ "type_id": type_id,
+ "attr1": attr1,
+ "attr2": attr2,
+ "size": size,
+ "attr3": attr3,
+ "name": name,
+ "name_bytes_hex": name_bytes.hex(),
+ "name_raw_hex": name_raw.hex(),
+ "data_offset": data_offset,
+ "sort_index": sort_index,
+ }
+ )
+
+ # Spec checks.
+ expected_sort = sorted(
+ range(entry_count),
+ key=lambda idx: bytes.fromhex(entries[idx]["name_bytes_hex"]).lower(),
+ )
+ current_sort = [item["sort_index"] for item in entries]
+ if current_sort != expected_sort:
+ issues.append(
+ "sort_index table does not match case-insensitive name order (spec 1.4)"
+ )
+
+ data_regions = sorted(
+ (
+ item["index"],
+ item["data_offset"],
+ item["size"],
+ )
+ for item in entries
+ )
+ for idx, data_offset, size in data_regions:
+ if data_offset % 8 != 0:
+ issues.append(f"entry {idx}: data_offset={data_offset} not aligned to 8 (spec 1.5)")
+ if data_offset < 16 or data_offset + size > directory_offset:
+ issues.append(
+ f"entry {idx}: data range [{data_offset}, {data_offset + size}) out of data area (spec 1.3)"
+ )
+ for i in range(len(data_regions) - 1):
+ _, start, size = data_regions[i]
+ _, next_start, _ = data_regions[i + 1]
+ if start + size > next_start:
+ issues.append(
+ f"entry overlap at data_offset={start}, next={next_start}"
+ )
+ padding = data[start + size : next_start]
+ if any(padding):
+ issues.append(
+ f"non-zero padding after data block at offset={start + size} (spec 1.5)"
+ )
+
+ return {
+ "format": "NRes",
+ "header": {
+ "magic": "NRes",
+ "version": version,
+ "entry_count": entry_count,
+ "total_size": total_size,
+ "directory_offset": directory_offset,
+ },
+ "entries": entries,
+ "issues": issues,
+ }
+
+
+def build_nres_name_field(entry: dict[str, Any]) -> bytes:
+ if "name_bytes_hex" in entry:
+ raw = bytes.fromhex(entry["name_bytes_hex"])
+ else:
+ raw = entry.get("name", "").encode("latin1", errors="replace")
+ raw = raw[:35]
+ return raw + b"\x00" * (36 - len(raw))
+
+
+def unpack_nres_file(archive_path: Path, out_dir: Path, source_root: Path | None = None) -> dict[str, Any]:
+ data = archive_path.read_bytes()
+ parsed = parse_nres(data, source=str(archive_path))
+
+ out_dir.mkdir(parents=True, exist_ok=True)
+ entries_dir = out_dir / "entries"
+ entries_dir.mkdir(parents=True, exist_ok=True)
+
+ manifest: dict[str, Any] = {
+ "format": "NRes",
+ "source_path": str(archive_path),
+ "source_relative_path": str(archive_path.relative_to(source_root)) if source_root else str(archive_path),
+ "header": parsed["header"],
+ "entries": [],
+ "issues": parsed["issues"],
+ "source_sha256": sha256_hex(data),
+ }
+
+ for entry in parsed["entries"]:
+ begin = entry["data_offset"]
+ end = begin + entry["size"]
+ if begin < 0 or end > len(data):
+ raise ArchiveFormatError(
+ f"{archive_path}: entry {entry['index']} data range outside file"
+ )
+ payload = data[begin:end]
+ base = safe_component(entry["name"], fallback=f"entry_{entry['index']:05d}")
+ file_name = (
+ f"{entry['index']:05d}__{base}"
+ f"__t{entry['type_id']:08X}_a1{entry['attr1']:08X}_a2{entry['attr2']:08X}.bin"
+ )
+ (entries_dir / file_name).write_bytes(payload)
+
+ manifest_entry = dict(entry)
+ manifest_entry["data_file"] = f"entries/{file_name}"
+ manifest_entry["sha256"] = sha256_hex(payload)
+ manifest["entries"].append(manifest_entry)
+
+ dump_json(out_dir / "manifest.json", manifest)
+ return manifest
+
+
+def pack_nres_manifest(manifest_path: Path, out_file: Path) -> bytes:
+ manifest = load_json(manifest_path)
+ if manifest.get("format") != "NRes":
+ raise ArchiveFormatError(f"{manifest_path}: not an NRes manifest")
+
+ entries = manifest["entries"]
+ count = len(entries)
+ version = int(manifest.get("header", {}).get("version", 0x100))
+
+ out = bytearray(b"\x00" * 16)
+ data_offsets: list[int] = []
+ data_sizes: list[int] = []
+
+ for entry in entries:
+ payload_path = manifest_path.parent / entry["data_file"]
+ payload = payload_path.read_bytes()
+ offset = len(out)
+ out.extend(payload)
+ padding = (-len(out)) % 8
+ if padding:
+ out.extend(b"\x00" * padding)
+ data_offsets.append(offset)
+ data_sizes.append(len(payload))
+
+ directory_offset = len(out)
+ expected_sort = sorted(
+ range(count),
+ key=lambda idx: bytes.fromhex(entries[idx].get("name_bytes_hex", "")).lower(),
+ )
+
+ for index, entry in enumerate(entries):
+ name_field = build_nres_name_field(entry)
+ out.extend(
+ struct.pack(
+ "<IIIII36sII",
+ int(entry["type_id"]),
+ int(entry["attr1"]),
+ int(entry["attr2"]),
+ data_sizes[index],
+ int(entry["attr3"]),
+ name_field,
+ data_offsets[index],
+ expected_sort[index],
+ )
+ )
+
+ total_size = len(out)
+ struct.pack_into("<4sIII", out, 0, MAGIC_NRES, version, count, total_size)
+
+ out_file.parent.mkdir(parents=True, exist_ok=True)
+ out_file.write_bytes(out)
+ return bytes(out)
+
+
+def parse_rsli(data: bytes, source: str = "<memory>") -> dict[str, Any]:
+ if len(data) < 32:
+ raise ArchiveFormatError(f"{source}: RsLi too short ({len(data)} bytes)")
+ if data[:4] != MAGIC_RSLI:
+ raise ArchiveFormatError(f"{source}: invalid RsLi magic")
+
+ issues: list[str] = []
+ reserved_zero = data[2]
+ version = data[3]
+ entry_count = struct.unpack_from("<h", data, 4)[0]
+ presorted_flag = struct.unpack_from("<H", data, 14)[0]
+ seed = struct.unpack_from("<I", data, 20)[0]
+
+ if reserved_zero != 0:
+ issues.append(f"header[2]={reserved_zero} != 0 (spec 2.2)")
+ if version != 1:
+ issues.append(f"version={version} != 1 (spec 2.2)")
+ if entry_count < 0:
+ raise ArchiveFormatError(f"{source}: negative entry_count={entry_count}")
+
+ table_offset = 32
+ table_size = entry_count * 32
+ if table_offset + table_size > len(data):
+ raise ArchiveFormatError(
+ f"{source}: encrypted table out of file bounds ({table_offset}+{table_size}>{len(data)})"
+ )
+
+ table_encrypted = data[table_offset : table_offset + table_size]
+ table_plain = xor_stream(table_encrypted, seed & 0xFFFF)
+
+ trailer: dict[str, Any] = {"present": False}
+ overlay_offset = 0
+ if len(data) >= 6 and data[-6:-4] == b"AO":
+ overlay_offset = struct.unpack_from("<I", data, len(data) - 4)[0]
+ trailer = {
+ "present": True,
+ "signature": "AO",
+ "overlay_offset": overlay_offset,
+ "raw_hex": data[-6:].hex(),
+ }
+
+ entries: list[dict[str, Any]] = []
+ sort_values: list[int] = []
+ for index in range(entry_count):
+ row = table_plain[index * 32 : (index + 1) * 32]
+ name_raw = row[0:12]
+ reserved4 = row[12:16]
+ flags_signed, sort_to_original = struct.unpack_from("<hh", row, 16)
+ unpacked_size, data_offset, packed_size = struct.unpack_from("<III", row, 20)
+ method = flags_signed & 0x1E0
+ name = name_raw.split(b"\x00", 1)[0].decode("latin1", errors="replace")
+ effective_offset = data_offset + overlay_offset
+ entries.append(
+ {
+ "index": index,
+ "name": name,
+ "name_raw_hex": name_raw.hex(),
+ "reserved_raw_hex": reserved4.hex(),
+ "flags_signed": flags_signed,
+ "flags_u16": flags_signed & 0xFFFF,
+ "method": method,
+ "sort_to_original": sort_to_original,
+ "unpacked_size": unpacked_size,
+ "data_offset": data_offset,
+ "effective_data_offset": effective_offset,
+ "packed_size": packed_size,
+ }
+ )
+ sort_values.append(sort_to_original)
+
+ if effective_offset < 0:
+ issues.append(f"entry {index}: negative effective_data_offset={effective_offset}")
+ elif effective_offset + packed_size > len(data):
+ end = effective_offset + packed_size
+ if method == 0x100 and end == len(data) + 1:
+ issues.append(
+ f"entry {index}: deflate packed_size reaches EOF+1 ({end}); "
+ "observed in game data, likely decoder lookahead byte"
+ )
+ else:
+ issues.append(
+ f"entry {index}: packed range [{effective_offset}, {end}) out of file"
+ )
+
+ if presorted_flag == 0xABBA:
+ if sorted(sort_values) != list(range(entry_count)):
+ issues.append(
+ "presorted flag is 0xABBA but sort_to_original is not a permutation [0..N-1] (spec 2.2/2.4)"
+ )
+
+ return {
+ "format": "RsLi",
+ "header_raw_hex": data[:32].hex(),
+ "header": {
+ "magic": "NL\\x00\\x01",
+ "entry_count": entry_count,
+ "seed": seed,
+ "presorted_flag": presorted_flag,
+ },
+ "entries": entries,
+ "issues": issues,
+ "trailer": trailer,
+ }
+
+
+def unpack_rsli_file(archive_path: Path, out_dir: Path, source_root: Path | None = None) -> dict[str, Any]:
+ data = archive_path.read_bytes()
+ parsed = parse_rsli(data, source=str(archive_path))
+
+ out_dir.mkdir(parents=True, exist_ok=True)
+ entries_dir = out_dir / "entries"
+ entries_dir.mkdir(parents=True, exist_ok=True)
+
+ manifest: dict[str, Any] = {
+ "format": "RsLi",
+ "source_path": str(archive_path),
+ "source_relative_path": str(archive_path.relative_to(source_root)) if source_root else str(archive_path),
+ "source_size": len(data),
+ "header_raw_hex": parsed["header_raw_hex"],
+ "header": parsed["header"],
+ "entries": [],
+ "issues": list(parsed["issues"]),
+ "trailer": parsed["trailer"],
+ "source_sha256": sha256_hex(data),
+ }
+
+ for entry in parsed["entries"]:
+ begin = int(entry["effective_data_offset"])
+ end = begin + int(entry["packed_size"])
+ packed = data[begin:end]
+ base = safe_component(entry["name"], fallback=f"entry_{entry['index']:05d}")
+ packed_name = f"{entry['index']:05d}__{base}__packed.bin"
+ (entries_dir / packed_name).write_bytes(packed)
+
+ manifest_entry = dict(entry)
+ manifest_entry["packed_file"] = f"entries/{packed_name}"
+ manifest_entry["packed_file_size"] = len(packed)
+ manifest_entry["packed_sha256"] = sha256_hex(packed)
+
+ try:
+ unpacked = decode_rsli_payload(
+ packed=packed,
+ method=int(entry["method"]),
+ sort_to_original=int(entry["sort_to_original"]),
+ unpacked_size=int(entry["unpacked_size"]),
+ )
+ unpacked_name = f"{entry['index']:05d}__{base}__unpacked.bin"
+ (entries_dir / unpacked_name).write_bytes(unpacked)
+ manifest_entry["unpacked_file"] = f"entries/{unpacked_name}"
+ manifest_entry["unpacked_sha256"] = sha256_hex(unpacked)
+ except ArchiveFormatError as exc:
+ manifest_entry["unpack_error"] = str(exc)
+ manifest["issues"].append(
+ f"entry {entry['index']}: cannot decode method 0x{entry['method']:03X}: {exc}"
+ )
+
+ manifest["entries"].append(manifest_entry)
+
+ dump_json(out_dir / "manifest.json", manifest)
+ return manifest
+
+
+def _pack_i16(value: int) -> int:
+ if not (-32768 <= int(value) <= 32767):
+ raise ArchiveFormatError(f"int16 overflow: {value}")
+ return int(value)
+
+
+def pack_rsli_manifest(manifest_path: Path, out_file: Path) -> bytes:
+ manifest = load_json(manifest_path)
+ if manifest.get("format") != "RsLi":
+ raise ArchiveFormatError(f"{manifest_path}: not an RsLi manifest")
+
+ entries = manifest["entries"]
+ count = len(entries)
+
+ header_raw = bytes.fromhex(manifest["header_raw_hex"])
+ if len(header_raw) != 32:
+ raise ArchiveFormatError(f"{manifest_path}: header_raw_hex must be 32 bytes")
+ header = bytearray(header_raw)
+ header[:4] = MAGIC_RSLI
+ struct.pack_into("<h", header, 4, count)
+ seed = int(manifest["header"]["seed"])
+ struct.pack_into("<I", header, 20, seed)
+
+ rows = bytearray()
+ packed_chunks: list[tuple[dict[str, Any], bytes]] = []
+
+ for entry in entries:
+ packed_path = manifest_path.parent / entry["packed_file"]
+ packed = packed_path.read_bytes()
+ declared_size = int(entry["packed_size"])
+ if len(packed) > declared_size:
+ raise ArchiveFormatError(
+ f"{packed_path}: packed size {len(packed)} > manifest packed_size {declared_size}"
+ )
+
+ data_offset = int(entry["data_offset"])
+ packed_chunks.append((entry, packed))
+
+ row = bytearray(32)
+ name_raw = bytes.fromhex(entry["name_raw_hex"])
+ reserved_raw = bytes.fromhex(entry["reserved_raw_hex"])
+ if len(name_raw) != 12 or len(reserved_raw) != 4:
+ raise ArchiveFormatError(
+ f"entry {entry['index']}: invalid name/reserved raw length"
+ )
+ row[0:12] = name_raw
+ row[12:16] = reserved_raw
+ struct.pack_into(
+ "<hhIII",
+ row,
+ 16,
+ _pack_i16(int(entry["flags_signed"])),
+ _pack_i16(int(entry["sort_to_original"])),
+ int(entry["unpacked_size"]),
+ data_offset,
+ declared_size,
+ )
+ rows.extend(row)
+
+ encrypted_table = xor_stream(bytes(rows), seed & 0xFFFF)
+ trailer = manifest.get("trailer", {})
+ trailer_raw = b""
+ if trailer.get("present"):
+ raw_hex = trailer.get("raw_hex", "")
+ trailer_raw = bytes.fromhex(raw_hex)
+ if len(trailer_raw) != 6:
+ raise ArchiveFormatError("trailer raw length must be 6 bytes")
+
+ source_size = manifest.get("source_size")
+ table_end = 32 + count * 32
+ if source_size is not None:
+ pre_trailer_size = int(source_size) - len(trailer_raw)
+ if pre_trailer_size < table_end:
+ raise ArchiveFormatError(
+ f"invalid source_size={source_size}: smaller than header+table"
+ )
+ else:
+ pre_trailer_size = table_end
+ for entry, packed in packed_chunks:
+ pre_trailer_size = max(
+ pre_trailer_size, int(entry["data_offset"]) + len(packed)
+ )
+
+ out = bytearray(pre_trailer_size)
+ out[0:32] = header
+ out[32:table_end] = encrypted_table
+ occupied = bytearray(pre_trailer_size)
+ occupied[0:table_end] = b"\x01" * table_end
+
+ for entry, packed in packed_chunks:
+ base_offset = int(entry["data_offset"])
+ for index, byte in enumerate(packed):
+ pos = base_offset + index
+ if pos >= pre_trailer_size:
+ raise ArchiveFormatError(
+ f"entry {entry['index']}: data write at {pos} beyond output size {pre_trailer_size}"
+ )
+ if occupied[pos] and out[pos] != byte:
+ raise ArchiveFormatError(
+ f"entry {entry['index']}: overlapping packed data conflict at offset {pos}"
+ )
+ out[pos] = byte
+ occupied[pos] = 1
+
+ out.extend(trailer_raw)
+ if source_size is not None and len(out) != int(source_size):
+ raise ArchiveFormatError(
+ f"packed size {len(out)} != source_size {source_size} from manifest"
+ )
+
+ out_file.parent.mkdir(parents=True, exist_ok=True)
+ out_file.write_bytes(out)
+ return bytes(out)
+
+
+def cmd_scan(args: argparse.Namespace) -> int:
+ root = Path(args.input).resolve()
+ archives = scan_archives(root)
+ if args.json:
+ print(json.dumps(archives, ensure_ascii=False, indent=2))
+ else:
+ print(f"Found {len(archives)} archive(s) in {root}")
+ for item in archives:
+ print(f"{item['type']:4} {item['size']:10d} {item['relative_path']}")
+ return 0
+
+
+def cmd_nres_unpack(args: argparse.Namespace) -> int:
+ archive_path = Path(args.archive).resolve()
+ out_dir = Path(args.output).resolve()
+ manifest = unpack_nres_file(archive_path, out_dir)
+ print(f"NRes unpacked: {archive_path}")
+ print(f"Manifest: {out_dir / 'manifest.json'}")
+ print(f"Entries : {len(manifest['entries'])}")
+ if manifest["issues"]:
+ print("Issues:")
+ for issue in manifest["issues"]:
+ print(f"- {issue}")
+ return 0
+
+
+def cmd_nres_pack(args: argparse.Namespace) -> int:
+ manifest_path = Path(args.manifest).resolve()
+ out_file = Path(args.output).resolve()
+ packed = pack_nres_manifest(manifest_path, out_file)
+ print(f"NRes packed: {out_file} ({len(packed)} bytes, sha256={sha256_hex(packed)})")
+ return 0
+
+
+def cmd_rsli_unpack(args: argparse.Namespace) -> int:
+ archive_path = Path(args.archive).resolve()
+ out_dir = Path(args.output).resolve()
+ manifest = unpack_rsli_file(archive_path, out_dir)
+ print(f"RsLi unpacked: {archive_path}")
+ print(f"Manifest: {out_dir / 'manifest.json'}")
+ print(f"Entries : {len(manifest['entries'])}")
+ if manifest["issues"]:
+ print("Issues:")
+ for issue in manifest["issues"]:
+ print(f"- {issue}")
+ return 0
+
+
+def cmd_rsli_pack(args: argparse.Namespace) -> int:
+ manifest_path = Path(args.manifest).resolve()
+ out_file = Path(args.output).resolve()
+ packed = pack_rsli_manifest(manifest_path, out_file)
+ print(f"RsLi packed: {out_file} ({len(packed)} bytes, sha256={sha256_hex(packed)})")
+ return 0
+
+
+def cmd_validate(args: argparse.Namespace) -> int:
+ input_root = Path(args.input).resolve()
+ archives = scan_archives(input_root)
+
+ temp_created = False
+ if args.workdir:
+ workdir = Path(args.workdir).resolve()
+ workdir.mkdir(parents=True, exist_ok=True)
+ else:
+ workdir = Path(tempfile.mkdtemp(prefix="nres-rsli-validate-"))
+ temp_created = True
+
+ report: dict[str, Any] = {
+ "input_root": str(input_root),
+ "workdir": str(workdir),
+ "archives_total": len(archives),
+ "results": [],
+ "summary": {},
+ }
+
+ failures = 0
+ try:
+ for idx, item in enumerate(archives):
+ rel = item["relative_path"]
+ archive_path = input_root / rel
+ marker = f"{idx:04d}_{safe_component(rel, fallback='archive')}"
+ unpack_dir = workdir / "unpacked" / marker
+ repacked_file = workdir / "repacked" / f"{marker}.bin"
+ try:
+ if item["type"] == "nres":
+ manifest = unpack_nres_file(archive_path, unpack_dir, source_root=input_root)
+ repacked = pack_nres_manifest(unpack_dir / "manifest.json", repacked_file)
+ elif item["type"] == "rsli":
+ manifest = unpack_rsli_file(archive_path, unpack_dir, source_root=input_root)
+ repacked = pack_rsli_manifest(unpack_dir / "manifest.json", repacked_file)
+ else:
+ continue
+
+ original = archive_path.read_bytes()
+ match = original == repacked
+ diff_offset, diff_desc = first_diff(original, repacked)
+ issues = list(manifest.get("issues", []))
+ result = {
+ "relative_path": rel,
+ "type": item["type"],
+ "size_original": len(original),
+ "size_repacked": len(repacked),
+ "sha256_original": sha256_hex(original),
+ "sha256_repacked": sha256_hex(repacked),
+ "match": match,
+ "first_diff_offset": diff_offset,
+ "first_diff": diff_desc,
+ "issues": issues,
+ "entries": len(manifest.get("entries", [])),
+ "error": None,
+ }
+ except Exception as exc: # pylint: disable=broad-except
+ result = {
+ "relative_path": rel,
+ "type": item["type"],
+ "size_original": item["size"],
+ "size_repacked": None,
+ "sha256_original": None,
+ "sha256_repacked": None,
+ "match": False,
+ "first_diff_offset": None,
+ "first_diff": None,
+ "issues": [f"processing error: {exc}"],
+ "entries": None,
+ "error": str(exc),
+ }
+
+ report["results"].append(result)
+
+ if not result["match"]:
+ failures += 1
+ if result["issues"] and args.fail_on_issues:
+ failures += 1
+
+ matches = sum(1 for row in report["results"] if row["match"])
+ mismatches = len(report["results"]) - matches
+ nres_count = sum(1 for row in report["results"] if row["type"] == "nres")
+ rsli_count = sum(1 for row in report["results"] if row["type"] == "rsli")
+ issues_total = sum(len(row["issues"]) for row in report["results"])
+ report["summary"] = {
+ "nres_count": nres_count,
+ "rsli_count": rsli_count,
+ "matches": matches,
+ "mismatches": mismatches,
+ "issues_total": issues_total,
+ }
+
+ if args.report:
+ dump_json(Path(args.report).resolve(), report)
+
+ print(f"Input root : {input_root}")
+ print(f"Work dir : {workdir}")
+ print(f"NRes archives : {nres_count}")
+ print(f"RsLi archives : {rsli_count}")
+ print(f"Roundtrip match: {matches}/{len(report['results'])}")
+ print(f"Doc issues : {issues_total}")
+
+ if mismatches:
+ print("\nMismatches:")
+ for row in report["results"]:
+ if row["match"]:
+ continue
+ print(
+ f"- {row['relative_path']} [{row['type']}] "
+ f"diff@{row['first_diff_offset']}: {row['first_diff']}"
+ )
+
+ if issues_total:
+ print("\nIssues:")
+ for row in report["results"]:
+ if not row["issues"]:
+ continue
+ print(f"- {row['relative_path']} [{row['type']}]")
+ for issue in row["issues"]:
+ print(f" * {issue}")
+
+ finally:
+ if temp_created or args.cleanup:
+ shutil.rmtree(workdir, ignore_errors=True)
+
+ if failures > 0:
+ return 1
+ if report["summary"].get("mismatches", 0) > 0 and args.fail_on_diff:
+ return 1
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="NRes/RsLi tools: scan, unpack, repack, and roundtrip validation."
+ )
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ scan = sub.add_parser("scan", help="Scan files by header signatures.")
+ scan.add_argument("--input", required=True, help="Root directory to scan.")
+ scan.add_argument("--json", action="store_true", help="Print JSON output.")
+ scan.set_defaults(func=cmd_scan)
+
+ nres_unpack = sub.add_parser("nres-unpack", help="Unpack a single NRes archive.")
+ nres_unpack.add_argument("--archive", required=True, help="Path to NRes file.")
+ nres_unpack.add_argument("--output", required=True, help="Output directory.")
+ nres_unpack.set_defaults(func=cmd_nres_unpack)
+
+ nres_pack = sub.add_parser("nres-pack", help="Pack NRes archive from manifest.")
+ nres_pack.add_argument("--manifest", required=True, help="Path to manifest.json.")
+ nres_pack.add_argument("--output", required=True, help="Output file path.")
+ nres_pack.set_defaults(func=cmd_nres_pack)
+
+ rsli_unpack = sub.add_parser("rsli-unpack", help="Unpack a single RsLi archive.")
+ rsli_unpack.add_argument("--archive", required=True, help="Path to RsLi file.")
+ rsli_unpack.add_argument("--output", required=True, help="Output directory.")
+ rsli_unpack.set_defaults(func=cmd_rsli_unpack)
+
+ rsli_pack = sub.add_parser("rsli-pack", help="Pack RsLi archive from manifest.")
+ rsli_pack.add_argument("--manifest", required=True, help="Path to manifest.json.")
+ rsli_pack.add_argument("--output", required=True, help="Output file path.")
+ rsli_pack.set_defaults(func=cmd_rsli_pack)
+
+ validate = sub.add_parser(
+ "validate",
+ help="Scan all archives and run unpack->repack->byte-compare validation.",
+ )
+ validate.add_argument("--input", required=True, help="Root with game data files.")
+ validate.add_argument(
+ "--workdir",
+ help="Working directory for temporary unpack/repack files. "
+ "If omitted, a temporary directory is used and removed automatically.",
+ )
+ validate.add_argument("--report", help="Optional JSON report output path.")
+ validate.add_argument(
+ "--fail-on-diff",
+ action="store_true",
+ help="Return non-zero exit code if any byte mismatch exists.",
+ )
+ validate.add_argument(
+ "--fail-on-issues",
+ action="store_true",
+ help="Return non-zero exit code if any spec issue was detected.",
+ )
+ validate.add_argument(
+ "--cleanup",
+ action="store_true",
+ help="Remove --workdir after completion.",
+ )
+ validate.set_defaults(func=cmd_validate)
+
+ return parser
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+ return int(args.func(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/init_testdata.py b/tools/init_testdata.py
new file mode 100644
index 0000000..4079cdb
--- /dev/null
+++ b/tools/init_testdata.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+"""
+Initialize test data folders by archive signatures.
+
+The script scans all files in --input and copies matching archives into:
+ --output/nres/<relative path>
+ --output/rsli/<relative path>
+"""
+
+from __future__ import annotations
+
+import argparse
+import shutil
+import sys
+from pathlib import Path
+
+MAGIC_NRES = b"NRes"
+MAGIC_RSLI = b"NL\x00\x01"
+
+
+def is_relative_to(path: Path, base: Path) -> bool:
+ try:
+ path.relative_to(base)
+ except ValueError:
+ return False
+ return True
+
+
+def detect_archive_type(path: Path) -> str | None:
+ try:
+ with path.open("rb") as handle:
+ magic = handle.read(4)
+ except OSError as exc:
+ print(f"[warn] cannot read {path}: {exc}", file=sys.stderr)
+ return None
+
+ if magic == MAGIC_NRES:
+ return "nres"
+ if magic == MAGIC_RSLI:
+ return "rsli"
+ return None
+
+
+def scan_archives(input_root: Path, excluded_root: Path | None) -> list[tuple[Path, str]]:
+ found: list[tuple[Path, str]] = []
+ for path in sorted(input_root.rglob("*")):
+ if not path.is_file():
+ continue
+ if excluded_root and is_relative_to(path.resolve(), excluded_root):
+ continue
+
+ archive_type = detect_archive_type(path)
+ if archive_type:
+ found.append((path, archive_type))
+ return found
+
+
+def confirm_overwrite(path: Path) -> str:
+ prompt = (
+ f"File exists: {path}\n"
+ "Overwrite? [y]es / [n]o / [a]ll / [q]uit (default: n): "
+ )
+ while True:
+ try:
+ answer = input(prompt).strip().lower()
+ except EOFError:
+ return "quit"
+
+ if answer in {"", "n", "no"}:
+ return "no"
+ if answer in {"y", "yes"}:
+ return "yes"
+ if answer in {"a", "all"}:
+ return "all"
+ if answer in {"q", "quit"}:
+ return "quit"
+ print("Please answer with y, n, a, or q.")
+
+
+def copy_archives(
+ archives: list[tuple[Path, str]],
+ input_root: Path,
+ output_root: Path,
+ force: bool,
+) -> int:
+ copied = 0
+ skipped = 0
+ overwritten = 0
+ overwrite_all = force
+
+ type_counts = {"nres": 0, "rsli": 0}
+ for _, archive_type in archives:
+ type_counts[archive_type] += 1
+
+ print(
+ f"Found archives: total={len(archives)}, "
+ f"nres={type_counts['nres']}, rsli={type_counts['rsli']}"
+ )
+
+ for source, archive_type in archives:
+ rel_path = source.relative_to(input_root)
+ destination = output_root / archive_type / rel_path
+ destination.parent.mkdir(parents=True, exist_ok=True)
+
+ if destination.exists():
+ if destination.is_dir():
+ print(
+ f"[error] destination is a directory, expected file: {destination}",
+ file=sys.stderr,
+ )
+ return 2
+
+ if not overwrite_all:
+ if not sys.stdin.isatty():
+ print(
+ "[error] destination file exists but stdin is not interactive. "
+ "Use --force to overwrite without prompts.",
+ file=sys.stderr,
+ )
+ return 2
+
+ decision = confirm_overwrite(destination)
+ if decision == "quit":
+ print("Aborted by user.")
+ return 130
+ if decision == "no":
+ skipped += 1
+ continue
+ if decision == "all":
+ overwrite_all = True
+
+ overwritten += 1
+
+ try:
+ shutil.copy2(source, destination)
+ except OSError as exc:
+ print(f"[error] failed to copy {source} -> {destination}: {exc}", file=sys.stderr)
+ return 2
+ copied += 1
+
+ print(
+ f"Done: copied={copied}, overwritten={overwritten}, skipped={skipped}, "
+ f"output={output_root}"
+ )
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Initialize test data by scanning NRes/RsLi signatures."
+ )
+ parser.add_argument(
+ "--input",
+ required=True,
+ help="Input directory to scan recursively.",
+ )
+ parser.add_argument(
+ "--output",
+ required=True,
+ help="Output root directory (archives go to nres/ and rsli/ subdirs).",
+ )
+ parser.add_argument(
+ "--force",
+ action="store_true",
+ help="Overwrite destination files without confirmation prompts.",
+ )
+ return parser
+
+
+def main() -> int:
+ args = build_parser().parse_args()
+
+ input_root = Path(args.input)
+ if not input_root.exists():
+ print(f"[error] input directory does not exist: {input_root}", file=sys.stderr)
+ return 2
+ if not input_root.is_dir():
+ print(f"[error] input path is not a directory: {input_root}", file=sys.stderr)
+ return 2
+
+ output_root = Path(args.output)
+ if output_root.exists() and not output_root.is_dir():
+ print(f"[error] output path exists and is not a directory: {output_root}", file=sys.stderr)
+ return 2
+
+ input_resolved = input_root.resolve()
+ output_resolved = output_root.resolve()
+ if input_resolved == output_resolved:
+ print("[error] input and output directories must be different.", file=sys.stderr)
+ return 2
+
+ excluded_root: Path | None = None
+ if is_relative_to(output_resolved, input_resolved):
+ excluded_root = output_resolved
+ print(f"Notice: output is inside input, skipping scan under: {excluded_root}")
+
+ archives = scan_archives(input_root, excluded_root)
+
+ output_root.mkdir(parents=True, exist_ok=True)
+ return copy_archives(archives, input_root, output_root, force=args.force)
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/msh_doc_validator.py b/tools/msh_doc_validator.py
new file mode 100644
index 0000000..ff096a4
--- /dev/null
+++ b/tools/msh_doc_validator.py
@@ -0,0 +1,1000 @@
+#!/usr/bin/env python3
+"""
+Validate assumptions from docs/specs/msh.md on real game archives.
+
+The tool checks three groups:
+1) MSH model payloads (nested NRes in *.msh entries),
+2) Texm texture payloads,
+3) FXID effect payloads.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import struct
+from collections import Counter
+from pathlib import Path
+from typing import Any
+
+import archive_roundtrip_validator as arv
+
+MAGIC_NRES = b"NRes"
+MAGIC_PAGE = b"Page"
+
+TYPE_FXID = 0x44495846
+TYPE_TEXM = 0x6D786554
+
+FX_CMD_SIZE = {1: 224, 2: 148, 3: 200, 4: 204, 5: 112, 6: 4, 7: 208, 8: 248, 9: 208, 10: 208}
+TEXM_KNOWN_FORMATS = {0, 565, 556, 4444, 888, 8888}
+
+
+def _add_issue(
+ issues: list[dict[str, Any]],
+ severity: str,
+ category: str,
+ archive: Path,
+ entry_name: str | None,
+ message: str,
+) -> None:
+ issues.append(
+ {
+ "severity": severity,
+ "category": category,
+ "archive": str(archive),
+ "entry": entry_name,
+ "message": message,
+ }
+ )
+
+
+def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes:
+ start = int(entry["data_offset"])
+ end = start + int(entry["size"])
+ return blob[start:end]
+
+
+def _entry_by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
+ by_type: dict[int, list[dict[str, Any]]] = {}
+ for item in entries:
+ by_type.setdefault(int(item["type_id"]), []).append(item)
+ return by_type
+
+
+def _expect_single_resource(
+ by_type: dict[int, list[dict[str, Any]]],
+ type_id: int,
+ label: str,
+ issues: list[dict[str, Any]],
+ archive: Path,
+ model_name: str,
+ required: bool,
+) -> dict[str, Any] | None:
+ rows = by_type.get(type_id, [])
+ if not rows:
+ if required:
+ _add_issue(
+ issues,
+ "error",
+ "model-resource",
+ archive,
+ model_name,
+ f"missing required resource type={type_id} ({label})",
+ )
+ return None
+ if len(rows) > 1:
+ _add_issue(
+ issues,
+ "warning",
+ "model-resource",
+ archive,
+ model_name,
+ f"multiple resources type={type_id} ({label}); using first entry",
+ )
+ return rows[0]
+
+
+def _check_fixed_stride(
+ *,
+ entry: dict[str, Any],
+ stride: int,
+ label: str,
+ issues: list[dict[str, Any]],
+ archive: Path,
+ model_name: str,
+ enforce_attr3: bool = True,
+ enforce_attr2_zero: bool = True,
+) -> int:
+ size = int(entry["size"])
+ attr1 = int(entry["attr1"])
+ attr2 = int(entry["attr2"])
+ attr3 = int(entry["attr3"])
+
+ count = -1
+ if size % stride != 0:
+ _add_issue(
+ issues,
+ "error",
+ "model-stride",
+ archive,
+ model_name,
+ f"{label}: size={size} is not divisible by stride={stride}",
+ )
+ else:
+ count = size // stride
+ if attr1 != count:
+ _add_issue(
+ issues,
+ "error",
+ "model-attr",
+ archive,
+ model_name,
+ f"{label}: attr1={attr1} != size/stride={count}",
+ )
+ if enforce_attr3 and attr3 != stride:
+ _add_issue(
+ issues,
+ "error",
+ "model-attr",
+ archive,
+ model_name,
+ f"{label}: attr3={attr3} != {stride}",
+ )
+ if enforce_attr2_zero and attr2 != 0:
+ _add_issue(
+ issues,
+ "warning",
+ "model-attr",
+ archive,
+ model_name,
+ f"{label}: attr2={attr2} (expected 0 in known assets)",
+ )
+ return count
+
+
+def _validate_res10(
+ data: bytes,
+ node_count: int,
+ issues: list[dict[str, Any]],
+ archive: Path,
+ model_name: str,
+) -> None:
+ off = 0
+ for idx in range(node_count):
+ if off + 4 > len(data):
+ _add_issue(
+ issues,
+ "error",
+ "res10",
+ archive,
+ model_name,
+ f"record {idx}: missing u32 length (offset={off}, size={len(data)})",
+ )
+ return
+ ln = struct.unpack_from("<I", data, off)[0]
+ off += 4
+ need = ln + 1 if ln else 0
+ if off + need > len(data):
+ _add_issue(
+ issues,
+ "error",
+ "res10",
+ archive,
+ model_name,
+ f"record {idx}: out of bounds (len={ln}, need={need}, offset={off}, size={len(data)})",
+ )
+ return
+ if ln and data[off + ln] != 0:
+ _add_issue(
+ issues,
+ "warning",
+ "res10",
+ archive,
+ model_name,
+ f"record {idx}: missing trailing NUL at payload end",
+ )
+ off += need
+
+ if off != len(data):
+ _add_issue(
+ issues,
+ "error",
+ "res10",
+ archive,
+ model_name,
+ f"tail bytes after node records: consumed={off}, size={len(data)}",
+ )
+
+
+def _validate_model_payload(
+ model_blob: bytes,
+ archive: Path,
+ model_name: str,
+ issues: list[dict[str, Any]],
+ counters: Counter[str],
+) -> None:
+ counters["models_total"] += 1
+
+ if model_blob[:4] != MAGIC_NRES:
+ _add_issue(
+ issues,
+ "error",
+ "model-container",
+ archive,
+ model_name,
+ "payload is not NRes (missing magic)",
+ )
+ return
+
+ try:
+ parsed = arv.parse_nres(model_blob, source=f"{archive}:{model_name}")
+ except Exception as exc: # pylint: disable=broad-except
+ _add_issue(
+ issues,
+ "error",
+ "model-container",
+ archive,
+ model_name,
+ f"cannot parse nested NRes: {exc}",
+ )
+ return
+
+ for item in parsed.get("issues", []):
+ _add_issue(issues, "warning", "model-container", archive, model_name, str(item))
+
+ entries = parsed["entries"]
+ by_type = _entry_by_type(entries)
+
+ res1 = _expect_single_resource(by_type, 1, "Res1", issues, archive, model_name, True)
+ res2 = _expect_single_resource(by_type, 2, "Res2", issues, archive, model_name, True)
+ res3 = _expect_single_resource(by_type, 3, "Res3", issues, archive, model_name, True)
+ res4 = _expect_single_resource(by_type, 4, "Res4", issues, archive, model_name, False)
+ res5 = _expect_single_resource(by_type, 5, "Res5", issues, archive, model_name, False)
+ res6 = _expect_single_resource(by_type, 6, "Res6", issues, archive, model_name, True)
+ res7 = _expect_single_resource(by_type, 7, "Res7", issues, archive, model_name, False)
+ res8 = _expect_single_resource(by_type, 8, "Res8", issues, archive, model_name, False)
+ res10 = _expect_single_resource(by_type, 10, "Res10", issues, archive, model_name, False)
+ res13 = _expect_single_resource(by_type, 13, "Res13", issues, archive, model_name, True)
+ res15 = _expect_single_resource(by_type, 15, "Res15", issues, archive, model_name, False)
+ res16 = _expect_single_resource(by_type, 16, "Res16", issues, archive, model_name, False)
+ res18 = _expect_single_resource(by_type, 18, "Res18", issues, archive, model_name, False)
+ res19 = _expect_single_resource(by_type, 19, "Res19", issues, archive, model_name, False)
+
+ if not (res1 and res2 and res3 and res6 and res13):
+ return
+
+ # Res1
+ res1_stride = int(res1["attr3"])
+ if res1_stride not in (38, 24):
+ _add_issue(
+ issues,
+ "warning",
+ "res1",
+ archive,
+ model_name,
+ f"unexpected Res1 stride attr3={res1_stride} (known: 38 or 24)",
+ )
+ if res1_stride <= 0:
+ _add_issue(issues, "error", "res1", archive, model_name, f"invalid Res1 stride={res1_stride}")
+ return
+ if int(res1["size"]) % res1_stride != 0:
+ _add_issue(
+ issues,
+ "error",
+ "res1",
+ archive,
+ model_name,
+ f"Res1 size={res1['size']} not divisible by stride={res1_stride}",
+ )
+ return
+ node_count = int(res1["size"]) // res1_stride
+ if int(res1["attr1"]) != node_count:
+ _add_issue(
+ issues,
+ "error",
+ "res1",
+ archive,
+ model_name,
+ f"Res1 attr1={res1['attr1']} != node_count={node_count}",
+ )
+
+ # Res2
+ res2_size = int(res2["size"])
+ res2_attr1 = int(res2["attr1"])
+ res2_attr2 = int(res2["attr2"])
+ res2_attr3 = int(res2["attr3"])
+ if res2_size < 0x8C:
+ _add_issue(issues, "error", "res2", archive, model_name, f"Res2 too small: size={res2_size}")
+ return
+ slot_bytes = res2_size - 0x8C
+ slot_count = -1
+ if slot_bytes % 68 != 0:
+ _add_issue(
+ issues,
+ "error",
+ "res2",
+ archive,
+ model_name,
+ f"Res2 slot area not divisible by 68: slot_bytes={slot_bytes}",
+ )
+ else:
+ slot_count = slot_bytes // 68
+ if res2_attr1 != slot_count:
+ _add_issue(
+ issues,
+ "error",
+ "res2",
+ archive,
+ model_name,
+ f"Res2 attr1={res2_attr1} != slot_count={slot_count}",
+ )
+ if res2_attr2 != 0:
+ _add_issue(
+ issues,
+ "warning",
+ "res2",
+ archive,
+ model_name,
+ f"Res2 attr2={res2_attr2} (expected 0 in known assets)",
+ )
+ if res2_attr3 != 68:
+ _add_issue(
+ issues,
+ "error",
+ "res2",
+ archive,
+ model_name,
+ f"Res2 attr3={res2_attr3} != 68",
+ )
+
+ # Fixed-stride resources
+ vertex_count = _check_fixed_stride(
+ entry=res3,
+ stride=12,
+ label="Res3",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ )
+ _ = _check_fixed_stride(
+ entry=res4,
+ stride=4,
+ label="Res4",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ ) if res4 else None
+ _ = _check_fixed_stride(
+ entry=res5,
+ stride=4,
+ label="Res5",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ ) if res5 else None
+ index_count = _check_fixed_stride(
+ entry=res6,
+ stride=2,
+ label="Res6",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ )
+ tri_desc_count = _check_fixed_stride(
+ entry=res7,
+ stride=16,
+ label="Res7",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ ) if res7 else -1
+ anim_key_count = _check_fixed_stride(
+ entry=res8,
+ stride=24,
+ label="Res8",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ enforce_attr3=False, # format stores attr3=4 in data set
+ ) if res8 else -1
+ if res8 and int(res8["attr3"]) != 4:
+ _add_issue(
+ issues,
+ "error",
+ "res8",
+ archive,
+ model_name,
+ f"Res8 attr3={res8['attr3']} != 4",
+ )
+ if res13:
+ batch_count = _check_fixed_stride(
+ entry=res13,
+ stride=20,
+ label="Res13",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ )
+ else:
+ batch_count = -1
+ if res15:
+ _check_fixed_stride(
+ entry=res15,
+ stride=8,
+ label="Res15",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ )
+ if res16:
+ _check_fixed_stride(
+ entry=res16,
+ stride=8,
+ label="Res16",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ )
+ if res18:
+ _check_fixed_stride(
+ entry=res18,
+ stride=4,
+ label="Res18",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ )
+
+ if res19:
+ anim_map_count = _check_fixed_stride(
+ entry=res19,
+ stride=2,
+ label="Res19",
+ issues=issues,
+ archive=archive,
+ model_name=model_name,
+ enforce_attr3=False,
+ enforce_attr2_zero=False,
+ )
+ if int(res19["attr3"]) != 2:
+ _add_issue(
+ issues,
+ "error",
+ "res19",
+ archive,
+ model_name,
+ f"Res19 attr3={res19['attr3']} != 2",
+ )
+ else:
+ anim_map_count = -1
+
+ # Res10
+ if res10:
+ if int(res10["attr1"]) != int(res1["attr1"]):
+ _add_issue(
+ issues,
+ "error",
+ "res10",
+ archive,
+ model_name,
+ f"Res10 attr1={res10['attr1']} != Res1.attr1={res1['attr1']}",
+ )
+ if int(res10["attr3"]) != 0:
+ _add_issue(
+ issues,
+ "warning",
+ "res10",
+ archive,
+ model_name,
+ f"Res10 attr3={res10['attr3']} (known assets use 0)",
+ )
+ _validate_res10(_entry_payload(model_blob, res10), node_count, issues, archive, model_name)
+
+ # Cross-table checks.
+ if vertex_count > 0 and (res4 and int(res4["size"]) // 4 != vertex_count):
+ _add_issue(issues, "error", "model-cross", archive, model_name, "Res4 count != Res3 count")
+ if vertex_count > 0 and (res5 and int(res5["size"]) // 4 != vertex_count):
+ _add_issue(issues, "error", "model-cross", archive, model_name, "Res5 count != Res3 count")
+
+ indices: list[int] = []
+ if index_count > 0:
+ res6_data = _entry_payload(model_blob, res6)
+ indices = list(struct.unpack_from(f"<{index_count}H", res6_data, 0))
+
+ if batch_count > 0:
+ res13_data = _entry_payload(model_blob, res13)
+ for batch_idx in range(batch_count):
+ b_off = batch_idx * 20
+ (
+ _batch_flags,
+ _mat_idx,
+ _unk4,
+ _unk6,
+ idx_count,
+ idx_start,
+ _unk14,
+ base_vertex,
+ ) = struct.unpack_from("<HHHHHIHI", res13_data, b_off)
+ end = idx_start + idx_count
+ if index_count > 0 and end > index_count:
+ _add_issue(
+ issues,
+ "error",
+ "res13",
+ archive,
+ model_name,
+ f"batch {batch_idx}: index range [{idx_start}, {end}) outside Res6 count={index_count}",
+ )
+ continue
+ if idx_count % 3 != 0:
+ _add_issue(
+ issues,
+ "warning",
+ "res13",
+ archive,
+ model_name,
+ f"batch {batch_idx}: indexCount={idx_count} is not divisible by 3",
+ )
+ if vertex_count > 0 and index_count > 0 and idx_count > 0:
+ raw_slice = indices[idx_start:end]
+ max_raw = max(raw_slice)
+ if base_vertex + max_raw >= vertex_count:
+ _add_issue(
+ issues,
+ "error",
+ "res13",
+ archive,
+ model_name,
+ f"batch {batch_idx}: baseVertex+maxIndex={base_vertex + max_raw} >= vertex_count={vertex_count}",
+ )
+
+ if slot_count > 0:
+ res2_data = _entry_payload(model_blob, res2)
+ for slot_idx in range(slot_count):
+ s_off = 0x8C + slot_idx * 68
+ tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", res2_data, s_off)
+ if tri_desc_count > 0 and tri_start + tri_count > tri_desc_count:
+ _add_issue(
+ issues,
+ "error",
+ "res2-slot",
+ archive,
+ model_name,
+ f"slot {slot_idx}: tri range [{tri_start}, {tri_start + tri_count}) outside Res7 count={tri_desc_count}",
+ )
+ if batch_count > 0 and batch_start + slot_batch_count > batch_count:
+ _add_issue(
+ issues,
+ "error",
+ "res2-slot",
+ archive,
+ model_name,
+ f"slot {slot_idx}: batch range [{batch_start}, {batch_start + slot_batch_count}) outside Res13 count={batch_count}",
+ )
+ # Slot bounds are 10 float values.
+ for f_idx in range(10):
+ value = struct.unpack_from("<f", res2_data, s_off + 8 + f_idx * 4)[0]
+ if not math.isfinite(value):
+ _add_issue(
+ issues,
+ "error",
+ "res2-slot",
+ archive,
+ model_name,
+ f"slot {slot_idx}: non-finite bound float at field {f_idx}",
+ )
+ break
+
+ if tri_desc_count > 0:
+ res7_data = _entry_payload(model_blob, res7)
+ for tri_idx in range(tri_desc_count):
+ t_off = tri_idx * 16
+ _flags, l0, l1, l2 = struct.unpack_from("<4H", res7_data, t_off)
+ for link in (l0, l1, l2):
+ if link != 0xFFFF and link >= tri_desc_count:
+ _add_issue(
+ issues,
+ "error",
+ "res7",
+ archive,
+ model_name,
+ f"tri {tri_idx}: link {link} outside tri_desc_count={tri_desc_count}",
+ )
+ _ = struct.unpack_from("<H", res7_data, t_off + 14)[0]
+
+ # Node-level constraints for slot matrix / animation mapping.
+ if res1_stride == 38:
+ res1_data = _entry_payload(model_blob, res1)
+ map_words: list[int] = []
+ if anim_map_count > 0 and res19:
+ res19_data = _entry_payload(model_blob, res19)
+ map_words = list(struct.unpack_from(f"<{anim_map_count}H", res19_data, 0))
+ frame_count = int(res19["attr2"]) if res19 else 0
+
+ for node_idx in range(node_count):
+ n_off = node_idx * 38
+ hdr2 = struct.unpack_from("<H", res1_data, n_off + 4)[0]
+ hdr3 = struct.unpack_from("<H", res1_data, n_off + 6)[0]
+ # Slot matrix: 15 uint16 at +8.
+ for w_idx in range(15):
+ slot_idx = struct.unpack_from("<H", res1_data, n_off + 8 + w_idx * 2)[0]
+ if slot_idx != 0xFFFF and slot_count > 0 and slot_idx >= slot_count:
+ _add_issue(
+ issues,
+ "error",
+ "res1-slot",
+ archive,
+ model_name,
+ f"node {node_idx}: slotIndex[{w_idx}]={slot_idx} outside slot_count={slot_count}",
+ )
+
+ if anim_key_count > 0 and hdr3 != 0xFFFF and hdr3 >= anim_key_count:
+ _add_issue(
+ issues,
+ "error",
+ "res1-anim",
+ archive,
+ model_name,
+ f"node {node_idx}: fallbackKeyIndex={hdr3} outside Res8 count={anim_key_count}",
+ )
+ if map_words and hdr2 != 0xFFFF and frame_count > 0:
+ end = hdr2 + frame_count
+ if end > len(map_words):
+ _add_issue(
+ issues,
+ "error",
+ "res19-map",
+ archive,
+ model_name,
+ f"node {node_idx}: map range [{hdr2}, {end}) outside Res19 count={len(map_words)}",
+ )
+
+ counters["models_ok"] += 1
+
+
+def _validate_texm_payload(
+ payload: bytes,
+ archive: Path,
+ entry_name: str,
+ issues: list[dict[str, Any]],
+ counters: Counter[str],
+) -> None:
+ counters["texm_total"] += 1
+
+ if len(payload) < 32:
+ _add_issue(
+ issues,
+ "error",
+ "texm",
+ archive,
+ entry_name,
+ f"payload too small: {len(payload)}",
+ )
+ return
+
+ magic, width, height, mip_count, flags4, flags5, unk6, fmt = struct.unpack_from("<8I", payload, 0)
+ if magic != TYPE_TEXM:
+ _add_issue(issues, "error", "texm", archive, entry_name, f"magic=0x{magic:08X} != Texm")
+ return
+ if width == 0 or height == 0:
+ _add_issue(issues, "error", "texm", archive, entry_name, f"invalid size {width}x{height}")
+ return
+ if mip_count == 0:
+ _add_issue(issues, "error", "texm", archive, entry_name, "mipCount=0")
+ return
+ if fmt not in TEXM_KNOWN_FORMATS:
+ _add_issue(
+ issues,
+ "error",
+ "texm",
+ archive,
+ entry_name,
+ f"unknown format code {fmt}",
+ )
+ return
+ if flags4 not in (0, 32):
+ _add_issue(
+ issues,
+ "warning",
+ "texm",
+ archive,
+ entry_name,
+ f"flags4={flags4} (known values: 0 or 32)",
+ )
+ if flags5 not in (0, 0x04000000, 0x00800000):
+ _add_issue(
+ issues,
+ "warning",
+ "texm",
+ archive,
+ entry_name,
+ f"flags5=0x{flags5:08X} (known values: 0, 0x00800000, 0x04000000)",
+ )
+
+ bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4)
+ pix_sum = 0
+ w = width
+ h = height
+ for _ in range(mip_count):
+ pix_sum += w * h
+ w = max(1, w >> 1)
+ h = max(1, h >> 1)
+ size_core = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum
+ if size_core > len(payload):
+ _add_issue(
+ issues,
+ "error",
+ "texm",
+ archive,
+ entry_name,
+ f"sizeCore={size_core} exceeds payload size={len(payload)}",
+ )
+ return
+
+ tail = len(payload) - size_core
+ if tail > 0:
+ off = size_core
+ if tail < 8:
+ _add_issue(
+ issues,
+ "error",
+ "texm",
+ archive,
+ entry_name,
+ f"tail too short for Page chunk: tail={tail}",
+ )
+ return
+ if payload[off : off + 4] != MAGIC_PAGE:
+ _add_issue(
+ issues,
+ "error",
+ "texm",
+ archive,
+ entry_name,
+ f"tail is present but no Page magic at offset {off}",
+ )
+ return
+ rect_count = struct.unpack_from("<I", payload, off + 4)[0]
+ need = 8 + rect_count * 8
+ if need > tail:
+ _add_issue(
+ issues,
+ "error",
+ "texm",
+ archive,
+ entry_name,
+ f"Page chunk truncated: need={need}, tail={tail}",
+ )
+ return
+ if need != tail:
+ _add_issue(
+ issues,
+ "error",
+ "texm",
+ archive,
+ entry_name,
+ f"extra bytes after Page chunk: tail={tail}, pageSize={need}",
+ )
+ return
+
+ _ = unk6 # carried as raw field in spec, semantics intentionally unknown.
+ counters["texm_ok"] += 1
+
+
+def _validate_fxid_payload(
+ payload: bytes,
+ archive: Path,
+ entry_name: str,
+ issues: list[dict[str, Any]],
+ counters: Counter[str],
+) -> None:
+ counters["fxid_total"] += 1
+
+ if len(payload) < 60:
+ _add_issue(
+ issues,
+ "error",
+ "fxid",
+ archive,
+ entry_name,
+ f"payload too small: {len(payload)}",
+ )
+ return
+
+ cmd_count = struct.unpack_from("<I", payload, 0)[0]
+ ptr = 0x3C
+ for idx in range(cmd_count):
+ if ptr + 4 > len(payload):
+ _add_issue(
+ issues,
+ "error",
+ "fxid",
+ archive,
+ entry_name,
+ f"command {idx}: missing header at offset={ptr}",
+ )
+ return
+ word = struct.unpack_from("<I", payload, ptr)[0]
+ opcode = word & 0xFF
+ if opcode not in FX_CMD_SIZE:
+ _add_issue(
+ issues,
+ "error",
+ "fxid",
+ archive,
+ entry_name,
+ f"command {idx}: unknown opcode={opcode} at offset={ptr}",
+ )
+ return
+ size = FX_CMD_SIZE[opcode]
+ if ptr + size > len(payload):
+ _add_issue(
+ issues,
+ "error",
+ "fxid",
+ archive,
+ entry_name,
+ f"command {idx}: truncated, need end={ptr + size}, payload={len(payload)}",
+ )
+ return
+ ptr += size
+
+ if ptr != len(payload):
+ _add_issue(
+ issues,
+ "error",
+ "fxid",
+ archive,
+ entry_name,
+ f"tail bytes after command stream: parsed_end={ptr}, payload={len(payload)}",
+ )
+ return
+
+ counters["fxid_ok"] += 1
+
+
+def _scan_nres_files(root: Path) -> list[Path]:
+ rows = arv.scan_archives(root)
+ out: list[Path] = []
+ for item in rows:
+ if item["type"] != "nres":
+ continue
+ out.append(root / item["relative_path"])
+ return out
+
+
+def run_validation(input_root: Path) -> dict[str, Any]:
+ archives = _scan_nres_files(input_root)
+ issues: list[dict[str, Any]] = []
+ counters: Counter[str] = Counter()
+
+ for archive_path in archives:
+ counters["archives_total"] += 1
+ data = archive_path.read_bytes()
+ try:
+ parsed = arv.parse_nres(data, source=str(archive_path))
+ except Exception as exc: # pylint: disable=broad-except
+ _add_issue(issues, "error", "archive", archive_path, None, f"cannot parse NRes: {exc}")
+ continue
+
+ for item in parsed.get("issues", []):
+ _add_issue(issues, "warning", "archive", archive_path, None, str(item))
+
+ for entry in parsed["entries"]:
+ name = str(entry["name"])
+ payload = _entry_payload(data, entry)
+ type_id = int(entry["type_id"])
+
+ if name.lower().endswith(".msh"):
+ _validate_model_payload(payload, archive_path, name, issues, counters)
+
+ if type_id == TYPE_TEXM:
+ _validate_texm_payload(payload, archive_path, name, issues, counters)
+
+ if type_id == TYPE_FXID:
+ _validate_fxid_payload(payload, archive_path, name, issues, counters)
+
+ errors = sum(1 for row in issues if row["severity"] == "error")
+ warnings = sum(1 for row in issues if row["severity"] == "warning")
+
+ return {
+ "input_root": str(input_root),
+ "summary": {
+ "archives_total": counters["archives_total"],
+ "models_total": counters["models_total"],
+ "models_ok": counters["models_ok"],
+ "texm_total": counters["texm_total"],
+ "texm_ok": counters["texm_ok"],
+ "fxid_total": counters["fxid_total"],
+ "fxid_ok": counters["fxid_ok"],
+ "errors": errors,
+ "warnings": warnings,
+ "issues_total": len(issues),
+ },
+ "issues": issues,
+ }
+
+
+def cmd_scan(args: argparse.Namespace) -> int:
+ root = Path(args.input).resolve()
+ report = run_validation(root)
+ summary = report["summary"]
+ print(f"Input root : {root}")
+ print(f"NRes archives : {summary['archives_total']}")
+ print(f"MSH models : {summary['models_total']}")
+ print(f"Texm textures : {summary['texm_total']}")
+ print(f"FXID effects : {summary['fxid_total']}")
+ return 0
+
+
+def cmd_validate(args: argparse.Namespace) -> int:
+ root = Path(args.input).resolve()
+ report = run_validation(root)
+ summary = report["summary"]
+
+ if args.report:
+ arv.dump_json(Path(args.report).resolve(), report)
+
+ print(f"Input root : {root}")
+ print(f"NRes archives : {summary['archives_total']}")
+ print(f"MSH models : {summary['models_ok']}/{summary['models_total']} valid")
+ print(f"Texm textures : {summary['texm_ok']}/{summary['texm_total']} valid")
+ print(f"FXID effects : {summary['fxid_ok']}/{summary['fxid_total']} valid")
+ print(f"Issues : {summary['issues_total']} (errors={summary['errors']}, warnings={summary['warnings']})")
+
+ if report["issues"]:
+ limit = max(1, int(args.print_limit))
+ print("\nSample issues:")
+ for item in report["issues"][:limit]:
+ where = item["archive"]
+ if item["entry"]:
+ where = f"{where}::{item['entry']}"
+ print(f"- [{item['severity']}] [{item['category']}] {where}: {item['message']}")
+ if len(report["issues"]) > limit:
+ print(f"... and {len(report['issues']) - limit} more issue(s)")
+
+ if summary["errors"] > 0:
+ return 1
+ if args.fail_on_warnings and summary["warnings"] > 0:
+ return 1
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Validate docs/specs/msh.md assumptions on real archives."
+ )
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ scan = sub.add_parser("scan", help="Quick scan and counts (models/textures/effects).")
+ scan.add_argument("--input", required=True, help="Root directory with game/test archives.")
+ scan.set_defaults(func=cmd_scan)
+
+ validate = sub.add_parser("validate", help="Run full spec validation.")
+ validate.add_argument("--input", required=True, help="Root directory with game/test archives.")
+ validate.add_argument("--report", help="Optional JSON report output path.")
+ validate.add_argument(
+ "--print-limit",
+ type=int,
+ default=50,
+ help="How many issues to print to stdout (default: 50).",
+ )
+ validate.add_argument(
+ "--fail-on-warnings",
+ action="store_true",
+ help="Return non-zero if warnings are present.",
+ )
+ validate.set_defaults(func=cmd_validate)
+
+ return parser
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+ return int(args.func(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/msh_export_obj.py b/tools/msh_export_obj.py
new file mode 100644
index 0000000..75a9602
--- /dev/null
+++ b/tools/msh_export_obj.py
@@ -0,0 +1,357 @@
+#!/usr/bin/env python3
+"""
+Export NGI MSH geometry to Wavefront OBJ.
+
+The exporter is intended for inspection/debugging and uses the same
+batch/slot selection logic as msh_preview_renderer.py.
+"""
+
+from __future__ import annotations
+
+import argparse
+import math
+import struct
+from pathlib import Path
+from typing import Any
+
+import archive_roundtrip_validator as arv
+
+MAGIC_NRES = b"NRes"
+
+
+def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes:
+ start = int(entry["data_offset"])
+ end = start + int(entry["size"])
+ return blob[start:end]
+
+
+def _parse_nres(blob: bytes, source: str) -> dict[str, Any]:
+ if blob[:4] != MAGIC_NRES:
+ raise RuntimeError(f"{source}: not an NRes payload")
+ return arv.parse_nres(blob, source=source)
+
+
+def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
+ out: dict[int, list[dict[str, Any]]] = {}
+ for row in entries:
+ out.setdefault(int(row["type_id"]), []).append(row)
+ return out
+
+
+def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]:
+ rows = by_type.get(type_id, [])
+ if not rows:
+ raise RuntimeError(f"missing resource type {type_id} ({label})")
+ return rows[0]
+
+
+def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]:
+ root_blob = archive_path.read_bytes()
+ parsed = _parse_nres(root_blob, str(archive_path))
+
+ msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
+ if msh_entries:
+ chosen: dict[str, Any] | None = None
+ if model_name:
+ model_l = model_name.lower()
+ for row in msh_entries:
+ name_l = str(row["name"]).lower()
+ if name_l == model_l:
+ chosen = row
+ break
+ if chosen is None:
+ for row in msh_entries:
+ if str(row["name"]).lower().startswith(model_l):
+ chosen = row
+ break
+ else:
+ chosen = msh_entries[0]
+
+ if chosen is None:
+ names = ", ".join(str(row["name"]) for row in msh_entries[:12])
+ raise RuntimeError(
+ f"model '{model_name}' not found in {archive_path}. Available: {names}"
+ )
+ return _entry_payload(root_blob, chosen), str(chosen["name"])
+
+ by_type = _by_type(parsed["entries"])
+ if all(k in by_type for k in (1, 2, 3, 6, 13)):
+ return root_blob, archive_path.name
+
+ raise RuntimeError(
+ f"{archive_path} does not contain .msh entries and does not look like a direct model payload"
+ )
+
+
+def _extract_geometry(
+ model_blob: bytes,
+ *,
+ lod: int,
+ group: int,
+ max_faces: int,
+ all_batches: bool,
+) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]:
+ parsed = _parse_nres(model_blob, "<model>")
+ by_type = _by_type(parsed["entries"])
+
+ res1 = _get_single(by_type, 1, "Res1")
+ res2 = _get_single(by_type, 2, "Res2")
+ res3 = _get_single(by_type, 3, "Res3")
+ res6 = _get_single(by_type, 6, "Res6")
+ res13 = _get_single(by_type, 13, "Res13")
+
+ pos_blob = _entry_payload(model_blob, res3)
+ if len(pos_blob) % 12 != 0:
+ raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}")
+ vertex_count = len(pos_blob) // 12
+ positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)]
+
+ idx_blob = _entry_payload(model_blob, res6)
+ if len(idx_blob) % 2 != 0:
+ raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}")
+ index_count = len(idx_blob) // 2
+ indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0))
+
+ batch_blob = _entry_payload(model_blob, res13)
+ if len(batch_blob) % 20 != 0:
+ raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}")
+ batch_count = len(batch_blob) // 20
+ batches: list[tuple[int, int, int, int]] = []
+ for i in range(batch_count):
+ off = i * 20
+ idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0]
+ idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0]
+ base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0]
+ batches.append((idx_count, idx_start, base_vertex, i))
+
+ res2_blob = _entry_payload(model_blob, res2)
+ if len(res2_blob) < 0x8C:
+ raise RuntimeError("Res2 is too small (< 0x8C)")
+ slot_blob = res2_blob[0x8C:]
+ if len(slot_blob) % 68 != 0:
+ raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}")
+ slot_count = len(slot_blob) // 68
+ slots: list[tuple[int, int, int, int]] = []
+ for i in range(slot_count):
+ off = i * 68
+ tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off)
+ slots.append((tri_start, tri_count, batch_start, slot_batch_count))
+
+ res1_blob = _entry_payload(model_blob, res1)
+ node_stride = int(res1["attr3"])
+ node_count = int(res1["attr1"])
+ node_slot_indices: list[int] = []
+ if not all_batches and node_stride >= 38 and len(res1_blob) >= node_count * node_stride:
+ if lod < 0 or lod > 2:
+ raise RuntimeError(f"lod must be 0..2 (got {lod})")
+ if group < 0 or group > 4:
+ raise RuntimeError(f"group must be 0..4 (got {group})")
+ matrix_index = lod * 5 + group
+ for n in range(node_count):
+ off = n * node_stride + 8 + matrix_index * 2
+ slot_idx = struct.unpack_from("<H", res1_blob, off)[0]
+ if slot_idx == 0xFFFF:
+ continue
+ if slot_idx >= slot_count:
+ continue
+ node_slot_indices.append(slot_idx)
+
+ faces: list[tuple[int, int, int]] = []
+ used_batches = 0
+ used_slots = 0
+
+ def append_batch(batch_idx: int) -> None:
+ nonlocal used_batches
+ if batch_idx < 0 or batch_idx >= len(batches):
+ return
+ idx_count, idx_start, base_vertex, _ = batches[batch_idx]
+ if idx_count < 3:
+ return
+ end = idx_start + idx_count
+ if end > len(indices):
+ return
+ used_batches += 1
+ tri_count = idx_count // 3
+ for t in range(tri_count):
+ i0 = indices[idx_start + t * 3 + 0] + base_vertex
+ i1 = indices[idx_start + t * 3 + 1] + base_vertex
+ i2 = indices[idx_start + t * 3 + 2] + base_vertex
+ if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count:
+ continue
+ faces.append((i0, i1, i2))
+ if len(faces) >= max_faces:
+ return
+
+ if node_slot_indices:
+ for slot_idx in node_slot_indices:
+ if len(faces) >= max_faces:
+ break
+ _tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx]
+ used_slots += 1
+ for bi in range(batch_start, batch_start + slot_batch_count):
+ append_batch(bi)
+ if len(faces) >= max_faces:
+ break
+ else:
+ for bi in range(batch_count):
+ append_batch(bi)
+ if len(faces) >= max_faces:
+ break
+
+ if not faces:
+ raise RuntimeError("no faces selected for export")
+
+ meta = {
+ "vertex_count": vertex_count,
+ "index_count": index_count,
+ "batch_count": batch_count,
+ "slot_count": slot_count,
+ "node_count": node_count,
+ "used_slots": used_slots,
+ "used_batches": used_batches,
+ "face_count": len(faces),
+ }
+ return positions, faces, meta
+
+
+def _compute_vertex_normals(
+ positions: list[tuple[float, float, float]],
+ faces: list[tuple[int, int, int]],
+) -> list[tuple[float, float, float]]:
+ acc = [[0.0, 0.0, 0.0] for _ in positions]
+ for i0, i1, i2 in faces:
+ p0 = positions[i0]
+ p1 = positions[i1]
+ p2 = positions[i2]
+ ux = p1[0] - p0[0]
+ uy = p1[1] - p0[1]
+ uz = p1[2] - p0[2]
+ vx = p2[0] - p0[0]
+ vy = p2[1] - p0[1]
+ vz = p2[2] - p0[2]
+ nx = uy * vz - uz * vy
+ ny = uz * vx - ux * vz
+ nz = ux * vy - uy * vx
+ acc[i0][0] += nx
+ acc[i0][1] += ny
+ acc[i0][2] += nz
+ acc[i1][0] += nx
+ acc[i1][1] += ny
+ acc[i1][2] += nz
+ acc[i2][0] += nx
+ acc[i2][1] += ny
+ acc[i2][2] += nz
+
+ normals: list[tuple[float, float, float]] = []
+ for nx, ny, nz in acc:
+ ln = math.sqrt(nx * nx + ny * ny + nz * nz)
+ if ln <= 1e-12:
+ normals.append((0.0, 1.0, 0.0))
+ else:
+ normals.append((nx / ln, ny / ln, nz / ln))
+ return normals
+
+
+def _write_obj(
+ output_path: Path,
+ object_name: str,
+ positions: list[tuple[float, float, float]],
+ faces: list[tuple[int, int, int]],
+) -> None:
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ normals = _compute_vertex_normals(positions, faces)
+
+ with output_path.open("w", encoding="utf-8", newline="\n") as out:
+ out.write("# Exported by msh_export_obj.py\n")
+ out.write(f"o {object_name}\n")
+ for x, y, z in positions:
+ out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
+ for nx, ny, nz in normals:
+ out.write(f"vn {nx:.9g} {ny:.9g} {nz:.9g}\n")
+ for i0, i1, i2 in faces:
+ a = i0 + 1
+ b = i1 + 1
+ c = i2 + 1
+ out.write(f"f {a}//{a} {b}//{b} {c}//{c}\n")
+
+
+def cmd_list_models(args: argparse.Namespace) -> int:
+ archive_path = Path(args.archive).resolve()
+ blob = archive_path.read_bytes()
+ parsed = _parse_nres(blob, str(archive_path))
+ rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
+ print(f"Archive: {archive_path}")
+ print(f"MSH entries: {len(rows)}")
+ for row in rows:
+ print(f"- {row['name']}")
+ return 0
+
+
+def cmd_export(args: argparse.Namespace) -> int:
+ archive_path = Path(args.archive).resolve()
+ output_path = Path(args.output).resolve()
+
+ model_blob, model_label = _pick_model_payload(archive_path, args.model)
+ positions, faces, meta = _extract_geometry(
+ model_blob,
+ lod=int(args.lod),
+ group=int(args.group),
+ max_faces=int(args.max_faces),
+ all_batches=bool(args.all_batches),
+ )
+ obj_name = Path(model_label).stem or "msh_model"
+ _write_obj(output_path, obj_name, positions, faces)
+
+ print(f"Exported model : {model_label}")
+ print(f"Output OBJ : {output_path}")
+ print(f"Object name : {obj_name}")
+ print(
+ "Geometry : "
+ f"vertices={meta['vertex_count']}, faces={meta['face_count']}, "
+ f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}"
+ )
+ print(
+ "Mode : "
+ f"lod={args.lod}, group={args.group}, all_batches={bool(args.all_batches)}"
+ )
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Export NGI MSH geometry to Wavefront OBJ."
+ )
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.")
+ list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).")
+ list_models.set_defaults(func=cmd_list_models)
+
+ export = sub.add_parser("export", help="Export one model to OBJ.")
+ export.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.")
+ export.add_argument(
+ "--model",
+ help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.",
+ )
+ export.add_argument("--output", required=True, help="Output .obj path.")
+ export.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).")
+ export.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).")
+ export.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).")
+ export.add_argument(
+ "--all-batches",
+ action="store_true",
+ help="Ignore slot matrix selection and export all batches.",
+ )
+ export.set_defaults(func=cmd_export)
+
+ return parser
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+ return int(args.func(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/msh_preview_renderer.py b/tools/msh_preview_renderer.py
new file mode 100644
index 0000000..53b4e63
--- /dev/null
+++ b/tools/msh_preview_renderer.py
@@ -0,0 +1,481 @@
+#!/usr/bin/env python3
+"""
+Primitive software renderer for NGI MSH models.
+
+Output format: binary PPM (P6), no external dependencies.
+"""
+
+from __future__ import annotations
+
+import argparse
+import math
+import struct
+from pathlib import Path
+from typing import Any
+
+import archive_roundtrip_validator as arv
+
+MAGIC_NRES = b"NRes"
+
+
+def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes:
+ start = int(entry["data_offset"])
+ end = start + int(entry["size"])
+ return blob[start:end]
+
+
+def _parse_nres(blob: bytes, source: str) -> dict[str, Any]:
+ if blob[:4] != MAGIC_NRES:
+ raise RuntimeError(f"{source}: not an NRes payload")
+ return arv.parse_nres(blob, source=source)
+
+
+def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
+ out: dict[int, list[dict[str, Any]]] = {}
+ for row in entries:
+ out.setdefault(int(row["type_id"]), []).append(row)
+ return out
+
+
+def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]:
+ root_blob = archive_path.read_bytes()
+ parsed = _parse_nres(root_blob, str(archive_path))
+
+ msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
+ if msh_entries:
+ chosen: dict[str, Any] | None = None
+ if model_name:
+ model_l = model_name.lower()
+ for row in msh_entries:
+ name_l = str(row["name"]).lower()
+ if name_l == model_l:
+ chosen = row
+ break
+ if chosen is None:
+ for row in msh_entries:
+ if str(row["name"]).lower().startswith(model_l):
+ chosen = row
+ break
+ else:
+ chosen = msh_entries[0]
+
+ if chosen is None:
+ names = ", ".join(str(row["name"]) for row in msh_entries[:12])
+ raise RuntimeError(
+ f"model '{model_name}' not found in {archive_path}. Available: {names}"
+ )
+ return _entry_payload(root_blob, chosen), str(chosen["name"])
+
+ # Fallback: treat file itself as a model NRes payload.
+ by_type = _by_type(parsed["entries"])
+ if all(k in by_type for k in (1, 2, 3, 6, 13)):
+ return root_blob, archive_path.name
+
+ raise RuntimeError(
+ f"{archive_path} does not contain .msh entries and does not look like a direct model payload"
+ )
+
+
+def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]:
+ rows = by_type.get(type_id, [])
+ if not rows:
+ raise RuntimeError(f"missing resource type {type_id} ({label})")
+ return rows[0]
+
+
+def _extract_geometry(
+ model_blob: bytes,
+ *,
+ lod: int,
+ group: int,
+ max_faces: int,
+) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]:
+ parsed = _parse_nres(model_blob, "<model>")
+ by_type = _by_type(parsed["entries"])
+
+ res1 = _get_single(by_type, 1, "Res1")
+ res2 = _get_single(by_type, 2, "Res2")
+ res3 = _get_single(by_type, 3, "Res3")
+ res6 = _get_single(by_type, 6, "Res6")
+ res13 = _get_single(by_type, 13, "Res13")
+
+ # Positions
+ pos_blob = _entry_payload(model_blob, res3)
+ if len(pos_blob) % 12 != 0:
+ raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}")
+ vertex_count = len(pos_blob) // 12
+ positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)]
+
+ # Indices
+ idx_blob = _entry_payload(model_blob, res6)
+ if len(idx_blob) % 2 != 0:
+ raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}")
+ index_count = len(idx_blob) // 2
+ indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0))
+
+ # Batches
+ batch_blob = _entry_payload(model_blob, res13)
+ if len(batch_blob) % 20 != 0:
+ raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}")
+ batch_count = len(batch_blob) // 20
+ batches: list[tuple[int, int, int, int]] = []
+ for i in range(batch_count):
+ off = i * 20
+ # Keep only fields used by renderer:
+ # indexCount, indexStart, baseVertex
+ idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0]
+ idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0]
+ base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0]
+ batches.append((idx_count, idx_start, base_vertex, i))
+
+ # Slots
+ res2_blob = _entry_payload(model_blob, res2)
+ if len(res2_blob) < 0x8C:
+ raise RuntimeError("Res2 is too small (< 0x8C)")
+ slot_blob = res2_blob[0x8C:]
+ if len(slot_blob) % 68 != 0:
+ raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}")
+ slot_count = len(slot_blob) // 68
+ slots: list[tuple[int, int, int, int]] = []
+ for i in range(slot_count):
+ off = i * 68
+ tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off)
+ slots.append((tri_start, tri_count, batch_start, slot_batch_count))
+
+ # Nodes / slot matrix
+ res1_blob = _entry_payload(model_blob, res1)
+ node_stride = int(res1["attr3"])
+ node_count = int(res1["attr1"])
+ node_slot_indices: list[int] = []
+ if node_stride >= 38 and len(res1_blob) >= node_count * node_stride:
+ if lod < 0 or lod > 2:
+ raise RuntimeError(f"lod must be 0..2 (got {lod})")
+ if group < 0 or group > 4:
+ raise RuntimeError(f"group must be 0..4 (got {group})")
+ matrix_index = lod * 5 + group
+ for n in range(node_count):
+ off = n * node_stride + 8 + matrix_index * 2
+ slot_idx = struct.unpack_from("<H", res1_blob, off)[0]
+ if slot_idx == 0xFFFF:
+ continue
+ if slot_idx >= slot_count:
+ continue
+ node_slot_indices.append(slot_idx)
+
+ # Build triangle list.
+ faces: list[tuple[int, int, int]] = []
+ used_batches = 0
+ used_slots = 0
+
+ def append_batch(batch_idx: int) -> None:
+ nonlocal used_batches
+ if batch_idx < 0 or batch_idx >= len(batches):
+ return
+ idx_count, idx_start, base_vertex, _ = batches[batch_idx]
+ if idx_count < 3:
+ return
+ end = idx_start + idx_count
+ if end > len(indices):
+ return
+ used_batches += 1
+ tri_count = idx_count // 3
+ for t in range(tri_count):
+ i0 = indices[idx_start + t * 3 + 0] + base_vertex
+ i1 = indices[idx_start + t * 3 + 1] + base_vertex
+ i2 = indices[idx_start + t * 3 + 2] + base_vertex
+ if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count:
+ continue
+ faces.append((i0, i1, i2))
+ if len(faces) >= max_faces:
+ return
+
+ if node_slot_indices:
+ for slot_idx in node_slot_indices:
+ if len(faces) >= max_faces:
+ break
+ _tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx]
+ used_slots += 1
+ for bi in range(batch_start, batch_start + slot_batch_count):
+ append_batch(bi)
+ if len(faces) >= max_faces:
+ break
+ else:
+ # Fallback if slot matrix is unavailable: draw all batches.
+ for bi in range(batch_count):
+ append_batch(bi)
+ if len(faces) >= max_faces:
+ break
+
+ meta = {
+ "vertex_count": vertex_count,
+ "index_count": index_count,
+ "batch_count": batch_count,
+ "slot_count": slot_count,
+ "node_count": node_count,
+ "used_slots": used_slots,
+ "used_batches": used_batches,
+ "face_count": len(faces),
+ }
+ if not faces:
+ raise RuntimeError("no faces selected for rendering")
+ return positions, faces, meta
+
+
+def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("wb") as handle:
+ handle.write(f"P6\n{width} {height}\n255\n".encode("ascii"))
+ handle.write(rgb)
+
+
+def _render_software(
+ positions: list[tuple[float, float, float]],
+ faces: list[tuple[int, int, int]],
+ *,
+ width: int,
+ height: int,
+ yaw_deg: float,
+ pitch_deg: float,
+ wireframe: bool,
+) -> bytearray:
+ xs = [p[0] for p in positions]
+ ys = [p[1] for p in positions]
+ zs = [p[2] for p in positions]
+ cx = (min(xs) + max(xs)) * 0.5
+ cy = (min(ys) + max(ys)) * 0.5
+ cz = (min(zs) + max(zs)) * 0.5
+ span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs))
+ radius = max(span * 0.5, 1e-3)
+
+ yaw = math.radians(yaw_deg)
+ pitch = math.radians(pitch_deg)
+ cyaw = math.cos(yaw)
+ syaw = math.sin(yaw)
+ cpitch = math.cos(pitch)
+ spitch = math.sin(pitch)
+
+ camera_dist = radius * 3.2
+ scale = min(width, height) * 0.95
+
+ # Transform all vertices once.
+ vx: list[float] = []
+ vy: list[float] = []
+ vz: list[float] = []
+ sx: list[float] = []
+ sy: list[float] = []
+ for x, y, z in positions:
+ x0 = x - cx
+ y0 = y - cy
+ z0 = z - cz
+ x1 = cyaw * x0 + syaw * z0
+ z1 = -syaw * x0 + cyaw * z0
+ y2 = cpitch * y0 - spitch * z1
+ z2 = spitch * y0 + cpitch * z1 + camera_dist
+ if z2 < 1e-3:
+ z2 = 1e-3
+ vx.append(x1)
+ vy.append(y2)
+ vz.append(z2)
+ sx.append(width * 0.5 + (x1 / z2) * scale)
+ sy.append(height * 0.5 - (y2 / z2) * scale)
+
+ rgb = bytearray([16, 18, 24] * (width * height))
+ zbuf = [float("inf")] * (width * height)
+ light_dir = (0.35, 0.45, 1.0)
+ l_len = math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2)
+ light = (light_dir[0] / l_len, light_dir[1] / l_len, light_dir[2] / l_len)
+
+ def edge(ax: float, ay: float, bx: float, by: float, px: float, py: float) -> float:
+ return (px - ax) * (by - ay) - (py - ay) * (bx - ax)
+
+ for i0, i1, i2 in faces:
+ x0 = sx[i0]
+ y0 = sy[i0]
+ x1 = sx[i1]
+ y1 = sy[i1]
+ x2 = sx[i2]
+ y2 = sy[i2]
+ area = edge(x0, y0, x1, y1, x2, y2)
+ if area == 0.0:
+ continue
+
+ # Shading from camera-space normal.
+ ux = vx[i1] - vx[i0]
+ uy = vy[i1] - vy[i0]
+ uz = vz[i1] - vz[i0]
+ wx = vx[i2] - vx[i0]
+ wy = vy[i2] - vy[i0]
+ wz = vz[i2] - vz[i0]
+ nx = uy * wz - uz * wy
+ ny = uz * wx - ux * wz
+ nz = ux * wy - uy * wx
+ n_len = math.sqrt(nx * nx + ny * ny + nz * nz)
+ if n_len > 0.0:
+ nx /= n_len
+ ny /= n_len
+ nz /= n_len
+ intensity = nx * light[0] + ny * light[1] + nz * light[2]
+ if intensity < 0.0:
+ intensity = 0.0
+ shade = int(45 + 200 * intensity)
+ color = (shade, shade, min(255, shade + 18))
+
+ minx = int(max(0, math.floor(min(x0, x1, x2))))
+ maxx = int(min(width - 1, math.ceil(max(x0, x1, x2))))
+ miny = int(max(0, math.floor(min(y0, y1, y2))))
+ maxy = int(min(height - 1, math.ceil(max(y0, y1, y2))))
+ if minx > maxx or miny > maxy:
+ continue
+
+ z0 = vz[i0]
+ z1 = vz[i1]
+ z2 = vz[i2]
+
+ for py in range(miny, maxy + 1):
+ fy = py + 0.5
+ row = py * width
+ for px in range(minx, maxx + 1):
+ fx = px + 0.5
+ w0 = edge(x1, y1, x2, y2, fx, fy)
+ w1 = edge(x2, y2, x0, y0, fx, fy)
+ w2 = edge(x0, y0, x1, y1, fx, fy)
+ if area > 0:
+ if w0 < 0 or w1 < 0 or w2 < 0:
+ continue
+ else:
+ if w0 > 0 or w1 > 0 or w2 > 0:
+ continue
+ inv_area = 1.0 / area
+ bz0 = w0 * inv_area
+ bz1 = w1 * inv_area
+ bz2 = w2 * inv_area
+ depth = bz0 * z0 + bz1 * z1 + bz2 * z2
+ idx = row + px
+ if depth >= zbuf[idx]:
+ continue
+ zbuf[idx] = depth
+ p = idx * 3
+ rgb[p + 0] = color[0]
+ rgb[p + 1] = color[1]
+ rgb[p + 2] = color[2]
+
+ if wireframe:
+ def draw_line(xa: float, ya: float, xb: float, yb: float) -> None:
+ x0i = int(round(xa))
+ y0i = int(round(ya))
+ x1i = int(round(xb))
+ y1i = int(round(yb))
+ dx = abs(x1i - x0i)
+ sx_step = 1 if x0i < x1i else -1
+ dy = -abs(y1i - y0i)
+ sy_step = 1 if y0i < y1i else -1
+ err = dx + dy
+ x = x0i
+ y = y0i
+ while True:
+ if 0 <= x < width and 0 <= y < height:
+ p = (y * width + x) * 3
+ rgb[p + 0] = 240
+ rgb[p + 1] = 245
+ rgb[p + 2] = 255
+ if x == x1i and y == y1i:
+ break
+ e2 = 2 * err
+ if e2 >= dy:
+ err += dy
+ x += sx_step
+ if e2 <= dx:
+ err += dx
+ y += sy_step
+
+ for i0, i1, i2 in faces:
+ draw_line(sx[i0], sy[i0], sx[i1], sy[i1])
+ draw_line(sx[i1], sy[i1], sx[i2], sy[i2])
+ draw_line(sx[i2], sy[i2], sx[i0], sy[i0])
+
+ return rgb
+
+
+def cmd_list_models(args: argparse.Namespace) -> int:
+ archive_path = Path(args.archive).resolve()
+ blob = archive_path.read_bytes()
+ parsed = _parse_nres(blob, str(archive_path))
+ rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")]
+ print(f"Archive: {archive_path}")
+ print(f"MSH entries: {len(rows)}")
+ for row in rows:
+ print(f"- {row['name']}")
+ return 0
+
+
+def cmd_render(args: argparse.Namespace) -> int:
+ archive_path = Path(args.archive).resolve()
+ output_path = Path(args.output).resolve()
+
+ model_blob, model_label = _pick_model_payload(archive_path, args.model)
+ positions, faces, meta = _extract_geometry(
+ model_blob,
+ lod=int(args.lod),
+ group=int(args.group),
+ max_faces=int(args.max_faces),
+ )
+ rgb = _render_software(
+ positions,
+ faces,
+ width=int(args.width),
+ height=int(args.height),
+ yaw_deg=float(args.yaw),
+ pitch_deg=float(args.pitch),
+ wireframe=bool(args.wireframe),
+ )
+ _write_ppm(output_path, int(args.width), int(args.height), rgb)
+
+ print(f"Rendered model: {model_label}")
+ print(f"Output : {output_path}")
+ print(
+ "Geometry : "
+ f"vertices={meta['vertex_count']}, faces={meta['face_count']}, "
+ f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}"
+ )
+ print(f"Mode : lod={args.lod}, group={args.group}, wireframe={bool(args.wireframe)}")
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ description="Primitive NGI MSH renderer (software, dependency-free)."
+ )
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.")
+ list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).")
+ list_models.set_defaults(func=cmd_list_models)
+
+ render = sub.add_parser("render", help="Render one model to PPM image.")
+ render.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.")
+ render.add_argument(
+ "--model",
+ help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.",
+ )
+ render.add_argument("--output", required=True, help="Output .ppm file path.")
+ render.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).")
+ render.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).")
+ render.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).")
+ render.add_argument("--width", type=int, default=1280, help="Image width (default: 1280).")
+ render.add_argument("--height", type=int, default=720, help="Image height (default: 720).")
+ render.add_argument("--yaw", type=float, default=35.0, help="Yaw angle in degrees (default: 35).")
+ render.add_argument("--pitch", type=float, default=18.0, help="Pitch angle in degrees (default: 18).")
+ render.add_argument("--wireframe", action="store_true", help="Draw white wireframe overlay.")
+ render.set_defaults(func=cmd_render)
+
+ return parser
+
+
+def main() -> int:
+ parser = build_parser()
+ args = parser.parse_args()
+ return int(args.func(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/unpacker/Cargo.toml b/unpacker/Cargo.toml
deleted file mode 100644
index adb64ec..0000000
--- a/unpacker/Cargo.toml
+++ /dev/null
@@ -1,9 +0,0 @@
-[package]
-name = "unpacker"
-version = "0.1.1"
-edition = "2021"
-
-[dependencies]
-byteorder = "1.4.3"
-serde = { version = "1.0.160", features = ["derive"] }
-serde_json = "1.0.96"
diff --git a/unpacker/README.md b/unpacker/README.md
deleted file mode 100644
index 2c6be02..0000000
--- a/unpacker/README.md
+++ /dev/null
@@ -1,41 +0,0 @@
-# NRes Game Resource Unpacker
-
-At the moment, this is a demonstration of the NRes game resource unpacking algorithm in action.
-It unpacks 100% of the NRes game resources for the game "Parkan: Iron Strategy".
-The unpacked resources can be packed again using the [packer](../packer) utility and replace the original game files.
-
-__Attention!__
-This is a test version of the utility.
-It overwrites existing files without asking.
-
-## Building
-
-To build the tools, you need to run the following command in the root directory:
-
-```bash
-cargo build --release
-```
-
-## Running
-
-You can run the utility with the following command:
-
-```bash
-./target/release/unpacker /path/to/file.ex /path/to/output
-```
-
-- `/path/to/file.ex`: This is the file containing the game resources that will be unpacked.
-- `/path/to/output`: This is the directory where the unpacked files will be placed.
-
-## How it Works
-
-The structure describing the packed game resources is not fully understood yet.
-Therefore, the utility saves unpacked files in the format `file_name.file_index` because some files have the same name.
-
-Additionally, an `index.json` file is created, which is important for re-packing the files.
-This file lists all the fields that game resources have in their packed form.
-It is essential to preserve the file index for the game to function correctly, as the game engine looks for the necessary files by index.
-
-Files can be replaced and packed back using the [packer](../packer).
-The newly obtained game resource files are correctly processed by the game engine.
-For example, sounds and 3D models of warbots' weapons were successfully replaced.
diff --git a/unpacker/src/main.rs b/unpacker/src/main.rs
deleted file mode 100644
index 2a84688..0000000
--- a/unpacker/src/main.rs
+++ /dev/null
@@ -1,124 +0,0 @@
-use std::env;
-use std::fs::File;
-use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
-
-use byteorder::{ByteOrder, LittleEndian};
-use serde::{Deserialize, Serialize};
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct FileHeader {
- pub size: u32,
- pub total: u32,
- pub type1: u32,
- pub type2: u32,
-}
-
-#[derive(Serialize, Deserialize, Debug)]
-pub struct ListElement {
- pub extension: String,
- pub index: u32,
- pub name: String,
- #[serde(skip_serializing)]
- pub position: u32,
- #[serde(skip_serializing)]
- pub size: u32,
- pub unknown0: u32,
- pub unknown1: u32,
- pub unknown2: u32,
-}
-
-fn main() {
- let args: Vec<String> = env::args().collect();
-
- let input = &args[1];
- let output = &args[2];
-
- unpack(String::from(input), String::from(output));
-}
-
-fn unpack(input: String, output: String) {
- let file = File::open(input).unwrap();
- let metadata = file.metadata().unwrap();
-
- let mut reader = BufReader::new(file);
- let mut list: Vec<ListElement> = Vec::new();
-
- // Считываем заголовок файла
- let mut header_buffer = [0u8; 16];
- reader.seek(SeekFrom::Start(0)).unwrap();
- reader.read_exact(&mut header_buffer).unwrap();
-
- let file_header = FileHeader {
- size: LittleEndian::read_u32(&header_buffer[12..16]),
- total: LittleEndian::read_u32(&header_buffer[8..12]),
- type1: LittleEndian::read_u32(&header_buffer[0..4]),
- type2: LittleEndian::read_u32(&header_buffer[4..8]),
- };
-
- if file_header.type1 != 1936020046 || file_header.type2 != 256 {
- panic!("this isn't NRes file");
- }
-
- if metadata.len() != file_header.size as u64 {
- panic!("incorrect size")
- }
-
- // Считываем список файлов
- let list_files_start_position = file_header.size - (file_header.total * 64);
- let list_files_size = file_header.total * 64;
-
- let mut list_buffer = vec![0u8; list_files_size as usize];
- reader
- .seek(SeekFrom::Start(list_files_start_position as u64))
- .unwrap();
- reader.read_exact(&mut list_buffer).unwrap();
-
- if list_buffer.len() % 64 != 0 {
- panic!("invalid files list")
- }
-
- for i in 0..(list_buffer.len() / 64) {
- let from = i * 64;
- let to = (i * 64) + 64;
- let chunk: &[u8] = &list_buffer[from..to];
-
- let element_list = ListElement {
- extension: String::from_utf8_lossy(&chunk[0..4])
- .trim_matches(char::from(0))
- .to_string(),
- index: LittleEndian::read_u32(&chunk[60..64]),
- name: String::from_utf8_lossy(&chunk[20..56])
- .trim_matches(char::from(0))
- .to_string(),
- position: LittleEndian::read_u32(&chunk[56..60]),
- size: LittleEndian::read_u32(&chunk[12..16]),
- unknown0: LittleEndian::read_u32(&chunk[4..8]),
- unknown1: LittleEndian::read_u32(&chunk[8..12]),
- unknown2: LittleEndian::read_u32(&chunk[16..20]),
- };
-
- list.push(element_list)
- }
-
- // Распаковываем файлы в директорию
- for element in &list {
- let path = format!("{}/{}.{}", output, element.name, element.index);
- let mut file = File::create(path).unwrap();
-
- let mut file_buffer = vec![0u8; element.size as usize];
- reader
- .seek(SeekFrom::Start(element.position as u64))
- .unwrap();
- reader.read_exact(&mut file_buffer).unwrap();
-
- file.write_all(&file_buffer).unwrap();
- file_buffer.clear();
- }
-
- // Выгрузка списка файлов в JSON
- let path = format!("{}/{}", output, "index.json");
- let file = File::create(path).unwrap();
- let mut writer = BufWriter::new(file);
- serde_json::to_writer_pretty(&mut writer, &list).unwrap();
- writer.flush().unwrap();
-}