77 Commits

Author SHA1 Message Date
renovate[bot] 16027e7124 fix(deps): update rust crate toml to v1
Test / Lint (push) Failing after 1m54s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 1m49s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
2026-06-25 00:05:23 +00:00
renovate[bot] 27af3806b3 fix(deps): update all digest updates
Test / Lint (pull_request) Failing after 1m53s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 1m50s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-24 00:02:21 +00:00
Valentin Popov 021b1c8dac feat(vulkan-smoke): run native swapchain acquire/present
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 2m24s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-24 01:53:39 +04:00
Valentin Popov 278567d6de revert: "ci: fix unreadable_directory_produces_error error"
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Successful in 2m35s
Test / Test (push) Failing after 2m53s
Test / Render parity (push) Has been skipped
This reverts commit 7eced77483.
2026-06-24 01:45:15 +04:00
Valentin Popov 7eced77483 ci: fix unreadable_directory_produces_error error
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 2m32s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-24 01:33:57 +04:00
Valentin Popov d41add32c4 feat: create Vulkan swapchain probe
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Successful in 2m36s
Test / Test (push) Failing after 2m52s
Test / Render parity (push) Has been skipped
2026-06-24 01:05:31 +04:00
Valentin Popov 159731664f feat: probe Vulkan logical device creation 2026-06-24 00:14:26 +04:00
Valentin Popov e6b7fa1896 feat: probe live Vulkan runtime capabilities 2026-06-24 00:05:46 +04:00
Valentin Popov 0e127117e9 feat: require created Vulkan smoke surface 2026-06-23 23:59:07 +04:00
Valentin Popov 4d19728c39 feat: create native smoke window handles 2026-06-23 23:56:40 +04:00
Valentin Popov 54f07ee3be feat: audit native smoke artifacts 2026-06-23 23:51:38 +04:00
Valentin Popov ed2b540abf feat: add target triple to native smoke report 2026-06-23 23:48:31 +04:00
Valentin Popov 00ae9067d8 feat: require swapchain recreation evidence in smoke reports 2026-06-23 23:45:08 +04:00
Valentin Popov c71e706d69 feat: add native smoke window preflight 2026-06-23 23:42:20 +04:00
Valentin Popov aa2133d82b feat: add Vulkan surface preflight to smoke runner 2026-06-23 23:38:09 +04:00
Valentin Popov 71ead678c0 feat: add Vulkan instance probe to smoke runner 2026-06-23 23:35:41 +04:00
Valentin Popov f15ea95bf2 feat: add Vulkan loader probe to smoke runner 2026-06-23 23:33:42 +04:00
Valentin Popov 99bcbf388f refactor: remove xtask native smoke generator 2026-06-23 23:30:26 +04:00
Valentin Popov 227d95fc49 feat: add Vulkan smoke runner entrypoint 2026-06-23 23:27:47 +04:00
Valentin Popov dceea70122 ci: add native smoke artifact schema 2026-06-23 23:22:29 +04:00
Valentin Popov fd452f6016 ci: add acceptance artifact metadata 2026-06-23 23:18:36 +04:00
Valentin Popov 1d0244c3e4 ci: enforce reproducible Rust toolchain 2026-06-23 23:16:50 +04:00
Valentin Popov 5d9e1cbe38 feat: add Vulkan frame submission plan 2026-06-23 23:13:52 +04:00
Valentin Popov 0e76c2ed7c ci: add built-in supply chain policy fallback 2026-06-23 23:10:16 +04:00
Valentin Popov 4c1edef21b test: verify headless dependency closure 2026-06-23 23:05:01 +04:00
Valentin Popov e6778d43af feat: add Vulkan shader manifest validation 2026-06-23 23:01:34 +04:00
Valentin Popov ec8f6599fc feat: add Vulkan swapchain planning policy 2026-06-23 22:57:03 +04:00
Valentin Popov f5fae8e84a feat: add Vulkan surface bootstrap boundary 2026-06-23 22:53:54 +04:00
Valentin Popov a0a4089e4b feat: expose native window handles 2026-06-23 22:50:32 +04:00
Valentin Popov dc7e72961a feat: add Vulkan instance bootstrap plan 2026-06-23 22:47:20 +04:00
Valentin Popov 8ea1fd5c18 feat: probe Vulkan loader boundary 2026-06-23 22:43:54 +04:00
Valentin Popov 69c032acca feat: add Vulkan capability selection boundary 2026-06-23 22:40:01 +04:00
Valentin Popov 9cc24e715d fix: close stage 0-2 synthetic gates 2026-06-23 22:32:50 +04:00
Valentin Popov f8e447ffee feat: close stage 0-2 audit groundwork
Remove legacy SDL/OpenGL adapters from the workspace and introduce winit/Vulkan adapter boundaries for the rendered composition root.

Add reproducible toolchain and xtask CI coverage for formatting, tests, clippy, docs, policy, deny, acceptance auditing, and hosted OS matrix evidence.

Strengthen Stage 1 data contracts with byte-first paths, VFS hardening, structured diagnostics, RsLi writer/edit scaffolding, corpus reporting, and resource error classification.

Advance Stage 2 asset preparation by moving mission loading through assets/runtime boundaries, materializing prototype graph data, preserving provenance, and adding inspection/viewer integration.

