174 Commits

Author SHA1 Message Date
e4317c66f5 chore(deps): update actions/upload-artifact action to v6
Some checks failed
Test / Lint (pull_request) Failing after 2m15s
Test / Test (pull_request) Has been skipped
Test / Render parity (pull_request) Has been skipped
2026-02-25 00:01:26 +00:00
ada3b903ad chore: update docs deployment branch from master to devel
Some checks failed
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
31d849ddbf updated docs
Some checks failed
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
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
598137ed13 feat(resource-viewer): добавить новый ресурсный просмотрщик с базовой функциональностью
Some checks failed
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
cb0ca2f2f0 feat(render-demo): добавить отображение FPS в заголовок окна и stdout в интерактивном режиме
Some checks failed
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
7346e695c4 feat(render-demo): обновить поддержку OpenGL с добавлением выбора между GLES2 и Core 3.3
Some checks failed
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
bb827c3928 feat: Refactor code structure and enhance functionality across multiple crates 2026-02-19 10:09:18 +00:00
efab61a45c feat(render-core): add default UV scale and refactor UV mapping logic
Some checks failed
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
0d7ae6a017 Документирование и обновление спецификаций
Some checks failed
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
a281ffa32e feat: Enhance model and texture loading with improved error handling and new features
Some checks failed
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
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
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
8a69872576 Refactor code structure for improved readability and maintainability
All checks were successful
Test / Lint (push) Successful in 1m8s
Test / Test (push) Successful in 1m15s
2026-02-12 11:07:25 +00:00
aa68906a3d feat: добавить экспорт в формат OBJ для рендеринга террейна и опциональных полигонов 2026-02-12 10:36:15 +00:00
8bf3b7b209 feat: добавить 3D рендерер для террейна с поддержкой Land.msh и Land.map 2026-02-12 10:24:42 +00:00
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
9c0df3d299 feat: добавить скрипт для детерминированного аудита FXID "absolute parity" 2026-02-12 08:22:51 +00:00
4c4f542fc2 Merge branch 'master' into renovate/actions-setup-python-6.x
All checks were successful
Test / Lint (pull_request) Successful in 1m2s
Test / Test (pull_request) Successful in 59s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m4s
Test / Lint (push) Successful in 1m4s
Test / Test (push) Successful in 58s
RenovateBot / renovate (push) Successful in 22s
2026-02-12 04:52:10 +04:00
4c9d772b03 chore(deps): update actions/setup-python action to v6
All checks were successful
Test / Lint (push) Successful in 51s
Test / Lint (pull_request) Successful in 53s
Test / Test (push) Successful in 52s
Test / Test (pull_request) Successful in 51s
2026-02-12 00:01:33 +00:00
097a915f35 fix(deps): update all digest updates
All checks were successful
Test / Lint (pull_request) Successful in 1m3s
Test / Test (pull_request) Successful in 50s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 1m10s
Test / Lint (push) Successful in 1m0s
Test / Test (push) Successful in 58s
2026-02-12 00:01:28 +00:00
c691de0dd0 fix: обновить срок действия авторских прав в документации и улучшить параметры rsync для развертывания
All checks were successful
Test / Lint (pull_request) Successful in 56s
Test / Test (pull_request) Successful in 59s
Docs Deploy / Build and Deploy MkDocs (push) Successful in 26s
Test / Lint (push) Successful in 57s
Test / Test (push) Successful in 52s
RenovateBot / renovate (push) Successful in 1m32s
2026-02-11 23:16:15 +00:00
92818ce0c4 fix: обновить путь развертывания в конфигурации rsync для корректной работы
Some checks failed
Test / Lint (pull_request) Successful in 1m8s
Test / Test (pull_request) Successful in 49s
Docs Deploy / Build and Deploy MkDocs (push) Failing after 25s
Test / Lint (push) Successful in 54s
Test / Test (push) Successful in 55s
2026-02-11 23:08:37 +00:00
6676cfdd8d feat: обновить параметры SSH для развертывания документации с использованием rsync
Some checks failed
Test / Lint (pull_request) Successful in 58s
Test / Test (pull_request) Successful in 53s
Docs Deploy / Build and Deploy MkDocs (push) Failing after 25s
Test / Lint (push) Successful in 52s
Test / Test (push) Successful in 51s
2026-02-11 23:02:03 +00:00
8b639ee6c9 feat: добавить установку rsync и openssh-client для развертывания документации
Some checks failed
Test / Lint (pull_request) Successful in 52s
Test / Test (pull_request) Successful in 1m13s
Docs Deploy / Build and Deploy MkDocs (push) Failing after 26s
Test / Lint (push) Successful in 1m0s
Test / Test (push) Successful in 53s
2026-02-11 22:49:30 +00:00
a58dea5499 feat: добавить рабочий процесс для развертывания документации MkDocs при пуше в ветку master
Some checks failed
Test / Lint (pull_request) Successful in 47s
Test / Test (pull_request) Successful in 49s
Docs Deploy / Build and Deploy MkDocs (push) Failing after 1m29s
Test / Lint (push) Successful in 53s
Test / Test (push) Successful in 52s
2026-02-11 22:41:22 +00:00
615891d550 feat: обновить заголовки разделов в документации по FXID и NRes для улучшения структуры
All checks were successful
Test / Lint (pull_request) Successful in 46s
Test / Test (pull_request) Successful in 48s
Test / Lint (push) Successful in 48s
Test / Test (push) Successful in 49s
2026-02-11 22:10:43 +00:00
481ff1c06d Implement feature X to enhance user experience and fix bug Y in module Z 2026-02-11 22:06:56 +00:00
7702d800a0 feat: улучшить документацию по материалам и текстурам, добавить детали о сборке и парсинге 2026-02-11 22:04:43 +00:00
3c06e768d6 feat: добавить поддержку атомарной замены файлов для Windows и тесты на максимальную длину имени 2026-02-11 22:00:46 +00:00
70ed6480c2 Refactor materials and Texm documentation for clarity and completeness
- Updated the structure and content of the materials and Texm documentation to provide a comprehensive overview of the material subsystem in the engine.
- Enhanced sections on identifiers, architecture, material layout, and runtime storage.
- Improved explanations of material attributes, animation modes, and parsing behavior.
- Added detailed specifications for toolchain interactions, including lossless write rules and validation recommendations.
- Included pseudocode examples for parsing MAT0 and Texm formats to aid in understanding.
2026-02-11 21:50:33 +00:00
662b292b5b feat: обновить методы обработки данных и улучшить обработку ошибок в библиотеке 2026-02-11 21:43:40 +00:00
3410b54793 feat: добавить тесты для проверки структурных инвариантов и корректности сортировки в RsLi 2026-02-11 21:21:32 +00:00
041b1a6cb3 Добавлены спецификации для сетевой подсистемы, системы звука, загрузки ландшафта, интерфейса пользователя и пайплайна выполнения. Обновлен файл навигации mkdocs.yml для включения новых документов. 2026-02-11 21:12:05 +00:00
5035d02220 Add MSH geometry export and preview rendering tools
All checks were successful
Test / Lint (push) Successful in 46s
Test / Test (push) Successful in 41s
- Implemented msh_export_obj.py for exporting NGI MSH geometry to Wavefront OBJ format, including model selection and geometry extraction.
- Added msh_preview_renderer.py for rendering NGI MSH models to binary PPM images, featuring a primitive software renderer with customizable parameters.
- Both tools utilize the same NRes parsing logic and provide command-line interfaces for listing models and exporting or rendering geometry.
2026-02-10 23:27:43 +00:00
ba1789f106 fix: обработка выхода за пределы индекса сортировки в архиве и улучшение декодирования LZSS с поддержкой XOR
All checks were successful
Test / Lint (push) Successful in 47s
Test / Test (push) Successful in 41s
2026-02-10 08:57:00 +00:00
842f4a8569 Implement LZSS decompression with optional XOR decryption
- Added `lzss_decompress_simple` function for LZSS decompression in `lzss.rs`.
- Introduced `XorState` struct and `xor_stream` function for XOR decryption in `xor.rs`.
- Updated `mod.rs` to include new LZSS and XOR modules.
- Refactored `parse_library` function in `parse.rs` to utilize the new XOR decryption functionality.
- Cleaned up and organized code in `lib.rs` by removing redundant functions and structures.
- Added tests for new functionality in `tests.rs`.
2026-02-10 08:38:58 +00:00
ce6e30f727 feat: добавить библиотеку common с ресурсами и буферами вывода; обновить зависимости в nres и rsli 2026-02-10 08:26:49 +00:00
4af183ad74 feat: добавить новые тесты для обработки не-NRes байтов и минимальной структуры архива
All checks were successful
Test / Lint (pull_request) Successful in 59s
Test / Test (pull_request) Successful in 58s
Test / Lint (push) Successful in 53s
Test / Test (push) Successful in 48s
RenovateBot / renovate (push) Successful in 1m3s
2026-02-09 23:56:30 +00:00
ab413bd751 fix: добавить проверку на наличие архивов в тестах для nres и rsli 2026-02-09 23:54:30 +00:00
b5e6fad3c3 fix: исправить ссылки на репозитории в разделе Contributing & Support
Some checks failed
Test / Lint (push) Successful in 51s
Test / Test (push) Failing after 1m12s
2026-02-09 23:40:21 +00:00
c69cad6a26 feat: добавить начальный README с описанием проекта и инструкциями
Some checks failed
Test / Lint (push) Successful in 53s
Test / Test (push) Failing after 54s
2026-02-09 23:36:32 +00:00
a24910791e feat: добавить README для библиотеки nres с описанием функционала и тестирования 2026-02-09 23:15:43 +00:00
371a060eb6 Refactor tests and move them to a dedicated module
- Moved the test suite from `lib.rs` to a new `tests.rs` file for better organization.
- Added a `SyntheticRsliEntry` struct to facilitate synthetic test cases.
- Introduced `RsliBuildOptions` struct to manage options for building RsLi byte arrays.
- Implemented various utility functions for file handling, data compression, and bit manipulation.
- Enhanced the `rsli_read_unpack_and_repack_all_files` test to validate all RsLi archives.
- Added new tests for synthetic entries covering all packing methods, overlay handling, and validation error cases.
2026-02-09 23:11:11 +00:00
e08b5f3853 feat: add initial implementation of rsli crate
Some checks failed
Test / Lint (push) Failing after 1m30s
Test / Test (push) Has been skipped
- Created Cargo.toml for the rsli crate with flate2 dependency.
- Implemented ResourceData enum for handling borrowed and owned byte slices.
- Added OutputBuffer trait and its Vec<u8> implementation for writing data.
- Defined a comprehensive Error enum for error handling in the library.
- Developed the Library struct to manage resource entries and provide methods for loading and unpacking resources.
- Implemented various packing methods and decompression algorithms, including LZSS and Deflate.
- Added tests for validating the functionality of the rsli library against sample data.
2026-02-09 22:58:16 +00:00
5a97f2e429 feat: удалить файл конфигурации dependabot 2026-02-09 22:51:08 +00:00
9e2dcb44a6 feat: обновить конфигурацию CI для тестирования и линтинга кода
Some checks failed
Test / Lint (push) Failing after 1m8s
Test / Test (push) Has been skipped
2026-02-09 22:47:25 +00:00
828106ba81 feat: добавить скрипты для инициализации тестовых данных и настройки окружения
Some checks failed
Test / cargo test (push) Failing after 58s
2026-02-09 22:39:12 +00:00
a7dd18fa1d feat: удалить устаревшие файлы и директории из проекта 2026-02-10 02:07:52 +04:00
f8cca32968 feat: изменить язык документации на русский 2026-02-10 02:05:27 +04:00
ef93237724 Add .gitignore for Python and project-specific files; implement archive roundtrip validator
Some checks failed
Test / cargo test (push) Failing after 50s
- Updated .gitignore to include common Python artifacts and project-specific files.
- Added `archive_roundtrip_validator.py` script for validating NRes and RsLi formats against real game data.
- Created README.md for the tools directory, detailing usage and supported signatures.
- Enhanced nres.md with practical nuances and empirical checks for game data.
2026-02-10 01:58:16 +04:00
58a896221f feat: обновление навигации в документации, добавление разделов для 3D моделей, текстур и эффектов 2026-02-10 01:49:09 +04:00
3f48f53bd5 feat: добавление документации по эффектам и частицам 2026-02-10 01:48:59 +04:00
2953f0c8c9 feat: добавление документации по модели ресурсов MSH/AniMesh 2026-02-10 01:47:19 +04:00
022ec608f5 feat: добавление документации по текстурам и материалам 2026-02-10 01:44:01 +04:00
54c94fddb5 Add detailed documentation for NRes and RsLi resource formats
Some checks failed
Test / cargo test (push) Failing after 41s
- Introduced a comprehensive markdown file `nres.md` detailing the structure, header, and operations of the NRes and RsLi formats.
- Updated `mkdocs.yml` to reflect the new documentation structure, consolidating NRes and RsLi under a single entry.
2026-02-10 00:30:25 +04:00
0def311fd1 feat: обновление документации по алгоритмам декомпрессии и добавление файлов .gitkeep в директории libs и tools 2026-02-05 03:28:03 +04:00
2f157d0972 feat: добавление файлов .gitkeep в директории libs и tools 2026-02-05 01:44:31 +04:00
8f57a8f0f9 feat: добавление файлов конфигурации Zig и обновление .gitignore
Some checks failed
Test / cargo test (push) Failing after 47s
2026-02-05 01:40:47 +04:00
40e7d88fd0 Add NRes format documentation and decompression algorithms
Some checks failed
Test / cargo test (push) Failing after 40s
- Created `huffman_decompression.md` detailing the Huffman decompression algorithm used in NRes, including context structure, block modes, and decoding methods.
- Created `overview.md` for the NRes format, outlining file structure, header details, file entries, and packing algorithms.
- Updated `mkdocs.yml` to include new documentation files in the navigation structure.
2026-02-05 01:32:24 +04:00
afe6b9a29b feat: remove Rust project 2026-02-05 00:37:59 +04:00
6a46fe9825 chore(deps): update actions/checkout action to v6
All checks were successful
Test / cargo test (pull_request) Successful in 1m36s
Test / cargo test (push) Successful in 1m45s
RenovateBot / renovate (push) Successful in 1m50s
2026-01-30 14:16:24 +00:00
7818a7ef3f chore: update renovate workflow to include GITHUB_COM_TOKEN
All checks were successful
Test / cargo test (push) Successful in 1m43s
RenovateBot / renovate (push) Successful in 25s
2026-01-30 18:15:52 +04:00
15f2a73e95 chore: wire RENOVATE_LOG_LEVEL
All checks were successful
RenovateBot / renovate (push) Successful in 21s
Test / cargo test (push) Successful in 1m35s
2026-01-30 04:35:32 +04:00
2890b69678 migrate renovate config to gitea
All checks were successful
RenovateBot / renovate (push) Successful in 1m51s
Test / cargo test (push) Successful in 1m34s
2026-01-30 04:27:02 +04:00
27e9d2b39c Move CI to Gitea Actions
All checks were successful
Test / cargo test (push) Successful in 1m37s
2026-01-30 04:00:58 +04:00
b283e2a8df Update dependencies and fix clippy warnings
Some checks failed
Mirror / mirror (push) Failing after 7s
Test / cargo test (push) Successful in 1m39s
2026-01-30 03:29:08 +04:00
9dcce90201 chore: update dependencies and fix clippy warnings
Some checks failed
Mirror / mirror (push) Failing after 1m45s
Test / cargo test (push) Successful in 1m33s
- refresh Cargo.lock to latest compatible crates
- simplify u32->u64 conversion in libnres
- use is_multiple_of in unpacker list validation
2026-01-19 20:52:54 +04:00
renovate[bot]
7c876faf12 Update Rust crate console to v0.16.1 (#48)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 13:25:23 +00:00
renovate[bot]
39c66e698e Update Rust crate log to v0.4.28 (#47)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-04 05:03:11 +00:00
renovate[bot]
abac84a008 Update Rust crate image to v0.25.8 (#46)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 20:21:26 +00:00
renovate[bot]
b44217d4af Update Rust crate clap to v4.5.47 (#45)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-03 05:25:25 +00:00
renovate[bot]
c268e4c205 Update all digest updates (#41)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 12:23:15 +04:00
renovate[bot]
8aabe74eb2 Update Rust crate thiserror to v2.0.15 (#39)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-17 10:13:52 +00:00
84f2175fd2 Merge pull request #33 from valentineus/renovate/all-digest
Update all digest updates
2025-08-13 18:16:56 +04:00
renovate[bot]
307b9c6d90 Update all digest updates 2025-08-13 13:45:03 +00:00
renovate[bot]
7de26b16d4 Update Rust crate clap to v4.5.41 (#32)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-10 19:47:12 +00:00
52f2ad43e6 Merge pull request #29 from valentineus/renovate/all-digest
Update all digest updates
2025-07-09 03:23:23 +04:00
renovate[bot]
c4dec3fe4c Update all digest updates 2025-07-08 20:30:48 +00:00
e51edcb561 Update dependencies in Cargo.lock 2025-06-14 23:02:49 +00:00
2273fd4263 Merge pull request #7 from valentineus/nres
Обновление структуры проекта
2025-06-15 02:42:55 +04:00
renovate[bot]
d4f104cf5e Update Rust crate clap to v4.5.40 (#28)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:27:38 +00:00
renovate[bot]
7f41a51f2a Update all digest updates (#27)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 03:58:39 +00:00
renovate[bot]
e97610a8ac Update Rust crate clap to v4.5.38 (#26)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-11 06:45:14 +00:00
renovate[bot]
ee02d922ae Update Rust crate miette to v7.6.0 (#25)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-27 14:41:46 +00:00
renovate[bot]
dbd7b6bf33 Update all digest updates (#24)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 18:14:47 +00:00
renovate[bot]
949c0aa087 Update all digest updates (#14)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-21 09:37:22 +00:00
renovate[bot]
4f29af53b6 Update Rust crate console to v0.15.11 (#13)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-02 05:23:27 +00:00
renovate[bot]
1d62740d59 Update Rust crate clap to v4.5.31 (#12)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 22:22:28 +00:00
d274602104 Merge branch 'master' into nres 2025-02-23 17:23:33 +04:00
8bc39d10b1 Updated dependencies 2025-02-23 17:22:30 +04:00
88faa6e3ea Merge branch 'master' into nres 2025-02-22 14:19:02 +04:00
renovate[bot]
66705ba4f0 Update Rust crate log to v0.4.26 (#11)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-21 10:51:06 +00:00
renovate[bot]
bb4c217ee2 Update all digest updates (#10)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-20 12:35:42 +00:00
renovate[bot]
c83822e353 Update Rust crate clap to v4.5.30 (#9)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-18 03:03:44 +00:00
renovate[bot]
130ee8df5b Update Rust crate clap to v4.5.29 (#8)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-12 02:55:33 +00:00
8d8653133b Обновление структуры проекта 2025-02-08 01:11:02 +00:00
94d2f8a512 Обновление зависимостей 2025-02-08 00:44:59 +00:00
215a093344 Updated Renovate config 2025-02-05 03:43:58 +04:00
3de1575082 Merge pull request #5 from valentineus/renovate/all-digest 2025-02-04 05:49:24 +04:00
renovate[bot]
aa8e1184bf Update Rust crate clap to v4.5.28 2025-02-04 01:47:42 +00:00
feb7ebe722 Merge pull request #4 from valentineus/renovate/all-digest
Update Rust crate miette to v7.5.0
2025-02-01 17:58:58 +04:00
renovate[bot]
becadef5ee Update Rust crate miette to v7.5.0 2025-02-01 04:26:56 +00:00
a4b36e1aea Merge pull request #3 from valentineus/renovate/all-digest
Update all digest updates
2025-01-30 04:34:02 +04:00
renovate[bot]
c7b099b596 Update all digest updates 2025-01-30 00:26:30 +00:00
48a08445e7 Added mirror 2025-01-30 04:25:11 +04:00
694de5edfa Moved Renocate config 2025-01-30 01:59:31 +04:00
0dc37e9604 Outdated CI and Renovate configurations have been removed, and a new Dependabot configuration file for dependency management has been added. 2025-01-24 20:50:13 +04:00
3d2e970225 Update Rust crate clap to v4.5.27 2025-01-21 00:01:51 +00:00
d90b9830bc Updated all dependencies 2025-01-20 20:18:27 +00:00
f91e1bda22 Update Rust crate serde_json to v1.0.137 2025-01-20 00:02:56 +00:00
e9a0fd718f Update Rust crate log to v0.4.25 2025-01-15 00:03:21 +00:00
509ce2d83d Update all digest updates 2025-01-10 23:44:03 +00:00
391756b77d Update all digest updates 2025-01-10 21:04:54 +00:00
035153c7c0 Update all digest updates 2025-01-07 21:04:58 +00:00
885a593829 Update Rust crate serde to v1.0.217 2024-12-27 21:02:46 +00:00
7c3c8cc969 Update all digest updates 2024-12-21 21:03:04 +00:00
00c62a9909 Update Rust crate thiserror to v2.0.8 2024-12-18 21:03:04 +00:00
c2899d27af Update Rust crate console to v0.15.10 2024-12-16 15:42:52 +00:00
e60fdd1958 Update Rust crate thiserror to v2.0.7 2024-12-14 21:02:34 +00:00
dd6d440ba5 Update Rust crate serde to v1.0.216 2024-12-11 21:04:41 +00:00
36a082ba18 Update all digest updates 2024-12-08 21:03:54 +00:00
09689a937c Update all digest updates 2024-12-03 21:01:39 +00:00
39f6479415 Update Rust crate miette to v7.4.0 2024-11-27 21:02:44 +00:00
01a2a47370 Update Rust crate miette to v7.3.0 2024-11-26 21:05:22 +00:00
4cd42afa37 Update Rust crate serde_json to v1.0.133 2024-11-17 21:05:34 +00:00
298aa954b9 Update Rust crate clap to v4.5.21 2024-11-13 21:01:52 +00:00
910deb6c17 Update all digest updates 2024-11-12 21:01:58 +00:00
4a22e2177e Merge pull request 'Update Rust crate thiserror to v2' (!36) from renovate/thiserror-2.x into master
Reviewed-on: #36
2024-11-11 15:10:34 +03:00
729c972573 Update Rust crate thiserror to v2 2024-11-10 21:05:05 +00:00
250d78a955 Update Rust crate thiserror to v1.0.69 2024-11-10 21:04:56 +00:00
03f2d762bb Merge pull request 'Update ghcr.io/renovatebot/renovate Docker tag to v39' (!34) from renovate/ghcr.io-renovatebot-renovate-39.x into master
Reviewed-on: #34
2024-11-06 09:43:20 +03:00
fcaa729544 Update all digest updates 2024-11-05 21:02:13 +00:00
8c2a6e2c19 Update ghcr.io/renovatebot/renovate Docker tag to v39 2024-11-04 21:02:13 +00:00
daa2efba89 Update Rust crate thiserror to v1.0.66 2024-11-01 21:03:36 +00:00
b5748505ef Update Rust crate serde to v1.0.214 2024-10-28 21:02:55 +00:00
d305b1f005 Update all digest updates 2024-10-22 21:01:55 +00:00
2cfba4891c Update Rust crate serde_json to v1.0.132 2024-10-19 21:01:55 +00:00
777d3814d3 Update Rust crate serde_json to v1.0.131 2024-10-18 23:23:57 +00:00
784ceeebdf Update Rust crate serde_json to v1.0.130 2024-10-18 21:02:00 +00:00
e3675555ea Update all digest updates 2024-10-17 21:02:30 +00:00
91104e214f Update Rust crate image to v0.25.3 2024-10-16 21:04:18 +00:00
9198b18652 Update Rust crate clap to v4.5.20 2024-10-08 21:04:51 +00:00
1ad7949828 Update Rust crate clap to v4.5.19 2024-10-01 21:03:54 +00:00
b98f01a810 Update Rust crate thiserror to v1.0.64 2024-09-24 09:34:04 +00:00
fa88050a52 Update all digest updates 2024-09-23 21:04:33 +00:00
1123c8a56e Update all digest updates 2024-09-15 21:07:25 +00:00
2eb6333552 Update Rust crate serde to v1.0.209 2024-08-24 12:51:04 +00:00
c5224e006f Update Rust crate serde_json to v1.0.127 2024-08-23 21:04:54 +00:00
79599f3cf4 Update Rust crate clap to v4.5.16 2024-08-15 23:00:19 +00:00
7acf99b9d6 Update all digest updates 2024-08-15 21:03:43 +00:00
ec542703b4 Update Rust crate serde to v1.0.207 2024-08-12 21:02:14 +00:00
ee1cdda38b Update Rust crate serde_json to v1.0.124 2024-08-11 21:42:34 +00:00
293a1de413 Update all digest updates 2024-08-11 21:04:17 +00:00
6635d4da9a Update Rust crate clap to v4.5.15 2024-08-10 21:04:17 +00:00
f549769fcf Update all digest updates 2024-08-08 21:05:42 +00:00
c0a56acc0c Update Rust crate serde_json to v1.0.122 2024-08-02 21:05:34 +00:00
a136dc5fa4 Update Rust crate clap to v4.5.13 2024-07-31 22:13:23 +00:00
1b13f2acfc Update Rust crate clap to v4.5.12 2024-07-31 21:03:54 +00:00
6c127ce028 Update Rust crate serde_json to v1.0.121 2024-07-29 21:03:12 +00:00
bc2e051741 Merge branch 'master' into renovate/ghcr.io-renovatebot-renovate-38.x 2024-07-26 17:12:49 +03:00
9abd2a4558 Update ghcr.io/renovatebot/renovate Docker tag to v38 2024-07-25 21:03:45 +00:00
f267a56fd0 Update Rust crate clap to v4.5.11 2024-07-25 21:03:42 +00:00
1d592418af Update Rust crate clap to v4.5.10 2024-07-23 21:02:15 +00:00
3448f0f930 Update Rust crate image to v0.25.2 2024-07-21 21:04:37 +00:00
039ed238a6 Added Gitea CI testing 2024-07-19 18:23:35 +04:00
b7349f9df9 Added CI check 2024-07-19 13:08:47 +00:00
12c7f0284e Added DevContainer 2024-07-19 13:08:46 +00:00
5c9a691495 Update Rust crate miette to v7 2024-07-19 12:43:23 +00:00
bf8be5c045 Update all digest updates 2024-07-19 12:41:15 +00:00
ee8a5fc02b Added Gitea 2024-07-19 16:39:08 +04:00
a990de90fe Deleted vendor folder 2024-07-19 16:37:58 +04:00
3d48cd3f81 Initial MkDocs 2024-02-06 02:26:50 +04:00
78d6eca336 Initial GitHub Actions 2024-02-06 02:20:26 +04:00
7431 changed files with 20728 additions and 2162294 deletions

View File

@@ -1,5 +0,0 @@
[source.crates-io]
replace-with = "vendored-sources"
[source.vendored-sources]
directory = "vendor"

View File

@@ -0,0 +1,9 @@
{
"image": "mcr.microsoft.com/devcontainers/rust:latest",
"customizations": {
"vscode": {
"extensions": ["rust-lang.rust-analyzer"]
}
},
"runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"]
}

View File

@@ -0,0 +1,48 @@
name: Docs Deploy
on:
push:
branches:
- devel
jobs:
deploy-docs:
name: Build and Deploy MkDocs
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.14"
- name: Install docs dependencies
run: pip install -r requirements.txt
- name: Build MkDocs site
run: mkdocs build
- name: Install rsync
run: |
sudo apt-get update
sudo apt-get install -y rsync openssh-client
- name: Prepare SSH key
env:
SSH_KEY_B64: ${{ secrets.ROOT_CI_KEY_B64 }}
run: |
umask 077
mkdir -p ~/.ssh
printf '%s' "$SSH_KEY_B64" | base64 -d > ~/.ssh/id_root_ci
chmod 600 ~/.ssh/id_root_ci
- name: Deploy via rsync
env:
DEPLOY_HOST: ${{ secrets.FPARKAN_DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.FPARKAN_DEPLOY_PORT }}
run: |
rsync -rlz --delete \
-e "ssh -p ${DEPLOY_PORT} -i ~/.ssh/id_root_ci -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \
site/ "gitea-runner@${DEPLOY_HOST}:./"

View File

@@ -0,0 +1,25 @@
name: RenovateBot
on:
schedule:
- cron: "@daily"
jobs:
renovate:
container: ghcr.io/renovatebot/renovate:43
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Run renovate
run: |
renovate
env:
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
LOG_LEVEL: ${{ vars.RENOVATE_LOG_LEVEL }}
RENOVATE_CONFIG_FILE: renovate.config.cjs
RENOVATE_LOG_LEVEL: ${{ vars.RENOVATE_LOG_LEVEL }}
RENOVATE_REPOSITORIES: ${{ gitea.repository }}
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}

55
.gitea/workflows/test.yml Normal file
View File

@@ -0,0 +1,55 @@
name: Test
on: [push, pull_request]
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Cargo check
run: cargo check --workspace --all-targets --all-features
- name: Clippy (deny warnings)
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
test:
name: Test
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Cargo test
run: cargo test --workspace --all-features -- --nocapture
render-parity:
name: Render parity
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v6
- 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@v6
with:
name: render-parity-artifacts
path: target/render-parity/current
if-no-files-found: ignore

219
.gitignore vendored
View File

@@ -1 +1,218 @@
/target
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Generated by Cargo
# will have compiled files and executables
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk
# MSVC Windows builds of rustc generate these, which store debugging information
*.pdb
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
tmp/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pdm
.pdm.toml
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Poetry local configuration file
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json

30
.renovaterc Normal file
View File

@@ -0,0 +1,30 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":disableDependencyDashboard"
],
"assignees": [
"valentineus"
],
"labels": [
"dependencies",
"automated"
],
"packageRules": [
{
"groupName": "all digest updates",
"groupSlug": "all-digest",
"matchUpdateTypes": [
"minor",
"patch",
"pin",
"digest"
],
"matchPackageNames": [
"*"
],
"automerge": true
}
]
}

1182
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,6 @@
[workspace]
resolver = "2"
members = [
"libnres",
"nres-cli",
"packer",
"texture-decoder",
"unpacker",
]
resolver = "3"
members = ["crates/*", "apps/resource-viewer"]
[profile.release]
codegen-units = 1

View File

@@ -1,11 +1,55 @@
# Utilities for the game "Parkan: Iron Strategy"
# FParkan
This repository contains utilities, tools, and libraries for the game "Parkan: Iron Strategy."
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования.
## List of projects
## Описание
- [unpacker](unpacker): Text-based utility for unpacking game resources in the NRres format. Allows unpacking 100% of game resources.
- [packer](packer): Text-based utility for packing game resources in the NRres format. Allows packing 100% of game resources.
- [texture-decoder](texture-decoder): (WIP) Decoder for game textures. Decodes approximately 20% of game textures.
- [libnres](libnres): _(Deprecation)_ Library for NRes files.
- [nres-cli](nres-cli): _(Deprecation)_ Console tool for NRes files.
Проект находится в активной разработке и включает:
- библиотеки для работы с форматами игровых архивов;
- инструменты для валидации/подготовки тестовых данных;
- спецификации форматов и сопутствующую документацию.
## Установка
Проект находится в начальной стадии, подробная инструкция по установке пока отсутствует.
## Документация
- локально: каталог [`docs/`](docs)
- сайт: <https://fparkan.popov.link>
## Инструменты
Вспомогательные инструменты находятся в каталоге [`tools/`](tools).
- [tools/archive_roundtrip_validator.py](tools/archive_roundtrip_validator.py) — инструмент верификации документации по архивам `NRes`/`RsLi` на реальных файлах (включая `unpack -> repack -> byte-compare`).
- [tools/init_testdata.py](tools/init_testdata.py) — подготовка тестовых данных по сигнатурам с раскладкой по каталогам.
## Библиотеки
- [crates/nres](crates/nres) — библиотека для работы с файлами архивов NRes (чтение, поиск, редактирование, сохранение).
- [crates/rsli](crates/rsli) — библиотека для работы с файлами архивов RsLi (чтение, поиск, загрузка/распаковка поддерживаемых методов).
## Тестирование
Базовое тестирование проходит на синтетических тестах из репозитория.
Для дополнительного тестирования на реальных игровых ресурсах:
- используйте [tools/init_testdata.py](tools/init_testdata.py) для подготовки локального набора;
- используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy));
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
## Contributing & Support
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
- **Primary development**: [valentineus/fparkan](https://code.popov.link/valentineus/fparkan)
- **GitHub mirror**: [valentineus/fparkan](https://github.com/valentineus/fparkan)
Основная разработка ведётся в self-hosted репозитории.
## Лицензия
Проект распространяется под лицензией **[GNU GPL v2](LICENSE.txt)**.

View File

@@ -0,0 +1,11 @@
[package]
name = "resource-viewer"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
iced = "0.13"
rfd = "0.15"
nres = { path = "../../crates/nres" }
rsli = { path = "../../crates/rsli" }

View File

@@ -0,0 +1,518 @@
use iced::widget::{button, column, container, horizontal_space, row, scrollable, text};
use iced::{application, Element, Length, Task, Theme};
use rfd::FileDialog;
use std::collections::BTreeMap;
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
fn main() -> iced::Result {
application("Parkan Resource Viewer", update, view)
.theme(theme)
.run_with(|| (ViewerApp::default(), Task::none()))
}
fn theme(_state: &ViewerApp) -> Theme {
Theme::Light
}
#[derive(Debug, Default)]
struct ViewerApp {
document: Option<DocumentModel>,
status: String,
}
#[derive(Debug, Clone)]
enum Message {
OpenRequested,
SelectNode(Selection),
}
fn update(state: &mut ViewerApp, message: Message) -> Task<Message> {
match message {
Message::OpenRequested => {
if let Some(path) = pick_archive_file() {
match load_document(&path) {
Ok(document) => {
state.status =
format!("Loaded {} as {}", path.display(), document.format.label());
state.document = Some(document);
}
Err(err) => {
state.status = err;
}
}
}
}
Message::SelectNode(selection) => {
if let Some(document) = state.document.as_mut() {
document.selected = selection;
}
}
}
Task::none()
}
fn view(state: &ViewerApp) -> Element<'_, Message> {
let top_bar = row![
button("Open archive").on_press(Message::OpenRequested),
text(status_text(state)).size(14)
]
.spacing(12);
let content = if let Some(document) = &state.document {
view_document(document)
} else {
container(text("Open an .nres/.rsli/.lib archive to start.").size(16))
.width(Length::Fill)
.height(Length::Fill)
.center_x(Length::Fill)
.center_y(Length::Fill)
.into()
};
container(column![top_bar, content].spacing(12).padding(12))
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn status_text(state: &ViewerApp) -> String {
if state.status.is_empty() {
String::from("Ready")
} else {
state.status.clone()
}
}
fn view_document(document: &DocumentModel) -> Element<'_, Message> {
let mut tree = column![text("Archive tree").size(18)].spacing(6);
for item in &document.tree_rows {
let indent = horizontal_space().width(Length::Fixed(f32::from(item.depth) * 16.0));
let line = row![indent, text(&item.label).size(14)].spacing(6);
if let Some(selection) = item.selection {
let mut node_button = button(line)
.width(Length::Fill)
.on_press(Message::SelectNode(selection));
if selection == document.selected {
node_button = node_button.style(button::primary);
}
tree = tree.push(node_button);
} else {
tree = tree.push(line);
}
}
let (panel_title, fields) = selected_fields(document);
let mut fields_column = column![text(panel_title).size(18)].spacing(8);
for field in fields {
fields_column = fields_column.push(
row![
text(&field.key).size(14).width(Length::Fixed(220.0)),
text(&field.value).size(14).width(Length::Fill)
]
.spacing(12),
);
}
let left = container(scrollable(tree))
.width(Length::FillPortion(2))
.height(Length::Fill);
let right = container(scrollable(fields_column))
.width(Length::FillPortion(5))
.height(Length::Fill);
row![left, right].spacing(12).height(Length::Fill).into()
}
fn selected_fields(document: &DocumentModel) -> (String, &[FieldRow]) {
match document.selected {
Selection::Archive => (
format!(
"{} fields ({})",
document.format.label(),
document.path.display()
),
&document.archive_fields,
),
Selection::Entry(index) => {
if let Some(entry) = document.entries.get(index) {
(entry.panel_title.clone(), &entry.fields)
} else {
(String::from("Entry"), &[])
}
}
}
}
fn pick_archive_file() -> Option<PathBuf> {
FileDialog::new()
.set_title("Open Parkan archive")
.pick_file()
}
fn load_document(path: &Path) -> Result<DocumentModel, String> {
let bytes =
fs::read(path).map_err(|err| format!("Failed to read {}: {err}", path.display()))?;
let Some(format) = detect_archive_format(&bytes) else {
return Err(format!(
"{} is not recognized as NRes/RsLi (unsupported magic).",
path.display()
));
};
match format {
ArchiveFormat::Nres => load_nres_document(path),
ArchiveFormat::Rsli => load_rsli_document(path),
}
}
fn detect_archive_format(bytes: &[u8]) -> Option<ArchiveFormat> {
if bytes.len() >= 4 && &bytes[0..4] == b"NRes" {
return Some(ArchiveFormat::Nres);
}
if bytes.len() >= 2 && &bytes[0..2] == b"NL" {
return Some(ArchiveFormat::Rsli);
}
None
}
fn load_nres_document(path: &Path) -> Result<DocumentModel, String> {
let archive = nres::Archive::open_path(path)
.map_err(|err| format!("NRes open failed for {}: {err}", path.display()))?;
let info = archive.info();
let mut archive_fields = vec![
FieldRow::new("format", "NRes"),
FieldRow::new("file_size", info.file_size.to_string()),
FieldRow::new("raw_mode", info.raw_mode.to_string()),
];
if let Some(header) = &info.header {
archive_fields.push(FieldRow::new(
"magic",
String::from_utf8_lossy(&header.magic).into_owned(),
));
archive_fields.push(FieldRow::new("version", format_u32_dec_hex(header.version)));
archive_fields.push(FieldRow::new("entry_count", header.entry_count.to_string()));
archive_fields.push(FieldRow::new(
"total_size",
format!("{} (0x{:08X})", header.total_size, header.total_size),
));
archive_fields.push(FieldRow::new(
"directory_offset",
header.directory_offset.to_string(),
));
archive_fields.push(FieldRow::new(
"directory_size",
header.directory_size.to_string(),
));
}
let mut entries = Vec::new();
for entry in archive.entries_inspect() {
let meta = entry.meta;
let mut fields = vec![
FieldRow::new("id", entry.id.0.to_string()),
FieldRow::new("name", meta.name.clone()),
FieldRow::new("type_id", format_u32_dec_hex(meta.kind)),
FieldRow::new("attr1", format_u32_dec_hex(meta.attr1)),
FieldRow::new("attr2", format_u32_dec_hex(meta.attr2)),
FieldRow::new("attr3", format_u32_dec_hex(meta.attr3)),
FieldRow::new("data_offset", meta.data_offset.to_string()),
FieldRow::new("data_size", meta.data_size.to_string()),
FieldRow::new("sort_index", meta.sort_index.to_string()),
FieldRow::new("name_raw_hex", bytes_as_hex(entry.name_raw)),
FieldRow::new("name_raw_ascii", bytes_as_ascii(entry.name_raw)),
];
fields.push(FieldRow::new("find_key", meta.name.to_ascii_lowercase()));
entries.push(EntryView {
full_name: meta.name.clone(),
panel_title: format!("NRes entry #{}: {}", entry.id.0, meta.name),
fields,
});
}
let tree_rows = build_tree_rows(&entries);
Ok(DocumentModel {
path: path.to_path_buf(),
format: ArchiveFormat::Nres,
archive_fields,
entries,
tree_rows,
selected: Selection::Archive,
})
}
fn load_rsli_document(path: &Path) -> Result<DocumentModel, String> {
let library = rsli::Library::open_path(path)
.map_err(|err| format!("RsLi open failed for {}: {err}", path.display()))?;
let header = library.header();
let mut archive_fields = vec![
FieldRow::new("format", "RsLi"),
FieldRow::new("magic", String::from_utf8_lossy(&header.magic).into_owned()),
FieldRow::new(
"reserved",
format!("{} (0x{:02X})", header.reserved, header.reserved),
),
FieldRow::new(
"version",
format!("{} (0x{:02X})", header.version, header.version),
),
FieldRow::new("entry_count", header.entry_count.to_string()),
FieldRow::new("presorted_flag", format!("0x{:04X}", header.presorted_flag)),
FieldRow::new("xor_seed", format!("0x{:08X}", header.xor_seed)),
FieldRow::new("header_raw_hex", bytes_as_hex(&header.raw)),
];
if let Some(ao) = library.ao_trailer() {
archive_fields.push(FieldRow::new("ao_trailer", "present"));
archive_fields.push(FieldRow::new("ao_overlay", ao.overlay.to_string()));
archive_fields.push(FieldRow::new("ao_raw_hex", bytes_as_hex(&ao.raw)));
} else {
archive_fields.push(FieldRow::new("ao_trailer", "absent"));
}
let mut entries = Vec::new();
for entry in library.entries_inspect() {
let meta = entry.meta;
let method_raw = (meta.flags as u16 as u32) & 0x1E0;
let fields = vec![
FieldRow::new("id", entry.id.0.to_string()),
FieldRow::new("name", meta.name.clone()),
FieldRow::new(
"flags",
format!("{} (0x{:04X})", meta.flags, meta.flags as u16),
),
FieldRow::new("method", format!("{:?}", meta.method)),
FieldRow::new("method_raw", format!("0x{:03X}", method_raw)),
FieldRow::new("packed_size", meta.packed_size.to_string()),
FieldRow::new("unpacked_size", meta.unpacked_size.to_string()),
FieldRow::new("data_offset_effective", meta.data_offset.to_string()),
FieldRow::new("data_offset_raw", entry.data_offset_raw.to_string()),
FieldRow::new("sort_to_original", entry.sort_to_original.to_string()),
FieldRow::new("name_raw_hex", bytes_as_hex(entry.name_raw)),
FieldRow::new("name_raw_ascii", bytes_as_ascii(entry.name_raw)),
FieldRow::new("service_tail_hex", bytes_as_hex(entry.service_tail)),
FieldRow::new("service_tail_ascii", bytes_as_ascii(entry.service_tail)),
];
entries.push(EntryView {
full_name: meta.name.clone(),
panel_title: format!("RsLi entry #{}: {}", entry.id.0, meta.name),
fields,
});
}
let tree_rows = build_tree_rows(&entries);
Ok(DocumentModel {
path: path.to_path_buf(),
format: ArchiveFormat::Rsli,
archive_fields,
entries,
tree_rows,
selected: Selection::Archive,
})
}
fn build_tree_rows(entries: &[EntryView]) -> Vec<TreeRow> {
let mut root = FolderNode::default();
for (index, entry) in entries.iter().enumerate() {
insert_tree_path(&mut root, &entry.full_name, index);
}
let mut rows = vec![TreeRow {
depth: 0,
label: String::from("[Archive fields]"),
selection: Some(Selection::Archive),
}];
flatten_tree(&root, 0, &mut rows);
rows
}
fn insert_tree_path(root: &mut FolderNode, full_name: &str, entry_index: usize) {
let mut parts: Vec<&str> = full_name
.split(['/', '\\'])
.filter(|part| !part.is_empty())
.collect();
if parts.is_empty() {
parts.push(full_name);
}
if parts.len() == 1 {
root.files.push((parts[0].to_string(), entry_index));
return;
}
let file_name = parts.pop().unwrap_or(full_name);
let mut node = root;
for part in parts {
node = node.folders.entry(part.to_string()).or_default();
}
node.files.push((file_name.to_string(), entry_index));
}
fn flatten_tree(node: &FolderNode, depth: u16, out: &mut Vec<TreeRow>) {
for (folder_name, folder_node) in &node.folders {
out.push(TreeRow {
depth,
label: format!("{folder_name}/"),
selection: None,
});
flatten_tree(folder_node, depth.saturating_add(1), out);
}
let mut files = node.files.clone();
files.sort_by(|left, right| left.0.cmp(&right.0));
for (name, index) in files {
out.push(TreeRow {
depth,
label: name,
selection: Some(Selection::Entry(index)),
});
}
}
fn bytes_as_hex(bytes: &[u8]) -> String {
let mut out = String::new();
for (index, byte) in bytes.iter().enumerate() {
if index > 0 {
out.push(' ');
}
let _ = write!(&mut out, "{byte:02X}");
}
out
}
fn bytes_as_ascii(bytes: &[u8]) -> String {
bytes
.iter()
.map(|byte| {
if byte.is_ascii_graphic() || *byte == b' ' {
char::from(*byte)
} else {
'.'
}
})
.collect()
}
fn format_u32_dec_hex(value: u32) -> String {
format!("{} (0x{:08X})", value, value)
}
#[derive(Debug, Clone)]
struct DocumentModel {
path: PathBuf,
format: ArchiveFormat,
archive_fields: Vec<FieldRow>,
entries: Vec<EntryView>,
tree_rows: Vec<TreeRow>,
selected: Selection,
}
#[derive(Debug, Clone, Copy)]
enum ArchiveFormat {
Nres,
Rsli,
}
impl ArchiveFormat {
fn label(self) -> &'static str {
match self {
Self::Nres => "NRes",
Self::Rsli => "RsLi",
}
}
}
#[derive(Debug, Clone)]
struct EntryView {
full_name: String,
panel_title: String,
fields: Vec<FieldRow>,
}
#[derive(Debug, Clone)]
struct FieldRow {
key: String,
value: String,
}
impl FieldRow {
fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
}
}
}
#[derive(Debug, Clone)]
struct TreeRow {
depth: u16,
label: String,
selection: Option<Selection>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Selection {
Archive,
Entry(usize),
}
#[derive(Default, Debug)]
struct FolderNode {
folders: BTreeMap<String, FolderNode>,
files: Vec<(String, usize)>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tree_builds_nested_paths() {
let entries = vec![
EntryView {
full_name: String::from("textures/ui/hud.texm"),
panel_title: String::new(),
fields: vec![],
},
EntryView {
full_name: String::from("textures/world/ground.texm"),
panel_title: String::new(),
fields: vec![],
},
EntryView {
full_name: String::from("root_file.msh"),
panel_title: String::new(),
fields: vec![],
},
];
let rows = build_tree_rows(&entries);
assert!(rows.iter().any(|row| row.label == "textures/"));
assert!(rows.iter().any(|row| row.label == "ui/"));
assert!(rows.iter().any(|row| row.label == "hud.texm"));
assert!(rows.iter().any(|row| row.label == "root_file.msh"));
}
}

6
crates/common/Cargo.toml Normal file
View File

@@ -0,0 +1,6 @@
[package]
name = "common"
version = "0.1.0"
edition = "2021"
[dependencies]

61
crates/common/src/lib.rs Normal file
View File

@@ -0,0 +1,61 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
/// 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(())
}
}
/// Recursively collects all files under `root`.
pub 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);
}
}
}

View File

@@ -0,0 +1,12 @@
[package]
name = "msh-core"
version = "0.1.0"
edition = "2021"
[dependencies]
encoding_rs = "0.8"
nres = { path = "../nres" }
[dev-dependencies]
common = { path = "../common" }
proptest = "1"

14
crates/msh-core/README.md Normal file
View File

@@ -0,0 +1,14 @@
# msh-core
Парсер core-части формата `MSH`.
Покрывает:
- `Res1`, `Res2`, `Res3`, `Res6`, `Res13` (обязательные);
- `Res4`, `Res5`, `Res10` (опциональные);
- slot lookup по `node/lod/group`.
Тесты:
- прогон по всем `.msh` в `testdata`;
- синтетическая минимальная модель.

View File

@@ -0,0 +1,75 @@
use core::fmt;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Nres(nres::error::Error),
MissingResource {
kind: u32,
label: &'static str,
},
InvalidResourceSize {
label: &'static str,
size: usize,
stride: usize,
},
InvalidRes2Size {
size: usize,
},
UnsupportedNodeStride {
stride: usize,
},
IndexOutOfBounds {
label: &'static str,
index: usize,
limit: usize,
},
IntegerOverflow,
}
impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self {
Self::Nres(value)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Nres(err) => write!(f, "{err}"),
Self::MissingResource { kind, label } => {
write!(f, "missing required resource type={kind} ({label})")
}
Self::InvalidResourceSize {
label,
size,
stride,
} => {
write!(
f,
"invalid {label} size={size}, expected multiple of stride={stride}"
)
}
Self::InvalidRes2Size { size } => {
write!(f, "invalid Res2 size={size}, expected >= 140")
}
Self::UnsupportedNodeStride { stride } => {
write!(
f,
"unsupported Res1 node stride={stride}, expected 38 or 24"
)
}
Self::IndexOutOfBounds {
label,
index,
limit,
} => write!(
f,
"{label} index out of bounds: index={index}, limit={limit}"
),
Self::IntegerOverflow => write!(f, "integer overflow"),
}
}
}
impl std::error::Error for Error {}

434
crates/msh-core/src/lib.rs Normal file
View File

@@ -0,0 +1,434 @@
pub mod error;
use crate::error::Error;
use encoding_rs::WINDOWS_1251;
use std::sync::Arc;
pub type Result<T> = core::result::Result<T, Error>;
pub const RES1_NODE_TABLE: u32 = 1;
pub const RES2_SLOTS: u32 = 2;
pub const RES3_POSITIONS: u32 = 3;
pub const RES4_NORMALS: u32 = 4;
pub const RES5_UV0: u32 = 5;
pub const RES6_INDICES: u32 = 6;
pub const RES10_NAMES: u32 = 10;
pub const RES13_BATCHES: u32 = 13;
#[derive(Clone, Debug)]
pub struct Slot {
pub tri_start: u16,
pub tri_count: u16,
pub batch_start: u16,
pub batch_count: u16,
pub aabb_min: [f32; 3],
pub aabb_max: [f32; 3],
pub sphere_center: [f32; 3],
pub sphere_radius: f32,
pub opaque: [u32; 5],
}
#[derive(Clone, Debug)]
pub struct Batch {
pub batch_flags: u16,
pub material_index: u16,
pub opaque4: u16,
pub opaque6: u16,
pub index_count: u16,
pub index_start: u32,
pub opaque14: u16,
pub base_vertex: u32,
}
#[derive(Clone, Debug)]
pub struct Model {
pub node_stride: usize,
pub node_count: usize,
pub nodes_raw: Vec<u8>,
pub slots: Vec<Slot>,
pub positions: Vec<[f32; 3]>,
pub normals: Option<Vec<[i8; 4]>>,
pub uv0: Option<Vec<[i16; 2]>>,
pub indices: Vec<u16>,
pub batches: Vec<Batch>,
pub node_names: Option<Vec<Option<String>>>,
}
impl Model {
pub fn slot_index(&self, node_index: usize, lod: usize, group: usize) -> Option<usize> {
if node_index >= self.node_count || lod >= 3 || group >= 5 {
return None;
}
if self.node_stride != 38 {
return None;
}
let node_off = node_index.checked_mul(self.node_stride)?;
let matrix_off = node_off.checked_add(8)?;
let word_off = matrix_off.checked_add((lod * 5 + group) * 2)?;
let raw = read_u16(&self.nodes_raw, word_off).ok()?;
if raw == u16::MAX {
return None;
}
let idx = usize::from(raw);
if idx >= self.slots.len() {
return None;
}
Some(idx)
}
}
pub fn parse_model_payload(payload: &[u8]) -> Result<Model> {
let archive = nres::Archive::open_bytes(
Arc::from(payload.to_vec().into_boxed_slice()),
nres::OpenOptions::default(),
)?;
let res1 = read_required(&archive, RES1_NODE_TABLE, "Res1")?;
let res2 = read_required(&archive, RES2_SLOTS, "Res2")?;
let res3 = read_required(&archive, RES3_POSITIONS, "Res3")?;
let res6 = read_required(&archive, RES6_INDICES, "Res6")?;
let res13 = read_required(&archive, RES13_BATCHES, "Res13")?;
let res4 = read_optional(&archive, RES4_NORMALS)?;
let res5 = read_optional(&archive, RES5_UV0)?;
let res10 = read_optional(&archive, RES10_NAMES)?;
let node_stride = usize::try_from(res1.meta.attr3).map_err(|_| Error::IntegerOverflow)?;
if node_stride != 38 && node_stride != 24 {
return Err(Error::UnsupportedNodeStride {
stride: node_stride,
});
}
if res1.bytes.len() % node_stride != 0 {
return Err(Error::InvalidResourceSize {
label: "Res1",
size: res1.bytes.len(),
stride: node_stride,
});
}
let node_count = res1.bytes.len() / node_stride;
if res2.bytes.len() < 0x8C {
return Err(Error::InvalidRes2Size {
size: res2.bytes.len(),
});
}
let slot_blob = res2
.bytes
.len()
.checked_sub(0x8C)
.ok_or(Error::IntegerOverflow)?;
if slot_blob % 68 != 0 {
return Err(Error::InvalidResourceSize {
label: "Res2.slots",
size: slot_blob,
stride: 68,
});
}
let slot_count = slot_blob / 68;
let mut slots = Vec::with_capacity(slot_count);
for i in 0..slot_count {
let off = 0x8Cusize
.checked_add(i.checked_mul(68).ok_or(Error::IntegerOverflow)?)
.ok_or(Error::IntegerOverflow)?;
slots.push(Slot {
tri_start: read_u16(&res2.bytes, off)?,
tri_count: read_u16(&res2.bytes, off + 2)?,
batch_start: read_u16(&res2.bytes, off + 4)?,
batch_count: read_u16(&res2.bytes, off + 6)?,
aabb_min: [
read_f32(&res2.bytes, off + 8)?,
read_f32(&res2.bytes, off + 12)?,
read_f32(&res2.bytes, off + 16)?,
],
aabb_max: [
read_f32(&res2.bytes, off + 20)?,
read_f32(&res2.bytes, off + 24)?,
read_f32(&res2.bytes, off + 28)?,
],
sphere_center: [
read_f32(&res2.bytes, off + 32)?,
read_f32(&res2.bytes, off + 36)?,
read_f32(&res2.bytes, off + 40)?,
],
sphere_radius: read_f32(&res2.bytes, off + 44)?,
opaque: [
read_u32(&res2.bytes, off + 48)?,
read_u32(&res2.bytes, off + 52)?,
read_u32(&res2.bytes, off + 56)?,
read_u32(&res2.bytes, off + 60)?,
read_u32(&res2.bytes, off + 64)?,
],
});
}
let positions = parse_positions(&res3.bytes)?;
let indices = parse_u16_array(&res6.bytes, "Res6")?;
let batches = parse_batches(&res13.bytes)?;
validate_slot_batch_ranges(&slots, batches.len())?;
validate_batch_index_ranges(&batches, indices.len())?;
let normals = match res4 {
Some(raw) => Some(parse_i8x4_array(&raw.bytes, "Res4")?),
None => None,
};
let uv0 = match res5 {
Some(raw) => Some(parse_i16x2_array(&raw.bytes, "Res5")?),
None => None,
};
let node_names = match res10 {
Some(raw) => Some(parse_res10_names(&raw.bytes, node_count)?),
None => None,
};
Ok(Model {
node_stride,
node_count,
nodes_raw: res1.bytes,
slots,
positions,
normals,
uv0,
indices,
batches,
node_names,
})
}
fn validate_slot_batch_ranges(slots: &[Slot], batch_count: usize) -> Result<()> {
for slot in slots {
let start = usize::from(slot.batch_start);
let end = start
.checked_add(usize::from(slot.batch_count))
.ok_or(Error::IntegerOverflow)?;
if end > batch_count {
return Err(Error::IndexOutOfBounds {
label: "Res2.batch_range",
index: end,
limit: batch_count,
});
}
}
Ok(())
}
fn validate_batch_index_ranges(batches: &[Batch], index_count: usize) -> Result<()> {
for batch in batches {
let start = usize::try_from(batch.index_start).map_err(|_| Error::IntegerOverflow)?;
let end = start
.checked_add(usize::from(batch.index_count))
.ok_or(Error::IntegerOverflow)?;
if end > index_count {
return Err(Error::IndexOutOfBounds {
label: "Res13.index_range",
index: end,
limit: index_count,
});
}
}
Ok(())
}
fn parse_positions(data: &[u8]) -> Result<Vec<[f32; 3]>> {
if !data.len().is_multiple_of(12) {
return Err(Error::InvalidResourceSize {
label: "Res3",
size: data.len(),
stride: 12,
});
}
let count = data.len() / 12;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let off = i * 12;
out.push([
read_f32(data, off)?,
read_f32(data, off + 4)?,
read_f32(data, off + 8)?,
]);
}
Ok(out)
}
fn parse_batches(data: &[u8]) -> Result<Vec<Batch>> {
if !data.len().is_multiple_of(20) {
return Err(Error::InvalidResourceSize {
label: "Res13",
size: data.len(),
stride: 20,
});
}
let count = data.len() / 20;
let mut out = Vec::with_capacity(count);
for i in 0..count {
let off = i * 20;
out.push(Batch {
batch_flags: read_u16(data, off)?,
material_index: read_u16(data, off + 2)?,
opaque4: read_u16(data, off + 4)?,
opaque6: read_u16(data, off + 6)?,
index_count: read_u16(data, off + 8)?,
index_start: read_u32(data, off + 10)?,
opaque14: read_u16(data, off + 14)?,
base_vertex: read_u32(data, off + 16)?,
});
}
Ok(out)
}
fn parse_u16_array(data: &[u8], label: &'static str) -> Result<Vec<u16>> {
if !data.len().is_multiple_of(2) {
return Err(Error::InvalidResourceSize {
label,
size: data.len(),
stride: 2,
});
}
let mut out = Vec::with_capacity(data.len() / 2);
for i in (0..data.len()).step_by(2) {
out.push(read_u16(data, i)?);
}
Ok(out)
}
fn parse_i8x4_array(data: &[u8], label: &'static str) -> Result<Vec<[i8; 4]>> {
if !data.len().is_multiple_of(4) {
return Err(Error::InvalidResourceSize {
label,
size: data.len(),
stride: 4,
});
}
let mut out = Vec::with_capacity(data.len() / 4);
for i in (0..data.len()).step_by(4) {
out.push([
read_i8(data, i)?,
read_i8(data, i + 1)?,
read_i8(data, i + 2)?,
read_i8(data, i + 3)?,
]);
}
Ok(out)
}
fn parse_i16x2_array(data: &[u8], label: &'static str) -> Result<Vec<[i16; 2]>> {
if !data.len().is_multiple_of(4) {
return Err(Error::InvalidResourceSize {
label,
size: data.len(),
stride: 4,
});
}
let mut out = Vec::with_capacity(data.len() / 4);
for i in (0..data.len()).step_by(4) {
out.push([read_i16(data, i)?, read_i16(data, i + 2)?]);
}
Ok(out)
}
fn parse_res10_names(data: &[u8], node_count: usize) -> Result<Vec<Option<String>>> {
let mut out = Vec::with_capacity(node_count);
let mut off = 0usize;
for _ in 0..node_count {
let len = usize::try_from(read_u32(data, off)?).map_err(|_| Error::IntegerOverflow)?;
off = off.checked_add(4).ok_or(Error::IntegerOverflow)?;
if len == 0 {
out.push(None);
continue;
}
let need = len.checked_add(1).ok_or(Error::IntegerOverflow)?;
let end = off.checked_add(need).ok_or(Error::IntegerOverflow)?;
let slice = data.get(off..end).ok_or(Error::InvalidResourceSize {
label: "Res10",
size: data.len(),
stride: 1,
})?;
let text = if slice.last().copied() == Some(0) {
&slice[..slice.len().saturating_sub(1)]
} else {
slice
};
let decoded = decode_cp1251(text);
out.push(Some(decoded));
off = end;
}
Ok(out)
}
fn decode_cp1251(bytes: &[u8]) -> String {
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
decoded.into_owned()
}
struct RawResource {
meta: nres::EntryMeta,
bytes: Vec<u8>,
}
fn read_required(archive: &nres::Archive, kind: u32, label: &'static str) -> Result<RawResource> {
let id = archive
.entries()
.find(|entry| entry.meta.kind == kind)
.map(|entry| entry.id)
.ok_or(Error::MissingResource { kind, label })?;
let entry = archive.get(id).ok_or(Error::IndexOutOfBounds {
label,
index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?,
limit: archive.entry_count(),
})?;
let data = archive.read(id)?.into_owned();
Ok(RawResource {
meta: entry.meta.clone(),
bytes: data,
})
}
fn read_optional(archive: &nres::Archive, kind: u32) -> Result<Option<RawResource>> {
let Some(id) = archive
.entries()
.find(|entry| entry.meta.kind == kind)
.map(|entry| entry.id)
else {
return Ok(None);
};
let entry = archive.get(id).ok_or(Error::IndexOutOfBounds {
label: "optional",
index: usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?,
limit: archive.entry_count(),
})?;
let data = archive.read(id)?.into_owned();
Ok(Some(RawResource {
meta: entry.meta.clone(),
bytes: data,
}))
}
fn read_u16(data: &[u8], offset: usize) -> Result<u16> {
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
Ok(u16::from_le_bytes(arr))
}
fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
Ok(i16::from_le_bytes(arr))
}
fn read_i8(data: &[u8], offset: usize) -> Result<i8> {
let byte = data.get(offset).copied().ok_or(Error::IntegerOverflow)?;
Ok(i8::from_le_bytes([byte]))
}
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?;
let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
Ok(u32::from_le_bytes(arr))
}
fn read_f32(data: &[u8], offset: usize) -> Result<f32> {
Ok(f32::from_bits(read_u32(data, offset)?))
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,438 @@
use super::*;
use common::collect_files_recursive;
use nres::Archive;
use proptest::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
let mut files = Vec::new();
collect_files_recursive(&root, &mut files);
files.sort();
files
.into_iter()
.filter(|path| {
fs::read(path)
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
.unwrap_or(false)
})
.collect()
}
fn is_msh_name(name: &str) -> bool {
name.to_ascii_lowercase().ends_with(".msh")
}
#[derive(Clone)]
struct SyntheticEntry {
kind: u32,
name: String,
attr1: u32,
attr2: u32,
attr3: u32,
data: Vec<u8>,
}
fn build_nested_nres(entries: &[SyntheticEntry]) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(b"NRes");
payload.extend_from_slice(&0x100u32.to_le_bytes());
payload.extend_from_slice(
&u32::try_from(entries.len())
.expect("entry count overflow in test")
.to_le_bytes(),
);
payload.extend_from_slice(&0u32.to_le_bytes()); // total_size placeholder
let mut resource_offsets = Vec::with_capacity(entries.len());
for entry in entries {
resource_offsets.push(u32::try_from(payload.len()).expect("offset overflow in test"));
payload.extend_from_slice(&entry.data);
while !payload.len().is_multiple_of(8) {
payload.push(0);
}
}
for (index, entry) in entries.iter().enumerate() {
payload.extend_from_slice(&entry.kind.to_le_bytes());
payload.extend_from_slice(&entry.attr1.to_le_bytes());
payload.extend_from_slice(&entry.attr2.to_le_bytes());
payload.extend_from_slice(
&u32::try_from(entry.data.len())
.expect("size overflow in test")
.to_le_bytes(),
);
payload.extend_from_slice(&entry.attr3.to_le_bytes());
let mut name_raw = [0u8; 36];
let name_bytes = entry.name.as_bytes();
assert!(name_bytes.len() <= 35, "name too long for synthetic test");
name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
payload.extend_from_slice(&name_raw);
payload.extend_from_slice(&resource_offsets[index].to_le_bytes());
payload.extend_from_slice(&(index as u32).to_le_bytes());
}
let total_size = u32::try_from(payload.len()).expect("size overflow in test");
payload[12..16].copy_from_slice(&total_size.to_le_bytes());
payload
}
fn synthetic_entry(kind: u32, name: &str, attr3: u32, data: Vec<u8>) -> SyntheticEntry {
SyntheticEntry {
kind,
name: name.to_string(),
attr1: 1,
attr2: 0,
attr3,
data,
}
}
fn res1_stride38_nodes(node_count: usize, node0_slot00: Option<u16>) -> Vec<u8> {
let mut out = vec![0u8; node_count.saturating_mul(38)];
for node in 0..node_count {
let node_off = node * 38;
for i in 0..15 {
let off = node_off + 8 + i * 2;
out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
}
}
if let Some(slot) = node0_slot00 {
out[8..10].copy_from_slice(&slot.to_le_bytes());
}
out
}
fn res1_stride24_nodes(node_count: usize) -> Vec<u8> {
vec![0u8; node_count.saturating_mul(24)]
}
fn res2_single_slot(batch_start: u16, batch_count: u16) -> Vec<u8> {
let mut res2 = vec![0u8; 0x8C + 68];
res2[0x8C..0x8C + 2].copy_from_slice(&0u16.to_le_bytes()); // tri_start
res2[0x8C + 2..0x8C + 4].copy_from_slice(&0u16.to_le_bytes()); // tri_count
res2[0x8C + 4..0x8C + 6].copy_from_slice(&batch_start.to_le_bytes()); // batch_start
res2[0x8C + 6..0x8C + 8].copy_from_slice(&batch_count.to_le_bytes()); // batch_count
res2
}
fn res3_triangle_positions() -> Vec<u8> {
[0f32, 0f32, 0f32, 1f32, 0f32, 0f32, 0f32, 1f32, 0f32]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect()
}
fn res4_normals() -> Vec<u8> {
vec![127u8, 0u8, 128u8, 0u8]
}
fn res5_uv0() -> Vec<u8> {
[1024i16, -1024i16]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect()
}
fn res6_triangle_indices() -> Vec<u8> {
[0u16, 1u16, 2u16]
.iter()
.flat_map(|v| v.to_le_bytes())
.collect()
}
fn res13_single_batch(index_start: u32, index_count: u16) -> Vec<u8> {
let mut batch = vec![0u8; 20];
batch[0..2].copy_from_slice(&0u16.to_le_bytes());
batch[2..4].copy_from_slice(&0u16.to_le_bytes());
batch[8..10].copy_from_slice(&index_count.to_le_bytes());
batch[10..14].copy_from_slice(&index_start.to_le_bytes());
batch[16..20].copy_from_slice(&0u32.to_le_bytes());
batch
}
fn res10_names_raw(names: &[Option<&[u8]>]) -> Vec<u8> {
let mut out = Vec::new();
for name in names {
match name {
Some(name) => {
out.extend_from_slice(
&u32::try_from(name.len())
.expect("name size overflow in test")
.to_le_bytes(),
);
out.extend_from_slice(name);
out.push(0);
}
None => out.extend_from_slice(&0u32.to_le_bytes()),
}
}
out
}
fn res10_names(names: &[Option<&str>]) -> Vec<u8> {
let raw: Vec<Option<&[u8]>> = names.iter().map(|name| name.map(str::as_bytes)).collect();
res10_names_raw(&raw)
}
fn base_synthetic_entries() -> Vec<SyntheticEntry> {
vec![
synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0))),
synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 1)),
synthetic_entry(RES3_POSITIONS, "Res3", 12, res3_triangle_positions()),
synthetic_entry(RES6_INDICES, "Res6", 2, res6_triangle_indices()),
synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(0, 3)),
]
}
#[test]
fn parse_all_game_msh_models() {
let archives = nres_test_files();
if archives.is_empty() {
eprintln!("skipping parse_all_game_msh_models: no NRes files in testdata");
return;
}
let mut model_count = 0usize;
let mut renderable_count = 0usize;
let mut legacy_stride24_count = 0usize;
for archive_path in archives {
let archive = Archive::open_path(&archive_path)
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
for entry in archive.entries() {
if !is_msh_name(&entry.meta.name) {
continue;
}
model_count += 1;
let payload = archive.read(entry.id).unwrap_or_else(|err| {
panic!(
"failed to read model '{}' in {}: {err}",
entry.meta.name,
archive_path.display()
)
});
let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
panic!(
"failed to parse model '{}' in {}: {err}",
entry.meta.name,
archive_path.display()
)
});
if model.node_stride == 24 {
legacy_stride24_count += 1;
}
for node_index in 0..model.node_count {
for lod in 0..3 {
for group in 0..5 {
if let Some(slot_idx) = model.slot_index(node_index, lod, group) {
assert!(
slot_idx < model.slots.len(),
"slot index out of bounds in '{}' ({})",
entry.meta.name,
archive_path.display()
);
}
}
}
}
let mut has_renderable_batch = false;
for node_index in 0..model.node_count {
let Some(slot_idx) = model.slot_index(node_index, 0, 0) else {
continue;
};
let slot = &model.slots[slot_idx];
let batch_end =
usize::from(slot.batch_start).saturating_add(usize::from(slot.batch_count));
if batch_end > model.batches.len() {
continue;
}
for batch in &model.batches[usize::from(slot.batch_start)..batch_end] {
let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
let index_count = usize::from(batch.index_count);
let end = index_start.saturating_add(index_count);
if end <= model.indices.len() && index_count >= 3 {
has_renderable_batch = true;
break;
}
}
if has_renderable_batch {
break;
}
}
if has_renderable_batch {
renderable_count += 1;
}
}
}
assert!(model_count > 0, "no .msh entries found");
assert!(
renderable_count > 0,
"no renderable models (lod0/group0) were detected"
);
assert!(
legacy_stride24_count <= model_count,
"internal test accounting error"
);
}
#[test]
fn parse_minimal_synthetic_model() {
let payload = build_nested_nres(&base_synthetic_entries());
let model = parse_model_payload(&payload).expect("failed to parse synthetic model");
assert_eq!(model.node_count, 1);
assert_eq!(model.positions.len(), 3);
assert_eq!(model.indices.len(), 3);
assert_eq!(model.batches.len(), 1);
assert_eq!(model.slot_index(0, 0, 0), Some(0));
}
#[test]
fn parse_synthetic_stride24_variant() {
let mut entries = base_synthetic_entries();
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 24, res1_stride24_nodes(1));
let payload = build_nested_nres(&entries);
let model = parse_model_payload(&payload).expect("failed to parse stride24 model");
assert_eq!(model.node_stride, 24);
assert_eq!(model.node_count, 1);
assert_eq!(model.slot_index(0, 0, 0), None);
}
#[test]
fn parse_synthetic_model_with_optional_res4_res5_res10() {
let mut entries = base_synthetic_entries();
entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, res4_normals()));
entries.push(synthetic_entry(RES5_UV0, "Res5", 4, res5_uv0()));
entries.push(synthetic_entry(
RES10_NAMES,
"Res10",
1,
res10_names(&[Some("Hull"), None]),
));
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(2, Some(0)));
let payload = build_nested_nres(&entries);
let model = parse_model_payload(&payload).expect("failed to parse model with optional data");
assert_eq!(model.node_count, 2);
assert_eq!(model.normals.as_ref().map(Vec::len), Some(1));
assert_eq!(model.uv0.as_ref().map(Vec::len), Some(1));
assert_eq!(model.node_names, Some(vec![Some("Hull".to_string()), None]));
}
#[test]
fn parse_res10_names_decodes_cp1251() {
let mut entries = base_synthetic_entries();
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 38, res1_stride38_nodes(1, Some(0)));
entries.push(synthetic_entry(
RES10_NAMES,
"Res10",
1,
res10_names_raw(&[Some(&[0xC0])]),
));
let payload = build_nested_nres(&entries);
let model = parse_model_payload(&payload).expect("failed to parse model with cp1251 name");
assert_eq!(model.node_names, Some(vec![Some("А".to_string())]));
}
#[test]
fn parse_fails_when_required_resource_missing() {
let mut entries = base_synthetic_entries();
entries.retain(|entry| entry.kind != RES13_BATCHES);
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::MissingResource {
kind: RES13_BATCHES,
label: "Res13"
})
));
}
#[test]
fn parse_fails_for_invalid_res2_size() {
let mut entries = base_synthetic_entries();
entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, vec![0u8; 0x8B]);
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::InvalidRes2Size { .. })
));
}
#[test]
fn parse_fails_for_unsupported_node_stride() {
let mut entries = base_synthetic_entries();
entries[0] = synthetic_entry(RES1_NODE_TABLE, "Res1", 30, vec![0u8; 30]);
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::UnsupportedNodeStride { stride: 30 })
));
}
#[test]
fn parse_fails_for_invalid_optional_resource_size() {
let mut entries = base_synthetic_entries();
entries.push(synthetic_entry(RES4_NORMALS, "Res4", 4, vec![1, 2, 3]));
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::InvalidResourceSize { label: "Res4", .. })
));
}
#[test]
fn parse_fails_for_slot_batch_range_out_of_bounds() {
let mut entries = base_synthetic_entries();
entries[1] = synthetic_entry(RES2_SLOTS, "Res2", 68, res2_single_slot(0, 2));
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::IndexOutOfBounds {
label: "Res2.batch_range",
..
})
));
}
#[test]
fn parse_fails_for_batch_index_range_out_of_bounds() {
let mut entries = base_synthetic_entries();
entries[4] = synthetic_entry(RES13_BATCHES, "Res13", 20, res13_single_batch(1, 3));
let payload = build_nested_nres(&entries);
assert!(matches!(
parse_model_payload(&payload),
Err(Error::IndexOutOfBounds {
label: "Res13.index_range",
..
})
));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn parse_model_payload_never_panics_on_random_bytes(data in proptest::collection::vec(any::<u8>(), 0..8192)) {
let _ = parse_model_payload(&data);
}
}

10
crates/nres/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "nres"
version = "0.1.0"
edition = "2021"
[dependencies]
common = { path = "../common" }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] }

42
crates/nres/README.md Normal file
View File

@@ -0,0 +1,42 @@
# nres
Rust-библиотека для работы с архивами формата **NRes**.
## Что умеет
- Открытие архива из файла (`open_path`) и из памяти (`open_bytes`).
- Поддержка `raw_mode` (весь файл как единый ресурс).
- Чтение метаданных и итерация по записям.
- Поиск по имени без учёта регистра (`find`).
- Чтение данных ресурса (`read`, `read_into`, `raw_slice`).
- Редактирование архива через `Editor`:
- `add`, `replace_data`, `remove`.
- `commit` с пересчётом `sort_index`, выравниванием по 8 байт и атомарной записью файла.
## Модель ошибок
Библиотека возвращает типизированные ошибки (`InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `DirectoryOutOfBounds`, `EntryDataOutOfBounds`, и др.) без паник в production-коде.
## Покрытие тестами
### Реальные файлы
- Рекурсивный прогон по `testdata/nres/**`.
- Сейчас в наборе: **120 архивов**.
- Для каждого архива проверяется:
- чтение всех записей;
- `read`/`read_into`/`raw_slice`;
- `find`;
- `unpack -> repack (Editor::commit)` с проверкой **byte-to-byte**.
### Синтетические тесты
- Проверка основных сценариев редактирования (`add/replace/remove/commit`).
- Проверка валидации и ошибок:
- `InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `InvalidEntryCount`, `DirectoryOutOfBounds`, `NameTooLong`, `EntryDataOutOfBounds`, `EntryIdOutOfRange`, `NameContainsNul`.
## Быстрый запуск тестов
```bash
cargo test -p nres -- --nocapture
```

110
crates/nres/src/error.rs Normal file
View File

@@ -0,0 +1,110 @@
use core::fmt;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Io(std::io::Error),
InvalidMagic {
got: [u8; 4],
},
UnsupportedVersion {
got: u32,
},
TotalSizeMismatch {
header: u32,
actual: u64,
},
InvalidEntryCount {
got: i32,
},
TooManyEntries {
got: usize,
},
DirectoryOutOfBounds {
directory_offset: u64,
directory_len: u64,
file_len: u64,
},
EntryIdOutOfRange {
id: u32,
entry_count: u32,
},
EntryDataOutOfBounds {
id: u32,
offset: u64,
size: u32,
directory_offset: u64,
},
NameTooLong {
got: usize,
max: usize,
},
NameContainsNul,
BadNameEncoding,
IntegerOverflow,
RawModeDisallowsOperation(&'static str),
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Io(e) => write!(f, "I/O error: {e}"),
Error::InvalidMagic { got } => write!(f, "invalid NRes magic: {got:02X?}"),
Error::UnsupportedVersion { got } => {
write!(f, "unsupported NRes version: {got:#x}")
}
Error::TotalSizeMismatch { header, actual } => {
write!(f, "NRes total_size mismatch: header={header}, actual={actual}")
}
Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
Error::DirectoryOutOfBounds {
directory_offset,
directory_len,
file_len,
} => write!(
f,
"directory out of bounds: off={directory_offset}, len={directory_len}, file={file_len}"
),
Error::EntryIdOutOfRange { id, entry_count } => {
write!(f, "entry id out of range: id={id}, count={entry_count}")
}
Error::EntryDataOutOfBounds {
id,
offset,
size,
directory_offset,
} => write!(
f,
"entry data out of bounds: id={id}, off={offset}, size={size}, dir_off={directory_offset}"
),
Error::NameTooLong { got, max } => write!(f, "name too long: {got} > {max}"),
Error::NameContainsNul => write!(f, "name contains NUL byte"),
Error::BadNameEncoding => write!(f, "bad name encoding"),
Error::IntegerOverflow => write!(f, "integer overflow"),
Error::RawModeDisallowsOperation(op) => {
write!(f, "operation not allowed in raw mode: {op}")
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}

772
crates/nres/src/lib.rs Normal file
View File

@@ -0,0 +1,772 @@
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(Clone, Debug)]
pub struct ArchiveHeader {
pub magic: [u8; 4],
pub version: u32,
pub entry_count: u32,
pub total_size: u32,
pub directory_offset: u64,
pub directory_size: u64,
}
#[derive(Clone, Debug)]
pub struct ArchiveInfo {
pub raw_mode: bool,
pub file_size: u64,
pub header: Option<ArchiveHeader>,
}
#[derive(Debug)]
pub struct Archive {
bytes: Arc<[u8]>,
entries: Vec<EntryRecord>,
info: ArchiveInfo,
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(Copy, Clone, Debug)]
pub struct EntryInspect<'a> {
pub id: EntryId,
pub meta: &'a EntryMeta,
pub name_raw: &'a [u8; 36],
}
#[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 file_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
let (entries, header) = parse_archive(&bytes, opts.raw_mode)?;
if opts.prefetch_pages {
prefetch_pages(&bytes);
}
Ok(Self {
bytes,
entries,
info: ArchiveInfo {
raw_mode: opts.raw_mode,
file_size,
header,
},
raw_mode: opts.raw_mode,
})
}
pub fn info(&self) -> &ArchiveInfo {
&self.info
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
let id = u32::try_from(idx).ok()?;
Some(EntryRef {
id: EntryId(id),
meta: &entry.meta,
})
})
}
pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
let id = u32::try_from(idx).ok()?;
Some(EntryInspect {
id: EntryId(id),
meta: &entry.meta,
name_raw: &entry.name_raw,
})
})
}
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 => {
let id = u32::try_from(target_idx).ok()?;
return Some(EntryId(id));
}
}
}
}
self.entries.iter().enumerate().find_map(|(idx, entry)| {
if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
== Ordering::Equal
{
let id = u32::try_from(idx).ok()?;
Some(EntryId(id))
} 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 inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
let idx = usize::try_from(id.0).ok()?;
let entry = self.entries.get(idx)?;
Some(EntryInspect {
id,
meta: &entry.meta,
name_raw: &entry.name_raw,
})
}
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: saturating_u32_len(self.entries.len()),
});
};
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().filter_map(|(idx, entry)| {
let id = u32::try_from(idx).ok()?;
Some(EntryRef {
id: EntryId(id),
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: saturating_u32_len(self.entries.len()),
});
};
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: saturating_u32_len(self.entries.len()),
});
}
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() {
// sort_index stores the original-entry index at sorted position `idx`.
// This mirrors the format emitted by the retail assets and test fixtures.
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>, Option<ArchiveHeader>)> {
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], None));
}
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,
Some(ArchiveHeader {
magic: *b"NRes",
version,
entry_count: u32::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?,
total_size,
directory_offset,
directory_size: directory_len,
}),
))
}
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 saturating_u32_len(len: usize) -> u32 {
u32::try_from(len).unwrap_or(u32::MAX)
}
fn prefetch_pages(bytes: &[u8]) {
use std::hint::black_box;
let mut cursor = 0usize;
let mut sink = 0u8;
while cursor < bytes.len() {
sink ^= bytes[cursor];
cursor = cursor.saturating_add(4096);
}
black_box(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();
// SAFETY: pointers reference NUL-terminated UTF-16 buffers that stay alive
// for the duration of the call; flags and argument contract match WinAPI.
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;

983
crates/nres/src/tests.rs Normal file
View File

@@ -0,0 +1,983 @@
use super::*;
use common::collect_files_recursive;
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 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);
}

View File

@@ -0,0 +1,11 @@
[package]
name = "render-core"
version = "0.1.0"
edition = "2021"
[dependencies]
msh-core = { path = "../msh-core" }
[dev-dependencies]
common = { path = "../common" }
nres = { path = "../nres" }

View File

@@ -0,0 +1,14 @@
# render-core
CPU-подготовка draw-данных для моделей `MSH`.
Покрывает:
- обход `node -> slot -> batch`;
- раскрытие индексов в triangle-list (`position + uv0`);
- расчёт bounds по вершинам.
Тесты:
- построение рендер-сеток на реальных `.msh` из `testdata`;
- unit-test bounds.

View File

@@ -0,0 +1,146 @@
use msh_core::Model;
use std::collections::HashMap;
pub const DEFAULT_UV_SCALE: f32 = 1024.0;
#[derive(Clone, Debug)]
pub struct RenderVertex {
pub position: [f32; 3],
pub uv0: [f32; 2],
}
#[derive(Clone, Debug)]
pub struct RenderMesh {
pub vertices: Vec<RenderVertex>,
pub indices: Vec<u16>,
pub batch_count: usize,
pub index_overflow: bool,
}
impl RenderMesh {
pub fn triangle_count(&self) -> usize {
self.indices.len() / 3
}
}
/// Builds an indexed triangle mesh for a specific LOD/group pair.
pub fn build_render_mesh(model: &Model, lod: usize, group: usize) -> RenderMesh {
let mut vertices = Vec::new();
let mut indices = Vec::new();
let mut index_remap: HashMap<usize, u16> = HashMap::new();
let mut batch_count = 0usize;
let mut index_overflow = false;
let uv0 = model.uv0.as_ref();
for node_index in 0..model.node_count {
let Some(slot_idx) = model.slot_index(node_index, lod, group) else {
continue;
};
let Some(slot) = model.slots.get(slot_idx) else {
continue;
};
let batch_start = usize::from(slot.batch_start);
let batch_end = batch_start.saturating_add(usize::from(slot.batch_count));
if batch_end > model.batches.len() {
continue;
}
for batch in &model.batches[batch_start..batch_end] {
let index_start = usize::try_from(batch.index_start).unwrap_or(usize::MAX);
let index_count = usize::from(batch.index_count);
let index_end = index_start.saturating_add(index_count);
if index_end > model.indices.len() || index_count < 3 {
continue;
}
let batch_out_start = indices.len();
let mut batch_valid = true;
for &idx in &model.indices[index_start..index_end] {
let final_idx_u64 = u64::from(batch.base_vertex).saturating_add(u64::from(idx));
let Ok(final_idx) = usize::try_from(final_idx_u64) else {
batch_valid = false;
break;
};
let Some(pos) = model.positions.get(final_idx) else {
batch_valid = false;
break;
};
let local_index = if let Some(&mapped) = index_remap.get(&final_idx) {
mapped
} else {
let Ok(mapped) = u16::try_from(vertices.len()) else {
index_overflow = true;
batch_valid = false;
break;
};
let uv = uv0
.and_then(|uvs| uvs.get(final_idx))
.copied()
.map(|packed| {
[
packed[0] as f32 / DEFAULT_UV_SCALE,
packed[1] as f32 / DEFAULT_UV_SCALE,
]
})
.unwrap_or([0.0, 0.0]);
vertices.push(RenderVertex {
position: *pos,
uv0: uv,
});
index_remap.insert(final_idx, mapped);
mapped
};
indices.push(local_index);
}
if !batch_valid {
indices.truncate(batch_out_start);
continue;
}
batch_count += 1;
}
}
RenderMesh {
vertices,
indices,
batch_count,
index_overflow,
}
}
pub fn compute_bounds(vertices: &[[f32; 3]]) -> Option<([f32; 3], [f32; 3])> {
compute_bounds_impl(vertices.iter().copied())
}
pub fn compute_bounds_for_mesh(vertices: &[RenderVertex]) -> Option<([f32; 3], [f32; 3])> {
compute_bounds_impl(vertices.iter().map(|v| v.position))
}
fn compute_bounds_impl<I>(mut positions: I) -> Option<([f32; 3], [f32; 3])>
where
I: Iterator<Item = [f32; 3]>,
{
let first = positions.next()?;
let mut min_v = first;
let mut max_v = first;
for pos in positions {
for i in 0..3 {
if pos[i] < min_v[i] {
min_v[i] = pos[i];
}
if pos[i] > max_v[i] {
max_v[i] = pos[i];
}
}
}
Some((min_v, max_v))
}
#[cfg(test)]
mod tests;

View File

@@ -0,0 +1,256 @@
use super::*;
use common::collect_files_recursive;
use msh_core::parse_model_payload;
use nres::Archive;
use std::fs;
use std::path::{Path, PathBuf};
fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
let mut files = Vec::new();
collect_files_recursive(&root, &mut files);
files.sort();
files
.into_iter()
.filter(|path| {
fs::read(path)
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
.unwrap_or(false)
})
.collect()
}
#[test]
fn build_render_mesh_for_real_models() {
let archives = nres_test_files();
if archives.is_empty() {
eprintln!("skipping build_render_mesh_for_real_models: no NRes files in testdata");
return;
}
let mut models_checked = 0usize;
let mut meshes_non_empty = 0usize;
let mut bounds_non_empty = 0usize;
for archive_path in archives {
let archive = Archive::open_path(&archive_path)
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
for entry in archive.entries() {
if !entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
continue;
}
models_checked += 1;
let payload = archive.read(entry.id).unwrap_or_else(|err| {
panic!(
"failed to read model '{}' from {}: {err}",
entry.meta.name,
archive_path.display()
)
});
let model = parse_model_payload(payload.as_slice()).unwrap_or_else(|err| {
panic!(
"failed to parse model '{}' from {}: {err}",
entry.meta.name,
archive_path.display()
)
});
let mesh = build_render_mesh(&model, 0, 0);
if !mesh.indices.is_empty() {
meshes_non_empty += 1;
}
if compute_bounds_for_mesh(&mesh.vertices).is_some() {
bounds_non_empty += 1;
}
for &index in &mesh.indices {
assert!(
usize::from(index) < mesh.vertices.len(),
"index out of bounds for '{}' in {}",
entry.meta.name,
archive_path.display()
);
}
for vertex in &mesh.vertices {
assert!(
vertex.uv0[0].is_finite() && vertex.uv0[1].is_finite(),
"UV must be finite for '{}' in {}",
entry.meta.name,
archive_path.display()
);
}
}
}
assert!(models_checked > 0, "no MSH models found");
assert!(
meshes_non_empty > 0,
"all generated render meshes are empty"
);
assert_eq!(
meshes_non_empty, bounds_non_empty,
"bounds must be available for every non-empty mesh"
);
}
#[test]
fn compute_bounds_handles_empty_and_non_empty() {
assert!(compute_bounds(&[]).is_none());
let bounds = compute_bounds(&[[1.0, 2.0, 3.0], [-2.0, 5.0, 0.5], [0.0, -1.0, 9.0]])
.expect("bounds expected");
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
}
#[test]
fn compute_bounds_for_mesh_handles_empty_and_non_empty() {
assert!(compute_bounds_for_mesh(&[]).is_none());
let bounds = compute_bounds_for_mesh(&[
RenderVertex {
position: [1.0, 2.0, 3.0],
uv0: [0.0, 0.0],
},
RenderVertex {
position: [-2.0, 5.0, 0.5],
uv0: [0.2, 0.3],
},
RenderVertex {
position: [0.0, -1.0, 9.0],
uv0: [1.0, 1.0],
},
])
.expect("bounds expected");
assert_eq!(bounds.0, [-2.0, -1.0, 0.5]);
assert_eq!(bounds.1, [1.0, 5.0, 9.0]);
}
fn nodes_with_slot_refs(slot_ids: &[Option<u16>]) -> Vec<u8> {
let mut out = vec![0u8; slot_ids.len().saturating_mul(38)];
for (node_index, slot_id) in slot_ids.iter().copied().enumerate() {
let node_off = node_index * 38;
for i in 0..15 {
let off = node_off + 8 + i * 2;
out[off..off + 2].copy_from_slice(&u16::MAX.to_le_bytes());
}
if let Some(slot_id) = slot_id {
out[node_off + 8..node_off + 10].copy_from_slice(&slot_id.to_le_bytes());
}
}
out
}
fn slot(batch_start: u16, batch_count: u16) -> msh_core::Slot {
msh_core::Slot {
tri_start: 0,
tri_count: 0,
batch_start,
batch_count,
aabb_min: [0.0; 3],
aabb_max: [0.0; 3],
sphere_center: [0.0; 3],
sphere_radius: 0.0,
opaque: [0; 5],
}
}
fn batch(index_start: u32, index_count: u16, base_vertex: u32) -> msh_core::Batch {
msh_core::Batch {
batch_flags: 0,
material_index: 0,
opaque4: 0,
opaque6: 0,
index_count,
index_start,
opaque14: 0,
base_vertex,
}
}
#[test]
fn build_render_mesh_handles_empty_slot_model() {
let model = msh_core::Model {
node_stride: 38,
node_count: 1,
nodes_raw: nodes_with_slot_refs(&[None]),
slots: Vec::new(),
positions: vec![[0.0, 0.0, 0.0]],
normals: None,
uv0: None,
indices: Vec::new(),
batches: Vec::new(),
node_names: None,
};
let mesh = build_render_mesh(&model, 0, 0);
assert!(mesh.vertices.is_empty());
assert!(mesh.indices.is_empty());
assert_eq!(mesh.batch_count, 0);
assert_eq!(mesh.triangle_count(), 0);
}
#[test]
fn build_render_mesh_supports_multi_node_and_uv_scaling() {
let model = msh_core::Model {
node_stride: 38,
node_count: 2,
nodes_raw: nodes_with_slot_refs(&[Some(0), Some(1)]),
slots: vec![slot(0, 1), slot(1, 1)],
positions: vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[2.0, 0.0, 0.0],
[3.0, 0.0, 0.0],
[2.0, 1.0, 0.0],
],
normals: None,
uv0: Some(vec![
[1024, -1024],
[512, 256],
[0, 0],
[1024, 1024],
[2048, 1024],
[1024, 0],
]),
indices: vec![0, 1, 2, 0, 1, 2],
batches: vec![batch(0, 3, 0), batch(3, 3, 3)],
node_names: None,
};
let mesh = build_render_mesh(&model, 0, 0);
assert_eq!(mesh.batch_count, 2);
assert_eq!(mesh.vertices.len(), 6);
assert_eq!(mesh.indices, vec![0, 1, 2, 3, 4, 5]);
assert_eq!(mesh.triangle_count(), 2);
assert_eq!(mesh.vertices[0].uv0, [1.0, -1.0]);
assert_eq!(mesh.vertices[1].uv0, [0.5, 0.25]);
assert_eq!(mesh.vertices[2].uv0, [0.0, 0.0]);
assert_eq!(mesh.vertices[3].uv0, [1.0, 1.0]);
}
#[test]
fn build_render_mesh_deduplicates_shared_vertices() {
let model = msh_core::Model {
node_stride: 38,
node_count: 1,
nodes_raw: nodes_with_slot_refs(&[Some(0)]),
slots: vec![slot(0, 1)],
positions: vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
[1.0, 1.0, 0.0],
],
normals: None,
uv0: None,
indices: vec![0, 1, 2, 2, 1, 3],
batches: vec![batch(0, 6, 0)],
node_names: None,
};
let mesh = build_render_mesh(&model, 0, 0);
assert_eq!(mesh.vertices.len(), 4);
assert_eq!(mesh.indices, vec![0, 1, 2, 2, 1, 3]);
assert_eq!(mesh.triangle_count(), 2);
}

View File

@@ -0,0 +1,31 @@
[package]
name = "render-demo"
version = "0.1.0"
edition = "2021"
[features]
default = []
demo = ["dep:sdl2", "dep:glow", "dep:image"]
[dependencies]
encoding_rs = "0.8"
msh-core = { path = "../msh-core" }
nres = { path = "../nres" }
render-core = { path = "../render-core" }
texm = { path = "../texm" }
glow = { version = "0.16", optional = true }
image = { version = "0.25", optional = true, default-features = false, features = ["png"] }
[dev-dependencies]
common = { path = "../common" }
[target.'cfg(target_os = "macos")'.dependencies]
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["use-pkgconfig"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
[[bin]]
name = "parkan-render-demo"
path = "src/main.rs"
required-features = ["demo"]

View File

@@ -0,0 +1,84 @@
# render-demo
Тестовый рендерер Parkan-моделей на Rust (`SDL2 + OpenGL`: GLES2 с fallback на Core 3.3).
## Назначение
- Проверить, что `nres + msh-core + render-core` дают рабочий draw-path на реальных ассетах.
- Проверить текстурный path `WEAR -> MAT0 -> Texm` на реальных ассетах.
- Служить минимальным reference-приложением.
## Запуск
```bash
cargo run -p render-demo --features demo -- \
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
--model "A_L_01.msh" \
--lod 0 \
--group 0
```
### macOS prerequisites
Для macOS `render-demo` ожидает системный SDL2 через `pkg-config`:
```bash
brew install sdl2 pkg-config
```
После этого запускайте той же командой `cargo run ... --features demo`.
Параметры:
- `--archive` (обязательный): NRes-архив с `.msh` entry.
- `--model` (опционально): имя модели; если не задано, берётся первая `.msh`.
- `--lod` (опционально, default `0`).
- `--group` (опционально, default `0`).
- `--width`, `--height` (опционально, default `1280x720`).
- `--angle` (опционально): фиксированный угол поворота вокруг Y (в радианах).
- `--spin-rate` (опционально, default `0.35`): скорость вращения в интерактивном режиме.
- В интерактивном режиме FPS выводится в заголовок окна и в stdout (обновление примерно каждые 0.5 сек).
- `--texture <name>`: явное имя `Texm` (override авто-резолва).
- `--texture-archive <path>`: путь к архиву текстур (по умолчанию `textures.lib` рядом с `--archive`).
- `--material-archive <path>`: путь к `material.lib` (по умолчанию соседний `material.lib`).
- `--wear <name.wea>`: имя wear-entry внутри модельного архива (по умолчанию `<model_stem>.wea`).
- `--no-texture`: отключить текстуры и рендерить однотонным цветом.
## Авто-резолв текстуры
Если не передан `--texture`, демо пытается взять текстуру из игровых данных:
1. `model.msh -> model.wea` (первый wear-материал),
2. `material.lib` (`MAT0`) по имени материала с fallback `DEFAULT`,
3. первая непустая `textureName` фаза материала,
4. загрузка `Texm` из `textures.lib` (или `lightmap.lib` как fallback).
## Детерминированный снимок кадра
Для parity-проверок используется headless-сценарий с фиксированными параметрами:
```bash
cargo run -p render-demo --features demo -- \
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
--model "A_L_01.msh" \
--lod 0 \
--group 0 \
--width 1280 \
--height 720 \
--angle 0.0 \
--capture "target/render-parity/current/animals_a_l_01.png"
```
Явный выбор текстуры:
```bash
cargo run -p render-demo --features demo -- \
--archive "testdata/Parkan - Iron Strategy/animals.rlb" \
--model "A_L_01.msh" \
--texture "PG09.0"
```
## Ограничения
- Используется только базовая texture-фаза (без полной material/fx анимации).
- Вывод через `glDrawElements(GL_TRIANGLES)` с index-buffer (позиции+UV).

View File

@@ -0,0 +1,4 @@
fn main() {
#[cfg(windows)]
println!("cargo:rustc-link-lib=advapi32");
}

View File

@@ -0,0 +1,591 @@
use encoding_rs::WINDOWS_1251;
use msh_core::{parse_model_payload, Model};
use nres::{Archive, EntryRef};
use std::fmt;
use std::path::{Path, PathBuf};
use texm::{decode_mip_rgba8, parse_texm};
const WEAR_KIND: u32 = 0x5241_4557;
const MAT0_KIND: u32 = 0x3054_414D;
#[derive(Debug)]
pub enum Error {
Nres(nres::error::Error),
Msh(msh_core::error::Error),
Texm(texm::error::Error),
Io(std::io::Error),
NoMshEntries,
ModelNotFound(String),
NoTexmEntries,
TextureNotFound(String),
MaterialNotFound(String),
WearNotFound(String),
InvalidWear(String),
InvalidMaterial(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Nres(err) => write!(f, "{err}"),
Self::Msh(err) => write!(f, "{err}"),
Self::Texm(err) => write!(f, "{err}"),
Self::Io(err) => write!(f, "{err}"),
Self::NoMshEntries => write!(f, "archive does not contain .msh entries"),
Self::ModelNotFound(name) => write!(f, "model not found: {name}"),
Self::NoTexmEntries => write!(f, "archive does not contain Texm entries"),
Self::TextureNotFound(name) => write!(f, "texture not found: {name}"),
Self::MaterialNotFound(name) => write!(f, "material not found: {name}"),
Self::WearNotFound(name) => write!(f, "wear entry not found: {name}"),
Self::InvalidWear(reason) => write!(f, "invalid WEAR payload: {reason}"),
Self::InvalidMaterial(reason) => write!(f, "invalid MAT0 payload: {reason}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Nres(err) => Some(err),
Self::Msh(err) => Some(err),
Self::Texm(err) => Some(err),
Self::Io(err) => Some(err),
_ => None,
}
}
}
impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self {
Self::Nres(value)
}
}
impl From<msh_core::error::Error> for Error {
fn from(value: msh_core::error::Error) -> Self {
Self::Msh(value)
}
}
impl From<texm::error::Error> for Error {
fn from(value: texm::error::Error) -> Self {
Self::Texm(value)
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Clone, Debug)]
pub struct LoadedModel {
pub name: String,
pub model: Model,
}
#[derive(Clone, Debug)]
pub struct LoadedTexture {
pub name: String,
pub width: u32,
pub height: u32,
pub rgba8: Vec<u8>,
}
pub fn load_model_with_name_from_archive(
path: &Path,
model_name: Option<&str>,
) -> Result<LoadedModel> {
let archive = Archive::open_path(path)?;
let mut msh_entries = Vec::new();
for entry in archive.entries() {
if entry.meta.name.to_ascii_lowercase().ends_with(".msh") {
msh_entries.push((entry.id, entry.meta.name.clone()));
}
}
if msh_entries.is_empty() {
return Err(Error::NoMshEntries);
}
let target_id = if let Some(name) = model_name {
msh_entries
.iter()
.find(|(_, n)| n.eq_ignore_ascii_case(name))
.map(|(id, _)| *id)
.ok_or_else(|| Error::ModelNotFound(name.to_string()))?
} else {
msh_entries[0].0
};
let target_name = archive
.get(target_id)
.map(|entry| entry.meta.name.clone())
.unwrap_or_else(|| String::from("<unknown>"));
let payload = archive.read(target_id)?;
Ok(LoadedModel {
name: target_name,
model: parse_model_payload(payload.as_slice())?,
})
}
pub fn load_model_from_archive(path: &Path, model_name: Option<&str>) -> Result<Model> {
Ok(load_model_with_name_from_archive(path, model_name)?.model)
}
pub fn load_texture_from_archive(path: &Path, texture_name: Option<&str>) -> Result<LoadedTexture> {
let archive = Archive::open_path(path)?;
if let Some(name) = texture_name {
return load_texture_from_archive_by_name(&archive, name);
}
let mut texm_entries = archive
.entries()
.filter(|entry| entry.meta.kind == texm::TEXM_MAGIC)
.collect::<Vec<_>>();
if texm_entries.is_empty() {
return Err(Error::NoTexmEntries);
}
texm_entries.sort_by(|a, b| {
a.meta
.name
.to_ascii_lowercase()
.cmp(&b.meta.name.to_ascii_lowercase())
});
let first = texm_entries[0];
decode_texture_entry(&archive, first)
}
pub fn resolve_texture_for_model(
model_archive_path: &Path,
model_entry_name: &str,
texture_name_override: Option<&str>,
textures_archive_override: Option<&Path>,
material_archive_override: Option<&Path>,
wear_entry_override: Option<&str>,
) -> Result<Option<LoadedTexture>> {
if let Some(name) = texture_name_override {
return load_texture_by_name_from_candidate_archives(
name,
candidate_texture_archives(model_archive_path, textures_archive_override),
)
.map(Some);
}
let wear_entry_name = if let Some(name) = wear_entry_override {
name.to_string()
} else {
derive_wear_entry_name(model_entry_name).ok_or_else(|| {
Error::WearNotFound(format!(
"cannot derive WEAR name from model '{model_entry_name}'"
))
})?
};
let model_archive = Archive::open_path(model_archive_path)?;
let wear_materials = parse_wear_material_names(
read_entry_by_name_kind(&model_archive, &wear_entry_name, WEAR_KIND)?
.0
.as_slice(),
)?;
let Some(primary_material) = wear_materials.first() else {
return Ok(None);
};
let material_path = if let Some(path) = material_archive_override {
path.to_path_buf()
} else {
sibling_archive_path(model_archive_path, "material.lib")
.ok_or_else(|| Error::MaterialNotFound(String::from("material.lib")))?
};
let material_archive = Archive::open_path(&material_path)?;
let material_entry = find_material_entry_with_fallback(&material_archive, primary_material)?;
let material_payload = material_archive.read(material_entry.id)?.into_owned();
let texture_name =
parse_primary_texture_name_from_mat0(&material_payload, material_entry.meta.attr2)?;
let Some(texture_name) = texture_name else {
return Ok(None);
};
let texture = load_texture_by_name_from_candidate_archives(
&texture_name,
candidate_texture_archives(model_archive_path, textures_archive_override),
)?;
Ok(Some(texture))
}
fn load_texture_by_name_from_candidate_archives(
texture_name: &str,
archives: Vec<PathBuf>,
) -> Result<LoadedTexture> {
let mut last_not_found = None;
for archive_path in archives {
if !archive_path.is_file() {
continue;
}
let archive = Archive::open_path(&archive_path)?;
match load_texture_from_archive_by_name(&archive, texture_name) {
Ok(texture) => return Ok(texture),
Err(Error::TextureNotFound(name)) => {
last_not_found = Some(name);
}
Err(other) => return Err(other),
}
}
Err(Error::TextureNotFound(
last_not_found.unwrap_or_else(|| texture_name.to_string()),
))
}
fn candidate_texture_archives(
model_archive_path: &Path,
textures_archive_override: Option<&Path>,
) -> Vec<PathBuf> {
if let Some(path) = textures_archive_override {
return vec![path.to_path_buf()];
}
let mut out = Vec::new();
if let Some(path) = sibling_archive_path(model_archive_path, "textures.lib") {
out.push(path);
}
if let Some(path) = sibling_archive_path(model_archive_path, "lightmap.lib") {
out.push(path);
}
out
}
fn sibling_archive_path(model_archive_path: &Path, name: &str) -> Option<PathBuf> {
let parent = model_archive_path.parent()?;
Some(parent.join(name))
}
fn derive_wear_entry_name(model_entry_name: &str) -> Option<String> {
let stem = model_entry_name.rsplit_once('.').map(|(left, _)| left)?;
Some(format!("{stem}.wea"))
}
fn read_entry_by_name_kind(
archive: &Archive,
name: &str,
expected_kind: u32,
) -> Result<(Vec<u8>, String)> {
let Some(id) = archive.find(name) else {
return Err(Error::WearNotFound(name.to_string()));
};
let Some(entry) = archive.get(id) else {
return Err(Error::WearNotFound(name.to_string()));
};
if entry.meta.kind != expected_kind {
return Err(Error::WearNotFound(name.to_string()));
}
let payload = archive.read(id)?.into_owned();
Ok((payload, entry.meta.name.clone()))
}
fn find_material_entry_with_fallback<'a>(
archive: &'a Archive,
requested_name: &str,
) -> Result<EntryRef<'a>> {
if let Some(id) = archive.find(requested_name) {
if let Some(entry) = archive.get(id) {
if entry.meta.kind == MAT0_KIND {
return Ok(entry);
}
}
}
if let Some(id) = archive.find("DEFAULT") {
if let Some(entry) = archive.get(id) {
if entry.meta.kind == MAT0_KIND {
return Ok(entry);
}
}
}
let Some(entry) = archive.entries().find(|entry| entry.meta.kind == MAT0_KIND) else {
return Err(Error::MaterialNotFound(requested_name.to_string()));
};
Ok(entry)
}
fn parse_wear_material_names(payload: &[u8]) -> Result<Vec<String>> {
let text = decode_cp1251(payload).replace('\r', "");
let mut lines = text.lines();
let Some(first) = lines.next() else {
return Err(Error::InvalidWear(String::from("WEAR payload is empty")));
};
let count = first
.trim()
.parse::<usize>()
.map_err(|_| Error::InvalidWear(format!("invalid wearCount line: '{first}'")))?;
if count == 0 {
return Err(Error::InvalidWear(String::from("wearCount must be > 0")));
}
let mut materials = Vec::with_capacity(count);
for idx in 0..count {
let Some(line) = lines.next() else {
return Err(Error::InvalidWear(format!(
"missing material line {idx} of {count}"
)));
};
let mut parts = line.split_whitespace();
let _legacy = parts
.next()
.ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
let name = parts
.next()
.ok_or_else(|| Error::InvalidWear(format!("invalid material line {idx}: '{line}'")))?;
materials.push(name.to_string());
}
Ok(materials)
}
fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
if payload.len() < 4 {
return Err(Error::InvalidMaterial(String::from(
"MAT0 payload is too small for header",
)));
}
let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
if phase_count == 0 {
return Ok(None);
}
let mut offset = 4usize;
if attr2 >= 2 {
offset = offset
.checked_add(2)
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
}
if attr2 >= 3 {
offset = offset
.checked_add(4)
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
}
if attr2 >= 4 {
offset = offset
.checked_add(4)
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 offset overflow")))?;
}
for phase in 0..phase_count {
let phase_off = offset
.checked_add(phase.checked_mul(34).ok_or_else(|| {
Error::InvalidMaterial(String::from("MAT0 phase offset overflow"))
})?)
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
let phase_end = phase_off
.checked_add(34)
.ok_or_else(|| Error::InvalidMaterial(String::from("MAT0 phase offset overflow")))?;
let Some(rec) = payload.get(phase_off..phase_end) else {
return Err(Error::InvalidMaterial(format!(
"MAT0 phase {phase} is out of bounds"
)));
};
let name_raw = &rec[18..34];
let name_end = name_raw
.iter()
.position(|&b| b == 0)
.unwrap_or(name_raw.len());
let name = decode_cp1251(&name_raw[..name_end]).trim().to_string();
if !name.is_empty() {
return Ok(Some(name));
}
}
Ok(None)
}
fn decode_cp1251(bytes: &[u8]) -> String {
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
decoded.into_owned()
}
fn load_texture_from_archive_by_name(archive: &Archive, name: &str) -> Result<LoadedTexture> {
let Some(id) = archive.find(name) else {
return Err(Error::TextureNotFound(name.to_string()));
};
let Some(entry) = archive.get(id) else {
return Err(Error::TextureNotFound(name.to_string()));
};
if entry.meta.kind != texm::TEXM_MAGIC {
return Err(Error::TextureNotFound(name.to_string()));
}
decode_texture_entry(archive, entry)
}
fn decode_texture_entry(archive: &Archive, entry: EntryRef<'_>) -> Result<LoadedTexture> {
let payload = archive.read(entry.id)?.into_owned();
let parsed = parse_texm(&payload)?;
let decoded = decode_mip_rgba8(&parsed, &payload, 0)?;
Ok(LoadedTexture {
name: entry.meta.name.clone(),
width: decoded.width,
height: decoded.height,
rgba8: decoded.rgba8,
})
}
#[cfg(test)]
mod tests {
use super::*;
use common::collect_files_recursive;
use std::fs;
use std::path::{Path, PathBuf};
fn archive_with_msh() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
let mut files = Vec::new();
collect_files_recursive(&root, &mut files);
files.sort();
for path in files {
let Ok(bytes) = fs::read(&path) else {
continue;
};
if bytes.get(0..4) != Some(b"NRes") {
continue;
}
let Ok(archive) = Archive::open_path(&path) else {
continue;
};
if archive
.entries()
.any(|entry| entry.meta.name.to_ascii_lowercase().ends_with(".msh"))
{
return Some(path);
}
}
None
}
fn game_root() -> Option<PathBuf> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
if path.is_dir() {
Some(path)
} else {
None
}
}
#[test]
fn load_model_from_real_archive() {
let Some(path) = archive_with_msh() else {
eprintln!("skipping load_model_from_real_archive: no .msh archives in testdata");
return;
};
let model = load_model_from_archive(&path, None)
.unwrap_or_else(|err| panic!("failed to load model from {}: {err:?}", path.display()));
assert!(model.node_count > 0);
assert!(!model.positions.is_empty());
assert!(!model.indices.is_empty());
}
#[test]
fn resolve_texture_for_real_model_via_wear_and_material() {
let Some(root) = game_root() else {
eprintln!(
"skipping resolve_texture_for_real_model_via_wear_and_material: no game root"
);
return;
};
let archive = root.join("animals.rlb");
if !archive.is_file() {
eprintln!("skipping resolve_texture_for_real_model_via_wear_and_material: missing animals.rlb");
return;
}
let loaded = load_model_with_name_from_archive(&archive, Some("A_L_01.msh"))
.unwrap_or_else(|err| {
panic!(
"failed to load model A_L_01.msh from {}: {err:?}",
archive.display()
)
});
let texture = resolve_texture_for_model(&archive, &loaded.name, None, None, None, None)
.unwrap_or_else(|err| panic!("failed to resolve texture for {}: {err:?}", loaded.name))
.expect("texture must be resolved for A_L_01.msh");
assert!(texture.width > 0 && texture.height > 0);
assert_eq!(
texture.rgba8.len(),
usize::try_from(texture.width)
.ok()
.and_then(|w| usize::try_from(texture.height).ok().map(|h| w * h * 4))
.unwrap_or(0)
);
}
#[test]
fn load_first_texture_from_real_archive() {
let Some(root) = game_root() else {
eprintln!("skipping load_first_texture_from_real_archive: no game root");
return;
};
let archive = root.join("textures.lib");
if !archive.is_file() {
eprintln!("skipping load_first_texture_from_real_archive: missing textures.lib");
return;
}
let texture = load_texture_from_archive(&archive, None).unwrap_or_else(|err| {
panic!(
"failed to load first texture from {}: {err:?}",
archive.display()
)
});
assert!(texture.width > 0 && texture.height > 0);
assert!(!texture.rgba8.is_empty());
}
#[test]
fn parse_wear_material_names_parses_counted_lines() {
let payload = b"2\r\n0 MAT_A\r\n1 MAT_B\r\n";
let materials =
parse_wear_material_names(payload).expect("failed to parse valid WEAR payload");
assert_eq!(materials, vec!["MAT_A".to_string(), "MAT_B".to_string()]);
}
#[test]
fn parse_wear_material_names_rejects_invalid_payload() {
let payload = b"2\n0 ONLY_ONE\n";
assert!(matches!(
parse_wear_material_names(payload),
Err(Error::InvalidWear(_))
));
}
#[test]
fn parse_primary_texture_name_from_mat0_respects_attr2_layout() {
let mut payload = vec![0u8; 4 + 10 + 34];
payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
// attr2=4 adds 10 bytes before phase table
let name = b"TEX_MAIN";
payload[4 + 10 + 18..4 + 10 + 18 + name.len()].copy_from_slice(name);
let parsed = parse_primary_texture_name_from_mat0(&payload, 4)
.expect("failed to parse MAT0 payload with attr2=4");
assert_eq!(parsed, Some("TEX_MAIN".to_string()));
}
#[test]
fn parse_primary_texture_name_from_mat0_decodes_cp1251_bytes() {
let mut payload = vec![0u8; 4 + 34];
payload[0..2].copy_from_slice(&1u16.to_le_bytes()); // phase_count
payload[4 + 18] = 0xC0; // 'А' in CP1251
let parsed =
parse_primary_texture_name_from_mat0(&payload, 0).expect("failed to parse MAT0");
assert_eq!(parsed, Some("А".to_string()));
}
}

View File

@@ -0,0 +1,997 @@
use glow::HasContext as _;
use render_core::{build_render_mesh, compute_bounds_for_mesh};
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
struct Args {
archive: PathBuf,
model: Option<String>,
lod: usize,
group: usize,
width: u32,
height: u32,
fov_deg: f32,
capture: Option<PathBuf>,
angle: Option<f32>,
spin_rate: f32,
texture: Option<String>,
texture_archive: Option<PathBuf>,
material_archive: Option<PathBuf>,
wear: Option<String>,
no_texture: bool,
}
struct GpuTexture {
handle: glow::NativeTexture,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum GlBackend {
Gles2,
Core33,
}
fn parse_args() -> Result<Args, String> {
let mut archive = None;
let mut model = None;
let mut lod = 0usize;
let mut group = 0usize;
let mut width = 1280u32;
let mut height = 720u32;
let mut fov_deg = 60.0f32;
let mut capture = None;
let mut angle = None;
let mut spin_rate = 0.35f32;
let mut texture = None;
let mut texture_archive = None;
let mut material_archive = None;
let mut wear = None;
let mut no_texture = false;
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--archive" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --archive"))?;
archive = Some(PathBuf::from(value));
}
"--model" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --model"))?;
model = Some(value);
}
"--lod" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --lod"))?;
lod = value
.parse::<usize>()
.map_err(|_| String::from("invalid --lod value"))?;
}
"--group" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --group"))?;
group = value
.parse::<usize>()
.map_err(|_| String::from("invalid --group value"))?;
}
"--width" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --width"))?;
width = value
.parse::<u32>()
.map_err(|_| String::from("invalid --width value"))?;
if width == 0 {
return Err(String::from("--width must be > 0"));
}
}
"--height" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --height"))?;
height = value
.parse::<u32>()
.map_err(|_| String::from("invalid --height value"))?;
if height == 0 {
return Err(String::from("--height must be > 0"));
}
}
"--fov" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --fov"))?;
fov_deg = value
.parse::<f32>()
.map_err(|_| String::from("invalid --fov value"))?;
if !(1.0..=179.0).contains(&fov_deg) {
return Err(String::from("--fov must be in range [1, 179]"));
}
}
"--capture" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --capture"))?;
capture = Some(PathBuf::from(value));
}
"--angle" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --angle"))?;
angle = Some(
value
.parse::<f32>()
.map_err(|_| String::from("invalid --angle value"))?,
);
}
"--spin-rate" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --spin-rate"))?;
spin_rate = value
.parse::<f32>()
.map_err(|_| String::from("invalid --spin-rate value"))?;
}
"--texture" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --texture"))?;
texture = Some(value);
}
"--texture-archive" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --texture-archive"))?;
texture_archive = Some(PathBuf::from(value));
}
"--material-archive" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --material-archive"))?;
material_archive = Some(PathBuf::from(value));
}
"--wear" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --wear"))?;
wear = Some(value);
}
"--no-texture" => {
no_texture = true;
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => {
return Err(format!("unknown argument: {other}"));
}
}
}
let archive = archive.ok_or_else(|| String::from("missing required --archive"))?;
Ok(Args {
archive,
model,
lod,
group,
width,
height,
fov_deg,
capture,
angle,
spin_rate,
texture,
texture_archive,
material_archive,
wear,
no_texture,
})
}
fn print_help() {
eprintln!(
"parkan-render-demo --archive <path> [--model <name.msh>] [--lod N] [--group N] [--width W] [--height H] [--fov DEG]"
);
eprintln!(" [--capture <out.png>] [--angle RAD] [--spin-rate RAD_PER_SEC]");
eprintln!(" [--texture <name>] [--texture-archive <path>] [--material-archive <path>] [--wear <name.wea>] [--no-texture]");
}
fn main() {
let args = match parse_args() {
Ok(v) => v,
Err(err) => {
eprintln!("{err}");
print_help();
std::process::exit(2);
}
};
if let Err(err) = run(args) {
eprintln!("{err}");
std::process::exit(1);
}
}
fn run(args: Args) -> Result<(), String> {
let loaded_model = load_model_with_name_from_archive(&args.archive, args.model.as_deref())
.map_err(|err| {
format!(
"failed to load model from archive {}: {err}",
args.archive.display()
)
})?;
let mesh = build_render_mesh(&loaded_model.model, args.lod, args.group);
if mesh.indices.is_empty() {
return Err(format!(
"model has no renderable triangles for lod={} group={}",
args.lod, args.group
));
}
if mesh.index_overflow {
eprintln!(
"warning: mesh exceeds u16 index space and may be partially rendered on GLES2 targets"
);
}
let Some((bounds_min, bounds_max)) = compute_bounds_for_mesh(&mesh.vertices) else {
return Err(String::from("failed to compute mesh bounds"));
};
let resolved_texture = resolve_texture(&args, &loaded_model.name)?;
if let Some(tex) = resolved_texture.as_ref() {
println!(
"resolved texture '{}' ({}x{})",
tex.name, tex.width, tex.height
);
} else {
println!("texture path disabled or unresolved; rendering with fallback color");
}
let center = [
0.5 * (bounds_min[0] + bounds_max[0]),
0.5 * (bounds_min[1] + bounds_max[1]),
0.5 * (bounds_min[2] + bounds_max[2]),
];
let extent = [
bounds_max[0] - bounds_min[0],
bounds_max[1] - bounds_min[1],
bounds_max[2] - bounds_min[2],
];
let radius =
(extent[0] * extent[0] + extent[1] * extent[1] + extent[2] * extent[2]).sqrt() * 0.5;
let camera_distance = (radius * 2.5).max(2.0);
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
let video = sdl
.video()
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
let (mut window, _gl_ctx, gl_backend) = create_window_and_context(&video, &args)?;
let _ = if args.capture.is_some() {
video.gl_set_swap_interval(0)
} else {
video.gl_set_swap_interval(1)
};
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
for vertex in &mesh.vertices {
vertex_data.push(vertex.position[0]);
vertex_data.push(vertex.position[1]);
vertex_data.push(vertex.position[2]);
vertex_data.push(vertex.uv0[0]);
vertex_data.push(vertex.uv0[1]);
}
let vertex_bytes = f32_slice_to_ne_bytes(&vertex_data);
let index_bytes = u16_slice_to_ne_bytes(&mesh.indices);
let gl = unsafe {
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
};
let program = unsafe { create_program(&gl, gl_backend)? };
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
let vbo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
let ebo = unsafe { gl.create_buffer().map_err(|e| e.to_string())? };
unsafe {
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
}
let vao = unsafe { create_vertex_layout_if_needed(&gl, gl_backend, vbo, ebo, a_pos, a_uv)? };
let gpu_texture = if let Some(texture) = resolved_texture.as_ref() {
Some(unsafe { create_texture(&gl, texture)? })
} else {
None
};
let result = if let Some(capture_path) = args.capture.as_ref() {
run_capture(
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
vbo,
ebo,
vao,
gpu_texture.as_ref(),
mesh.indices.len(),
&args,
center,
camera_distance,
capture_path,
)
} else {
run_interactive(
&sdl,
&mut window,
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
vbo,
ebo,
vao,
gpu_texture.as_ref(),
mesh.indices.len(),
&args,
center,
camera_distance,
)
};
unsafe {
if let Some(texture) = gpu_texture {
gl.delete_texture(texture.handle);
}
if let Some(vao) = vao {
gl.delete_vertex_array(vao);
}
gl.delete_buffer(ebo);
gl.delete_buffer(vbo);
gl.delete_program(program);
}
result
}
fn create_window_and_context(
video: &sdl2::VideoSubsystem,
args: &Args,
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
let candidates = [
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
];
let mut errors = Vec::new();
for (backend, profile, major, minor) in candidates {
{
let gl_attr = video.gl_attr();
gl_attr.set_context_profile(profile);
gl_attr.set_context_version(major, minor);
gl_attr.set_depth_size(24);
gl_attr.set_double_buffer(true);
}
let mut window_builder = video.window(
"Parkan Render Demo (SDL2 + OpenGL)",
args.width,
args.height,
);
window_builder.opengl();
if args.capture.is_some() {
window_builder.hidden();
} else {
window_builder.resizable();
}
let window = match window_builder.build() {
Ok(window) => window,
Err(err) => {
errors.push(format!(
"{profile:?} {major}.{minor}: window build failed ({err})"
));
continue;
}
};
let gl_ctx = match window.gl_create_context() {
Ok(ctx) => ctx,
Err(err) => {
errors.push(format!(
"{profile:?} {major}.{minor}: context create failed ({err})"
));
continue;
}
};
if let Err(err) = window.gl_make_current(&gl_ctx) {
errors.push(format!(
"{profile:?} {major}.{minor}: make current failed ({err})"
));
continue;
}
return Ok((window, gl_ctx, backend));
}
Err(format!(
"failed to create OpenGL context. Attempts: {}",
errors.join(" | ")
))
}
unsafe fn create_vertex_layout_if_needed(
gl: &glow::Context,
backend: GlBackend,
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
a_pos: u32,
a_uv: u32,
) -> Result<Option<glow::NativeVertexArray>, String> {
if backend != GlBackend::Core33 {
return Ok(None);
}
let vao = gl.create_vertex_array().map_err(|e| e.to_string())?;
gl.bind_vertex_array(Some(vao));
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
gl.enable_vertex_attrib_array(a_pos);
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
gl.enable_vertex_attrib_array(a_uv);
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
gl.bind_vertex_array(None);
Ok(Some(vao))
}
fn resolve_texture(args: &Args, model_name: &str) -> Result<Option<LoadedTexture>, String> {
if args.no_texture {
return Ok(None);
}
match resolve_texture_for_model(
&args.archive,
model_name,
args.texture.as_deref(),
args.texture_archive.as_deref(),
args.material_archive.as_deref(),
args.wear.as_deref(),
) {
Ok(texture) => Ok(texture),
Err(err) => {
if args.texture.is_some()
|| args.texture_archive.is_some()
|| args.material_archive.is_some()
|| args.wear.is_some()
{
Err(format!("failed to resolve texture: {err}"))
} else {
eprintln!("warning: auto texture resolve failed ({err}), fallback to solid color");
Ok(None)
}
}
}
}
unsafe fn create_texture(
gl: &glow::Context,
texture: &LoadedTexture,
) -> Result<GpuTexture, String> {
let handle = gl.create_texture().map_err(|e| e.to_string())?;
gl.bind_texture(glow::TEXTURE_2D, Some(handle));
gl.tex_parameter_i32(
glow::TEXTURE_2D,
glow::TEXTURE_MIN_FILTER,
glow::LINEAR as i32,
);
gl.tex_parameter_i32(
glow::TEXTURE_2D,
glow::TEXTURE_MAG_FILTER,
glow::LINEAR as i32,
);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA as i32,
texture.width.min(i32::MAX as u32) as i32,
texture.height.min(i32::MAX as u32) as i32,
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
);
gl.bind_texture(glow::TEXTURE_2D, None);
Ok(GpuTexture { handle })
}
#[allow(clippy::too_many_arguments)]
fn run_capture(
gl: &glow::Context,
program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>,
u_use_tex: Option<&glow::NativeUniformLocation>,
u_tex: Option<&glow::NativeUniformLocation>,
a_pos: u32,
a_uv: u32,
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
vao: Option<glow::NativeVertexArray>,
texture: Option<&GpuTexture>,
index_count: usize,
args: &Args,
center: [f32; 3],
camera_distance: f32,
capture_path: &Path,
) -> Result<(), String> {
let angle = args.angle.unwrap_or(0.0);
let mvp = compute_mvp(
args.width,
args.height,
args.fov_deg,
center,
camera_distance,
angle,
);
unsafe {
draw_frame(
gl,
program,
u_mvp,
u_use_tex,
u_tex,
a_pos,
a_uv,
vbo,
ebo,
vao,
texture,
index_count,
args.width,
args.height,
&mvp,
);
}
let mut rgba = unsafe { read_pixels_rgba(gl, args.width, args.height)? };
flip_image_y_rgba(&mut rgba, args.width as usize, args.height as usize);
save_png(capture_path, args.width, args.height, rgba)?;
println!("captured frame to {}", capture_path.display());
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn run_interactive(
sdl: &sdl2::Sdl,
window: &mut sdl2::video::Window,
gl: &glow::Context,
program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>,
u_use_tex: Option<&glow::NativeUniformLocation>,
u_tex: Option<&glow::NativeUniformLocation>,
a_pos: u32,
a_uv: u32,
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
vao: Option<glow::NativeVertexArray>,
texture: Option<&GpuTexture>,
index_count: usize,
args: &Args,
center: [f32; 3],
camera_distance: f32,
) -> Result<(), String> {
let mut events = sdl
.event_pump()
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
let start = Instant::now();
let mut fps_window_start = Instant::now();
let mut fps_frames: u32 = 0;
let mut fps_printed = false;
let base_title = "Parkan Render Demo (SDL2 + OpenGL)";
'main_loop: loop {
for event in events.poll_iter() {
match event {
sdl2::event::Event::Quit { .. } => break 'main_loop,
sdl2::event::Event::KeyDown {
keycode: Some(sdl2::keyboard::Keycode::Escape),
..
} => break 'main_loop,
_ => {}
}
}
let (w, h) = window.size();
let angle = args
.angle
.unwrap_or(start.elapsed().as_secs_f32() * args.spin_rate);
let mvp = compute_mvp(w, h, args.fov_deg, center, camera_distance, angle);
unsafe {
draw_frame(
gl,
program,
u_mvp,
u_use_tex,
u_tex,
a_pos,
a_uv,
vbo,
ebo,
vao,
texture,
index_count,
w,
h,
&mvp,
);
}
window.gl_swap_window();
fps_frames = fps_frames.saturating_add(1);
let elapsed = fps_window_start.elapsed();
if elapsed >= Duration::from_millis(500) {
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
let frame_time_ms = 1000.0 / fps.max(0.000_1);
let _ = window.set_title(&format!(
"{base_title} | FPS: {fps:.1} ({frame_time_ms:.2} ms)"
));
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
let _ = std::io::stdout().flush();
fps_printed = true;
fps_frames = 0;
fps_window_start = Instant::now();
}
}
if fps_printed {
println!();
}
Ok(())
}
fn compute_mvp(
width: u32,
height: u32,
fov_deg: f32,
center: [f32; 3],
camera_distance: f32,
angle_rad: f32,
) -> [f32; 16] {
let aspect = (width as f32 / (height.max(1) as f32)).max(0.01);
let proj = mat4_perspective(fov_deg.to_radians(), aspect, 0.01, camera_distance * 10.0);
let view = mat4_translation(0.0, 0.0, -camera_distance);
let center_shift = mat4_translation(-center[0], -center[1], -center[2]);
let rot = mat4_rotation_y(angle_rad);
let model_m = mat4_mul(&rot, &center_shift);
let vp = mat4_mul(&view, &model_m);
mat4_mul(&proj, &vp)
}
#[allow(clippy::too_many_arguments)]
unsafe fn draw_frame(
gl: &glow::Context,
program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>,
u_use_tex: Option<&glow::NativeUniformLocation>,
u_tex: Option<&glow::NativeUniformLocation>,
a_pos: u32,
a_uv: u32,
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
vao: Option<glow::NativeVertexArray>,
texture: Option<&GpuTexture>,
index_count: usize,
width: u32,
height: u32,
mvp: &[f32; 16],
) {
gl.viewport(
0,
0,
width.min(i32::MAX as u32) as i32,
height.min(i32::MAX as u32) as i32,
);
gl.enable(glow::DEPTH_TEST);
gl.clear_color(0.06, 0.08, 0.12, 1.0);
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
gl.use_program(Some(program));
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
let texture_enabled = texture.is_some();
gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
if let Some(tex) = texture {
gl.active_texture(glow::TEXTURE0);
gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
gl.uniform_1_i32(u_tex, 0);
} else {
gl.bind_texture(glow::TEXTURE_2D, None);
}
if let Some(vao) = vao {
gl.bind_vertex_array(Some(vao));
gl.draw_elements(
glow::TRIANGLES,
index_count.min(i32::MAX as usize) as i32,
glow::UNSIGNED_SHORT,
0,
);
gl.bind_vertex_array(None);
} else {
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
gl.enable_vertex_attrib_array(a_pos);
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
gl.enable_vertex_attrib_array(a_uv);
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
gl.draw_elements(
glow::TRIANGLES,
index_count.min(i32::MAX as usize) as i32,
glow::UNSIGNED_SHORT,
0,
);
gl.disable_vertex_attrib_array(a_uv);
gl.disable_vertex_attrib_array(a_pos);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
}
gl.bind_texture(glow::TEXTURE_2D, None);
gl.use_program(None);
}
unsafe fn read_pixels_rgba(gl: &glow::Context, width: u32, height: u32) -> Result<Vec<u8>, String> {
let pixel_count = usize::try_from(width)
.ok()
.and_then(|w| usize::try_from(height).ok().map(|h| w.saturating_mul(h)))
.ok_or_else(|| String::from("frame dimensions are too large"))?;
let mut pixels = vec![0u8; pixel_count.saturating_mul(4)];
gl.read_pixels(
0,
0,
width.min(i32::MAX as u32) as i32,
height.min(i32::MAX as u32) as i32,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelPackData::Slice(Some(pixels.as_mut_slice())),
);
Ok(pixels)
}
fn flip_image_y_rgba(rgba: &mut [u8], width: usize, height: usize) {
let stride = width.saturating_mul(4);
if stride == 0 {
return;
}
for y in 0..(height / 2) {
let top = y * stride;
let bottom = (height - 1 - y) * stride;
for i in 0..stride {
rgba.swap(top + i, bottom + i);
}
}
}
fn save_png(path: &Path, width: u32, height: u32, rgba: Vec<u8>) -> Result<(), String> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create output directory {}: {err}",
parent.display()
)
})?;
}
}
let image = image::RgbaImage::from_raw(width, height, rgba)
.ok_or_else(|| String::from("failed to build image from framebuffer bytes"))?;
image
.save(path)
.map_err(|err| format!("failed to save PNG {}: {err}", path.display()))
}
unsafe fn create_program(
gl: &glow::Context,
backend: GlBackend,
) -> Result<glow::NativeProgram, String> {
let (vs_src, fs_src) = match backend {
GlBackend::Gles2 => (
r#"
attribute vec3 a_pos;
attribute vec2 a_uv;
uniform mat4 u_mvp;
varying vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0);
}
"#,
r#"
precision mediump float;
uniform sampler2D u_tex;
uniform float u_use_tex;
varying vec2 v_uv;
void main() {
vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
vec4 texColor = texture2D(u_tex, v_uv);
gl_FragColor = mix(base, texColor, u_use_tex);
}
"#,
),
GlBackend::Core33 => (
r#"#version 330 core
in vec3 a_pos;
in vec2 a_uv;
uniform mat4 u_mvp;
out vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0);
}
"#,
r#"#version 330 core
uniform sampler2D u_tex;
uniform float u_use_tex;
in vec2 v_uv;
out vec4 fragColor;
void main() {
vec4 base = vec4(0.85, 0.90, 1.00, 1.0);
vec4 texColor = texture(u_tex, v_uv);
fragColor = mix(base, texColor, u_use_tex);
}
"#,
),
};
let program = gl.create_program().map_err(|e| e.to_string())?;
let vs = gl
.create_shader(glow::VERTEX_SHADER)
.map_err(|e| e.to_string())?;
let fs = gl
.create_shader(glow::FRAGMENT_SHADER)
.map_err(|e| e.to_string())?;
gl.shader_source(vs, vs_src);
gl.compile_shader(vs);
if !gl.get_shader_compile_status(vs) {
let log = gl.get_shader_info_log(vs);
gl.delete_shader(vs);
gl.delete_shader(fs);
gl.delete_program(program);
return Err(format!("vertex shader compile failed: {log}"));
}
gl.shader_source(fs, fs_src);
gl.compile_shader(fs);
if !gl.get_shader_compile_status(fs) {
let log = gl.get_shader_info_log(fs);
gl.delete_shader(vs);
gl.delete_shader(fs);
gl.delete_program(program);
return Err(format!("fragment shader compile failed: {log}"));
}
gl.attach_shader(program, vs);
gl.attach_shader(program, fs);
gl.link_program(program);
gl.detach_shader(program, vs);
gl.detach_shader(program, fs);
gl.delete_shader(vs);
gl.delete_shader(fs);
if !gl.get_program_link_status(program) {
let log = gl.get_program_info_log(program);
gl.delete_program(program);
return Err(format!("program link failed: {log}"));
}
Ok(program)
}
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
for &value in slice {
out.extend_from_slice(&value.to_ne_bytes());
}
out
}
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
for &value in slice {
out.extend_from_slice(&value.to_ne_bytes());
}
out
}
fn mat4_identity() -> [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 mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
let mut m = mat4_identity();
m[12] = x;
m[13] = y;
m[14] = z;
m
}
fn mat4_rotation_y(rad: f32) -> [f32; 16] {
let c = rad.cos();
let s = rad.sin();
[
c, 0.0, -s, 0.0, //
0.0, 1.0, 0.0, 0.0, //
s, 0.0, c, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
let f = 1.0 / (0.5 * fovy).tan();
let nf = 1.0 / (near - far);
[
f / aspect,
0.0,
0.0,
0.0,
0.0,
f,
0.0,
0.0,
0.0,
0.0,
(far + near) * nf,
-1.0,
0.0,
0.0,
(2.0 * far * near) * nf,
0.0,
]
}
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
let mut out = [0.0f32; 16];
for c in 0..4 {
for r in 0..4 {
let mut acc = 0.0f32;
for k in 0..4 {
acc += a[k * 4 + r] * b[c * 4 + k];
}
out[c * 4 + r] = acc;
}
}
out
}

View File

@@ -0,0 +1,33 @@
[package]
name = "render-mission-demo"
version = "0.1.0"
edition = "2021"
[features]
default = []
demo = ["dep:sdl2", "dep:glow"]
[dependencies]
encoding_rs = "0.8"
glow = { version = "0.16", optional = true }
nres = { path = "../nres" }
render-core = { path = "../render-core" }
render-demo = { path = "../render-demo" }
tma = { path = "../tma" }
terrain-core = { path = "../terrain-core" }
texm = { path = "../texm" }
unitdat = { path = "../unitdat" }
[dev-dependencies]
common = { path = "../common" }
[target.'cfg(target_os = "macos")'.dependencies]
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["use-pkgconfig"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
sdl2 = { version = "0.37", optional = true, default-features = false, features = ["bundled", "static-link"] }
[[bin]]
name = "parkan-render-mission-demo"
path = "src/main.rs"
required-features = ["demo"]

View File

@@ -0,0 +1,881 @@
use encoding_rs::WINDOWS_1251;
use nres::Archive;
use render_core::{build_render_mesh, RenderMesh};
use render_demo::{load_model_with_name_from_archive, resolve_texture_for_model, LoadedTexture};
use std::collections::HashMap;
use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use terrain_core::TerrainMesh;
use tma::MissionFile;
const MAT0_KIND: u32 = 0x3054_414D;
const MESH_KIND: u32 = 0x4853_454D;
const OBJECT_REF_STRIDE: usize = 64;
const OBJECT_REF_ARCHIVE_BYTES: usize = 32;
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
Mission(tma::Error),
Terrain(terrain_core::Error),
UnitDat(unitdat::Error),
RenderDemo(render_demo::Error),
Nres(nres::error::Error),
Texm(texm::error::Error),
InvalidMapPath(String),
GameRootNotFound(PathBuf),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "{err}"),
Self::Mission(err) => write!(f, "{err}"),
Self::Terrain(err) => write!(f, "{err}"),
Self::UnitDat(err) => write!(f, "{err}"),
Self::RenderDemo(err) => write!(f, "{err}"),
Self::Nres(err) => write!(f, "{err}"),
Self::Texm(err) => write!(f, "{err}"),
Self::InvalidMapPath(path) => write!(f, "invalid mission map path: {path}"),
Self::GameRootNotFound(path) => {
write!(
f,
"failed to detect game root from mission path {}",
path.display()
)
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
Self::Mission(err) => Some(err),
Self::Terrain(err) => Some(err),
Self::UnitDat(err) => Some(err),
Self::RenderDemo(err) => Some(err),
Self::Nres(err) => Some(err),
Self::Texm(err) => Some(err),
Self::InvalidMapPath(_) | Self::GameRootNotFound(_) => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl From<tma::Error> for Error {
fn from(value: tma::Error) -> Self {
Self::Mission(value)
}
}
impl From<terrain_core::Error> for Error {
fn from(value: terrain_core::Error) -> Self {
Self::Terrain(value)
}
}
impl From<unitdat::Error> for Error {
fn from(value: unitdat::Error) -> Self {
Self::UnitDat(value)
}
}
impl From<render_demo::Error> for Error {
fn from(value: render_demo::Error) -> Self {
Self::RenderDemo(value)
}
}
impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self {
Self::Nres(value)
}
}
impl From<texm::error::Error> for Error {
fn from(value: texm::error::Error) -> Self {
Self::Texm(value)
}
}
#[derive(Copy, Clone, Debug)]
pub struct LoadOptions {
pub load_model_textures: bool,
pub load_terrain_texture: bool,
}
impl Default for LoadOptions {
fn default() -> Self {
Self {
load_model_textures: true,
load_terrain_texture: true,
}
}
}
#[derive(Clone, Debug)]
pub struct MissionScene {
pub game_root: PathBuf,
pub mission_path: PathBuf,
pub mission: MissionFile,
pub map_folder_rel: PathBuf,
pub land_msh_path: PathBuf,
pub terrain: TerrainMesh,
pub terrain_texture: Option<LoadedTexture>,
pub models: Vec<SceneModel>,
pub skipped_objects: usize,
}
#[derive(Clone, Debug)]
pub struct SceneModel {
pub archive_path: PathBuf,
pub model_name: String,
pub mesh: RenderMesh,
pub texture: Option<LoadedTexture>,
pub instances: Vec<ModelInstance>,
}
#[derive(Copy, Clone, Debug)]
pub struct ModelInstance {
pub position: [f32; 3],
pub yaw_rad: f32,
pub scale: [f32; 3],
}
#[derive(Clone, Debug)]
struct ObjectPrototype {
archive_path: PathBuf,
model_name: String,
}
#[derive(Clone, Debug)]
struct ObjectRef {
archive_name: String,
resource_name: String,
}
#[derive(Clone, Debug, Hash, PartialEq, Eq)]
struct ModelKey {
archive_path: PathBuf,
model_name: String,
}
pub fn detect_game_root_from_mission_path(mission_path: &Path) -> Option<PathBuf> {
let mut cursor = mission_path.parent();
while let Some(dir) = cursor {
if dir.join("DATA").is_dir() && dir.join("objects.rlb").is_file() {
return Some(dir.to_path_buf());
}
cursor = dir.parent();
}
None
}
pub fn load_scene(
game_root: impl AsRef<Path>,
mission_path: impl AsRef<Path>,
) -> Result<MissionScene> {
load_scene_with_options(game_root, mission_path, LoadOptions::default())
}
pub fn load_scene_with_options(
game_root: impl AsRef<Path>,
mission_path: impl AsRef<Path>,
options: LoadOptions,
) -> Result<MissionScene> {
let game_root = game_root.as_ref().to_path_buf();
let mission_path = mission_path.as_ref().to_path_buf();
let mission = tma::parse_path(&mission_path)?;
let map_folder_rel = map_folder_from_footer(&mission.footer.map_path)?;
let land_msh_path = game_root.join(&map_folder_rel).join("Land.msh");
let terrain = terrain_core::load_land_mesh(&land_msh_path)?;
let terrain_texture = if options.load_terrain_texture {
resolve_terrain_texture(&game_root, &map_folder_rel)?
} else {
None
};
let mut grouped_instances: HashMap<ModelKey, Vec<ModelInstance>> = HashMap::new();
let mut prototype_cache: HashMap<String, Option<ObjectPrototype>> = HashMap::new();
let mut skipped = 0usize;
for object in &mission.objects {
let cache_key = object.resource_name.to_ascii_lowercase();
let proto = if let Some(cached) = prototype_cache.get(&cache_key) {
cached.clone()
} else {
let resolved = resolve_object_prototype(&game_root, object)?;
prototype_cache.insert(cache_key, resolved.clone());
resolved
};
let Some(proto) = proto else {
skipped += 1;
continue;
};
let instance = ModelInstance {
position: object.position,
yaw_rad: object.orientation[2],
scale: normalize_scale(object.scale),
};
grouped_instances
.entry(ModelKey {
archive_path: proto.archive_path,
model_name: proto.model_name,
})
.or_default()
.push(instance);
}
let mut models = Vec::new();
for (key, instances) in grouped_instances {
let loaded =
match load_model_with_name_from_archive(&key.archive_path, Some(&key.model_name)) {
Ok(v) => v,
Err(_) => {
skipped += instances.len();
continue;
}
};
let mesh = build_render_mesh(&loaded.model, 0, 0);
if mesh.indices.is_empty() {
skipped += instances.len();
continue;
}
let texture = if options.load_model_textures {
resolve_texture_for_model(&key.archive_path, &loaded.name, None, None, None, None)
.ok()
.flatten()
} else {
None
};
models.push(SceneModel {
archive_path: key.archive_path,
model_name: loaded.name,
mesh,
texture,
instances,
});
}
models.sort_by(|a, b| a.model_name.cmp(&b.model_name));
Ok(MissionScene {
game_root,
mission_path,
mission,
map_folder_rel,
land_msh_path,
terrain,
terrain_texture,
models,
skipped_objects: skipped,
})
}
pub fn compute_scene_bounds(scene: &MissionScene) -> Option<([f32; 3], [f32; 3])> {
let mut min_v = [f32::INFINITY; 3];
let mut max_v = [f32::NEG_INFINITY; 3];
let mut any = false;
for pos in &scene.terrain.positions {
merge_bounds(&mut min_v, &mut max_v, *pos);
any = true;
}
for model in &scene.models {
for instance in &model.instances {
merge_bounds(&mut min_v, &mut max_v, instance.position);
any = true;
}
}
any.then_some((min_v, max_v))
}
fn merge_bounds(min_v: &mut [f32; 3], max_v: &mut [f32; 3], p: [f32; 3]) {
for i in 0..3 {
if p[i] < min_v[i] {
min_v[i] = p[i];
}
if p[i] > max_v[i] {
max_v[i] = p[i];
}
}
}
fn normalize_scale(scale: [f32; 3]) -> [f32; 3] {
let mut out = scale;
for item in &mut out {
if !item.is_finite() || item.abs() < 0.000_1 {
*item = 1.0;
}
}
out
}
fn map_folder_from_footer(map_path: &str) -> Result<PathBuf> {
let mut parts = split_relative_path(map_path);
if parts.len() < 2 {
return Err(Error::InvalidMapPath(map_path.to_string()));
}
parts.pop(); // remove 'land'
let mut out = PathBuf::new();
for part in parts {
out.push(part);
}
Ok(out)
}
fn resolve_object_prototype(
game_root: &Path,
object: &tma::MissionObject,
) -> Result<Option<ObjectPrototype>> {
if object.resource_name.to_ascii_lowercase().ends_with(".dat") {
let dat_path = game_root.join(pathbuf_from_rel(&object.resource_name));
if !dat_path.is_file() {
return Ok(None);
}
let parsed = unitdat::parse_path(&dat_path)?;
let archive_path = game_root.join(pathbuf_from_rel(&parsed.archive_name));
if !archive_path.is_file() {
return Ok(None);
}
return resolve_archive_model(game_root, &archive_path, &parsed.model_key);
}
let archive_path = game_root.join("objects.rlb");
if !archive_path.is_file() {
return Ok(None);
}
resolve_archive_model(game_root, &archive_path, &object.resource_name)
}
fn resolve_archive_model(
game_root: &Path,
archive_path: &Path,
model_key: &str,
) -> Result<Option<ObjectPrototype>> {
if !archive_path.is_file() {
return Ok(None);
}
if is_objects_registry_archive(archive_path) {
if let Some(proto) = resolve_objects_registry_model(game_root, archive_path, model_key)? {
return Ok(Some(proto));
}
}
let model_name = ensure_msh_suffix(model_key);
if !archive_has_mesh_entry(archive_path, &model_name)? {
return Ok(None);
}
Ok(Some(ObjectPrototype {
archive_path: archive_path.to_path_buf(),
model_name: model_name.to_ascii_lowercase(),
}))
}
fn is_objects_registry_archive(archive_path: &Path) -> bool {
archive_path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case("objects.rlb"))
}
fn resolve_objects_registry_model(
game_root: &Path,
registry_archive_path: &Path,
object_key: &str,
) -> Result<Option<ObjectPrototype>> {
let archive = Archive::open_path(registry_archive_path)?;
let Some(entry_id) = find_registry_entry_id(&archive, object_key) else {
return Ok(None);
};
let payload = archive.read(entry_id)?.into_owned();
let refs = parse_object_refs(&payload);
if refs.is_empty() {
return Ok(None);
}
for item in refs
.iter()
.filter(|item| has_extension(&item.resource_name, "msh"))
{
if let Some(proto) = resolve_object_ref_model(game_root, item, &item.resource_name)? {
return Ok(Some(proto));
}
}
for item in refs
.iter()
.filter(|item| has_extension(&item.resource_name, "bas"))
{
let Some(stem) = Path::new(&item.resource_name)
.file_stem()
.and_then(|stem| stem.to_str())
else {
continue;
};
if stem.is_empty() {
continue;
}
let candidate = format!("{stem}.msh");
if let Some(proto) = resolve_object_ref_model(game_root, item, &candidate)? {
return Ok(Some(proto));
}
}
Ok(None)
}
fn find_registry_entry_id(archive: &Archive, object_key: &str) -> Option<nres::EntryId> {
mesh_name_candidates(object_key)
.into_iter()
.find_map(|candidate| archive.find(&candidate))
}
fn resolve_object_ref_model(
game_root: &Path,
item: &ObjectRef,
model_name: &str,
) -> Result<Option<ObjectPrototype>> {
let archive_path = game_root.join(pathbuf_from_rel(&item.archive_name));
if !archive_path.is_file() {
return Ok(None);
}
if !archive_has_mesh_entry(&archive_path, model_name)? {
return Ok(None);
}
Ok(Some(ObjectPrototype {
archive_path,
model_name: model_name.to_ascii_lowercase(),
}))
}
fn parse_object_refs(payload: &[u8]) -> Vec<ObjectRef> {
if !payload.len().is_multiple_of(OBJECT_REF_STRIDE) {
return Vec::new();
}
let mut refs = Vec::with_capacity(payload.len() / OBJECT_REF_STRIDE);
for chunk in payload.chunks_exact(OBJECT_REF_STRIDE) {
let archive_name = decode_cp1251_cstr(&chunk[..OBJECT_REF_ARCHIVE_BYTES]);
let resource_name = decode_cp1251_cstr(&chunk[OBJECT_REF_ARCHIVE_BYTES..]);
if archive_name.is_empty() || resource_name.is_empty() {
continue;
}
refs.push(ObjectRef {
archive_name,
resource_name,
});
}
refs
}
fn archive_has_mesh_entry(archive_path: &Path, requested_name: &str) -> Result<bool> {
let archive = Archive::open_path(archive_path)?;
Ok(find_mesh_entry_id(&archive, requested_name).is_some())
}
fn find_mesh_entry_id(archive: &Archive, requested_name: &str) -> Option<nres::EntryId> {
for candidate in mesh_name_candidates(requested_name) {
let Some(id) = archive.find(&candidate) else {
continue;
};
let Some(entry) = archive.get(id) else {
continue;
};
if entry.meta.kind == MESH_KIND || has_extension(&entry.meta.name, "msh") {
return Some(id);
}
}
None
}
fn mesh_name_candidates(name: &str) -> Vec<String> {
let mut out = Vec::new();
let trimmed = name.trim();
if trimmed.is_empty() {
return out;
}
push_unique_string(&mut out, trimmed.to_string());
if let Some(stem) = trimmed
.strip_suffix(".msh")
.or_else(|| trimmed.strip_suffix(".MSH"))
{
if !stem.is_empty() {
push_unique_string(&mut out, stem.to_string());
}
} else {
push_unique_string(&mut out, format!("{trimmed}.msh"));
}
out
}
fn push_unique_string(items: &mut Vec<String>, value: String) {
if !items.iter().any(|item| item.eq_ignore_ascii_case(&value)) {
items.push(value);
}
}
fn ensure_msh_suffix(name: &str) -> String {
let trimmed = name.trim();
if trimmed.to_ascii_lowercase().ends_with(".msh") {
trimmed.to_string()
} else {
format!("{trimmed}.msh")
}
}
fn has_extension(name: &str, ext: &str) -> bool {
Path::new(name)
.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case(ext))
}
fn resolve_terrain_texture(
game_root: &Path,
map_folder_rel: &Path,
) -> Result<Option<LoadedTexture>> {
let material_archive_path = game_root.join("material.lib");
let texture_archive_path = game_root.join("textures.lib");
if !material_archive_path.is_file() || !texture_archive_path.is_file() {
return Ok(None);
}
for wear_name in ["Land1.wea", "Land2.wea"] {
let wear_path = game_root.join(map_folder_rel).join(wear_name);
if !wear_path.is_file() {
continue;
}
let wear_payload = fs::read(&wear_path)?;
let Some(material_name) = parse_primary_material_from_wear(&wear_payload) else {
continue;
};
let Some(texture_name) =
resolve_texture_name_from_material_archive(&material_archive_path, &material_name)?
else {
continue;
};
if let Some(texture) = load_texm_by_name(&texture_archive_path, &texture_name)? {
return Ok(Some(texture));
}
}
Ok(None)
}
fn parse_primary_material_from_wear(bytes: &[u8]) -> Option<String> {
let text = decode_cp1251(bytes).replace('\r', "");
let mut lines = text.lines();
let count = lines.next()?.trim().parse::<usize>().ok()?;
if count == 0 {
return None;
}
for line in lines.take(count) {
let mut parts = line.split_whitespace();
let _legacy = parts.next()?;
let name = parts.next()?;
if !name.is_empty() {
return Some(name.to_string());
}
}
None
}
fn resolve_texture_name_from_material_archive(
archive_path: &Path,
material_name: &str,
) -> Result<Option<String>> {
let archive = Archive::open_path(archive_path)?;
let entry = if let Some(id) = archive.find(material_name) {
archive
.get(id)
.filter(|entry| entry.meta.kind == MAT0_KIND)
.or_else(|| {
archive
.find("DEFAULT")
.and_then(|id| archive.get(id))
.filter(|entry| entry.meta.kind == MAT0_KIND)
})
} else {
archive
.find("DEFAULT")
.and_then(|id| archive.get(id))
.filter(|entry| entry.meta.kind == MAT0_KIND)
}
.or_else(|| archive.entries().find(|entry| entry.meta.kind == MAT0_KIND));
let Some(entry) = entry else {
return Ok(None);
};
let payload = archive.read(entry.id)?.into_owned();
parse_primary_texture_name_from_mat0(&payload, entry.meta.attr2)
}
fn parse_primary_texture_name_from_mat0(payload: &[u8], attr2: u32) -> Result<Option<String>> {
if payload.len() < 4 {
return Ok(None);
}
let phase_count = u16::from_le_bytes([payload[0], payload[1]]) as usize;
if phase_count == 0 {
return Ok(None);
}
let mut offset = 4usize;
if attr2 >= 2 {
offset = offset.saturating_add(2);
}
if attr2 >= 3 {
offset = offset.saturating_add(4);
}
if attr2 >= 4 {
offset = offset.saturating_add(4);
}
for phase in 0..phase_count {
let phase_off = offset.saturating_add(phase.saturating_mul(34));
let Some(rec) = payload.get(phase_off..phase_off + 34) else {
break;
};
let name_raw = &rec[18..34];
let end = name_raw
.iter()
.position(|&b| b == 0)
.unwrap_or(name_raw.len());
let name = decode_cp1251(&name_raw[..end]).trim().to_string();
if !name.is_empty() {
return Ok(Some(name));
}
}
Ok(None)
}
fn load_texm_by_name(archive_path: &Path, texture_name: &str) -> Result<Option<LoadedTexture>> {
let archive = Archive::open_path(archive_path)?;
let Some(id) = archive.find(texture_name) else {
return Ok(None);
};
let Some(entry) = archive.get(id) else {
return Ok(None);
};
if entry.meta.kind != texm::TEXM_MAGIC {
return Ok(None);
}
let payload = archive.read(id)?.into_owned();
let parsed = texm::parse_texm(&payload)?;
let decoded = texm::decode_mip_rgba8(&parsed, &payload, 0)?;
Ok(Some(LoadedTexture {
name: entry.meta.name.clone(),
width: decoded.width,
height: decoded.height,
rgba8: decoded.rgba8,
}))
}
fn split_relative_path(path: &str) -> Vec<&str> {
path.split(['\\', '/'])
.filter(|part| !part.is_empty())
.collect()
}
fn pathbuf_from_rel(path: &str) -> PathBuf {
let mut out = PathBuf::new();
for part in split_relative_path(path) {
out.push(part);
}
out
}
fn decode_cp1251_cstr(bytes: &[u8]) -> String {
let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..end]);
decoded.trim().to_string()
}
fn decode_cp1251(bytes: &[u8]) -> String {
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
decoded.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn game_root() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
root.is_dir().then_some(root)
}
#[test]
fn detects_game_root_from_mission_path() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let mission = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !mission.is_file() {
eprintln!("skipping missing mission sample");
return;
}
let detected = detect_game_root_from_mission_path(&mission)
.expect("failed to detect game root from mission path");
assert_eq!(detected, root);
}
#[test]
fn loads_scene_cpu_without_textures() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let mission = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !mission.is_file() {
eprintln!("skipping missing mission sample");
return;
}
let scene = load_scene_with_options(
&root,
&mission,
LoadOptions {
load_model_textures: false,
load_terrain_texture: false,
},
)
.unwrap_or_else(|err| panic!("failed to load scene {}: {err}", mission.display()));
assert!(!scene.terrain.positions.is_empty());
assert!(!scene.terrain.faces.is_empty());
assert!(!scene.models.is_empty());
let instance_count = scene
.models
.iter()
.map(|model| model.instances.len())
.sum::<usize>();
assert!(instance_count >= 10);
let bounds = compute_scene_bounds(&scene).expect("scene bounds should exist");
assert!(bounds.0[0] <= bounds.1[0]);
assert!(bounds.0[1] <= bounds.1[1]);
assert!(bounds.0[2] <= bounds.1[2]);
}
#[test]
fn loads_scene_with_textures() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let mission = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !mission.is_file() {
eprintln!("skipping missing mission sample");
return;
}
let scene = load_scene_with_options(&root, &mission, LoadOptions::default())
.unwrap_or_else(|err| panic!("failed to load textured scene {}: {err}", mission.display()));
assert!(!scene.models.is_empty());
let textured_models = scene.models.iter().filter(|model| model.texture.is_some()).count();
assert!(textured_models > 0, "no model textures resolved");
assert!(scene.terrain_texture.is_some(), "terrain texture was not resolved");
}
#[test]
fn resolves_objects_registry_models() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let registry = root.join("objects.rlb");
if !registry.is_file() {
eprintln!("skipping missing objects.rlb");
return;
}
let cases = [
("r_h_01", "bases.rlb", "r_h_01.msh"),
("s_tree_04", "static.rlb", "s_tree_0_04.msh"),
("fr_m_brige", "fortif.rlb", "fr_m_brige.msh"),
];
for (key, archive_name, model_name) in cases {
let proto = resolve_objects_registry_model(&root, &registry, key)
.unwrap_or_else(|err| panic!("failed to resolve '{key}' from objects.rlb: {err}"))
.unwrap_or_else(|| panic!("missing model resolution for '{key}'"));
let got_archive = proto
.archive_path
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_ascii_lowercase())
.unwrap_or_default();
assert_eq!(got_archive, archive_name.to_ascii_lowercase());
assert!(
proto.model_name.eq_ignore_ascii_case(model_name),
"unexpected model for key '{key}': got '{}', expected '{}'",
proto.model_name,
model_name
);
}
}
}

View File

@@ -0,0 +1,924 @@
use glow::HasContext as _;
use render_mission_demo::{
compute_scene_bounds, detect_game_root_from_mission_path, load_scene_with_options, LoadOptions,
MissionScene, ModelInstance,
};
use std::io::Write as _;
use std::path::PathBuf;
use std::time::{Duration, Instant};
struct Args {
mission: PathBuf,
game_root: Option<PathBuf>,
width: u32,
height: u32,
fov_deg: f32,
no_model_texture: bool,
no_terrain_texture: bool,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum GlBackend {
Gles2,
Core33,
}
struct GpuTexture {
handle: glow::NativeTexture,
}
struct GpuRenderable {
vbo: glow::NativeBuffer,
ebo: glow::NativeBuffer,
index_count: usize,
texture: Option<GpuTexture>,
}
struct ModelRenderable {
gpu: GpuRenderable,
instances: Vec<ModelInstance>,
}
#[derive(Copy, Clone, Debug)]
struct Camera {
position: [f32; 3],
yaw: f32,
pitch: f32,
move_speed: f32,
mouse_sensitivity: f32,
}
fn parse_args() -> Result<Args, String> {
let mut mission = None;
let mut game_root = None;
let mut width = 1600u32;
let mut height = 900u32;
let mut fov_deg = 60.0f32;
let mut no_model_texture = false;
let mut no_terrain_texture = false;
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--mission" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --mission"))?;
mission = Some(PathBuf::from(value));
}
"--game-root" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --game-root"))?;
game_root = Some(PathBuf::from(value));
}
"--width" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --width"))?;
width = value
.parse::<u32>()
.map_err(|_| String::from("invalid --width value"))?;
if width == 0 {
return Err(String::from("--width must be > 0"));
}
}
"--height" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --height"))?;
height = value
.parse::<u32>()
.map_err(|_| String::from("invalid --height value"))?;
if height == 0 {
return Err(String::from("--height must be > 0"));
}
}
"--fov" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --fov"))?;
fov_deg = value
.parse::<f32>()
.map_err(|_| String::from("invalid --fov value"))?;
if !(1.0..=179.0).contains(&fov_deg) {
return Err(String::from("--fov must be in range [1, 179]"));
}
}
"--no-model-texture" => {
no_model_texture = true;
}
"--no-terrain-texture" => {
no_terrain_texture = true;
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => {
return Err(format!("unknown argument: {other}"));
}
}
}
let mission = mission.ok_or_else(|| String::from("missing required --mission"))?;
Ok(Args {
mission,
game_root,
width,
height,
fov_deg,
no_model_texture,
no_terrain_texture,
})
}
fn print_help() {
eprintln!("parkan-render-mission-demo --mission <path/to/data.tma> [--game-root <path>] [--width W] [--height H] [--fov DEG]");
eprintln!(" [--no-model-texture] [--no-terrain-texture]");
eprintln!("controls: arrows/WASD move, PageUp/PageDown vertical move, Right Mouse drag look, Shift speed-up, Esc exit");
}
fn main() {
let args = match parse_args() {
Ok(v) => v,
Err(err) => {
eprintln!("{err}");
print_help();
std::process::exit(2);
}
};
if let Err(err) = run(args) {
eprintln!("{err}");
std::process::exit(1);
}
}
fn run(args: Args) -> Result<(), String> {
let game_root = if let Some(path) = args.game_root.clone() {
path
} else {
detect_game_root_from_mission_path(&args.mission).ok_or_else(|| {
format!(
"failed to detect game root from mission path {} (use --game-root)",
args.mission.display()
)
})?
};
let scene = load_scene_with_options(
&game_root,
&args.mission,
LoadOptions {
load_model_textures: !args.no_model_texture,
load_terrain_texture: !args.no_terrain_texture,
},
)
.map_err(|err| format!("failed to load mission scene: {err}"))?;
let terrain_mesh = terrain_core::build_render_mesh(&scene.terrain)
.map_err(|err| format!("failed to build terrain render mesh: {err}"))?;
let instance_count = scene
.models
.iter()
.map(|model| model.instances.len())
.sum::<usize>();
println!(
"mission loaded: map='{}', terrain_vertices={}, terrain_faces={}, models={}, instances={}, skipped={}",
scene.mission.footer.map_path,
scene.terrain.positions.len(),
scene.terrain.faces.len(),
scene.models.len(),
instance_count,
scene.skipped_objects
);
let sdl = sdl2::init().map_err(|err| format!("failed to init SDL2: {err}"))?;
let video = sdl
.video()
.map_err(|err| format!("failed to init SDL2 video: {err}"))?;
let (mut window, _gl_ctx, gl_backend) =
create_window_and_context(&video, args.width, args.height)?;
let _ = video.gl_set_swap_interval(1);
let gl = unsafe {
glow::Context::from_loader_function(|name| video.gl_get_proc_address(name) as *const _)
};
let program = unsafe { create_program(&gl, gl_backend)? };
let u_mvp = unsafe { gl.get_uniform_location(program, "u_mvp") };
let u_use_tex = unsafe { gl.get_uniform_location(program, "u_use_tex") };
let u_tex = unsafe { gl.get_uniform_location(program, "u_tex") };
let a_pos = unsafe { gl.get_attrib_location(program, "a_pos") }
.ok_or_else(|| String::from("shader attribute a_pos is missing"))?;
let a_uv = unsafe { gl.get_attrib_location(program, "a_uv") }
.ok_or_else(|| String::from("shader attribute a_uv is missing"))?;
let terrain_gpu =
unsafe { upload_terrain_renderable(&gl, &terrain_mesh, scene.terrain_texture.as_ref())? };
let mut model_gpus = Vec::new();
for model in &scene.models {
let renderable = unsafe { upload_model_renderable(&gl, model)? };
model_gpus.push(renderable);
}
let (scene_center, scene_radius) = initial_scene_sphere(&scene);
let mut camera = Camera {
position: [
scene_center[0],
scene_center[1] + scene_radius * 0.6,
scene_center[2] + scene_radius * 1.4,
],
yaw: std::f32::consts::PI,
pitch: -0.28,
move_speed: (scene_radius * 0.55).max(60.0),
mouse_sensitivity: 0.005,
};
let mut events = sdl
.event_pump()
.map_err(|err| format!("failed to get SDL event pump: {err}"))?;
let mut last = Instant::now();
let mut fps_window_start = Instant::now();
let mut fps_frames = 0u32;
let mut fps_printed = false;
let mut mouse_look = false;
'main_loop: loop {
for event in events.poll_iter() {
match event {
sdl2::event::Event::Quit { .. } => break 'main_loop,
sdl2::event::Event::KeyDown {
keycode: Some(sdl2::keyboard::Keycode::Escape),
..
} => break 'main_loop,
sdl2::event::Event::MouseButtonDown {
mouse_btn: sdl2::mouse::MouseButton::Right,
..
} => {
mouse_look = true;
sdl.mouse().set_relative_mouse_mode(true);
}
sdl2::event::Event::MouseButtonUp {
mouse_btn: sdl2::mouse::MouseButton::Right,
..
} => {
mouse_look = false;
sdl.mouse().set_relative_mouse_mode(false);
}
sdl2::event::Event::MouseMotion { xrel, yrel, .. } if mouse_look => {
camera.yaw += xrel as f32 * camera.mouse_sensitivity;
camera.pitch -= yrel as f32 * camera.mouse_sensitivity;
camera.pitch = camera.pitch.clamp(-1.54, 1.54);
}
_ => {}
}
}
let now = Instant::now();
let dt = (now - last).as_secs_f32().clamp(0.0, 0.05);
last = now;
update_camera(&events, &mut camera, dt);
let (w, h) = window.size();
let proj = mat4_perspective(
args.fov_deg.to_radians(),
(w as f32 / h.max(1) as f32).max(0.01),
0.1,
(scene_radius * 25.0).max(5000.0),
);
let forward = camera_forward(camera.yaw, camera.pitch);
let view = mat4_look_at(
camera.position,
[
camera.position[0] + forward[0],
camera.position[1] + forward[1],
camera.position[2] + forward[2],
],
[0.0, 1.0, 0.0],
);
unsafe {
draw_frame_begin(&gl, w, h);
let terrain_mvp = mat4_mul(&proj, &view);
draw_gpu_renderable(
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
&terrain_gpu,
&terrain_mvp,
);
for model in &model_gpus {
for instance in &model.instances {
let model_m = model_matrix(instance.position, instance.yaw_rad, instance.scale);
let view_model = mat4_mul(&view, &model_m);
let mvp = mat4_mul(&proj, &view_model);
draw_gpu_renderable(
&gl,
program,
u_mvp.as_ref(),
u_use_tex.as_ref(),
u_tex.as_ref(),
a_pos,
a_uv,
&model.gpu,
&mvp,
);
}
}
}
window.gl_swap_window();
fps_frames = fps_frames.saturating_add(1);
let elapsed = fps_window_start.elapsed();
if elapsed >= Duration::from_millis(500) {
let fps = fps_frames as f32 / elapsed.as_secs_f32().max(0.000_1);
let frame_time_ms = 1000.0 / fps.max(0.000_1);
let _ = window.set_title(&format!(
"Parkan Mission Demo | FPS: {fps:.1} ({frame_time_ms:.2} ms) | objects: {instance_count}"
));
print!("\rFPS: {fps:.1} ({frame_time_ms:.2} ms)");
let _ = std::io::stdout().flush();
fps_printed = true;
fps_frames = 0;
fps_window_start = Instant::now();
}
}
if fps_printed {
println!();
}
unsafe {
cleanup_renderable(&gl, terrain_gpu);
for model in model_gpus {
cleanup_renderable(&gl, model.gpu);
}
gl.delete_program(program);
}
Ok(())
}
fn initial_scene_sphere(scene: &MissionScene) -> ([f32; 3], f32) {
if let Some((min_v, max_v)) = compute_scene_bounds(scene) {
let center = [
0.5 * (min_v[0] + max_v[0]),
0.5 * (min_v[1] + max_v[1]),
0.5 * (min_v[2] + max_v[2]),
];
let extent = [
max_v[0] - min_v[0],
max_v[1] - min_v[1],
max_v[2] - min_v[2],
];
let radius = ((extent[0] * extent[0]) + (extent[1] * extent[1]) + (extent[2] * extent[2]))
.sqrt()
.max(10.0)
* 0.5;
return (center, radius);
}
([0.0, 0.0, 0.0], 100.0)
}
fn update_camera(events: &sdl2::EventPump, camera: &mut Camera, dt: f32) {
use sdl2::keyboard::Scancode;
let keys = events.keyboard_state();
let mut move_dir = [0.0f32, 0.0f32, 0.0f32];
let forward = camera_forward(camera.yaw, camera.pitch);
let right = normalize3(cross3(forward, [0.0, 1.0, 0.0]));
if keys.is_scancode_pressed(Scancode::Up) || keys.is_scancode_pressed(Scancode::W) {
move_dir[0] += forward[0];
move_dir[1] += forward[1];
move_dir[2] += forward[2];
}
if keys.is_scancode_pressed(Scancode::Down) || keys.is_scancode_pressed(Scancode::S) {
move_dir[0] -= forward[0];
move_dir[1] -= forward[1];
move_dir[2] -= forward[2];
}
if keys.is_scancode_pressed(Scancode::Left) || keys.is_scancode_pressed(Scancode::A) {
move_dir[0] -= right[0];
move_dir[1] -= right[1];
move_dir[2] -= right[2];
}
if keys.is_scancode_pressed(Scancode::Right) || keys.is_scancode_pressed(Scancode::D) {
move_dir[0] += right[0];
move_dir[1] += right[1];
move_dir[2] += right[2];
}
if keys.is_scancode_pressed(Scancode::PageUp) || keys.is_scancode_pressed(Scancode::E) {
move_dir[1] += 1.0;
}
if keys.is_scancode_pressed(Scancode::PageDown) || keys.is_scancode_pressed(Scancode::Q) {
move_dir[1] -= 1.0;
}
let shift =
keys.is_scancode_pressed(Scancode::LShift) || keys.is_scancode_pressed(Scancode::RShift);
let speed_mul = if shift { 3.0 } else { 1.0 };
let norm = normalize3(move_dir);
camera.position[0] += norm[0] * camera.move_speed * speed_mul * dt;
camera.position[1] += norm[1] * camera.move_speed * speed_mul * dt;
camera.position[2] += norm[2] * camera.move_speed * speed_mul * dt;
}
unsafe fn upload_model_renderable(
gl: &glow::Context,
model: &render_mission_demo::SceneModel,
) -> Result<ModelRenderable, String> {
let mut vertex_data = Vec::with_capacity(model.mesh.vertices.len() * 5);
for vertex in &model.mesh.vertices {
vertex_data.push(vertex.position[0]);
vertex_data.push(vertex.position[1]);
vertex_data.push(vertex.position[2]);
vertex_data.push(vertex.uv0[0]);
vertex_data.push(vertex.uv0[1]);
}
let gpu = upload_gpu_renderable(
gl,
&vertex_data,
&model.mesh.indices,
model.texture.as_ref(),
)?;
Ok(ModelRenderable {
gpu,
instances: model.instances.clone(),
})
}
unsafe fn upload_terrain_renderable(
gl: &glow::Context,
mesh: &terrain_core::TerrainRenderMesh,
texture: Option<&render_demo::LoadedTexture>,
) -> Result<GpuRenderable, String> {
let mut vertex_data = Vec::with_capacity(mesh.vertices.len() * 5);
for vertex in &mesh.vertices {
vertex_data.push(vertex.position[0]);
vertex_data.push(vertex.position[1]);
vertex_data.push(vertex.position[2]);
vertex_data.push(vertex.uv0[0]);
vertex_data.push(vertex.uv0[1]);
}
upload_gpu_renderable(gl, &vertex_data, &mesh.indices, texture)
}
unsafe fn upload_gpu_renderable(
gl: &glow::Context,
vertices: &[f32],
indices: &[u16],
texture: Option<&render_demo::LoadedTexture>,
) -> Result<GpuRenderable, String> {
let vbo = gl.create_buffer().map_err(|e| e.to_string())?;
let ebo = gl.create_buffer().map_err(|e| e.to_string())?;
let vertex_bytes = f32_slice_to_ne_bytes(vertices);
let index_bytes = u16_slice_to_ne_bytes(indices);
gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo));
gl.buffer_data_u8_slice(glow::ARRAY_BUFFER, &vertex_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo));
gl.buffer_data_u8_slice(glow::ELEMENT_ARRAY_BUFFER, &index_bytes, glow::STATIC_DRAW);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
let gpu_texture = if let Some(texture) = texture {
Some(create_texture(gl, texture)?)
} else {
None
};
Ok(GpuRenderable {
vbo,
ebo,
index_count: indices.len(),
texture: gpu_texture,
})
}
unsafe fn cleanup_renderable(gl: &glow::Context, renderable: GpuRenderable) {
if let Some(tex) = renderable.texture {
gl.delete_texture(tex.handle);
}
gl.delete_buffer(renderable.ebo);
gl.delete_buffer(renderable.vbo);
}
unsafe fn draw_frame_begin(gl: &glow::Context, width: u32, height: u32) {
gl.viewport(
0,
0,
width.min(i32::MAX as u32) as i32,
height.min(i32::MAX as u32) as i32,
);
gl.enable(glow::DEPTH_TEST);
gl.clear_color(0.06, 0.08, 0.12, 1.0);
gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT);
}
unsafe fn draw_gpu_renderable(
gl: &glow::Context,
program: glow::NativeProgram,
u_mvp: Option<&glow::NativeUniformLocation>,
u_use_tex: Option<&glow::NativeUniformLocation>,
u_tex: Option<&glow::NativeUniformLocation>,
a_pos: u32,
a_uv: u32,
renderable: &GpuRenderable,
mvp: &[f32; 16],
) {
gl.use_program(Some(program));
gl.uniform_matrix_4_f32_slice(u_mvp, false, mvp);
let texture_enabled = renderable.texture.is_some();
gl.uniform_1_f32(u_use_tex, if texture_enabled { 1.0 } else { 0.0 });
if let Some(tex) = &renderable.texture {
gl.active_texture(glow::TEXTURE0);
gl.bind_texture(glow::TEXTURE_2D, Some(tex.handle));
gl.uniform_1_i32(u_tex, 0);
} else {
gl.bind_texture(glow::TEXTURE_2D, None);
}
gl.bind_buffer(glow::ARRAY_BUFFER, Some(renderable.vbo));
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(renderable.ebo));
gl.enable_vertex_attrib_array(a_pos);
gl.vertex_attrib_pointer_f32(a_pos, 3, glow::FLOAT, false, 20, 0);
gl.enable_vertex_attrib_array(a_uv);
gl.vertex_attrib_pointer_f32(a_uv, 2, glow::FLOAT, false, 20, 12);
gl.draw_elements(
glow::TRIANGLES,
renderable.index_count.min(i32::MAX as usize) as i32,
glow::UNSIGNED_SHORT,
0,
);
gl.disable_vertex_attrib_array(a_uv);
gl.disable_vertex_attrib_array(a_pos);
gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, None);
gl.bind_buffer(glow::ARRAY_BUFFER, None);
gl.bind_texture(glow::TEXTURE_2D, None);
gl.use_program(None);
}
fn create_window_and_context(
video: &sdl2::VideoSubsystem,
width: u32,
height: u32,
) -> Result<(sdl2::video::Window, sdl2::video::GLContext, GlBackend), String> {
let candidates = [
(GlBackend::Gles2, sdl2::video::GLProfile::GLES, 2, 0),
(GlBackend::Core33, sdl2::video::GLProfile::Core, 3, 3),
];
let mut errors = Vec::new();
for (backend, profile, major, minor) in candidates {
{
let gl_attr = video.gl_attr();
gl_attr.set_context_profile(profile);
gl_attr.set_context_version(major, minor);
gl_attr.set_depth_size(24);
gl_attr.set_double_buffer(true);
}
let mut window_builder = video.window("Parkan Mission Demo", width, height);
window_builder.opengl().resizable();
let window = match window_builder.build() {
Ok(window) => window,
Err(err) => {
errors.push(format!(
"{profile:?} {major}.{minor}: window build failed ({err})"
));
continue;
}
};
let gl_ctx = match window.gl_create_context() {
Ok(ctx) => ctx,
Err(err) => {
errors.push(format!(
"{profile:?} {major}.{minor}: context create failed ({err})"
));
continue;
}
};
if let Err(err) = window.gl_make_current(&gl_ctx) {
errors.push(format!(
"{profile:?} {major}.{minor}: make current failed ({err})"
));
continue;
}
return Ok((window, gl_ctx, backend));
}
Err(format!(
"failed to create OpenGL context. Attempts: {}",
errors.join(" | ")
))
}
unsafe fn create_texture(
gl: &glow::Context,
texture: &render_demo::LoadedTexture,
) -> Result<GpuTexture, String> {
let handle = gl.create_texture().map_err(|e| e.to_string())?;
gl.bind_texture(glow::TEXTURE_2D, Some(handle));
gl.tex_parameter_i32(
glow::TEXTURE_2D,
glow::TEXTURE_MIN_FILTER,
glow::LINEAR as i32,
);
gl.tex_parameter_i32(
glow::TEXTURE_2D,
glow::TEXTURE_MAG_FILTER,
glow::LINEAR as i32,
);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::REPEAT as i32);
gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::REPEAT as i32);
gl.pixel_store_i32(glow::UNPACK_ALIGNMENT, 1);
gl.tex_image_2d(
glow::TEXTURE_2D,
0,
glow::RGBA as i32,
texture.width.min(i32::MAX as u32) as i32,
texture.height.min(i32::MAX as u32) as i32,
0,
glow::RGBA,
glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice(Some(texture.rgba8.as_slice())),
);
gl.bind_texture(glow::TEXTURE_2D, None);
Ok(GpuTexture { handle })
}
unsafe fn create_program(
gl: &glow::Context,
backend: GlBackend,
) -> Result<glow::NativeProgram, String> {
let (vs_src, fs_src) = match backend {
GlBackend::Gles2 => (
r#"
attribute vec3 a_pos;
attribute vec2 a_uv;
uniform mat4 u_mvp;
varying vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0);
}
"#,
r#"
precision mediump float;
uniform sampler2D u_tex;
uniform float u_use_tex;
varying vec2 v_uv;
void main() {
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
vec4 texColor = texture2D(u_tex, v_uv);
gl_FragColor = mix(base, texColor, u_use_tex);
}
"#,
),
GlBackend::Core33 => (
r#"#version 330 core
in vec3 a_pos;
in vec2 a_uv;
uniform mat4 u_mvp;
out vec2 v_uv;
void main() {
v_uv = a_uv;
gl_Position = u_mvp * vec4(a_pos, 1.0);
}
"#,
r#"#version 330 core
uniform sampler2D u_tex;
uniform float u_use_tex;
in vec2 v_uv;
out vec4 fragColor;
void main() {
vec4 base = vec4(0.82, 0.87, 0.95, 1.0);
vec4 texColor = texture(u_tex, v_uv);
fragColor = mix(base, texColor, u_use_tex);
}
"#,
),
};
let program = gl.create_program().map_err(|e| e.to_string())?;
let vs = gl
.create_shader(glow::VERTEX_SHADER)
.map_err(|e| e.to_string())?;
let fs = gl
.create_shader(glow::FRAGMENT_SHADER)
.map_err(|e| e.to_string())?;
gl.shader_source(vs, vs_src);
gl.compile_shader(vs);
if !gl.get_shader_compile_status(vs) {
let log = gl.get_shader_info_log(vs);
gl.delete_shader(vs);
gl.delete_shader(fs);
gl.delete_program(program);
return Err(format!("vertex shader compile failed: {log}"));
}
gl.shader_source(fs, fs_src);
gl.compile_shader(fs);
if !gl.get_shader_compile_status(fs) {
let log = gl.get_shader_info_log(fs);
gl.delete_shader(vs);
gl.delete_shader(fs);
gl.delete_program(program);
return Err(format!("fragment shader compile failed: {log}"));
}
gl.attach_shader(program, vs);
gl.attach_shader(program, fs);
gl.link_program(program);
gl.detach_shader(program, vs);
gl.detach_shader(program, fs);
gl.delete_shader(vs);
gl.delete_shader(fs);
if !gl.get_program_link_status(program) {
let log = gl.get_program_info_log(program);
gl.delete_program(program);
return Err(format!("program link failed: {log}"));
}
Ok(program)
}
fn model_matrix(position: [f32; 3], yaw: f32, scale: [f32; 3]) -> [f32; 16] {
let translation = mat4_translation(position[0], position[1], position[2]);
let rotation = mat4_rotation_y(yaw);
let scaling = mat4_scale(scale[0], scale[1], scale[2]);
let tr = mat4_mul(&translation, &rotation);
mat4_mul(&tr, &scaling)
}
fn camera_forward(yaw: f32, pitch: f32) -> [f32; 3] {
let cp = pitch.cos();
normalize3([yaw.sin() * cp, pitch.sin(), yaw.cos() * cp])
}
fn cross3(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn dot3(a: [f32; 3], b: [f32; 3]) -> f32 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn normalize3(v: [f32; 3]) -> [f32; 3] {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len <= 1e-6 {
[0.0, 0.0, 0.0]
} else {
[v[0] / len, v[1] / len, v[2] / len]
}
}
fn mat4_identity() -> [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 mat4_translation(x: f32, y: f32, z: f32) -> [f32; 16] {
let mut m = mat4_identity();
m[12] = x;
m[13] = y;
m[14] = z;
m
}
fn mat4_scale(x: f32, y: f32, z: f32) -> [f32; 16] {
[
x, 0.0, 0.0, 0.0, //
0.0, y, 0.0, 0.0, //
0.0, 0.0, z, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_rotation_y(rad: f32) -> [f32; 16] {
let c = rad.cos();
let s = rad.sin();
[
c, 0.0, -s, 0.0, //
0.0, 1.0, 0.0, 0.0, //
s, 0.0, c, 0.0, //
0.0, 0.0, 0.0, 1.0, //
]
}
fn mat4_perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> [f32; 16] {
let f = 1.0 / (0.5 * fovy).tan();
let nf = 1.0 / (near - far);
[
f / aspect,
0.0,
0.0,
0.0,
0.0,
f,
0.0,
0.0,
0.0,
0.0,
(far + near) * nf,
-1.0,
0.0,
0.0,
(2.0 * far * near) * nf,
0.0,
]
}
fn mat4_look_at(eye: [f32; 3], target: [f32; 3], up: [f32; 3]) -> [f32; 16] {
let f = normalize3([target[0] - eye[0], target[1] - eye[1], target[2] - eye[2]]);
let s = normalize3(cross3(f, up));
let u = cross3(s, f);
[
s[0],
u[0],
-f[0],
0.0,
s[1],
u[1],
-f[1],
0.0,
s[2],
u[2],
-f[2],
0.0,
-dot3(s, eye),
-dot3(u, eye),
dot3(f, eye),
1.0,
]
}
fn mat4_mul(a: &[f32; 16], b: &[f32; 16]) -> [f32; 16] {
let mut out = [0.0f32; 16];
for c in 0..4 {
for r in 0..4 {
let mut acc = 0.0f32;
for k in 0..4 {
acc += a[k * 4 + r] * b[c * 4 + k];
}
out[c * 4 + r] = acc;
}
}
out
}
fn f32_slice_to_ne_bytes(slice: &[f32]) -> Vec<u8> {
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<f32>()));
for &value in slice {
out.extend_from_slice(&value.to_ne_bytes());
}
out
}
fn u16_slice_to_ne_bytes(slice: &[u16]) -> Vec<u8> {
let mut out = Vec::with_capacity(slice.len().saturating_mul(std::mem::size_of::<u16>()));
for &value in slice {
out.extend_from_slice(&value.to_ne_bytes());
}
out
}

View File

@@ -0,0 +1,9 @@
[package]
name = "render-parity"
version = "0.1.0"
edition = "2021"
[dependencies]
image = { version = "0.25", default-features = false, features = ["png"] }
serde = { version = "1", features = ["derive"] }
toml = "0.8"

View File

@@ -0,0 +1,16 @@
# render-parity
Deterministic frame-diff runner for `parkan-render-demo`.
Usage:
```bash
cargo run -p render-parity -- \
--manifest parity/cases.toml \
--output-dir target/render-parity/current
```
Options:
- `--demo-bin <path>`: use prebuilt `parkan-render-demo` binary instead of `cargo run`.
- `--keep-going`: continue all cases even after failures.

View File

@@ -0,0 +1,212 @@
use image::{ImageBuffer, Rgba, RgbaImage};
use serde::Deserialize;
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ManifestMeta {
pub width: Option<u32>,
pub height: Option<u32>,
pub lod: Option<usize>,
pub group: Option<usize>,
pub angle: Option<f32>,
pub diff_threshold: Option<u8>,
pub max_mean_abs: Option<f32>,
pub max_changed_ratio: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CaseSpec {
pub id: String,
pub archive: String,
pub model: Option<String>,
pub reference: String,
pub width: Option<u32>,
pub height: Option<u32>,
pub lod: Option<usize>,
pub group: Option<usize>,
pub angle: Option<f32>,
pub diff_threshold: Option<u8>,
pub max_mean_abs: Option<f32>,
pub max_changed_ratio: Option<f32>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ParityManifest {
#[serde(default)]
pub meta: ManifestMeta,
#[serde(rename = "case", default)]
pub cases: Vec<CaseSpec>,
}
#[derive(Debug, Clone)]
pub struct DiffMetrics {
pub width: u32,
pub height: u32,
pub mean_abs: f32,
pub max_abs: u8,
pub changed_pixels: u64,
pub changed_ratio: f32,
}
pub fn compare_images(
reference: &RgbaImage,
actual: &RgbaImage,
diff_threshold: u8,
) -> Result<DiffMetrics, String> {
let (rw, rh) = reference.dimensions();
let (aw, ah) = actual.dimensions();
if rw != aw || rh != ah {
return Err(format!(
"image size mismatch: reference={}x{}, actual={}x{}",
rw, rh, aw, ah
));
}
let mut diff_sum = 0u64;
let mut max_abs = 0u8;
let mut changed_pixels = 0u64;
let pixel_count = u64::from(rw).saturating_mul(u64::from(rh));
for (ref_px, act_px) in reference.pixels().zip(actual.pixels()) {
let mut pixel_changed = false;
for chan in 0..3 {
let a = i16::from(ref_px[chan]);
let b = i16::from(act_px[chan]);
let diff = (a - b).unsigned_abs() as u8;
diff_sum = diff_sum.saturating_add(u64::from(diff));
if diff > max_abs {
max_abs = diff;
}
if diff > diff_threshold {
pixel_changed = true;
}
}
if pixel_changed {
changed_pixels = changed_pixels.saturating_add(1);
}
}
let channels = pixel_count.saturating_mul(3);
let mean_abs = if channels == 0 {
0.0
} else {
diff_sum as f32 / channels as f32
};
let changed_ratio = if pixel_count == 0 {
0.0
} else {
changed_pixels as f32 / pixel_count as f32
};
Ok(DiffMetrics {
width: rw,
height: rh,
mean_abs,
max_abs,
changed_pixels,
changed_ratio,
})
}
pub fn build_diff_image(reference: &RgbaImage, actual: &RgbaImage) -> Result<RgbaImage, String> {
let (rw, rh) = reference.dimensions();
let (aw, ah) = actual.dimensions();
if rw != aw || rh != ah {
return Err(format!(
"image size mismatch: reference={}x{}, actual={}x{}",
rw, rh, aw, ah
));
}
let mut out: ImageBuffer<Rgba<u8>, Vec<u8>> = ImageBuffer::new(rw, rh);
for (dst, (ref_px, act_px)) in out
.pixels_mut()
.zip(reference.pixels().zip(actual.pixels()))
{
let dr = (i16::from(ref_px[0]) - i16::from(act_px[0])).unsigned_abs() as u8;
let dg = (i16::from(ref_px[1]) - i16::from(act_px[1])).unsigned_abs() as u8;
let db = (i16::from(ref_px[2]) - i16::from(act_px[2])).unsigned_abs() as u8;
*dst = Rgba([dr, dg, db, 255]);
}
Ok(out)
}
pub fn evaluate_metrics(
metrics: &DiffMetrics,
max_mean_abs: f32,
max_changed_ratio: f32,
) -> Vec<String> {
let mut violations = Vec::new();
if metrics.mean_abs > max_mean_abs {
violations.push(format!(
"mean_abs {:.4} > allowed {:.4}",
metrics.mean_abs, max_mean_abs
));
}
if metrics.changed_ratio > max_changed_ratio {
violations.push(format!(
"changed_ratio {:.4}% > allowed {:.4}%",
metrics.changed_ratio * 100.0,
max_changed_ratio * 100.0
));
}
violations
}
#[cfg(test)]
mod tests {
use super::*;
fn solid(w: u32, h: u32, r: u8, g: u8, b: u8) -> RgbaImage {
let mut img = RgbaImage::new(w, h);
for px in img.pixels_mut() {
*px = Rgba([r, g, b, 255]);
}
img
}
#[test]
fn compare_identical_images() {
let ref_img = solid(4, 3, 10, 20, 30);
let act_img = solid(4, 3, 10, 20, 30);
let metrics = compare_images(&ref_img, &act_img, 2).expect("comparison must succeed");
assert_eq!(metrics.width, 4);
assert_eq!(metrics.height, 3);
assert_eq!(metrics.max_abs, 0);
assert_eq!(metrics.changed_pixels, 0);
assert_eq!(metrics.mean_abs, 0.0);
assert_eq!(metrics.changed_ratio, 0.0);
}
#[test]
fn compare_detects_changes_and_thresholds() {
let mut ref_img = solid(2, 2, 100, 100, 100);
let mut act_img = solid(2, 2, 100, 100, 100);
ref_img.put_pixel(1, 1, Rgba([120, 100, 100, 255]));
act_img.put_pixel(1, 1, Rgba([100, 100, 100, 255]));
let metrics = compare_images(&ref_img, &act_img, 5).expect("comparison must succeed");
assert_eq!(metrics.max_abs, 20);
assert_eq!(metrics.changed_pixels, 1);
assert!((metrics.changed_ratio - 0.25).abs() < 1e-6);
assert!(metrics.mean_abs > 0.0);
let violations = evaluate_metrics(&metrics, 2.0, 0.20);
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("changed_ratio"));
}
#[test]
fn build_diff_image_returns_per_channel_abs_diff() {
let mut ref_img = solid(1, 1, 100, 150, 200);
let mut act_img = solid(1, 1, 90, 180, 170);
ref_img.put_pixel(0, 0, Rgba([100, 150, 200, 255]));
act_img.put_pixel(0, 0, Rgba([90, 180, 170, 255]));
let diff = build_diff_image(&ref_img, &act_img).expect("diff image must build");
let px = diff.get_pixel(0, 0);
assert_eq!(px[0], 10);
assert_eq!(px[1], 30);
assert_eq!(px[2], 30);
assert_eq!(px[3], 255);
}
}

View File

@@ -0,0 +1,405 @@
use image::RgbaImage;
use render_parity::{
build_diff_image, compare_images, evaluate_metrics, CaseSpec, ManifestMeta, ParityManifest,
};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
const DEFAULT_MANIFEST: &str = "parity/cases.toml";
const DEFAULT_OUTPUT_DIR: &str = "target/render-parity/current";
const DEFAULT_WIDTH: u32 = 1280;
const DEFAULT_HEIGHT: u32 = 720;
const DEFAULT_LOD: usize = 0;
const DEFAULT_GROUP: usize = 0;
const DEFAULT_ANGLE: f32 = 0.0;
const DEFAULT_DIFF_THRESHOLD: u8 = 8;
const DEFAULT_MAX_MEAN_ABS: f32 = 2.0;
const DEFAULT_MAX_CHANGED_RATIO: f32 = 0.01;
struct Args {
manifest: PathBuf,
output_dir: PathBuf,
demo_bin: Option<PathBuf>,
keep_going: bool,
}
#[derive(Debug, Clone)]
struct EffectiveCase {
id: String,
archive: PathBuf,
model: Option<String>,
reference: PathBuf,
width: u32,
height: u32,
lod: usize,
group: usize,
angle: f32,
diff_threshold: u8,
max_mean_abs: f32,
max_changed_ratio: f32,
}
fn main() {
let args = match parse_args() {
Ok(v) => v,
Err(err) => {
eprintln!("{err}");
print_help();
std::process::exit(2);
}
};
if let Err(err) = run(args) {
eprintln!("{err}");
std::process::exit(1);
}
}
fn parse_args() -> Result<Args, String> {
let mut manifest = PathBuf::from(DEFAULT_MANIFEST);
let mut output_dir = PathBuf::from(DEFAULT_OUTPUT_DIR);
let mut demo_bin = None;
let mut keep_going = false;
let mut it = std::env::args().skip(1);
while let Some(arg) = it.next() {
match arg.as_str() {
"--manifest" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --manifest"))?;
manifest = PathBuf::from(value);
}
"--output-dir" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --output-dir"))?;
output_dir = PathBuf::from(value);
}
"--demo-bin" => {
let value = it
.next()
.ok_or_else(|| String::from("missing value for --demo-bin"))?;
demo_bin = Some(PathBuf::from(value));
}
"--keep-going" => {
keep_going = true;
}
"--help" | "-h" => {
print_help();
std::process::exit(0);
}
other => {
return Err(format!("unknown argument: {other}"));
}
}
}
Ok(Args {
manifest,
output_dir,
demo_bin,
keep_going,
})
}
fn print_help() {
eprintln!(
"render-parity [--manifest <cases.toml>] [--output-dir <dir>] [--demo-bin <path>] [--keep-going]"
);
eprintln!(" --manifest path to parity manifest (default: {DEFAULT_MANIFEST})");
eprintln!(" --output-dir where current renders and diff images are written");
eprintln!(" --demo-bin prebuilt parkan-render-demo binary path");
eprintln!(" --keep-going continue all cases even after failures");
}
fn run(args: Args) -> Result<(), String> {
let workspace = workspace_root()?;
let manifest_path = resolve_path(&workspace, &args.manifest);
let output_dir = resolve_path(&workspace, &args.output_dir);
let demo_bin = args
.demo_bin
.as_ref()
.map(|path| resolve_path(&workspace, path));
let manifest_raw = fs::read_to_string(&manifest_path)
.map_err(|err| format!("failed to read manifest {}: {err}", manifest_path.display()))?;
let manifest: ParityManifest = toml::from_str(&manifest_raw).map_err(|err| {
format!(
"failed to parse manifest {}: {err}",
manifest_path.display()
)
})?;
if manifest.cases.is_empty() {
println!(
"render-parity: no cases in {} (nothing to validate)",
manifest_path.display()
);
return Ok(());
}
fs::create_dir_all(&output_dir).map_err(|err| {
format!(
"failed to create output directory {}: {err}",
output_dir.display()
)
})?;
let manifest_dir = manifest_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| workspace.clone());
let mut failed_cases = 0usize;
for case in &manifest.cases {
let effective = make_effective_case(&manifest.meta, case, &manifest_dir)?;
let case_file = output_dir.join(format!("{}.png", sanitize_case_id(&effective.id)));
let diff_file = output_dir
.join("diff")
.join(format!("{}.png", sanitize_case_id(&effective.id)));
let run_res = run_single_case(
&workspace, // ensure `cargo run` executes from workspace root
demo_bin.as_deref(),
&effective,
&case_file,
&diff_file,
);
match run_res {
Ok(()) => {}
Err(err) => {
failed_cases = failed_cases.saturating_add(1);
eprintln!("[FAIL] {}: {}", effective.id, err);
if !args.keep_going {
break;
}
}
}
}
if failed_cases > 0 {
return Err(format!(
"render-parity failed: {} case(s) did not match reference frames",
failed_cases
));
}
println!("render-parity: all cases passed");
Ok(())
}
fn run_single_case(
workspace: &Path,
demo_bin: Option<&Path>,
case: &EffectiveCase,
case_file: &Path,
diff_file: &Path,
) -> Result<(), String> {
run_render_capture(workspace, demo_bin, case, case_file)?;
let reference = load_rgba(&case.reference)?;
let actual = load_rgba(case_file)?;
let metrics = compare_images(&reference, &actual, case.diff_threshold)?;
let violations = evaluate_metrics(&metrics, case.max_mean_abs, case.max_changed_ratio);
if violations.is_empty() {
println!(
"[OK] {} mean_abs={:.4} changed={:.4}% max_abs={} ({}x{})",
case.id,
metrics.mean_abs,
metrics.changed_ratio * 100.0,
metrics.max_abs,
metrics.width,
metrics.height
);
return Ok(());
}
if let Some(parent) = diff_file.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create diff output directory {}: {err}",
parent.display()
)
})?;
}
let diff = build_diff_image(&reference, &actual)?;
diff.save(diff_file)
.map_err(|err| format!("failed to save diff image {}: {err}", diff_file.display()))?;
let mut details = String::new();
for item in violations {
if !details.is_empty() {
details.push_str("; ");
}
details.push_str(&item);
}
Err(format!(
"{} | diff={} | mean_abs={:.4}, changed={:.4}% ({} px), max_abs={}",
details,
diff_file.display(),
metrics.mean_abs,
metrics.changed_ratio * 100.0,
metrics.changed_pixels,
metrics.max_abs
))
}
fn run_render_capture(
workspace: &Path,
demo_bin: Option<&Path>,
case: &EffectiveCase,
out_path: &Path,
) -> Result<(), String> {
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create capture directory {}: {err}",
parent.display()
)
})?;
}
let mut cmd = if let Some(bin) = demo_bin {
Command::new(bin)
} else {
let mut command = Command::new("cargo");
command.args(["run", "-p", "render-demo", "--features", "demo", "--"]);
command
};
cmd.current_dir(workspace)
.arg("--archive")
.arg(&case.archive)
.arg("--lod")
.arg(case.lod.to_string())
.arg("--group")
.arg(case.group.to_string())
.arg("--width")
.arg(case.width.to_string())
.arg("--height")
.arg(case.height.to_string())
.arg("--angle")
.arg(case.angle.to_string())
.arg("--capture")
.arg(out_path);
if let Some(model) = case.model.as_deref() {
cmd.arg("--model").arg(model);
}
let output = cmd.output().map_err(|err| {
let mode = if demo_bin.is_some() {
"parkan-render-demo"
} else {
"cargo run -p render-demo"
};
format!("failed to execute {} for case {}: {err}", mode, case.id)
})?;
if !output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!(
"render command exited with status {:?}\nstdout:\n{}\nstderr:\n{}",
output.status.code(),
stdout,
stderr
));
}
Ok(())
}
fn load_rgba(path: &Path) -> Result<RgbaImage, String> {
image::open(path)
.map_err(|err| format!("failed to load image {}: {err}", path.display()))
.map(|img| img.to_rgba8())
}
fn make_effective_case(
meta: &ManifestMeta,
case: &CaseSpec,
manifest_dir: &Path,
) -> Result<EffectiveCase, String> {
let width = case.width.or(meta.width).unwrap_or(DEFAULT_WIDTH);
let height = case.height.or(meta.height).unwrap_or(DEFAULT_HEIGHT);
if width == 0 || height == 0 {
return Err(format!(
"case '{}' has invalid dimensions {}x{}",
case.id, width, height
));
}
let archive = resolve_path(manifest_dir, Path::new(&case.archive));
let reference = resolve_path(manifest_dir, Path::new(&case.reference));
if !archive.is_file() {
return Err(format!(
"case '{}' archive not found: {}",
case.id,
archive.display()
));
}
if !reference.is_file() {
return Err(format!(
"case '{}' reference frame not found: {}",
case.id,
reference.display()
));
}
Ok(EffectiveCase {
id: case.id.clone(),
archive,
model: case.model.clone(),
reference,
width,
height,
lod: case.lod.or(meta.lod).unwrap_or(DEFAULT_LOD),
group: case.group.or(meta.group).unwrap_or(DEFAULT_GROUP),
angle: case.angle.or(meta.angle).unwrap_or(DEFAULT_ANGLE),
diff_threshold: case
.diff_threshold
.or(meta.diff_threshold)
.unwrap_or(DEFAULT_DIFF_THRESHOLD),
max_mean_abs: case
.max_mean_abs
.or(meta.max_mean_abs)
.unwrap_or(DEFAULT_MAX_MEAN_ABS),
max_changed_ratio: case
.max_changed_ratio
.or(meta.max_changed_ratio)
.unwrap_or(DEFAULT_MAX_CHANGED_RATIO),
})
}
fn sanitize_case_id(id: &str) -> String {
id.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
fn workspace_root() -> Result<PathBuf, String> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.canonicalize()
.map_err(|err| format!("failed to resolve workspace root: {err}"))?;
Ok(root)
}
fn resolve_path(base: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
base.join(path)
}
}

11
crates/rsli/Cargo.toml Normal file
View File

@@ -0,0 +1,11 @@
[package]
name = "rsli"
version = "0.1.0"
edition = "2021"
[dependencies]
common = { path = "../common" }
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
[dev-dependencies]
proptest = "1"

58
crates/rsli/README.md Normal file
View File

@@ -0,0 +1,58 @@
# rsli
Rust-библиотека для чтения архивов формата **RsLi**.
## Что умеет
- Открытие библиотеки из файла (`open_path`, `open_path_with`).
- Дешифрование таблицы записей (XOR stream cipher).
- Поддержка AO-трейлера и media overlay (`allow_ao_trailer`).
- Поддержка quirk для Deflate `EOF+1` (`allow_deflate_eof_plus_one`).
- Поиск по имени (`find`, c приведением запроса к uppercase).
- Загрузка данных:
- `load`, `load_into`, `load_packed`, `unpack`, `load_fast`.
## Поддерживаемые методы упаковки
- `0x000` None
- `0x020` XorOnly
- `0x040` Lzss
- `0x060` XorLzss
- `0x080` LzssHuffman
- `0x0A0` XorLzssHuffman
- `0x100` Deflate
## Модель ошибок
Типизированные ошибки без паник в production-коде (`InvalidMagic`, `UnsupportedVersion`, `EntryTableOutOfBounds`, `PackedSizePastEof`, `DeflateEofPlusOneQuirkRejected`, `UnsupportedMethod`, и др.).
## Покрытие тестами
### Реальные файлы
- Рекурсивный прогон по `testdata/rsli/**`.
- Сейчас в наборе: **2 архива**.
- На реальных данных подтверждены и проходят byte-to-byte проверки методы:
- `0x040` (LZSS)
- `0x100` (Deflate)
- Для каждого архива проверяется:
- `load`/`load_into`/`load_packed`/`unpack`/`load_fast`;
- `find`;
- пересборка и сравнение **byte-to-byte**.
### Синтетические тесты
Из-за отсутствия реальных файлов для части методов добавлены синтетические архивы и тесты:
- Методы:
- `0x000`, `0x020`, `0x060`, `0x080`, `0x0A0`.
- Спецкейсы формата:
- AO trailer + overlay;
- Deflate `EOF+1` (оба режима: accepted/rejected);
- некорректные заголовки/таблицы/смещения/методы.
## Быстрый запуск тестов
```bash
cargo test -p rsli -- --nocapture
```

View File

@@ -0,0 +1,14 @@
use crate::error::Error;
use crate::Result;
use flate2::read::DeflateDecoder;
use std::io::Read;
/// Decode raw Deflate (RFC 1951) payload.
pub fn decode_deflate(packed: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
let mut decoder = DeflateDecoder::new(packed);
decoder
.read_to_end(&mut out)
.map_err(|_| Error::DecompressionFailed("deflate"))?;
Ok(out)
}

View File

@@ -0,0 +1,303 @@
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()?);
let branch = node
.checked_add(bit)
.ok_or(Error::DecompressionFailed("lzss-huffman tree overflow"))?;
node = *self.son.get(branch).ok_or(Error::DecompressionFailed(
"lzss-huffman tree out of bounds",
))?;
}
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)
}
}

View File

@@ -0,0 +1,79 @@
use super::xor::XorState;
use crate::error::Error;
use crate::Result;
/// Simple LZSS decompression with optional on-the-fly XOR decryption
pub fn lzss_decompress_simple(
data: &[u8],
expected_size: usize,
xor_key: Option<u16>,
) -> Result<Vec<u8>> {
let mut ring = [0x20u8; 0x1000];
let mut ring_pos = 0xFEEusize;
let mut out = Vec::with_capacity(expected_size);
let mut in_pos = 0usize;
let mut control = 0u8;
let mut bits_left = 0u8;
// XOR state for on-the-fly decryption
let mut xor_state = xor_key.map(XorState::new);
// Helper to read byte with optional XOR decryption
let read_byte = |pos: usize, state: &mut Option<XorState>| -> Option<u8> {
let encrypted = data.get(pos).copied()?;
Some(if let Some(ref mut s) = state {
s.decrypt_byte(encrypted)
} else {
encrypted
})
};
while out.len() < expected_size {
if bits_left == 0 {
let byte = read_byte(in_pos, &mut xor_state)
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
control = byte;
in_pos += 1;
bits_left = 8;
}
if (control & 1) != 0 {
let byte = read_byte(in_pos, &mut xor_state)
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
in_pos += 1;
out.push(byte);
ring[ring_pos] = byte;
ring_pos = (ring_pos + 1) & 0x0FFF;
} else {
let low = read_byte(in_pos, &mut xor_state)
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
let high = read_byte(in_pos + 1, &mut xor_state)
.ok_or(Error::DecompressionFailed("lzss-simple: unexpected EOF"))?;
in_pos += 2;
let offset = usize::from(low) | (usize::from(high & 0xF0) << 4);
let length = usize::from((high & 0x0F) + 3);
for step in 0..length {
let byte = ring[(offset + step) & 0x0FFF];
out.push(byte);
ring[ring_pos] = byte;
ring_pos = (ring_pos + 1) & 0x0FFF;
if out.len() >= expected_size {
break;
}
}
}
control >>= 1;
bits_left -= 1;
}
if out.len() != expected_size {
return Err(Error::DecompressionFailed("lzss-simple"));
}
Ok(out)
}

View File

@@ -0,0 +1,9 @@
pub mod deflate;
pub mod lzh;
pub mod lzss;
pub mod xor;
pub use deflate::decode_deflate;
pub use lzh::lzss_huffman_decompress;
pub use lzss::lzss_decompress_simple;
pub use xor::{xor_stream, XorState};

View File

@@ -0,0 +1,29 @@
/// XOR cipher state for RsLi format
pub struct XorState {
lo: u8,
hi: u8,
}
impl XorState {
/// Create new XOR state from 16-bit key
pub fn new(key16: u16) -> Self {
Self {
lo: (key16 & 0xFF) as u8,
hi: ((key16 >> 8) & 0xFF) as u8,
}
}
/// Decrypt a single byte and update state
pub fn decrypt_byte(&mut self, encrypted: u8) -> u8 {
self.lo = self.hi ^ self.lo.wrapping_shl(1);
let decrypted = encrypted ^ self.lo;
self.hi = self.lo ^ (self.hi >> 1);
decrypted
}
}
/// Decrypt entire buffer with XOR stream cipher
pub fn xor_stream(data: &[u8], key16: u16) -> Vec<u8> {
let mut state = XorState::new(key16);
data.iter().map(|&b| state.decrypt_byte(b)).collect()
}

140
crates/rsli/src/error.rs Normal file
View File

@@ -0,0 +1,140 @@
use core::fmt;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
Io(std::io::Error),
InvalidMagic {
got: [u8; 2],
},
UnsupportedVersion {
got: u8,
},
InvalidEntryCount {
got: i16,
},
TooManyEntries {
got: usize,
},
EntryTableOutOfBounds {
table_offset: u64,
table_len: u64,
file_len: u64,
},
EntryTableDecryptFailed,
CorruptEntryTable(&'static str),
EntryIdOutOfRange {
id: u32,
entry_count: u32,
},
EntryDataOutOfBounds {
id: u32,
offset: u64,
size: u32,
file_len: u64,
},
AoTrailerInvalid,
MediaOverlayOutOfBounds {
overlay: u32,
file_len: u64,
},
UnsupportedMethod {
raw: u32,
},
PackedSizePastEof {
id: u32,
offset: u64,
packed_size: u32,
file_len: u64,
},
DeflateEofPlusOneQuirkRejected {
id: u32,
},
DecompressionFailed(&'static str),
OutputSizeMismatch {
expected: u32,
got: u32,
},
IntegerOverflow,
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Io(e) => write!(f, "I/O error: {e}"),
Error::InvalidMagic { got } => write!(f, "invalid RsLi magic: {got:02X?}"),
Error::UnsupportedVersion { got } => write!(f, "unsupported RsLi version: {got:#x}"),
Error::InvalidEntryCount { got } => write!(f, "invalid entry_count: {got}"),
Error::TooManyEntries { got } => write!(f, "too many entries: {got} exceeds u32::MAX"),
Error::EntryTableOutOfBounds {
table_offset,
table_len,
file_len,
} => write!(
f,
"entry table out of bounds: off={table_offset}, len={table_len}, file={file_len}"
),
Error::EntryTableDecryptFailed => write!(f, "failed to decrypt entry table"),
Error::CorruptEntryTable(s) => write!(f, "corrupt entry table: {s}"),
Error::EntryIdOutOfRange { id, entry_count } => {
write!(f, "entry id out of range: id={id}, count={entry_count}")
}
Error::EntryDataOutOfBounds {
id,
offset,
size,
file_len,
} => write!(
f,
"entry data out of bounds: id={id}, off={offset}, size={size}, file={file_len}"
),
Error::AoTrailerInvalid => write!(f, "invalid AO trailer"),
Error::MediaOverlayOutOfBounds { overlay, file_len } => {
write!(
f,
"media overlay out of bounds: overlay={overlay}, file={file_len}"
)
}
Error::UnsupportedMethod { raw } => write!(f, "unsupported packing method: {raw:#x}"),
Error::PackedSizePastEof {
id,
offset,
packed_size,
file_len,
} => write!(
f,
"packed range past EOF: id={id}, off={offset}, size={packed_size}, file={file_len}"
),
Error::DeflateEofPlusOneQuirkRejected { id } => {
write!(f, "deflate EOF+1 quirk rejected for entry {id}")
}
Error::DecompressionFailed(s) => write!(f, "decompression failed: {s}"),
Error::OutputSizeMismatch { expected, got } => {
write!(f, "output size mismatch: expected={expected}, got={got}")
}
Error::IntegerOverflow => write!(f, "integer overflow"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}

470
crates/rsli/src/lib.rs Normal file
View File

@@ -0,0 +1,470 @@
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(Clone, Debug)]
pub struct LibraryHeader {
pub raw: [u8; 32],
pub magic: [u8; 2],
pub reserved: u8,
pub version: u8,
pub entry_count: i16,
pub presorted_flag: u16,
pub xor_seed: u32,
}
#[derive(Clone, Debug)]
pub struct AoTrailer {
pub raw: [u8; 6],
pub overlay: u32,
}
#[derive(Debug)]
pub struct Library {
bytes: Arc<[u8]>,
entries: Vec<EntryRecord>,
header: LibraryHeader,
ao_trailer: Option<AoTrailer>,
#[cfg(test)]
pub(crate) table_plain_original: Vec<u8>,
#[cfg(test)]
pub(crate) source_size: usize,
}
#[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,
}
#[derive(Copy, Clone, Debug)]
pub struct EntryInspect<'a> {
pub id: EntryId,
pub meta: &'a EntryMeta,
pub name_raw: &'a [u8; 12],
pub service_tail: &'a [u8; 4],
pub sort_to_original: i16,
pub data_offset_raw: u32,
}
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) service_tail: [u8; 4],
pub(crate) sort_to_original: i16,
pub(crate) key16: u16,
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 header(&self) -> &LibraryHeader {
&self.header
}
pub fn ao_trailer(&self) -> Option<&AoTrailer> {
self.ao_trailer.as_ref()
}
pub fn entry_count(&self) -> usize {
self.entries.len()
}
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
let id = u32::try_from(idx).ok()?;
Some(EntryRef {
id: EntryId(id),
meta: &entry.meta,
})
})
}
pub fn entries_inspect(&self) -> impl Iterator<Item = EntryInspect<'_>> {
self.entries.iter().enumerate().filter_map(|(idx, entry)| {
let id = u32::try_from(idx).ok()?;
Some(EntryInspect {
id: EntryId(id),
meta: &entry.meta,
name_raw: &entry.name_raw,
service_tail: &entry.service_tail,
sort_to_original: entry.sort_to_original,
data_offset_raw: entry.data_offset_raw,
})
})
}
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 => {
let id = u32::try_from(idx).ok()?;
return Some(EntryId(id));
}
}
}
// 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 {
let id = u32::try_from(idx).ok()?;
Some(EntryId(id))
} 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 inspect(&self, id: EntryId) -> Option<EntryInspect<'_>> {
let idx = usize::try_from(id.0).ok()?;
let entry = self.entries.get(idx)?;
Some(EntryInspect {
id,
meta: &entry.meta,
name_raw: &entry.name_raw,
service_tail: &entry.service_tail,
sort_to_original: entry.sort_to_original,
data_offset_raw: entry.data_offset_raw,
})
}
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: saturating_u32_len(self.entries.len()),
})
}
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.ao_trailer.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.header.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 id = u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?;
let packed = self.load_packed(EntryId(id))?.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,
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.ao_trailer {
out.extend_from_slice(&trailer.raw);
}
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
)
}
fn saturating_u32_len(len: usize) -> u32 {
u32::try_from(len).unwrap_or(u32::MAX)
}
#[cfg(test)]
mod tests;

278
crates/rsli/src/parse.rs Normal file
View File

@@ -0,0 +1,278 @@
use crate::compress::xor::xor_stream;
use crate::error::Error;
use crate::{
AoTrailer, EntryMeta, EntryRecord, Library, LibraryHeader, 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]);
let mut magic = [0u8; 2];
magic.copy_from_slice(&bytes[0..2]);
if &magic != b"NL" {
let mut got = [0u8; 2];
got.copy_from_slice(&bytes[0..2]);
return Err(Error::InvalidMagic { got });
}
let reserved = bytes[2];
let version = bytes[3];
if version != 0x01 {
return Err(Error::UnsupportedVersion { got: version });
}
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 presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
let header = LibraryHeader {
raw: header_raw,
magic,
reserved,
version,
entry_count,
presorted_flag,
xor_seed,
};
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)?;
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 mut service_tail = [0u8; 4];
service_tail.copy_from_slice(&row[12..16]);
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).map_err(|_| Error::IntegerOverflow)?,
});
}
} else {
return Err(Error::PackedSizePastEof {
id: u32::try_from(idx).map_err(|_| Error::IntegerOverflow)?,
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).map_err(|_| Error::IntegerOverflow)?,
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,
service_tail,
sort_to_original,
key16: sort_to_original as u16,
data_offset_raw,
packed_size_declared,
packed_size_available,
effective_offset,
});
}
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,
header,
ao_trailer: trailer_raw.map(|raw| AoTrailer { raw, overlay }),
#[cfg(test)]
table_plain_original,
#[cfg(test)]
source_size,
})
}
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())
}

1338
crates/rsli/src/tests.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
[package]
name = "terrain-core"
version = "0.1.0"
edition = "2021"
[dependencies]
nres = { path = "../nres" }
[dev-dependencies]
common = { path = "../common" }

View File

@@ -0,0 +1,281 @@
use nres::Archive;
use std::fmt;
use std::path::Path;
pub const TERRAIN_UV_SCALE: f32 = 1024.0;
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Nres(nres::error::Error),
MissingChunk(&'static str),
InvalidChunkSize {
label: &'static str,
size: usize,
stride: usize,
},
VertexCountOverflow {
count: usize,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Nres(err) => write!(f, "{err}"),
Self::MissingChunk(label) => write!(f, "missing required terrain chunk: {label}"),
Self::InvalidChunkSize {
label,
size,
stride,
} => write!(
f,
"invalid chunk size for {label}: {size} (must be divisible by {stride})"
),
Self::VertexCountOverflow { count } => {
write!(f, "terrain vertex count {count} exceeds u16 range")
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Nres(err) => Some(err),
_ => None,
}
}
}
impl From<nres::error::Error> for Error {
fn from(value: nres::error::Error) -> Self {
Self::Nres(value)
}
}
#[derive(Clone, Debug)]
pub struct TerrainMesh {
pub positions: Vec<[f32; 3]>,
pub uv0: Vec<[f32; 2]>,
pub faces: Vec<TerrainFace>,
}
#[derive(Copy, Clone, Debug)]
pub struct TerrainFace {
pub indices: [u16; 3],
pub flags: u32,
pub material_tag: u16,
pub aux_tag: u16,
}
#[derive(Clone, Debug)]
pub struct TerrainRenderMesh {
pub vertices: Vec<TerrainRenderVertex>,
pub indices: Vec<u16>,
pub face_count_raw: usize,
pub face_count_kept: usize,
pub face_count_dropped_invalid: usize,
}
#[derive(Copy, Clone, Debug)]
pub struct TerrainRenderVertex {
pub position: [f32; 3],
pub uv0: [f32; 2],
}
pub fn load_land_mesh(path: impl AsRef<Path>) -> Result<TerrainMesh> {
let archive = Archive::open_path(path.as_ref())?;
let positions_entry = archive
.entries()
.find(|entry| entry.meta.kind == 3)
.ok_or(Error::MissingChunk("type=3 (positions)"))?;
let uv_entry = archive.entries().find(|entry| entry.meta.kind == 5);
let faces_entry = archive
.entries()
.find(|entry| entry.meta.kind == 21)
.ok_or(Error::MissingChunk("type=21 (faces)"))?;
let positions_payload = archive.read(positions_entry.id)?.into_owned();
if positions_payload.len() % 12 != 0 {
return Err(Error::InvalidChunkSize {
label: "type=3 (positions)",
size: positions_payload.len(),
stride: 12,
});
}
let mut positions = Vec::with_capacity(positions_payload.len() / 12);
for chunk in positions_payload.chunks_exact(12) {
let x = f32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
let y = f32::from_le_bytes(chunk[4..8].try_into().unwrap_or([0; 4]));
let z = f32::from_le_bytes(chunk[8..12].try_into().unwrap_or([0; 4]));
positions.push([x, y, z]);
}
let mut uv0 = vec![[0.0f32, 0.0f32]; positions.len()];
if let Some(uv_entry) = uv_entry {
let uv_payload = archive.read(uv_entry.id)?.into_owned();
if uv_payload.len() % 4 != 0 {
return Err(Error::InvalidChunkSize {
label: "type=5 (uv)",
size: uv_payload.len(),
stride: 4,
});
}
let uv_count = uv_payload.len() / 4;
for idx in 0..uv_count.min(uv0.len()) {
let off = idx * 4;
let u = i16::from_le_bytes([uv_payload[off], uv_payload[off + 1]]) as f32;
let v = i16::from_le_bytes([uv_payload[off + 2], uv_payload[off + 3]]) as f32;
uv0[idx] = [u / TERRAIN_UV_SCALE, v / TERRAIN_UV_SCALE];
}
}
let face_payload = archive.read(faces_entry.id)?.into_owned();
if face_payload.len() % 28 != 0 {
return Err(Error::InvalidChunkSize {
label: "type=21 (faces)",
size: face_payload.len(),
stride: 28,
});
}
let mut faces = Vec::with_capacity(face_payload.len() / 28);
for chunk in face_payload.chunks_exact(28) {
let flags = u32::from_le_bytes(chunk[0..4].try_into().unwrap_or([0; 4]));
let material_tag = u16::from_le_bytes(chunk[4..6].try_into().unwrap_or([0; 2]));
let aux_tag = u16::from_le_bytes(chunk[6..8].try_into().unwrap_or([0; 2]));
let i0 = u16::from_le_bytes(chunk[8..10].try_into().unwrap_or([0; 2]));
let i1 = u16::from_le_bytes(chunk[10..12].try_into().unwrap_or([0; 2]));
let i2 = u16::from_le_bytes(chunk[12..14].try_into().unwrap_or([0; 2]));
if usize::from(i0) >= positions.len()
|| usize::from(i1) >= positions.len()
|| usize::from(i2) >= positions.len()
{
continue;
}
faces.push(TerrainFace {
indices: [i0, i1, i2],
flags,
material_tag,
aux_tag,
});
}
Ok(TerrainMesh {
positions,
uv0,
faces,
})
}
pub fn build_render_mesh(mesh: &TerrainMesh) -> Result<TerrainRenderMesh> {
if mesh.positions.len() > usize::from(u16::MAX) + 1 {
return Err(Error::VertexCountOverflow {
count: mesh.positions.len(),
});
}
let vertices = mesh
.positions
.iter()
.enumerate()
.map(|(idx, &position)| TerrainRenderVertex {
position,
uv0: mesh.uv0.get(idx).copied().unwrap_or([0.0, 0.0]),
})
.collect::<Vec<_>>();
let mut indices = Vec::with_capacity(mesh.faces.len() * 3);
for face in &mesh.faces {
indices.extend_from_slice(&face.indices);
}
Ok(TerrainRenderMesh {
vertices,
indices,
face_count_raw: mesh.faces.len(),
face_count_kept: mesh.faces.len(),
face_count_dropped_invalid: 0,
})
}
#[cfg(test)]
mod tests {
use super::*;
use common::collect_files_recursive;
use std::path::{Path, PathBuf};
fn game_root() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
root.is_dir().then_some(root)
}
#[test]
fn loads_known_land_mesh() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let land = root
.join("DATA")
.join("MAPS")
.join("Tut_1")
.join("Land.msh");
if !land.is_file() {
eprintln!("skipping missing sample {}", land.display());
return;
}
let mesh = load_land_mesh(&land)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", land.display()));
assert!(mesh.positions.len() > 1000);
assert!(mesh.faces.len() > 1000);
let render = build_render_mesh(&mesh).expect("failed to build render mesh");
assert_eq!(render.vertices.len(), mesh.positions.len());
assert_eq!(render.indices.len(), mesh.faces.len() * 3);
}
#[test]
fn loads_all_retail_land_meshes() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let maps_root = root.join("DATA").join("MAPS");
let mut files = Vec::new();
collect_files_recursive(&maps_root, &mut files);
files.sort();
let mut parsed = 0usize;
for path in files {
if !path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case("Land.msh"))
{
continue;
}
let mesh = load_land_mesh(&path)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
assert!(
!mesh.positions.is_empty() && !mesh.faces.is_empty(),
"{} parsed but empty",
path.display()
);
parsed += 1;
}
assert!(parsed > 0, "no Land.msh files parsed");
}
}

9
crates/texm/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "texm"
version = "0.1.0"
edition = "2021"
[dev-dependencies]
common = { path = "../common" }
nres = { path = "../nres" }
proptest = "1"

15
crates/texm/README.md Normal file
View File

@@ -0,0 +1,15 @@
# texm
Парсер формата текстур `Texm`.
Покрывает:
- header (`width/height/mipCount/flags/format`);
- core size расчёт;
- optional `Page` chunk;
- строгую валидацию layout.
Тесты:
- прогон по реальным `Texm` из `testdata`;
- синтетические edge-cases (indexed + page, minimal rgba).

86
crates/texm/src/error.rs Normal file
View File

@@ -0,0 +1,86 @@
use core::fmt;
#[derive(Debug)]
#[non_exhaustive]
pub enum Error {
HeaderTooSmall {
size: usize,
},
InvalidMagic {
got: u32,
},
InvalidDimensions {
width: u32,
height: u32,
},
InvalidMipCount {
mip_count: u32,
},
UnknownFormat {
format: u32,
},
IntegerOverflow,
CoreDataOutOfBounds {
expected_end: usize,
actual_size: usize,
},
MipIndexOutOfRange {
requested: usize,
mip_count: usize,
},
MipDataOutOfBounds {
offset: usize,
size: usize,
payload_size: usize,
},
InvalidPageMagic,
InvalidPageSize {
expected: usize,
actual: usize,
},
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::HeaderTooSmall { size } => {
write!(f, "Texm payload too small for header: {size}")
}
Self::InvalidMagic { got } => write!(f, "invalid Texm magic: 0x{got:08X}"),
Self::InvalidDimensions { width, height } => {
write!(f, "invalid Texm dimensions: {width}x{height}")
}
Self::InvalidMipCount { mip_count } => write!(f, "invalid Texm mip_count={mip_count}"),
Self::UnknownFormat { format } => write!(f, "unknown Texm format={format}"),
Self::IntegerOverflow => write!(f, "integer overflow"),
Self::CoreDataOutOfBounds {
expected_end,
actual_size,
} => write!(
f,
"Texm core data out of bounds: expected_end={expected_end}, actual_size={actual_size}"
),
Self::MipIndexOutOfRange {
requested,
mip_count,
} => write!(
f,
"Texm mip index out of range: requested={requested}, mip_count={mip_count}"
),
Self::MipDataOutOfBounds {
offset,
size,
payload_size,
} => write!(
f,
"Texm mip data out of bounds: offset={offset}, size={size}, payload_size={payload_size}"
),
Self::InvalidPageMagic => write!(f, "Texm tail exists but Page magic is missing"),
Self::InvalidPageSize { expected, actual } => {
write!(f, "invalid Page chunk size: expected={expected}, actual={actual}")
}
}
}
}
impl std::error::Error for Error {}

417
crates/texm/src/lib.rs Normal file
View File

@@ -0,0 +1,417 @@
pub mod error;
use crate::error::Error;
pub type Result<T> = core::result::Result<T, Error>;
pub const TEXM_MAGIC: u32 = 0x6D78_6554;
pub const PAGE_MAGIC: u32 = 0x6567_6150;
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub enum PixelFormat {
Indexed8,
Rgb565,
Rgb556,
Argb4444,
LuminanceAlpha88,
Rgb888,
Argb8888,
}
impl PixelFormat {
pub fn from_raw(raw: u32) -> Option<Self> {
match raw {
0 => Some(Self::Indexed8),
565 => Some(Self::Rgb565),
556 => Some(Self::Rgb556),
4444 => Some(Self::Argb4444),
88 => Some(Self::LuminanceAlpha88),
888 => Some(Self::Rgb888),
8888 => Some(Self::Argb8888),
_ => None,
}
}
pub fn bytes_per_pixel(self) -> usize {
match self {
Self::Indexed8 => 1,
Self::Rgb565 | Self::Rgb556 | Self::Argb4444 | Self::LuminanceAlpha88 => 2,
// Parkan stores format 888 as 32-bit RGBX in texture payloads.
Self::Rgb888 | Self::Argb8888 => 4,
}
}
}
#[derive(Clone, Debug)]
pub struct Header {
pub width: u32,
pub height: u32,
pub mip_count: u32,
pub flags4: u32,
pub flags5: u32,
pub unk6: u32,
pub format_raw: u32,
pub format: PixelFormat,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct MipLevel {
pub width: u32,
pub height: u32,
pub offset: usize,
pub size: usize,
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct PageRect {
pub x: i16,
pub w: i16,
pub y: i16,
pub h: i16,
}
#[derive(Clone, Debug)]
pub struct Texture {
pub header: Header,
pub palette: Option<[u8; 1024]>,
pub mip_levels: Vec<MipLevel>,
pub page_rects: Vec<PageRect>,
}
impl Texture {
pub fn core_size(&self) -> usize {
let mut size = 32usize;
if self.palette.is_some() {
size += 1024;
}
for level in &self.mip_levels {
size += level.size;
}
size
}
}
#[derive(Clone, Debug)]
pub struct DecodedMip {
pub width: u32,
pub height: u32,
pub rgba8: Vec<u8>,
}
pub fn parse_texm(payload: &[u8]) -> Result<Texture> {
if payload.len() < 32 {
return Err(Error::HeaderTooSmall {
size: payload.len(),
});
}
let magic = read_u32(payload, 0)?;
if magic != TEXM_MAGIC {
return Err(Error::InvalidMagic { got: magic });
}
let width = read_u32(payload, 4)?;
let height = read_u32(payload, 8)?;
let mip_count = read_u32(payload, 12)?;
let flags4 = read_u32(payload, 16)?;
let flags5 = read_u32(payload, 20)?;
let unk6 = read_u32(payload, 24)?;
let format_raw = read_u32(payload, 28)?;
if width == 0 || height == 0 {
return Err(Error::InvalidDimensions { width, height });
}
if mip_count == 0 {
return Err(Error::InvalidMipCount { mip_count });
}
let format =
PixelFormat::from_raw(format_raw).ok_or(Error::UnknownFormat { format: format_raw })?;
let bytes_per_pixel = format.bytes_per_pixel();
let mut offset = 32usize;
let palette = if format == PixelFormat::Indexed8 {
let end = offset.checked_add(1024).ok_or(Error::IntegerOverflow)?;
if end > payload.len() {
return Err(Error::CoreDataOutOfBounds {
expected_end: end,
actual_size: payload.len(),
});
}
let mut pal = [0u8; 1024];
pal.copy_from_slice(&payload[offset..end]);
offset = end;
Some(pal)
} else {
None
};
let mut mip_levels =
Vec::with_capacity(usize::try_from(mip_count).map_err(|_| Error::IntegerOverflow)?);
let mut w = width;
let mut h = height;
for _ in 0..mip_count {
let pixel_count_u64 = u64::from(w)
.checked_mul(u64::from(h))
.ok_or(Error::IntegerOverflow)?;
let level_size_u64 = pixel_count_u64
.checked_mul(u64::try_from(bytes_per_pixel).map_err(|_| Error::IntegerOverflow)?)
.ok_or(Error::IntegerOverflow)?;
let level_size = usize::try_from(level_size_u64).map_err(|_| Error::IntegerOverflow)?;
let level_offset = offset;
offset = offset
.checked_add(level_size)
.ok_or(Error::IntegerOverflow)?;
if offset > payload.len() {
return Err(Error::CoreDataOutOfBounds {
expected_end: offset,
actual_size: payload.len(),
});
}
mip_levels.push(MipLevel {
width: w,
height: h,
offset: level_offset,
size: level_size,
});
w = (w >> 1).max(1);
h = (h >> 1).max(1);
}
let page_rects = parse_page_tail(payload, offset)?;
Ok(Texture {
header: Header {
width,
height,
mip_count,
flags4,
flags5,
unk6,
format_raw,
format,
},
palette,
mip_levels,
page_rects,
})
}
pub fn decode_mip_rgba8(texture: &Texture, payload: &[u8], mip_index: usize) -> Result<DecodedMip> {
let Some(level) = texture.mip_levels.get(mip_index).copied() else {
return Err(Error::MipIndexOutOfRange {
requested: mip_index,
mip_count: texture.mip_levels.len(),
});
};
let end = level
.offset
.checked_add(level.size)
.ok_or(Error::IntegerOverflow)?;
let Some(level_data) = payload.get(level.offset..end) else {
return Err(Error::MipDataOutOfBounds {
offset: level.offset,
size: level.size,
payload_size: payload.len(),
});
};
let pixel_count = usize::try_from(level.width)
.ok()
.and_then(|w| {
usize::try_from(level.height)
.ok()
.map(|h| w.saturating_mul(h))
})
.ok_or(Error::IntegerOverflow)?;
let mut rgba = vec![0u8; pixel_count.saturating_mul(4)];
match texture.header.format {
PixelFormat::Indexed8 => {
let palette = texture.palette.as_ref().ok_or(Error::IntegerOverflow)?;
for (i, &index) in level_data.iter().enumerate() {
if i >= pixel_count {
break;
}
let poff = usize::from(index).saturating_mul(4);
// Keep this form to accept the last palette item (index 255).
if poff + 4 > palette.len() {
continue;
}
let out = i.saturating_mul(4);
rgba[out] = palette[poff];
rgba[out + 1] = palette[poff + 1];
rgba[out + 2] = palette[poff + 2];
rgba[out + 3] = palette[poff + 3];
}
}
PixelFormat::Rgb565 => {
decode_words(level_data, pixel_count, &mut rgba, decode_rgb565);
}
PixelFormat::Rgb556 => {
decode_words(level_data, pixel_count, &mut rgba, decode_rgb556);
}
PixelFormat::Argb4444 => {
decode_words(level_data, pixel_count, &mut rgba, decode_argb4444);
}
PixelFormat::LuminanceAlpha88 => {
decode_words(level_data, pixel_count, &mut rgba, decode_luminance_alpha88);
}
PixelFormat::Rgb888 => {
decode_dwords(level_data, pixel_count, &mut rgba, decode_rgb888x);
}
PixelFormat::Argb8888 => {
decode_dwords(level_data, pixel_count, &mut rgba, decode_argb8888);
}
}
Ok(DecodedMip {
width: level.width,
height: level.height,
rgba8: rgba,
})
}
fn parse_page_tail(payload: &[u8], core_end: usize) -> Result<Vec<PageRect>> {
if core_end == payload.len() {
return Ok(Vec::new());
}
if payload.len().saturating_sub(core_end) < 8 {
return Err(Error::InvalidPageSize {
expected: 8,
actual: payload.len().saturating_sub(core_end),
});
}
let magic = read_u32(payload, core_end)?;
if magic != PAGE_MAGIC {
return Err(Error::InvalidPageMagic);
}
let rect_count = read_u32(payload, core_end + 4)?;
let rect_count_usize = usize::try_from(rect_count).map_err(|_| Error::IntegerOverflow)?;
let expected_size = 8usize
.checked_add(
rect_count_usize
.checked_mul(8)
.ok_or(Error::IntegerOverflow)?,
)
.ok_or(Error::IntegerOverflow)?;
let actual = payload.len().saturating_sub(core_end);
if expected_size != actual {
return Err(Error::InvalidPageSize {
expected: expected_size,
actual,
});
}
let mut rects = Vec::with_capacity(rect_count_usize);
for i in 0..rect_count_usize {
let off = core_end
.checked_add(8)
.and_then(|v| v.checked_add(i * 8))
.ok_or(Error::IntegerOverflow)?;
rects.push(PageRect {
x: read_i16(payload, off)?,
w: read_i16(payload, off + 2)?,
y: read_i16(payload, off + 4)?,
h: read_i16(payload, off + 6)?,
});
}
Ok(rects)
}
fn read_u32(data: &[u8], offset: usize) -> Result<u32> {
let bytes = data.get(offset..offset + 4).ok_or(Error::IntegerOverflow)?;
let arr: [u8; 4] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
Ok(u32::from_le_bytes(arr))
}
fn read_i16(data: &[u8], offset: usize) -> Result<i16> {
let bytes = data.get(offset..offset + 2).ok_or(Error::IntegerOverflow)?;
let arr: [u8; 2] = bytes.try_into().map_err(|_| Error::IntegerOverflow)?;
Ok(i16::from_le_bytes(arr))
}
fn decode_words(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u16) -> [u8; 4]) {
for i in 0..pixel_count {
let off = i.saturating_mul(2);
let Some(bytes) = data.get(off..off + 2) else {
break;
};
let word = u16::from_le_bytes([bytes[0], bytes[1]]);
let px = decode(word);
let out = i.saturating_mul(4);
rgba[out..out + 4].copy_from_slice(&px);
}
}
fn decode_dwords(data: &[u8], pixel_count: usize, rgba: &mut [u8], decode: fn(u32) -> [u8; 4]) {
for i in 0..pixel_count {
let off = i.saturating_mul(4);
let Some(bytes) = data.get(off..off + 4) else {
break;
};
let dword = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let px = decode(dword);
let out = i.saturating_mul(4);
rgba[out..out + 4].copy_from_slice(&px);
}
}
fn expand5(v: u16) -> u8 {
((u32::from(v) * 255 + 15) / 31) as u8
}
fn expand6(v: u16) -> u8 {
((u32::from(v) * 255 + 31) / 63) as u8
}
fn expand4(v: u16) -> u8 {
(u32::from(v) * 17) as u8
}
fn decode_rgb565(word: u16) -> [u8; 4] {
let r = expand5((word >> 11) & 0x1F);
let g = expand6((word >> 5) & 0x3F);
let b = expand5(word & 0x1F);
[r, g, b, 255]
}
fn decode_rgb556(word: u16) -> [u8; 4] {
let r = expand5((word >> 11) & 0x1F);
let g = expand5((word >> 6) & 0x1F);
let b = expand6(word & 0x3F);
[r, g, b, 255]
}
fn decode_argb4444(word: u16) -> [u8; 4] {
let a = expand4((word >> 12) & 0x0F);
let r = expand4((word >> 8) & 0x0F);
let g = expand4((word >> 4) & 0x0F);
let b = expand4(word & 0x0F);
[r, g, b, a]
}
fn decode_luminance_alpha88(word: u16) -> [u8; 4] {
let l = ((word >> 8) & 0xFF) as u8;
let a = (word & 0xFF) as u8;
[l, l, l, a]
}
fn decode_rgb888x(dword: u32) -> [u8; 4] {
let r = (dword & 0xFF) as u8;
let g = ((dword >> 8) & 0xFF) as u8;
let b = ((dword >> 16) & 0xFF) as u8;
[r, g, b, 255]
}
fn decode_argb8888(dword: u32) -> [u8; 4] {
let a = (dword & 0xFF) as u8;
let r = ((dword >> 8) & 0xFF) as u8;
let g = ((dword >> 16) & 0xFF) as u8;
let b = ((dword >> 24) & 0xFF) as u8;
[r, g, b, a]
}
#[cfg(test)]
mod tests;

330
crates/texm/src/tests.rs Normal file
View File

@@ -0,0 +1,330 @@
use super::*;
use common::collect_files_recursive;
use nres::Archive;
use proptest::prelude::*;
use std::fs;
use std::path::{Path, PathBuf};
fn nres_test_files() -> Vec<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata");
let mut files = Vec::new();
collect_files_recursive(&root, &mut files);
files.sort();
files
.into_iter()
.filter(|path| {
fs::read(path)
.map(|bytes| bytes.get(0..4) == Some(b"NRes"))
.unwrap_or(false)
})
.collect()
}
fn build_texm_payload(
width: u32,
height: u32,
format_raw: u32,
flags5: u32,
palette: Option<[u8; 1024]>,
mip_levels: &[&[u8]],
) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(&TEXM_MAGIC.to_le_bytes());
payload.extend_from_slice(&width.to_le_bytes());
payload.extend_from_slice(&height.to_le_bytes());
payload.extend_from_slice(
&u32::try_from(mip_levels.len())
.expect("mip level count overflow in test")
.to_le_bytes(),
);
payload.extend_from_slice(&0u32.to_le_bytes()); // flags4
payload.extend_from_slice(&flags5.to_le_bytes());
payload.extend_from_slice(&0u32.to_le_bytes()); // unk6
payload.extend_from_slice(&format_raw.to_le_bytes());
if let Some(palette) = palette {
payload.extend_from_slice(&palette);
}
for level in mip_levels {
payload.extend_from_slice(level);
}
payload
}
#[test]
fn texm_parse_all_game_textures() {
let archives = nres_test_files();
if archives.is_empty() {
eprintln!("skipping texm_parse_all_game_textures: no NRes files in testdata");
return;
}
let mut texm_total = 0usize;
let mut texm_with_page = 0usize;
for archive_path in archives {
let archive = Archive::open_path(&archive_path)
.unwrap_or_else(|err| panic!("failed to open {}: {err}", archive_path.display()));
for entry in archive.entries() {
if entry.meta.kind != TEXM_MAGIC {
continue;
}
texm_total += 1;
let payload = archive.read(entry.id).unwrap_or_else(|err| {
panic!(
"failed to read Texm entry '{}' in {}: {err}",
entry.meta.name,
archive_path.display()
)
});
let texture = parse_texm(payload.as_slice()).unwrap_or_else(|err| {
panic!(
"failed to parse Texm '{}' in {}: {err}",
entry.meta.name,
archive_path.display()
)
});
if !texture.page_rects.is_empty() {
texm_with_page += 1;
}
assert!(
texture.core_size() <= payload.as_slice().len(),
"core size must be within payload for '{}' in {}",
entry.meta.name,
archive_path.display()
);
assert_eq!(
usize::try_from(texture.header.mip_count).ok(),
Some(texture.mip_levels.len()),
"mip count mismatch for '{}' in {}",
entry.meta.name,
archive_path.display()
);
}
}
assert!(texm_total > 0, "no Texm textures found");
assert!(
texm_with_page > 0,
"expected at least one Texm texture with Page chunk"
);
}
#[test]
fn texm_parse_minimal_argb8888_no_page() {
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
assert_eq!(parsed.header.width, 1);
assert_eq!(parsed.header.height, 1);
assert_eq!(parsed.mip_levels.len(), 1);
assert!(parsed.page_rects.is_empty());
}
#[test]
fn texm_decode_minimal_argb8888_no_page() {
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[0x40, 0x11, 0x22, 0x33]]);
let parsed = parse_texm(&payload).expect("failed to parse minimal texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode mip");
assert_eq!(decoded.width, 1);
assert_eq!(decoded.height, 1);
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 0x40]);
}
#[test]
fn texm_decode_rgb565() {
let word = 0xFFE0u16; // r=31 g=63 b=0
let payload = build_texm_payload(1, 1, 565, 0, None, &[&word.to_le_bytes()]);
let parsed = parse_texm(&payload).expect("failed to parse rgb565 texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb565 texm");
assert_eq!(decoded.rgba8, vec![255, 255, 0, 255]);
}
#[test]
fn texm_decode_rgb556() {
let word = 0xF800u16; // r=31 g=0 b=0
let payload = build_texm_payload(1, 1, 556, 0, None, &[&word.to_le_bytes()]);
let parsed = parse_texm(&payload).expect("failed to parse rgb556 texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb556 texm");
assert_eq!(decoded.rgba8, vec![255, 0, 0, 255]);
}
#[test]
fn texm_decode_argb4444() {
let word = 0xF12Eu16; // a=F r=1 g=2 b=E
let payload = build_texm_payload(1, 1, 4444, 0, None, &[&word.to_le_bytes()]);
let parsed = parse_texm(&payload).expect("failed to parse argb4444 texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode argb4444 texm");
assert_eq!(decoded.rgba8, vec![17, 34, 238, 255]);
}
#[test]
fn texm_decode_luminance_alpha88() {
let word = 0x7F40u16; // luminance=0x7F alpha=0x40
let payload = build_texm_payload(1, 1, 88, 0, None, &[&word.to_le_bytes()]);
let parsed = parse_texm(&payload).expect("failed to parse la88 texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode la88 texm");
assert_eq!(decoded.rgba8, vec![0x7F, 0x7F, 0x7F, 0x40]);
}
#[test]
fn texm_decode_rgb888x() {
let payload = build_texm_payload(1, 1, 888, 0, None, &[&[0x11, 0x22, 0x33, 0x99]]);
let parsed = parse_texm(&payload).expect("failed to parse rgb888 texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode rgb888 texm");
assert_eq!(decoded.rgba8, vec![0x11, 0x22, 0x33, 255]);
}
#[test]
fn texm_parse_indexed_with_page_chunk() {
let mut palette = [0u8; 1024];
palette[4..8].copy_from_slice(&[10, 20, 30, 255]);
let mut payload = build_texm_payload(2, 2, 0, 0, Some(palette), &[&[1, 1, 1, 1]]);
payload.extend_from_slice(&PAGE_MAGIC.to_le_bytes());
payload.extend_from_slice(&1u32.to_le_bytes()); // rect_count
payload.extend_from_slice(&0i16.to_le_bytes()); // x
payload.extend_from_slice(&2i16.to_le_bytes()); // w
payload.extend_from_slice(&0i16.to_le_bytes()); // y
payload.extend_from_slice(&2i16.to_le_bytes()); // h
let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
assert!(parsed.palette.is_some());
assert_eq!(parsed.page_rects.len(), 1);
assert_eq!(
parsed.page_rects[0],
PageRect {
x: 0,
w: 2,
y: 0,
h: 2
}
);
}
#[test]
fn texm_decode_indexed_with_palette_last_entry() {
let mut palette = [0u8; 1024];
palette[4..8].copy_from_slice(&[10, 20, 30, 255]); // index 1
palette[8..12].copy_from_slice(&[40, 50, 60, 200]); // index 2
palette[1020..1024].copy_from_slice(&[1, 2, 3, 4]); // index 255 (last)
let payload = build_texm_payload(3, 1, 0, 0, Some(palette), &[&[1u8, 2u8, 255u8]]);
let parsed = parse_texm(&payload).expect("failed to parse indexed texm");
let decoded = decode_mip_rgba8(&parsed, &payload, 0).expect("failed to decode indexed texm");
assert_eq!(decoded.width, 3);
assert_eq!(decoded.height, 1);
assert_eq!(
decoded.rgba8,
vec![10, 20, 30, 255, 40, 50, 60, 200, 1, 2, 3, 4]
);
}
#[test]
fn texm_parse_multi_mip_offsets() {
let mip0 = [0x10u8; 32]; // 4*2*4
let mip1 = [0x20u8; 8]; // 2*1*4
let mip2 = [0x30u8; 4]; // 1*1*4
let payload = build_texm_payload(4, 2, 8888, 0, None, &[&mip0, &mip1, &mip2]);
let parsed = parse_texm(&payload).expect("failed to parse multi-mip texm");
assert_eq!(parsed.header.mip_count, 3);
assert_eq!(parsed.mip_levels.len(), 3);
assert_eq!(
parsed.mip_levels,
vec![
MipLevel {
width: 4,
height: 2,
offset: 32,
size: 32
},
MipLevel {
width: 2,
height: 1,
offset: 64,
size: 8
},
MipLevel {
width: 1,
height: 1,
offset: 72,
size: 4
},
]
);
}
#[test]
fn texm_preserves_flags5_for_mip_skip_metadata() {
let payload = build_texm_payload(1, 1, 8888, 0x0000_00A5, None, &[&[0, 0, 0, 0]]);
let parsed = parse_texm(&payload).expect("failed to parse texm");
assert_eq!(parsed.header.flags5, 0x0000_00A5);
}
#[test]
fn texm_errors_for_invalid_header_values() {
let mut bad_magic = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
bad_magic[0..4].copy_from_slice(&0u32.to_le_bytes());
assert!(matches!(
parse_texm(&bad_magic),
Err(Error::InvalidMagic { .. })
));
let zero_dims = build_texm_payload(0, 1, 8888, 0, None, &[&[]]);
assert!(matches!(
parse_texm(&zero_dims),
Err(Error::InvalidDimensions { .. })
));
let mut bad_mips = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
bad_mips[12..16].copy_from_slice(&0u32.to_le_bytes());
assert!(matches!(
parse_texm(&bad_mips),
Err(Error::InvalidMipCount { .. })
));
let bad_format = build_texm_payload(1, 1, 12345, 0, None, &[&[0, 0, 0, 0]]);
assert!(matches!(
parse_texm(&bad_format),
Err(Error::UnknownFormat { .. })
));
}
#[test]
fn texm_errors_for_page_chunk_and_mip_bounds() {
let mut bad_page = build_texm_payload(1, 1, 8888, 0, None, &[&[0, 0, 0, 0]]);
bad_page.extend_from_slice(b"X");
assert!(matches!(
parse_texm(&bad_page),
Err(Error::InvalidPageSize { .. })
));
let payload = build_texm_payload(1, 1, 8888, 0, None, &[&[1, 2, 3, 4]]);
let parsed = parse_texm(&payload).expect("failed to parse valid texm");
assert!(matches!(
decode_mip_rgba8(&parsed, &payload, 7),
Err(Error::MipIndexOutOfRange { .. })
));
let truncated = &payload[..payload.len() - 1];
assert!(matches!(
decode_mip_rgba8(&parsed, truncated, 0),
Err(Error::MipDataOutOfBounds { .. })
));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(64))]
#[test]
fn parse_texm_is_panic_free_on_random_bytes(payload in proptest::collection::vec(any::<u8>(), 0..4096)) {
if let Ok(texture) = parse_texm(&payload) {
for mip_index in 0..texture.mip_levels.len() {
let _ = decode_mip_rgba8(&texture, &payload, mip_index);
}
}
}
}

10
crates/tma/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "tma"
version = "0.1.0"
edition = "2021"
[dependencies]
encoding_rs = "0.8"
[dev-dependencies]
common = { path = "../common" }

485
crates/tma/src/lib.rs Normal file
View File

@@ -0,0 +1,485 @@
use encoding_rs::WINDOWS_1251;
use std::fmt;
use std::fs;
use std::path::Path;
const OBJECT_RECORD_FLAGS: u32 = 0x8000_0002;
const FOOTER_MAGIC: &[u8; 4] = b"MtPr";
const MAP_PATH_TOKEN: &[u8; 10] = b"DATA\\MAPS\\";
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
FooterNotFound,
FooterCorrupt(&'static str),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "{err}"),
Self::FooterNotFound => write!(f, "footer magic 'MtPr' not found"),
Self::FooterCorrupt(reason) => write!(f, "corrupt mission footer: {reason}"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
#[derive(Clone, Debug)]
pub struct MissionFile {
pub footer: MissionFooter,
pub objects: Vec<MissionObject>,
}
#[derive(Clone, Debug)]
pub struct MissionFooter {
pub map_path: String,
pub title: String,
pub version: u32,
}
#[derive(Clone, Debug)]
pub struct MissionObject {
pub offset: usize,
pub group_id: u32,
pub flags: u32,
pub resource_name: String,
pub logical_id: i32,
pub clan_id: i32,
pub position: [f32; 3],
pub orientation: [f32; 3],
pub scale: [f32; 3],
pub alias: String,
}
pub fn parse_path(path: impl AsRef<Path>) -> Result<MissionFile> {
let bytes = fs::read(path.as_ref())?;
parse_bytes(&bytes)
}
pub fn parse_bytes(bytes: &[u8]) -> Result<MissionFile> {
let footer = parse_footer(bytes)?;
let objects = parse_objects(bytes);
Ok(MissionFile { footer, objects })
}
fn parse_footer(bytes: &[u8]) -> Result<MissionFooter> {
let map_positions = find_all_map_path_positions(bytes);
if map_positions.is_empty() {
return Err(Error::FooterNotFound);
}
for map_start in map_positions.into_iter().rev() {
if map_start < 4 {
continue;
}
let map_end = scan_path_end(bytes, map_start);
if map_end <= map_start {
continue;
}
let map_len = map_end - map_start;
let Some(declared_map_len) = read_u32(bytes, map_start - 4).map(|v| v as usize) else {
continue;
};
if declared_map_len != map_len {
continue;
}
let Some(zero_pad) = read_u32(bytes, map_end) else {
continue;
};
if zero_pad != 0 {
continue;
}
let title_len_off = map_end + 4;
let Some(title_len) = read_u32(bytes, title_len_off).map(|v| v as usize) else {
continue;
};
if title_len == 0 || title_len > 256 {
continue;
}
let title_start = title_len_off + 4;
let Some(title_end) = title_start.checked_add(title_len) else {
continue;
};
if title_end > bytes.len() {
continue;
}
let map_path = decode_cp1251(&bytes[map_start..map_end]);
if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
continue;
}
let title = decode_title(&bytes[title_start..title_end]);
let version = parse_footer_version(bytes, title_end)?;
return Ok(MissionFooter {
map_path,
title,
version,
});
}
// Fallback for multiplayer/legacy variants where the footer tail differs,
// but map path is still present in clear text near EOF.
let Some(map_start) = bytes
.windows(MAP_PATH_TOKEN.len())
.rposition(|window| window == MAP_PATH_TOKEN)
else {
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
};
let map_end = scan_path_end(bytes, map_start);
if map_end <= map_start {
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
}
let map_path = decode_cp1251(&bytes[map_start..map_end]);
if !map_path.to_ascii_uppercase().contains("DATA\\MAPS\\") {
return Err(Error::FooterCorrupt("failed to decode map/title envelope"));
}
let mut title = String::new();
if let Some(title_len) = read_u32(bytes, map_end + 8).map(|v| v as usize) {
let title_start = map_end + 12;
let title_end = title_start.saturating_add(title_len);
if title_len > 0 && title_len <= 256 && title_end <= bytes.len() {
let raw = &bytes[title_start..title_end];
if raw.iter().all(|b| b.is_ascii_graphic() || *b == b' ') {
title = decode_title(raw);
}
}
}
let version = if let Some(magic_off) = bytes
.windows(FOOTER_MAGIC.len())
.rposition(|window| window == FOOTER_MAGIC)
{
read_u32(bytes, magic_off + 4).unwrap_or(1)
} else {
read_u32(bytes, map_end).unwrap_or(1)
};
Ok(MissionFooter {
map_path,
title,
version,
})
}
fn parse_footer_version(bytes: &[u8], after_title_off: usize) -> Result<u32> {
if after_title_off + 8 <= bytes.len()
&& &bytes[after_title_off..after_title_off + 4] == FOOTER_MAGIC
{
let version = read_u32(bytes, after_title_off + 4)
.ok_or(Error::FooterCorrupt("missing version after MtPr"))?;
return Ok(version);
}
let version = read_u32(bytes, after_title_off)
.ok_or(Error::FooterCorrupt("missing version after title"))?;
Ok(version)
}
fn find_all_map_path_positions(bytes: &[u8]) -> Vec<usize> {
bytes
.windows(MAP_PATH_TOKEN.len())
.enumerate()
.filter_map(|(idx, window)| (window == MAP_PATH_TOKEN).then_some(idx))
.collect()
}
fn scan_path_end(bytes: &[u8], start: usize) -> usize {
let mut off = start;
while off < bytes.len() && is_path_byte(bytes[off]) {
off += 1;
}
off
}
fn is_path_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'/' | b'\\' | b'-' | b' ' | b':')
}
fn parse_objects(bytes: &[u8]) -> Vec<MissionObject> {
let mut objects = Vec::new();
let min_record_tail = 48usize;
for offset in 0..bytes.len().saturating_sub(16) {
let Some(flags) = read_u32(bytes, offset + 4) else {
continue;
};
if flags != OBJECT_RECORD_FLAGS {
continue;
}
let Some(name_len) = read_u32(bytes, offset + 8).map(|v| v as usize) else {
continue;
};
if !(3..=260).contains(&name_len) {
continue;
}
let name_start = offset + 12;
let Some(name_end) = name_start.checked_add(name_len) else {
continue;
};
if name_end + min_record_tail > bytes.len() {
continue;
}
let name_raw = &bytes[name_start..name_end];
if !is_object_name_bytes(name_raw) {
continue;
}
let resource_name = decode_cp1251(name_raw);
if !looks_like_object_name(&resource_name) {
continue;
}
let Some(group_id) = read_u32(bytes, offset) else {
continue;
};
let Some(logical_id) = read_i32(bytes, name_end) else {
continue;
};
let Some(clan_id) = read_i32(bytes, name_end + 4) else {
continue;
};
let Some(position) = read_vec3(bytes, name_end + 8) else {
continue;
};
let Some(orientation) = read_vec3(bytes, name_end + 20) else {
continue;
};
let Some(scale) = read_vec3(bytes, name_end + 32) else {
continue;
};
if !all_finite(&position) || !all_finite(&orientation) || !all_finite(&scale) {
continue;
}
let alias = parse_alias(bytes, name_end + 44);
objects.push(MissionObject {
offset,
group_id,
flags,
resource_name,
logical_id,
clan_id,
position,
orientation,
scale,
alias,
});
}
objects.sort_by_key(|obj| obj.offset);
objects.dedup_by_key(|obj| obj.offset);
objects
}
fn parse_alias(bytes: &[u8], alias_len_off: usize) -> String {
let Some(alias_len) = read_u32(bytes, alias_len_off).map(|v| v as usize) else {
return String::new();
};
if alias_len == 0 || alias_len > 96 {
return String::new();
}
let alias_start = alias_len_off + 4;
let Some(alias_end) = alias_start.checked_add(alias_len) else {
return String::new();
};
if alias_end > bytes.len() {
return String::new();
}
let alias_raw = &bytes[alias_start..alias_end];
if !alias_raw
.iter()
.all(|&b| b == b'_' || b == b'-' || b == b'.' || b.is_ascii_alphanumeric())
{
return String::new();
}
decode_cp1251(alias_raw)
}
fn looks_like_object_name(name: &str) -> bool {
if name.ends_with(".dat") {
return true;
}
name.contains('_')
}
fn is_object_name_bytes(bytes: &[u8]) -> bool {
bytes
.iter()
.all(|b| b.is_ascii_alphanumeric() || matches!(*b, b'_' | b'.' | b'/' | b'\\' | b'-'))
}
fn all_finite(v: &[f32; 3]) -> bool {
v.iter().all(|c| c.is_finite())
}
fn decode_cp1251(bytes: &[u8]) -> String {
let (decoded, _, _) = WINDOWS_1251.decode(bytes);
decoded.into_owned()
}
fn decode_title(bytes: &[u8]) -> String {
let end = bytes
.iter()
.rposition(|b| *b != 0 && *b != 0xCD)
.map(|idx| idx + 1)
.unwrap_or(0);
decode_cp1251(&bytes[..end]).trim().to_string()
}
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
let end = offset.checked_add(4)?;
let chunk = bytes.get(offset..end)?;
Some(u32::from_le_bytes(chunk.try_into().ok()?))
}
fn read_i32(bytes: &[u8], offset: usize) -> Option<i32> {
read_u32(bytes, offset).map(|v| v as i32)
}
fn read_f32(bytes: &[u8], offset: usize) -> Option<f32> {
let end = offset.checked_add(4)?;
let chunk = bytes.get(offset..end)?;
Some(f32::from_le_bytes(chunk.try_into().ok()?))
}
fn read_vec3(bytes: &[u8], offset: usize) -> Option<[f32; 3]> {
Some([
read_f32(bytes, offset)?,
read_f32(bytes, offset + 4)?,
read_f32(bytes, offset + 8)?,
])
}
#[cfg(test)]
mod tests {
use super::*;
use common::collect_files_recursive;
use std::path::{Path, PathBuf};
fn game_root() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
root.is_dir().then_some(root)
}
#[test]
fn parses_known_mission_footer_and_objects() {
let Some(root) = game_root() else {
eprintln!("skipping: game root is missing");
return;
};
let path = root
.join("MISSIONS")
.join("CAMPAIGN")
.join("CAMPAIGN.00")
.join("Mission.01")
.join("data.tma");
if !path.is_file() {
eprintln!("skipping: sample mission is missing ({})", path.display());
return;
}
let mission = parse_path(&path).expect("parse mission failed");
assert_eq!(mission.footer.version, 1);
assert!(
mission
.footer
.map_path
.eq_ignore_ascii_case("DATA\\MAPS\\Tut_1\\land"),
"unexpected map path: {}",
mission.footer.map_path
);
assert!(mission.objects.len() >= 20);
assert!(mission
.objects
.iter()
.any(|obj| obj.resource_name.eq_ignore_ascii_case("s_tree_04")));
assert!(mission.objects.iter().any(|obj| {
obj.resource_name
.eq_ignore_ascii_case("UNITS\\UNITS\\HERO\\tut1_p.dat")
}));
}
#[test]
fn parses_all_retail_missions() {
let Some(root) = game_root() else {
eprintln!("skipping: game root is missing");
return;
};
let mission_root = root.join("MISSIONS");
let mut files = Vec::new();
collect_files_recursive(&mission_root, &mut files);
files.sort();
let mut mission_count = 0usize;
for path in files {
if !path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.eq_ignore_ascii_case("data.tma"))
{
continue;
}
mission_count += 1;
let mission = parse_path(&path)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
assert!(
mission
.footer
.map_path
.to_ascii_uppercase()
.contains("DATA\\MAPS\\"),
"{}: invalid map path '{}'",
path.display(),
mission.footer.map_path
);
assert!(
!mission.objects.is_empty(),
"{}: mission has no parsed object records",
path.display()
);
assert!(
mission
.objects
.iter()
.all(|obj| obj.position.iter().all(|v| v.is_finite())),
"{}: mission has non-finite position",
path.display()
);
}
assert!(mission_count > 0, "no data.tma files found");
}
}

10
crates/unitdat/Cargo.toml Normal file
View File

@@ -0,0 +1,10 @@
[package]
name = "unitdat"
version = "0.1.0"
edition = "2021"
[dependencies]
encoding_rs = "0.8"
[dev-dependencies]
common = { path = "../common" }

180
crates/unitdat/src/lib.rs Normal file
View File

@@ -0,0 +1,180 @@
use encoding_rs::WINDOWS_1251;
use std::fmt;
use std::fs;
use std::path::Path;
const MIN_SIZE: usize = 0x48;
const MAGIC: u32 = 0x0000_F0F1;
pub type Result<T> = core::result::Result<T, Error>;
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
TooSmall { got: usize },
InvalidMagic { got: u32 },
MissingArchiveName,
MissingModelKey,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(err) => write!(f, "{err}"),
Self::TooSmall { got } => write!(f, "unit .dat is too small: {got} bytes"),
Self::InvalidMagic { got } => write!(f, "invalid .dat magic: 0x{got:08X}"),
Self::MissingArchiveName => write!(f, "unit .dat has empty archive name"),
Self::MissingModelKey => write!(f, "unit .dat has empty model key"),
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(err) => Some(err),
_ => None,
}
}
}
impl From<std::io::Error> for Error {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}
#[derive(Clone, Debug)]
pub struct UnitDat {
pub magic: u32,
pub flags: u32,
pub archive_name: String,
pub model_key: String,
}
pub fn parse_path(path: impl AsRef<Path>) -> Result<UnitDat> {
let bytes = fs::read(path.as_ref())?;
parse_bytes(&bytes)
}
pub fn parse_bytes(bytes: &[u8]) -> Result<UnitDat> {
if bytes.len() < MIN_SIZE {
return Err(Error::TooSmall { got: bytes.len() });
}
let magic = read_u32(bytes, 0).ok_or(Error::TooSmall { got: bytes.len() })?;
if magic != MAGIC {
return Err(Error::InvalidMagic { got: magic });
}
let flags = read_u32(bytes, 4).ok_or(Error::TooSmall { got: bytes.len() })?;
let archive_name = decode_c_string_fixed(&bytes[0x08..0x28]);
if archive_name.is_empty() {
return Err(Error::MissingArchiveName);
}
let model_key = decode_c_string_fixed(&bytes[0x28..0x48]);
if model_key.is_empty() {
return Err(Error::MissingModelKey);
}
Ok(UnitDat {
magic,
flags,
archive_name,
model_key,
})
}
fn read_u32(bytes: &[u8], offset: usize) -> Option<u32> {
let end = offset.checked_add(4)?;
let chunk = bytes.get(offset..end)?;
Some(u32::from_le_bytes(chunk.try_into().ok()?))
}
fn decode_c_string_fixed(bytes: &[u8]) -> String {
let used = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
let (decoded, _, _) = WINDOWS_1251.decode(&bytes[..used]);
decoded.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use common::collect_files_recursive;
use std::path::{Path, PathBuf};
fn game_root() -> Option<PathBuf> {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("Parkan - Iron Strategy");
root.is_dir().then_some(root)
}
#[test]
fn parses_known_dat_files() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let samples = [
root.join("UNITS/UNITS/HERO/tut1_p.dat"),
root.join("UNITS/UNITS/BATTLE/l_targ.dat"),
root.join("UNITS/BUILDS/BRIDGE/m_bridge.dat"),
];
for path in samples {
if !path.is_file() {
eprintln!("skipping missing sample {}", path.display());
continue;
}
let dat = parse_path(&path)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
assert_eq!(dat.magic, MAGIC);
assert!(dat.archive_name.to_ascii_lowercase().ends_with(".rlb"));
assert!(dat.model_key.contains('_'));
}
}
#[test]
fn parses_retail_dat_corpus() {
let Some(root) = game_root() else {
eprintln!("skipping: game root missing");
return;
};
let units_root = root.join("UNITS");
let mut files = Vec::new();
collect_files_recursive(&units_root, &mut files);
files.sort();
let mut parsed = 0usize;
for path in files {
if !path
.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("dat"))
{
continue;
}
let dat = parse_path(&path)
.unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()));
assert!(
!dat.archive_name.is_empty(),
"{} empty archive",
path.display()
);
assert!(
!dat.model_key.is_empty(),
"{} empty model key",
path.display()
);
parsed += 1;
}
assert!(parsed > 0, "no .dat files parsed");
}
}

17
docs/index.md Normal file
View File

@@ -0,0 +1,17 @@
# Welcome to MkDocs
For full documentation visit [mkdocs.org](https://www.mkdocs.org).
## 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
mkdocs.yml # The configuration file.
docs/
index.md # The documentation homepage.
... # Other markdown pages, images and other files.

35
docs/specs/ai.md Normal file
View File

@@ -0,0 +1,35 @@
# AI system
Страница фиксирует границы подсистемы AI на уровне движка:
- выбор целей;
- тактические приоритеты;
- координация с `Behavior`, `ArealMap`, `Missions`.
## 1. Текущая зафиксированная часть
1. AI работает поверх ареалов/клеток карты, а не напрямую поверх render-геометрии.
2. Результат AI передается в behavior/command-слой как набор целевых состояний и команд.
3. Решения AI зависят от миссионных триггеров и состояния объектов мира.
## 2. Контракт интеграции
В 1:1 реализации AI должен быть совместим с:
1. системой ареалов (`Land.map`);
2. объектными категориями (`BuildDat.lst`);
3. поведением юнитов (`behavior.md`);
4. миссионными условиями (`missions.md`).
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- роль AI в общей архитектуре и точки интеграции с соседними подсистемами.
Осталось:
1. Полный формат runtime-AI состояний и таблиц решений.
2. Полные правила выбора цели/маршрута/приоритета огня.
3. Полная спецификация влияния миссионных скриптов на AI.
4. Набор тест-кейсов «AI tick parity» для побайтного/пошагового сравнения с оригиналом.

31
docs/specs/arealmap.md Normal file
View File

@@ -0,0 +1,31 @@
# ArealMap
`ArealMap` — подсистема топологии мира и логических зон.
Подробный бинарный формат `Land.map` и связь с terrain описаны в:
- [Terrain + ArealMap](terrain-map-loading.md)
## 1. Роль в движке
1. Хранит ареалы, связи между ареалами и клеточный индекс.
2. Используется для навигации, логики объектов и AI-решений.
3. Связывает геометрию карты с миссионной и поведенческой логикой.
## 2. Минимальный runtime-контракт
1. Валидный граф ареалов и edge-link связей.
2. Валидная cell-grid индексация (`cellsX/cellsY` + hit lists).
3. Согласованные идентификаторы ареалов для AI/Behavior/Missions.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- бинарный контракт `Land.map` и pair-загрузка с `Land.msh`.
Осталось:
1. Полная доменная семантика `class_id`/`logic_flag` по всем игровым сценариям.
2. Формальная спецификация API-запросов к ArealMap (поиск зон, фильтры, события).
3. Набор parity-тестов поведения навигационных запросов на одинаковых входах.

28
docs/specs/behavior.md Normal file
View File

@@ -0,0 +1,28 @@
# Behavior system
`Behavior` — слой исполнения состояний юнитов между AI-решением и низкоуровневым control-командованием.
## 1. Роль в кадре
1. Принимает решения из AI.
2. Переводит их в state machine юнита.
3. Формирует команды движения/атаки/действий в world/control-слой.
## 2. Внешние зависимости
1. `ArealMap` (доступность/топология).
2. `Missions` (триггеры и ограничения сценария).
3. `Control` (выполнение команд).
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- архитектурная роль подсистемы и ее место в runtime-пайплайне.
Осталось:
1. Полная спецификация finite-state машин по типам юнитов.
2. Полная таблица переходов, таймаутов и приоритетов.
3. Формализация входных/выходных структур поведения для 1:1 эмуляции.
4. Поведенческие parity-тесты на фиксированных replay-сценариях.

28
docs/specs/control.md Normal file
View File

@@ -0,0 +1,28 @@
# Control system
`Control` — подсистема входа и маршрутизации команд (пользовательских и системных).
## 1. Роль
1. Преобразует ввод устройств в команды движка.
2. Синхронизирует управление камерой, UI и объектами мира.
3. Передает команды в gameplay-подсистемы с учетом активного режима игры.
## 2. Минимальный контракт совместимости
1. Детерминированный mapping input -> command.
2. Стабильная обработка очереди команд в пределах кадра.
3. Корректный приоритет UI-фокуса над world-input.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- место control-слоя в архитектуре и базовый runtime-контур.
Осталось:
1. Полная карта input actions и режимов обработки.
2. Формат внутренних очередей команд и их сериализация.
3. Спецификация edge-case поведения (повтор клавиш, захват мыши, hotkey-конфликты).
4. Пошаговые parity-тесты на записанных последовательностях ввода.

View File

@@ -0,0 +1,51 @@
# Documentation coverage audit
Дата аудита: `2026-02-19`
Корпус данных: `testdata/Parkan - Iron Strategy`
## 1. Проверка форматов архивов
Результаты:
- `NRes`: `120` архивов, roundtrip `120/120` (byte-identical)
- `RsLi`: `2` архива, roundtrip `2/2` (byte-identical)
- подтвержден один совместимый quirk: `sprites.lib`, entry `23`, `deflate EOF+1`
Инструмент:
- `tools/archive_roundtrip_validator.py`
## 2. Проверка рендерных форматов
Результаты:
- `MSH`: `435/435` валидны
- `Texm`: `518/518` валидны
- `FXID`: `923/923` валидны
- `Terrain/Map` (`Land.msh` + `Land.map`): `33/33` без ошибок/предупреждений
Инструменты:
- `tools/msh_doc_validator.py`
- `tools/fxid_abs100_audit.py`
- `tools/terrain_map_doc_validator.py`
## 3. Глобальный статус по подсистемам
| Подсистема | Статус | Что блокирует 100% |
|---|---|---|
| Архивы (`NRes`, `RsLi`) | практически закрыта | формализация редких не-ASCII/служебных edge-case |
| 3D geometry (`MSH core`) | высокая готовность | семантика opaque-полей и канонический writer «с нуля» |
| Animation (`Res8/Res19`) | высокая готовность | полный FP-parity на всех edge-case |
| Material/Wear/Texture | высокая готовность | полная field-level семантика служебных флагов и writer-профиль |
| FXID | высокая готовность | полная field-level семантика payload по каждому opcode |
| Terrain/Areal map formats | высокая готовность | доменная семантика `class_id/logic_flag`, ветка `poly_count>0` |
| Render pipeline | хорошая | полный pixel-parity набор эталонных кадров в CI |
| AI/Behavior/Control/Missions/UI/Sound/Network | начальное покрытие | требуется полная спецификация форматов и runtime-контрактов |
## 4. План доведения до 100%
1. Закрыть field-level семантику opaque/служебных полей в 3D/FX/terrain подсистемах.
2. Завершить canonical writer paths для авторинга новых ассетов без copy-through.
3. Зафиксировать и автоматизировать pixel/frame parity-критерии в CI.
4. Расширить подсистемные спецификации (`AI`, `Behavior`, `Missions`, `Control`, `UI`, `Sound`, `Network`) до уровня «полный формат + полный runtime-контракт + parity-тесты».

202
docs/specs/fxid.md Normal file
View File

@@ -0,0 +1,202 @@
# FXID
`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy.
Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов.
Связанные контейнеры: [NRes](nres.md), [RsLi](rsli.md).
## 1. Контейнер
- Тип ресурса в `NRes`: `0x44495846` (`FXID`).
- Значения `attr1/attr2/attr3` в типовых игровых данных стабильны, но при редактуре их нужно сохранять как есть.
## 2. Бинарный формат
Все значения little-endian.
### 2.1. Заголовок (60 байт)
```c
struct FxHeader60 {
uint32_t cmd_count; // 0x00
uint32_t time_mode; // 0x04
float duration_sec; // 0x08
float phase_jitter; // 0x0C
uint32_t flags; // 0x10
uint32_t settings_id; // 0x14
float rand_shift_x; // 0x18
float rand_shift_y; // 0x1C
float rand_shift_z; // 0x20
float pivot_x; // 0x24
float pivot_y; // 0x28
float pivot_z; // 0x2C
float scale_x; // 0x30
float scale_y; // 0x34
float scale_z; // 0x38
};
```
Поток команд начинается строго с `offset = 0x3C`.
### 2.2. Команда
Каждая команда:
1. `uint32 cmd_word`
2. body фиксированного размера, зависящего от `opcode`
Поля `cmd_word`:
- `opcode = cmd_word & 0xFF`
- `enabled = (cmd_word >> 8) & 1`
- `bits 9..31` нужно сохранять 1:1
Выравнивания между командами нет.
### 2.3. Размеры команд
| Opcode | Размер |
|---:|---:|
| 1 | 224 |
| 2 | 148 |
| 3 | 200 |
| 4 | 204 |
| 5 | 112 |
| 6 | 4 |
| 7 | 208 |
| 8 | 248 |
| 9 | 208 |
| 10 | 208 |
## 3. Смысл заголовка
- `cmd_count`: число команд в потоке.
- `time_mode`: способ вычисления текущего коэффициента эффекта.
- `duration_sec`: длительность (в рантайме переводится в миллисекунды).
- `phase_jitter`: амплитуда случайного фазового сдвига.
- `flags`: флаги поведения (видимость, альфа-модификаторы, режимы гейтинга).
- `settings_id`: индекс профиля/настроек эффекта.
- `rand_shift_*`: случайный пространственный сдвиг.
- `pivot_*`: локальная опора.
- `scale_*`: базовый масштаб инстанса эффекта.
## 4. Флаги заголовка
Практически важные биты:
- `0x0001`: случайный сдвиг фазы
- `0x0008`: случайный пространственный сдвиг (`rand_shift_*`)
- `0x0010`: ветки видимости/окклюзии
- `0x0020`: треугольный ремап альфы
- `0x0040`: инверсия исходного active-state
- `0x0080`, `0x0100`: фильтрация по времени суток
- `0x0200`: умножение альфы на нормализованное время жизни
- `0x0400`, `0x1000`: дополнительные биты состояния менеджера эффекта
- `0x0800`: дополнительный гейтинг
Неизвестные биты должны сохраняться без изменений.
## 5. `time_mode` (0..17)
База:
- `tn = (now - start) / (end - start)`
- `prev = предыдущая вычисленная альфа`
Поддерживаемые семейства режимов:
- константный режим;
- линейный (`tn`), обратный (`1-tn`), циклический (`fract(tn)`);
- режимы от внешних параметров мира/очереди;
- режимы на основе норм векторов состояния;
- режимы с ограничением вниз/вверх относительно `prev`.
После вычисления:
- при `flags & 0x0200` применяется `alpha *= tn`;
- при `flags & 0x0020` применяется triangular remap.
## 6. Resource-ссылки внутри команд
Для opcode `2/3/4/5/7/8/9/10` используется ссылка:
```c
struct ResourceRef64 {
char archive[32];
char name[32];
};
```
Контракт:
- строки ASCII, нуль-терминированные;
- сравнение имён регистронезависимое;
- обычно:
- `opcode 2`: `sounds.lib` + `*.wav`
- остальные: `material.lib` + имя материала/эффекта.
## 7. Runtime-контракт исполнения
На создании инстанса:
1. Заголовок копируется в runtime-состояние.
2. Вычисляется `end_time`.
3. Для каждой команды создаётся runtime-объект по `opcode`.
4. В объект копируется `enabled`.
5. Объект инициализируется контекстом эффекта.
На каждом кадре:
1. Вычисляется текущий коэффициент/альфа по `time_mode` и `flags`.
2. Выполняется update каждой команды.
3. Выполняется emit/render часть активных команд.
4. Применяются события Start/Stop/Restart.
## 8. Строгий парсер (рекомендуемый)
1. Проверить `len(payload) >= 60`.
2. Прочитать `cmd_count`.
3. Идти от `ptr = 0x3C`.
4. Для каждой команды:
- проверить `ptr + 4 <= len`;
- прочитать `opcode`;
- проверить, что `opcode` поддержан;
- проверить `ptr + size(opcode) <= len`;
- сдвинуть `ptr += size(opcode)`.
5. Проверить `ptr == len(payload)`.
## 9. Writer и редактор
Для lossless-совместимости:
- сохранять все неизвестные поля/биты;
- не менять фиксированные размеры команд;
- не добавлять padding;
- пересчитывать только `cmd_count` и размеры контейнера;
- сохранять порядок команд.
## 10. Что требуется для 1:1 переноса
1. Полная поддержка opcode `1..10`.
2. Точный контракт вычисления `time_mode` и `flags`.
3. Точное поведение `ResourceRef64`.
4. Повторяемый RNG и одинаковая политика плавающей точки.
## 11. Статус валидации
- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `923/923` FXID payload без ошибок.
## 12. Статус покрытия и что осталось до 100%
Закрыто:
1. Контейнер FXID, fixed-size командный поток, opcode-покрытие `1..10`.
2. Базовый runtime-контур исполнения эффекта.
3. Корпусная валидация формата на retail-данных.
Осталось:
1. Полная field-level семантика payload каждого opcode для авторинга новых эффектов «с нуля».
2. Формальная спецификация всех `time_mode` веток на уровне точных числовых формул и edge-case поведения.
3. Полный набор пиксельных parity-тестов FX (оригинал vs новый рендер) на фиксированных сценах.

144
docs/specs/material.md Normal file
View File

@@ -0,0 +1,144 @@
# Material (`MAT0`)
`MAT0` описывает материал и его фазовую анимацию.
Связанные страницы:
- [Wear table (`WEAR`)](wear.md)
- [Texture (`Texm`)](texture.md)
- [Render pipeline](render.md)
## 1. Контейнер
- Тип ресурса: `0x3054414D` (`MAT0`).
- Обычно хранится в `Material.lib`.
- `attr1` используется как битовое поле runtime-флагов материала.
- `attr2` задаёт версию заголовка payload.
## 2. Бинарный layout
```c
struct Mat0Payload {
uint16_t phaseCount;
uint16_t animBlockCount; // должно быть < 20
// если attr2 >= 2
uint8_t metaA8;
uint8_t metaB8;
// если attr2 >= 3
uint32_t metaC32;
// если attr2 >= 4
uint32_t metaD32;
PhaseRecord34 phases[phaseCount];
AnimBlockRaw anim[animBlockCount];
};
```
Если `attr2 < 2`, используются runtime-значения по умолчанию:
- `metaA = 255`
- `metaB = 255`
- `metaC = 1.0f`
- `metaD = 0`
## 3. Фазы материала
```c
struct PhaseRecord34 {
uint8_t params[18];
char textureName[16];
};
```
В рантайме запись разворачивается в структуру ~76 байт:
- набор коэффициентов цвета/освещения/прозрачности;
- индекс слота текстуры;
- дополнительные целочисленные поля.
`textureName`:
- пустая строка -> фаза без текстуры (`texSlot = -1`);
- непустая строка -> загрузка текстуры по имени.
## 4. Анимационные блоки
```c
struct AnimBlockRaw {
uint32_t headerRaw; // mode = low 3 bits, interpMask = остальные
uint16_t keyCount;
KeyRaw keys[keyCount];
};
struct KeyRaw {
uint16_t k0;
uint16_t k1;
uint16_t k2; // opaque, сохранять 1:1
};
```
`k2` нельзя удалять или нормализовать: это часть бинарного контракта.
## 5. Выбор текущей фазы
Материал выбирает фазу по времени и по режиму анимации блока:
- loop;
- ping-pong;
- one-shot с clamp;
- random-offset.
При смешивании интерполируется только часть полей, остальные копируются из активной фазы.
Для 1:1 совместимости важно сохранить эту выборочную интерполяцию.
## 6. Загрузка и fallback
При запросе материала по имени:
1. Точный поиск по имени.
2. Если не найдено — fallback на `DEFAULT`.
3. Если `DEFAULT` отсутствует — используется запись с индексом `0`.
## 7. Атрибуты и флаги
Практически важные биты `attr1`:
- бит загрузки текстурной фазы с расширенными флагами;
- флаги аппаратного профиля;
- 4-битный режим (`nibbleMode`);
- дополнительный флаг material-поведения.
Неизвестные биты должны сохраняться без изменений.
## 8. Ограничения
- `animBlockCount < 20`
- `phaseCount` и фактический размер секции фаз должны совпадать
- `textureName` должен быть NUL-terminated и укладываться в 16 байт
## 9. Правила writer/editor
1. Сохранять `attr1/attr2/attr3`.
2. Не менять `metaA/B/C/D` без явного запроса.
3. Сохранять opaque-поля анимации (включая `k2`) 1:1.
4. Проверять выход за границы payload при парсинге.
## 10. Статус валидации
- Инварианты MAT0 зафиксированы в текущем toolchain проекта (`docs/specs` + `tools`).
- Структурная валидация MAT0 включена в корпусный прогон `tools/msh_doc_validator.py` на полном retail-наборе.
## 11. Статус покрытия и что осталось до 100%
Закрыто:
1. Бинарный layout `MAT0` и правила чтения фаз/анимационных блоков.
2. Fallback-цепочка материала.
3. Контракт сохранения opaque-полей для lossless editor path.
Осталось:
1. Полная семантика всех битов `attr1` и `metaA/B/C/D` для авторинга новых материалов.
2. Полный writer-профиль «канонический MAT0» для генерации ассетов без copy-through.
3. Набор визуальных parity-тестов по material phase animation на реальных моделях.

View File

@@ -0,0 +1,18 @@
# Materials, WEAR, Texm
Старая объединённая страница разбита по объектам.
- [Material (`MAT0`)](material.md)
- [Wear table (`WEAR`)](wear.md)
- [Texture (`Texm`)](texture.md)
- [Render pipeline](render.md)
## Статус покрытия и что осталось до 100%
Закрыто:
1. Страница корректно декомпозирована на отдельные объектные спецификации.
Осталось:
1. Поддерживать единый changelog согласованности между `material.md`, `wear.md`, `texture.md` и `render.md`.

46
docs/specs/missions.md Normal file
View File

@@ -0,0 +1,46 @@
# Missions
Подсистема `Missions` управляет сценарием:
- стартовыми условиями;
- триггерами;
- победой/поражением;
- синхронизацией с AI/Behavior/World.
## 1. Что уже зафиксировано
1. Миссии связаны с картами (`Land.msh`/`Land.map`) и объектными категориями.
2. Скриптовые ресурсы хранятся в архивных контейнерах (`NRes`) и участвуют в runtime-логике.
3. Миссионные события влияют на AI и поведение объектов через общий gameplay-слой.
## 2. Минимальный runtime-контракт
1. Детерминированный порядок обработки триггеров в кадре.
2. Единая шкала времени миссии для всех подсистем.
3. Согласованность идентификаторов объектов между mission-data и world-state.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- связь миссионной подсистемы с форматом ресурсов и runtime-контуром.
Осталось:
1. Полная спецификация форматов миссионных скриптов/таблиц.
2. Полный перечень типов триггеров и их параметров.
3. Формальные правила разрешения конфликтов триггеров в одном кадре.
4. Набор replay parity-тестов «миссия от старта до завершения».
## 4. Mission -> Prototype -> Mesh bridge
Для 3D-объектов миссии обязательна промежуточная стадия `objects.rlb`:
1. `data.tma` задаёт либо прямой ключ объекта, либо путь к `*.dat`.
2. `*.dat` даёт `model_key` (в retail-наборе через `objects.rlb`).
3. Ключ резолвится в запись прототипа внутри `objects.rlb`.
4. Из прототипа выбирается фактический `*.msh` и архив (например `bases.rlb`, `static.rlb`, `fortif.rlb`).
5. Только после этого запускается стандартная цепочка материалов и текстур.
Детальный формат и алгоритм вынесены в отдельную страницу:
- [Object registry (`objects.rlb`)](object-registry.md)

126
docs/specs/msh-animation.md Normal file
View File

@@ -0,0 +1,126 @@
# MSH animation
`MSH animation` описывает связку `Res8 + Res19` и runtime-правила сэмплирования/смешивания поз.
Связанные страницы:
- [MSH core](msh-core.md)
- [Render pipeline](render.md)
## 1. Ресурсы анимации
### 1.1. `Res8` (пул ключей)
```c
struct AnimKey24 {
float pos_x;
float pos_y;
float pos_z;
float time;
int16_t qx;
int16_t qy;
int16_t qz;
int16_t qw;
};
```
Декодирование quaternion-компонент: `q = s16 / 32767.0`.
### 1.2. `Res19` (карта кадров)
```c
uint16_t map_words[]; // size/2 элементов
```
`Res19.attr2` хранит глобальную длину таймлайна (число кадров).
### 1.3. Связь с `Res1`
Для каждого узла:
- `anim_map_start` (`hdr2`) — начало блока в `Res19` или `0xFFFF`.
- `fallback_key` (`hdr3`) — индекс fallback-ключа в `Res8`.
## 2. Сэмплирование узла
Вход: время `t`, текущий узел.
Выход: `quat(w,x,y,z)` и `pos(x,y,z)`.
### 2.1. Индекс кадра
Движок использует x87-совместимое округление для выражения `t - 0.5`.
Для 1:1 повторения нужно сохранить ту же политику плавающей точки.
### 2.2. Выбор key index
1. Если кадр вне диапазона `frame_count` -> `fallback_key`.
2. Если `anim_map_start == 0xFFFF` -> `fallback_key`.
3. Иначе берётся `map_words[anim_map_start + frame]`:
- если значение `>= fallback_key`, тоже используется `fallback_key`;
- иначе используется значение из map.
### 2.3. Интерполяция
Если выбран fallback, возвращается ровно этот ключ без интерполяции.
Иначе:
1. Берутся соседние ключи `k0` и `k1`.
2. Если `t` точно равен `k0.time` или `k1.time`, возвращается соответствующий ключ.
3. Иначе:
- `alpha = (t - k0.time) / (k1.time - k0.time)`
- `pos = lerp(k0.pos, k1.pos, alpha)`
- `quat = slerp_like(k0.quat, k1.quat, alpha)`
Кватернион в runtime хранится в порядке `[w, x, y, z]`.
## 3. Смешивание двух сэмплов
При blending между позами A и B:
1. Выбираются валидные стороны по `blend` и валидности времени.
2. Если активна одна сторона, берётся она.
3. Если активны обе:
- применяется shortest-path flip для `qB`;
- выполняется quaternion blend;
- позиция смешивается линейно.
Матрица строится из quaternion, а translation подставляется отдельным шагом.
## 4. Каноника writer
Рекомендуемые правила:
1. Ключи узлов писать подряд в `Res8` в порядке узлов.
2. `fallback_key` узла указывает на последний ключ его трека.
3. Для узлов с map выделять блок длины `frame_count` в `Res19`.
4. Для статических узлов: `anim_map_start = 0xFFFF`, один ключ с `time=0`.
5. `Res8.attr1 = key_count`, `Res8.attr3 = 4`.
6. `Res19.attr1 = map_word_count`, `Res19.attr2 = frame_count`, `Res19.attr3 = 2`.
## 5. Валидация перед сохранением
- `Res8.size % 24 == 0`
- `Res19.size % 2 == 0`
- каждый `fallback_key < key_count`
- для узла с map: `anim_map_start + frame_count <= map_word_count`
- внутри трека времена ключей строго возрастают
## 6. Статус валидации
- Форматные проверки включены в `tools/msh_doc_validator.py`.
- Корпусная валидация анимационных инвариантов включена в прогон `tools/msh_doc_validator.py` на полном retail-наборе.
## 7. Статус покрытия и что осталось до 100%
Закрыто:
1. Контракт `Res8 + Res19` и fallback-логика выбора ключа.
2. Базовая интерполяция поз и blending двух сэмплов.
3. Канонические инварианты writer path для существующих ассетов.
Осталось:
1. Полная фиксация численного поведения на всех FP-edge-case (включая платформенные различия округления).
2. Полный writer-профиль для авторинга новых анимаций без опоры на reference copy-through.
3. Набор runtime parity-тестов «frame-by-frame pose equivalence» на длинных анимациях.

193
docs/specs/msh-core.md Normal file
View File

@@ -0,0 +1,193 @@
# MSH core
`MSH core` описывает геометрию, слоты, батчи и базовые таблицы модели.
Документ покрывает контракт, необходимый для 1:1 воспроизведения рендера и коллизии.
Связанные страницы:
- [MSH animation](msh-animation.md)
- [Material](material.md)
- [Texture (Texm)](texture.md)
- [Render pipeline](render.md)
- [NRes](nres.md)
- [RsLi](rsli.md)
## 1. Общая модель
MSH-модель хранится как `NRes`-контейнер.
Связь таблиц строится по `type`, а не по порядку записей.
Базовый путь геометрии:
1. `Res1` выбирает slot по `(node, lod, group)`.
2. `Res2.slot` задаёт диапазоны треугольников и батчей.
3. `Res13` задаёт диапазон индексов и `baseVertex`.
4. `Res6` даёт `uint16` индексы.
5. `Res3/Res4/Res5` дают вершины, нормали и UV.
## 2. Карта core-ресурсов
| Type | Ресурс | Обязательность | Stride / layout |
|---:|---|---|---|
| 1 | Node table | обязательный | обычно 38 байт |
| 2 | Header + slots | обязательный | `0x8C + n*68` |
| 3 | Positions | обязательный | 12 |
| 4 | Packed normals | обычно обязательный | 4 |
| 5 | Packed UV0 | обычно обязательный | 4 |
| 6 | Index buffer | обязательный | 2 |
| 7 | Tri descriptors | для коллизии/пикинга | 16 |
| 8 | Anim key pool | для анимированных | 24 |
| 10 | Node strings | опциональный | variable |
| 13 | Batch table | обязательный | 20 |
| 15 | Доп. stream | опциональный | 8 |
| 16 | Доп. stream | опциональный | 8 |
| 18 | Доп. stream | опциональный | 4 |
| 19 | Anim map | для анимированных | 2 |
| 20 | Доп. таблица | опциональный | variable |
## 3. Основные структуры
### 3.1. `Res1` (узлы)
```c
struct Node38 {
uint16_t hdr0;
uint16_t parent_or_link;
uint16_t anim_map_start;
uint16_t fallback_key;
uint16_t slotIndex[15]; // lod0:g0..g4, lod1:g0..g4, lod2:g0..g4
};
```
Формула slot-выбора:
```c
slot = node.slotIndex[lod * 5 + group]
```
`0xFFFF` означает отсутствие слота.
### 3.2. `Res2` (header + slot records)
```c
struct Slot68 {
uint16_t triStart;
uint16_t triCount;
uint16_t batchStart;
uint16_t batchCount;
float aabbMin[3];
float aabbMax[3];
float sphereCenter[3];
float sphereRadius;
uint32_t opaque[5];
};
```
`opaque[5]` должны сохраняться 1:1.
### 3.3. `Res3`, `Res4`, `Res5`, `Res6`
- `Res3`: `float3` позиции (`stride=12`)
- `Res4`: `int8[4]` packed normal (`stride=4`)
- `Res5`: `int16[2]` UV (`stride=4`)
- `Res6`: `uint16` индексы (`stride=2`)
Декодирование:
- normal = `clamp(n / 127.0, -1..1)`
- uv = `packed / 1024.0`
### 3.4. `Res7` и `Res13`
```c
struct TriDesc16 {
uint16_t triFlags;
uint16_t link0;
uint16_t link1;
uint16_t link2;
int16_t nx;
int16_t ny;
int16_t nz;
uint16_t selPacked;
};
struct Batch20 {
uint16_t batchFlags;
uint16_t materialIndex;
uint16_t opaque4;
uint16_t opaque6;
uint16_t indexCount;
uint32_t indexStart;
uint16_t opaque14;
uint32_t baseVertex;
};
```
`selPacked` хранит 3 селектора по 2 бита; значение `3` трактуется как `0xFFFF`.
## 4. Runtime-обход модели
Псевдокод рендера:
```c
for each node:
slot = resolve_slot(node, lod, group)
if slot == none: continue
if culled(slot.bounds, node_transform): continue
for b in slot.batchRange:
batch = batches[b]
bind_material(batch.materialIndex)
draw_indexed(
baseVertex = batch.baseVertex,
indexStart = batch.indexStart,
indexCount = batch.indexCount
)
```
## 5. Критические инварианты
Обязательно проверять:
- `Res2.size >= 0x8C`
- `(Res2.size - 0x8C) % 68 == 0`
- `batchStart + batchCount` не выходит за `Res13`
- `triStart + triCount` не выходит за `Res7`
- `indexStart + indexCount` не выходит за `Res6`
- `baseVertex + max(indexSlice) < vertexCount`
- `slotIndex == 0xFFFF` или `< slotCount`
## 6. Важные edge-cases
- Встречается редкий вариант `Res1.attr3 = 24`; для существующих ассетов нужен copy-through.
- Для строгого writer лучше генерировать `Res1` в основном формате `38` байт/узел.
- Неизвестные поля таблиц нельзя нормализовать или обнулять.
## 7. Правила для writer/editor
1. Сохранять неизвестные поля и неизвестные `type`-ресурсы.
2. Пересчитывать только явно вычислимые атрибуты (`attr1/attr3` и size-зависимые поля).
3. Не менять порядок/контент opaque-данных без явной цели.
4. Сериализовать little-endian, без внутреннего padding.
## 8. Статус валидации
- Инварианты формата реализованы в `tools/msh_doc_validator.py`.
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `435/435` MSH-моделей без структурных ошибок.
## 9. Статус покрытия и что осталось до 100%
Закрыто:
1. Базовые таблицы geometry path (`Res1/2/3/4/5/6/7/13`).
2. Критичные range-инварианты slot/batch/index.
3. Правила совместимого writer/editor для lossless работы с существующими ассетами.
Осталось:
1. Полная семантика части opaque-полей (`Slot68` tail, `Batch20` opaque-поля) для authoring без copy-through.
2. Полная формализация редких веток (`Res1.attr3 != 38`) на расширенном корпусе.
3. End-to-end writer для генерации новых игровых MSH с подтвержденным runtime-паритетом.

118
docs/specs/msh-notes.md Normal file
View File

@@ -0,0 +1,118 @@
# 3D implementation notes
Контрольная страница с практическими правилами реализации 3D-пайплайна и с перечнем незакрытых зон.
Документ intentionally high-level: без ссылок на внутренние функции/адреса.
Связанные страницы:
- [MSH core](msh-core.md)
- [MSH animation](msh-animation.md)
- [Material (`MAT0`)](material.md)
- [Texture (`Texm`)](texture.md)
- [FXID](fxid.md)
- [Render pipeline](render.md)
## 1. Базовые двоичные правила
1. Все форматы в этой подсистеме little-endian.
2. Внутри NRes данные ресурсов выравниваются по 8 байт.
3. Внутри payload таблиц padding между записями обычно отсутствует: записи идут подряд по stride.
## 2. Быстрая карта stride'ов
| Ресурс | Запись | Stride |
|---|---|---:|
| Res1 | Node | 38 |
| Res2 | Slot | 68 (после header `0x8C`) |
| Res3 | Position | 12 |
| Res4 | Normal | 4 |
| Res5 | UV0 | 4 |
| Res6 | Index | 2 |
| Res7 | Tri descriptor | 16 |
| Res8 | Animation key | 24 |
| Res13 | Batch | 20 |
| Res19 | Animation map | 2 |
## 3. Декодирование ключевых потоков
## 3.1. Позиции (Res3)
`float3`, stride `12`.
## 3.2. Нормали (Res4)
`int8[4]`, используются первые 3 компоненты:
```text
n = clamp(s8 / 127.0, -1..1)
```
## 3.3. UV (Res5)
`int16[2]`:
```text
u = s16 / 1024.0
v = s16 / 1024.0
```
## 3.4. Animation key (Res8)
`pos(float3) + time(float) + quat(int16x4)`:
```text
q = s16 / 32767.0
```
## 4. Практический reader-контракт
Для runtime-совместимого чтения модели:
1. Найти нужные ресурсы по `type_id` в NRes.
2. Проверить `size/stride`-инварианты.
3. Проверить диапазоны ссылок:
- slot -> batch/triangles;
- batch -> indices;
- indices -> vertices;
- anim_map -> anim_keys.
4. Неизвестные поля и неизвестные ресурсы сохранять через copy-through.
## 5. Практический writer-контракт
1. Пересчитывать только явно вычислимые поля.
2. Не нормализовать opaque-данные без уверенной спецификации.
3. При roundtrip неизмененных данных требовать byte-identical результат.
4. Для новых ассетов фиксировать отдельную политику «генерация vs preserve».
## 6. Runtime-связка материалов и текстур
Канонический путь резолва:
1. Модель -> wear-таблица (`*.wea`).
2. Wear-слот -> material name.
3. Material -> текущая фаза -> `textureName`.
4. `Texm` ищется в `Textures.lib` (или lightmap-библиотеке для lightmap-ветки).
Fallback:
- материал: `DEFAULT`, затем индекс `0`;
- текстура/lightmap: fallback-слот движка.
## 7. Что уже закрыто для 1:1
1. Бинарный контракт базовых MSH таблиц.
2. Контракт animation sampling (`Res8 + Res19`).
3. Контракт MAT0/WEAR/Texm на уровне чтения и применения в кадре.
4. Формат FXID-контейнера, командный поток и fixed command sizes.
5. Валидация на retail-корпусе через `tools/msh_doc_validator.py` (0 ошибок/предупреждений).
## 8. Статус покрытия и что осталось до 100%
1. Полная field-level семантика части служебных полей:
- `Batch20` opaque-поля;
- хвостовые служебные поля slot-записей;
- часть флагов узлов/групп.
2. Полный writer-путь для авторинга новых анимированных ассетов (не только roundtrip существующих).
3. Полная формализация семантики FX payload полей по каждому opcode для генерации новых эффектов, а не только для корректного чтения/исполнения.
4. Полный канонический writer `Texm` для всех редких форматов и edge-case комбинаций служебных флагов.
5. Сквозной «импорт внешнего ассета -> игровой пакет» с формальной спецификацией sidecar-метаданных (материал/эффект/анимация).

39
docs/specs/msh.md Normal file
View File

@@ -0,0 +1,39 @@
# Форматы 3D-ресурсов движка NGI
Этот документ теперь является обзором и точкой входа в набор отдельных спецификаций.
## Структура спецификаций
1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица.
2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция.
3. [Material (`MAT0`)](material.md) — формат материала и фазовая анимация.
4. [Wear (`WEAR`)](wear.md) — текстовая таблица привязки материалов/lightmap.
5. [Texture (`Texm`)](texture.md) — форматы текстур, mip-chain и `Page`.
6. [FXID](fxid.md) — контейнер эффекта и поток команд.
7. [Render pipeline](render.md) — полный процесс рендера кадра.
8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
9. [3D implementation notes](msh-notes.md) — контрольные заметки и открытые вопросы.
10. [Documentation coverage audit](coverage-audit.md) — сводка покрытия и оставшиеся блокеры.
## Связанные спецификации
- [NRes](nres.md)
- [RsLi](rsli.md)
## Принцип декомпозиции
- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько runtime-подсистем и не является форматом на диске.
## Статус покрытия и что осталось до 100%
Закрыто:
1. Документация декомпозирована по объектам: geometry, animation, material, texture, wear, fx, render, terrain.
2. Форматные инварианты ключевых 3D-ресурсов проверяются автоматическими валидаторами на retail-корпусе.
Осталось:
1. Полный сквозной writer-путь для генерации новых игровых ассетов без copy-through зависимостей.
2. Полный паритетный рендер-тест (эталонные кадры оригинала vs новый рендер) на расширенном наборе моделей/материалов/FX.
3. Полное покрытие соседних геймплейных подсистем (`AI`, `Behavior`, `Missions`, `Control`, `UI`, `Sound`, `Network`) до уровня точных форматов и runtime-контрактов.

28
docs/specs/network.md Normal file
View File

@@ -0,0 +1,28 @@
# Network system
`Network` — подсистема синхронизации состояния игры между узлами (мультиплеер/обмен состоянием).
## 1. Роль
1. Транспортирует игровые события и state-delta.
2. Синхронизирует критичные объекты мира и таймеры.
3. Обеспечивает согласованность simulation между участниками.
## 2. Минимальный контракт для 1:1
1. Детеминированная сериализация сетевых сообщений.
2. Согласованная обработка порядка/потерь/повторов пакетов.
3. Единая политика authority и коррекции расхождений.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- определено место сетевого слоя в общей архитектуре движка.
Осталось:
1. Полная спецификация wire-протокола (header, message types, payload layout).
2. Полный контракт handshake/session lifecycle.
3. Формальные правила resync/rollback/correction.
4. Набор сетевых parity-тестов на контролируемой потере/задержке.

202
docs/specs/nres.md Normal file
View File

@@ -0,0 +1,202 @@
# NRes
`NRes` — базовый контейнер ресурсов движка Parkan: Iron Strategy.
Страница фиксирует формат на диске и runtime-контракт чтения/поиска/сохранения в высокоуровневом виде, без привязки к внутренним адресам и именам из дизассемблера.
Связанная страница:
- [RsLi](rsli.md)
## 1. Назначение
`NRes` используется как универсальный архив:
- 3D-модели (`*.msh`, `*.rlb`);
- текстуры (`Texm`);
- материалы (`MAT0`);
- эффекты (`FXID`);
- миссионные и служебные ресурсы.
Формат поддерживает:
- чтение;
- поиск по имени;
- редактирование (add/replace/remove);
- полную пересборку архива.
## 2. Общий layout файла
```text
[Header: 16]
[Data region: variable, 8-byte aligned chunks]
[Directory: entry_count * 64, всегда в конце файла]
```
Критично: каталог всегда расположен в конце файла.
## 3. Заголовок (16 байт)
Все значения little-endian.
| Offset | Size | Type | Значение |
|---:|---:|---|---|
| 0 | 4 | char[4] | `NRes` |
| 4 | 4 | u32 | `0x00000100` (версия 1.0) |
| 8 | 4 | i32 | `entry_count` (должен быть `>= 0`) |
| 12 | 4 | u32 | `total_size` (должен быть равен фактическому размеру файла) |
Производные значения:
- `directory_size = entry_count * 64`;
- `directory_offset = total_size - directory_size`.
Ограничения:
- `directory_offset >= 16`;
- `directory_offset + directory_size == total_size`.
## 4. Запись каталога (64 байта)
| Offset | Size | Type | Поле |
|---:|---:|---|---|
| 0 | 4 | u32 | `type_id` |
| 4 | 4 | u32 | `attr1` |
| 8 | 4 | u32 | `attr2` |
| 12 | 4 | u32 | `size` (размер payload) |
| 16 | 4 | u32 | `attr3` |
| 20 | 36 | char[36] | `name_raw` (C-строка) |
| 56 | 4 | u32 | `data_offset` |
| 60 | 4 | u32 | `sort_index` |
### 4.1. Имя ресурса (`name_raw`)
Контракт:
- максимум 35 полезных байт + NUL;
- допускается ровно один терминатор внутри 36-байтового поля;
- имя сравнивается регистронезависимо по ASCII-правилу (`A..Z` -> `a..z`).
Для writer/editor:
- запрещено писать NUL внутри полезной части имени;
- запрещены имена длиной > 35 байт.
### 4.2. Диапазон данных (`data_offset`, `size`)
Для каждой записи:
- `data_offset >= 16`;
- `data_offset + size <= directory_offset`.
Практически (канонический writer): каждый payload начинается с 8-байтного выравнивания.
## 5. Таблица сортировки (`sort_index`)
`sort_index` задает перестановку «отсортированный список -> исходный индекс записи».
Пусть:
- `entries[i]` — i-я запись каталога в исходном порядке;
- `P` — массив индексов `0..entry_count-1`, отсортированный по `entries[idx].name` (ASCII case-insensitive).
Тогда в канонической записи:
- `entries[i].sort_index = P[i]`.
Это именно таблица для бинарного поиска по имени, а не «ранг текущей записи».
## 6. Поиск по имени
Алгоритм поиска:
1. Выполнить бинарный поиск по диапазону `i in [0, entry_count)`.
2. На шаге `i` взять `target = entries[i].sort_index`.
3. Сравнить искомое имя с `entries[target].name` (ASCII case-insensitive).
4. При совпадении вернуть `target`.
Fail-safe поведение:
- если `sort_index` некорректен (выход за диапазон), реализация должна перейти на линейный fallback по всем записям;
- fallback использует то же ASCII case-insensitive сравнение.
## 7. Каноническая пересборка архива
Канонический writer выполняет:
1. Пишет заглушку заголовка (16 байт).
2. Пишет payload всех записей в текущем порядке.
3. После каждого payload добавляет 0-padding до кратности 8.
4. Пересчитывает `sort_index` через сортировку имен.
5. Дописывает каталог (`entry_count * 64`).
6. Пересчитывает и записывает `total_size`.
Итоговый файл должен удовлетворять всем ограничениям из разделов 35.
## 8. Режим `raw` (совместимость инструментов)
Для служебных инструментов допускается `raw_mode`:
- любой бинарный файл трактуется как один «сырой» ресурс;
- возвращается одна запись (`name = RAW`, `data_offset = 0`, `size = len(file)`).
Этот режим не является форматом `NRes` на диске, это только режим открытия.
## 9. Контрольные инварианты
Минимальный набор проверок при чтении:
1. `magic == "NRes"`.
2. `version == 0x100`.
3. `entry_count >= 0`.
4. `header.total_size == file_size`.
5. Каталог находится в конце файла.
6. Для каждой записи диапазон данных не пересекает каталог.
7. Имя корректно C-терминировано и не длиннее 35 байт.
Минимальный набор проверок при записи:
1. Все имена <= 35 байт и без внутренних NUL.
2. `sort_index` формирует валидную перестановку `0..N-1`.
3. Все паддинги между payload состоят из нулевых байт.
4. `total_size` равен фактической длине выходного файла.
## 10. Эмпирическая проверка на retail-корпусе
Валидация на полном наборе `testdata/Parkan - Iron Strategy`:
- найдено `120` архивов `NRes`;
- roundtrip `unpack -> repack -> byte-compare`: `120/120` совпали побайтно;
- критических расхождений формата не обнаружено.
Инструмент:
- `tools/archive_roundtrip_validator.py`
## 11. Статус покрытия и что осталось до 100%
Закрыто:
- формат заголовка/каталога;
- правила поиска;
- каноническая пересборка;
- строгие инварианты валидатора;
- побайтовый roundtrip на retail-корпусе.
Осталось до полного 100% архитектурного покрытия движка:
1. Формальная семантика `attr1/attr2/attr3` для всех типов ресурсов (частично вынесена в профильные страницы `msh`, `material`, `texture`, `fxid`, `terrain`).
2. Полная спецификация поведения при не-ASCII именах (в реальных игровых архивах используется ASCII-практика; для Unicode-коллации движок не документирован).
3. Полная спецификация платформенных гарантий атомарной записи (формат данных закрыт, но OS-уровневые гарантии замены файла зависят от платформы и файловой системы).
## 12. Специализация `objects.rlb`
Хотя `objects.rlb` формально является обычным `NRes`, его payload имеет отдельный семантический контракт:
- запись каталога соответствует одному объектному прототипу;
- payload записи - массив фиксированных ссылок `ObjectRef64` (`archive_name[32] + resource_name[32]`);
- runtime-резолв меша выполняется через эти ссылки, а не через имя entry `*.msh` внутри `objects.rlb`.
Это означает, что `objects.rlb` должен рассматриваться не как архив мешей, а как реестр привязок между mission/unit-ключами и фактическими ресурсами.
См. детальную страницу:
- [Object registry (`objects.rlb`)](object-registry.md)

View File

@@ -0,0 +1,145 @@
# Object Registry (`objects.rlb`)
`objects.rlb` - это не архив с готовыми мешами.
Это реестр игровых прототипов, который связывает логический идентификатор объекта (`r_h_01`, `s_tree_04`, `fr_m_brige`, ...) с набором реальных ресурсов в других архивах.
Документ описывает формат и runtime-контракт на высоком уровне, без привязки к внутренним именам/адресам из дизассемблера.
Связанные страницы:
- [Missions](missions.md)
- [NRes](nres.md)
- [MSH core](msh-core.md)
- [Wear (`WEAR`)](wear.md)
- [Material (`MAT0`)](material.md)
- [Render pipeline](render.md)
## 1. Роль в пайплайне
При загрузке миссии движок работает так:
1. Из `data.tma` получает `resource_name` объекта:
- либо прямой ключ (`s_tree_04`);
- либо путь к `*.dat` (например `UNITS\\UNITS\\HERO\\tut1_p.dat`).
2. Для `*.dat` читает заголовок и получает:
- `archive_name` (в retail-корпусе всегда `objects.rlb`);
- `model_key` (например `R_H_02`).
3. В `objects.rlb` по ключу (`model_key`/`resource_name`) ищет запись прототипа.
4. Из записи прототипа резолвит фактический `*.msh` и архив, где лежит геометрия.
5. Дальше запускается стандартная цепочка:
`MSH -> WEAR -> MAT0 -> Texm`.
## 2. Контейнер
`objects.rlb` сам является обычным `NRes`-архивом.
Практические наблюдения на retail-корпусе:
- формат заголовка/каталога полностью совпадает с `NRes`;
- payload каждой записи прототипа кратен `64` байтам;
- имя entry в каталоге - это логический ключ объекта (например `r_h_01`, `s_tree_04`).
## 3. Формат payload записи прототипа
Payload состоит из массива фиксированных записей:
```c
struct ObjectRef64 {
char archive_name[32]; // C-строка (CP1251/ASCII)
char resource_name[32]; // C-строка (CP1251/ASCII)
}
```
Интерпретация:
- `archive_name`: архив-источник (`bases.rlb`, `static.rlb`, `fortif.rlb`, `effects.rlb`, ...).
- `resource_name`: имя ресурса в этом архиве (`*.msh`, `*.wea`, `*.cpt`, `*.ctl`, `*.bas`, ...).
Важно:
- после первого `NUL` в 32-байтовом поле могут встречаться служебные байты; для runtime-резолва используется только C-строка до первого `NUL`;
- неизвестные хвостовые байты должны сохраняться 1:1 при writer/roundtrip-редактировании.
## 4. Runtime-резолв геометрии
Канонический порядок выбора меша:
1. Найти запись прототипа по ключу в `objects.rlb`.
2. Прочитать список `ObjectRef64`.
3. Если есть ссылка на `*.msh`:
- взять первую валидную ссылку;
- открыть указанный архив;
- загрузить этот `*.msh`.
4. Если `*.msh` нет, но есть `*.bas`:
- взять stem от `*.bas` (`fr_m_brige.bas` -> `fr_m_brige`);
- искать `<stem>.msh` в том же архиве (`fortif.rlb`).
5. Если нет ни `*.msh`, ни `*.bas`, объект трактуется как не-геометрический (пример: солнечный/системный объект) и в 3D-проход не попадает.
## 5. Типовые примеры
`r_h_01`:
- `bases.rlb :: r_h_01.msh`
- `bases.rlb :: r_h_01.wea`
- `bases.rlb :: r_h_01.cpt`
- ...
`s_tree_04`:
- `static.rlb :: s_tree_0_04.msh`
- `static.rlb :: s_tree_0_04.wea`
- ...
`fr_m_brige`:
- прямого `*.msh` в записи нет;
- есть `fortif.rlb :: fr_m_brige.bas`;
- меш резолвится как `fortif.rlb :: fr_m_brige.msh`.
`sun_01`:
- ссылки на `*.sun`/effect-ресурсы;
- 3D-меш отсутствует.
## 6. Инварианты для reader/writer
Reader:
- payload записи прототипа должен быть кратен `64`;
- каждая запись читается как две независимые C-строки фиксированной длины;
- поиск в архивах должен быть case-insensitive по ASCII.
Writer/editor:
- сохранять порядок `ObjectRef64` без перестановок;
- сохранять неизвестные служебные байты полей 1:1;
- не нормализовать имена, если это не требуется задачей.
## 7. Валидация
Проверено на retail-корпусе `testdata/Parkan - Iron Strategy`:
- все `590` записей `objects.rlb` имеют payload, кратный `64`;
- `554` записей имеют прямую ссылку на `*.msh`;
- `34` записи используют ветку через `*.bas`;
- `2` записи не содержат геометрии (системные/sun).
Интеграционные тесты в Rust подтверждают резолв:
- `r_h_01 -> bases.rlb :: r_h_01.msh`
- `s_tree_04 -> static.rlb :: s_tree_0_04.msh`
- `fr_m_brige -> fortif.rlb :: fr_m_brige.msh`
## 8. Статус покрытия и что осталось до 100%
Закрыто:
1. Формат payload записи прототипа (`ObjectRef64`) и правила чтения.
2. Runtime-алгоритм выбора меша (`*.msh` напрямую и fallback через `*.bas`).
3. Корпусная проверка структуры и интеграционные тесты резолва.
Осталось:
1. Полная field-level семантика служебных байтов после `NUL` в `resource_name[32]`.
2. Формальная семантика всех категорий ссылок (`*.ctl`, `*.cpt`, `*.ndp`, `*.sun`) в терминах систем движка (не только render-пути).
3. Writer-спецификация уровня "authoring new prototype from scratch" с гарантией runtime-паритета.

View File

@@ -0,0 +1,90 @@
# Рендер-паритет (кадровый diff)
Документ описывает процесс проверки соответствия рендера:
`оригинальный движок -> эталонный кадр -> render-demo -> diff-метрики`.
## Цель
- Зафиксировать объективный критерий "паритет достигнут / не достигнут".
- Убрать субъективную визуальную оценку "похоже/не похоже".
- Дать CI-проверку, которая ловит регрессии сразу после коммита.
## Единица проверки
Один тест-кейс = один объект (одна модель) + фиксированная конфигурация:
- архив ресурса;
- имя модели;
- `lod`;
- `group`;
- размер кадра (`width`, `height`);
- угол камеры (`angle`);
- PNG-эталон из оригинального рендера.
## Инварианты детерминизма
Для корректного сравнения кадры должны быть сняты в одинаковых условиях:
- одинаковый FOV и расстояние камеры до объекта;
- одинаковый clear-color/фон;
- одинаковые `lod/group`;
- фиксированный угол (`angle`), без анимации;
- фиксированное разрешение.
## Метрики сравнения
Сравнение выполняется по RGB-каналам:
- `mean_abs`: средняя абсолютная разница канала (0..255);
- `max_abs`: максимальная разница канала;
- `changed_ratio`: доля пикселей, где хотя бы один канал превышает `diff_threshold`.
Кейс считается пройденным, если:
- `mean_abs <= max_mean_abs`;
- `changed_ratio <= max_changed_ratio`.
## Конфигурация кейсов
Файл: `parity/cases.toml`.
- секция `[meta]`: глобальные дефолты;
- `[[case]]`: параметры конкретной модели и путь к эталонному PNG.
Эталонные кадры хранятся в `parity/reference/`.
## Локальный запуск
```bash
cargo run -p render-parity -- \
--manifest parity/cases.toml \
--output-dir target/render-parity/current
```
При расхождении утилита пишет diff-изображение в:
- `target/render-parity/current/diff/<case>.png`
## CI-модель
CI запускает `render-parity` на каждом push/PR:
1. собирает `parkan-render-demo`;
2. прогоняет кейсы из `cases.toml`;
3. при падении публикует текущие кадры и diff как артефакт.
Важно: оригинальный движок в CI обычно не запускается.
Эталонные PNG снимаются офлайн и версионируются в репозитории.
## Статус покрытия и что осталось до 100%
Закрыто:
1. Определена метрика сравнения кадров (`mean_abs`, `max_abs`, `changed_ratio`).
2. Описан единый manifest-формат кейсов и CI-процедура.
Осталось:
1. Снять и зафиксировать расширенный эталонный набор кадров оригинала (10-20+ ключевых моделей и режимов).
2. Зафиксировать пороговые критерии pass/fail по каждому классу сцен (статик, анимация, FX, lightmap).
3. Добавить автоматическую публикацию diff-артефактов и регрессионных отчетов в CI.

182
docs/specs/render.md Normal file
View File

@@ -0,0 +1,182 @@
# Render pipeline
Документ описывает полный процесс рендера кадра в движке Parkan: Iron Strategy, без привязки к внутренним адресам/именам дизассемблера.
Связанные страницы:
- [MSH core](msh-core.md)
- [MSH animation](msh-animation.md)
- [Material (`MAT0`)](material.md)
- [Wear table (`WEAR`)](wear.md)
- [Texture (`Texm`)](texture.md)
- [FXID](fxid.md)
## 1. Инициализация рендера
На старте движок:
1. Выбирает видеодрайвер (software или аппаратный).
2. Создаёт render backend.
3. Подключает библиотеки ресурсов:
- `Material.lib`
- `Textures.lib`
- `LightMap.lib`
- `palettes.lib`
4. Инициализирует менеджеры:
- material manager
- texture/lightmap cache
- effect manager
5. Загружает базовые world-ресурсы (включая наборы объектов сцены).
## 2. Структура кадра
Кадр выполняется как последовательность:
1. `Simulation update`
2. `Animation sampling`
3. `Visibility / culling`
4. `Material + texture resolve`
5. `Mesh draw`
6. `FX update + draw`
7. `UI/overlay draw`
8. `Present`
## 3. Geometry path
### 3.1. Подготовка инстансов
Для каждого видимого объекта:
1. Вычисляется `world transform`.
2. Выбирается `LOD`.
3. Для каждого узла выбирается slot через `Res1`.
### 3.2. Culling
Сначала отсекаются узлы/слоты по bounds (`AABB/sphere`) из `Res2`.
### 3.3. Батчи
Для каждого прошедшего slot:
1. Берутся батчи из диапазона `Res13`.
2. По `materialIndex` выбирается активный материал.
3. По фазе материала выбирается текстура/lightmap.
4. Выполняется `DrawIndexedPrimitive`:
- индексный диапазон: `indexStart/indexCount`
- базовая вершина: `baseVertex`
- индексы читаются из `Res6`
- вершины/атрибуты читаются из `Res3/Res4/Res5` (+ optional streams)
## 4. Animation path
Для анимированных моделей:
1. Для узла выбирается ключ через `Res19` и fallback-логику.
2. Декодируются `pos + quat` из `Res8`.
3. При необходимости выполняется blending двух сэмплов.
4. Узловая матрица передаётся в geometry path.
## 5. Material path
Material pipeline на кадре:
1. По material handle выбирается запись `MAT0`.
2. По игровому времени выбирается текущая фаза.
3. Применяются коэффициенты фазы (цвет/альфа/параметры).
4. Резолвятся ссылки на texture/lightmap.
5. Невалидные ссылки обрабатываются fallback-стратегией.
Практическая цепочка привязки для большинства `*.msh` ассетов из `*.rlb`:
1. Для модели выбирается одноимённый `WEAR` (`<model_stem>.wea`).
2. Из `WEAR` берётся material-слот (по имени, `legacyId` не участвует в выборе).
3. В `Material.lib` ищется `MAT0` по имени (`DEFAULT`, затем индекс `0` как fallback).
4. Из выбранной material-фазы берётся `textureName`.
5. `Texm` ищется в `Textures.lib` (и/или lightmap-архиве для lightmap-ветки).
## 6. Texture path
При резолве текстуры:
1. Ищется `Texm` entry по имени.
2. Проверяется и декодируется заголовок.
3. При необходимости применяется `mipSkip`.
4. Для indexed-формата подключается палитра.
5. Optional `Page` chunk интерпретируется как atlas-таблица.
6. Объект текстуры кладётся/берётся из cache.
## 7. FX path
Эффекты выполняются параллельно mesh-рендеру:
1. Для активных инстансов FX вычисляется runtime-коэффициент (`time_mode + flags`).
2. Команды FX обновляют внутреннее состояние.
3. Команды emit-этапа формируют примитивы/батчи эффектов.
4. Эффекты рисуются в 3D-кадре с собственным счётчиком батчей.
## 8. Псевдокод кадра
```c
void RenderFrame(Scene* scene, Camera* cam, float dt) {
UpdateGame(scene, dt);
for (Object* obj : scene->objects) {
if (!obj->visible) continue;
UpdateObjectAnimation(obj, scene->time);
BuildObjectNodeTransforms(obj);
}
BeginFrame(cam);
for (Object* obj : scene->objects) {
if (!obj->visible) continue;
RenderObjectMeshes(obj, cam);
}
UpdateAndRenderFx(scene, dt, cam);
RenderUI(scene);
Present();
}
```
## 9. Критичные условия для 1:1
1. Та же политика округления/FP для анимации и FX.
2. Та же логика fallback по материалам и текстурам.
3. Та же очередность стадий кадра.
4. Тот же контракт интерпретации `Res1/Res2/Res13/Res6`.
5. Тот же контракт `FXID` командного потока.
## 10. Статус валидации
- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущей runtime-валидацией проекта.
- Детальные инварианты форматов зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
## 11. Статус покрытия и что осталось до 100%
Закрыто:
1. Высокоуровневый кадр: simulation -> animation -> culling -> material/texture resolve -> mesh draw -> fx -> ui -> present.
2. Связка MSH/MAT0/WEAR/Texm/FXID в едином runtime-процессе.
3. Форматная валидация входных данных на полном retail-корпусе.
Осталось:
1. Полный pixel-parity контур с эталонными кадрами оригинального рендера по набору моделей/сцен.
2. Формализация всех render-state деталей (точные blend/depth/cull/state transitions) для гарантии 1:1 в каждом draw-pass.
3. Полный coverage-пакет по динамическим веткам (FX-heavy кадры, сложные material-режимы, lightmap-комбинации).
## 12. Object registry bridge (`objects.rlb`)
Для миссионного/юнитного рендера критично учитывать промежуточный слой прототипов:
1. `TMA`/`*.dat` обычно дают не прямой `*.msh`, а ключ прототипа.
2. Ключ резолвится через `objects.rlb` (реестр ссылок на реальные архивы ресурсов).
3. Только после этого выполняется стандартный путь:
`MSH -> WEAR -> MAT0 -> Texm`.
Детальная спецификация этого шага вынесена в отдельную страницу:
- [Object registry (`objects.rlb`)](object-registry.md)

230
docs/specs/rsli.md Normal file
View File

@@ -0,0 +1,230 @@
# RsLi
`RsLi` — библиотечный контейнер ресурсов движка Parkan: Iron Strategy с зашифрованной таблицей записей и несколькими методами упаковки данных.
Страница описывает формат и runtime-контракт в высокоуровневом виде, без ссылок на внутренние адреса/функции дизассемблера.
Связанная страница:
- [NRes](nres.md)
## 1. Общая структура файла
```text
[Header: 32]
[Entry table: entry_count * 32, XOR-encrypted]
[Packed payloads]
[Optional trailer: "AO" + overlay:u32]
```
В отличие от `NRes`, таблица записей у `RsLi` расположена в начале файла.
## 2. Заголовок (32 байта)
Все значения little-endian.
| Offset | Size | Type | Поле |
|---:|---:|---|---|
| 0 | 2 | char[2] | `NL` (магия) |
| 2 | 1 | u8 | зарезервировано, в retail = `0` |
| 3 | 1 | u8 | версия, в retail = `1` |
| 4 | 2 | i16 | `entry_count` (должен быть `>= 0`) |
| 14 | 2 | u16 | `presorted_flag` (`0xABBA` = таблица сортировки уже задана) |
| 20 | 4 | u32 | `xor_seed` |
Остальные байты заголовка считаются служебными и должны сохраняться без нормализации.
## 3. Таблица записей (после дешифровки)
Таблица начинается с `offset = 32`, размер `entry_count * 32`.
Каждая запись (32 байта):
| Offset | Size | Type | Поле |
|---:|---:|---|---|
| 0 | 12 | char[12] | `name_raw` (обычно uppercase ASCII, NUL optional) |
| 12 | 4 | bytes | служебный хвост, сохранять как есть |
| 16 | 2 | i16 | `flags` |
| 18 | 2 | i16 | `sort_to_original` |
| 20 | 4 | u32 | `unpacked_size` |
| 24 | 4 | u32 | `data_offset_raw` |
| 28 | 4 | u32 | `packed_size` |
### 3.1. Метод упаковки
`method = flags & 0x1E0`
Поддерживаемые значения:
| Маска | Метод |
|---:|---|
| `0x000` | без сжатия |
| `0x020` | XOR only |
| `0x040` | LZSS |
| `0x060` | XOR + LZSS |
| `0x080` | LZSS + адаптивный Huffman |
| `0x0A0` | XOR + LZSS + адаптивный Huffman |
| `0x100` | raw Deflate (RFC1951) |
Другие значения считаются неподдерживаемыми.
## 4. XOR-дешифрование таблицы и данных
Для таблицы и XOR-методов payload используется один и тот же потоковый XOR-алгоритм.
Ключ:
- `key16 = xor_seed & 0xFFFF` (используются только младшие 16 бит seed).
Состояние:
```text
lo = key16 & 0xFF
hi = key16 >> 8
```
Для каждого байта:
```text
lo = hi XOR ((lo << 1) mod 256)
out = in XOR lo
hi = lo XOR (hi >> 1)
```
## 5. `sort_to_original` и поиск по имени
### 5.1. Режим `presorted_flag == 0xABBA`
`sort_to_original` обязан быть перестановкой `0..entry_count-1` без дубликатов.
### 5.2. Режим без presorted-флага
Слой загрузки строит `sort_to_original` самостоятельно:
- сортирует индексы по `strcmp`-порядку имен (байтовое сравнение);
- записывает эту перестановку в lookup-таблицу.
### 5.3. Поиск
Поиск выполняется бинарным поиском по lookup-таблице:
1. запрос переводится в uppercase ASCII;
2. на шаге бинарного поиска используется индекс `sort_to_original[mid]`;
3. сравнение имен — bytewise (`strcmp`-логика).
Fail-safe:
- при невалидном индексе lookup-таблицы выполняется линейный fallback.
## 6. AO-трейлер и media overlay
Опциональный трейлер в конце файла:
```text
"AO" + overlay:u32
```
Если трейлер присутствует:
- эффективный offset payload: `effective_offset = data_offset_raw + overlay`.
Ограничение:
- `overlay <= file_size`.
## 7. Декодирование payload по методам
## 7.1. Без сжатия (`0x000`)
Берутся первые `unpacked_size` байт из packed-диапазона.
## 7.2. XOR only (`0x020`)
XOR-дешифрование первых `unpacked_size` байт.
## 7.3. LZSS (`0x040`, `0x060`)
Параметры:
- ring buffer: `4096` байт;
- начальное заполнение ring: `0x20`;
- стартовый указатель ring: `0xFEE`;
- control-биты читаются LSB-first.
Правила:
- `bit=1`: literal byte;
- `bit=0`: ссылка из 2 байт
`offset = low | ((high & 0xF0) << 4)`
`length = (high & 0x0F) + 3`.
Для `0x060` XOR применяется на лету к packed-потоку до LZSS-декодирования.
## 7.4. LZSS + адаптивный Huffman (`0x080`, `0x0A0`)
Параметры:
- `N=4096`, `F=60`, `THRESHOLD=2`;
- адаптивное дерево Huffman обновляется по мере декодирования.
Для `0x0A0` XOR применяется на лету к битовому потоку до Huffman/LZSS-декодирования.
## 7.5. Deflate (`0x100`)
Используется raw Deflate-поток (RFC1951).
Важно:
- zlib-обертка (`RFC1950`) не принимается.
## 8. Quirk: Deflate EOF+1
На retail-корпусе встречается один подтвержденный случай, где:
- `effective_offset + packed_size == file_size + 1`.
Совместимое поведение:
- для метода `0x100` допустить чтение `packed_size - 1` байт (если включен режим совместимости);
- в строгом режиме считать это ошибкой.
## 9. Контрольные инварианты
Минимальные проверки:
1. `magic == "NL"`, `reserved == 0`, `version == 1`.
2. `entry_count >= 0`.
3. `table_end <= file_size`.
4. Если `presorted_flag == 0xABBA`, `sort_to_original` — валидная перестановка.
5. `effective_offset + packed_size` не выходит за EOF (кроме разрешенного deflate EOF+1 quirk).
6. Итоговый распакованный размер равен `unpacked_size`.
## 10. Эмпирическая проверка на retail-корпусе
Проверка на полном наборе `testdata/Parkan - Iron Strategy`:
- обнаружено `2` архива `RsLi`;
- roundtrip `unpack -> repack -> byte-compare`: `2/2` совпали побайтно;
- подтвержден ровно один `deflate EOF+1` случай (`sprites.lib`, entry `23`).
Инструменты:
- `tools/archive_roundtrip_validator.py`
- `crates/rsli` tests
## 11. Статус покрытия и что осталось до 100%
Закрыто:
- формат заголовка/таблицы;
- XOR-алгоритм;
- все используемые методы декодирования;
- AO overlay;
- lookup-поиск и fallback;
- retail-валидация и побайтовый roundtrip.
Осталось до полного 100% архитектурного покрытия движка:
1. Полная функциональная семантика битов `flags` вне маски метода (`0x1E0`) для геймплейных подсистем.
2. Канонический writer для авторинга новых архивов со стабильной стратегией выбора методов (`0x080/0x0A0/0x100`) и параметров компрессии.
3. Формализация поведения для не-ASCII имен (на практике архивы используют ASCII-диапазон).

View File

@@ -0,0 +1,18 @@
# Runtime pipeline
Актуальный документ по полному кадру находится здесь:
- [Render pipeline](render.md)
Эта страница оставлена как совместимый указатель для старых ссылок.
## Статус покрытия и что осталось до 100%
Закрыто:
1. Актуальный runtime-пайплайн централизован в `render.md`.
Осталось:
1. Поддерживать обратную совместимость ссылок при дальнейшей декомпозиции render-документа.

32
docs/specs/sound.md Normal file
View File

@@ -0,0 +1,32 @@
# Sound system
`Sound` — подсистема аудио:
- загрузка и кеширование звуковых ресурсов;
- воспроизведение SFX/voice/music;
- пространственное позиционирование и микширование.
## 1. Архитектурная роль
1. Получает события от gameplay/FX/mission/UI.
2. Резолвит аудиоресурсы через архивные библиотеки.
3. Управляет каналами, приоритетами и жизненным циклом источников звука.
## 2. Минимальный runtime-контракт
1. Стабильный выбор источника и fallback при отсутствии ресурса.
2. Детерминированные правила приоритета при переполнении каналов.
3. Согласованная модель пространственного затухания и панорамирования.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- место аудио-подсистемы в общем runtime-контуре.
Осталось:
1. Полная спецификация форматов аудио-ресурсов и lookup-таблиц.
2. Полный контракт 2D/3D микширования и лимитов каналов.
3. Правила взаимодействия с FXID-командами, которые инициируют звук.
4. Набор audio parity-тестов (тайминг/громкость/панорама).

View File

@@ -0,0 +1,293 @@
# Terrain + ArealMap
Документ описывает подсистему ландшафта и ареалов мира в движке Parkan: Iron Strategy:
- `Land.msh` (terrain-геометрия и вспомогательные таблицы);
- `Land.map` (ареалы и навигационные связи);
- `BuildDat.lst` (категории объектных зон).
Описание дано в высокоуровневом переносимом виде, без ссылок на внутренние адреса и имена из дизассемблера.
Связанные страницы:
- [NRes](nres.md)
- [RsLi](rsli.md)
- [MSH core](msh-core.md)
- [Render pipeline](render.md)
## 1. End-to-End загрузка уровня
Для каждой карты движок загружает пару файлов:
- `.../Land.msh`
- `.../Land.map`
Высокоуровневый порядок:
1. Открыть `Land.msh` как `NRes`.
2. Прочитать обязательные terrain-chunk'и.
3. Построить runtime-структуры terrain (slots, faces, spatial grid).
4. Открыть `Land.map` как `NRes`.
5. Найти единственный chunk `type=12`.
6. Прочитать ареалы, их связи и cell-grid.
7. Применить инициализацию объектных категорий из `BuildDat.lst`.
## 2. Формат `Land.msh`
`Land.msh` — обычный `NRes` архив с фиксированным набором terrain-ресурсов.
## 2.1. Состав chunk'ов
Обязательные типы:
- `1`, `2`, `3`, `4`, `5`, `11`, `18`, `21`
Опциональные типы:
- `14`
Наблюдаемый retail-порядок chunk'ов:
```text
[1, 2, 3, 4, 5, 18, 14, 11, 21]
```
## 2.2. Stride и атрибуты
| Type | Назначение | Stride |
|---:|---|---:|
| 1 | node/slot матрица | 38 |
| 3 | позиции вершин | 12 |
| 4 | нормали (packed) | 4 |
| 5 | UV (packed) | 4 |
| 11 | cell-ускоритель | 4 |
| 14 | доп. поток | 4 |
| 18 | доп. поток | 4 |
| 21 | terrain face | 28 |
Общее правило для этих chunk'ов:
- `attr1 == size / stride`
- `attr3 == stride`
## 2.3. Type `2`: slot table
`type=2` содержит:
- заголовок `0x8C` байт;
- затем таблицу slots по `68` байт.
Инварианты:
- `size >= 0x8C`
- `(size - 0x8C) % 68 == 0`
- `attr1 == (size - 0x8C) / 68`
- `attr3 == 68`
## 2.4. Type `21`: terrain face (28 байт)
Высокоуровневая структура face:
- флаги face;
- индексы треугольника (`i0, i1, i2`);
- индексы соседей (`n0, n1, n2`, значение `0xFFFF` = нет соседа);
- служебные поля (материал/класс/edge-поля и др.).
Критичные проверки:
- `i0/i1/i2 < vertex_count` (`type=3`);
- `nX == 0xFFFF` или `nX < face_count`.
## 2.5. Маски face и compact-представления
В рантайме используются:
- полная 32-битная маска (`full`);
- компактные представления (`compactMain16`, `compactMaterial6`).
Подтвержденный remap `full -> compactMain16`:
| Full bit | Compact bit |
|---:|---:|
| `0x00000001` | `0x0001` |
| `0x00000008` | `0x0002` |
| `0x00000010` | `0x0004` |
| `0x00000020` | `0x0008` |
| `0x00001000` | `0x0010` |
| `0x00004000` | `0x0020` |
| `0x00000002` | `0x0040` |
| `0x00000400` | `0x0080` |
| `0x00000800` | `0x0100` |
| `0x00020000` | `0x0200` |
| `0x00002000` | `0x0400` |
| `0x00000200` | `0x0800` |
| `0x00000004` | `0x1000` |
| `0x00000040` | `0x2000` |
| `0x00200000` | `0x8000` |
Подтвержденный remap `full -> compactMaterial6`:
| Full bit | Compact bit |
|---:|---:|
| `0x00000100` | `0x01` |
| `0x00008000` | `0x02` |
| `0x00010000` | `0x04` |
| `0x00040000` | `0x08` |
| `0x00080000` | `0x10` |
| `0x00000080` | `0x20` |
Для 1:1 реализации нужно поддерживать оба представления и обратное восстановление `compact -> full`.
## 2.6. Type `11` и cell-ускоритель terrain
`type=11` служит источником cell-ускорителя для terrain-запросов.
Практические требования для editor/toolchain:
- не переупорядочивать содержимое без полного пересчета зависимых таблиц;
- сохранять служебные/неизвестные поля побайтно;
- выполнять валидацию диапазонов face/slot после любых правок.
## 3. Формат `Land.map` (chunk `type=12`)
`Land.map``NRes`, содержащий ровно один ресурс `type=12`.
Контракт верхнего уровня:
- `entry.attr1` = `areal_count`;
- payload включает:
- `areal_count` переменных записей ареалов;
- затем grid-секцию cell-попаданий.
## 3.1. Запись ареала
Старт записи:
```c
float anchor_x; // +0
float anchor_y; // +4
float anchor_z; // +8
float reserved_12; // +12
float area_metric; // +16
float normal_x; // +20
float normal_y; // +24
float normal_z; // +28
uint32_t logic_flag; // +32
uint32_t reserved_36; // +36
uint32_t class_id; // +40
uint32_t reserved_44; // +44
uint32_t vertex_count; // +48
uint32_t poly_count; // +52
```
Далее:
1. `float3 vertices[vertex_count]`
2. `EdgeLink8 links[vertex_count + 3 * poly_count]`, где
`EdgeLink8 = { int32 area_ref; int32 edge_ref; }`
3. для каждого полигона block:
- `uint32 n`
- `4 * (3*n + 1)` байт данных полигона
## 3.2. Семантика edge-link
Для `links[0 .. vertex_count-1]`:
- `(-1, -1)` означает «соседа нет»;
- иначе `area_ref` указывает на индекс соседнего ареала, `edge_ref` — на ребро в соседнем ареале.
## 3.3. Grid-секция после ареалов
Формат:
```c
uint32 cellsX;
uint32 cellsY;
for (x=0; x<cellsX; x++) {
for (y=0; y<cellsY; y++) {
uint16 hitCount;
uint16 areaIds[hitCount];
}
}
```
В runtime существует упакованное cell-meta представление:
- high 10 бит: `hitCount`;
- low 22 бита: `startIndex` (в общем `areaIds` пуле).
## 3.4. Валидация целостности chunk 12
Обязательные проверки:
- `areal_count > 0`;
- `cellsX > 0 && cellsY > 0`;
- каждый `area_id` из cell-списков `< areal_count`;
- все `area_ref/edge_ref` валидны относительно целевых ареалов;
- полный объем прочитанных байт должен точно совпасть с размером payload.
## 4. `BuildDat.lst`
Используются 12 объектных категорий ареалов:
| Имя | Маска |
|---|---:|
| `Bunker_Small` | `0x80010000` |
| `Bunker_Medium` | `0x80020000` |
| `Bunker_Large` | `0x80040000` |
| `Generator` | `0x80000002` |
| `Mine` | `0x80000004` |
| `Storage` | `0x80000008` |
| `Plant` | `0x80000010` |
| `Hangar` | `0x80000040` |
| `MainTeleport` | `0x80000200` |
| `Institute` | `0x80000400` |
| `Tower_Medium` | `0x80100000` |
| `Tower_Large` | `0x80200000` |
Файл должен парситься строго секционно; поврежденный формат считается ошибкой.
## 5. Требования к reader/writer/editor
1. Сохранять порядок и бинарную форму chunk'ов, если не выполняется осознанная нормализация.
2. Все неизвестные поля хранить и писать побайтно (`preserve-as-is`).
3. После правок пересчитывать только вычислимые поля, не «чистить» opaque-данные.
4. Проверять диапазоны индексов между связанными таблицами (`nodes/slots/faces/vertices/areas/cells`).
5. Для неизмененных ресурсов обеспечивать byte-identical roundtrip.
## 6. Эмпирическая верификация (retail)
Валидация на `testdata/Parkan - Iron Strategy`:
- карт: `33`
- `Land.msh`: `33/33` валидны
- `Land.map`: `33/33` валидны
- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`
Подтвержденные наблюдения:
- `Land.msh` порядок chunk'ов стабилен: `[1,2,3,4,5,18,14,11,21]`;
- `Land.map` всегда содержит один chunk `type=12`;
- `cellsX == cellsY == 128` во всех retail-картах;
- `poly_count == 0` во всем проверенном retail-корпусе;
- `normal` имеет длину ~1.0;
- `reserved_12`, `reserved_36`, `reserved_44` в retail наблюдаются как `0`.
Инструмент:
- `tools/terrain_map_doc_validator.py`
## 7. Статус покрытия и что осталось до 100%
Закрыто:
- бинарный контракт `Land.msh` и `Land.map`;
- диапазонные и структурные инварианты;
- remap масок `full/compact`;
- валидация на полном retail-корпусе карт.
Осталось до полного 100% архитектурного покрытия движка:
1. Полная доменная семантика `class_id` и `logic_flag` (игровые значения/поведенческие правила).
2. Полная спецификация ветки `poly_count > 0` на живых данных (в retail не встречена).
3. Полная field-level семантика части битов `TerrainFace28.flags` (бинарный контракт и remap закрыты, но не все биты имеют документированные геймплейные имена).

153
docs/specs/texture.md Normal file
View File

@@ -0,0 +1,153 @@
# Texture (`Texm`)
`Texm` — основной формат текстур движка.
Связанные страницы:
- [Material (`MAT0`)](material.md)
- [Wear table (`WEAR`)](wear.md)
- [Render pipeline](render.md)
## 1. Контейнер
- Тип ресурса: `0x6D786554` (`Texm`).
- Используется в `Textures.lib`, `LightMap.lib` и других `NRes` архивах.
## 2. Заголовок
```c
struct TexmHeader32 {
uint32_t magic; // 'Texm'
uint32_t width;
uint32_t height;
uint32_t mipCount;
uint32_t flags4;
uint32_t flags5;
uint32_t unk6;
uint32_t format;
};
```
## 3. Поддерживаемые форматы
Базовые форматы:
- `0` (8-bit indexed + palette)
- `565`
- `4444`
- `888`
- `8888`
Дополнительные ветки загрузки поддерживают также `556` и `88`.
## 4. Layout payload
1. `TexmHeader32` (32 байта)
2. palette `1024` байта, если `format == 0`
3. mip-chain пикселей
4. optional `Page` chunk
Расчёт ядра:
```c
bytesPerPixel =
(format == 0) ? 1 :
(format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
4;
pixelCount = sum(max(1, width>>i) * max(1, height>>i), i=0..mipCount-1);
sizeCore = 32 + (format==0 ? 1024 : 0) + bytesPerPixel * pixelCount;
```
## 4.1. Декодирование в RGBA8 (runtime/инструменты)
Для CPU-пути (preview, валидация, оффлайн-конвертация) используется декодирование:
- `0` (`Indexed8`): `index -> palette[index]` (`RGBA` из палитры 256×4).
- `565`: `R5 G6 B5`, `A=255`.
- `556`: `R5 G5 B6`, `A=255`.
- `4444`: `A4 R4 G4 B4` (с расширением 4-битных каналов в 8-битные).
- `88`: `L8 A8` (`R=G=B=L`).
- `888`: `R8 G8 B8` + padding/служебный байт, `A=255`.
- `8888`: `A8 R8 G8 B8`.
Это декодирование соответствует текущему test/demo pipeline проекта.
## 5. `Page` chunk
```c
struct PageChunk {
uint32_t magic; // 'Page'
uint32_t rectCount;
Rect16 rects[rectCount];
};
struct Rect16 {
int16_t x;
int16_t w;
int16_t y;
int16_t h;
};
```
`Page` задаёт atlas-прямоугольники для выборки под-областей текстуры.
## 6. Mip-skip политика
Загрузчик может пропускать первые mip-уровни в зависимости от:
- `flags5`,
- размеров текстуры,
- количества mip.
После `mipSkip`:
- уменьшаются `width/height/mipCount`;
- сдвигается начало пиксельных данных;
- `Page`-координаты пересчитываются в соответствии с новым базовым уровнем.
## 7. Палитры
Для части текстур движок связывает палитру по суффиксу имени.
Практический формат:
- буква `A..Z` + вариант `""` или `0..9`
- всего `26 * 11 = 286` возможных слотов палитр.
Невалидные суффиксы нужно считать ошибкой входных данных в инструментах.
## 8. Кэширование
Движок ведёт отдельные кэши:
- общий texture cache;
- lightmap cache.
Для обычных текстур используется отложенный сбор неиспользуемых слотов (по времени нулевого refcount).
## 9. Правила writer/editor
1. Не нормализовать `flags4/flags5/unk6`.
2. Сохранять payload без лишних хвостовых байт.
3. Если есть `Page`, его размер должен быть ровно `8 + rectCount * 8`.
4. Проверять `width > 0`, `height > 0`, `mipCount > 0`.
## 10. Статус валидации
- Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`.
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `518/518` текстурных payload (`Texm`) без ошибок.
## 11. Статус покрытия и что осталось до 100%
Закрыто:
1. Заголовок `Texm`, mip-chain layout и `Page` chunk.
2. Базовые decode-пути в RGBA8 для проверок/preview.
3. Корпусная валидация структурных инвариантов.
Осталось:
1. Полная формальная спецификация всех редких служебных комбинаций `flags4/flags5/unk6`.
2. Канонический writer для полного набора форматов (`indexed`, `565`, `556`, `4444`, `88`, `888`, `8888`) с проверенным roundtrip-профилем.
3. Pixel-parity тесты «оригинальный рендер vs новый рендер» с учетом mipSkip/atlas-page веток.

33
docs/specs/ui.md Normal file
View File

@@ -0,0 +1,33 @@
# UI system
`UI` — подсистема интерфейса:
- экранные панели и HUD;
- меню;
- шрифты;
- minimap и служебные оверлеи.
## 1. Архитектурная роль
1. Работает поверх render-пайплайна как отдельный этап кадра.
2. Использует UI-ресурсы из архивных библиотек.
3. Перехватывает пользовательский ввод по правилам фокуса.
## 2. Минимальный runtime-контракт
1. Детерминированный порядок draw-проходов UI.
2. Консистентный фокус и приоритет ввода (UI vs world).
3. Стабильная загрузка font/minimap/ui-ресурсов по именам.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- позиция UI-слоя в общем кадре и его связи с render/input.
Осталось:
1. Полная спецификация форматов UI layout и контролов.
2. Полный контракт ресурсов шрифтов и text-rendering поведения.
3. Формат minimap-данных и правила трансформации координат.
4. UI parity-тесты (скриншотные и событийные).

96
docs/specs/wear.md Normal file
View File

@@ -0,0 +1,96 @@
# Wear table (`WEAR`)
`WEAR` — текстовый ресурс, который связывает слоты wear с именами материалов и lightmap.
Связанные страницы:
- [Material (`MAT0`)](material.md)
- [Texture (`Texm`)](texture.md)
## 1. Контейнер
- Тип ресурса: `0x52414557` (`WEAR`).
- Обычно хранится как `*.wea` внутри world/mission архивов.
## 2. Формат текста
```text
<wearCount:int>
<legacyId:int> <materialName>
... (wearCount строк)
[пустая строка]
[LIGHTMAPS
<lightmapCount:int>
<legacyId:int> <lightmapName>
... (lightmapCount строк)]
```
`legacyId` читается, но логика выбора работает по имени.
## 3. Совместимость парсинга
В движке используются два режима чтения (`из файла` и `из буфера`), у которых различается обработка блока `LIGHTMAPS`.
Практическое правило для полного совпадения:
- если присутствует блок `LIGHTMAPS`, перед строкой `LIGHTMAPS` должна быть пустая строка-разделитель.
## 4. Runtime-ограничения
- Число wear-таблиц в менеджере ограничено: максимум `70`.
- Для `wearCount <= 0` ресурс считается некорректным.
- Для `LIGHTMAPS` блока `lightmapCount <= 0` — также ошибка формата.
## 5. Поведение резолва
### 5.1. Материал
Для каждого wear-слота:
1. Ищется материал по имени.
2. Если не найден — используется fallback (`DEFAULT`, затем индекс 0).
### 5.2. Lightmap
Для каждого lightmap-слота:
1. Ищется текстура lightmap по имени.
2. Если не найдено — слот получает `-1`.
## 6. Handle-кодирование
Движок кодирует ссылку на material-slot как:
```c
handle = (tableIndex << 16) | wearIndex
```
- `tableIndex` — номер wear-таблицы.
- `wearIndex` — индекс строки внутри таблицы.
## 7. Правила writer/editor
1. Сохранять порядок строк.
2. Не переставлять и не нормализовать `legacyId`.
3. Для совместимости buffer-парсинга сохранять пустую строку перед `LIGHTMAPS`.
4. Проверять, что число строк соответствует `wearCount`/`lightmapCount`.
## 8. Статус валидации
- Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном.
- Корпусные проверки связки `WEAR -> MAT0 -> Texm` включены в текущий валидаторный контур проекта.
## 9. Статус покрытия и что осталось до 100%
Закрыто:
1. Текстовый формат `WEAR`, включая блок `LIGHTMAPS`.
2. Handle-кодирование material slot и fallback-резолв.
3. Правила совместимого writer/editor path.
Осталось:
1. Полная спецификация edge-case форматов строк (кодировки, редкие разделители, возможные legacy-варианты).
2. Формализация всех ограничений менеджера wear-таблиц в runtime (лимиты и политики вытеснения).
3. Интеграционные parity-тесты на полном цикле «модель -> wear -> material -> texture/lightmap».

View File

@@ -1,16 +0,0 @@
[package]
name = "libnres"
version = "0.1.4"
description = "Library for NRes files"
authors = ["Valentin Popov <valentin@popov.link>"]
homepage = "https://git.popov.link/valentineus/fparkan"
repository = "https://git.popov.link/valentineus/fparkan.git"
license = "GPL-2.0"
edition = "2021"
keywords = ["gamedev", "library", "nres"]
[dependencies]
byteorder = "1.4"
log = "0.4"
miette = "5.6"
thiserror = "1.0"

View File

@@ -1,25 +0,0 @@
# Library for NRes files (Deprecated)
Library for viewing and retrieving game resources of the game **"Parkan: Iron Strategy"**.
All versions of the game are supported: Demo, IS, IS: Part 1, IS: Part 2.
Supports files with `lib`, `trf`, `rlb` extensions.
The files `gamefont.rlb` and `sprites.lib` are not supported.
This files have an unknown signature.
## Example
Example of extracting game resources:
```rust
fn main() {
let file = std::fs::File::open("./voices.lib").unwrap();
// Extracting the list of files
let list = libnres::reader::get_list(&file).unwrap();
for element in list {
// Extracting the contents of the file
let data = libnres::reader::get_file(&file, &element).unwrap();
}
}
```

View File

@@ -1,33 +0,0 @@
use crate::error::ConverterError;
/// Method for converting u32 to u64.
pub fn u32_to_u64(value: u32) -> Result<u64, ConverterError> {
match u64::try_from(value) {
Err(error) => Err(ConverterError::Infallible(error)),
Ok(result) => Ok(result),
}
}
/// Method for converting u32 to usize.
pub fn u32_to_usize(value: u32) -> Result<usize, ConverterError> {
match usize::try_from(value) {
Err(error) => Err(ConverterError::TryFromIntError(error)),
Ok(result) => Ok(result),
}
}
/// Method for converting u64 to u32.
pub fn u64_to_u32(value: u64) -> Result<u32, ConverterError> {
match u32::try_from(value) {
Err(error) => Err(ConverterError::TryFromIntError(error)),
Ok(result) => Ok(result),
}
}
/// Method for converting usize to u32.
pub fn usize_to_u32(value: usize) -> Result<u32, ConverterError> {
match u32::try_from(value) {
Err(error) => Err(ConverterError::TryFromIntError(error)),
Ok(result) => Ok(result),
}
}

View File

@@ -1,45 +0,0 @@
extern crate miette;
extern crate thiserror;
use miette::Diagnostic;
use thiserror::Error;
#[derive(Error, Diagnostic, Debug)]
pub enum ConverterError {
#[error("error converting an value")]
#[diagnostic(code(libnres::infallible))]
Infallible(#[from] std::convert::Infallible),
#[error("error converting an value")]
#[diagnostic(code(libnres::try_from_int_error))]
TryFromIntError(#[from] std::num::TryFromIntError),
}
#[derive(Error, Diagnostic, Debug)]
pub enum ReaderError {
#[error(transparent)]
#[diagnostic(code(libnres::convert_error))]
ConvertValue(#[from] ConverterError),
#[error("incorrect header format")]
#[diagnostic(code(libnres::list_type_error))]
IncorrectHeader,
#[error("incorrect file size (expected {expected:?} bytes, received {received:?} bytes)")]
#[diagnostic(code(libnres::file_size_error))]
IncorrectSizeFile { expected: u32, received: u32 },
#[error(
"incorrect size of the file list (not a multiple of {expected:?}, received {received:?})"
)]
#[diagnostic(code(libnres::list_size_error))]
IncorrectSizeList { expected: u32, received: u32 },
#[error("resource file reading error")]
#[diagnostic(code(libnres::io_error))]
ReadFile(#[from] std::io::Error),
#[error("file is too small (must be at least {expected:?} bytes, received {received:?} byte)")]
#[diagnostic(code(libnres::file_size_error))]
SmallFile { expected: u32, received: u32 },
}

View File

@@ -1,24 +0,0 @@
/// First constant value of the NRes file ("NRes" characters in numeric)
pub const FILE_TYPE_1: u32 = 1936020046;
/// Second constant value of the NRes file
pub const FILE_TYPE_2: u32 = 256;
/// Size of the element item (in bytes)
pub const LIST_ELEMENT_SIZE: u32 = 64;
/// Minimum allowed file size (in bytes)
pub const MINIMUM_FILE_SIZE: u32 = 16;
static DEBUG: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
mod converter;
mod error;
pub mod reader;
/// Get debug status value
pub fn get_debug() -> bool {
DEBUG.load(std::sync::atomic::Ordering::Relaxed)
}
/// Change debug status value
pub fn set_debug(value: bool) {
DEBUG.store(value, std::sync::atomic::Ordering::Relaxed)
}

View File

@@ -1,227 +0,0 @@
use std::io::{Read, Seek};
use byteorder::ByteOrder;
use crate::error::ReaderError;
use crate::{converter, FILE_TYPE_1, FILE_TYPE_2, LIST_ELEMENT_SIZE, MINIMUM_FILE_SIZE};
#[derive(Debug)]
pub struct ListElement {
/// Unknown parameter
_unknown0: i32,
/// Unknown parameter
_unknown1: i32,
/// Unknown parameter
_unknown2: i32,
/// File extension
pub extension: String,
/// Identifier or sequence number
pub index: u32,
/// File name
pub name: String,
/// Position in the file
pub position: u32,
/// File size (in bytes)
pub size: u32,
}
impl ListElement {
/// Get full name of the file
pub fn get_filename(&self) -> String {
format!("{}.{}", self.name, self.extension)
}
}
#[derive(Debug)]
pub struct FileHeader {
/// File size
size: u32,
/// Number of files
total: u32,
/// First constant value
type1: u32,
/// Second constant value
type2: u32,
}
/// Get a packed file data
pub fn get_file(file: &std::fs::File, element: &ListElement) -> Result<Vec<u8>, ReaderError> {
let size = get_file_size(file)?;
check_file_size(size)?;
let header = get_file_header(file)?;
check_file_header(&header, size)?;
let data = get_element_data(file, element)?;
Ok(data)
}
/// Get a list of packed files
pub fn get_list(file: &std::fs::File) -> Result<Vec<ListElement>, ReaderError> {
let mut list: Vec<ListElement> = Vec::new();
let size = get_file_size(file)?;
check_file_size(size)?;
let header = get_file_header(file)?;
check_file_header(&header, size)?;
get_file_list(file, &header, &mut list)?;
Ok(list)
}
fn check_file_header(header: &FileHeader, size: u32) -> Result<(), ReaderError> {
if header.type1 != FILE_TYPE_1 || header.type2 != FILE_TYPE_2 {
return Err(ReaderError::IncorrectHeader);
}
if header.size != size {
return Err(ReaderError::IncorrectSizeFile {
expected: size,
received: header.size,
});
}
Ok(())
}
fn check_file_size(size: u32) -> Result<(), ReaderError> {
if size < MINIMUM_FILE_SIZE {
return Err(ReaderError::SmallFile {
expected: MINIMUM_FILE_SIZE,
received: size,
});
}
Ok(())
}
fn get_element_data(file: &std::fs::File, element: &ListElement) -> Result<Vec<u8>, ReaderError> {
let position = converter::u32_to_u64(element.position)?;
let size = converter::u32_to_usize(element.size)?;
let mut reader = std::io::BufReader::new(file);
let mut buffer = vec![0u8; size];
if let Err(error) = reader.seek(std::io::SeekFrom::Start(position)) {
return Err(ReaderError::ReadFile(error));
};
if let Err(error) = reader.read_exact(&mut buffer) {
return Err(ReaderError::ReadFile(error));
};
Ok(buffer)
}
fn get_element_position(index: u32) -> Result<(usize, usize), ReaderError> {
let from = converter::u32_to_usize(index * LIST_ELEMENT_SIZE)?;
let to = converter::u32_to_usize((index * LIST_ELEMENT_SIZE) + LIST_ELEMENT_SIZE)?;
Ok((from, to))
}
fn get_file_header(file: &std::fs::File) -> Result<FileHeader, ReaderError> {
let mut reader = std::io::BufReader::new(file);
let mut buffer = vec![0u8; MINIMUM_FILE_SIZE as usize];
if let Err(error) = reader.seek(std::io::SeekFrom::Start(0)) {
return Err(ReaderError::ReadFile(error));
};
if let Err(error) = reader.read_exact(&mut buffer) {
return Err(ReaderError::ReadFile(error));
};
let header = FileHeader {
size: byteorder::LittleEndian::read_u32(&buffer[12..16]),
total: byteorder::LittleEndian::read_u32(&buffer[8..12]),
type1: byteorder::LittleEndian::read_u32(&buffer[0..4]),
type2: byteorder::LittleEndian::read_u32(&buffer[4..8]),
};
buffer.clear();
Ok(header)
}
fn get_file_list(
file: &std::fs::File,
header: &FileHeader,
list: &mut Vec<ListElement>,
) -> Result<(), ReaderError> {
let (start_position, list_size) = get_list_position(header)?;
let mut reader = std::io::BufReader::new(file);
let mut buffer = vec![0u8; list_size];
if let Err(error) = reader.seek(std::io::SeekFrom::Start(start_position)) {
return Err(ReaderError::ReadFile(error));
};
if let Err(error) = reader.read_exact(&mut buffer) {
return Err(ReaderError::ReadFile(error));
}
let buffer_size = converter::usize_to_u32(buffer.len())?;
if buffer_size % LIST_ELEMENT_SIZE != 0 {
return Err(ReaderError::IncorrectSizeList {
expected: LIST_ELEMENT_SIZE,
received: buffer_size,
});
}
for i in 0..(buffer_size / LIST_ELEMENT_SIZE) {
let (from, to) = get_element_position(i)?;
let chunk: &[u8] = &buffer[from..to];
let element = get_list_element(chunk)?;
list.push(element);
}
buffer.clear();
Ok(())
}
fn get_file_size(file: &std::fs::File) -> Result<u32, ReaderError> {
let metadata = match file.metadata() {
Err(error) => return Err(ReaderError::ReadFile(error)),
Ok(value) => value,
};
let result = converter::u64_to_u32(metadata.len())?;
Ok(result)
}
fn get_list_element(buffer: &[u8]) -> Result<ListElement, ReaderError> {
let index = byteorder::LittleEndian::read_u32(&buffer[60..64]);
let position = byteorder::LittleEndian::read_u32(&buffer[56..60]);
let size = byteorder::LittleEndian::read_u32(&buffer[12..16]);
let unknown0 = byteorder::LittleEndian::read_i32(&buffer[4..8]);
let unknown1 = byteorder::LittleEndian::read_i32(&buffer[8..12]);
let unknown2 = byteorder::LittleEndian::read_i32(&buffer[16..20]);
let extension = String::from_utf8_lossy(&buffer[0..4])
.trim_matches(char::from(0))
.to_string();
let name = String::from_utf8_lossy(&buffer[20..56])
.trim_matches(char::from(0))
.to_string();
Ok(ListElement {
_unknown0: unknown0,
_unknown1: unknown1,
_unknown2: unknown2,
extension,
index,
name,
position,
size,
})
}
fn get_list_position(header: &FileHeader) -> Result<(u64, usize), ReaderError> {
let position = converter::u32_to_u64(header.size - (header.total * LIST_ELEMENT_SIZE))?;
let size = converter::u32_to_usize(header.total * LIST_ELEMENT_SIZE)?;
Ok((position, size))
}

54
mkdocs.yml Normal file
View File

@@ -0,0 +1,54 @@
# Project information
site_name: FParkan
site_url: https://fparkan.popov.link/
site_author: Valentin Popov
site_description: >-
Utilities and tools for the game “Parkan: Iron Strategy”.
# Repository
repo_name: valentineus/fparkan
repo_url: https://github.com/valentineus/fparkan
# Copyright
copyright: Copyright &copy; 2023 &mdash; 2026 Valentin Popov
# Configuration
theme:
name: material
language: ru
palette:
scheme: slate
# Navigation
nav:
- Home: index.md
- Specs:
- 3D implementation notes: specs/msh-notes.md
- AI system: specs/ai.md
- ArealMap: specs/arealmap.md
- Behavior system: specs/behavior.md
- Control system: specs/control.md
- FXID: specs/fxid.md
- Material (MAT0): specs/material.md
- Wear (WEAR): specs/wear.md
- Texture (Texm): specs/texture.md
- Materials index: specs/materials-texm.md
- Missions: specs/missions.md
- Object registry (objects.rlb): specs/object-registry.md
- MSH animation: specs/msh-animation.md
- MSH core: specs/msh-core.md
- Network system: specs/network.md
- NRes / RsLi: specs/nres.md
- Render pipeline: specs/render.md
- Render parity: specs/render-parity.md
- Runtime pointer: specs/runtime-pipeline.md
- Sound system: specs/sound.md
- Terrain + map loading: specs/terrain-map-loading.md
- UI system: specs/ui.md
- Форматы 3Dресурсов (обзор): specs/msh.md
# Additional configuration
extra:
social:
- icon: fontawesome/brands/github
link: https://github.com/valentineus/fparkan

View File

@@ -1,20 +0,0 @@
[package]
name = "nres-cli"
version = "0.2.3"
description = "Console tool for NRes files"
authors = ["Valentin Popov <valentin@popov.link>"]
homepage = "https://git.popov.link/valentineus/fparkan"
repository = "https://git.popov.link/valentineus/fparkan.git"
license = "GPL-2.0"
edition = "2021"
keywords = ["cli", "gamedev", "nres"]
[dependencies]
byteorder = "1.4"
clap = { version = "4.2", features = ["derive"] }
console = "0.15"
dialoguer = { version = "0.10", features = ["completion"] }
indicatif = "0.17"
libnres = { version = "0.1", path = "../libnres" }
miette = { version = "5.6", features = ["fancy"] }
tempdir = "0.3"

View File

@@ -1,6 +0,0 @@
# Console tool for NRes files (Deprecated)
## Commands
- `extract` - Extract game resources from a "NRes" file.
- `ls` - Get a list of files in a "NRes" file.

View File

@@ -1,198 +0,0 @@
extern crate core;
extern crate libnres;
use std::io::Write;
use clap::{Parser, Subcommand};
use miette::{IntoDiagnostic, Result};
#[derive(Parser, Debug)]
#[command(name = "NRes CLI")]
#[command(about, author, version, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
/// Check if the "NRes" file can be extract
Check {
/// "NRes" file
file: String,
},
/// Print debugging information on the "NRes" file
#[command(arg_required_else_help = true)]
Debug {
/// "NRes" file
file: String,
/// Filter results by file name
#[arg(long)]
name: Option<String>,
},
/// Extract files or a file from the "NRes" file
#[command(arg_required_else_help = true)]
Extract {
/// "NRes" file
file: String,
/// Overwrite files
#[arg(short, long, default_value_t = false, value_name = "TRUE|FALSE")]
force: bool,
/// Outbound directory
#[arg(short, long, value_name = "DIR")]
out: String,
},
/// Print a list of files in the "NRes" file
#[command(arg_required_else_help = true)]
Ls {
/// "NRes" file
file: String,
},
}
pub fn main() -> Result<()> {
let stdout = console::Term::stdout();
let cli = Cli::parse();
match cli.command {
Commands::Check { file } => command_check(stdout, file)?,
Commands::Debug { file, name } => command_debug(stdout, file, name)?,
Commands::Extract { file, force, out } => command_extract(stdout, file, out, force)?,
Commands::Ls { file } => command_ls(stdout, file)?,
}
Ok(())
}
fn command_check(_stdout: console::Term, file: String) -> Result<()> {
let file = std::fs::File::open(file).into_diagnostic()?;
let list = libnres::reader::get_list(&file).into_diagnostic()?;
let tmp = tempdir::TempDir::new("nres").into_diagnostic()?;
let bar = indicatif::ProgressBar::new(list.len() as u64);
bar.set_style(get_bar_style()?);
for element in list {
bar.set_message(element.get_filename());
let path = tmp.path().join(element.get_filename());
let mut output = std::fs::File::create(path).into_diagnostic()?;
let mut buffer = libnres::reader::get_file(&file, &element).into_diagnostic()?;
output.write_all(&buffer).into_diagnostic()?;
buffer.clear();
bar.inc(1);
}
bar.finish();
Ok(())
}
fn command_debug(stdout: console::Term, file: String, name: Option<String>) -> Result<()> {
let file = std::fs::File::open(file).into_diagnostic()?;
let mut list = libnres::reader::get_list(&file).into_diagnostic()?;
let mut total_files_size: u32 = 0;
let mut total_files_gap: u32 = 0;
let mut total_files: u32 = 0;
for (index, item) in list.iter().enumerate() {
total_files_size += item.size;
total_files += 1;
let mut gap = 0;
if index > 1 {
let previous_item = &list[index - 1];
gap = item.position - (previous_item.position + previous_item.size);
}
total_files_gap += gap;
}
if let Some(name) = name {
list.retain(|item| item.name.contains(&name));
};
for (index, item) in list.iter().enumerate() {
let mut gap = 0;
if index > 1 {
let previous_item = &list[index - 1];
gap = item.position - (previous_item.position + previous_item.size);
}
let text = format!("Index: {};\nGap: {};\nItem: {:#?};\n", index, gap, item);
stdout.write_line(&text).into_diagnostic()?;
}
let text = format!(
"Total files: {};\nTotal files gap: {} (bytes);\nTotal files size: {} (bytes);",
total_files, total_files_gap, total_files_size
);
stdout.write_line(&text).into_diagnostic()?;
Ok(())
}
fn command_extract(_stdout: console::Term, file: String, out: String, force: bool) -> Result<()> {
let file = std::fs::File::open(file).into_diagnostic()?;
let list = libnres::reader::get_list(&file).into_diagnostic()?;
let bar = indicatif::ProgressBar::new(list.len() as u64);
bar.set_style(get_bar_style()?);
for element in list {
bar.set_message(element.get_filename());
let path = format!("{}/{}", out, element.get_filename());
if !force && is_exist_file(&path) {
let message = format!("File \"{}\" exists. Overwrite it?", path);
if !dialoguer::Confirm::new()
.with_prompt(message)
.interact()
.into_diagnostic()?
{
continue;
}
}
let mut output = std::fs::File::create(path).into_diagnostic()?;
let mut buffer = libnres::reader::get_file(&file, &element).into_diagnostic()?;
output.write_all(&buffer).into_diagnostic()?;
buffer.clear();
bar.inc(1);
}
bar.finish();
Ok(())
}
fn command_ls(stdout: console::Term, file: String) -> Result<()> {
let file = std::fs::File::open(file).into_diagnostic()?;
let list = libnres::reader::get_list(&file).into_diagnostic()?;
for element in list {
stdout.write_line(&element.name).into_diagnostic()?;
}
Ok(())
}
fn get_bar_style() -> Result<indicatif::ProgressStyle> {
Ok(
indicatif::ProgressStyle::with_template("[{bar:32}] {pos:>7}/{len:7} {msg}")
.into_diagnostic()?
.progress_chars("=>-"),
)
}
fn is_exist_file(path: &String) -> bool {
let metadata = std::path::Path::new(path);
metadata.exists()
}

View File

@@ -1,9 +0,0 @@
[package]
name = "packer"
version = "0.1.0"
edition = "2021"
[dependencies]
byteorder = "1.4.3"
serde = { version = "1.0.160", features = ["derive"] }
serde_json = "1.0.96"

View File

@@ -1,27 +0,0 @@
# NRes Game Resource Packer
At the moment, this is a demonstration of the NRes game resource packing algorithm in action.
It packs 100% of the NRes game resources for the game "Parkan: Iron Strategy".
The hash sums of the resulting files match the original game files.
__Attention!__
This is a test version of the utility. It overwrites the specified final file without asking.
## Building
To build the tools, you need to run the following command in the root directory:
```bash
cargo build --release
```
## Running
You can run the utility with the following command:
```bash
./target/release/packer /path/to/unpack /path/to/file.ex
```
- `/path/to/unpack`: This is the directory with the resources unpacked by the [unpacker](../unpacker) utility.
- `/path/to/file.ex`: This is the final file that will be created.

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