diff options
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 @@ -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", -] @@ -1,12 +1,6 @@ [workspace] -resolver = "2" -members = [ - "libnres", - "nres-cli", - "packer", - "texture-decoder", - "unpacker", -] +resolver = "3" +members = ["crates/*"] [profile.release] codegen-units = 1 @@ -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)) -} @@ -10,15 +10,37 @@ repo_name: valentineus/fparkan repo_url: https://github.com/valentineus/fparkan # Copyright -copyright: Copyright © 2023 — 2024 Valentin Popov +copyright: Copyright © 2023 — 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(); -} |