Record the Stage 0-2 audit input, acceptance roadmap, coverage updates, and documentation notes for follow-up evidence.
2026-06-23 22:05:16 +04:00
Valentin Popov 83d763dd70 fix: trace runtime scheduler phases
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s
Test / Lint (push) Failing after 1m15s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-22 17:32:56 +04:00
Valentin Popov 162de8ccab fix: require manifests for licensed gates
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 1m16s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-22 17:29:33 +04:00
Valentin Popov 0b23cf48e7 fix: use canonical sha256 world hashes 2026-06-22 17:11:21 +04:00
Valentin Popov 7356238ffb fix: harden render command validation 2026-06-22 17:05:45 +04:00
Valentin Popov 42441082f0 fix: cap fixed-step catch-up 2026-06-22 17:02:00 +04:00
Valentin Popov ccd61c05b0 fix: expose configurable rsli read profiles 2026-06-22 16:58:59 +04:00
Valentin Popov 813beec7be fix: preserve nres gaps during edits 2026-06-22 16:55:10 +04:00
Valentin Popov 91c7a8a14e fix: make corpus reports explicit and fallible 2026-06-22 16:49:32 +04:00
Valentin Popov 8b91a0bfbf fix: make core error displays actionable 2026-06-22 16:41:21 +04:00
Valentin Popov fb97405e0c fix: decode payloads outside resource lock 2026-06-22 16:36:50 +04:00
Valentin Popov d579b696e6 fix: cap decoded payload cache bytes 2026-06-22 16:34:14 +04:00
Valentin Popov aa1b809bd8 fix: strengthen resource fingerprints 2026-06-22 16:31:57 +04:00
Valentin Popov f69c893a40 fix: harden path lookup and mark gl backend gap 2026-06-22 16:12:57 +04:00
Valentin Popov 5436727961 docs: mark stage 4 runtime gaps explicit 2026-06-22 16:04:35 +04:00
Valentin Popov be41fa839f fix: harden resource and world state correctness 2026-06-22 16:02:16 +04:00
Valentin Popov 8e5e46b7b3 fix: make ci locked and isolate licensed tests 2026-06-22 15:55:37 +04:00
Valentin Popov d0bdbaa1ed feat: implement FParkan architecture foundation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 35s
Test / Lint (push) Failing after 1m14s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Add the modular fparkan workspace, domain crates, adapters, apps, xtask policy/CI, acceptance evidence, and licensed corpus gates for the macOS-focused roadmap foundation.
2026-06-22 13:13:32 +04:00
Valentin Popov 7416fdc7e9 Merge pull request 'chore(deps): update actions/checkout action to v7' (#16) from renovate/actions-checkout-7.x into devel
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 1m8s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Reviewed-on: #16
2026-06-22 03:09:59 +04:00
Valentin Popov 78fc5f1deb docs: rewrite MkDocs documentation
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s
Test / Lint (push) Failing after 1m7s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-22 01:58:51 +04:00
Valentin Popov 50c2cf4686 chore: remove Python tooling and resource viewer
Docs Deploy / Build and Deploy MkDocs (push) Successful in 2m6s
Test / Lint (push) Failing after 1m10s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-06-22 00:35:19 +04:00
renovate[bot] a63290fbc8 chore(deps): update actions/checkout action to v7
Test / Lint (push) Failing after 1m2s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 1m1s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
2026-06-19 00:05:00 +00:00
renovate[bot] 96a25b6c0e fix(deps): update rust crate glow to 0.17
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 1m1s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 46s
Test / Lint (push) Failing after 1m5s
RenovateBot / renovate (push) Successful in 25s
2026-03-08 00:02:26 +00:00
Valentin Popov f4262cf369 Merge pull request 'fix(deps): update rust crate toml to v1' (#14) from renovate/toml-1.x into devel
Docs Deploy / Build and Deploy MkDocs (push) Successful in 33s
Test / Lint (push) Failing after 1m3s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
RenovateBot / renovate (push) Successful in 28s
Reviewed-on: #14
2026-03-02 18:42:03 +04:00
renovate[bot] 9b100b8fc3 chore(deps): update actions/upload-artifact action to v7
Test / Lint (pull_request) Failing after 1m6s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Test / Lint (push) Has been cancelled
Test / Test (push) Has been cancelled
Test / Render parity (push) Has been cancelled
Docs Deploy / Build and Deploy MkDocs (push) Has been cancelled
2026-02-27 00:01:47 +00:00
renovate[bot] 9fceeb9a0a fix(deps): update rust crate toml to v1
Test / Lint (push) Failing after 1m2s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 1m4s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
2026-02-26 00:01:14 +00:00
renovate[bot] 4b7f1a16b9 fix(deps): update all digest updates
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
Test / Lint (pull_request) Failing after 59s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
Docs Deploy / Build and Deploy MkDocs (push) Successful in 32s
Test / Lint (push) Failing after 1m2s
RenovateBot / renovate (push) Successful in 28s
2026-02-25 00:01:21 +00:00
Valentin Popov ada3b903ad chore: update docs deployment branch from master to devel
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m9s
Test / Lint (push) Failing after 2m11s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
RenovateBot / renovate (push) Successful in 1m7s
2026-02-24 22:42:18 +00:00
Valentin Popov 31d849ddbf updated docs
Test / Lint (push) Failing after 1m57s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-02-19 16:10:57 +04:00
Valentin Popov 4ef08d0bf6 feat: add terrain-core, tma, and unitdat crates with parsing functionality
- Introduced `terrain-core` crate for loading and processing terrain mesh data.
- Added `tma` crate for parsing mission files, including footer and object records.
- Created `unitdat` crate for reading unit data files with validation of structure.
- Implemented error handling and tests for all new crates.
- Documented object registry format and rendering pipeline in specifications.
2026-02-19 16:07:01 +04:00
Valentin Popov 598137ed13 feat(resource-viewer): добавить новый ресурсный просмотрщик с базовой функциональностью
Test / Lint (push) Failing after 2m30s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
feat(nres): улучшить структуру архива с добавлением заголовка и информации о записях
feat(rsli): добавить поддержку заголовка библиотеки и улучшить обработку записей
2026-02-19 10:51:54 +00:00
Valentin Popov cb0ca2f2f0 feat(render-demo): добавить отображение FPS в заголовок окна и stdout в интерактивном режиме
Test / Lint (push) Failing after 1m16s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-02-19 10:27:10 +00:00
Valentin Popov 7346e695c4 feat(render-demo): обновить поддержку OpenGL с добавлением выбора между GLES2 и Core 3.3
Test / Lint (push) Failing after 1m17s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
2026-02-19 10:17:14 +00:00
Valentin Popov bb827c3928 feat: Refactor code structure and enhance functionality across multiple crates 2026-02-19 10:09:18 +00:00
Valentin Popov efab61a45c feat(render-core): add default UV scale and refactor UV mapping logic
Test / Lint (push) Failing after 1m12s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
- Introduced a constant `DEFAULT_UV_SCALE` for UV scaling.
- Refactored UV mapping in `build_render_mesh` to use the new constant.
- Simplified `compute_bounds` functions by extracting common logic into `compute_bounds_impl`.

test(render-core): add tests for rendering with empty and multi-node models

- Added tests to verify behavior when building render meshes from models with no slots and multiple nodes.
- Ensured UV scaling is correctly applied in tests.

feat(render-demo): add FOV argument and improve error handling

- Added a `--fov` command-line argument to set the field of view.
- Enhanced error messages for texture resolution failures.
- Updated MVP computation to use the new FOV parameter.

fix(rsli): improve error handling in LZH decompression

- Added checks to prevent out-of-bounds access in LZH decoding logic.

refactor(texm): streamline texture parsing and decoding tests

- Created a helper function `build_texm_payload` for constructing test payloads.
- Added tests for various texture formats including RGB565, RGB556, ARGB4444, and Luminance Alpha.
- Improved error handling for invalid TEXM headers and mip bounds.
2026-02-19 09:46:23 +00:00
Valentin Popov 0d7ae6a017 Документирование и обновление спецификаций
Test / Lint (push) Failing after 1m10s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
- Обновлены спецификации `runtime-pipeline`, `sound`, `terrain-map-loading`, `texture`, `ui` и `wear`.
- Добавлены разделы о статусе покрытия и оставшихся задачах для достижения 100% завершенности.
- Внесены уточнения по архитектурным ролям, минимальным контрактам и требованиям к toolchain для каждой подсистемы.
- Уточнены форматы данных и правила взаимодействия между компонентами системы.
2026-02-19 11:07:04 +04:00
Valentin Popov a281ffa32e feat: Enhance model and texture loading with improved error handling and new features
Test / Lint (push) Failing after 1m10s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped
- Introduced `LoadedModel` and `LoadedTexture` structs for better encapsulation of model and texture data.
- Added functions to load models and textures from archives, including support for resolving textures based on materials and wear entries.
- Implemented error handling for missing textures, materials, and wear entries.
- Updated the rendering pipeline to support texture loading and binding, including command-line arguments for texture customization.
- Enhanced the `texm` crate with new decoding capabilities for various pixel formats, including indexed textures.
- Added tests for texture decoding and loading to ensure reliability and correctness.
- Updated documentation to reflect changes in the material and texture resolution process.
2026-02-19 05:19:18 +04:00
Valentin Popov 18d4c6cf9f feat(render-parity): add deterministic frame comparison tool
- Introduced `render-parity` crate for comparing rendered frames against reference images.
- Added command-line options for specifying manifest and output directory.
- Implemented image comparison metrics: mean absolute difference, maximum absolute difference, and changed pixel ratio.
- Created a configuration file `cases.toml` for defining test cases with global defaults and specific parameters.
- Added functionality to capture frames from `render-demo` and save diff images on discrepancies.
- Updated documentation to include usage instructions and CI model for automated testing.
2026-02-19 05:02:26 +04:00
Valentin Popov 0e19660eb5 Refactor documentation structure and add new specifications
- Updated MSH documentation to reflect changes in material, wear, and texture specifications.
- Introduced new `render.md` file detailing the render pipeline process.
- Removed outdated sections from `runtime-pipeline.md` and redirected to `render.md`.
- Added detailed specifications for `Texm` texture format and `WEAR` wear table.
- Updated navigation in `mkdocs.yml` to align with new documentation structure.
2026-02-19 04:46:23 +04:00
Valentin Popov 8a69872576 Refactor code structure for improved readability and maintainability
Test / Lint (push) Successful in 1m8s
Test / Test (push) Successful in 1m15s
2026-02-12 11:07:25 +00:00
Valentin Popov aa68906a3d feat: добавить экспорт в формат OBJ для рендеринга террейна и опциональных полигонов 2026-02-12 10:36:15 +00:00
Valentin Popov 8bf3b7b209 feat: добавить 3D рендерер для террейна с поддержкой Land.msh и Land.map 2026-02-12 10:24:42 +00:00
Valentin Popov 669fb40a70 Add terrain map documentation validator
This commit introduces a new Python script, `terrain_map_doc_validator.py`, which validates terrain and map documentation assumptions against actual game data. The validator checks for the presence and correctness of various data chunks in the `Land.msh` and `Land.map` files, reporting any issues found during the validation process. It also generates a summary report of the validation results, including counts of errors and warnings, and statistics related to the map and mesh data.
2026-02-12 10:17:41 +00:00
Valentin Popov 9c0df3d299 feat: добавить скрипт для детерминированного аудита FXID "absolute parity" 2026-02-12 08:22:51 +00:00
155 changed files with 47568 additions and 11325 deletions
+2
View File
@@ -0,0 +1,2 @@
[alias]
xtask = "run -p xtask --"
+2 -2
View File
@@ -3,7 +3,7 @@ name: Docs Deploy
on:
push:
branches:
- master
- devel
jobs:
deploy-docs:
@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Run renovate
run: |
+30 -2
View File
@@ -7,7 +7,7 @@ jobs:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
@@ -21,7 +21,35 @@ jobs:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Cargo test
run: cargo test --workspace --all-features -- --nocapture
render-parity:
name: Render parity
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- name: Install headless GL runtime
run: |
sudo apt-get update
sudo apt-get install -y xvfb libgl1-mesa-dri libgles2-mesa-dev mesa-utils
- name: Build render-demo binary
run: cargo build -p render-demo --features demo
- name: Run frame parity suite
run: |
xvfb-run -s "-screen 0 1280x720x24" cargo run -p render-parity -- \
--manifest parity/cases.toml \
--output-dir target/render-parity/current \
--demo-bin target/debug/parkan-render-demo \
--keep-going
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: render-parity-artifacts
path: target/render-parity/current
if-no-files-found: ignore
+90
View File
@@ -0,0 +1,90 @@
name: fparkan-ci
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
msrv-backend-neutral:
name: MSRV backend-neutral crates
runs-on: ubuntu-latest
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.87.0
- name: Test backend-neutral crates
run: >
cargo test
-p fparkan-animation
-p fparkan-binary
-p fparkan-corpus
-p fparkan-diagnostics
-p fparkan-fx
-p fparkan-inspection
-p fparkan-material
-p fparkan-mission-format
-p fparkan-msh
-p fparkan-nres
-p fparkan-path
-p fparkan-platform
-p fparkan-prototype
-p fparkan-render
-p fparkan-resource
-p fparkan-rsli
-p fparkan-runtime
-p fparkan-terrain
-p fparkan-terrain-format
-p fparkan-texm
-p fparkan-vfs
-p fparkan-world
--all-targets
--locked
stage0-matrix:
name: Stage 0-2 CI (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
smoke_platform: linux
- os: windows-latest
smoke_platform: windows
- os: macos-latest
smoke_platform: macos
env:
CARGO_TERM_COLOR: always
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@master
with:
toolchain-file: rust-toolchain.toml
- name: Install cargo-deny
run: cargo install cargo-deny --locked
- name: Run canonical CI gate
run: cargo xtask ci
- name: Record native Vulkan smoke status
if: always()
shell: bash
run: >
cargo run -p fparkan-vulkan-smoke --locked --
--platform "${{ matrix.smoke_platform }}"
--out "target/fparkan/native-smoke/${{ runner.os }}.json"
--status blocked
--probe-surface
--reason "native Vulkan smoke runner is not enabled on this CI lane yet"
- name: Upload acceptance evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: stage-0-2-acceptance-${{ matrix.os }}
path: |
target/fparkan/acceptance/stage-0-2-audit.json
target/fparkan/native-smoke/*.json
if-no-files-found: ignore
+1 -5
View File
@@ -69,10 +69,6 @@ $RECYCLE.BIN/
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
@@ -215,4 +211,4 @@ poetry.toml
.ruff_cache/
# LSP config files
pyrightconfig.json
pyrightconfig.json
Generated
+2233
View File
File diff suppressed because it is too large Load Diff
+60 -1
View File
@@ -1,6 +1,65 @@
[workspace]
resolver = "3"
members = ["crates/*"]
members = [
"crates/fparkan-animation",
"crates/fparkan-assets",
"crates/fparkan-binary",
"crates/fparkan-corpus",
"crates/fparkan-diagnostics",
"crates/fparkan-fx",
"crates/fparkan-inspection",
"crates/fparkan-material",
"crates/fparkan-mission-format",
"crates/fparkan-msh",
"crates/fparkan-nres",
"crates/fparkan-path",
"crates/fparkan-platform",
"crates/fparkan-prototype",
"crates/fparkan-render",
"crates/fparkan-resource",
"crates/fparkan-rsli",
"crates/fparkan-runtime",
"crates/fparkan-terrain",
"crates/fparkan-terrain-format",
"crates/fparkan-test-support",
"crates/fparkan-texm",
"crates/fparkan-vfs",
"crates/fparkan-world",
"adapters/fparkan-platform-winit",
"adapters/fparkan-render-vulkan",
"apps/fparkan-cli",
"apps/fparkan-game",
"apps/fparkan-headless",
"apps/fparkan-vulkan-smoke",
"apps/fparkan-viewer",
"xtask",
]
[workspace.package]
version = "0.1.0"
edition = "2021"
rust-version = "1.87"
license = "GPL-2.0-only"
repository = "https://github.com/valentineus/fparkan"
[workspace.lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"
unreachable_pub = "warn"
unused_must_use = "deny"
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "warn", priority = -1 }
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
todo = "deny"
unimplemented = "deny"
dbg_macro = "deny"
print_stdout = "warn"
print_stderr = "warn"
lossy_float_literal = "deny"
[profile.release]
codegen-units = 1
+35 -13
View File
@@ -1,13 +1,12 @@
# FParkan
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования.
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»**.
## Описание
Проект находится в активной разработке и включает:
- библиотеки для работы с форматами игровых архивов;
- инструменты для валидации/подготовки тестовых данных;
- спецификации форматов и сопутствующую документацию.
## Установка
@@ -19,28 +18,51 @@ Open source проект с реализацией компонентов игр
- локально: каталог [`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 (чтение, поиск, загрузка/распаковка поддерживаемых методов).
- [crates/fparkan-nres](crates/fparkan-nres) — strict/lossless модель архивов NRes.
- [crates/fparkan-rsli](crates/fparkan-rsli) — чтение, lookup и lossless roundtrip архивов RsLi.
- [crates/fparkan-msh](crates/fparkan-msh) — validated static MSH geometry.
- [crates/fparkan-runtime](crates/fparkan-runtime) — transactional mission loading и headless runtime foundation.
- [apps/fparkan-cli](apps/fparkan-cli), [apps/fparkan-viewer](apps/fparkan-viewer), [apps/fparkan-headless](apps/fparkan-headless), [apps/fparkan-game](apps/fparkan-game) — composition roots.
## Тестирование
Базовое тестирование проходит на синтетических тестах из репозитория.
Базовое тестирование проходит на синтетических тестах из репозитория:
```bash
cargo xtask ci
```
Для дополнительного тестирования на реальных игровых ресурсах:
- используйте [tools/init_testdata.py](tools/init_testdata.py) для подготовки локального набора;
- используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy));
- разместите игровые каталоги в [`testdata/`](testdata);
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
Локальный licensed gate использует некоммитимый manifest:
```bash
cat > /private/tmp/fparkan-corpora.toml <<'EOF'
schema = 1
[[corpus]]
id = "part1-local"
kind = "part1"
root = "/absolute/path/to/IS"
expected_profile = "parkan-is-part1"
[[corpus]]
id = "part2-local"
kind = "part2"
root = "/absolute/path/to/IS2"
expected_profile = "parkan-is-part2"
EOF
FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \
cargo xtask acceptance report --suite licensed --stage 5
```
## Contributing & Support
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
@@ -0,0 +1,14 @@
[package]
name = "fparkan-platform-winit"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform" }
raw-window-handle = "0.6"
winit = "0.30"
[lints]
workspace = true
+509
View File
@@ -0,0 +1,509 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Minimal `winit`-backed platform adapter shim.
use fparkan_platform::{
EventSource, MonotonicClock, MonotonicInstant, NativeWindowHandles, PhysicalSize,
PlatformError, PlatformEvent, RenderRequest, WindowHandle, WindowPort,
};
use raw_window_handle::{HasDisplayHandle, HasWindowHandle};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use winit::application::ApplicationHandler;
use winit::dpi::PhysicalSize as WinitPhysicalSize;
use winit::event::{Event, MouseButton, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::platform::scancode::PhysicalKeyExtScancode;
use winit::window::{Window, WindowId};
static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1);
const DEFAULT_SMOKE_WIDTH: u32 = 1280;
const DEFAULT_SMOKE_HEIGHT: u32 = 720;
fn next_window_id() -> u64 {
NEXT_WINDOW_HANDLE_ID.fetch_add(1, Ordering::Relaxed)
}
/// Simple monotonic clock for windowing abstractions.
#[derive(Clone, Copy, Debug)]
pub struct WinitClock;
impl MonotonicClock for WinitClock {
fn now(&self) -> MonotonicInstant {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
MonotonicInstant(duration.as_millis().try_into().unwrap_or(u64::MAX))
}
}
/// Event source backed by pre-buffered platform events.
#[derive(Clone, Debug, Default)]
pub struct WinitEventSource {
queue: VecDeque<PlatformEvent>,
}
impl WinitEventSource {
/// Creates an empty source.
#[must_use]
pub const fn new() -> Self {
Self {
queue: VecDeque::new(),
}
}
/// Pushes a synthetic event (used by tests and smoke stubs).
pub fn push(&mut self, event: PlatformEvent) {
self.queue.push_back(event);
}
/// Pushes a mapped native window event.
pub fn push_window_event(&mut self, event: &WindowEvent) {
match event {
WindowEvent::KeyboardInput { event, .. } => {
self.queue.push_back(PlatformEvent::KeyboardInput {
scancode: event.physical_key.to_scancode().unwrap_or(0),
pressed: event.state.is_pressed(),
});
}
WindowEvent::MouseInput { state, button, .. } => {
self.queue.push_back(PlatformEvent::MouseInput {
button: mouse_button_code(*button),
pressed: state.is_pressed(),
x: 0.0,
y: 0.0,
});
}
WindowEvent::CursorMoved { position, .. } => {
self.queue.push_back(PlatformEvent::CursorMoved {
x: position.x,
y: position.y,
});
}
WindowEvent::Resized(size) => {
self.queue.push_back(PlatformEvent::Resize {
width: size.width,
height: size.height,
});
}
WindowEvent::Focused(focused) => {
self.queue
.push_back(PlatformEvent::FocusChanged { focused: *focused });
}
WindowEvent::ScaleFactorChanged { scale_factor, .. } => {
self.queue.push_back(PlatformEvent::DpiChanged {
scale: *scale_factor,
});
}
WindowEvent::CloseRequested => {
self.queue.push_back(PlatformEvent::QuitRequested);
}
_ => {}
}
}
/// Pushes events from an event loop event.
pub fn push_event<T>(&mut self, event: &Event<T>) {
if let Event::WindowEvent { event, .. } = event {
self.push_window_event(event);
}
}
}
fn mouse_button_code(button: MouseButton) -> u16 {
match button {
MouseButton::Left => 0,
MouseButton::Right => 1,
MouseButton::Middle => 2,
MouseButton::Back => 3,
MouseButton::Forward => 4,
MouseButton::Other(index) => 100 + index,
}
}
impl EventSource for WinitEventSource {
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError> {
while let Some(event) = self.queue.pop_front() {
out.push(event);
}
Ok(())
}
}
/// Window creation plan for native smoke entrypoints.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct WinitWindowPlan {
/// Requested drawable width in physical pixels.
pub width: u32,
/// Requested drawable height in physical pixels.
pub height: u32,
/// Whether native window/display handles are required by the caller.
pub requires_native_handles: bool,
}
impl WinitWindowPlan {
/// Returns the Stage 0 native smoke window plan.
#[must_use]
pub const fn smoke() -> Self {
Self {
width: DEFAULT_SMOKE_WIDTH,
height: DEFAULT_SMOKE_HEIGHT,
requires_native_handles: true,
}
}
/// Validates the window plan before a native event loop is entered.
///
/// # Errors
///
/// Returns [`PlatformError`] when the drawable extent is zero.
pub fn validate(self) -> Result<Self, PlatformError> {
if self.width == 0 || self.height == 0 {
return Err(PlatformError::Backend {
context: "winit window plan",
message: "drawable extent must be non-zero".to_string(),
});
}
Ok(self)
}
}
/// Native smoke window creation result.
#[derive(Clone, Copy, Debug)]
pub struct WinitSmokeWindowProbe {
/// Validated creation plan.
pub plan: WinitWindowPlan,
/// Captured window descriptor.
pub window: WinitWindow,
}
impl WinitSmokeWindowProbe {
/// Returns raw native handles captured from the native window.
#[must_use]
pub fn native_handles(&self) -> Option<NativeWindowHandles> {
self.window.native_handles()
}
}
/// Creates a native smoke window, captures raw handles, then exits the event loop.
///
/// # Errors
///
/// Returns [`PlatformError`] when the plan is invalid, the event loop/window
/// cannot be created, or raw native handles are unavailable.
pub fn probe_smoke_window() -> Result<WinitSmokeWindowProbe, PlatformError> {
let plan = WinitWindowPlan::smoke().validate()?;
let event_loop = EventLoop::new().map_err(|err| PlatformError::Backend {
context: "winit event loop",
message: err.to_string(),
})?;
let mut app = SmokeWindowApp::new(plan);
event_loop
.run_app(&mut app)
.map_err(|err| PlatformError::Backend {
context: "winit event loop",
message: err.to_string(),
})?;
app.into_probe()
}
struct SmokeWindowApp {
plan: WinitWindowPlan,
window: Option<WinitWindow>,
error: Option<String>,
}
impl SmokeWindowApp {
const fn new(plan: WinitWindowPlan) -> Self {
Self {
plan,
window: None,
error: None,
}
}
fn into_probe(self) -> Result<WinitSmokeWindowProbe, PlatformError> {
if let Some(message) = self.error {
return Err(PlatformError::Backend {
context: "winit smoke window",
message,
});
}
let window = self.window.ok_or_else(|| PlatformError::Backend {
context: "winit smoke window",
message: "event loop exited before creating a window".to_string(),
})?;
if self.plan.requires_native_handles && window.native_handles().is_none() {
return Err(PlatformError::Backend {
context: "winit smoke window",
message: "native window/display handles are unavailable".to_string(),
});
}
Ok(WinitSmokeWindowProbe {
plan: self.plan,
window,
})
}
}
impl ApplicationHandler for SmokeWindowApp {
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
if self.window.is_some() || self.error.is_some() {
event_loop.exit();
return;
}
let attributes = Window::default_attributes()
.with_title("FParkan Vulkan smoke")
.with_inner_size(WinitPhysicalSize::new(self.plan.width, self.plan.height));
match event_loop.create_window(attributes) {
Ok(window) => {
self.window = Some(WinitWindow::from_window(&window));
}
Err(err) => {
self.error = Some(err.to_string());
}
}
event_loop.exit();
}
fn window_event(
&mut self,
_event_loop: &ActiveEventLoop,
_window_id: WindowId,
_event: WindowEvent,
) {
}
}
/// Minimal window view over a `winit` window.
#[derive(Clone, Copy, Debug)]
pub struct WinitWindow {
handle: WindowHandle,
width: u32,
height: u32,
scale: f64,
focused: bool,
minimized: bool,
occluded: bool,
native_handles: Option<NativeWindowHandles>,
}
impl WinitWindow {
/// Builds a stable descriptor from a `winit` window.
#[must_use]
pub fn from_window(window: &Window) -> Self {
let scale = window.scale_factor();
let size = window.inner_size();
Self {
handle: WindowHandle {
id: next_window_id(),
},
width: size.width,
height: size.height,
scale,
focused: true,
minimized: false,
occluded: false,
native_handles: native_handles(window),
}
}
/// Returns conservative defaults if a native window is not available yet.
#[must_use]
pub fn synthetic(width: u32, height: u32) -> Self {
Self {
handle: WindowHandle {
id: next_window_id(),
},
width,
height,
scale: 1.0,
focused: true,
minimized: false,
occluded: false,
native_handles: None,
}
}
/// Returns requested default render profile for integration points.
#[must_use]
pub const fn default_render_request() -> RenderRequest {
RenderRequest::conservative()
}
}
impl WindowPort for WinitWindow {
fn drawable_size(&self) -> PhysicalSize {
PhysicalSize {
width: self.width,
height: self.height,
}
}
fn dpi_scale(&self) -> f64 {
self.scale
}
fn has_focus(&self) -> bool {
self.focused
}
fn is_minimized(&self) -> bool {
self.minimized
}
fn is_occluded(&self) -> bool {
self.occluded
}
fn handle(&self) -> WindowHandle {
self.handle
}
fn native_handles(&self) -> Option<NativeWindowHandles> {
self.native_handles
}
}
fn native_handles(window: &Window) -> Option<NativeWindowHandles> {
let display = window.display_handle().ok()?.as_raw();
let window = window.window_handle().ok()?.as_raw();
Some(NativeWindowHandles { display, window })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn event_source_buffers_synthetic_events() -> Result<(), PlatformError> {
let mut source = WinitEventSource::new();
source.push(PlatformEvent::Resumed);
source.push(PlatformEvent::QuitRequested);
let mut events = Vec::new();
source.poll(&mut events)?;
assert_eq!(
events,
vec![PlatformEvent::Resumed, PlatformEvent::QuitRequested]
);
Ok(())
}
#[test]
fn window_port_reports_default_request_profile() {
let window = WinitWindow::synthetic(640, 360);
let request = WinitWindow::default_render_request();
assert_eq!(
request.presentation,
fparkan_platform::PresentationMode::Fifo
);
assert_eq!(
window.drawable_size(),
PhysicalSize {
width: 640,
height: 360
}
);
assert!(window.native_handles().is_none());
}
#[test]
fn smoke_window_plan_requires_native_handles_and_nonzero_extent() -> Result<(), PlatformError> {
let plan = WinitWindowPlan::smoke().validate()?;
assert_eq!(plan.width, DEFAULT_SMOKE_WIDTH);
assert_eq!(plan.height, DEFAULT_SMOKE_HEIGHT);
assert!(plan.requires_native_handles);
Ok(())
}
#[test]
fn smoke_window_plan_rejects_zero_extent() {
let plan = WinitWindowPlan {
width: 0,
height: DEFAULT_SMOKE_HEIGHT,
requires_native_handles: true,
};
assert!(matches!(
plan.validate(),
Err(PlatformError::Backend {
context: "winit window plan",
..
})
));
}
#[test]
fn smoke_window_app_requires_created_native_window() {
let app = SmokeWindowApp::new(WinitWindowPlan::smoke());
assert!(matches!(
app.into_probe(),
Err(PlatformError::Backend {
context: "winit smoke window",
..
})
));
}
#[test]
fn smoke_window_app_rejects_synthetic_window_without_native_handles() {
let mut app = SmokeWindowApp::new(WinitWindowPlan::smoke());
app.window = Some(WinitWindow::synthetic(
DEFAULT_SMOKE_WIDTH,
DEFAULT_SMOKE_HEIGHT,
));
assert!(matches!(
app.into_probe(),
Err(PlatformError::Backend {
context: "winit smoke window",
..
})
));
}
#[test]
fn window_events_push_expected_platform_events() {
let mut source = WinitEventSource::new();
let size = winit::dpi::PhysicalSize::new(1024u32, 768u32);
source.push_window_event(&WindowEvent::Resized(size));
source.push_window_event(&WindowEvent::Focused(false));
source.push_window_event(&WindowEvent::CloseRequested);
let mut events = Vec::new();
source
.poll(&mut events)
.expect("platform event pump should never fail");
assert!(events.contains(&PlatformEvent::Resize {
width: 1024,
height: 768,
}));
assert!(events.contains(&PlatformEvent::FocusChanged { focused: false }));
assert!(events.contains(&PlatformEvent::QuitRequested));
}
}
// SAFETY: no unsafe usage in this crate.
+32
View File
@@ -0,0 +1,32 @@
[package]
name = "fparkan-render-vulkan"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
ash = "0.38"
ash-window = "0.13"
fparkan-binary = { path = "../../crates/fparkan-binary" }
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-render = { path = "../../crates/fparkan-render" }
[lints.rust]
unsafe_code = "allow"
missing_docs = "warn"
unreachable_pub = "warn"
unused_must_use = "deny"
[lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "warn", priority = -1 }
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
todo = "deny"
unimplemented = "deny"
dbg_macro = "deny"
print_stdout = "warn"
print_stderr = "warn"
lossy_float_literal = "deny"
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
# ADR-0001: Modular Monolith
Status: accepted
FParkan is implemented as one Cargo workspace with local crates grouped by domain. Binaries and adapters compose domain crates; domain crates do not import platform, windowing, OpenGL, GUI, or application packages.
+5
View File
@@ -0,0 +1,5 @@
# ADR-0002: Behavior Compatibility, Not ABI Compatibility
Status: accepted
The project targets clean-room behavior compatibility for formats, resource lookup, loading order, deterministic runtime behavior, and presentation command semantics. It does not reproduce original DLL boundaries, exports, calling conventions, object layouts, RVAs, or native singleton access patterns.
+5
View File
@@ -0,0 +1,5 @@
# ADR-0003: Raw And Interpreted Data
Status: accepted
Legacy data models keep raw bytes distinct from validated structure and interpreted domain views. Writers preserve raw data unless an explicit editing profile requests canonical rebuilding.
@@ -0,0 +1,5 @@
# ADR-0004: Synthetic And Licensed Tests
Status: accepted
Synthetic tests run everywhere and contain no proprietary data. Licensed corpus tests require an explicit local manifest and fail when requested without configuration. Reports contain metrics and fingerprints, not payload dumps or absolute game roots.
@@ -0,0 +1,5 @@
# ADR-0005: Deterministic Reference Runtime
Status: accepted
Stages 0-5 use a single-threaded deterministic reference profile. Stable ordering, explicit ticks, named random streams, and canonical captures are part of the contract. Wall-clock time, pointer addresses, hash iteration order, and GPU handles are not semantic inputs.
+5
View File
@@ -0,0 +1,5 @@
# ADR-0006: Error Policy
Status: accepted
Missing required resources, malformed bytes, unsupported documented branches, capability mismatches, and budget failures are structured errors. Runtime code must not silently skip mandatory objects or convert corrupted data into empty success values.
+26
View File
@@ -0,0 +1,26 @@
# ADR-0007: Safe SDL/OpenGL Boundary
Status: provisional
Workspace-owned code forbids `unsafe`. SDL/OpenGL adapters must use maintained
external crates behind a safe project API; local Objective-C/CGL/SDL/OpenGL FFI
inside FParkan is not an acceptable implementation strategy.
The current adapter crates are safe boundary stubs. They compile the intended
ports and deterministic command contracts, but they do not create SDL windows,
GL contexts, GPU resources, shaders, draw calls, swapchains, or presents. They
must not be treated as backend readiness evidence.
To close the macOS backend requirement, choose and vendor/lock a maintained
safe facade stack, then implement:
- SDL event source, window creation, GL context lifecycle, drawable size and
present;
- GL shader compile/link, buffer/texture upload, render state, draw calls and
diagnostics;
- game/viewer composition roots using those adapters;
- hidden-window/offscreen macOS smoke tests and licensed local model/terrain
frame captures.
Until those are implemented, Desktop GL evidence may document external probes
only; it does not satisfy the permanent adapter requirement.
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "fparkan-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-assets = { path = "../../crates/fparkan-assets" }
fparkan-corpus = { path = "../../crates/fparkan-corpus" }
fparkan-prototype = { path = "../../crates/fparkan-prototype" }
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
fparkan-resource = { path = "../../crates/fparkan-resource" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
[lints]
workspace = true
+363
View File
@@ -0,0 +1,363 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` command-line tools.
use fparkan_assets::extend_graph_report_with_visual_dependencies;
use fparkan_corpus::{discover, render_report_json, report, DiscoverOptions};
use fparkan_inspection::inspect_archive_file;
use fparkan_inspection::ArchiveInspection;
use fparkan_prototype::build_prototype_graph_report;
use fparkan_resource::{resource_name, CachedResourceRepository};
use fparkan_runtime::{
create, load_mission, EngineConfig, EngineMode, EngineServices, MissionRequest,
};
use fparkan_vfs::DirectoryVfs;
use std::fmt::Write;
use std::path::PathBuf;
use std::sync::Arc;
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = run(&args);
let code = exit_code(&result);
if let Err(err) = result {
eprintln!("{err}");
}
std::process::exit(code);
}
fn run(args: &[String]) -> Result<(), String> {
match args {
[domain, command, rest @ ..] if domain == "corpus" && command == "discover" => {
let rest = strip_format_json(rest)?;
let root = parse_root(&rest)?;
let manifest =
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
let report = report(&root, &manifest).map_err(|e| e.to_string())?;
println!("{}", render_report_json(&report));
Ok(())
}
[domain, command, rest @ ..] if domain == "corpus" && command == "validate" => {
let rest = strip_format_json(rest)?;
let root = parse_root(&rest)?;
let manifest =
discover(&root, DiscoverOptions::default()).map_err(|e| e.to_string())?;
let report = report(&root, &manifest).map_err(|e| e.to_string())?;
if report.casefold_collisions > 0 {
return Err("casefold collisions found".to_string());
}
if report.failures > 0 {
return Err(format!("corpus report found {} failures", report.failures));
}
println!("{}", render_report_json(&report));
Ok(())
}
[domain, command, rest @ ..] if domain == "archive" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_archive(&rest)
}
[domain, command, rest @ ..] if domain == "prototype" && command == "inspect" => {
let rest = strip_format_json(rest)?;
inspect_prototype(&rest)
}
[domain, command, rest @ ..] if domain == "mission" && command == "graph" => {
let rest = strip_format_json(rest)?;
graph_mission(&rest)
}
_ => Err(usage()),
}
}
fn exit_code(result: &Result<(), String>) -> i32 {
if result.is_ok() {
0
} else {
2
}
}
fn strip_format_json(args: &[String]) -> Result<Vec<String>, String> {
let mut stripped = Vec::with_capacity(args.len());
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if arg == "--format" {
let value = iter
.next()
.ok_or_else(|| "--format requires a value".to_string())?;
if value != "json" {
return Err(format!("unsupported output format: {value}"));
}
continue;
}
stripped.push(arg.clone());
}
Ok(stripped)
}
fn parse_root(args: &[String]) -> Result<PathBuf, String> {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if arg == "--root" {
return iter
.next()
.map(PathBuf::from)
.ok_or_else(|| "--root requires a path".to_string());
}
}
Err("missing --root".to_string())
}
fn parse_root_alias(args: &[String]) -> Result<PathBuf, String> {
parse_option(args, &["--root", "--game-root"])
.map(PathBuf::from)
.ok_or_else(|| "missing --root".to_string())
}
fn parse_required(args: &[String], names: &[&str], label: &str) -> Result<String, String> {
parse_option(args, names).ok_or_else(|| format!("missing {label}"))
}
fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if names.iter().any(|name| arg == name) {
return iter.next().cloned();
}
}
None
}
fn inspect_prototype(args: &[String]) -> Result<(), String> {
let root = parse_root_alias(args)?;
let key = parse_required(args, &["--key"], "--key")?;
let vfs = Arc::new(DirectoryVfs::new(root));
let repository = CachedResourceRepository::new(vfs.clone());
let roots = [resource_name(key.as_bytes())];
let (graph, resolved, mut report) =
build_prototype_graph_report(&repository, vfs.as_ref(), &roots);
extend_graph_report_with_visual_dependencies(&repository, &mut report, &graph, &resolved);
println!("{}", prototype_inspect_json(&key, &graph, &report));
Ok(())
}
fn prototype_inspect_json(
key: &str,
graph: &fparkan_prototype::PrototypeGraph,
report: &fparkan_prototype::PrototypeGraphReport,
) -> String {
format!(
"{{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":{},\"roots\":{},\"prototype_requests\":{},\"resolved\":{},\"unit_references\":{},\"unit_components\":{},\"direct_references\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
json_string(key),
report.root_count,
graph.prototype_requests.len(),
report.resolved_count,
report.unit_reference_count,
report.unit_component_count,
report.direct_reference_count,
report.wear_resolved_count,
report.material_resolved_count,
report.texture_resolved_count,
report.lightmap_resolved_count,
report.failures.len()
)
}
fn graph_mission(args: &[String]) -> Result<(), String> {
let root = parse_root_alias(args)?;
let mission = parse_required(args, &["--mission"], "--mission")?;
let services = EngineServices::new(Arc::new(DirectoryVfs::new(root)));
let mut engine = create(
EngineConfig {
mode: EngineMode::Headless,
},
services,
)
.map_err(|err| err.to_string())?;
let loaded = load_mission(
&mut engine,
MissionRequest {
key: mission.clone(),
},
)
.map_err(|err| err.to_string())?;
println!(
"{{\"schema_version\":\"fparkan-mission-graph-v1\",\"mission\":{},\"objects\":{},\"paths\":{},\"clans\":{},\"extras\":{},\"roots\":{},\"direct_references\":{},\"unit_references\":{},\"unit_components\":{},\"prototype_requests\":{},\"wear\":{},\"materials\":{},\"textures\":{},\"lightmaps\":{},\"failures\":{}}}",
json_string(&mission),
loaded.object_count,
loaded.path_count,
loaded.clan_count,
loaded.extra_count,
loaded.graph_root_count,
loaded.graph_direct_reference_count,
loaded.graph_unit_reference_count,
loaded.graph_unit_component_count,
loaded.graph_resolved_count,
loaded.graph_wear_resolved_count,
loaded.graph_material_resolved_count,
loaded.graph_texture_resolved_count,
loaded.graph_lightmap_resolved_count,
loaded.graph_failure_count
);
Ok(())
}
fn inspect_archive(args: &[String]) -> Result<(), String> {
let path = parse_archive_path(args)?;
let inspection = inspect_archive_file(&path, 0).map_err(|err| err.to_string())?;
match inspection {
ArchiveInspection::Nres {
entries,
lookup_order_valid,
..
} => {
println!(
"{}",
archive_inspect_json(
&path.display().to_string(),
"NRes",
entries,
Some(lookup_order_valid),
)
);
Ok(())
}
ArchiveInspection::Rsli { entries } => {
println!(
"{}",
archive_inspect_json(&path.display().to_string(), "RsLi", entries, None)
);
Ok(())
}
ArchiveInspection::Unsupported => {
Err(format!("{}: unsupported archive magic", path.display()))
}
}
}
fn archive_inspect_json(
path: &str,
kind: &str,
entries: usize,
lookup_order_valid: Option<bool>,
) -> String {
let mut out = format!(
"{{\"schema_version\":\"fparkan-archive-inspect-v1\",\"path\":{},\"kind\":{},\"entries\":{}",
json_string(path),
json_string(kind),
entries
);
if let Some(valid) = lookup_order_valid {
let _ = write!(out, ",\"lookup_order_valid\":{valid}");
}
out.push('}');
out
}
fn parse_archive_path(args: &[String]) -> Result<PathBuf, String> {
match args {
[path] => Ok(PathBuf::from(path)),
[flag, path] if flag == "--file" => Ok(PathBuf::from(path)),
_ => Err("archive inspect requires <file> or --file <file>".to_string()),
}
}
fn json_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn usage() -> String {
"usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph --root <path> --mission <path> [--format json]".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn stable_exit_codes_are_mapped() {
assert_eq!(exit_code(&Ok(())), 0);
assert_eq!(exit_code(&Err("failure".to_string())), 2);
}
#[test]
fn accepts_json_format_option() {
assert_eq!(
strip_format_json(&strings(&["--root", "testdata", "--format", "json"])),
Ok(strings(&["--root", "testdata"]))
);
assert_eq!(
strip_format_json(&strings(&["--format", "text"])),
Err("unsupported output format: text".to_string())
);
}
#[test]
fn archive_json_has_schema_version() {
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true));
assert!(json.contains("\"schema_version\":\"fparkan-archive-inspect-v1\""));
assert!(json.contains("\"kind\":\"NRes\""));
assert!(json.contains("\"lookup_order_valid\":true"));
}
#[test]
fn prototype_graph_json_has_canonical_field_order() {
let mut graph = fparkan_prototype::PrototypeGraph::default();
graph
.prototype_requests
.push(fparkan_prototype::PrototypeKey(resource_name(b"root")));
let report = fparkan_prototype::PrototypeGraphReport {
root_count: 1,
direct_reference_count: 1,
resolved_count: 1,
..fparkan_prototype::PrototypeGraphReport::default()
};
let json = prototype_inspect_json("root", &graph, &report);
assert_eq!(
json,
"{\"schema_version\":\"fparkan-prototype-inspect-v1\",\"key\":\"root\",\"roots\":1,\"prototype_requests\":1,\"resolved\":1,\"unit_references\":0,\"unit_components\":0,\"direct_references\":1,\"wear\":0,\"materials\":0,\"textures\":0,\"lightmaps\":0,\"failures\":0}"
);
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "fparkan-game"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-assets = { path = "../../crates/fparkan-assets" }
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-render = { path = "../../crates/fparkan-render" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
fparkan-world = { path = "../../crates/fparkan-world" }
[lints]
workspace = true
+389
View File
@@ -0,0 +1,389 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` rendered game composition root.
use fparkan_assets::PreparedVisual;
use fparkan_platform::WindowPort;
use fparkan_platform_winit::WinitWindow;
use fparkan_render::{
DrawCommand, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderBackend, RenderCommand,
RenderCommandList, RenderPhase,
};
use fparkan_render_vulkan::VulkanBackend;
use fparkan_runtime::{
create, frame, load_mission, loaded_mission_assets, EngineConfig, EngineMode, EngineServices,
MissionAssets, MissionRequest,
};
use fparkan_vfs::DirectoryVfs;
use fparkan_world::WorldSnapshot;
use std::path::PathBuf;
use std::sync::Arc;
fn main() {
let raw_args = std::env::args().skip(1).collect::<Vec<_>>();
let code = match run(&raw_args) {
Ok(output) => {
println!("{output}");
0
}
Err(err) => {
eprintln!("{err}");
2
}
};
std::process::exit(code);
}
fn run(args: &[String]) -> Result<String, String> {
let args = Args::parse(args)?;
let services = EngineServices::new(Arc::new(DirectoryVfs::new(&args.root)));
let mut engine = create(
EngineConfig {
mode: EngineMode::Rendered,
},
services,
)
.map_err(|err| err.to_string())?;
let loaded = load_mission(
&mut engine,
MissionRequest {
key: args.mission.clone(),
},
)
.map_err(|err| err.to_string())?;
let mut backend = VulkanBackend::new();
let _request = WinitWindow::default_render_request();
let window = WinitWindow::synthetic(1280, 720);
let _ = window.drawable_size();
let _ = window.handle();
let mut last_draw_count = 0usize;
let mut last_tick = 0u64;
let mut last_hash = [0u8; 32];
for _ in 0..args.frames {
let result = frame(&mut engine).map_err(|err| err.to_string())?;
last_tick = result.snapshot.tick.0;
last_hash = result.snapshot.hash.0;
let mission_assets = loaded_mission_assets(&engine);
let commands = render_snapshot_commands_with_assets(&result.snapshot, mission_assets);
last_draw_count = commands
.commands
.iter()
.filter(|command| matches!(command, RenderCommand::Draw(_)))
.count();
backend
.execute(&commands)
.map_err(|err| format!("render backend: {err}"))?;
}
let capture_report = backend.report();
Ok(format!(
"{{\"mission\":{},\"objects\":{},\"frames\":{},\"tick\":{},\"draws\":{},\"captures\":{},\"last_capture_bytes\":{},\"hash\":{}}}",
json_string(&args.mission),
loaded.object_count,
args.frames,
last_tick,
last_draw_count,
capture_report.submissions,
capture_report.last_capture_size,
json_hash(&last_hash)
))
}
#[cfg(test)]
fn render_snapshot_commands(snapshot: &WorldSnapshot) -> RenderCommandList {
render_snapshot_commands_with_assets(snapshot, None)
}
fn render_snapshot_commands_with_assets(
snapshot: &WorldSnapshot,
mission_assets: Option<&MissionAssets>,
) -> RenderCommandList {
let mut commands = Vec::with_capacity(snapshot.objects.len() + 2);
commands.push(RenderCommand::BeginFrame);
for (index, handle) in snapshot.objects.iter().enumerate() {
let stable_order = u64::from(handle.slot);
let prepared = mission_assets.and_then(|assets| {
assets
.visual_for_object(index)
.and_then(|visual_id| assets.visual_by_id(visual_id))
});
let mesh = if let Some(visual) = prepared {
visual.mesh.as_ref().map_or_else(
|| GpuMeshId(u64::from(handle.slot) + 1),
|_| GpuMeshId(visual.id.raw()),
)
} else {
GpuMeshId(u64::from(handle.slot) + 1)
};
let material = prepared
.and_then(PreparedVisual::primary_material_id)
.map_or(GpuMaterialId(1), |material_id| {
GpuMaterialId(material_id.raw())
});
let draw_id = snapshot
.tick
.0
.wrapping_mul(1_000_003)
.wrapping_add(stable_order);
commands.push(RenderCommand::Draw(DrawCommand {
id: DrawId(draw_id),
phase: RenderPhase::Opaque,
object_id: None,
mesh,
material,
transform: identity_transform(index_to_f32(index)),
range: IndexRange { start: 0, count: 3 },
stable_order,
}));
}
commands.push(RenderCommand::EndFrame);
RenderCommandList { commands }
}
fn identity_transform(x: f32) -> [f32; 16] {
[
1.0, 0.0, 0.0, x, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
]
}
fn index_to_f32(index: usize) -> f32 {
u16::try_from(index).map_or(f32::from(u16::MAX), f32::from)
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct Args {
root: PathBuf,
mission: String,
frames: u64,
}
impl Args {
fn parse(args: &[String]) -> Result<Self, String> {
let mut root = None;
let mut mission = None;
let mut frames = 1;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--root" => {
root = Some(
iter.next()
.map(PathBuf::from)
.ok_or_else(|| "--root requires a path".to_string())?,
);
}
"--mission" => {
mission = Some(
iter.next()
.cloned()
.ok_or_else(|| "--mission requires a path".to_string())?,
);
}
"--frames" => {
frames = iter
.next()
.ok_or_else(|| "--frames requires a value".to_string())?
.parse()
.map_err(|_| "--frames must be an integer".to_string())?;
}
_ => return Err(usage()),
}
}
let root = root.ok_or_else(|| "missing --root".to_string())?;
let mission = mission.ok_or_else(|| "missing --mission".to_string())?;
if frames == 0 {
return Err("--frames must be greater than zero".to_string());
}
Ok(Self {
root,
mission,
frames,
})
}
}
fn json_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
use std::fmt::Write as _;
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn json_hash(hash: &[u8; 32]) -> String {
let mut out = String::from("\"");
for byte in hash {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out.push('"');
out
}
fn usage() -> String {
"usage: fparkan-game --root <path> --mission <path> [--frames <n>]".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use fparkan_world::{ObjectHandle, StateHash, Tick};
use std::path::Path;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn parses_required_args() {
assert_eq!(
Args::parse(&strings(&[
"--root",
"testdata/IS",
"--mission",
"MISSIONS/Autodemo.00/data.tma",
"--frames",
"3",
])),
Ok(Args {
root: PathBuf::from("testdata/IS"),
mission: "MISSIONS/Autodemo.00/data.tma".to_string(),
frames: 3,
})
);
}
#[test]
fn render_commands_follow_snapshot_order() -> Result<(), String> {
let snapshot = WorldSnapshot {
tick: Tick(7),
objects: vec![
ObjectHandle {
generation: 1,
slot: 2,
},
ObjectHandle {
generation: 1,
slot: 5,
},
],
events: Vec::new(),
hash: StateHash([0; 32]),
};
let commands = render_snapshot_commands(&snapshot);
assert_eq!(commands.commands.len(), 4);
assert!(matches!(commands.commands[0], RenderCommand::BeginFrame));
assert!(matches!(commands.commands[3], RenderCommand::EndFrame));
let RenderCommand::Draw(first) = &commands.commands[1] else {
return Err("expected draw".to_string());
};
assert_eq!(first.mesh, GpuMeshId(3));
assert_eq!(first.stable_order, 2);
Ok(())
}
#[test]
#[ignore = "requires licensed corpus"]
fn selected_is_and_is2_missions_produce_approved_render_captures() {
for case in [
RenderCase {
root: "IS",
mission: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma",
expected: "{\"mission\":\"MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma\",\"objects\":33,\"frames\":1,\"tick\":1,\"draws\":33,\"captures\":1,\"last_capture_bytes\":810,\"hash\":\"ca17cc76e55c45e83c1c9c1c088e84bf1a698be91a7730943210fe27596af841\"}",
},
RenderCase {
root: "IS2",
mission: "MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma",
expected: "{\"mission\":\"MISSIONS/Campaign/CAMPAIGN.00/Mission.02/data.tma\",\"objects\":10,\"frames\":1,\"tick\":1,\"draws\":10,\"captures\":1,\"last_capture_bytes\":235,\"hash\":\"5d720b3ab690076a398a79a404850bbeaee2e33811b5bb570ec8a96d4a7a2fc4\"}",
},
] {
assert_eq!(
run(&render_args(&licensed_root(case.root), case.mission)),
Ok(case.expected.to_string())
);
}
}
#[test]
fn json_hash_is_hex() {
let mut hash = [0; 32];
hash[0] = 0xab;
hash[31] = 0xcd;
assert_eq!(
json_hash(&hash),
"\"ab000000000000000000000000000000000000000000000000000000000000cd\""
);
}
#[derive(Clone, Copy)]
struct RenderCase {
root: &'static str,
mission: &'static str,
expected: &'static str,
}
fn render_args(root: &Path, mission: &str) -> Vec<String> {
vec![
"--root".to_string(),
root.to_str().expect("utf8 root").to_string(),
"--mission".to_string(),
mission.to_string(),
"--frames".to_string(),
"1".to_string(),
]
}
fn licensed_root(name: &str) -> PathBuf {
let variable = match name {
"IS" => "FPARKAN_CORPUS_PART1_ROOT",
"IS2" => "FPARKAN_CORPUS_PART2_ROOT",
_ => panic!("unknown licensed corpus part: {name}"),
};
let root = std::env::var_os(variable)
.map(PathBuf::from)
.unwrap_or_else(|| panic!("{variable} is required for licensed corpus tests"));
assert!(
root.is_dir(),
"licensed corpus root is missing: {}",
root.display()
);
root
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "fparkan-headless"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-runtime = { path = "../../crates/fparkan-runtime" }
fparkan-vfs = { path = "../../crates/fparkan-vfs" }
fparkan-world = { path = "../../crates/fparkan-world" }
[lints]
workspace = true
+133
View File
@@ -0,0 +1,133 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` headless runtime entrypoint.
use fparkan_runtime::{
create, load_mission, step_headless, EngineConfig, EngineMode, EngineServices, MissionRequest,
};
use fparkan_vfs::DirectoryVfs;
use fparkan_world::InputSnapshot;
use std::path::PathBuf;
use std::sync::Arc;
fn main() {
if let Err(err) = run() {
eprintln!("{err}");
std::process::exit(2);
}
}
fn run() -> Result<(), String> {
let raw_args: Vec<String> = std::env::args().skip(1).collect();
let args = Args::parse(&raw_args)?;
let services = if let Some(root) = &args.root {
EngineServices::new(Arc::new(DirectoryVfs::new(root)))
} else {
EngineServices::default()
};
let mut engine = create(
EngineConfig {
mode: EngineMode::Headless,
},
services,
)
.map_err(|err| format!("{err}"))?;
if let Some(mission) = args.mission {
let loaded = load_mission(&mut engine, MissionRequest { key: mission })
.map_err(|err| format!("{err}"))?;
println!(
"mission objects={} areals={} surfaces={} graph_roots={} components={} wear={} material_slots={} textures={} lightmaps={} graph_failures={}",
loaded.object_count,
loaded.areal_count,
loaded.surface_count,
loaded.graph_root_count,
loaded.graph_unit_component_count,
loaded.graph_wear_resolved_count,
loaded.graph_material_resolved_count,
loaded.graph_texture_resolved_count,
loaded.graph_lightmap_resolved_count,
loaded.graph_failure_count
);
}
let mut last = None;
for _ in 0..args.ticks {
last = Some(step_headless(&mut engine, InputSnapshot).map_err(|err| format!("{err}"))?);
}
if let Some(frame) = last {
println!(
"tick={} hash={:02x?}",
frame.snapshot.tick.0, frame.snapshot.hash.0
);
}
Ok(())
}
struct Args {
root: Option<PathBuf>,
mission: Option<String>,
ticks: u64,
}
impl Args {
fn parse(args: &[String]) -> Result<Self, String> {
let mut parsed = Self {
root: None,
mission: None,
ticks: 1,
};
let mut iter = args.iter();
while let Some(arg) = iter.next() {
match arg.as_str() {
"--root" => {
parsed.root = Some(
iter.next()
.map(PathBuf::from)
.ok_or_else(|| "--root requires a path".to_string())?,
);
}
"--mission" => {
parsed.mission = Some(
iter.next()
.cloned()
.ok_or_else(|| "--mission requires a path".to_string())?,
);
}
"--ticks" => {
parsed.ticks = iter
.next()
.ok_or_else(|| "--ticks requires a value".to_string())?
.parse()
.map_err(|_| "--ticks must be an integer".to_string())?;
}
_ => return Err(usage()),
}
}
if parsed.mission.is_some() && parsed.root.is_none() {
return Err("--mission requires --root".to_string());
}
Ok(parsed)
}
}
fn usage() -> String {
"usage: fparkan-headless [--root <path> --mission <path>] [--ticks <n>]".to_string()
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "fparkan-viewer"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-inspection = { path = "../../crates/fparkan-inspection" }
fparkan-render = { path = "../../crates/fparkan-render" }
[lints]
workspace = true
+349
View File
@@ -0,0 +1,349 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
#![allow(clippy::print_stderr, clippy::print_stdout)]
//! `FParkan` asset viewer composition root.
use fparkan_inspection::{
inspect_land_file, inspect_model_from_root, inspect_texture_from_root, ArchiveInspection,
LandFileKind, MapInspection, NresEntrySummary,
};
use fparkan_render::{
build_commands, CameraSnapshot, DrawId, GpuMaterialId, GpuMeshId, IndexRange, RenderPhase,
RenderProfile, RenderSnapshot, RenderSnapshotDraw,
};
use std::fmt::Write;
use std::path::PathBuf;
fn main() {
let args = std::env::args().skip(1).collect::<Vec<_>>();
let code = match run(&args) {
Ok(json) => {
println!("{json}");
0
}
Err(err) => {
eprintln!("{err}");
2
}
};
std::process::exit(code);
}
fn run(args: &[String]) -> Result<String, String> {
match args {
[domain, rest @ ..] if domain == "archive" => inspect_archive(rest),
[domain, rest @ ..] if domain == "model" => inspect_model(rest),
[domain, rest @ ..] if domain == "texture" => inspect_texture(rest),
[domain, rest @ ..] if domain == "map" => inspect_map(rest),
_ => Err(usage()),
}
}
fn inspect_archive(args: &[String]) -> Result<String, String> {
let file = parse_file(args)?;
let limit = parse_limit(args)?;
let inspection = fparkan_inspection::inspect_archive_file(&file, limit)?;
match inspection {
ArchiveInspection::Nres {
entries,
lookup_order_valid,
sample,
} => Ok(format!(
"{{\"kind\":\"NRes\",\"path\":{},\"entries\":{},\"lookup_order_valid\":{},\"sample\":[{}]}}",
json_string(&file.display().to_string()),
entries,
lookup_order_valid,
render_nres_entries(&sample)
)),
ArchiveInspection::Rsli { entries } => Ok(format!(
"{{\"kind\":\"RsLi\",\"path\":{},\"entries\":{}}}",
json_string(&file.display().to_string()),
entries
)),
ArchiveInspection::Unsupported => Err(format!("{}: unsupported archive magic", file.display())),
}
}
fn inspect_model(args: &[String]) -> Result<String, String> {
if let Some(fixture) = parse_option(args, &["--fixture"]) {
return ViewerModelService::inspect_synthetic_model(&fixture);
}
let query = parse_resource_query(args)?;
let inspection = inspect_model_from_root(&query.root, &query.archive, &query.name)?;
Ok(format!(
"{{\"kind\":\"model\",\"archive\":{},\"name\":{},\"streams\":{},\"nodes\":{},\"slots\":{},\"positions\":{},\"indices\":{},\"batches\":{}}}",
json_string(&query.archive),
json_string(&query.name),
inspection.streams,
inspection.nodes,
inspection.slots,
inspection.positions,
inspection.indices,
inspection.batches
))
}
#[derive(Clone, Debug)]
struct ViewerModelService;
impl ViewerModelService {
fn inspect_synthetic_model(fixture: &str) -> Result<String, String> {
if fixture != "synthetic/model-basic" {
return Err(format!("unknown model fixture: {fixture}"));
}
let snapshot = RenderSnapshot {
camera: CameraSnapshot::default(),
draws: vec![RenderSnapshotDraw {
id: DrawId(1),
phase: RenderPhase::Opaque,
object_id: None,
mesh: GpuMeshId(1),
material_slots: vec![GpuMaterialId(7)],
material_index: 0,
transform: identity_transform(),
range: IndexRange { start: 0, count: 3 },
stable_order: 0,
}],
};
let commands = build_commands(&snapshot, RenderProfile::default())
.map_err(|err| format!("render command generation: {err}"))?;
let draw_commands = commands
.commands
.iter()
.filter(|command| matches!(command, fparkan_render::RenderCommand::Draw(_)))
.count();
Ok(format!(
"{{\"kind\":\"model\",\"fixture\":{},\"service\":\"synthetic-model\",\"draw_commands\":{draw_commands}}}",
json_string(fixture)
))
}
}
fn inspect_texture(args: &[String]) -> Result<String, String> {
let query = parse_resource_query(args)?;
let inspection = inspect_texture_from_root(&query.root, &query.archive, &query.name)?;
Ok(format!(
"{{\"kind\":\"texture\",\"archive\":{},\"name\":{},\"width\":{},\"height\":{},\"format\":{},\"mips\":{},\"pages\":{}}}",
json_string(&query.archive),
json_string(&query.name),
inspection.width,
inspection.height,
json_string(&inspection.format),
inspection.mips,
inspection.pages
))
}
fn inspect_map(args: &[String]) -> Result<String, String> {
let file = parse_file(args)?;
let kind = parse_option(args, &["--kind"]).ok_or_else(|| "missing --kind".to_string())?;
let inspection = inspect_land_file(
&file,
match kind.as_str() {
"land-msh" => LandFileKind::LandMsh,
"land-map" => LandFileKind::LandMap,
_ => return Err(format!("unknown map kind: {kind}")),
},
)?;
Ok(render_map_inspection_json(
&file.display().to_string(),
&kind,
&inspection,
))
}
fn render_map_inspection_json(path: &str, kind: &str, inspection: &MapInspection) -> String {
match kind {
"land-msh" => format!(
"{{\"kind\":\"land-msh\",\"path\":{},\"streams\":{},\"positions\":{},\"faces\":{},\"slots\":{}}}",
json_string(path),
inspection.streams,
inspection.positions,
inspection.faces,
inspection.slots
),
"land-map" => format!(
"{{\"kind\":\"land-map\",\"path\":{},\"areals\":{},\"declared_areals\":{},\"grid_width\":{},\"grid_height\":{}}}",
json_string(path),
inspection.areals,
inspection.declared_areals,
inspection.grid_width,
inspection.grid_height
),
_ => unreachable!("invalid land kind: {kind}"),
}
}
struct ResourceQuery {
root: PathBuf,
archive: String,
name: String,
}
fn parse_resource_query(args: &[String]) -> Result<ResourceQuery, String> {
Ok(ResourceQuery {
root: parse_path_option(args, &["--root", "--game-root"], "--root")?,
archive: parse_option(args, &["--archive"])
.ok_or_else(|| "missing --archive".to_string())?,
name: parse_option(args, &["--name"]).ok_or_else(|| "missing --name".to_string())?,
})
}
fn parse_file(args: &[String]) -> Result<PathBuf, String> {
parse_path_option(args, &["--file"], "--file")
}
fn parse_limit(args: &[String]) -> Result<usize, String> {
parse_option(args, &["--limit"])
.map(|value| {
value
.parse::<usize>()
.map_err(|_| format!("invalid --limit: {value}"))
})
.transpose()
.map(|value| value.unwrap_or(0))
}
fn render_nres_entries(entries: &[NresEntrySummary]) -> String {
let mut out = String::new();
for (index, entry) in entries.iter().enumerate() {
if index > 0 {
out.push(',');
}
let name = &entry.name;
let _ = write!(
out,
"{{\"name\":{},\"type\":{},\"size\":{}}}",
json_string(name),
entry.type_id,
entry.data_size
);
}
out
}
fn parse_path_option(args: &[String], names: &[&str], label: &str) -> Result<PathBuf, String> {
parse_option(args, names)
.map(PathBuf::from)
.ok_or_else(|| format!("missing {label}"))
}
fn parse_option(args: &[String], names: &[&str]) -> Option<String> {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if names.iter().any(|name| arg == name) {
return iter.next().cloned();
}
}
None
}
fn json_string(value: &str) -> String {
let mut out = String::with_capacity(value.len() + 2);
out.push('"');
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c.is_control() => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
out
}
fn identity_transform() -> [f32; 16] {
[
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
]
}
fn usage() -> String {
"usage: fparkan-viewer archive --file <archive> [--limit N] | model --root <game-root> --archive <archive> --name <msh> | model --fixture synthetic/model-basic | texture --root <game-root> --archive <archive> --name <texm> | map --file <Land.msh|Land.map> --kind land-msh|land-map".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn strings(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_string()).collect()
}
#[test]
fn parses_resource_query() -> Result<(), String> {
let query = parse_resource_query(&strings(&[
"--root",
"testdata/IS",
"--archive",
"textures.lib",
"--name",
"grass.tex",
]))?;
assert_eq!(query.root, PathBuf::from("testdata/IS"));
assert_eq!(query.archive, "textures.lib");
assert_eq!(query.name, "grass.tex");
Ok(())
}
#[test]
fn json_string_escapes_controls() {
assert_eq!(json_string("a\"b\\c\n"), "\"a\\\"b\\\\c\\n\"");
}
#[test]
fn usage_rejects_empty_args() {
assert_eq!(run(&[]), Err(usage()));
}
#[test]
fn parses_limit() {
assert_eq!(parse_limit(&strings(&["--limit", "2"])), Ok(2));
assert_eq!(parse_limit(&[]), Ok(0));
assert_eq!(
parse_limit(&strings(&["--limit", "x"])),
Err("invalid --limit: x".to_string())
);
}
#[test]
fn model_fixture_uses_viewer_service_and_render_commands() -> Result<(), String> {
assert_eq!(
run(&strings(&["model", "--fixture", "synthetic/model-basic"]))?,
"{\"kind\":\"model\",\"fixture\":\"synthetic/model-basic\",\"service\":\"synthetic-model\",\"draw_commands\":1}"
);
Ok(())
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "fparkan-vulkan-smoke"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-platform = { path = "../../crates/fparkan-platform" }
fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" }
fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
-6
View File
@@ -1,6 +0,0 @@
[package]
name = "common"
version = "0.1.0"
edition = "2021"
[dependencies]
-44
View File
@@ -1,44 +0,0 @@
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(())
}
}
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "fparkan-animation"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "fparkan-assets"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
fparkan-mission-format = { path = "../fparkan-mission-format" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-texm = { path = "../fparkan-texm" }
fparkan-terrain = { path = "../fparkan-terrain" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
[dev-dependencies]
fparkan-vfs = { path = "../fparkan-vfs" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "fparkan-binary"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
[lints]
workspace = true
+519
View File
@@ -0,0 +1,519 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Bounded little-endian binary cursor and checked layout helpers.
use std::fmt;
/// SHA-256 digest bytes.
pub type Sha256Digest = [u8; 32];
/// Parser limits shared by binary formats.
#[derive(Clone, Copy, Debug)]
pub struct Limits {
/// Maximum file bytes.
pub max_file_bytes: u64,
/// Maximum entries.
pub max_entries: u32,
/// Maximum string bytes.
pub max_string_bytes: u32,
/// Maximum array items.
pub max_array_items: u32,
/// Maximum recursion depth.
pub max_recursion_depth: u16,
/// Maximum decoded bytes.
pub max_decoded_bytes: u64,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_file_bytes: 256 * 1024 * 1024,
max_entries: 1_000_000,
max_string_bytes: 64 * 1024,
max_array_items: 1_000_000,
max_recursion_depth: 64,
max_decoded_bytes: 512 * 1024 * 1024,
}
}
}
/// Decode error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum DecodeError {
/// Input ended before requested bytes.
UnexpectedEof {
/// Offset where read was attempted.
offset: u64,
/// Required byte count.
needed: u64,
/// Remaining byte count.
remaining: u64,
},
/// Arithmetic overflow.
IntegerOverflow,
/// Count exceeds limit.
LimitExceeded {
/// Declared count.
count: u64,
/// Configured limit.
limit: u64,
},
/// Cursor did not end at EOF.
TrailingBytes {
/// Offset where EOF was expected.
offset: u64,
/// Remaining byte count.
remaining: u64,
},
/// Invalid data.
Invalid(&'static str),
}
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnexpectedEof {
offset,
needed,
remaining,
} => write!(
f,
"unexpected EOF at {offset}: need {needed}, have {remaining}"
),
Self::IntegerOverflow => write!(f, "integer overflow"),
Self::LimitExceeded { count, limit } => {
write!(f, "count {count} exceeds limit {limit}")
}
Self::TrailingBytes { offset, remaining } => {
write!(f, "trailing bytes at {offset}: {remaining}")
}
Self::Invalid(reason) => write!(f, "invalid data: {reason}"),
}
}
}
impl std::error::Error for DecodeError {}
/// Cursor checkpoint.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Checkpoint(pub u64);
/// Bounded cursor.
#[derive(Clone, Debug)]
pub struct Cursor<'a> {
bytes: &'a [u8],
offset: usize,
}
impl<'a> Cursor<'a> {
/// Creates a cursor.
#[must_use]
pub fn new(bytes: &'a [u8]) -> Self {
Self { bytes, offset: 0 }
}
/// Current offset.
#[must_use]
pub fn offset(&self) -> u64 {
self.offset as u64
}
/// Remaining bytes.
#[must_use]
pub fn remaining(&self) -> usize {
self.bytes.len().saturating_sub(self.offset)
}
/// Creates a checkpoint.
#[must_use]
pub fn checkpoint(&self) -> Checkpoint {
Checkpoint(self.offset())
}
/// Reads exact bytes.
///
/// # Errors
///
/// Returns [`DecodeError::IntegerOverflow`] if the requested end offset
/// overflows, or [`DecodeError::UnexpectedEof`] if there are not enough
/// bytes remaining.
pub fn read_exact(&mut self, len: usize) -> Result<&'a [u8], DecodeError> {
let end = self
.offset
.checked_add(len)
.ok_or(DecodeError::IntegerOverflow)?;
if end > self.bytes.len() {
return Err(DecodeError::UnexpectedEof {
offset: self.offset(),
needed: len as u64,
remaining: self.remaining() as u64,
});
}
let out = &self.bytes[self.offset..end];
self.offset = end;
Ok(out)
}
/// Reads a little-endian u16.
///
/// # Errors
///
/// Returns [`DecodeError`] if two bytes cannot be read.
pub fn read_u16_le(&mut self) -> Result<u16, DecodeError> {
let b = self.read_exact(2)?;
Ok(u16::from_le_bytes([b[0], b[1]]))
}
/// Reads a little-endian u32.
///
/// # Errors
///
/// Returns [`DecodeError`] if four bytes cannot be read.
pub fn read_u32_le(&mut self) -> Result<u32, DecodeError> {
let b = self.read_exact(4)?;
Ok(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
/// Reads a little-endian i32.
///
/// # Errors
///
/// Returns [`DecodeError`] if four bytes cannot be read.
pub fn read_i32_le(&mut self) -> Result<i32, DecodeError> {
let b = self.read_exact(4)?;
Ok(i32::from_le_bytes([b[0], b[1], b[2], b[3]]))
}
/// Reads a little-endian f32.
///
/// # Errors
///
/// Returns [`DecodeError`] if four bytes cannot be read.
pub fn read_f32_le(&mut self) -> Result<f32, DecodeError> {
Ok(f32::from_bits(self.read_u32_le()?))
}
/// Requires exact EOF.
///
/// # Errors
///
/// Returns [`DecodeError::TrailingBytes`] when unread bytes remain.
pub fn require_eof(&self) -> Result<(), DecodeError> {
if self.remaining() == 0 {
Ok(())
} else {
Err(DecodeError::TrailingBytes {
offset: self.offset(),
remaining: self.remaining() as u64,
})
}
}
}
/// Validates `count * stride <= remaining` and returns bytes as usize.
///
/// # Errors
///
/// Returns [`DecodeError::IntegerOverflow`] on arithmetic or conversion
/// overflow, or [`DecodeError::UnexpectedEof`] when the declared byte count is
/// larger than the remaining bounded input.
pub fn checked_count_bytes(count: u64, stride: u64, remaining: u64) -> Result<usize, DecodeError> {
let bytes = count
.checked_mul(stride)
.ok_or(DecodeError::IntegerOverflow)?;
if bytes > remaining {
return Err(DecodeError::UnexpectedEof {
offset: 0,
needed: bytes,
remaining,
});
}
usize::try_from(bytes).map_err(|_| DecodeError::IntegerOverflow)
}
/// Validates a declared allocation size before constructing the allocation.
///
/// # Errors
///
/// Returns [`DecodeError::LimitExceeded`] when `declared` is larger than
/// `limit`, or [`DecodeError::IntegerOverflow`] when the accepted size cannot
/// be represented by the host `usize`.
pub fn checked_allocation_len(declared: u64, limit: u64) -> Result<usize, DecodeError> {
if declared > limit {
return Err(DecodeError::LimitExceeded {
count: declared,
limit,
});
}
usize::try_from(declared).map_err(|_| DecodeError::IntegerOverflow)
}
/// Reads length-prefixed bytes.
///
/// # Errors
///
/// Returns [`DecodeError`] if the length cannot be read, exceeds `max`, or the
/// declared payload is truncated.
pub fn read_lp_bytes(cursor: &mut Cursor<'_>, max: u32) -> Result<Vec<u8>, DecodeError> {
let len = cursor.read_u32_le()?;
if len > max {
return Err(DecodeError::LimitExceeded {
count: u64::from(len),
limit: u64::from(max),
});
}
let len = checked_allocation_len(u64::from(len), u64::from(max))?;
Ok(cursor.read_exact(len)?.to_vec())
}
/// Computes a SHA-256 content digest without external dependencies.
#[must_use]
pub fn sha256(bytes: &[u8]) -> Sha256Digest {
const K: [u32; 64] = [
0x428a_2f98,
0x7137_4491,
0xb5c0_fbcf,
0xe9b5_dba5,
0x3956_c25b,
0x59f1_11f1,
0x923f_82a4,
0xab1c_5ed5,
0xd807_aa98,
0x1283_5b01,
0x2431_85be,
0x550c_7dc3,
0x72be_5d74,
0x80de_b1fe,
0x9bdc_06a7,
0xc19b_f174,
0xe49b_69c1,
0xefbe_4786,
0x0fc1_9dc6,
0x240c_a1cc,
0x2de9_2c6f,
0x4a74_84aa,
0x5cb0_a9dc,
0x76f9_88da,
0x983e_5152,
0xa831_c66d,
0xb003_27c8,
0xbf59_7fc7,
0xc6e0_0bf3,
0xd5a7_9147,
0x06ca_6351,
0x1429_2967,
0x27b7_0a85,
0x2e1b_2138,
0x4d2c_6dfc,
0x5338_0d13,
0x650a_7354,
0x766a_0abb,
0x81c2_c92e,
0x9272_2c85,
0xa2bf_e8a1,
0xa81a_664b,
0xc24b_8b70,
0xc76c_51a3,
0xd192_e819,
0xd699_0624,
0xf40e_3585,
0x106a_a070,
0x19a4_c116,
0x1e37_6c08,
0x2748_774c,
0x34b0_bcb5,
0x391c_0cb3,
0x4ed8_aa4a,
0x5b9c_ca4f,
0x682e_6ff3,
0x748f_82ee,
0x78a5_636f,
0x84c8_7814,
0x8cc7_0208,
0x90be_fffa,
0xa450_6ceb,
0xbef9_a3f7,
0xc671_78f2,
];
let mut h = [
0x6a09_e667,
0xbb67_ae85,
0x3c6e_f372,
0xa54f_f53a,
0x510e_527f,
0x9b05_688c,
0x1f83_d9ab,
0x5be0_cd19,
];
let bit_len = (bytes.len() as u64).wrapping_mul(8);
let mut chunks = bytes.chunks_exact(64);
for chunk in &mut chunks {
compress_sha256_chunk(&mut h, chunk, &K);
}
let tail = chunks.remainder();
let mut block = [0u8; 128];
block[..tail.len()].copy_from_slice(tail);
block[tail.len()] = 0x80;
let padded_len = if tail.len() < 56 { 64 } else { 128 };
block[padded_len - 8..padded_len].copy_from_slice(&bit_len.to_be_bytes());
for chunk in block[..padded_len].chunks_exact(64) {
compress_sha256_chunk(&mut h, chunk, &K);
}
let mut out = [0u8; 32];
for (idx, word) in h.iter().enumerate() {
out[idx * 4..idx * 4 + 4].copy_from_slice(&word.to_be_bytes());
}
out
}
/// Renders a SHA-256 digest as lowercase hexadecimal.
#[must_use]
pub fn sha256_hex(digest: &Sha256Digest) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(64);
for byte in digest {
out.push(char::from(HEX[usize::from(byte >> 4)]));
out.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
out
}
#[allow(clippy::many_single_char_names)]
fn compress_sha256_chunk(h: &mut [u32; 8], chunk: &[u8], k: &[u32; 64]) {
let mut w = [0u32; 64];
for (idx, word) in w.iter_mut().take(16).enumerate() {
let base = idx * 4;
*word = u32::from_be_bytes([
chunk[base],
chunk[base + 1],
chunk[base + 2],
chunk[base + 3],
]);
}
for idx in 16..64 {
let s0 = w[idx - 15].rotate_right(7) ^ w[idx - 15].rotate_right(18) ^ (w[idx - 15] >> 3);
let s1 = w[idx - 2].rotate_right(17) ^ w[idx - 2].rotate_right(19) ^ (w[idx - 2] >> 10);
w[idx] = w[idx - 16]
.wrapping_add(s0)
.wrapping_add(w[idx - 7])
.wrapping_add(s1);
}
let mut a = h[0];
let mut b = h[1];
let mut c = h[2];
let mut d = h[3];
let mut e = h[4];
let mut f = h[5];
let mut g = h[6];
let mut hh = h[7];
for idx in 0..64 {
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
let ch = (e & f) ^ ((!e) & g);
let temp1 = hh
.wrapping_add(s1)
.wrapping_add(ch)
.wrapping_add(k[idx])
.wrapping_add(w[idx]);
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
let maj = (a & b) ^ (a & c) ^ (b & c);
let temp2 = s0.wrapping_add(maj);
hh = g;
g = f;
f = e;
e = d.wrapping_add(temp1);
d = c;
c = b;
b = a;
a = temp1.wrapping_add(temp2);
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
h[5] = h[5].wrapping_add(f);
h[6] = h[6].wrapping_add(g);
h[7] = h[7].wrapping_add(hh);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_count_stride_overflow() {
assert_eq!(
checked_count_bytes(u64::MAX, 2, u64::MAX),
Err(DecodeError::IntegerOverflow)
);
}
#[test]
fn exact_eof_reports_trailing() {
let mut cursor = Cursor::new(&[1, 2]);
assert_eq!(cursor.read_exact(1).expect("byte"), &[1]);
assert!(matches!(
cursor.require_eof(),
Err(DecodeError::TrailingBytes { .. })
));
}
#[test]
fn rejects_oversized_declared_allocation_before_read() {
assert_eq!(
checked_allocation_len(1025, 1024),
Err(DecodeError::LimitExceeded {
count: 1025,
limit: 1024
})
);
let bytes = 2048u32.to_le_bytes();
let mut cursor = Cursor::new(&bytes);
assert_eq!(
read_lp_bytes(&mut cursor, 1024),
Err(DecodeError::LimitExceeded {
count: 2048,
limit: 1024
})
);
assert_eq!(cursor.offset(), 4);
}
#[test]
fn sha256_matches_known_vectors() {
assert_eq!(
sha256_hex(&sha256(b"")),
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_eq!(
sha256_hex(&sha256(b"abc")),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
}
+22
View File
@@ -0,0 +1,22 @@
[package]
name = "fparkan-corpus"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-fx = { path = "../fparkan-fx" }
fparkan-material = { path = "../fparkan-material" }
fparkan-msh = { path = "../fparkan-msh" }
fparkan-mission-format = { path = "../fparkan-mission-format" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-path = { path = "../fparkan-path" }
fparkan-rsli = { path = "../fparkan-rsli" }
fparkan-texm = { path = "../fparkan-texm" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "fparkan-diagnostics"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
[lints]
workspace = true
+249
View File
@@ -0,0 +1,249 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Structured diagnostics shared by `FParkan` crates.
use serde::Serialize;
/// Diagnostic severity.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
/// Informational note.
Info,
/// Recoverable warning.
Warning,
/// Error for the current operation.
Error,
/// Fatal error for the current run.
Fatal,
}
/// Evidence level for a contract or interpretation.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum EvidenceStatus {
/// Described by project documentation.
Documented,
/// Verified by synthetic fixtures.
SyntheticVerified,
/// Verified against the licensed corpus.
CorpusVerified,
/// Verified by runtime capture.
RuntimeCaptured,
/// Working hypothesis; not a runtime contract.
Hypothesis,
}
/// Operation phase where a diagnostic was produced.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Phase {
/// Discovery.
Discover,
/// Read.
Read,
/// Parse.
Parse,
/// Validate.
Validate,
/// Resolve.
Resolve,
/// Prepare.
Prepare,
/// Construct.
Construct,
/// Register.
Register,
/// Simulate.
Simulate,
/// Render.
Render,
}
/// Byte span in an input source.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
pub struct SourceSpan {
/// Start offset.
pub offset: u64,
/// Length in bytes.
pub length: u64,
}
/// Stable diagnostic code.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize)]
pub struct DiagnosticCode(pub &'static str);
/// Context attached to a diagnostic.
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
pub struct DiagnosticContext {
/// Phase.
#[serde(skip_serializing_if = "Option::is_none")]
pub phase: Option<Phase>,
/// Redacted or logical path.
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
/// Archive entry name.
#[serde(skip_serializing_if = "Option::is_none")]
pub archive_entry: Option<String>,
/// Object/prototype key.
#[serde(skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
/// Input span.
#[serde(skip_serializing_if = "Option::is_none")]
pub span: Option<SourceSpan>,
}
/// Structured diagnostic with cause chain.
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
pub struct Diagnostic {
/// Stable code.
pub code: DiagnosticCode,
/// Severity.
pub severity: Severity,
/// Human message.
pub message: String,
/// Context.
pub context: DiagnosticContext,
/// Causes.
pub causes: Vec<Diagnostic>,
}
/// Creates a diagnostic with default error severity.
#[must_use]
pub fn diagnostic(code: DiagnosticCode, message: impl Into<String>) -> Diagnostic {
Diagnostic {
code,
severity: Severity::Error,
message: message.into(),
context: DiagnosticContext::default(),
causes: Vec::new(),
}
}
impl Diagnostic {
/// Returns a copy with severity changed.
#[must_use]
pub fn with_severity(mut self, severity: Severity) -> Self {
self.severity = severity;
self
}
/// Returns a copy with context changed.
#[must_use]
pub fn with_context(mut self, context: DiagnosticContext) -> Self {
self.context = context;
self
}
/// Adds a cause.
pub fn push_cause(&mut self, cause: Diagnostic) {
self.causes.push(cause);
}
}
/// Renders a compact human-readable diagnostic.
#[must_use]
pub fn render_human(diagnostic: &Diagnostic) -> String {
let mut out = format!(
"{:?} {}: {}",
diagnostic.severity, diagnostic.code.0, diagnostic.message
);
if let Some(path) = &diagnostic.context.path {
out.push_str(" [");
out.push_str(path);
out.push(']');
}
out
}
/// Renders deterministic JSON using the typed diagnostic schema.
#[must_use]
pub fn render_json(diagnostic: &Diagnostic) -> String {
match serde_json::to_string(diagnostic) {
Ok(json) => json,
Err(err) => format!("{{\"error\":\"diagnostic serialization failed: {err}\"}}"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn json_is_stable() {
let d = diagnostic(DiagnosticCode("S0-DIAG-001"), "keeps context").with_context(
DiagnosticContext {
phase: Some(Phase::Parse),
..DiagnosticContext::default()
},
);
assert_eq!(
render_json(&d),
"{\"code\":\"S0-DIAG-001\",\"severity\":\"error\",\"message\":\"keeps context\",\"context\":{\"phase\":\"parse\"},\"causes\":[]}"
);
}
#[test]
fn diagnostic_chain_preserves_context() {
let mut root = diagnostic(DiagnosticCode("ROOT"), "root").with_context(DiagnosticContext {
phase: Some(Phase::Resolve),
path: Some("archives/material.lib".to_string()),
archive_entry: Some("MATERIAL.MAT0".to_string()),
object_key: Some("unit/tank".to_string()),
span: Some(SourceSpan {
offset: 12,
length: 4,
}),
});
root.push_cause(diagnostic(DiagnosticCode("CAUSE"), "cause").with_context(
DiagnosticContext {
phase: Some(Phase::Parse),
path: Some("archives/material.lib".to_string()),
span: Some(SourceSpan {
offset: 16,
length: 8,
}),
..DiagnosticContext::default()
},
));
let json = render_json(&root);
assert!(json.contains("\"code\":\"ROOT\""));
assert!(json.contains("\"phase\":\"resolve\""));
assert!(json.contains("\"path\":\"archives/material.lib\""));
assert!(json.contains("\"archive_entry\":\"MATERIAL.MAT0\""));
assert!(json.contains("\"object_key\":\"unit/tank\""));
assert!(json.contains("\"span\":{\"offset\":12,\"length\":4}"));
assert!(json.contains("\"code\":\"CAUSE\""));
assert!(json.contains("\"span\":{\"offset\":16,\"length\":8}"));
}
#[test]
fn json_escapes_all_control_characters() {
let value = diagnostic(DiagnosticCode("S1-H01"), "quote\"\u{0000}tab\tline\r\n");
let json = render_json(&value);
assert!(json.contains("\\u0000"));
assert!(json.contains("\\t"));
assert!(!json.contains('\t'));
assert!(!json.contains('\r'));
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "fparkan-fx"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "fparkan-inspection"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-msh = { path = "../fparkan-msh" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-rsli = { path = "../fparkan-rsli" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
fparkan-texm = { path = "../fparkan-texm" }
fparkan-vfs = { path = "../fparkan-vfs" }
[lints]
workspace = true
+327
View File
@@ -0,0 +1,327 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Shared inspection helpers for format-backed tooling.
use fparkan_msh::{decode_msh, validate_msh};
use fparkan_nres::{decode as decode_nres, NresDocument, ReadProfile};
use fparkan_resource::{archive_path, resource_name, CachedResourceRepository, ResourceRepository};
use fparkan_rsli::decode as decode_rsli;
use fparkan_terrain_format::{decode_land_map, decode_land_msh};
use fparkan_texm::decode_texm;
use fparkan_vfs::DirectoryVfs;
use std::fs;
use std::path::Path;
use std::sync::Arc;
/// Archive inspection variants.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum ArchiveInspection {
/// `NRes` inspection summary.
Nres {
/// Archive entry count.
entries: usize,
/// Lookup order validity.
lookup_order_valid: bool,
/// Entry samples (subject to request limit).
sample: Vec<NresEntrySummary>,
},
/// `RsLi` inspection summary.
Rsli {
/// Archive entry count.
entries: usize,
},
/// Unknown/unsupported archive magic.
Unsupported,
}
/// `NRes` entry summary.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NresEntrySummary {
/// ASCII/legacy resource name.
pub name: String,
/// Entry type identifier.
pub type_id: u32,
/// Declared entry payload size.
pub data_size: u32,
}
/// Model inspection payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ModelInspection {
/// Terrain stream/document stream count.
pub streams: usize,
/// Node count.
pub nodes: usize,
/// Slot count.
pub slots: usize,
/// Position count.
pub positions: usize,
/// Index count.
pub indices: usize,
/// Batch count.
pub batches: usize,
}
/// Texture inspection payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct TextureInspection {
/// Width.
pub width: u32,
/// Height.
pub height: u32,
/// Texture format debug text.
pub format: String,
/// Mip level count.
pub mips: usize,
/// Total page rectangles.
pub pages: usize,
}
/// Land map/msh inspection payload.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MapInspection {
/// Mapped mesh stream count.
pub streams: usize,
/// Slot count.
pub slots: usize,
/// Position count.
pub positions: usize,
/// Face count.
pub faces: usize,
/// Terrain areals.
pub areals: usize,
/// Declared areal count from map metadata.
pub declared_areals: u32,
/// Map grid width.
pub grid_width: u32,
/// Map grid height.
pub grid_height: u32,
}
/// Supported land file kinds.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LandFileKind {
/// `land.msh` payload.
LandMsh,
/// `land.map` payload.
LandMap,
}
/// Inspects a format archive.
///
/// # Errors
///
/// Returns a string error when the archive cannot be read or decoded.
pub fn inspect_archive_file(path: &Path, sample_limit: usize) -> Result<ArchiveInspection, String> {
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
inspect_archive_bytes(&bytes, sample_limit, Some(path))
}
/// Inspects archive bytes and returns a typed summary.
fn inspect_archive_bytes(
bytes: &[u8],
sample_limit: usize,
source: Option<&Path>,
) -> Result<ArchiveInspection, String> {
if bytes.starts_with(b"NRes") {
let document = decode_nres(
Arc::from(bytes.to_vec().into_boxed_slice()),
ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
let mut sample = Vec::new();
for entry in document.entries().iter().take(sample_limit) {
sample.push(NresEntrySummary {
name: String::from_utf8_lossy(entry.name_bytes()).to_string(),
type_id: entry.meta().type_id,
data_size: entry.meta().data_size,
});
}
Ok(ArchiveInspection::Nres {
entries: document.entries().len(),
lookup_order_valid: document.lookup_order_valid(),
sample,
})
} else if bytes.get(0..4) == Some(b"NL\0\x01") {
let document = decode_rsli(
Arc::from(bytes.to_vec().into_boxed_slice()),
fparkan_rsli::ReadProfile::Compatible,
)
.map_err(|err| err.to_string())?;
Ok(ArchiveInspection::Rsli {
entries: document.entries().len(),
})
} else {
match source {
Some(path) => Err(format!("{}: unsupported archive magic", path.display())),
None => Err("unsupported archive magic".to_string()),
}
}
}
/// Inspects a model through repository-backed resource lookup.
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved or parsed as a
/// valid model payload.
pub fn inspect_model_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<ModelInspection, String> {
let bytes = read_resource_bytes(root, archive, resource)?;
let document = decode_nres(bytes, ReadProfile::Compatible).map_err(|err| err.to_string())?;
let msh = decode_msh(&document).map_err(|err| err.to_string())?;
let validated = validate_msh(&msh).map_err(|err| err.to_string())?;
Ok(ModelInspection {
streams: msh.streams().len(),
nodes: validated.node_count,
slots: validated.slots.len(),
positions: validated.positions.len(),
indices: validated.indices.len(),
batches: validated.batches.len(),
})
}
/// Inspects a texture through repository-backed resource lookup.
///
/// # Errors
///
/// Returns a string error when the resource cannot be resolved or parsed as a
/// valid texture payload.
pub fn inspect_texture_from_root(
root: &Path,
archive: &str,
resource: &str,
) -> Result<TextureInspection, String> {
let bytes = read_resource_bytes(root, archive, resource)?;
let document = decode_texm(bytes).map_err(|err| err.to_string())?;
Ok(TextureInspection {
width: document.width(),
height: document.height(),
format: format!("{:?}", document.format()),
mips: document.mip_count(),
pages: document.page_rects().len(),
})
}
/// Inspects a terrain land file by path.
///
/// # Errors
///
/// Returns a string error when the file cannot be read or parsed as the
/// requested terrain payload kind.
pub fn inspect_land_file(path: &Path, kind: LandFileKind) -> Result<MapInspection, String> {
let bytes = fs::read(path).map_err(|err| format!("{}: {err}", path.display()))?;
let document = decode_nres(Arc::from(bytes.into_boxed_slice()), ReadProfile::Compatible)
.map_err(|err| err.to_string())?;
match kind {
LandFileKind::LandMsh => inspect_land_msh(&document),
LandFileKind::LandMap => inspect_land_map(&document),
}
}
fn inspect_land_msh(document: &NresDocument) -> Result<MapInspection, String> {
let land_msh = decode_land_msh(document).map_err(|err| err.to_string())?;
Ok(MapInspection {
streams: land_msh.streams.len(),
slots: land_msh.slots.slots_raw.len(),
positions: land_msh.positions.len(),
faces: land_msh.faces.len(),
areals: 0,
declared_areals: 0,
grid_width: 0,
grid_height: 0,
})
}
fn inspect_land_map(document: &NresDocument) -> Result<MapInspection, String> {
let land_map = decode_land_map(document).map_err(|err| err.to_string())?;
Ok(MapInspection {
streams: 0,
slots: 0,
positions: 0,
faces: 0,
areals: land_map.areals.len(),
declared_areals: land_map.areal_count,
grid_width: land_map.grid.cells_x,
grid_height: land_map.grid.cells_y,
})
}
fn read_resource_bytes(root: &Path, archive: &str, name: &str) -> Result<Arc<[u8]>, String> {
let repository = CachedResourceRepository::new(Arc::new(DirectoryVfs::new(root)));
let archive_path = archive_path(archive.as_bytes()).map_err(|err| err.to_string())?;
let resource_name = resource_name(name.as_bytes());
let archive_handle = repository
.open_archive(&archive_path)
.map_err(|err| format!("{err}"))?;
let Some(handle) = repository
.find(archive_handle, &resource_name)
.map_err(|err| format!("{err}"))?
else {
return Err(format!(
"resource not found: {archive}/{}",
String::from_utf8_lossy(name.as_bytes())
));
};
let bytes = repository.read(handle).map_err(|err| format!("{err}"))?;
Ok(Arc::from(bytes.into_owned()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
use std::path::PathBuf;
#[test]
fn inspect_rsli_rejects_malformed_archive() {
let dir = temp_dir("inspect");
let path = dir.join("test.rsli");
let mut file = fs::File::create(&path).expect("file");
file.write_all(b"NL\0\x01").expect("magic");
drop(file);
let error = inspect_archive_file(&path, 0).expect_err("malformed archive");
assert!(error.contains("entry table out of bounds"));
}
#[test]
fn nres_entry_summary_fields_are_readable() {
let dir = temp_dir("inspect-nres");
let archive = dir.join("test.nres");
let payload = Vec::from("NRes\x00\x00\x00\x00");
fs::write(&archive, &payload).expect("nres");
let _ = inspect_archive_file(&archive, 2);
}
fn temp_dir(name: &str) -> PathBuf {
let base = PathBuf::from("/tmp")
.join("fparkan-inspection-tests")
.join(name);
let _ = fs::remove_dir_all(&base);
fs::create_dir_all(&base).expect("tmp dir");
base
}
}
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "fparkan-material"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
encoding_rs = "0.8"
fparkan-path = { path = "../fparkan-path" }
fparkan-resource = { path = "../fparkan-resource" }
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
fparkan-vfs = { path = "../fparkan-vfs" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "fparkan-mission-format"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
encoding_rs = "0.8"
fparkan-binary = { path = "../fparkan-binary" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "fparkan-msh"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
encoding_rs = "0.8"
fparkan-nres = { path = "../fparkan-nres" }
[dev-dependencies]
fparkan-animation = { path = "../fparkan-animation" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "fparkan-nres"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-path = { path = "../fparkan-path" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
[package]
name = "fparkan-path"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
[lints]
workspace = true
+367
View File
@@ -0,0 +1,367 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Legacy path normalization and ASCII lookup semantics.
use std::fmt;
use std::path::{Path, PathBuf};
/// Original bytes.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct OriginalPathBytes(pub Vec<u8>);
impl OriginalPathBytes {
/// Returns the preserved byte image.
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
/// Returns the preserved byte image as an owned vector.
#[must_use]
pub fn into_vec(self) -> Vec<u8> {
self.0
}
}
/// Normalized relative path.
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct NormalizedPath {
raw: Vec<u8>,
display: String,
}
impl NormalizedPath {
/// Returns string view.
#[must_use]
pub fn as_str(&self) -> &str {
&self.display
}
/// Returns normalized byte view.
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.raw
}
/// Returns an OS path owned path buffer.
#[must_use]
pub fn as_path(&self) -> PathBuf {
as_os_path_from_bytes(&self.raw)
}
}
/// Normalized path paired with its original byte image.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct NormalizedPathWithOriginal {
normalized: NormalizedPath,
original: OriginalPathBytes,
}
impl NormalizedPathWithOriginal {
/// Returns normalized path.
#[must_use]
pub fn normalized(&self) -> &NormalizedPath {
&self.normalized
}
/// Returns original path bytes.
#[must_use]
pub fn original(&self) -> &OriginalPathBytes {
&self.original
}
/// Splits into normalized and original path parts.
#[must_use]
pub fn into_parts(self) -> (NormalizedPath, OriginalPathBytes) {
(self.normalized, self.original)
}
}
/// ASCII lookup key.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct LookupKey(pub Vec<u8>);
/// Resource name bytes.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ResourceName(pub Vec<u8>);
/// Path policy.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PathPolicy {
/// Strict legacy relative resource path.
StrictLegacy,
/// Host compatible relative path.
HostCompatible,
}
/// Path error.
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum PathError {
/// Empty path.
Empty,
/// Embedded NUL.
EmbeddedNul,
/// Absolute path.
Absolute,
/// Parent traversal.
ParentTraversal,
/// Host path escape.
EscapesRoot,
}
impl fmt::Display for PathError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Empty => write!(f, "path is empty"),
Self::EmbeddedNul => write!(f, "path contains an embedded NUL byte"),
Self::Absolute => write!(f, "path must be relative and cannot be absolute"),
Self::ParentTraversal => write!(f, "path attempts to traverse outside its root"),
Self::EscapesRoot => write!(f, "normalized path escapes the configured root"),
}
}
}
impl std::error::Error for PathError {}
/// Normalizes a relative path.
///
/// # Errors
///
/// Returns [`PathError`] when the input is empty, absolute, contains an
/// embedded NUL, attempts parent traversal, or has an invalid drive prefix.
pub fn normalize_relative(raw: &[u8], policy: PathPolicy) -> Result<NormalizedPath, PathError> {
if raw.is_empty() {
return Err(PathError::Empty);
}
if raw.contains(&0) {
return Err(PathError::EmbeddedNul);
}
if raw.starts_with(b"/") || raw.starts_with(b"\\") || has_drive_prefix(raw) {
return Err(PathError::Absolute);
}
let mut parts = Vec::new();
for part in raw.split(|byte| *byte == b'/' || *byte == b'\\') {
if part.is_empty() || part == b"." {
if policy == PathPolicy::StrictLegacy {
return Err(PathError::ParentTraversal);
}
continue;
}
if part == b".." {
return Err(PathError::ParentTraversal);
}
if policy == PathPolicy::StrictLegacy && part.contains(&b':') {
return Err(PathError::Absolute);
}
parts.push(part);
}
if parts.is_empty() {
return Err(PathError::Empty);
}
let mut normalized = Vec::new();
for (index, part) in parts.iter().enumerate() {
if index > 0 {
normalized.push(b'/');
}
normalized.extend_from_slice(part);
}
let display = String::from_utf8_lossy(&normalized).into_owned();
Ok(NormalizedPath {
raw: normalized,
display,
})
}
/// Normalizes a relative path while preserving its original bytes.
///
/// # Errors
///
/// Returns [`PathError`] under the same conditions as [`normalize_relative`].
pub fn normalize_relative_with_original(
raw: &[u8],
policy: PathPolicy,
) -> Result<NormalizedPathWithOriginal, PathError> {
let normalized = normalize_relative(raw, policy)?;
Ok(NormalizedPathWithOriginal {
normalized,
original: OriginalPathBytes(raw.to_vec()),
})
}
fn has_drive_prefix(bytes: &[u8]) -> bool {
bytes.len() >= 2 && bytes[1] == b':' && bytes[0].is_ascii_alphabetic()
}
/// Builds an ASCII-only casefold lookup key.
#[must_use]
pub fn ascii_lookup_key(raw: &[u8]) -> LookupKey {
LookupKey(raw.iter().map(u8::to_ascii_uppercase).collect())
}
/// Ensures relative path does not escape.
///
/// # Errors
///
/// Returns [`PathError::ParentTraversal`] when a normalized segment attempts
/// to address a parent directory.
pub fn reject_escape(rel: &NormalizedPath) -> Result<(), PathError> {
if rel
.as_bytes()
.split(|byte| *byte == b'/')
.any(|part| part == b"..")
{
Err(PathError::ParentTraversal)
} else {
Ok(())
}
}
/// Joins normalized path under root.
///
/// # Errors
///
/// Returns [`PathError`] if the normalized path fails the escape check.
pub fn join_under(root: &Path, rel: &NormalizedPath) -> Result<PathBuf, PathError> {
reject_escape(rel)?;
Ok(root.join(rel.as_path()))
}
#[cfg(unix)]
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
PathBuf::from(OsString::from_vec(raw.to_vec()))
}
#[cfg(not(unix))]
fn as_os_path_from_bytes(raw: &[u8]) -> PathBuf {
PathBuf::from(String::from_utf8_lossy(raw).into_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_separators() {
let p = normalize_relative(b"DATA\\MAPS/INTRO/Land.msh", PathPolicy::StrictLegacy)
.expect("path");
assert_eq!(p.as_str(), "DATA/MAPS/INTRO/Land.msh");
}
#[test]
fn rejects_escape() {
assert_eq!(
normalize_relative(b"DATA/../secret", PathPolicy::StrictLegacy),
Err(PathError::ParentTraversal)
);
}
#[test]
fn rejects_absolute_drive_and_nul_paths() {
assert_eq!(
normalize_relative(b"/DATA/MAPS", PathPolicy::StrictLegacy),
Err(PathError::Absolute)
);
assert_eq!(
normalize_relative(b"C:\\DATA\\MAPS", PathPolicy::StrictLegacy),
Err(PathError::Absolute)
);
assert_eq!(
normalize_relative(b"DATA\0MAPS", PathPolicy::StrictLegacy),
Err(PathError::EmbeddedNul)
);
}
#[test]
fn path_error_display_is_actionable() {
assert_eq!(
PathError::ParentTraversal.to_string(),
"path attempts to traverse outside its root"
);
assert_eq!(
PathError::EmbeddedNul.to_string(),
"path contains an embedded NUL byte"
);
}
#[test]
fn strict_legacy_rejects_host_only_segments() {
assert_eq!(
normalize_relative(b"./DATA/MAPS", PathPolicy::StrictLegacy),
Err(PathError::ParentTraversal)
);
assert_eq!(
normalize_relative(b"DATA//MAPS", PathPolicy::StrictLegacy),
Err(PathError::ParentTraversal)
);
assert_eq!(
normalize_relative(b"DATA/stream:name", PathPolicy::StrictLegacy),
Err(PathError::Absolute)
);
let host = normalize_relative(b"./DATA//MAPS", PathPolicy::HostCompatible).expect("host");
assert_eq!(host.as_str(), "DATA/MAPS");
}
#[test]
fn join_under_keeps_normalized_path_below_root() {
let rel = normalize_relative(b"DATA/MAPS/Land.map", PathPolicy::StrictLegacy)
.expect("relative path");
let joined = join_under(Path::new("/game"), &rel).expect("join");
assert_eq!(joined, PathBuf::from("/game/DATA/MAPS/Land.map"));
}
#[test]
fn ascii_casefold_does_not_unicode_fold() {
assert_eq!(ascii_lookup_key(b"AbZ\xD0"), LookupKey(b"ABZ\xD0".to_vec()));
}
#[test]
fn non_ascii_original_bytes_remain_stable() {
let raw = "DATA/Тест.bin".as_bytes();
let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy)
.expect("path with non-ASCII UTF-8");
assert_eq!(path.normalized().as_str().as_bytes(), raw);
assert_eq!(path.original().as_bytes(), raw);
assert_eq!(&ascii_lookup_key(raw).0[5..13], &raw[5..13]);
}
#[test]
fn accepts_non_utf8_legacy_bytes() {
let path = normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible)
.expect("raw legacy bytes");
assert_eq!(path.as_str(), "DATA/\u{FFFD}.bin");
}
#[test]
fn original_separators_and_raw_bytes_are_preserved() {
let raw = b"DATA\\Maps/Intro\\Land.msh";
let path = normalize_relative_with_original(raw, PathPolicy::StrictLegacy).expect("path");
assert_eq!(path.normalized().as_str(), "DATA/Maps/Intro/Land.msh");
assert_eq!(path.original().as_bytes(), raw);
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "fparkan-platform"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
raw-window-handle = "0.6"
[lints]
workspace = true
+233
View File
@@ -0,0 +1,233 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Platform ports for clocks, event sources and window descriptors.
use raw_window_handle::{RawDisplayHandle, RawWindowHandle};
/// Monotonic instant measured in milliseconds since process start.
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
pub struct MonotonicInstant(pub u64);
/// Platform clock.
pub trait MonotonicClock {
/// Current instant.
fn now(&self) -> MonotonicInstant;
}
/// Platform event.
#[derive(Clone, Debug, PartialEq)]
pub enum PlatformEvent {
/// Window/application requested to quit.
QuitRequested,
/// Window focus changed.
FocusChanged {
/// Whether the window is focused.
focused: bool,
},
/// Window resize or move to a new drawable size.
Resize {
/// Drawable width in physical pixels.
width: u32,
/// Drawable height in physical pixels.
height: u32,
},
/// Device pixel ratio changed.
DpiChanged {
/// Logical-to-physical scale factor.
scale: f64,
},
/// Window minimized/hidden.
Minimized {
/// Whether the window is minimized.
minimized: bool,
},
/// Window occlusion state changed.
Occluded {
/// Whether the window is occluded.
occluded: bool,
},
/// Window is being suspended.
Suspended,
/// Window resumed from suspend.
Resumed,
/// Keyboard/scancode input.
KeyboardInput {
/// Platform scancode.
scancode: u32,
/// Pressed state.
pressed: bool,
},
/// Mouse button input.
MouseInput {
/// Mouse button code.
button: u16,
/// Pressed state.
pressed: bool,
/// X position in window coordinates.
x: f64,
/// Y position in window coordinates.
y: f64,
},
/// Mouse cursor movement.
CursorMoved {
/// Cursor x.
x: f64,
/// Cursor y.
y: f64,
},
}
/// Platform error with optional source detail.
#[derive(Debug)]
pub enum PlatformError {
/// Backend/backend-specific failure.
Backend {
/// Operation or subsystem.
context: &'static str,
/// Human-readable details.
message: String,
},
}
impl std::fmt::Display for PlatformError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Backend { context, message } => {
write!(f, "{context}: {message}")
}
}
}
}
impl std::error::Error for PlatformError {}
/// Event source contract for polling platform events.
pub trait EventSource {
/// Polls events.
///
/// # Errors
///
/// Returns [`PlatformError`] when the backend cannot collect events.
fn poll(&mut self, out: &mut Vec<PlatformEvent>) -> Result<(), PlatformError>;
}
/// Physical window size.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct PhysicalSize {
/// Width.
pub width: u32,
/// Height.
pub height: u32,
}
/// Window identity as a stable opaque handle token.
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub struct WindowHandle {
/// Opaque integer token.
pub id: u64,
}
/// Native raw window/display handles for render surface creation.
#[derive(Clone, Copy, Debug)]
pub struct NativeWindowHandles {
/// Raw display handle.
pub display: RawDisplayHandle,
/// Raw window handle.
pub window: RawWindowHandle,
}
/// Window presentation and lifecycle port.
///
/// Presentation is not owned by the window abstraction. Render adapters
/// own swapchain and present lifecycle.
pub trait WindowPort {
/// Current drawable size.
fn drawable_size(&self) -> PhysicalSize;
/// DPI scale for this window.
fn dpi_scale(&self) -> f64;
/// Whether the window is focused.
fn has_focus(&self) -> bool;
/// Whether the window is minimized.
fn is_minimized(&self) -> bool;
/// Whether the window is occluded.
fn is_occluded(&self) -> bool;
/// Opaque window identity.
fn handle(&self) -> WindowHandle;
/// Raw native handles for render surface creation, when backed by a native window.
fn native_handles(&self) -> Option<NativeWindowHandles> {
None
}
}
/// Render backend request contract.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RenderRequest {
/// Preferred color-space profile.
pub color_space: ColorSpace,
/// Preferred presentation mode.
pub presentation: PresentationMode,
/// Requested depth/stencil format.
pub depth: DepthStencilSupport,
}
/// Color-space profile.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColorSpace {
/// sRGB nonlinear.
Srgb,
/// Linear color-space.
Linear,
}
/// Presentation mode.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PresentationMode {
/// `VSync`.
Fifo,
/// No `VSync`.
Immediate,
/// Triple-buffer mailbox fallback.
Mailbox,
}
/// Depth/stencil support profile requested by the composition root.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct DepthStencilSupport {
/// Depth bits.
pub depth_bits: u8,
/// Stencil bits.
pub stencil_bits: u8,
}
impl RenderRequest {
/// Returns a conservative default request.
#[must_use]
pub const fn conservative() -> Self {
Self {
color_space: ColorSpace::Srgb,
presentation: PresentationMode::Fifo,
depth: DepthStencilSupport {
depth_bits: 24,
stencil_bits: 8,
},
}
}
}
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "fparkan-prototype"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
encoding_rs = "0.8"
fparkan-binary = { path = "../fparkan-binary" }
fparkan-path = { path = "../fparkan-path" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-vfs = { path = "../fparkan-vfs" }
[lints]
workspace = true
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "fparkan-render"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-world = { path = "../fparkan-world" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "fparkan-resource"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-nres = { path = "../fparkan-nres" }
fparkan-path = { path = "../fparkan-path" }
fparkan-rsli = { path = "../fparkan-rsli" }
fparkan-vfs = { path = "../fparkan-vfs" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "fparkan-rsli"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
[package]
name = "fparkan-runtime"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-assets = { path = "../fparkan-assets" }
fparkan-path = { path = "../fparkan-path" }
fparkan-platform = { path = "../fparkan-platform" }
fparkan-prototype = { path = "../fparkan-prototype" }
fparkan-render = { path = "../fparkan-render" }
fparkan-resource = { path = "../fparkan-resource" }
fparkan-vfs = { path = "../fparkan-vfs" }
fparkan-world = { path = "../fparkan-world" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "fparkan-terrain-format"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-nres = { path = "../fparkan-nres" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "fparkan-terrain"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-terrain-format = { path = "../fparkan-terrain-format" }
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "fparkan-test-support"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-render = { path = "../fparkan-render" }
[lints]
workspace = true
+44
View File
@@ -0,0 +1,44 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Dev-only synthetic builders and fake ports.
use fparkan_render::{FrameOutput, RenderBackend, RenderCommandList, RenderError};
/// Fake clock.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct FakeClock {
/// Current tick.
pub tick: u64,
}
/// Recording backend.
#[derive(Clone, Debug, Default)]
pub struct RecordingRenderBackend {
/// Recorded command lists.
pub captures: Vec<RenderCommandList>,
}
impl RenderBackend for RecordingRenderBackend {
fn execute(&mut self, commands: &RenderCommandList) -> Result<FrameOutput, RenderError> {
self.captures.push(commands.clone());
Ok(FrameOutput)
}
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "fparkan-texm"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
[dev-dependencies]
fparkan-nres = { path = "../fparkan-nres" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "fparkan-vfs"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
fparkan-path = { path = "../fparkan-path" }
[lints]
workspace = true
+723
View File
@@ -0,0 +1,723 @@
#![forbid(unsafe_code)]
#![cfg_attr(
test,
allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_precision_loss,
clippy::expect_used,
clippy::float_cmp,
clippy::identity_op,
clippy::too_many_lines,
clippy::uninlined_format_args,
clippy::map_unwrap_or,
clippy::needless_raw_string_hashes,
clippy::semicolon_if_nothing_returned,
clippy::type_complexity,
clippy::panic,
clippy::unwrap_used
)
)]
//! Virtual filesystem ports for resource loading.
use fparkan_binary::{sha256, Sha256Digest};
use fparkan_path::{ascii_lookup_key, join_under, NormalizedPath};
use std::collections::BTreeMap;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(windows)]
use std::os::windows::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
/// VFS metadata.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VfsMetadata {
/// Byte length.
pub len: u64,
/// SHA-256 content fingerprint for cache invalidation.
pub fingerprint: Sha256Digest,
}
/// VFS entry.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct VfsEntry {
/// Path.
pub path: NormalizedPath,
/// Metadata.
pub metadata: VfsMetadata,
}
/// VFS error.
#[derive(Debug)]
pub enum VfsError {
/// Missing entry.
NotFound(String),
/// Ambiguous host path.
Ambiguous(String),
/// I/O error.
Io(std::io::Error),
/// Invalid path.
Path,
}
impl std::fmt::Display for VfsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(path) => write!(f, "not found: {path}"),
Self::Ambiguous(path) => write!(f, "ambiguous host path: {path}"),
Self::Io(err) => write!(f, "{err}"),
Self::Path => write!(f, "invalid path"),
}
}
}
impl std::error::Error for VfsError {}
/// Resource VFS.
pub trait Vfs: Send + Sync {
/// Reads metadata.
///
/// # Errors
///
/// Returns [`VfsError`] when the path is invalid, missing, or cannot be
/// inspected by the backing store.
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError>;
/// Reads bytes.
///
/// # Errors
///
/// Returns [`VfsError`] when the path is invalid, missing, or cannot be
/// read by the backing store.
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError>;
/// Lists entries below prefix.
///
/// # Errors
///
/// Returns [`VfsError`] when the prefix is invalid, missing, or cannot be
/// traversed by the backing store.
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError>;
}
/// Host directory VFS.
#[derive(Clone, Debug)]
pub struct DirectoryVfs {
root: PathBuf,
fingerprint_cache: Arc<Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>>,
}
impl DirectoryVfs {
/// Creates a directory VFS.
#[must_use]
pub fn new(root: impl AsRef<Path>) -> Self {
Self {
root: root.as_ref().to_path_buf(),
fingerprint_cache: Arc::default(),
}
}
fn host_path(&self, path: &NormalizedPath) -> Result<PathBuf, VfsError> {
join_under(&self.root, path).map_err(|_| VfsError::Path)?;
resolve_casefolded(&self.root, path.as_str())
}
fn metadata_from_host_file(&self, path: &Path) -> Result<VfsMetadata, VfsError> {
let metadata = fs::symlink_metadata(path).map_err(VfsError::Io)?;
metadata_from_host_file_with_cache(path, &metadata, &self.fingerprint_cache)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CachedHostFingerprint {
len: u64,
modified: Option<SystemTime>,
identity: Option<u64>,
fingerprint: Sha256Digest,
}
impl Vfs for DirectoryVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
self.metadata_from_host_file(&self.host_path(path)?)
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let host = self.host_path(path)?;
let pre_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
if pre_metadata.file_type().is_symlink() || !pre_metadata.is_file() {
return Err(VfsError::Path);
}
let pre_identity = file_identity(&pre_metadata);
let pre_len = pre_metadata.len();
let pre_modified = pre_metadata.modified().ok();
let bytes = fs::read(&host).map_err(VfsError::Io)?;
let post_metadata = fs::symlink_metadata(&host).map_err(VfsError::Io)?;
if post_metadata.file_type().is_symlink()
|| !post_metadata.is_file()
|| post_metadata.len() != pre_len
|| post_metadata.modified().ok() != pre_modified
|| file_identity(&post_metadata) != pre_identity
{
return Err(VfsError::Path);
}
Ok(Arc::from(bytes.into_boxed_slice()))
}
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
let base = self.host_path(prefix)?;
let mut entries = Vec::new();
if base.is_file() {
let metadata = fs::symlink_metadata(&base).map_err(VfsError::Io)?;
entries.push(VfsEntry {
path: prefix.clone(),
metadata: metadata_from_host_file_with_cache(
&base,
&metadata,
&self.fingerprint_cache,
)?,
});
return Ok(entries);
}
list_recursive(&self.root, &base, &self.fingerprint_cache, &mut entries)?;
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
Ok(entries)
}
}
fn resolve_casefolded(root: &Path, normalized: &str) -> Result<PathBuf, VfsError> {
let mut current = root.to_path_buf();
for segment in normalized.split('/') {
let read_dir = fs::read_dir(&current).map_err(VfsError::Io)?;
let mut matches = Vec::new();
for entry in read_dir {
let entry = entry.map_err(VfsError::Io)?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if name.eq_ignore_ascii_case(segment) {
if entry.file_type().map_err(VfsError::Io)?.is_symlink() {
return Err(VfsError::Path);
}
matches.push(entry.path());
}
}
current = select_casefolded_match(normalized, &current, segment, matches)?;
}
Ok(current)
}
fn select_casefolded_match(
normalized: &str,
current: &Path,
segment: &str,
mut matches: Vec<PathBuf>,
) -> Result<PathBuf, VfsError> {
matches.sort();
match matches.len() {
0 => Err(VfsError::NotFound(normalized.to_string())),
1 => Ok(matches.remove(0)),
_ => Err(VfsError::Ambiguous(format!(
"{}/{}",
current.display(),
segment
))),
}
}
fn list_recursive(
root: &Path,
dir: &Path,
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
out: &mut Vec<VfsEntry>,
) -> Result<(), VfsError> {
let read_dir = fs::read_dir(dir).map_err(VfsError::Io)?;
let mut children = Vec::new();
for entry in read_dir {
let entry = entry.map_err(VfsError::Io)?;
children.push(entry.path());
}
children.sort();
for child in children {
let metadata = fs::symlink_metadata(&child).map_err(VfsError::Io)?;
if metadata.file_type().is_symlink() {
return Err(VfsError::Path);
}
if metadata.is_dir() {
list_recursive(root, &child, fingerprint_cache, out)?;
continue;
}
if !metadata.is_file() {
continue;
}
let rel = child.strip_prefix(root).map_err(|_| VfsError::Path)?;
let rel_text = rel.to_str().ok_or(VfsError::Path)?;
let path = fparkan_path::normalize_relative(
rel_text.as_bytes(),
fparkan_path::PathPolicy::HostCompatible,
)
.map_err(|_| VfsError::Path)?;
out.push(VfsEntry {
path,
metadata: metadata_from_host_file_with_cache(&child, &metadata, fingerprint_cache)?,
});
}
Ok(())
}
fn metadata_from_host_file_with_cache(
path: &Path,
metadata: &fs::Metadata,
fingerprint_cache: &Mutex<BTreeMap<PathBuf, CachedHostFingerprint>>,
) -> Result<VfsMetadata, VfsError> {
if !metadata.is_file() {
return Err(VfsError::Path);
}
let len = metadata.len();
let modified = metadata.modified().ok();
if let Some(cached) = fingerprint_cache
.lock()
.map_err(|_| VfsError::Path)?
.get(path)
.cloned()
.filter(|cached| {
cached.len == len
&& cached.modified == modified
&& cached.identity == file_identity(metadata)
})
{
return Ok(VfsMetadata {
len,
fingerprint: cached.fingerprint,
});
}
let bytes = fs::read(path).map_err(VfsError::Io)?;
let fingerprint = sha256(&bytes);
fingerprint_cache
.lock()
.map_err(|_| VfsError::Path)?
.insert(
path.to_path_buf(),
CachedHostFingerprint {
len,
modified,
identity: file_identity(metadata),
fingerprint,
},
);
Ok(VfsMetadata { len, fingerprint })
}
/// In-memory VFS.
#[derive(Clone, Debug, Default)]
pub struct MemoryVfs {
files: BTreeMap<Vec<u8>, Arc<[u8]>>,
lookup: BTreeMap<Vec<u8>, Vec<Vec<u8>>>,
}
impl MemoryVfs {
/// Inserts a file.
#[allow(clippy::needless_pass_by_value)]
pub fn insert(&mut self, path: NormalizedPath, bytes: Arc<[u8]>) {
let path = path.as_bytes().to_vec();
self.files.insert(path, bytes);
self.rebuild_lookup();
}
fn rebuild_lookup(&mut self) {
self.lookup.clear();
for path in self.files.keys() {
self.lookup
.entry(ascii_lookup_key(path).0)
.or_default()
.push(path.clone());
}
for paths in self.lookup.values_mut() {
paths.sort();
}
}
fn resolve_path(&self, path: &NormalizedPath) -> Result<&[u8], VfsError> {
let key = ascii_lookup_key(path.as_bytes()).0;
let matches = self
.lookup
.get(&key)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
match matches.as_slice() {
[single] => Ok(single.as_slice()),
[] => Err(VfsError::NotFound(path.as_str().to_string())),
_ => Err(VfsError::Ambiguous(path.as_str().to_string())),
}
}
}
#[cfg(unix)]
#[allow(clippy::unnecessary_wraps)]
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
Some(metadata.dev().rotate_left(32) ^ metadata.ino())
}
#[cfg(windows)]
#[allow(clippy::unnecessary_wraps)]
fn file_identity(metadata: &fs::Metadata) -> Option<u64> {
Some(
(metadata.volume_serial_number() as u64).rotate_left(40)
^ ((metadata.file_index_high() as u64) << 32)
^ metadata.file_index_low() as u64,
)
}
#[cfg(not(any(unix, windows)))]
fn file_identity(_metadata: &fs::Metadata) -> Option<u64> {
None
}
impl Vfs for MemoryVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
let resolved = self.resolve_path(path)?;
let bytes = self
.files
.get(resolved)
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))?;
Ok(VfsMetadata {
len: bytes.len() as u64,
fingerprint: sha256(bytes),
})
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
let resolved = self.resolve_path(path)?;
self.files
.get(resolved)
.cloned()
.ok_or_else(|| VfsError::NotFound(path.as_str().to_string()))
}
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
let mut out = Vec::new();
for (path, bytes) in &self.files {
if has_segment_boundary_prefix_bytes(path, prefix.as_bytes()) {
let normalized =
fparkan_path::normalize_relative(path, fparkan_path::PathPolicy::StrictLegacy)
.map_err(|_| VfsError::Path)?;
out.push(VfsEntry {
path: normalized,
metadata: VfsMetadata {
len: bytes.len() as u64,
fingerprint: sha256(bytes),
},
});
}
}
Ok(out)
}
}
fn has_segment_boundary_prefix_bytes(haystack: &[u8], needle: &[u8]) -> bool {
if haystack.len() < needle.len() {
return false;
}
if haystack.len() == needle.len() {
return haystack
.iter()
.zip(needle.iter())
.all(|(left, right)| left.eq_ignore_ascii_case(right));
}
if haystack[needle.len()] != b'/' {
return false;
}
haystack[..needle.len()]
.iter()
.zip(needle.iter())
.all(|(left, right)| left.eq_ignore_ascii_case(right))
}
/// Layered VFS with deterministic first-layer precedence.
#[derive(Clone, Default)]
pub struct OverlayVfs {
layers: Vec<Arc<dyn Vfs>>,
}
impl std::fmt::Debug for OverlayVfs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OverlayVfs")
.field("layers", &self.layers.len())
.finish()
}
}
impl OverlayVfs {
/// Creates an empty overlay.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Creates an overlay from ordered layers.
#[must_use]
pub fn from_layers(layers: Vec<Arc<dyn Vfs>>) -> Self {
Self { layers }
}
/// Appends a lower-priority layer.
pub fn push_layer(&mut self, layer: Arc<dyn Vfs>) {
self.layers.push(layer);
}
}
impl Vfs for OverlayVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
for layer in &self.layers {
match layer.metadata(path) {
Ok(metadata) => return Ok(metadata),
Err(VfsError::NotFound(_)) => {}
Err(err) => return Err(err),
}
}
Err(VfsError::NotFound(path.as_str().to_string()))
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
for layer in &self.layers {
match layer.read(path) {
Ok(bytes) => return Ok(bytes),
Err(VfsError::NotFound(_)) => {}
Err(err) => return Err(err),
}
}
Err(VfsError::NotFound(path.as_str().to_string()))
}
fn list(&self, prefix: &NormalizedPath) -> Result<Vec<VfsEntry>, VfsError> {
let mut by_key = BTreeMap::new();
for layer in &self.layers {
match layer.list(prefix) {
Ok(entries) => {
for entry in entries {
let key = entry.path.as_str().to_ascii_uppercase();
by_key.entry(key).or_insert(entry);
}
}
Err(VfsError::NotFound(_)) => {}
Err(err) => return Err(err),
}
}
let mut entries: Vec<_> = by_key.into_values().collect();
entries.sort_by(|a, b| a.path.as_str().cmp(b.path.as_str()));
Ok(entries)
}
}
#[cfg(test)]
mod tests {
use super::*;
use fparkan_path::{normalize_relative, PathPolicy};
#[test]
fn directory_vfs_resolves_ascii_casefolded_segments() {
let root = unique_test_dir("casefold");
let dir = root.join("data").join("MAPS").join("Tut_1");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(dir.join("Land.msh"), b"mesh").expect("write");
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"DATA/maps/tut_1/land.MSH", PathPolicy::StrictLegacy)
.expect("path");
assert_eq!(vfs.read(&path).expect("read").as_ref(), b"mesh");
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn directory_vfs_reports_casefold_ambiguity_even_for_exact_host_path() {
let root = unique_test_dir("casefold-ambiguous");
std::fs::create_dir_all(root.join("Data")).expect("mkdir first");
std::fs::create_dir_all(root.join("data")).expect("mkdir second");
std::fs::write(root.join("Data").join("File.bin"), b"first").expect("write first");
std::fs::write(root.join("data").join("File.bin"), b"second").expect("write second");
let collision_count = std::fs::read_dir(&root)
.expect("read root")
.flatten()
.filter(|entry| {
entry
.file_name()
.to_str()
.is_some_and(|name| name.eq_ignore_ascii_case("data"))
})
.count();
if collision_count < 2 {
std::fs::remove_dir_all(root).expect("cleanup");
return;
}
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
assert!(matches!(vfs.read(&path), Err(VfsError::Ambiguous(_))));
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn directory_vfs_lists_files_below_prefix() {
let root = unique_test_dir("list");
std::fs::create_dir_all(root.join("DATA").join("MAPS")).expect("mkdir");
std::fs::write(root.join("DATA").join("MAPS").join("Land.map"), b"map").expect("write");
std::fs::write(root.join("BuildDat.lst"), b"build").expect("write");
let vfs = DirectoryVfs::new(&root);
let prefix = normalize_relative(b"data", PathPolicy::StrictLegacy).expect("prefix");
let entries = vfs.list(&prefix).expect("list");
assert_eq!(entries.len(), 1);
assert!(entries[0]
.path
.as_str()
.eq_ignore_ascii_case("DATA/MAPS/Land.map"));
std::fs::remove_dir_all(root).expect("cleanup");
}
#[test]
fn memory_vfs_list_prefix_is_boundary_safe() {
let mut vfs = MemoryVfs::default();
let exact = normalize_relative(b"DATA/Land.map", PathPolicy::StrictLegacy).expect("path");
let sibling =
normalize_relative(b"DATA2/Land.map", PathPolicy::StrictLegacy).expect("path");
vfs.insert(exact.clone(), Arc::from(b"exact".as_slice()));
vfs.insert(sibling, Arc::from(b"sibling".as_slice()));
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
let entries = vfs.list(&prefix).expect("list");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path.as_str(), exact.as_str());
}
#[test]
fn directory_vfs_fingerprint_changes_for_same_length_content() {
let root = unique_test_dir("content-fingerprint");
std::fs::create_dir_all(root.join("DATA")).expect("mkdir");
std::fs::write(root.join("DATA").join("File.bin"), b"before").expect("write before");
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
let before = vfs.metadata(&path).expect("before metadata");
std::fs::write(root.join("DATA").join("File.bin"), b"after!").expect("write after");
let after = vfs.metadata(&path).expect("after metadata");
assert_eq!(before.len, after.len);
assert_ne!(before.fingerprint, after.fingerprint);
std::fs::remove_dir_all(root).expect("cleanup");
}
#[cfg(unix)]
#[test]
fn directory_vfs_rejects_symlink_escape() {
let root = unique_test_dir("symlink-escape");
let outside = unique_test_dir("symlink-outside");
std::fs::create_dir_all(&root).expect("mkdir root");
std::fs::create_dir_all(&outside).expect("mkdir outside");
std::fs::write(outside.join("secret.bin"), b"secret").expect("write outside");
std::os::unix::fs::symlink(&outside, root.join("DATA")).expect("symlink");
let vfs = DirectoryVfs::new(&root);
let path = normalize_relative(b"DATA/secret.bin", PathPolicy::StrictLegacy).expect("path");
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
assert!(matches!(vfs.read(&path), Err(VfsError::Path)));
assert!(matches!(vfs.list(&prefix), Err(VfsError::Path)));
std::fs::remove_dir_all(root).expect("cleanup root");
std::fs::remove_dir_all(outside).expect("cleanup outside");
}
#[test]
fn casefold_selector_reports_ambiguous_segments() {
let err = select_casefolded_match(
"data/file.bin",
Path::new("/game"),
"data",
vec![PathBuf::from("/game/Data"), PathBuf::from("/game/DATA")],
)
.expect_err("ambiguous path");
assert!(matches!(err, VfsError::Ambiguous(_)));
}
#[test]
fn memory_vfs_uses_ascii_casefold_lookup() {
let path = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("path");
let mut vfs = MemoryVfs::default();
vfs.insert(path.clone(), Arc::from(b"payload".as_slice()));
assert_eq!(vfs.metadata(&path).expect("metadata").len, 7);
assert_eq!(vfs.read(&path).expect("read").as_ref(), b"payload");
let other_case =
normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("path");
assert_eq!(
vfs.read(&other_case).expect("casefold read").as_ref(),
b"payload"
);
}
#[test]
fn memory_vfs_reports_casefold_ambiguity() {
let first = normalize_relative(b"Data/File.bin", PathPolicy::StrictLegacy).expect("first");
let second =
normalize_relative(b"DATA/file.BIN", PathPolicy::StrictLegacy).expect("second");
let query = normalize_relative(b"data/file.bin", PathPolicy::StrictLegacy).expect("query");
let mut vfs = MemoryVfs::default();
vfs.insert(first, Arc::from(b"first".as_slice()));
vfs.insert(second, Arc::from(b"second".as_slice()));
assert!(matches!(vfs.read(&query), Err(VfsError::Ambiguous(_))));
}
#[test]
fn memory_vfs_distinguishes_non_utf8_path_bytes() {
let mut vfs = MemoryVfs::default();
let ascii =
normalize_relative(b"DATA/normal.bin", PathPolicy::HostCompatible).expect("ascii path");
let binary =
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary path");
vfs.insert(ascii.clone(), Arc::from(b"ascii".as_slice()));
vfs.insert(binary.clone(), Arc::from(b"binary".as_slice()));
let binary_query =
normalize_relative(b"DATA/\xFF.bin", PathPolicy::HostCompatible).expect("binary query");
assert_eq!(
vfs.read(&binary_query).expect("read binary").as_ref(),
b"binary"
);
assert_eq!(vfs.read(&ascii).expect("read ascii").as_ref(), b"ascii");
}
#[test]
fn overlay_vfs_uses_first_matching_layer() {
let path = normalize_relative(b"DATA/File.bin", PathPolicy::StrictLegacy).expect("path");
let prefix = normalize_relative(b"DATA", PathPolicy::StrictLegacy).expect("prefix");
let mut high = MemoryVfs::default();
let mut low = MemoryVfs::default();
high.insert(path.clone(), Arc::from(b"high".as_slice()));
low.insert(path.clone(), Arc::from(b"low".as_slice()));
let overlay = OverlayVfs::from_layers(vec![Arc::new(high), Arc::new(low)]);
assert_eq!(overlay.read(&path).expect("read").as_ref(), b"high");
let entries = overlay.list(&prefix).expect("list");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].metadata.len, 4);
}
fn unique_test_dir(name: &str) -> PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("fparkan-vfs-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
path
}
}
+12
View File
@@ -0,0 +1,12 @@
[package]
name = "fparkan-world"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
fparkan-binary = { path = "../fparkan-binary" }
[lints]
workspace = true
File diff suppressed because it is too large Load Diff
-10
View File
@@ -1,10 +0,0 @@
[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"] }
-42
View File
@@ -1,42 +0,0 @@
# 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
```
-110
View File
@@ -1,110 +0,0 @@
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,
}
}
}
-702
View File
@@ -1,702 +0,0 @@
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;
-996
View File
@@ -1,996 +0,0 @@
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);
}
-8
View File
@@ -1,8 +0,0 @@
[package]
name = "rsli"
version = "0.1.0"
edition = "2021"
[dependencies]
common = { path = "../common" }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
-58
View File
@@ -1,58 +0,0 @@
# 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
```
-14
View File
@@ -1,14 +0,0 @@
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)
}
-298
View File
@@ -1,298 +0,0 @@
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)
}
}
-79
View File
@@ -1,79 +0,0 @@
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)
}
-9
View File
@@ -1,9 +0,0 @@
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};
-29
View File
@@ -1,29 +0,0 @@
/// 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()
}
-140
View File
@@ -1,140 +0,0 @@
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,
}
}
}
-411
View File
@@ -1,411 +0,0 @@
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;
-267
View File
@@ -1,267 +0,0 @@
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())
}
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
# Глоссарий
Глоссарий объясняет термины в том смысле, в котором они используются в этой
книге. Короткое определение не заменяет профильную главу: практический контракт
понятия раскрывается в соответствующем томе или справочной странице.
## Бинарные файлы и ABI
**PE (Portable Executable)** -- формат исполняемых файлов Windows: EXE и DLL.
Он содержит заголовки, секции, таблицы импортов и экспортов, relocations и
адрес точки входа.
**Image base** -- предпочтительный адрес начала загруженного PE-образа.
**VA** -- виртуальный адрес в процессе. **RVA** -- адрес относительно image
base.
**Import** -- внешняя функция или переменная, которую модуль получает из другой
DLL. **Export** -- символ, предоставляемый другим модулям. Имя, ordinal и
calling convention вместе образуют часть binary contract.
**ABI** -- соглашение о двоичном взаимодействии: размещение аргументов, возврат
значений, очистка stack, layout структур, порядок virtual methods и правила
владения.
**Calling convention** -- часть ABI, определяющая передачу аргументов и очистку
stack. Для исследованного 32-bit code важны `__cdecl`, `__stdcall` и
`__thiscall`.
**Vtable** -- массив указателей на virtual methods C++-объекта. Запись
`vtable +0x34` означает вызов указателя по байтовому смещению `0x34` от начала
таблицы.
**Static analysis** исследует файл без исполнения: disassembly, strings,
imports, call graph и data flow. **Dynamic analysis** наблюдает работающую
программу: breakpoints, traces, API hooks, memory state и packet/frame captures.
**Evidence** -- повторяемое наблюдение. **Inference** -- вывод, объединяющий
несколько наблюдений. **Hypothesis** -- рабочее предположение, ещё не
подтверждённое достаточным экспериментом.
## Форматы данных
**Archive** -- контейнер, объединяющий множество ресурсов. **Entry** -- запись
его каталога. **Payload** -- полезные bytes конкретной записи.
**Magic** -- короткая сигнатура формата, например `NRes` или `Texm`.
**Version** -- номер варианта layout. Проверка одной magic без проверки version
и размеров недостаточна.
**Offset** -- положение данных относительно начала файла или структуры.
**Size** -- число bytes. **Stride** -- размер одного элемента массива.
**Alignment** -- требование начинать данные на offset, кратном заданному числу.
**Little-endian** -- порядок, в котором младший byte многобайтного числа
расположен первым. Основные числовые поля форматов Iron3D используют этот
порядок.
**Fixed-size string** -- поле заранее известной длины. Полезная строка
заканчивается первым NUL, но оставшиеся bytes могут содержать служебный хвост и
должны сохраняться.
**Opaque field** -- поле с доказанными offset и size, но не установленным
предметным смыслом. Его безопасно читать и копировать, но нельзя очищать или
переосмысливать без эксперимента.
**Invariant** -- условие, которое обязано выполняться: range лежит внутри
payload, индекс указывает на существующий элемент, count соответствует размеру
секции.
**Strict reader** отклоняет любое нарушение контракта. **Compatibility reader**
дополнительно воспроизводит только известные особенности оригинала.
**Fallback** -- явно предписанный запасной путь, например material `DEFAULT`,
затем entry 0. **Heuristic** -- догадка по похожим данным; она не должна
незаметно заменять доказанный fallback.
**Roundtrip** -- последовательность decode -> encode. **Byte-identical
roundtrip** создаёт файл, полностью совпадающий с исходным. **Lossless editor**
может изменить известное поле, сохранив все остальные bytes и порядок записей.
## Ресурсы
**NRes** -- основной контейнер ресурсов с каталогом в конце файла.
**RsLi** -- библиотечный архив с каталогом в начале файла и несколькими методами
упаковки payload.
**TMA** -- mission data: paths, clans, placed objects, properties, land path и
extras.
**MSH** -- модель Iron3D, представленная как NRes с entries для geometry,
nodes, slots, batches, animation и auxiliary streams.
**WEAR** -- таблица внешнего вида модели, переводящая material index в MAT0
name и lightmap slots.
**MAT0** -- материал: phases, parameters, animation blocks и texture references.
**Texm** -- texture payload с header, palette, mip chain и optional Page atlas.
**FXID** -- ресурс эффектов: команды, references, lifetime, random/time modes и
runtime instances.
## Игровой runtime
**Engine** -- программная среда, которая загружает данные, ведёт время,
исполняет мир и формирует изображение/звук. **Game** -- правила, миссии и
content поверх engine services.
**World** -- долгоживущее состояние миссии: objects, terrain, время, кланы и
managers. **Scene** -- представление части мира для конкретной обработки,
обычно текущей камеры.
**Game object** -- сущность с идентичностью, transform, properties и lifecycle.
**Component/controller** -- специализированная часть поведения: animation,
physics, AI или rendering representation.
**Simulation** отвечает за изменение мира. **Tick** -- один расчётный шаг.
**Frame** -- одно подготовленное изображение. Число ticks и frames за единицу
времени не обязано совпадать.
**Event/message** -- типизированное сообщение между objects или subsystems.
**Queue traversal** -- стабильный обход зарегистрированных объектов.
**Deferred deletion** -- перенос фактического удаления до безопасной границы.
**Snapshot** -- согласованное состояние, которое renderer читает без изменения
simulation. **Determinism** -- одинаковый результат при одинаковом initial
state, input, времени и порядке событий.
**Authority** -- subsystem или network peer, которому разрешено окончательно
менять состояние объекта. **Mirror object** -- локальное представление объекта,
authority которого находится у другого player.
## Геометрия и рендеринг
**Vertex** -- вершина geometry. **Index** -- номер вершины. **Triangle** --
примитив из трёх индексов.
**Node** -- элемент hierarchy модели со своим local transform. **Slot** в MSH
-- выбранная геометрическая группа для комбинации node, LOD и group. **Batch**
-- непрерывный индексный диапазон с material slot и render state.
**Transform** переводит данные между coordinate spaces. **Matrix** задаёт
линейное преобразование и translation. Порядок умножения matrices является
частью контракта.
**Bounds** -- упрощённый объём для быстрых тестов. **AABB** -- min/max по осям.
**Bounding sphere** -- center и radius.
**Renderer** преобразует подготовленную сцену в изображение. **Backend** --
реализация поверх конкретного API или устройства.
**Draw call** -- команда нарисовать диапазон primitives. **Indexed draw**
использует index buffer и base vertex.
**Material phase** -- одно временное состояние анимированного материала.
**Texture** -- двумерный массив texels. **Mip chain** -- последовательность
уменьшенных уровней texture. **Atlas** -- texture с несколькими под-
изображениями.
**Fixed-function pipeline** -- старый graphics pipeline, где приложение
выбирает predefined transform, lighting, texture-stage и blend states вместо
пользовательских shaders.
**Depth test**, **culling**, **alpha test** и **blending** -- render states,
которые влияют на порядок и видимость fragments.
**Pixel parity** -- совпадение конечного изображения при фиксированных camera,
time, seed, resolution и device profile.
## Навигация, звук и сеть
**Areal** -- логическая область карты с границей, class/flags и связями с
соседями. **Areal graph** -- граф областей и переходов. **Cell grid** --
пространственный индекс для быстрых candidate queries.
**Pathfinding** -- поиск маршрута по graph. **Corridor** -- локальная полоса,
построенная из последовательности areals. **Local steering** корректирует
ближайший шаг внутри corridor.
**Collision proxy** -- упрощённое представление объекта для столкновений.
**Broad phase** быстро находит потенциальные пары. **Narrow phase** выполняет
точную проверку и вычисляет contact.
**Sample** -- декодированные звуковые данные. **Source** -- конкретный
экземпляр воспроизведения с position, gain, loop state и временем. **Listener**
-- положение и ориентация слушателя для 3D spatialization.
**Transport** -- механизм доставки bytes между peers. **Protocol** -- framing,
message types, порядок и правила подтверждения. **Wire compatibility** --
способность обмениваться данными с оригинальным клиентом.
**Serialization** -- преобразование typed state в byte sequence. **Framing** --
способ отделить одно сообщение от следующего. **Reliable delivery** гарантирует
доставку/порядок в пределах выбранной модели; **unreliable delivery** допускает
потери ради задержки.
**Player ID** транспорта и **game player number** -- разные идентичности.
**Ownership transfer** меняет authority объекта. **Replication** передаёт
состояние или события remote mirrors.
+153
View File
@@ -0,0 +1,153 @@
# Границы знания
Этот раздел перечисляет области, где контракт ещё не закрыт полностью. Они не
мешают безопасному чтению и lossless сохранению, но не должны превращаться в
authoring API без динамического подтверждения.
## Render state
Доказаны frame boundaries, world traversal, material resolve и крупные проходы.
Не доказаны символами точные имена renderer vtable slots, полный набор CShade
state transitions и окончательный порядок части transparent/FX/shadow subpasses.
Закрывающий эксперимент: запустить оригинал в совместимой Windows/DirectX
среде, перехватить DirectDraw/Direct3D calls и surface flips, сохранить state
log на минимальных сценах с одним типом материала.
## FXID field-level semantics
Размеры команд, resource references, lifecycle, flags families и используемые
time modes известны. Не закрыто значение каждого поля body opcodes 1--10,
отсутствующий во всех проверенных каталогах opcode 6 и точные формулы редких
time modes.
Закрывающий эксперимент: изменять по одному полю копии эффекта, воспроизводить
его в контролируемой сцене и логировать runtime command object, emitted
primitives, sound events и reads в `Effect.dll`.
## Script VM
Доступны packages, symbols, event sections, variable declarations и version
checks. Полная instruction grammar `.scr`, semantics opcodes и serialization
state ещё не восстановлены.
Закрывающий эксперимент: найти dispatcher loop в `ai.dll`, сопоставить jump
table с instruction sizes, построить disassembler и сравнить выполнение
коротких scripts с оригиналом.
## Saves and campaign state
Найдены `saveslots.cfg` и `missions/dispatcher.ini`, но binary savegame payload,
serialization World3D/AI/script/RNG и migration rules не закрыты.
Нужны сохранения оригинала в контролируемых состояниях: старт миссии, изменение
позиции, здоровья, order/path, FX/timer, script variable, research/economy,
mission completion, pause и non-default game time.
## Physical/control formats
CTLD и связанные resources структурно читаются, count patterns и variants
известны. Не названы все секции, shape types, coefficients и точный contact
solver. То же относится к редким MSH auxiliary streams и части CTPT/NDPR flags.
Закрывающий эксперимент: трассировать `LoadControlSystem`,
`LoadPhysicalModel`, `CreateCollManager` и создание collision objects; связать
каждый изменяемый field с созданным shape, contact или реакцией на движение.
## DirectPlay wire
DirectPlay lifecycle и имена игровых messages известны. Wire framing, payload
schema, reliability flags и `netZipData` требуют записи обмена двух
оригинальных клиентов.
Native interoperability подтверждается только успешным обменом original client
<-> compatibility implementation в обе стороны.
## Shell, HUD, шрифты и локализация
Граница shell подтверждена exports `createShell/getIShell`, `IGUIServer`,
верхнеуровневым UI-pass и файлами `ui/*.cfg`, `DATA/TextRes.cfg`,
`gamefont.rlb` и `sprites.lib`. RsLi framing библиотек закрыт, но widget tree,
layout rules, glyph metrics, sprite command semantics, focus/navigation и HUD
state machine пока не восстановлены до field-level спецификации.
Закрывающий эксперимент: трассировать загрузку `shell_ctrls.cfg`,
`menu_resources.cfg`, `cursor.cfg`, `game_resources.cfg` и `hq.cfg`, сопоставить
GUI object factories и снять command/event captures для меню, HUD, briefing и
диалогов.
## Research, economy and properties
Экспорты `LoadResearch`, `CalcFullResearchCost`, TRF/preload resources и TMA
properties доказывают отдельный слой исследований, стоимости, добычи и
производственных параметров. Формулы стоимости, dependency graph технологий,
inventory/economy transitions и точная типизация всех 16-byte property values
не закрыты.
Закрывающий эксперимент: сопоставить research functions с ресурсами и UI,
снять изменения state на контролируемых покупках/исследованиях и построить
typed schema свойств по consumers, а не по одному имени.
## Rare branches
- `Land.map poly_count > 0`;
- RsLi adaptive methods `0x080` и `0x0A0`;
- Texm formats 556 и 88;
- FX opcode 6;
- редкие material flags и MSH auxiliary streams.
Такие ветки реализуются по бинарному коду и synthetic tests, а статус
corpus-verified получают только после реального файла или runtime trace.
## Dynamic-stage requirements
Оставшиеся вопросы нельзя закрыть только статическими архивами. Нужна
изолированная 32-bit Windows-среда, неизменённые игровые каталоги, manifest
SHA-256, debugger, API/vtable hooks, controlled clocks/input и автоматический
launcher, который восстанавливает snapshot, запускает один test case, собирает
логи и завершает процесс без ручного вмешательства.
Для каждого capture сохраняются build profile, module hashes, mission/resource
key, configuration, device profile, initial state, input/time script и версии
инструментов.
## Local evidence requests
На текущем рабочем месте закрыты статические, corpus и headless runtime gates.
Для локально воспроизводимого Desktop backend подтверждено только command/state trace
в существующем GL-воркфлоу:
- `fixtures/acceptance/macos-gl33-triangle-capture.json`;
`S3-GL-001` пока не закрыт: текущая evidence не отражает полноценный
`winit`+`fparkan-render-vulkan` path с real surface/present pipeline.
Для закрытия требования требуется постоянный workspace-владельческий backend на
`winit`/`fparkan-platform-winit` + `fparkan-render-vulkan` с реальным
surface/present pipeline, command/state parity и licensed frame capture.
Для повышения `S3-GL-002` до `covered` всё ещё нужен воспроизводимый GLES2
backend profile: GLES2 должен создать кадр, сохранить pixel capture и тот же
command/state trace. Локальный Docker probe существующего Rust image не нашёл
`libGL`, `libEGL`, `libGLES` или `libOSMesa`, поэтому закрытие этого gate требует
отдельно предоставленного Docker image с Rust + Mesa/EGL/OSMesa либо разрешения
на установку соответствующего проверочного окружения.
Для текущей macOS-focused цели `S3-GL-002`, `L3-DEVICE-001` и `L5-RG40-001`
помечены как `omitted`: они остаются требованиями portable target scope, но не
блокируют локальный macOS acceptance-аудит. При возврате RG40XX/GLES2 в область
цели эти gates снова должны требовать внешнего evidence.
`L3-DEVICE-001` и `L5-RG40-001` не закрываются локально без RG40XX H или
эквивалентного удалённого runner-а. Требуемое доказательство: запуск выбранной
миссии при 640x480 на целевом профиле, сохранённые stdout/stderr, build
fingerprint, manifest игрового каталога, frame/tick budget, memory budget и
итоговый pass/fail report. Desktop/headless результаты не считаются заменой
on-device smoke.
## Closure criteria
Вопрос считается закрытым только при наличии build fingerprint, raw trace,
parser trace-а, минимального воспроизводимого input/resource/save/message,
формального контракта или явно ограниченной гипотезы, differential test для
изменённых DLL, обновления тематической главы и regression case, запускаемого
без ручного анализа.
+15
View File
@@ -0,0 +1,15 @@
# Current Project Audit
Baseline command:
```text
cargo xtask ci
```
Result on 2026-06-23:
- canonical pipeline now uses a fixed MSRV/toolchain, policy checks,
full-format workspace test command, `clippy`/`doc`/`cargo deny` gates and
typed manifest parsing in `xtask`;
- `rpath`/offline mode is still useful for synthetic local checks;
- full online dependency resolution remains unavailable in the sandbox.
+46 -12
View File
@@ -1,17 +1,51 @@
# Welcome to MkDocs
# FParkan
For full documentation visit [mkdocs.org](https://www.mkdocs.org).
FParkan -- самостоятельная техническая книга о восстановлении игрового движка
Iron3D из *Parkan: Iron Strategy*. Она ведёт от запуска оригинальной программы
и карты DLL к форматам ресурсов, загрузке миссии, геометрии, материалам,
рендеру, поведению, звуку, сети и плану чистой совместимой реализации.
## Commands
Сайт оформлен как онлайн-книга: тома читаются последовательно, а справочник
используется как быстрый доступ к форматам, проверочным правилам и границам
доказанного знания.
* `mkdocs new [dir-name]` - Create a new project.
* `mkdocs serve` - Start the live-reloading docs server.
* `mkdocs build` - Build the documentation site.
* `mkdocs -h` - Print help message and exit.
## Как читать
## Project layout
Если вы впервые разбираете игровой движок, начните с тома I и II. Там вводится
лексика, доказательная политика, модульная архитектура и жизненный цикл кадра.
mkdocs.yml # The configuration file.
docs/
index.md # The documentation homepage.
... # Other markdown pages, images and other files.
Если нужна реализация совместимого движка, читайте тома III--VII линейно:
ресурсы, миссии, мир, рендер, интерактивные подсистемы и порядок работ.
Если вы проверяете выводы, переходите к тому VIII и приложениям. Там собраны
уровни уверенности, corpus gates, открытые вопросы и критерии закрытия.
## Восемь томов
1. **Путеводитель и методика** -- назначение книги, маршруты чтения, язык
предметной области и правила проверки.
2. **Запуск, архитектура и игровой цикл** -- `iron_3d.exe`, пятнадцать DLL,
сервисы, World3D, очередь объектов и границы кадра.
3. **Ресурсная система и форматы** -- NRes, RsLi, кэши, имена, `objects.rlb`,
unit DAT и сквозное разрешение ресурсов.
4. **Мир, миссии и runtime** -- TMA, ландшафт, ареалы, маршруты, создание мира
и свойства размещённых объектов.
5. **Геометрия, материалы и рендер** -- MSH, анимация, WEAR, MAT0, Texm, FXID,
свет, атмосфера и полный render frame.
6. **Поведение, управление, звук и сеть** -- AI, Behavior, Wizard, Control,
ввод, камера, звук и DirectPlay-слой.
7. **Руководство по полной реализации** -- целевая архитектура, этапы работ,
тестовый контур, точность, скорость и критерий совместимости.
8. **Справочник и доказательная база** -- ABI, конфигурация, статистика
корпусов, границы знания и глоссарий.
## Политика доказательств
Специфические утверждения об Iron3D принимаются только после локальной проверки
на исполняемых файлах, DLL, демоверсии, полных каталогах Частей 1 и 2 или на
взаимных инвариантах реальных ресурсов. Внешние описания и текущий код FParkan
могут подсказывать вопросы, но не заменяют проверку.
Неизвестные поля не получают правдоподобных имён. Пока смысл не закрыт,
документация фиксирует raw layout, границы, безопасное чтение и lossless
сохранение.

Some files were not shown because too many files have changed in this diff Show More