Compare commits
1 Commits
c69cad6a26
...
f1bd98083b
| Author | SHA1 | Date | |
|---|---|---|---|
| f1bd98083b |
@@ -2,8 +2,14 @@
|
|||||||
"image": "mcr.microsoft.com/devcontainers/rust:latest",
|
"image": "mcr.microsoft.com/devcontainers/rust:latest",
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": ["rust-lang.rust-analyzer"]
|
"extensions": [
|
||||||
|
"rust-lang.rust-analyzer"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"]
|
"runArgs": [
|
||||||
}
|
"--cap-add=SYS_PTRACE",
|
||||||
|
"--security-opt",
|
||||||
|
"seccomp=unconfined"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ name: RenovateBot
|
|||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: "@daily"
|
- cron: "@daily"
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
renovate:
|
renovate:
|
||||||
|
|||||||
@@ -3,25 +3,11 @@ name: Test
|
|||||||
on: [push, pull_request]
|
on: [push, pull_request]
|
||||||
|
|
||||||
jobs:
|
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:
|
test:
|
||||||
name: Test
|
name: cargo test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: lint
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- name: Cargo test
|
- run: cargo check --all
|
||||||
run: cargo test --workspace --all-features -- --nocapture
|
- run: cargo test --all-features
|
||||||
|
|||||||
14
.github/dependabot.yml
vendored
Normal file
14
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "cargo"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
- package-ecosystem: "devcontainers"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
219
.gitignore
vendored
219
.gitignore
vendored
@@ -1,218 +1 @@
|
|||||||
*~
|
/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
|
|
||||||
|
|||||||
1710
Cargo.lock
generated
Normal file
1710
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "3"
|
resolver = "2"
|
||||||
members = ["crates/*"]
|
members = ["libs/*", "tools/*", "packer"]
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
codegen-units = 1
|
codegen-units = 1
|
||||||
|
|||||||
60
README.md
60
README.md
@@ -1,55 +1,11 @@
|
|||||||
# FParkan
|
# Utilities for the game "Parkan: Iron Strategy"
|
||||||
|
|
||||||
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования.
|
This repository contains utilities, tools, and libraries for the game "Parkan: Iron Strategy."
|
||||||
|
|
||||||
## Описание
|
## 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/issues)
|
|
||||||
- **GitHub mirror**: [valentineus/fparkan](https://github.com/valentineus/fparkan/issues)
|
|
||||||
|
|
||||||
Основная разработка ведётся в self-hosted репозитории.
|
|
||||||
|
|
||||||
## Лицензия
|
|
||||||
|
|
||||||
Проект распространяется под лицензией **[GNU GPL v2](LICENSE.txt)**.
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "nres"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# nres
|
|
||||||
|
|
||||||
Rust-библиотека для работы с архивами формата **NRes**.
|
|
||||||
|
|
||||||
## Что умеет
|
|
||||||
|
|
||||||
- Открытие архива из файла (`open_path`) и из памяти (`open_bytes`).
|
|
||||||
- Поддержка `raw_mode` (весь файл как единый ресурс).
|
|
||||||
- Чтение метаданных и итерация по записям.
|
|
||||||
- Поиск по имени без учёта регистра (`find`).
|
|
||||||
- Чтение данных ресурса (`read`, `read_into`, `raw_slice`).
|
|
||||||
- Редактирование архива через `Editor`:
|
|
||||||
- `add`, `replace_data`, `remove`.
|
|
||||||
- `commit` с пересчётом `sort_index`, выравниванием по 8 байт и атомарной записью файла.
|
|
||||||
|
|
||||||
## Модель ошибок
|
|
||||||
|
|
||||||
Библиотека возвращает типизированные ошибки (`InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `DirectoryOutOfBounds`, `EntryDataOutOfBounds`, и др.) без паник в production-коде.
|
|
||||||
|
|
||||||
## Покрытие тестами
|
|
||||||
|
|
||||||
### Реальные файлы
|
|
||||||
|
|
||||||
- Рекурсивный прогон по `testdata/nres/**`.
|
|
||||||
- Сейчас в наборе: **120 архивов**.
|
|
||||||
- Для каждого архива проверяется:
|
|
||||||
- чтение всех записей;
|
|
||||||
- `read`/`read_into`/`raw_slice`;
|
|
||||||
- `find`;
|
|
||||||
- `unpack -> repack (Editor::commit)` с проверкой **byte-to-byte**.
|
|
||||||
|
|
||||||
### Синтетические тесты
|
|
||||||
|
|
||||||
- Проверка основных сценариев редактирования (`add/replace/remove/commit`).
|
|
||||||
- Проверка валидации и ошибок:
|
|
||||||
- `InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `InvalidEntryCount`, `DirectoryOutOfBounds`, `NameTooLong`, `EntryDataOutOfBounds`, `EntryIdOutOfRange`, `NameContainsNul`.
|
|
||||||
|
|
||||||
## Быстрый запуск тестов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo test -p nres -- --nocapture
|
|
||||||
```
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
use std::io;
|
|
||||||
|
|
||||||
/// Resource payload that can be either borrowed from mapped bytes or owned.
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub enum ResourceData<'a> {
|
|
||||||
Borrowed(&'a [u8]),
|
|
||||||
Owned(Vec<u8>),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> ResourceData<'a> {
|
|
||||||
pub fn as_slice(&self) -> &[u8] {
|
|
||||||
match self {
|
|
||||||
Self::Borrowed(slice) => slice,
|
|
||||||
Self::Owned(buf) => buf.as_slice(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn into_owned(self) -> Vec<u8> {
|
|
||||||
match self {
|
|
||||||
Self::Borrowed(slice) => slice.to_vec(),
|
|
||||||
Self::Owned(buf) => buf,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AsRef<[u8]> for ResourceData<'_> {
|
|
||||||
fn as_ref(&self) -> &[u8] {
|
|
||||||
self.as_slice()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Output sink used by `read_into`/`load_into` APIs.
|
|
||||||
pub trait OutputBuffer {
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
use core::fmt;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum Error {
|
|
||||||
Io(std::io::Error),
|
|
||||||
|
|
||||||
InvalidMagic {
|
|
||||||
got: [u8; 4],
|
|
||||||
},
|
|
||||||
UnsupportedVersion {
|
|
||||||
got: u32,
|
|
||||||
},
|
|
||||||
TotalSizeMismatch {
|
|
||||||
header: u32,
|
|
||||||
actual: u64,
|
|
||||||
},
|
|
||||||
|
|
||||||
InvalidEntryCount {
|
|
||||||
got: i32,
|
|
||||||
},
|
|
||||||
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::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 {}
|
|
||||||
@@ -1,624 +0,0 @@
|
|||||||
pub mod data;
|
|
||||||
pub mod error;
|
|
||||||
|
|
||||||
use crate::data::{OutputBuffer, ResourceData};
|
|
||||||
use crate::error::Error;
|
|
||||||
use core::ops::Range;
|
|
||||||
use std::cmp::Ordering;
|
|
||||||
use std::fs::{self, OpenOptions as FsOpenOptions};
|
|
||||||
use std::io::Write;
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
pub type Result<T> = core::result::Result<T, Error>;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub struct OpenOptions {
|
|
||||||
pub raw_mode: bool,
|
|
||||||
pub sequential_hint: bool,
|
|
||||||
pub prefetch_pages: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub enum OpenMode {
|
|
||||||
#[default]
|
|
||||||
ReadOnly,
|
|
||||||
ReadWrite,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Archive {
|
|
||||||
bytes: Arc<[u8]>,
|
|
||||||
entries: Vec<EntryRecord>,
|
|
||||||
raw_mode: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
|
||||||
pub struct EntryId(pub u32);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct EntryMeta {
|
|
||||||
pub kind: u32,
|
|
||||||
pub attr1: u32,
|
|
||||||
pub attr2: u32,
|
|
||||||
pub attr3: u32,
|
|
||||||
pub name: String,
|
|
||||||
pub data_offset: u64,
|
|
||||||
pub data_size: u32,
|
|
||||||
pub sort_index: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug)]
|
|
||||||
pub struct EntryRef<'a> {
|
|
||||||
pub id: EntryId,
|
|
||||||
pub meta: &'a EntryMeta,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct EntryRecord {
|
|
||||||
meta: EntryMeta,
|
|
||||||
name_raw: [u8; 36],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Archive {
|
|
||||||
pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
Self::open_path_with(path, OpenMode::ReadOnly, OpenOptions::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_path_with(
|
|
||||||
path: impl AsRef<Path>,
|
|
||||||
_mode: OpenMode,
|
|
||||||
opts: OpenOptions,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let bytes = fs::read(path.as_ref())?;
|
|
||||||
let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
|
|
||||||
Self::open_bytes(arc, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_bytes(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Self> {
|
|
||||||
let (entries, _) = parse_archive(&bytes, opts.raw_mode)?;
|
|
||||||
if opts.prefetch_pages {
|
|
||||||
prefetch_pages(&bytes);
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
bytes,
|
|
||||||
entries,
|
|
||||||
raw_mode: opts.raw_mode,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn entry_count(&self) -> usize {
|
|
||||||
self.entries.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
|
||||||
self.entries
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, entry)| EntryRef {
|
|
||||||
id: EntryId(idx as u32),
|
|
||||||
meta: &entry.meta,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find(&self, name: &str) -> Option<EntryId> {
|
|
||||||
if self.entries.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if !self.raw_mode {
|
|
||||||
let mut low = 0usize;
|
|
||||||
let mut high = self.entries.len();
|
|
||||||
while low < high {
|
|
||||||
let mid = low + (high - low) / 2;
|
|
||||||
let target_idx = self.entries[mid].meta.sort_index as usize;
|
|
||||||
if target_idx >= self.entries.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let cmp = cmp_name_case_insensitive(
|
|
||||||
name.as_bytes(),
|
|
||||||
entry_name_bytes(&self.entries[target_idx].name_raw),
|
|
||||||
);
|
|
||||||
match cmp {
|
|
||||||
Ordering::Less => high = mid,
|
|
||||||
Ordering::Greater => low = mid + 1,
|
|
||||||
Ordering::Equal => return Some(EntryId(target_idx as u32)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.entries.iter().enumerate().find_map(|(idx, entry)| {
|
|
||||||
if cmp_name_case_insensitive(name.as_bytes(), entry_name_bytes(&entry.name_raw))
|
|
||||||
== Ordering::Equal
|
|
||||||
{
|
|
||||||
Some(EntryId(idx as u32))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
|
|
||||||
let idx = usize::try_from(id.0).ok()?;
|
|
||||||
let entry = self.entries.get(idx)?;
|
|
||||||
Some(EntryRef {
|
|
||||||
id,
|
|
||||||
meta: &entry.meta,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read(&self, id: EntryId) -> Result<ResourceData<'_>> {
|
|
||||||
let range = self.entry_range(id)?;
|
|
||||||
Ok(ResourceData::Borrowed(&self.bytes[range]))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn read_into(&self, id: EntryId, out: &mut dyn OutputBuffer) -> Result<usize> {
|
|
||||||
let range = self.entry_range(id)?;
|
|
||||||
out.write_exact(&self.bytes[range.clone()])?;
|
|
||||||
Ok(range.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn raw_slice(&self, id: EntryId) -> Result<Option<&[u8]>> {
|
|
||||||
let range = self.entry_range(id)?;
|
|
||||||
Ok(Some(&self.bytes[range]))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn edit_path(path: impl AsRef<Path>) -> Result<Editor> {
|
|
||||||
let path_buf = path.as_ref().to_path_buf();
|
|
||||||
let bytes = fs::read(&path_buf)?;
|
|
||||||
let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
|
|
||||||
let (entries, _) = parse_archive(&arc, false)?;
|
|
||||||
let mut editable = Vec::with_capacity(entries.len());
|
|
||||||
for entry in &entries {
|
|
||||||
let range = checked_range(entry.meta.data_offset, entry.meta.data_size, arc.len())?;
|
|
||||||
editable.push(EditableEntry {
|
|
||||||
meta: entry.meta.clone(),
|
|
||||||
name_raw: entry.name_raw,
|
|
||||||
data: arc[range].to_vec(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(Editor {
|
|
||||||
path: path_buf,
|
|
||||||
entries: editable,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn entry_range(&self, id: EntryId) -> Result<Range<usize>> {
|
|
||||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
let Some(entry) = self.entries.get(idx) else {
|
|
||||||
return Err(Error::EntryIdOutOfRange {
|
|
||||||
id: id.0,
|
|
||||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
checked_range(
|
|
||||||
entry.meta.data_offset,
|
|
||||||
entry.meta.data_size,
|
|
||||||
self.bytes.len(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Editor {
|
|
||||||
path: PathBuf,
|
|
||||||
entries: Vec<EditableEntry>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct EditableEntry {
|
|
||||||
meta: EntryMeta,
|
|
||||||
name_raw: [u8; 36],
|
|
||||||
data: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct NewEntry<'a> {
|
|
||||||
pub kind: u32,
|
|
||||||
pub attr1: u32,
|
|
||||||
pub attr2: u32,
|
|
||||||
pub attr3: u32,
|
|
||||||
pub name: &'a str,
|
|
||||||
pub data: &'a [u8],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Editor {
|
|
||||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
|
||||||
self.entries
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, entry)| EntryRef {
|
|
||||||
id: EntryId(idx as u32),
|
|
||||||
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: entry.data.to_vec(),
|
|
||||||
});
|
|
||||||
Ok(EntryId(id_u32))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn replace_data(&mut self, id: EntryId, data: &[u8]) -> Result<()> {
|
|
||||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
let Some(entry) = self.entries.get_mut(idx) else {
|
|
||||||
return Err(Error::EntryIdOutOfRange {
|
|
||||||
id: id.0,
|
|
||||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
entry.meta.data_size = u32::try_from(data.len()).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
entry.data.clear();
|
|
||||||
entry.data.extend_from_slice(data);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove(&mut self, id: EntryId) -> Result<()> {
|
|
||||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
if idx >= self.entries.len() {
|
|
||||||
return Err(Error::EntryIdOutOfRange {
|
|
||||||
id: id.0,
|
|
||||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.entries.remove(idx);
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn commit(mut self) -> Result<()> {
|
|
||||||
let count_u32 = u32::try_from(self.entries.len()).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
let mut out = vec![0; 16];
|
|
||||||
|
|
||||||
for entry in &mut self.entries {
|
|
||||||
entry.meta.data_offset =
|
|
||||||
u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
entry.meta.data_size =
|
|
||||||
u32::try_from(entry.data.len()).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
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..self.entries.len()).collect();
|
|
||||||
sort_order.sort_by(|a, b| {
|
|
||||||
cmp_name_case_insensitive(
|
|
||||||
entry_name_bytes(&self.entries[*a].name_raw),
|
|
||||||
entry_name_bytes(&self.entries[*b].name_raw),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
for (idx, entry) in self.entries.iter_mut().enumerate() {
|
|
||||||
entry.meta.sort_index =
|
|
||||||
u32::try_from(sort_order[idx]).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in &self.entries {
|
|
||||||
let data_offset_u32 =
|
|
||||||
u32::try_from(entry.meta.data_offset).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
push_u32(&mut out, entry.meta.kind);
|
|
||||||
push_u32(&mut out, entry.meta.attr1);
|
|
||||||
push_u32(&mut out, entry.meta.attr2);
|
|
||||||
push_u32(&mut out, entry.meta.data_size);
|
|
||||||
push_u32(&mut out, entry.meta.attr3);
|
|
||||||
out.extend_from_slice(&entry.name_raw);
|
|
||||||
push_u32(&mut out, data_offset_u32);
|
|
||||||
push_u32(&mut out, entry.meta.sort_index);
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_size_u32 = u32::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
out[0..4].copy_from_slice(b"NRes");
|
|
||||||
out[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
|
|
||||||
out[8..12].copy_from_slice(&count_u32.to_le_bytes());
|
|
||||||
out[12..16].copy_from_slice(&total_size_u32.to_le_bytes());
|
|
||||||
|
|
||||||
write_atomic(&self.path, &out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_archive(bytes: &[u8], raw_mode: bool) -> Result<(Vec<EntryRecord>, u64)> {
|
|
||||||
if raw_mode {
|
|
||||||
let data_size = u32::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
let entry = EntryRecord {
|
|
||||||
meta: EntryMeta {
|
|
||||||
kind: 0,
|
|
||||||
attr1: 0,
|
|
||||||
attr2: 0,
|
|
||||||
attr3: 0,
|
|
||||||
name: String::from("RAW"),
|
|
||||||
data_offset: 0,
|
|
||||||
data_size,
|
|
||||||
sort_index: 0,
|
|
||||||
},
|
|
||||||
name_raw: {
|
|
||||||
let mut name = [0u8; 36];
|
|
||||||
let bytes_name = b"RAW";
|
|
||||||
name[..bytes_name.len()].copy_from_slice(bytes_name);
|
|
||||||
name
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return Ok((vec![entry], bytes.len() as u64));
|
|
||||||
}
|
|
||||||
|
|
||||||
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)?;
|
|
||||||
|
|
||||||
let total_size = read_u32(bytes, 12)?;
|
|
||||||
let actual_size = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
if u64::from(total_size) != actual_size {
|
|
||||||
return Err(Error::TotalSizeMismatch {
|
|
||||||
header: total_size,
|
|
||||||
actual: actual_size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let directory_len = u64::try_from(entry_count)
|
|
||||||
.map_err(|_| Error::IntegerOverflow)?
|
|
||||||
.checked_mul(64)
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
let directory_offset =
|
|
||||||
u64::from(total_size)
|
|
||||||
.checked_sub(directory_len)
|
|
||||||
.ok_or(Error::DirectoryOutOfBounds {
|
|
||||||
directory_offset: 0,
|
|
||||||
directory_len,
|
|
||||||
file_len: actual_size,
|
|
||||||
})?;
|
|
||||||
|
|
||||||
if directory_offset < 16 || directory_offset + directory_len > actual_size {
|
|
||||||
return Err(Error::DirectoryOutOfBounds {
|
|
||||||
directory_offset,
|
|
||||||
directory_len,
|
|
||||||
file_len: actual_size,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut entries = Vec::with_capacity(entry_count);
|
|
||||||
for index in 0..entry_count {
|
|
||||||
let base = usize::try_from(directory_offset)
|
|
||||||
.map_err(|_| Error::IntegerOverflow)?
|
|
||||||
.checked_add(index.checked_mul(64).ok_or(Error::IntegerOverflow)?)
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
|
|
||||||
let kind = read_u32(bytes, base)?;
|
|
||||||
let attr1 = read_u32(bytes, base + 4)?;
|
|
||||||
let attr2 = read_u32(bytes, base + 8)?;
|
|
||||||
let data_size = read_u32(bytes, base + 12)?;
|
|
||||||
let attr3 = read_u32(bytes, base + 16)?;
|
|
||||||
|
|
||||||
let mut name_raw = [0u8; 36];
|
|
||||||
let name_slice = bytes
|
|
||||||
.get(base + 20..base + 56)
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
name_raw.copy_from_slice(name_slice);
|
|
||||||
|
|
||||||
let name_bytes = entry_name_bytes(&name_raw);
|
|
||||||
if name_bytes.len() > 35 {
|
|
||||||
return Err(Error::NameTooLong {
|
|
||||||
got: name_bytes.len(),
|
|
||||||
max: 35,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let data_offset = u64::from(read_u32(bytes, base + 56)?);
|
|
||||||
let sort_index = read_u32(bytes, base + 60)?;
|
|
||||||
|
|
||||||
let end = data_offset
|
|
||||||
.checked_add(u64::from(data_size))
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
if data_offset < 16 || end > directory_offset {
|
|
||||||
return Err(Error::EntryDataOutOfBounds {
|
|
||||||
id: u32::try_from(index).map_err(|_| Error::IntegerOverflow)?,
|
|
||||||
offset: data_offset,
|
|
||||||
size: data_size,
|
|
||||||
directory_offset,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.push(EntryRecord {
|
|
||||||
meta: EntryMeta {
|
|
||||||
kind,
|
|
||||||
attr1,
|
|
||||||
attr2,
|
|
||||||
attr3,
|
|
||||||
name: decode_name(name_bytes),
|
|
||||||
data_offset,
|
|
||||||
data_size,
|
|
||||||
sort_index,
|
|
||||||
},
|
|
||||||
name_raw,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((entries, directory_offset))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn checked_range(offset: u64, size: u32, bytes_len: usize) -> Result<Range<usize>> {
|
|
||||||
let start = usize::try_from(offset).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
let len = usize::try_from(size).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
let end = start.checked_add(len).ok_or(Error::IntegerOverflow)?;
|
|
||||||
if end > bytes_len {
|
|
||||||
return Err(Error::IntegerOverflow);
|
|
||||||
}
|
|
||||||
Ok(start..end)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> {
|
|
||||||
let data = bytes
|
|
||||||
.get(offset..offset + 4)
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
let arr: [u8; 4] = data.try_into().map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
Ok(u32::from_le_bytes(arr))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn push_u32(out: &mut Vec<u8>, value: u32) {
|
|
||||||
out.extend_from_slice(&value.to_le_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_name_field(name: &str) -> Result<[u8; 36]> {
|
|
||||||
let bytes = name.as_bytes();
|
|
||||||
if bytes.contains(&0) {
|
|
||||||
return Err(Error::NameContainsNul);
|
|
||||||
}
|
|
||||||
if bytes.len() > 35 {
|
|
||||||
return Err(Error::NameTooLong {
|
|
||||||
got: bytes.len(),
|
|
||||||
max: 35,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = [0u8; 36];
|
|
||||||
out[..bytes.len()].copy_from_slice(bytes);
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn entry_name_bytes(raw: &[u8; 36]) -> &[u8] {
|
|
||||||
let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
|
|
||||||
&raw[..len]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_name(name: &[u8]) -> String {
|
|
||||||
name.iter().map(|b| char::from(*b)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cmp_name_case_insensitive(a: &[u8], b: &[u8]) -> Ordering {
|
|
||||||
let mut idx = 0usize;
|
|
||||||
let min_len = a.len().min(b.len());
|
|
||||||
while idx < min_len {
|
|
||||||
let left = ascii_lower(a[idx]);
|
|
||||||
let right = ascii_lower(b[idx]);
|
|
||||||
if left != right {
|
|
||||||
return left.cmp(&right);
|
|
||||||
}
|
|
||||||
idx += 1;
|
|
||||||
}
|
|
||||||
a.len().cmp(&b.len())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ascii_lower(value: u8) -> u8 {
|
|
||||||
if value.is_ascii_uppercase() {
|
|
||||||
value + 32
|
|
||||||
} else {
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn prefetch_pages(bytes: &[u8]) {
|
|
||||||
use std::sync::atomic::{compiler_fence, Ordering};
|
|
||||||
|
|
||||||
let mut cursor = 0usize;
|
|
||||||
let mut sink = 0u8;
|
|
||||||
while cursor < bytes.len() {
|
|
||||||
sink ^= bytes[cursor];
|
|
||||||
cursor = cursor.saturating_add(4096);
|
|
||||||
}
|
|
||||||
compiler_fence(Ordering::SeqCst);
|
|
||||||
let _ = sink;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_atomic(path: &Path, content: &[u8]) -> Result<()> {
|
|
||||||
let file_name = path
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.unwrap_or("archive");
|
|
||||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
|
||||||
|
|
||||||
let mut temp_path = None;
|
|
||||||
for attempt in 0..128u32 {
|
|
||||||
let name = format!(
|
|
||||||
".{}.tmp.{}.{}.{}",
|
|
||||||
file_name,
|
|
||||||
std::process::id(),
|
|
||||||
unix_time_nanos(),
|
|
||||||
attempt
|
|
||||||
);
|
|
||||||
let candidate = parent.join(name);
|
|
||||||
let opened = FsOpenOptions::new()
|
|
||||||
.create_new(true)
|
|
||||||
.write(true)
|
|
||||||
.open(&candidate);
|
|
||||||
if let Ok(mut file) = opened {
|
|
||||||
file.write_all(content)?;
|
|
||||||
file.sync_all()?;
|
|
||||||
temp_path = Some((candidate, file));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((tmp_path, mut file)) = temp_path else {
|
|
||||||
return Err(Error::Io(std::io::Error::new(
|
|
||||||
std::io::ErrorKind::AlreadyExists,
|
|
||||||
"failed to create temporary file for atomic write",
|
|
||||||
)));
|
|
||||||
};
|
|
||||||
|
|
||||||
file.flush()?;
|
|
||||||
drop(file);
|
|
||||||
|
|
||||||
match fs::rename(&tmp_path, path) {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(rename_err) => {
|
|
||||||
if path.exists() {
|
|
||||||
fs::remove_file(path)?;
|
|
||||||
fs::rename(&tmp_path, path)?;
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
let _ = fs::remove_file(&tmp_path);
|
|
||||||
Err(Error::Io(rename_err))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn unix_time_nanos() -> u128 {
|
|
||||||
match SystemTime::now().duration_since(UNIX_EPOCH) {
|
|
||||||
Ok(duration) => duration.as_nanos(),
|
|
||||||
Err(_) => 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,543 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use std::any::Any;
|
|
||||||
use std::fs;
|
|
||||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct SyntheticEntry<'a> {
|
|
||||||
kind: u32,
|
|
||||||
attr1: u32,
|
|
||||||
attr2: u32,
|
|
||||||
attr3: u32,
|
|
||||||
name: &'a str,
|
|
||||||
data: &'a [u8],
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
|
||||||
let Ok(entries) = fs::read_dir(root) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
collect_files_recursive(&path, out);
|
|
||||||
} else if path.is_file() {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn nres_test_files() -> Vec<PathBuf> {
|
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.join("..")
|
|
||||||
.join("..")
|
|
||||||
.join("testdata")
|
|
||||||
.join("nres");
|
|
||||||
let mut files = Vec::new();
|
|
||||||
collect_files_recursive(&root, &mut files);
|
|
||||||
files.sort();
|
|
||||||
files
|
|
||||||
.into_iter()
|
|
||||||
.filter(|path| {
|
|
||||||
fs::read(path)
|
|
||||||
.map(|data| data.get(0..4) == Some(b"NRes"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn make_temp_copy(original: &Path, bytes: &[u8]) -> PathBuf {
|
|
||||||
let mut path = std::env::temp_dir();
|
|
||||||
let file_name = original
|
|
||||||
.file_name()
|
|
||||||
.and_then(|v| v.to_str())
|
|
||||||
.unwrap_or("archive");
|
|
||||||
path.push(format!(
|
|
||||||
"nres-test-{}-{}-{}",
|
|
||||||
std::process::id(),
|
|
||||||
unix_time_nanos(),
|
|
||||||
file_name
|
|
||||||
));
|
|
||||||
fs::write(&path, bytes).expect("failed to create temp file");
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn panic_message(payload: Box<dyn Any + Send>) -> String {
|
|
||||||
let any = payload.as_ref();
|
|
||||||
if let Some(message) = any.downcast_ref::<String>() {
|
|
||||||
return message.clone();
|
|
||||||
}
|
|
||||||
if let Some(message) = any.downcast_ref::<&str>() {
|
|
||||||
return (*message).to_string();
|
|
||||||
}
|
|
||||||
String::from("panic without message")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn 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_read_and_roundtrip_all_files() {
|
|
||||||
let files = nres_test_files();
|
|
||||||
assert!(!files.is_empty(), "testdata/nres contains no NRes archives");
|
|
||||||
|
|
||||||
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 first = files.first().expect("testdata/nres has no archives");
|
|
||||||
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_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_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);
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "rsli"
|
|
||||||
version = "0.1.0"
|
|
||||||
edition = "2021"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# rsli
|
|
||||||
|
|
||||||
Rust-библиотека для чтения архивов формата **RsLi**.
|
|
||||||
|
|
||||||
## Что умеет
|
|
||||||
|
|
||||||
- Открытие библиотеки из файла (`open_path`, `open_path_with`).
|
|
||||||
- Дешифрование таблицы записей (XOR stream cipher).
|
|
||||||
- Поддержка AO-трейлера и media overlay (`allow_ao_trailer`).
|
|
||||||
- Поддержка quirk для Deflate `EOF+1` (`allow_deflate_eof_plus_one`).
|
|
||||||
- Поиск по имени (`find`, c приведением запроса к uppercase).
|
|
||||||
- Загрузка данных:
|
|
||||||
- `load`, `load_into`, `load_packed`, `unpack`, `load_fast`.
|
|
||||||
|
|
||||||
## Поддерживаемые методы упаковки
|
|
||||||
|
|
||||||
- `0x000` None
|
|
||||||
- `0x020` XorOnly
|
|
||||||
- `0x040` Lzss
|
|
||||||
- `0x060` XorLzss
|
|
||||||
- `0x080` LzssHuffman
|
|
||||||
- `0x0A0` XorLzssHuffman
|
|
||||||
- `0x100` Deflate
|
|
||||||
|
|
||||||
## Модель ошибок
|
|
||||||
|
|
||||||
Типизированные ошибки без паник в production-коде (`InvalidMagic`, `UnsupportedVersion`, `EntryTableOutOfBounds`, `PackedSizePastEof`, `DeflateEofPlusOneQuirkRejected`, `UnsupportedMethod`, и др.).
|
|
||||||
|
|
||||||
## Покрытие тестами
|
|
||||||
|
|
||||||
### Реальные файлы
|
|
||||||
|
|
||||||
- Рекурсивный прогон по `testdata/rsli/**`.
|
|
||||||
- Сейчас в наборе: **2 архива**.
|
|
||||||
- На реальных данных подтверждены и проходят byte-to-byte проверки методы:
|
|
||||||
- `0x040` (LZSS)
|
|
||||||
- `0x100` (Deflate)
|
|
||||||
- Для каждого архива проверяется:
|
|
||||||
- `load`/`load_into`/`load_packed`/`unpack`/`load_fast`;
|
|
||||||
- `find`;
|
|
||||||
- пересборка и сравнение **byte-to-byte**.
|
|
||||||
|
|
||||||
### Синтетические тесты
|
|
||||||
|
|
||||||
Из-за отсутствия реальных файлов для части методов добавлены синтетические архивы и тесты:
|
|
||||||
|
|
||||||
- Методы:
|
|
||||||
- `0x000`, `0x020`, `0x060`, `0x080`, `0x0A0`.
|
|
||||||
- Спецкейсы формата:
|
|
||||||
- AO trailer + overlay;
|
|
||||||
- Deflate `EOF+1` (оба режима: accepted/rejected);
|
|
||||||
- некорректные заголовки/таблицы/смещения/методы.
|
|
||||||
|
|
||||||
## Быстрый запуск тестов
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cargo test -p rsli -- --nocapture
|
|
||||||
```
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
use std::io;
|
|
||||||
|
|
||||||
#[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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub trait OutputBuffer {
|
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
use core::fmt;
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
#[non_exhaustive]
|
|
||||||
pub enum Error {
|
|
||||||
Io(std::io::Error),
|
|
||||||
|
|
||||||
InvalidMagic {
|
|
||||||
got: [u8; 2],
|
|
||||||
},
|
|
||||||
UnsupportedVersion {
|
|
||||||
got: u8,
|
|
||||||
},
|
|
||||||
InvalidEntryCount {
|
|
||||||
got: i16,
|
|
||||||
},
|
|
||||||
|
|
||||||
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::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 {}
|
|
||||||
@@ -1,983 +0,0 @@
|
|||||||
pub mod data;
|
|
||||||
pub mod error;
|
|
||||||
|
|
||||||
use crate::data::{OutputBuffer, ResourceData};
|
|
||||||
use crate::error::Error;
|
|
||||||
use flate2::read::{DeflateDecoder, ZlibDecoder};
|
|
||||||
use std::cmp::Ordering;
|
|
||||||
use std::fs;
|
|
||||||
use std::io::Read;
|
|
||||||
use std::path::Path;
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
pub type Result<T> = core::result::Result<T, Error>;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct OpenOptions {
|
|
||||||
pub allow_ao_trailer: bool,
|
|
||||||
pub allow_deflate_eof_plus_one: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for OpenOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
allow_ao_trailer: true,
|
|
||||||
allow_deflate_eof_plus_one: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct Library {
|
|
||||||
bytes: Arc<[u8]>,
|
|
||||||
entries: Vec<EntryRecord>,
|
|
||||||
#[cfg(test)]
|
|
||||||
header_raw: [u8; 32],
|
|
||||||
#[cfg(test)]
|
|
||||||
table_plain_original: Vec<u8>,
|
|
||||||
#[cfg(test)]
|
|
||||||
xor_seed: u32,
|
|
||||||
#[cfg(test)]
|
|
||||||
source_size: usize,
|
|
||||||
#[cfg(test)]
|
|
||||||
trailer_raw: Option<[u8; 6]>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
|
|
||||||
pub struct EntryId(pub u32);
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
pub struct EntryMeta {
|
|
||||||
pub name: String,
|
|
||||||
pub flags: i32,
|
|
||||||
pub method: PackMethod,
|
|
||||||
pub data_offset: u64,
|
|
||||||
pub packed_size: u32,
|
|
||||||
pub unpacked_size: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub enum PackMethod {
|
|
||||||
None,
|
|
||||||
XorOnly,
|
|
||||||
Lzss,
|
|
||||||
XorLzss,
|
|
||||||
LzssHuffman,
|
|
||||||
XorLzssHuffman,
|
|
||||||
Deflate,
|
|
||||||
Unknown(u32),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug)]
|
|
||||||
pub struct EntryRef<'a> {
|
|
||||||
pub id: EntryId,
|
|
||||||
pub meta: &'a EntryMeta,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct PackedResource {
|
|
||||||
pub meta: EntryMeta,
|
|
||||||
pub packed: Vec<u8>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct EntryRecord {
|
|
||||||
meta: EntryMeta,
|
|
||||||
name_raw: [u8; 12],
|
|
||||||
sort_to_original: i16,
|
|
||||||
key16: u16,
|
|
||||||
#[cfg(test)]
|
|
||||||
data_offset_raw: u32,
|
|
||||||
packed_size_declared: u32,
|
|
||||||
packed_size_available: usize,
|
|
||||||
effective_offset: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Library {
|
|
||||||
pub fn open_path(path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
Self::open_path_with(path, OpenOptions::default())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn open_path_with(path: impl AsRef<Path>, opts: OpenOptions) -> Result<Self> {
|
|
||||||
let bytes = fs::read(path.as_ref())?;
|
|
||||||
let arc: Arc<[u8]> = Arc::from(bytes.into_boxed_slice());
|
|
||||||
parse_library(arc, opts)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn entry_count(&self) -> usize {
|
|
||||||
self.entries.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn entries(&self) -> impl Iterator<Item = EntryRef<'_>> {
|
|
||||||
self.entries
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(idx, entry)| EntryRef {
|
|
||||||
id: EntryId(idx as u32),
|
|
||||||
meta: &entry.meta,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn find(&self, name: &str) -> Option<EntryId> {
|
|
||||||
if self.entries.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let query = name.to_ascii_uppercase();
|
|
||||||
let query_bytes = query.as_bytes();
|
|
||||||
|
|
||||||
let mut low = 0usize;
|
|
||||||
let mut high = self.entries.len();
|
|
||||||
while low < high {
|
|
||||||
let mid = low + (high - low) / 2;
|
|
||||||
let idx = self.entries[mid].sort_to_original;
|
|
||||||
if idx < 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
let idx = usize::try_from(idx).ok()?;
|
|
||||||
if idx >= self.entries.len() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
let cmp = cmp_c_string(query_bytes, c_name_bytes(&self.entries[idx].name_raw));
|
|
||||||
match cmp {
|
|
||||||
Ordering::Less => high = mid,
|
|
||||||
Ordering::Greater => low = mid + 1,
|
|
||||||
Ordering::Equal => return Some(EntryId(idx as u32)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.entries.iter().enumerate().find_map(|(idx, entry)| {
|
|
||||||
if cmp_c_string(query_bytes, c_name_bytes(&entry.name_raw)) == Ordering::Equal {
|
|
||||||
Some(EntryId(idx as u32))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get(&self, id: EntryId) -> Option<EntryRef<'_>> {
|
|
||||||
let idx = usize::try_from(id.0).ok()?;
|
|
||||||
let entry = self.entries.get(idx)?;
|
|
||||||
Some(EntryRef {
|
|
||||||
id,
|
|
||||||
meta: &entry.meta,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load(&self, id: EntryId) -> Result<Vec<u8>> {
|
|
||||||
let entry = self.entry_by_id(id)?;
|
|
||||||
let packed = self.packed_slice(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(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(entry)?;
|
|
||||||
let size =
|
|
||||||
usize::try_from(entry.meta.unpacked_size).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
if packed.len() < size {
|
|
||||||
return Err(Error::OutputSizeMismatch {
|
|
||||||
expected: entry.meta.unpacked_size,
|
|
||||||
got: u32::try_from(packed.len()).unwrap_or(u32::MAX),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Ok(ResourceData::Borrowed(&packed[..size]));
|
|
||||||
}
|
|
||||||
Ok(ResourceData::Owned(self.load(id)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn entry_by_id(&self, id: EntryId) -> Result<&EntryRecord> {
|
|
||||||
let idx = usize::try_from(id.0).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
self.entries
|
|
||||||
.get(idx)
|
|
||||||
.ok_or_else(|| Error::EntryIdOutOfRange {
|
|
||||||
id: id.0,
|
|
||||||
entry_count: self.entries.len().try_into().unwrap_or(u32::MAX),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn packed_slice<'a>(&'a self, 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: 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)]
|
|
||||||
fn rebuild_from_parsed_metadata(&self) -> Result<Vec<u8>> {
|
|
||||||
let trailer_len = usize::from(self.trailer_raw.is_some()) * 6;
|
|
||||||
let pre_trailer_size = self
|
|
||||||
.source_size
|
|
||||||
.checked_sub(trailer_len)
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
|
|
||||||
let count = self.entries.len();
|
|
||||||
let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
|
|
||||||
let table_end = 32usize
|
|
||||||
.checked_add(table_len)
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
if pre_trailer_size < table_end {
|
|
||||||
return Err(Error::EntryTableOutOfBounds {
|
|
||||||
table_offset: 32,
|
|
||||||
table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?,
|
|
||||||
file_len: u64::try_from(pre_trailer_size).map_err(|_| Error::IntegerOverflow)?,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut out = vec![0u8; pre_trailer_size];
|
|
||||||
out[0..32].copy_from_slice(&self.header_raw);
|
|
||||||
let encrypted_table =
|
|
||||||
xor_stream(&self.table_plain_original, (self.xor_seed & 0xFFFF) as u16);
|
|
||||||
out[32..table_end].copy_from_slice(&encrypted_table);
|
|
||||||
|
|
||||||
let mut occupied = vec![false; pre_trailer_size];
|
|
||||||
for byte in occupied.iter_mut().take(table_end) {
|
|
||||||
*byte = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (idx, entry) in self.entries.iter().enumerate() {
|
|
||||||
let packed = self.load_packed(EntryId(idx as u32))?.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: idx as u32,
|
|
||||||
offset: u64::from(entry.data_offset_raw),
|
|
||||||
packed_size: entry.packed_size_declared,
|
|
||||||
file_len: u64::try_from(out.len()).map_err(|_| Error::IntegerOverflow)?,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if occupied[pos] && out[pos] != byte {
|
|
||||||
return Err(Error::CorruptEntryTable("packed payload overlap conflict"));
|
|
||||||
}
|
|
||||||
out[pos] = byte;
|
|
||||||
occupied[pos] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(trailer) = self.trailer_raw {
|
|
||||||
out.extend_from_slice(&trailer);
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_library(bytes: Arc<[u8]>, opts: OpenOptions) -> Result<Library> {
|
|
||||||
if bytes.len() < 32 {
|
|
||||||
return Err(Error::EntryTableOutOfBounds {
|
|
||||||
table_offset: 32,
|
|
||||||
table_len: 0,
|
|
||||||
file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut header_raw = [0u8; 32];
|
|
||||||
header_raw.copy_from_slice(&bytes[0..32]);
|
|
||||||
|
|
||||||
if &bytes[0..2] != b"NL" {
|
|
||||||
let mut got = [0u8; 2];
|
|
||||||
got.copy_from_slice(&bytes[0..2]);
|
|
||||||
return Err(Error::InvalidMagic { got });
|
|
||||||
}
|
|
||||||
if bytes[3] != 0x01 {
|
|
||||||
return Err(Error::UnsupportedVersion { got: bytes[3] });
|
|
||||||
}
|
|
||||||
|
|
||||||
let entry_count = i16::from_le_bytes([bytes[4], bytes[5]]);
|
|
||||||
if entry_count < 0 {
|
|
||||||
return Err(Error::InvalidEntryCount { got: entry_count });
|
|
||||||
}
|
|
||||||
let count = usize::try_from(entry_count).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
|
|
||||||
let xor_seed = u32::from_le_bytes([bytes[20], bytes[21], bytes[22], bytes[23]]);
|
|
||||||
|
|
||||||
let table_len = count.checked_mul(32).ok_or(Error::IntegerOverflow)?;
|
|
||||||
let table_offset = 32usize;
|
|
||||||
let table_end = table_offset
|
|
||||||
.checked_add(table_len)
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
if table_end > bytes.len() {
|
|
||||||
return Err(Error::EntryTableOutOfBounds {
|
|
||||||
table_offset: u64::try_from(table_offset).map_err(|_| Error::IntegerOverflow)?,
|
|
||||||
table_len: u64::try_from(table_len).map_err(|_| Error::IntegerOverflow)?,
|
|
||||||
file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let table_enc = &bytes[table_offset..table_end];
|
|
||||||
let table_plain_original = xor_stream(table_enc, (xor_seed & 0xFFFF) as u16);
|
|
||||||
if table_plain_original.len() != table_len {
|
|
||||||
return Err(Error::EntryTableDecryptFailed);
|
|
||||||
}
|
|
||||||
|
|
||||||
let (overlay, trailer_raw) = parse_ao_trailer(&bytes, opts.allow_ao_trailer)?;
|
|
||||||
#[cfg(not(test))]
|
|
||||||
let _ = trailer_raw;
|
|
||||||
|
|
||||||
let mut entries = Vec::with_capacity(count);
|
|
||||||
for idx in 0..count {
|
|
||||||
let row = &table_plain_original[idx * 32..(idx + 1) * 32];
|
|
||||||
|
|
||||||
let mut name_raw = [0u8; 12];
|
|
||||||
name_raw.copy_from_slice(&row[0..12]);
|
|
||||||
|
|
||||||
let flags_signed = i16::from_le_bytes([row[16], row[17]]);
|
|
||||||
let sort_to_original = i16::from_le_bytes([row[18], row[19]]);
|
|
||||||
let unpacked_size = u32::from_le_bytes([row[20], row[21], row[22], row[23]]);
|
|
||||||
let data_offset_raw = u32::from_le_bytes([row[24], row[25], row[26], row[27]]);
|
|
||||||
let packed_size_declared = u32::from_le_bytes([row[28], row[29], row[30], row[31]]);
|
|
||||||
|
|
||||||
let method_raw = (flags_signed as u16 as u32) & 0x1E0;
|
|
||||||
let method = parse_method(method_raw);
|
|
||||||
|
|
||||||
let effective_offset_u64 = u64::from(data_offset_raw)
|
|
||||||
.checked_add(u64::from(overlay))
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
let effective_offset =
|
|
||||||
usize::try_from(effective_offset_u64).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
|
|
||||||
let packed_size_usize =
|
|
||||||
usize::try_from(packed_size_declared).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
let mut packed_size_available = packed_size_usize;
|
|
||||||
|
|
||||||
let end = effective_offset_u64
|
|
||||||
.checked_add(u64::from(packed_size_declared))
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
let file_len_u64 = u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
|
|
||||||
if end > file_len_u64 {
|
|
||||||
if method_raw == 0x100 && end == file_len_u64 + 1 {
|
|
||||||
if opts.allow_deflate_eof_plus_one {
|
|
||||||
packed_size_available = packed_size_available
|
|
||||||
.checked_sub(1)
|
|
||||||
.ok_or(Error::IntegerOverflow)?;
|
|
||||||
} else {
|
|
||||||
return Err(Error::DeflateEofPlusOneQuirkRejected { id: idx as u32 });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return Err(Error::PackedSizePastEof {
|
|
||||||
id: idx as u32,
|
|
||||||
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: idx as u32,
|
|
||||||
offset: effective_offset_u64,
|
|
||||||
size: packed_size_declared,
|
|
||||||
file_len: file_len_u64,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = decode_name(c_name_bytes(&name_raw));
|
|
||||||
|
|
||||||
entries.push(EntryRecord {
|
|
||||||
meta: EntryMeta {
|
|
||||||
name,
|
|
||||||
flags: i32::from(flags_signed),
|
|
||||||
method,
|
|
||||||
data_offset: effective_offset_u64,
|
|
||||||
packed_size: packed_size_declared,
|
|
||||||
unpacked_size,
|
|
||||||
},
|
|
||||||
name_raw,
|
|
||||||
sort_to_original,
|
|
||||||
key16: sort_to_original as u16,
|
|
||||||
#[cfg(test)]
|
|
||||||
data_offset_raw,
|
|
||||||
packed_size_declared,
|
|
||||||
packed_size_available,
|
|
||||||
effective_offset,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let presorted_flag = u16::from_le_bytes([bytes[14], bytes[15]]);
|
|
||||||
if presorted_flag == 0xABBA {
|
|
||||||
for entry in &entries {
|
|
||||||
let idx = i32::from(entry.sort_to_original);
|
|
||||||
if idx < 0 || usize::try_from(idx).map_err(|_| Error::IntegerOverflow)? >= count {
|
|
||||||
return Err(Error::CorruptEntryTable(
|
|
||||||
"sort_to_original is not a valid permutation index",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let mut sorted: Vec<usize> = (0..count).collect();
|
|
||||||
sorted.sort_by(|a, b| {
|
|
||||||
cmp_c_string(
|
|
||||||
c_name_bytes(&entries[*a].name_raw),
|
|
||||||
c_name_bytes(&entries[*b].name_raw),
|
|
||||||
)
|
|
||||||
});
|
|
||||||
for (idx, entry) in entries.iter_mut().enumerate() {
|
|
||||||
entry.sort_to_original =
|
|
||||||
i16::try_from(sorted[idx]).map_err(|_| Error::IntegerOverflow)?;
|
|
||||||
entry.key16 = entry.sort_to_original as u16;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
let source_size = bytes.len();
|
|
||||||
|
|
||||||
Ok(Library {
|
|
||||||
bytes,
|
|
||||||
entries,
|
|
||||||
#[cfg(test)]
|
|
||||||
header_raw,
|
|
||||||
#[cfg(test)]
|
|
||||||
table_plain_original,
|
|
||||||
#[cfg(test)]
|
|
||||||
xor_seed,
|
|
||||||
#[cfg(test)]
|
|
||||||
source_size,
|
|
||||||
#[cfg(test)]
|
|
||||||
trailer_raw,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_ao_trailer(bytes: &[u8], allow: bool) -> Result<(u32, Option<[u8; 6]>)> {
|
|
||||||
if !allow || bytes.len() < 6 {
|
|
||||||
return Ok((0, None));
|
|
||||||
}
|
|
||||||
|
|
||||||
if &bytes[bytes.len() - 6..bytes.len() - 4] != b"AO" {
|
|
||||||
return Ok((0, None));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut trailer = [0u8; 6];
|
|
||||||
trailer.copy_from_slice(&bytes[bytes.len() - 6..]);
|
|
||||||
let overlay = u32::from_le_bytes([trailer[2], trailer[3], trailer[4], trailer[5]]);
|
|
||||||
|
|
||||||
if u64::from(overlay) > u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)? {
|
|
||||||
return Err(Error::MediaOverlayOutOfBounds {
|
|
||||||
overlay,
|
|
||||||
file_len: u64::try_from(bytes.len()).map_err(|_| Error::IntegerOverflow)?,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((overlay, Some(trailer)))
|
|
||||||
}
|
|
||||||
|
|
||||||
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_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)?,
|
|
||||||
PackMethod::XorLzss => {
|
|
||||||
let decrypted = xor_stream(packed, key16);
|
|
||||||
lzss_decompress_simple(&decrypted, expected)?
|
|
||||||
}
|
|
||||||
PackMethod::LzssHuffman => lzss_huffman_decompress(packed, expected)?,
|
|
||||||
PackMethod::XorLzssHuffman => {
|
|
||||||
let decrypted = xor_stream(packed, key16);
|
|
||||||
lzss_huffman_decompress(&decrypted, expected)?
|
|
||||||
}
|
|
||||||
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 decode_deflate(packed: &[u8]) -> Result<Vec<u8>> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut decoder = DeflateDecoder::new(packed);
|
|
||||||
if decoder.read_to_end(&mut out).is_ok() {
|
|
||||||
return Ok(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
out.clear();
|
|
||||||
let mut zlib = ZlibDecoder::new(packed);
|
|
||||||
zlib.read_to_end(&mut out)
|
|
||||||
.map_err(|_| Error::DecompressionFailed("deflate"))?;
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn xor_stream(data: &[u8], key16: u16) -> Vec<u8> {
|
|
||||||
let mut lo = (key16 & 0xFF) as u8;
|
|
||||||
let mut hi = ((key16 >> 8) & 0xFF) as u8;
|
|
||||||
|
|
||||||
let mut out = Vec::with_capacity(data.len());
|
|
||||||
for value in data {
|
|
||||||
lo = hi ^ lo.wrapping_shl(1);
|
|
||||||
out.push(value ^ lo);
|
|
||||||
hi = lo ^ (hi >> 1);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lzss_decompress_simple(data: &[u8], expected_size: usize) -> 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;
|
|
||||||
|
|
||||||
while out.len() < expected_size {
|
|
||||||
if bits_left == 0 {
|
|
||||||
let Some(byte) = data.get(in_pos).copied() else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
control = byte;
|
|
||||||
in_pos += 1;
|
|
||||||
bits_left = 8;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (control & 1) != 0 {
|
|
||||||
let Some(byte) = data.get(in_pos).copied() else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
in_pos += 1;
|
|
||||||
|
|
||||||
out.push(byte);
|
|
||||||
ring[ring_pos] = byte;
|
|
||||||
ring_pos = (ring_pos + 1) & 0x0FFF;
|
|
||||||
} else {
|
|
||||||
let (Some(low), Some(high)) =
|
|
||||||
(data.get(in_pos).copied(), data.get(in_pos + 1).copied())
|
|
||||||
else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
const LZH_N: usize = 4096;
|
|
||||||
const LZH_F: usize = 60;
|
|
||||||
const LZH_THRESHOLD: usize = 2;
|
|
||||||
const LZH_N_CHAR: usize = 256 - LZH_THRESHOLD + LZH_F;
|
|
||||||
const LZH_T: usize = LZH_N_CHAR * 2 - 1;
|
|
||||||
const LZH_R: usize = LZH_T - 1;
|
|
||||||
const LZH_MAX_FREQ: u16 = 0x8000;
|
|
||||||
|
|
||||||
fn lzss_huffman_decompress(data: &[u8], expected_size: usize) -> Result<Vec<u8>> {
|
|
||||||
let mut decoder = LzhDecoder::new(data);
|
|
||||||
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]) -> Self {
|
|
||||||
let mut decoder = Self {
|
|
||||||
bit_reader: BitReader::new(data),
|
|
||||||
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) -> usize {
|
|
||||||
let mut node = self.son[LZH_R];
|
|
||||||
while node < LZH_T {
|
|
||||||
let bit = usize::from(self.bit_reader.read_bit_or_zero());
|
|
||||||
node = self.son[node + bit];
|
|
||||||
}
|
|
||||||
|
|
||||||
let c = node - LZH_T;
|
|
||||||
self.update(c);
|
|
||||||
c
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_position(&mut self) -> usize {
|
|
||||||
let i = self.bit_reader.read_bits_or_zero(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_or_zero()) << j;
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<'a> BitReader<'a> {
|
|
||||||
fn new(data: &'a [u8]) -> Self {
|
|
||||||
Self {
|
|
||||||
data,
|
|
||||||
byte_pos: 0,
|
|
||||||
bit_mask: 0x80,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_bit_or_zero(&mut self) -> u8 {
|
|
||||||
let Some(byte) = self.data.get(self.byte_pos).copied() else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
let bit = if (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);
|
|
||||||
}
|
|
||||||
bit
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_bits_or_zero(&mut self, bits: usize) -> u32 {
|
|
||||||
let mut value = 0u32;
|
|
||||||
for _ in 0..bits {
|
|
||||||
value = (value << 1) | u32::from(self.read_bit_or_zero());
|
|
||||||
}
|
|
||||||
value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decode_name(name: &[u8]) -> String {
|
|
||||||
name.iter().map(|b| char::from(*b)).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn c_name_bytes(raw: &[u8; 12]) -> &[u8] {
|
|
||||||
let len = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
|
|
||||||
&raw[..len]
|
|
||||||
}
|
|
||||||
|
|
||||||
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())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn needs_xor_key(method: PackMethod) -> bool {
|
|
||||||
matches!(
|
|
||||||
method,
|
|
||||||
PackMethod::XorOnly | PackMethod::XorLzss | PackMethod::XorLzssHuffman
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,847 +0,0 @@
|
|||||||
use super::*;
|
|
||||||
use flate2::write::DeflateEncoder;
|
|
||||||
use flate2::Compression;
|
|
||||||
use std::any::Any;
|
|
||||||
use std::fs;
|
|
||||||
use std::io::Write as _;
|
|
||||||
use std::panic::{catch_unwind, AssertUnwindSafe};
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct SyntheticRsliEntry {
|
|
||||||
name: String,
|
|
||||||
method_raw: u16,
|
|
||||||
plain: Vec<u8>,
|
|
||||||
declared_packed_size: Option<u32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct RsliBuildOptions {
|
|
||||||
seed: u32,
|
|
||||||
presorted: bool,
|
|
||||||
overlay: u32,
|
|
||||||
add_ao_trailer: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for RsliBuildOptions {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
seed: 0x1234_5678,
|
|
||||||
presorted: true,
|
|
||||||
overlay: 0,
|
|
||||||
add_ao_trailer: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn collect_files_recursive(root: &Path, out: &mut Vec<PathBuf>) {
|
|
||||||
let Ok(entries) = fs::read_dir(root) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.is_dir() {
|
|
||||||
collect_files_recursive(&path, out);
|
|
||||||
} else if path.is_file() {
|
|
||||||
out.push(path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rsli_test_files() -> Vec<PathBuf> {
|
|
||||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
||||||
.join("..")
|
|
||||||
.join("..")
|
|
||||||
.join("testdata")
|
|
||||||
.join("rsli");
|
|
||||||
let mut files = Vec::new();
|
|
||||||
collect_files_recursive(&root, &mut files);
|
|
||||||
files.sort();
|
|
||||||
files
|
|
||||||
.into_iter()
|
|
||||||
.filter(|path| {
|
|
||||||
fs::read(path)
|
|
||||||
.map(|data| data.get(0..4) == Some(b"NL\0\x01"))
|
|
||||||
.unwrap_or(false)
|
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn panic_message(payload: Box<dyn Any + Send>) -> String {
|
|
||||||
let any = payload.as_ref();
|
|
||||||
if let Some(message) = any.downcast_ref::<String>() {
|
|
||||||
return message.clone();
|
|
||||||
}
|
|
||||||
if let Some(message) = any.downcast_ref::<&str>() {
|
|
||||||
return (*message).to_string();
|
|
||||||
}
|
|
||||||
String::from("panic without message")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_temp_file(prefix: &str, bytes: &[u8]) -> PathBuf {
|
|
||||||
let mut path = std::env::temp_dir();
|
|
||||||
path.push(format!(
|
|
||||||
"{}-{}-{}.bin",
|
|
||||||
prefix,
|
|
||||||
std::process::id(),
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_nanos())
|
|
||||||
.unwrap_or(0)
|
|
||||||
));
|
|
||||||
fs::write(&path, bytes).expect("failed to write temp archive");
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
fn deflate_raw(data: &[u8]) -> Vec<u8> {
|
|
||||||
let mut encoder = DeflateEncoder::new(Vec::new(), Compression::default());
|
|
||||||
encoder
|
|
||||||
.write_all(data)
|
|
||||||
.expect("deflate encoder write failed");
|
|
||||||
encoder.finish().expect("deflate encoder finish failed")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lzss_pack_literals(data: &[u8]) -> Vec<u8> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for chunk in data.chunks(8) {
|
|
||||||
let mask = if chunk.len() == 8 {
|
|
||||||
0xFF
|
|
||||||
} else {
|
|
||||||
(1u16
|
|
||||||
.checked_shl(u32::try_from(chunk.len()).expect("chunk len overflow"))
|
|
||||||
.expect("shift overflow")
|
|
||||||
- 1) as u8
|
|
||||||
};
|
|
||||||
out.push(mask);
|
|
||||||
out.extend_from_slice(chunk);
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
struct BitWriter {
|
|
||||||
bytes: Vec<u8>,
|
|
||||||
current: u8,
|
|
||||||
mask: u8,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl BitWriter {
|
|
||||||
fn new() -> Self {
|
|
||||||
Self {
|
|
||||||
bytes: Vec::new(),
|
|
||||||
current: 0,
|
|
||||||
mask: 0x80,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_bit(&mut self, bit: u8) {
|
|
||||||
if bit != 0 {
|
|
||||||
self.current |= self.mask;
|
|
||||||
}
|
|
||||||
self.mask >>= 1;
|
|
||||||
if self.mask == 0 {
|
|
||||||
self.bytes.push(self.current);
|
|
||||||
self.current = 0;
|
|
||||||
self.mask = 0x80;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn finish(mut self) -> Vec<u8> {
|
|
||||||
if self.mask != 0x80 {
|
|
||||||
self.bytes.push(self.current);
|
|
||||||
}
|
|
||||||
self.bytes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct LzhLiteralModel {
|
|
||||||
freq: [u16; LZH_T + 1],
|
|
||||||
parent: [usize; LZH_T + LZH_N_CHAR],
|
|
||||||
son: [usize; LZH_T + 1],
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LzhLiteralModel {
|
|
||||||
fn new() -> Self {
|
|
||||||
let mut model = Self {
|
|
||||||
freq: [0; LZH_T + 1],
|
|
||||||
parent: [0; LZH_T + LZH_N_CHAR],
|
|
||||||
son: [0; LZH_T + 1],
|
|
||||||
};
|
|
||||||
model.start_huff();
|
|
||||||
model
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_literal(&mut self, literal: u8, writer: &mut BitWriter) {
|
|
||||||
let target = usize::from(literal) + LZH_T;
|
|
||||||
let mut path = Vec::new();
|
|
||||||
let mut visited = [false; LZH_T + 1];
|
|
||||||
let found = self.find_path(self.son[LZH_R], target, &mut path, &mut visited);
|
|
||||||
assert!(found, "failed to encode literal {literal}");
|
|
||||||
for bit in path {
|
|
||||||
writer.write_bit(bit);
|
|
||||||
}
|
|
||||||
|
|
||||||
self.update(usize::from(literal));
|
|
||||||
}
|
|
||||||
|
|
||||||
fn find_path(
|
|
||||||
&self,
|
|
||||||
node: usize,
|
|
||||||
target: usize,
|
|
||||||
path: &mut Vec<u8>,
|
|
||||||
visited: &mut [bool; LZH_T + 1],
|
|
||||||
) -> bool {
|
|
||||||
if node == target {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if node >= LZH_T {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if visited[node] {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
visited[node] = true;
|
|
||||||
|
|
||||||
for bit in [0u8, 1u8] {
|
|
||||||
let child = self.son[node + usize::from(bit)];
|
|
||||||
path.push(bit);
|
|
||||||
if self.find_path(child, target, path, visited) {
|
|
||||||
visited[node] = false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
path.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
visited[node] = false;
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
fn start_huff(&mut self) {
|
|
||||||
for i in 0..LZH_N_CHAR {
|
|
||||||
self.freq[i] = 1;
|
|
||||||
self.son[i] = i + LZH_T;
|
|
||||||
self.parent[i + LZH_T] = i;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut i = 0usize;
|
|
||||||
let mut j = LZH_N_CHAR;
|
|
||||||
while j <= LZH_R {
|
|
||||||
self.freq[j] = self.freq[i].saturating_add(self.freq[i + 1]);
|
|
||||||
self.son[j] = i;
|
|
||||||
self.parent[i] = j;
|
|
||||||
self.parent[i + 1] = j;
|
|
||||||
i += 2;
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.freq[LZH_T] = u16::MAX;
|
|
||||||
self.parent[LZH_R] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn update(&mut self, c: usize) {
|
|
||||||
if self.freq[LZH_R] == LZH_MAX_FREQ {
|
|
||||||
self.reconstruct();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut current = self.parent[c + LZH_T];
|
|
||||||
loop {
|
|
||||||
self.freq[current] = self.freq[current].saturating_add(1);
|
|
||||||
let freq = self.freq[current];
|
|
||||||
|
|
||||||
if current + 1 < self.freq.len() && freq > self.freq[current + 1] {
|
|
||||||
let mut swap_idx = current + 1;
|
|
||||||
while swap_idx + 1 < self.freq.len() && freq > self.freq[swap_idx + 1] {
|
|
||||||
swap_idx += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.freq.swap(current, swap_idx);
|
|
||||||
|
|
||||||
let left = self.son[current];
|
|
||||||
let right = self.son[swap_idx];
|
|
||||||
self.son[current] = right;
|
|
||||||
self.son[swap_idx] = left;
|
|
||||||
|
|
||||||
self.parent[left] = swap_idx;
|
|
||||||
if left < LZH_T {
|
|
||||||
self.parent[left + 1] = swap_idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.parent[right] = current;
|
|
||||||
if right < LZH_T {
|
|
||||||
self.parent[right + 1] = current;
|
|
||||||
}
|
|
||||||
|
|
||||||
current = swap_idx;
|
|
||||||
}
|
|
||||||
|
|
||||||
current = self.parent[current];
|
|
||||||
if current == 0 {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reconstruct(&mut self) {
|
|
||||||
let mut j = 0usize;
|
|
||||||
for i in 0..LZH_T {
|
|
||||||
if self.son[i] >= LZH_T {
|
|
||||||
self.freq[j] = self.freq[i].div_ceil(2);
|
|
||||||
self.son[j] = self.son[i];
|
|
||||||
j += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut i = 0usize;
|
|
||||||
let mut current = LZH_N_CHAR;
|
|
||||||
while current < LZH_T {
|
|
||||||
let sum = self.freq[i].saturating_add(self.freq[i + 1]);
|
|
||||||
self.freq[current] = sum;
|
|
||||||
|
|
||||||
let mut insert_at = current;
|
|
||||||
while insert_at > 0 && sum < self.freq[insert_at - 1] {
|
|
||||||
insert_at -= 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for move_idx in (insert_at..current).rev() {
|
|
||||||
self.freq[move_idx + 1] = self.freq[move_idx];
|
|
||||||
self.son[move_idx + 1] = self.son[move_idx];
|
|
||||||
}
|
|
||||||
|
|
||||||
self.freq[insert_at] = sum;
|
|
||||||
self.son[insert_at] = i;
|
|
||||||
i += 2;
|
|
||||||
current += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for idx in 0..LZH_T {
|
|
||||||
let node = self.son[idx];
|
|
||||||
self.parent[node] = idx;
|
|
||||||
if node < LZH_T {
|
|
||||||
self.parent[node + 1] = idx;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.freq[LZH_T] = u16::MAX;
|
|
||||||
self.parent[LZH_R] = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lzh_pack_literals(data: &[u8]) -> Vec<u8> {
|
|
||||||
let mut writer = BitWriter::new();
|
|
||||||
let mut model = LzhLiteralModel::new();
|
|
||||||
for byte in data {
|
|
||||||
model.encode_literal(*byte, &mut writer);
|
|
||||||
}
|
|
||||||
writer.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn packed_for_method(method_raw: u16, plain: &[u8], key16: u16) -> Vec<u8> {
|
|
||||||
match (u32::from(method_raw)) & 0x1E0 {
|
|
||||||
0x000 => plain.to_vec(),
|
|
||||||
0x020 => xor_stream(plain, key16),
|
|
||||||
0x040 => lzss_pack_literals(plain),
|
|
||||||
0x060 => xor_stream(&lzss_pack_literals(plain), key16),
|
|
||||||
0x080 => lzh_pack_literals(plain),
|
|
||||||
0x0A0 => xor_stream(&lzh_pack_literals(plain), key16),
|
|
||||||
0x100 => deflate_raw(plain),
|
|
||||||
_ => plain.to_vec(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_rsli_bytes(entries: &[SyntheticRsliEntry], opts: &RsliBuildOptions) -> Vec<u8> {
|
|
||||||
let count = entries.len();
|
|
||||||
let mut rows_plain = vec![0u8; count * 32];
|
|
||||||
let table_end = 32 + rows_plain.len();
|
|
||||||
|
|
||||||
let mut sort_lookup: Vec<usize> = (0..count).collect();
|
|
||||||
sort_lookup.sort_by(|a, b| entries[*a].name.as_bytes().cmp(entries[*b].name.as_bytes()));
|
|
||||||
|
|
||||||
let mut packed_blobs = Vec::with_capacity(count);
|
|
||||||
for index in 0..count {
|
|
||||||
let key16 = u16::try_from(sort_lookup[index]).expect("sort index overflow");
|
|
||||||
let packed = packed_for_method(entries[index].method_raw, &entries[index].plain, key16);
|
|
||||||
packed_blobs.push(packed);
|
|
||||||
}
|
|
||||||
|
|
||||||
let overlay = usize::try_from(opts.overlay).expect("overlay overflow");
|
|
||||||
let mut cursor = table_end + overlay;
|
|
||||||
let mut output = vec![0u8; cursor];
|
|
||||||
|
|
||||||
let mut data_offsets = Vec::with_capacity(count);
|
|
||||||
for (index, packed) in packed_blobs.iter().enumerate() {
|
|
||||||
let raw_offset = cursor
|
|
||||||
.checked_sub(overlay)
|
|
||||||
.expect("overlay larger than cursor");
|
|
||||||
data_offsets.push(raw_offset);
|
|
||||||
|
|
||||||
let end = cursor.checked_add(packed.len()).expect("cursor overflow");
|
|
||||||
if output.len() < end {
|
|
||||||
output.resize(end, 0);
|
|
||||||
}
|
|
||||||
output[cursor..end].copy_from_slice(packed);
|
|
||||||
cursor = end;
|
|
||||||
|
|
||||||
let base = index * 32;
|
|
||||||
let mut name_raw = [0u8; 12];
|
|
||||||
let uppercase = entries[index].name.to_ascii_uppercase();
|
|
||||||
let name_bytes = uppercase.as_bytes();
|
|
||||||
assert!(name_bytes.len() <= 12, "name too long in synthetic fixture");
|
|
||||||
name_raw[..name_bytes.len()].copy_from_slice(name_bytes);
|
|
||||||
|
|
||||||
rows_plain[base..base + 12].copy_from_slice(&name_raw);
|
|
||||||
|
|
||||||
let sort_field: i16 = if opts.presorted {
|
|
||||||
i16::try_from(sort_lookup[index]).expect("sort field overflow")
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
let packed_size = entries[index]
|
|
||||||
.declared_packed_size
|
|
||||||
.unwrap_or_else(|| u32::try_from(packed.len()).expect("packed size overflow"));
|
|
||||||
|
|
||||||
rows_plain[base + 16..base + 18].copy_from_slice(&entries[index].method_raw.to_le_bytes());
|
|
||||||
rows_plain[base + 18..base + 20].copy_from_slice(&sort_field.to_le_bytes());
|
|
||||||
rows_plain[base + 20..base + 24].copy_from_slice(
|
|
||||||
&u32::try_from(entries[index].plain.len())
|
|
||||||
.expect("unpacked size overflow")
|
|
||||||
.to_le_bytes(),
|
|
||||||
);
|
|
||||||
rows_plain[base + 24..base + 28].copy_from_slice(
|
|
||||||
&u32::try_from(data_offsets[index])
|
|
||||||
.expect("data offset overflow")
|
|
||||||
.to_le_bytes(),
|
|
||||||
);
|
|
||||||
rows_plain[base + 28..base + 32].copy_from_slice(&packed_size.to_le_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
if output.len() < table_end {
|
|
||||||
output.resize(table_end, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
output[0..2].copy_from_slice(b"NL");
|
|
||||||
output[2] = 0;
|
|
||||||
output[3] = 1;
|
|
||||||
output[4..6].copy_from_slice(
|
|
||||||
&i16::try_from(count)
|
|
||||||
.expect("entry count overflow")
|
|
||||||
.to_le_bytes(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let presorted_flag = if opts.presorted { 0xABBA_u16 } else { 0_u16 };
|
|
||||||
output[14..16].copy_from_slice(&presorted_flag.to_le_bytes());
|
|
||||||
output[20..24].copy_from_slice(&opts.seed.to_le_bytes());
|
|
||||||
|
|
||||||
let encrypted_table = xor_stream(&rows_plain, (opts.seed & 0xFFFF) as u16);
|
|
||||||
output[32..table_end].copy_from_slice(&encrypted_table);
|
|
||||||
|
|
||||||
if opts.add_ao_trailer {
|
|
||||||
output.extend_from_slice(b"AO");
|
|
||||||
output.extend_from_slice(&opts.overlay.to_le_bytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
output
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rsli_read_unpack_and_repack_all_files() {
|
|
||||||
let files = rsli_test_files();
|
|
||||||
assert!(!files.is_empty(), "testdata/rsli contains no RsLi archives");
|
|
||||||
|
|
||||||
let checked = files.len();
|
|
||||||
let mut success = 0usize;
|
|
||||||
let mut failures = Vec::new();
|
|
||||||
|
|
||||||
for path in files {
|
|
||||||
let display_path = path.display().to_string();
|
|
||||||
let result = catch_unwind(AssertUnwindSafe(|| {
|
|
||||||
let original = fs::read(&path).expect("failed to read archive");
|
|
||||||
let library = Library::open_path(&path)
|
|
||||||
.unwrap_or_else(|err| panic!("failed to open {}: {err}", path.display()));
|
|
||||||
|
|
||||||
let count = library.entry_count();
|
|
||||||
assert_eq!(
|
|
||||||
count,
|
|
||||||
library.entries().count(),
|
|
||||||
"entry count mismatch: {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
for idx in 0..count {
|
|
||||||
let id = EntryId(idx as u32);
|
|
||||||
let meta_ref = library
|
|
||||||
.get(id)
|
|
||||||
.unwrap_or_else(|| panic!("missing entry #{idx} in {}", path.display()));
|
|
||||||
|
|
||||||
let loaded = library.load(id).unwrap_or_else(|err| {
|
|
||||||
panic!("load failed for {} entry #{idx}: {err}", path.display())
|
|
||||||
});
|
|
||||||
|
|
||||||
let packed = library.load_packed(id).unwrap_or_else(|err| {
|
|
||||||
panic!(
|
|
||||||
"load_packed failed for {} entry #{idx}: {err}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let unpacked = library.unpack(&packed).unwrap_or_else(|err| {
|
|
||||||
panic!("unpack failed for {} entry #{idx}: {err}", path.display())
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
loaded,
|
|
||||||
unpacked,
|
|
||||||
"load != unpack in {} entry #{idx}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let written = library.load_into(id, &mut out).unwrap_or_else(|err| {
|
|
||||||
panic!(
|
|
||||||
"load_into failed for {} entry #{idx}: {err}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
written,
|
|
||||||
loaded.len(),
|
|
||||||
"load_into size mismatch in {} entry #{idx}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
out,
|
|
||||||
loaded,
|
|
||||||
"load_into payload mismatch in {} entry #{idx}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let fast = library.load_fast(id).unwrap_or_else(|err| {
|
|
||||||
panic!(
|
|
||||||
"load_fast failed for {} entry #{idx}: {err}",
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
assert_eq!(
|
|
||||||
fast.as_slice(),
|
|
||||||
loaded.as_slice(),
|
|
||||||
"load_fast mismatch in {} entry #{idx}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let found = library.find(&meta_ref.meta.name).unwrap_or_else(|| {
|
|
||||||
panic!(
|
|
||||||
"find failed for '{}' in {}",
|
|
||||||
meta_ref.meta.name,
|
|
||||||
path.display()
|
|
||||||
)
|
|
||||||
});
|
|
||||||
let found_meta = library.get(found).expect("find returned invalid entry id");
|
|
||||||
assert_eq!(
|
|
||||||
found_meta.meta.name,
|
|
||||||
meta_ref.meta.name,
|
|
||||||
"find returned a different entry in {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let rebuilt = library
|
|
||||||
.rebuild_from_parsed_metadata()
|
|
||||||
.unwrap_or_else(|err| panic!("rebuild failed for {}: {err}", path.display()));
|
|
||||||
assert_eq!(
|
|
||||||
rebuilt,
|
|
||||||
original,
|
|
||||||
"byte-to-byte roundtrip mismatch for {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
}));
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(()) => success += 1,
|
|
||||||
Err(payload) => failures.push(format!("{}: {}", display_path, panic_message(payload))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let failed = failures.len();
|
|
||||||
eprintln!(
|
|
||||||
"RsLi summary: checked={}, success={}, failed={}",
|
|
||||||
checked, success, failed
|
|
||||||
);
|
|
||||||
if !failures.is_empty() {
|
|
||||||
panic!(
|
|
||||||
"RsLi validation failed.\nsummary: checked={}, success={}, failed={}\n{}",
|
|
||||||
checked,
|
|
||||||
success,
|
|
||||||
failed,
|
|
||||||
failures.join("\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rsli_synthetic_all_methods_roundtrip() {
|
|
||||||
let entries = vec![
|
|
||||||
SyntheticRsliEntry {
|
|
||||||
name: "M_NONE".to_string(),
|
|
||||||
method_raw: 0x000,
|
|
||||||
plain: b"plain-data".to_vec(),
|
|
||||||
declared_packed_size: None,
|
|
||||||
},
|
|
||||||
SyntheticRsliEntry {
|
|
||||||
name: "M_XOR".to_string(),
|
|
||||||
method_raw: 0x020,
|
|
||||||
plain: b"xor-only".to_vec(),
|
|
||||||
declared_packed_size: None,
|
|
||||||
},
|
|
||||||
SyntheticRsliEntry {
|
|
||||||
name: "M_LZSS".to_string(),
|
|
||||||
method_raw: 0x040,
|
|
||||||
plain: b"lzss literals payload".to_vec(),
|
|
||||||
declared_packed_size: None,
|
|
||||||
},
|
|
||||||
SyntheticRsliEntry {
|
|
||||||
name: "M_XLZS".to_string(),
|
|
||||||
method_raw: 0x060,
|
|
||||||
plain: b"xor lzss payload".to_vec(),
|
|
||||||
declared_packed_size: None,
|
|
||||||
},
|
|
||||||
SyntheticRsliEntry {
|
|
||||||
name: "M_LZHU".to_string(),
|
|
||||||
method_raw: 0x080,
|
|
||||||
plain: b"huffman literals payload".to_vec(),
|
|
||||||
declared_packed_size: None,
|
|
||||||
},
|
|
||||||
SyntheticRsliEntry {
|
|
||||||
name: "M_XLZH".to_string(),
|
|
||||||
method_raw: 0x0A0,
|
|
||||||
plain: b"xor huffman payload".to_vec(),
|
|
||||||
declared_packed_size: None,
|
|
||||||
},
|
|
||||||
SyntheticRsliEntry {
|
|
||||||
name: "M_DEFL".to_string(),
|
|
||||||
method_raw: 0x100,
|
|
||||||
plain: b"deflate payload with repetition repetition repetition".to_vec(),
|
|
||||||
declared_packed_size: None,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
let bytes = build_rsli_bytes(
|
|
||||||
&entries,
|
|
||||||
&RsliBuildOptions {
|
|
||||||
seed: 0xA1B2_C3D4,
|
|
||||||
presorted: false,
|
|
||||||
overlay: 0,
|
|
||||||
add_ao_trailer: false,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let path = write_temp_file("rsli-all-methods", &bytes);
|
|
||||||
|
|
||||||
let library = Library::open_path(&path).expect("open synthetic rsli failed");
|
|
||||||
assert_eq!(library.entry_count(), entries.len());
|
|
||||||
|
|
||||||
for entry in &entries {
|
|
||||||
let id = library
|
|
||||||
.find(&entry.name)
|
|
||||||
.unwrap_or_else(|| panic!("find failed for {}", entry.name));
|
|
||||||
let loaded = library
|
|
||||||
.load(id)
|
|
||||||
.unwrap_or_else(|err| panic!("load failed for {}: {err}", entry.name));
|
|
||||||
assert_eq!(
|
|
||||||
loaded, entry.plain,
|
|
||||||
"decoded payload mismatch for {}",
|
|
||||||
entry.name
|
|
||||||
);
|
|
||||||
|
|
||||||
let packed = library
|
|
||||||
.load_packed(id)
|
|
||||||
.unwrap_or_else(|err| panic!("load_packed failed for {}: {err}", entry.name));
|
|
||||||
let unpacked = library
|
|
||||||
.unpack(&packed)
|
|
||||||
.unwrap_or_else(|err| panic!("unpack failed for {}: {err}", entry.name));
|
|
||||||
assert_eq!(unpacked, entry.plain, "unpack mismatch for {}", entry.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rsli_synthetic_overlay_and_ao_trailer() {
|
|
||||||
let entries = vec![SyntheticRsliEntry {
|
|
||||||
name: "OVERLAY".to_string(),
|
|
||||||
method_raw: 0x040,
|
|
||||||
plain: b"overlay-data".to_vec(),
|
|
||||||
declared_packed_size: None,
|
|
||||||
}];
|
|
||||||
|
|
||||||
let bytes = build_rsli_bytes(
|
|
||||||
&entries,
|
|
||||||
&RsliBuildOptions {
|
|
||||||
seed: 0x4433_2211,
|
|
||||||
presorted: true,
|
|
||||||
overlay: 128,
|
|
||||||
add_ao_trailer: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let path = write_temp_file("rsli-overlay", &bytes);
|
|
||||||
|
|
||||||
let library = Library::open_path_with(
|
|
||||||
&path,
|
|
||||||
OpenOptions {
|
|
||||||
allow_ao_trailer: true,
|
|
||||||
allow_deflate_eof_plus_one: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("open with AO trailer enabled failed");
|
|
||||||
|
|
||||||
let id = library.find("OVERLAY").expect("find overlay entry failed");
|
|
||||||
let payload = library.load(id).expect("load overlay entry failed");
|
|
||||||
assert_eq!(payload, b"overlay-data");
|
|
||||||
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rsli_deflate_eof_plus_one_quirk() {
|
|
||||||
let plain = b"quirk deflate payload".to_vec();
|
|
||||||
let packed = deflate_raw(&plain);
|
|
||||||
let declared = u32::try_from(packed.len() + 1).expect("declared size overflow");
|
|
||||||
|
|
||||||
let entries = vec![SyntheticRsliEntry {
|
|
||||||
name: "QUIRK".to_string(),
|
|
||||||
method_raw: 0x100,
|
|
||||||
plain,
|
|
||||||
declared_packed_size: Some(declared),
|
|
||||||
}];
|
|
||||||
let bytes = build_rsli_bytes(&entries, &RsliBuildOptions::default());
|
|
||||||
let path = write_temp_file("rsli-deflate-quirk", &bytes);
|
|
||||||
|
|
||||||
let lib_ok = Library::open_path_with(
|
|
||||||
&path,
|
|
||||||
OpenOptions {
|
|
||||||
allow_ao_trailer: true,
|
|
||||||
allow_deflate_eof_plus_one: true,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.expect("open with EOF+1 quirk enabled failed");
|
|
||||||
let loaded = lib_ok
|
|
||||||
.load(lib_ok.find("QUIRK").expect("find quirk entry failed"))
|
|
||||||
.expect("load quirk entry failed");
|
|
||||||
assert_eq!(loaded, b"quirk deflate payload");
|
|
||||||
|
|
||||||
match Library::open_path_with(
|
|
||||||
&path,
|
|
||||||
OpenOptions {
|
|
||||||
allow_ao_trailer: true,
|
|
||||||
allow_deflate_eof_plus_one: false,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Err(Error::DeflateEofPlusOneQuirkRejected { id }) => assert_eq!(id, 0),
|
|
||||||
other => panic!("expected DeflateEofPlusOneQuirkRejected, got {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn rsli_validation_error_cases() {
|
|
||||||
let valid = build_rsli_bytes(
|
|
||||||
&[SyntheticRsliEntry {
|
|
||||||
name: "BASE".to_string(),
|
|
||||||
method_raw: 0x000,
|
|
||||||
plain: b"abc".to_vec(),
|
|
||||||
declared_packed_size: None,
|
|
||||||
}],
|
|
||||||
&RsliBuildOptions::default(),
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut bad_magic = valid.clone();
|
|
||||||
bad_magic[0..2].copy_from_slice(b"XX");
|
|
||||||
let path = write_temp_file("rsli-bad-magic", &bad_magic);
|
|
||||||
match Library::open_path(&path) {
|
|
||||||
Err(Error::InvalidMagic { .. }) => {}
|
|
||||||
other => panic!("expected InvalidMagic, got {other:?}"),
|
|
||||||
}
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
let mut bad_version = valid.clone();
|
|
||||||
bad_version[3] = 2;
|
|
||||||
let path = write_temp_file("rsli-bad-version", &bad_version);
|
|
||||||
match Library::open_path(&path) {
|
|
||||||
Err(Error::UnsupportedVersion { got }) => assert_eq!(got, 2),
|
|
||||||
other => panic!("expected UnsupportedVersion, got {other:?}"),
|
|
||||||
}
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
let mut bad_count = valid.clone();
|
|
||||||
bad_count[4..6].copy_from_slice(&(-1_i16).to_le_bytes());
|
|
||||||
let path = write_temp_file("rsli-bad-count", &bad_count);
|
|
||||||
match Library::open_path(&path) {
|
|
||||||
Err(Error::InvalidEntryCount { got }) => assert_eq!(got, -1),
|
|
||||||
other => panic!("expected InvalidEntryCount, got {other:?}"),
|
|
||||||
}
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
let mut bad_table = valid.clone();
|
|
||||||
bad_table[4..6].copy_from_slice(&100_i16.to_le_bytes());
|
|
||||||
let path = write_temp_file("rsli-bad-table", &bad_table);
|
|
||||||
match Library::open_path(&path) {
|
|
||||||
Err(Error::EntryTableOutOfBounds { .. }) => {}
|
|
||||||
other => panic!("expected EntryTableOutOfBounds, got {other:?}"),
|
|
||||||
}
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
let mut unknown_method = build_rsli_bytes(
|
|
||||||
&[SyntheticRsliEntry {
|
|
||||||
name: "UNK".to_string(),
|
|
||||||
method_raw: 0x120,
|
|
||||||
plain: b"x".to_vec(),
|
|
||||||
declared_packed_size: None,
|
|
||||||
}],
|
|
||||||
&RsliBuildOptions::default(),
|
|
||||||
);
|
|
||||||
// Force truly unknown method by writing 0x1C0 mask bits.
|
|
||||||
let row = 32;
|
|
||||||
unknown_method[row + 16..row + 18].copy_from_slice(&(0x1C0_u16).to_le_bytes());
|
|
||||||
// Re-encrypt table with the same seed.
|
|
||||||
let seed = u32::from_le_bytes([
|
|
||||||
unknown_method[20],
|
|
||||||
unknown_method[21],
|
|
||||||
unknown_method[22],
|
|
||||||
unknown_method[23],
|
|
||||||
]);
|
|
||||||
let mut plain_row = vec![0u8; 32];
|
|
||||||
plain_row.copy_from_slice(&unknown_method[32..64]);
|
|
||||||
plain_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16);
|
|
||||||
plain_row[16..18].copy_from_slice(&(0x1C0_u16).to_le_bytes());
|
|
||||||
let encrypted_row = xor_stream(&plain_row, (seed & 0xFFFF) as u16);
|
|
||||||
unknown_method[32..64].copy_from_slice(&encrypted_row);
|
|
||||||
|
|
||||||
let path = write_temp_file("rsli-unknown-method", &unknown_method);
|
|
||||||
let lib = Library::open_path(&path).expect("open archive with unknown method failed");
|
|
||||||
match lib.load(EntryId(0)) {
|
|
||||||
Err(Error::UnsupportedMethod { raw }) => assert_eq!(raw, 0x1C0),
|
|
||||||
other => panic!("expected UnsupportedMethod, got {other:?}"),
|
|
||||||
}
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
let mut bad_packed = valid.clone();
|
|
||||||
bad_packed[32 + 28..32 + 32].copy_from_slice(&0xFFFF_FFF0_u32.to_le_bytes());
|
|
||||||
let path = write_temp_file("rsli-bad-packed", &bad_packed);
|
|
||||||
match Library::open_path(&path) {
|
|
||||||
Err(Error::PackedSizePastEof { .. }) => {}
|
|
||||||
other => panic!("expected PackedSizePastEof, got {other:?}"),
|
|
||||||
}
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
|
|
||||||
let mut with_bad_overlay = valid;
|
|
||||||
with_bad_overlay.extend_from_slice(b"AO");
|
|
||||||
with_bad_overlay.extend_from_slice(&0xFFFF_FFFF_u32.to_le_bytes());
|
|
||||||
let path = write_temp_file("rsli-bad-overlay", &with_bad_overlay);
|
|
||||||
match Library::open_path_with(
|
|
||||||
&path,
|
|
||||||
OpenOptions {
|
|
||||||
allow_ao_trailer: true,
|
|
||||||
allow_deflate_eof_plus_one: true,
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Err(Error::MediaOverlayOutOfBounds { .. }) => {}
|
|
||||||
other => panic!("expected MediaOverlayOutOfBounds, got {other:?}"),
|
|
||||||
}
|
|
||||||
let _ = fs::remove_file(&path);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
# Эффекты и частицы
|
|
||||||
|
|
||||||
Пока что — **не байтовая спецификация**, а “карта” по тому, что видно в библиотеках. Полную документацию по эффектам/шейдерам/частицам можно будет сделать после того, как:
|
|
||||||
|
|
||||||
- найдём формат эффекта (файл/ресурс),
|
|
||||||
- найдём точку загрузки/парсинга,
|
|
||||||
- найдём точки рендера (создание буферов/вершинного формата/материалов).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Что видно по `Effect.dll`
|
|
||||||
|
|
||||||
- Есть экспорт `CreateFxManager(...)`, который создаёт менеджер эффектов и регистрирует его в движке.
|
|
||||||
- Внутри много логики “сообщений/команд” через виртуальные вызовы (похоже на общий компонентный интерфейс).
|
|
||||||
- Явного парсера формата эффекта (по типу “читать заголовок, читать эмиттеры…”) в найденных местах пока не идентифицировано.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Что видно по `Terrain.dll` (рендер‑статистика частиц)
|
|
||||||
|
|
||||||
В `Terrain.dll` есть отладочная/статистическая телеметрия:
|
|
||||||
|
|
||||||
- количество отрендеренных частиц (`Rendered particles`)
|
|
||||||
- количество батчей (`Rendered batches`)
|
|
||||||
- количество отрендеренных треугольников
|
|
||||||
|
|
||||||
Это подтверждает:
|
|
||||||
|
|
||||||
- частицы рендерятся батчами,
|
|
||||||
- они интегрированы в общий 3D‑рендер (через тот же графический слой).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Что важно для совместимости
|
|
||||||
|
|
||||||
Даже без точного формата эффекта, из поведения оригинала следует:
|
|
||||||
|
|
||||||
- Эффекты/частицы завязаны на общий набор рендер‑фич (фильтрация/мультитекстурность/блендинг).
|
|
||||||
- На слабом железе (и для минимализма) должны работать деградации:
|
|
||||||
- без мипмапов,
|
|
||||||
- без bilinear/trilinear,
|
|
||||||
- без multitexturing,
|
|
||||||
- возможно с 16‑бит текстурами.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) План “докопать” до формата эффектов
|
|
||||||
|
|
||||||
1. Найти **точку создания эффекта по имени/ID**:
|
|
||||||
- поискать места, где в строки/лог пишется имя эффекта,
|
|
||||||
- найти функции, которые принимают “путь/имя” и возвращают handle.
|
|
||||||
|
|
||||||
2. Найти **точку загрузки данных**:
|
|
||||||
- чтение из NRes/RsLi ресурса,
|
|
||||||
- распаковка/декодирование.
|
|
||||||
|
|
||||||
3. Зафиксировать **структуру данных эффекта в памяти**:
|
|
||||||
- эмиттеры,
|
|
||||||
- спауны,
|
|
||||||
- lifetime,
|
|
||||||
- ключи размера/цвета,
|
|
||||||
- привязка к текстурам/материалам.
|
|
||||||
|
|
||||||
4. Найти рендер‑код:
|
|
||||||
- какой vertex format у частицы,
|
|
||||||
- как формируются квадраты/ленты (billboard/trail),
|
|
||||||
- какие state’ы включаются.
|
|
||||||
|
|
||||||
После этого можно будет выпустить полноценный документ “FX format”.
|
|
||||||
@@ -1,314 +0,0 @@
|
|||||||
# 3D модели (MSH / AniMesh)
|
|
||||||
|
|
||||||
Документ описывает **модельные ресурсы** старого движка по результатам анализа `AniMesh.dll` и сопутствующих библиотек.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0) Термины
|
|
||||||
|
|
||||||
- **Модель** — набор геометрии + иерархия узлов (node/bone) + дополнительные таблицы (батчи/слоты/треки).
|
|
||||||
- **Node** — узел иерархии (часть/кость). Визуально: “кусок” модели, которому можно применять transform (rigid).
|
|
||||||
- **LOD** — уровень детализации. В коде обнаружены **3 уровня LOD: 0..2** (и “текущий” LOD через `-1`).
|
|
||||||
- **Slot** — связка “(node, LOD, group) → диапазоны геометрии + bounds”.
|
|
||||||
- **Batch** — рендер‑пакет: “материал + диапазон индексов + baseVertex”.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Архитектура модели в движке (как это реально рисуется)
|
|
||||||
|
|
||||||
### 1.1 Рендер‑модель: rigid‑скининг (по узлам), без весов вершин
|
|
||||||
|
|
||||||
По коду выборка геометрии делается так:
|
|
||||||
|
|
||||||
1. Выбирается **LOD** (в объекте хранится `current_lod`, см. `sub_100124D0`).
|
|
||||||
2. Для каждого узла **node** выбирается **slot** по `(nodeIndex, group, lod)`:
|
|
||||||
- Если lod == `-1`, то берётся `current_lod`.
|
|
||||||
- Если в node‑таблице хранится `0xFFFF`, slot отсутствует.
|
|
||||||
3. Slot задаёт **диапазон batch’ей** (`batch_start`, `batch_count`).
|
|
||||||
4. Рендерер получает batch‑диапазон и для каждого batch делает `DrawIndexedPrimitive` (абстрактный вызов через графический интерфейс движка), используя:
|
|
||||||
- `baseVertex`
|
|
||||||
- `indexStart`
|
|
||||||
- `indexCount`
|
|
||||||
- материал (индекс материала/шейдера в batch’е)
|
|
||||||
|
|
||||||
**Важно:** в “модельном” формате не видно классических skin weights (4 bone indices + 4 weights). Это очень похоже на “rigid parts”: каждый batch/часть привязан к одному узлу (или группе узлов) и рендерится с матрицей этого узла.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Набор ресурсов модели (что лежит внутри “файла модели”)
|
|
||||||
|
|
||||||
Ниже перечислены ресурсы, которые гарантированно встречаются в загрузчике `AniMesh`:
|
|
||||||
|
|
||||||
- **Res1** — node table (таблица узлов и LOD‑слотов).
|
|
||||||
- **Res2** — header + slot table (слоты и bounds).
|
|
||||||
- **Res3** — vertex positions (float3).
|
|
||||||
- **Res4** — packed normals (4 байта на вершину; s8‑компоненты).
|
|
||||||
- **Res5** — packed UV0 (4 байта на вершину; s16 U,V).
|
|
||||||
- **Res6** — index buffer (u16 индексы).
|
|
||||||
- **Res7** — triangle descriptors (по 16 байт на треугольник).
|
|
||||||
- **Res8** — keyframes / anim track data (используется в интерполяции).
|
|
||||||
- **Res10** — string table (имена: материалов/узлов/частей — точный маппинг зависит от вызывающей стороны).
|
|
||||||
- **Res13** — batch table (по 20 байт на batch).
|
|
||||||
- **Res19** — дополнительная таблица для анимации/маппинга (используется вместе с Res8; точная семантика пока не восстановлена).
|
|
||||||
|
|
||||||
Опциональные (встречаются условно, если ресурс присутствует):
|
|
||||||
|
|
||||||
- **Res15** — per‑vertex stream, stride 8 (семантика не подтверждена).
|
|
||||||
- **Res16** — per‑vertex stream, stride 8, при этом движок создаёт **два “под‑потока” по 4 байта** (см. ниже).
|
|
||||||
- **Res18** — per‑vertex stream, stride 4 (семантика не подтверждена).
|
|
||||||
- **Res20** — дополнительный массив + отдельное “count/meta” поле из заголовка ресурса.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) Декодирование базовой геометрии
|
|
||||||
|
|
||||||
### 3.1 Positions (Res3)
|
|
||||||
|
|
||||||
- Структура: массив `float3`.
|
|
||||||
- Stride: `12`.
|
|
||||||
- Использование: `pos = *(float3*)(res3 + 12*vertexIndex)`.
|
|
||||||
|
|
||||||
### 3.2 UV0 (Res5) — packed s16
|
|
||||||
|
|
||||||
- Stride: `4`.
|
|
||||||
- Формат: `int16 u, int16 v`
|
|
||||||
- Нормализация (из кода): `uv = (u, v) * (1/1024)`
|
|
||||||
|
|
||||||
То есть:
|
|
||||||
|
|
||||||
- `u_float = (int16)u / 1024.0`
|
|
||||||
- `v_float = (int16)v / 1024.0`
|
|
||||||
|
|
||||||
### 3.3 Normals (Res4) — packed s8
|
|
||||||
|
|
||||||
- Stride: `4`.
|
|
||||||
- Формат (минимально подтверждено): `int8 nx, int8 ny, int8 nz, int8 nw(?)`
|
|
||||||
- Нормализация (из кода): множитель `1/128 = 0.0078125`
|
|
||||||
|
|
||||||
То есть:
|
|
||||||
|
|
||||||
- `n = (nx, ny, nz) / 128.0`
|
|
||||||
|
|
||||||
4‑й байт пока не подтверждён (встречается как паддинг/знак/индекс — нужно дальше копать).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Таблицы, задающие разбиение геометрии
|
|
||||||
|
|
||||||
### 4.1 Batch table (Res13), запись 20 байт
|
|
||||||
|
|
||||||
Batch используется в рендере и в обходе треугольников. Из обхода достоверно:
|
|
||||||
|
|
||||||
- `indexCount` читается как `u16` по смещению `+8`.
|
|
||||||
- `indexStart` используется как **u32 по смещению `+10`** (движок читает dword и умножает на 2 для смещения в u16‑индексах).
|
|
||||||
- `baseVertex` читается как `u32` по смещению `+16`.
|
|
||||||
|
|
||||||
Рекомендуемая реконструкция:
|
|
||||||
|
|
||||||
- `+0 u16 batchFlags` — используется для фильтрации (битовая маска).
|
|
||||||
- `+2 u16 materialIndex` — очень похоже на индекс материала/шейдера.
|
|
||||||
- `+4 u16 unk4`
|
|
||||||
- `+6 u16 unk6` — **возможный** `nodeIndex` (часто именно здесь держат привязку батча к кости).
|
|
||||||
- `+8 u16 indexCount` — число индексов (кратно 3 для треугольников).
|
|
||||||
- `+10 u32 indexStart` — стартовый индекс в общем index buffer (в элементах u16).
|
|
||||||
- `+14 u16 unk14` — возможно “primitive/strip mode” или ещё один флаг.
|
|
||||||
- `+16 u32 baseVertex` — смещение вершинного индекса (в вершинах).
|
|
||||||
|
|
||||||
### 4.2 Triangle descriptors (Res7), запись 16 байт
|
|
||||||
|
|
||||||
Треугольные дескрипторы используются при итерации треугольников (коллизии/выбор/тесты):
|
|
||||||
|
|
||||||
- `+0 u16 triFlags` — используется для фильтрации (битовая маска)
|
|
||||||
- Остальные поля пока не подтверждены (вероятно: доп. флаги, группа, precomputed normal, ID поверхности и т.п.)
|
|
||||||
|
|
||||||
**Важно:** индексы вершин треугольника берутся **из index buffer (Res6)** через `indexStart/indexCount` batch’а. TriDesc не хранит сами индексы.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5) Slot table (Res2 + смещение 140), запись 68 байт
|
|
||||||
|
|
||||||
Slot — ключевая структура, по которой движок:
|
|
||||||
|
|
||||||
- получает bounds (AABB + sphere),
|
|
||||||
- получает диапазон batch’ей для рендера/обхода,
|
|
||||||
- получает стартовый индекс треугольников (triStart) в TriDesc.
|
|
||||||
|
|
||||||
В коде Slot читается как `u16`‑поля + как `float`‑поля (AABB/sphere). Подтверждённая раскладка:
|
|
||||||
|
|
||||||
### 5.1 Заголовок slot (первые 8 байт)
|
|
||||||
|
|
||||||
- `+0 u16 triStart` — индекс первого треугольника в `Res7` (TriDesc), используемый в обходе.
|
|
||||||
- `+2 u16 slotFlagsOrUnk` — пока не восстановлено (не путать с batchFlags/triFlags).
|
|
||||||
- `+4 u16 batchStart` — индекс первого batch’а в `Res13`.
|
|
||||||
- `+6 u16 batchCount` — количество batch’ей.
|
|
||||||
|
|
||||||
### 5.2 AABB (локальные границы, 24 байта)
|
|
||||||
|
|
||||||
- `+8 float aabbMin.x`
|
|
||||||
- `+12 float aabbMin.y`
|
|
||||||
- `+16 float aabbMin.z`
|
|
||||||
- `+20 float aabbMax.x`
|
|
||||||
- `+24 float aabbMax.y`
|
|
||||||
- `+28 float aabbMax.z`
|
|
||||||
|
|
||||||
### 5.3 Bounding sphere (локальные границы, 16 байт)
|
|
||||||
|
|
||||||
- `+32 float sphereCenter.x`
|
|
||||||
- `+36 float sphereCenter.y`
|
|
||||||
- `+40 float sphereCenter.z`
|
|
||||||
- `+44 float sphereRadius`
|
|
||||||
|
|
||||||
### 5.4 Хвост (20 байт)
|
|
||||||
|
|
||||||
- `+48..+67` — не используется в найденных вызовах bounds/рендера; назначение неизвестно. Возможные кандидаты: LOD‑дистанции, доп. bounds, служебные поля экспортёра.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6) Node table (Res1), запись 19 \* u16 на узел (38 байт)
|
|
||||||
|
|
||||||
Node table — это не “матрицы узлов”, а компактная карта слотов по LOD и группам.
|
|
||||||
|
|
||||||
Движок вычисляет адрес слова так:
|
|
||||||
|
|
||||||
`wordIndex = nodeIndex * 19 + lod * 5 + group + 4`
|
|
||||||
|
|
||||||
где:
|
|
||||||
|
|
||||||
- `lod` в диапазоне `0..2` (**три уровня LOD**)
|
|
||||||
- `group` в диапазоне `0..4` (**пять групп слотов**)
|
|
||||||
- если вместо `lod` передать `-1`, движок подставит `current_lod` из инстанса.
|
|
||||||
|
|
||||||
Из этого следует структура узла:
|
|
||||||
|
|
||||||
### 6.1 Заголовок узла (первые 4 u16)
|
|
||||||
|
|
||||||
- `u16 hdr0`
|
|
||||||
- `u16 hdr1`
|
|
||||||
- `u16 hdr2`
|
|
||||||
- `u16 hdr3`
|
|
||||||
|
|
||||||
Семантика заголовка узла **пока не восстановлена** (кандидаты: parent/firstChild/nextSibling/flags).
|
|
||||||
|
|
||||||
### 6.2 SlotIndex‑матрица: 3 LOD \* 5 groups = 15 u16
|
|
||||||
|
|
||||||
Дальше идут 15 слов:
|
|
||||||
|
|
||||||
- для `lod=0`: `slotIndex[group0..4]`
|
|
||||||
- для `lod=1`: `slotIndex[group0..4]`
|
|
||||||
- для `lod=2`: `slotIndex[group0..4]`
|
|
||||||
|
|
||||||
`slotIndex` — это индекс в slot table (`Res2+140`), либо `0xFFFF` если слота нет.
|
|
||||||
|
|
||||||
**Группы (0..4)**: в коде чаще всего используется `group=0`. Остальные группы встречаются как параметр обхода, но назначение (например, “коллизия”, “тени”, “декали”, “альфа‑геометрия” и т.п.) пока не доказано. В документации ниже они называются просто `group`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7) Рендер‑проход (рекомендуемая реконструкция)
|
|
||||||
|
|
||||||
Минимальный корректный порт рендера может повторять логику:
|
|
||||||
|
|
||||||
1. Определить `current_lod` (0..2) для модели (по дистанции/настройкам).
|
|
||||||
2. Для каждого node:
|
|
||||||
- взять slotIndex = node.slotIndex[current_lod][group=0]
|
|
||||||
- если `0xFFFF` — пропустить
|
|
||||||
- slot = slotTable[slotIndex]
|
|
||||||
3. Для slot’а:
|
|
||||||
- для i in `0 .. slot.batchCount-1`:
|
|
||||||
- batch = batchTable[slot.batchStart + i]
|
|
||||||
- применить материал `materialIndex`
|
|
||||||
- применить transform узла (как минимум: rootTransform \* nodeTransform)
|
|
||||||
- нарисовать индексированную геометрию:
|
|
||||||
- baseVertex = batch.baseVertex
|
|
||||||
- indexStart = batch.indexStart
|
|
||||||
- indexCount = batch.indexCount
|
|
||||||
4. Для culling:
|
|
||||||
- использовать slot AABB/sphere, трансформируя их матрицей узла/инстанса.
|
|
||||||
- при неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (так делает оригинальный код).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8) Обход треугольников (коллизия/пикинг/дебаг)
|
|
||||||
|
|
||||||
В движке есть универсальный обход:
|
|
||||||
|
|
||||||
- Идём по slot’ам (node, lod, group).
|
|
||||||
- Для каждого slot:
|
|
||||||
- for batch in slot.batchRange:
|
|
||||||
- получаем индексы из Res6 (indexStart/indexCount)
|
|
||||||
- triCount = (indexCount + 2) / 3
|
|
||||||
- параллельно двигаем указатель TriDesc начиная с `triStart`
|
|
||||||
- для каждого треугольника:
|
|
||||||
- читаем `triFlags` (TriDesc[0])
|
|
||||||
- фильтруем по маскам
|
|
||||||
- вызываем callback, которому доступны:
|
|
||||||
- triDesc (16 байт)
|
|
||||||
- три индекса (из index buffer)
|
|
||||||
- три позиции (из Res3 через baseVertex + индекс)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9) Опциональные vertex streams (Res15/16/18/20) — текущий статус
|
|
||||||
|
|
||||||
Эти ресурсы загружаются, но в найденных местах пока **нет однозначного декодера**. Что точно видно по загрузчику:
|
|
||||||
|
|
||||||
- **Res15**: stride 8, массив на вершину.
|
|
||||||
- кандидаты: `float2 uv1` (lightmap), либо 4×`int16` (2 UV‑пары), либо что‑то иное.
|
|
||||||
|
|
||||||
- **Res16**: stride 8, но движок создаёт два “под‑потока”:
|
|
||||||
- streamA = res16 + 0, stride 8
|
|
||||||
- streamB = res16 + 4, stride 8 Это сильно похоже на “два packed‑вектора по 4 байта”, например `tangent` и `bitangent` (s8×4).
|
|
||||||
|
|
||||||
- **Res18**: stride 4, массив на вершину.
|
|
||||||
- кандидаты: `D3DCOLOR` (RGBA), либо packed‑параметры освещения/окклюзии.
|
|
||||||
|
|
||||||
- **Res20**: присутствует не всегда; отдельно читается `count/meta` поле из заголовка ресурса.
|
|
||||||
- кандидаты: дополнительная таблица соответствий (vertex remap), либо ускорение для эффектов/деформаций.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10) Как “создавать” модели (экспортёр / конвертер) — практическая рекомендация
|
|
||||||
|
|
||||||
Чтобы собрать совместимый формат (минимум, достаточный для рендера и коллизии), нужно:
|
|
||||||
|
|
||||||
1. Сформировать единый массив вершин:
|
|
||||||
- positions (Res3)
|
|
||||||
- packed normals (Res4) — если хотите сохранить оригинальную упаковку
|
|
||||||
- packed uv0 (Res5)
|
|
||||||
|
|
||||||
2. Сформировать index buffer (Res6) u16.
|
|
||||||
|
|
||||||
3. Сформировать batch table (Res13):
|
|
||||||
- сгруппировать треугольники по (материал, узел/часть, режим)
|
|
||||||
- записать `baseVertex`, `indexStart`, `indexCount`
|
|
||||||
- заполнить неизвестные поля нулями (пока нет доказанной семантики).
|
|
||||||
|
|
||||||
4. Сформировать triangle descriptor table (Res7):
|
|
||||||
- на каждый треугольник 16 байт
|
|
||||||
- минимум: `triFlags=0`
|
|
||||||
- остальное — 0.
|
|
||||||
|
|
||||||
5. Сформировать slot table (Res2+140):
|
|
||||||
- для каждого (node, lod, group) задать:
|
|
||||||
- triStart (индекс начала triDesc для обхода)
|
|
||||||
- batchStart/batchCount
|
|
||||||
- AABB и bounding sphere в локальных координатах узла/части
|
|
||||||
- неиспользуемые поля хвоста = 0.
|
|
||||||
|
|
||||||
6. Сформировать node table (Res1):
|
|
||||||
- для каждого node:
|
|
||||||
- 4 заголовочных u16 (пока можно 0)
|
|
||||||
- 15 slotIndex’ов (LOD0..2 × group0..4), `0xFFFF` где нет слота.
|
|
||||||
|
|
||||||
7. Анимацию/Res8/Res19/Res11:
|
|
||||||
- если не нужна — можно отсутствующими, но надо проверить, что загрузчик/движок допускает “статическую” модель без этих ресурсов (в оригинале много логики завязано на них).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 11) Что ещё нужно восстановить, чтобы документация стала “закрывающей” на 100%
|
|
||||||
|
|
||||||
1. Точная семантика `batch.unk6` (вероятный nodeIndex) и `batch.unk4/unk14`.
|
|
||||||
2. Полная раскладка TriDesc16 (кроме triFlags).
|
|
||||||
3. Назначение `slotFlagsOrUnk`.
|
|
||||||
4. Семантика групп `group=1..4` в node‑таблице.
|
|
||||||
5. Назначение и декодирование Res15/Res16/Res18/Res20.
|
|
||||||
6. Связь строковой таблицы (Res10) с материалами/узлами (кто именно как индексирует строки).
|
|
||||||
@@ -1,718 +0,0 @@
|
|||||||
# Форматы игровых ресурсов
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов:
|
|
||||||
|
|
||||||
1. **NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей.
|
|
||||||
|
|
||||||
2. **RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 1. Формат NRes
|
|
||||||
|
|
||||||
## 1.1. Общая структура файла
|
|
||||||
|
|
||||||
```
|
|
||||||
┌──────────────────────────┐ Смещение 0
|
|
||||||
│ Заголовок (16 байт) │
|
|
||||||
├──────────────────────────┤ Смещение 16
|
|
||||||
│ │
|
|
||||||
│ Данные ресурсов │
|
|
||||||
│ (выровнены по 8 байт) │
|
|
||||||
│ │
|
|
||||||
├──────────────────────────┤ Смещение = total_size - entry_count × 64
|
|
||||||
│ Каталог записей │
|
|
||||||
│ (entry_count × 64 байт) │
|
|
||||||
└──────────────────────────┘ Смещение = total_size
|
|
||||||
```
|
|
||||||
|
|
||||||
## 1.2. Заголовок файла (16 байт)
|
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Значение | Описание |
|
|
||||||
| -------- | ------ | ------- | ------------------- | ------------------------------------ |
|
|
||||||
| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) |
|
|
||||||
| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
|
|
||||||
| 8 | 4 | int32 | — | Количество записей в каталоге |
|
|
||||||
| 12 | 4 | int32 | — | Полный размер файла в байтах |
|
|
||||||
|
|
||||||
**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
|
|
||||||
|
|
||||||
## 1.3. Положение каталога в файле
|
|
||||||
|
|
||||||
Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
|
|
||||||
|
|
||||||
```
|
|
||||||
directory_offset = total_size - entry_count × 64
|
|
||||||
```
|
|
||||||
|
|
||||||
Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
|
|
||||||
|
|
||||||
## 1.4. Запись каталога (64 байта)
|
|
||||||
|
|
||||||
Каждая запись каталога занимает ровно **64 байта** (0x40):
|
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Описание |
|
|
||||||
| -------- | ------ | -------- | ------------------------------------------------- |
|
|
||||||
| 0 | 4 | uint32 | Тип / идентификатор ресурса |
|
|
||||||
| 4 | 4 | uint32 | Атрибут 1 (например, формат, дата, категория) |
|
|
||||||
| 8 | 4 | uint32 | Атрибут 2 (например, подтип, метка времени) |
|
|
||||||
| 12 | 4 | uint32 | Размер данных ресурса в байтах |
|
|
||||||
| 16 | 4 | uint32 | Атрибут 3 (дополнительный параметр) |
|
|
||||||
| 20 | 36 | char[36] | Имя файла (null-terminated, макс. 35 символов) |
|
|
||||||
| 56 | 4 | uint32 | Смещение данных от начала файла |
|
|
||||||
| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
|
|
||||||
|
|
||||||
### Поле «Имя файла» (смещение 20, 36 байт)
|
|
||||||
|
|
||||||
- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
|
|
||||||
- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
|
|
||||||
- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
|
|
||||||
|
|
||||||
### Поле «Индекс сортировки» (смещение 60)
|
|
||||||
|
|
||||||
Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
|
|
||||||
|
|
||||||
**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
|
|
||||||
|
|
||||||
### Поле «Смещение данных» (смещение 56)
|
|
||||||
|
|
||||||
Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
|
|
||||||
|
|
||||||
## 1.5. Выравнивание данных
|
|
||||||
|
|
||||||
При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
|
|
||||||
|
|
||||||
```c
|
|
||||||
padding = ((data_size + 7) & ~7) - data_size;
|
|
||||||
// Если padding > 0, записываются нулевые байты
|
|
||||||
```
|
|
||||||
|
|
||||||
Таким образом, каждый блок данных начинается с адреса, кратного 8.
|
|
||||||
|
|
||||||
При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
|
|
||||||
|
|
||||||
## 1.6. Создание файла (API `niCreateResFile`)
|
|
||||||
|
|
||||||
При создании нового файла:
|
|
||||||
|
|
||||||
1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
|
|
||||||
2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
|
|
||||||
|
|
||||||
При закрытии файла (`sub_100122D0`):
|
|
||||||
|
|
||||||
1. Заголовок переписывается в начало файла (16 байт).
|
|
||||||
2. Вычисляется `total_size = data_end_offset + entry_count × 64`.
|
|
||||||
3. Индексы сортировки пересчитываются.
|
|
||||||
4. Каталог записей записывается в конец файла.
|
|
||||||
|
|
||||||
## 1.7. Режимы сортировки каталога
|
|
||||||
|
|
||||||
Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11):
|
|
||||||
|
|
||||||
| Режим | Порядок сортировки |
|
|
||||||
| ----- | --------------------------------- |
|
|
||||||
| 0 | Без сортировки (сброс) |
|
|
||||||
| 1 | По атрибуту 1 (смещение 4) |
|
|
||||||
| 2 | По атрибуту 2 (смещение 8) |
|
|
||||||
| 3 | По (атрибут 1, атрибут 2) |
|
|
||||||
| 4 | По типу ресурса (смещение 0) |
|
|
||||||
| 5 | По (тип, атрибут 1) |
|
|
||||||
| 6 | По (тип, атрибут 1) — идентичен 5 |
|
|
||||||
| 7 | По (тип, атрибут 1, атрибут 2) |
|
|
||||||
| 8 | По имени (регистронезависимо) |
|
|
||||||
| 9 | По (тип, имя) |
|
|
||||||
| 10 | По (атрибут 1, имя) |
|
|
||||||
| 11 | По (атрибут 2, имя) |
|
|
||||||
|
|
||||||
## 1.8. Операция `niOpenResFileEx` — флаги открытия
|
|
||||||
|
|
||||||
Второй параметр — битовые флаги:
|
|
||||||
|
|
||||||
| Бит | Маска | Описание |
|
|
||||||
| --- | ----- | ----------------------------------------------------------------------------------- |
|
|
||||||
| 0 | 0x01 | Sequential scan hint (`FILE_FLAG_SEQUENTIAL_SCAN` вместо `FILE_FLAG_RANDOM_ACCESS`) |
|
|
||||||
| 1 | 0x02 | Открыть для записи (read-write). Без флага — только чтение |
|
|
||||||
| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
|
|
||||||
| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
|
|
||||||
|
|
||||||
## 1.9. Виртуальное касание страниц
|
|
||||||
|
|
||||||
Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
|
|
||||||
|
|
||||||
```
|
|
||||||
for (result = 0x10000; result < size; result += 4096);
|
|
||||||
```
|
|
||||||
|
|
||||||
Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 2. Формат RsLi
|
|
||||||
|
|
||||||
## 2.1. Общая структура файла
|
|
||||||
|
|
||||||
```
|
|
||||||
┌───────────────────────────────┐ Смещение 0
|
|
||||||
│ Заголовок файла (32 байта) │
|
|
||||||
├───────────────────────────────┤ Смещение 32
|
|
||||||
│ Таблица записей (зашифрована)│
|
|
||||||
│ (entry_count × 32 байт) │
|
|
||||||
├───────────────────────────────┤ Смещение 32 + entry_count × 32
|
|
||||||
│ │
|
|
||||||
│ Данные ресурсов │
|
|
||||||
│ │
|
|
||||||
├───────────────────────────────┤
|
|
||||||
│ [Опциональный трейлер — 6 б] │
|
|
||||||
└───────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2.2. Заголовок файла (32 байта)
|
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Значение | Описание |
|
|
||||||
| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
|
|
||||||
| 0 | 2 | char[2] | `NL` (0x4C4E) | Магическая сигнатура |
|
|
||||||
| 2 | 1 | uint8 | `0x00` | Зарезервировано (должно быть 0) |
|
|
||||||
| 3 | 1 | uint8 | `0x01` | Версия формата |
|
|
||||||
| 4 | 2 | int16 | — | Количество записей (sign-extended при чтении) |
|
|
||||||
| 6 | 8 | — | — | Зарезервировано / не используется |
|
|
||||||
| 14 | 2 | uint16 | `0xABBA` или иное | Флаг предсортировки (см. ниже) |
|
|
||||||
| 16 | 4 | — | — | Зарезервировано |
|
|
||||||
| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
|
|
||||||
| 24 | 8 | — | — | Зарезервировано |
|
|
||||||
|
|
||||||
### Флаг предсортировки (смещение 14)
|
|
||||||
|
|
||||||
- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XOR‑ключ для данных).
|
|
||||||
- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XOR‑ключ (младшие 16 бит).
|
|
||||||
|
|
||||||
## 2.3. XOR-шифр таблицы записей
|
|
||||||
|
|
||||||
Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
|
|
||||||
|
|
||||||
### Начальное состояние
|
|
||||||
|
|
||||||
```
|
|
||||||
seed = *(uint32*)(header + 20)
|
|
||||||
lo = seed & 0xFF // Младший байт
|
|
||||||
hi = (seed >> 8) & 0xFF // Второй байт
|
|
||||||
```
|
|
||||||
|
|
||||||
### Алгоритм дешифровки (побайтовый)
|
|
||||||
|
|
||||||
Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
|
|
||||||
|
|
||||||
```
|
|
||||||
step 1: lo = hi ^ ((lo << 1) & 0xFF) // Сдвиг lo влево на 1, XOR с hi
|
|
||||||
step 2: decrypted[i] = lo ^ encrypted[i] // Расшифровка байта
|
|
||||||
step 3: hi = lo ^ ((hi >> 1) & 0xFF) // Сдвиг hi вправо на 1, XOR с lo
|
|
||||||
```
|
|
||||||
|
|
||||||
**Пример реализации:**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
|
||||||
lo = seed & 0xFF
|
|
||||||
hi = (seed >> 8) & 0xFF
|
|
||||||
result = bytearray(len(encrypted_data))
|
|
||||||
for i in range(len(encrypted_data)):
|
|
||||||
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
|
|
||||||
result[i] = lo ^ encrypted_data[i]
|
|
||||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
|
||||||
return bytes(result)
|
|
||||||
```
|
|
||||||
|
|
||||||
Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
|
|
||||||
|
|
||||||
## 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
|
|
||||||
|
|
||||||
После дешифровки каждая запись имеет следующую структуру:
|
|
||||||
|
|
||||||
| Смещение | Размер | Тип | Описание |
|
|
||||||
| -------- | ------ | -------- | -------------------------------------------------------------- |
|
|
||||||
| 0 | 12 | char[12] | Имя ресурса (ASCII, обычно uppercase; строка читается до `\0`) |
|
|
||||||
| 12 | 4 | — | Зарезервировано (движком игнорируется) |
|
|
||||||
| 16 | 2 | int16 | **Флаги** (метод сжатия и атрибуты) |
|
|
||||||
| 18 | 2 | int16 | **`sort_to_original[i]` / XOR‑ключ** (см. ниже) |
|
|
||||||
| 20 | 4 | uint32 | **Размер распакованных данных** (`unpacked_size`) |
|
|
||||||
| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) |
|
|
||||||
| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) |
|
|
||||||
|
|
||||||
### Имена ресурсов
|
|
||||||
|
|
||||||
- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`.
|
|
||||||
- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно.
|
|
||||||
- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён.
|
|
||||||
|
|
||||||
### Поле `sort_to_original[i]` (смещение 18)
|
|
||||||
|
|
||||||
Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
|
|
||||||
|
|
||||||
- Таблица реализована “внутри записей”: значение берётся как `entry[i].sort_to_original` (где `i` — позиция двоичного поиска), а реальная запись для сравнения берётся как `entry[ sort_to_original[i] ]`.
|
|
||||||
- Тем же значением (младшие 16 бит) инициализируется XOR‑шифр данных для методов, где он используется (0x20/0x60/0xA0). Поэтому при упаковке/шифровании данных ключ должен совпадать с итоговым `sort_to_original[i]` (см. флаг 0xABBA в разделе 2.2).
|
|
||||||
|
|
||||||
Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
|
|
||||||
|
|
||||||
## 2.5. Поле флагов (смещение 16 записи)
|
|
||||||
|
|
||||||
Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
|
|
||||||
|
|
||||||
```
|
|
||||||
Биты [8:5] (маска 0x1E0): Метод сжатия/шифрования
|
|
||||||
Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Методы сжатия (биты 8–5, маска 0x1E0)
|
|
||||||
|
|
||||||
| Значение | Hex | Описание |
|
|
||||||
| -------- | ----- | --------------------------------------- |
|
|
||||||
| 0x000 | 0x00 | Без сжатия (копирование) |
|
|
||||||
| 0x020 | 0x20 | Только XOR-шифр |
|
|
||||||
| 0x040 | 0x40 | LZSS (простой вариант) |
|
|
||||||
| 0x060 | 0x60 | XOR-шифр + LZSS (простой вариант) |
|
|
||||||
| 0x080 | 0x80 | LZSS с адаптивным кодированием Хаффмана |
|
|
||||||
| 0x0A0 | 0xA0 | XOR-шифр + LZSS с Хаффманом |
|
|
||||||
| 0x100 | 0x100 | Deflate (аналог zlib/RFC 1951) |
|
|
||||||
|
|
||||||
Примечание: `rsGetPackMethod()` возвращает `flags & 0x1C0` (без бита 0x20). Поэтому:
|
|
||||||
|
|
||||||
- для 0x20 вернётся 0x00,
|
|
||||||
- для 0x60 вернётся 0x40,
|
|
||||||
- для 0xA0 вернётся 0x80.
|
|
||||||
|
|
||||||
### Бит 0x40 (выделение +0x12 и последующее `realloc`)
|
|
||||||
|
|
||||||
Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
|
|
||||||
|
|
||||||
Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
|
|
||||||
|
|
||||||
## 2.6. Размеры данных
|
|
||||||
|
|
||||||
В каждой записи на диске хранятся оба значения:
|
|
||||||
|
|
||||||
- `unpacked_size` (смещение 20) — размер распакованных данных.
|
|
||||||
- `packed_size` (смещение 28) — размер упакованных данных (байт во входном потоке для выбранного метода).
|
|
||||||
|
|
||||||
Для метода 0x00 (без сжатия) обычно `packed_size == unpacked_size`.
|
|
||||||
|
|
||||||
`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
|
|
||||||
|
|
||||||
Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
|
|
||||||
|
|
||||||
## 2.7. Опциональный трейлер медиа (6 байт)
|
|
||||||
|
|
||||||
При открытии с флагом `a2 & 2`:
|
|
||||||
|
|
||||||
| Смещение от конца | Размер | Тип | Описание |
|
|
||||||
| ----------------- | ------ | ------- | ----------------------- |
|
|
||||||
| −6 | 2 | char[2] | Сигнатура `AO` (0x4F41) |
|
|
||||||
| −4 | 4 | uint32 | Смещение медиа-оверлея |
|
|
||||||
|
|
||||||
Если трейлер присутствует, все смещения данных в записях корректируются: `effective_offset = entry_offset + media_overlay_offset`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 3. Алгоритмы сжатия (формат RsLi)
|
|
||||||
|
|
||||||
## 3.1. XOR-шифр данных (метод 0x20)
|
|
||||||
|
|
||||||
Алгоритм идентичен XOR‑шифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
|
|
||||||
|
|
||||||
Важно про размер входа:
|
|
||||||
|
|
||||||
- В ветке **0x20** движок XOR‑ит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
|
|
||||||
- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
|
|
||||||
|
|
||||||
### Инициализация
|
|
||||||
|
|
||||||
```
|
|
||||||
key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
|
|
||||||
lo = key16 & 0xFF
|
|
||||||
hi = (key16 >> 8) & 0xFF
|
|
||||||
```
|
|
||||||
|
|
||||||
### Дешифровка (псевдокод)
|
|
||||||
|
|
||||||
```
|
|
||||||
for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
|
|
||||||
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
|
|
||||||
out[i] = in[i] ^ lo
|
|
||||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3.2. LZSS — простой вариант (метод 0x40)
|
|
||||||
|
|
||||||
Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
|
|
||||||
|
|
||||||
### Параметры
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
| ----------------------------- | ------------------ |
|
|
||||||
| Размер кольцевого буфера | 4096 байт (0x1000) |
|
|
||||||
| Начальная позиция записи | 4078 (0xFEE) |
|
|
||||||
| Начальное заполнение | 0x20 (пробел) |
|
|
||||||
| Минимальная длина совпадения | 3 |
|
|
||||||
| Максимальная длина совпадения | 18 (4 бита + 3) |
|
|
||||||
|
|
||||||
### Алгоритм декомпрессии
|
|
||||||
|
|
||||||
```
|
|
||||||
Инициализация:
|
|
||||||
ring_buffer[0..4095] = 0x20 (заполнить пробелами)
|
|
||||||
ring_pos = 4078
|
|
||||||
flags_byte = 0
|
|
||||||
flags_bits_remaining = 0
|
|
||||||
|
|
||||||
Цикл (пока не заполнен выходной буфер И не исчерпан входной):
|
|
||||||
|
|
||||||
1. Если flags_bits_remaining == 0:
|
|
||||||
- Прочитать 1 байт из входного потока → flags_byte
|
|
||||||
- flags_bits_remaining = 8
|
|
||||||
|
|
||||||
Декодировать как:
|
|
||||||
- Старший бит устанавливается в 0x7F (маркер)
|
|
||||||
- Оставшиеся 7 бит — флаги текущей группы
|
|
||||||
|
|
||||||
Реально в коде: control_word = (flags_byte) | (0x7F << 8)
|
|
||||||
Каждый бит проверяется сдвигом вправо.
|
|
||||||
|
|
||||||
2. Проверить младший бит control_word:
|
|
||||||
|
|
||||||
Если бит = 1 (литерал):
|
|
||||||
- Прочитать 1 байт из входного потока → byte
|
|
||||||
- ring_buffer[ring_pos] = byte
|
|
||||||
- ring_pos = (ring_pos + 1) & 0xFFF
|
|
||||||
- Записать byte в выходной буфер
|
|
||||||
|
|
||||||
Если бит = 0 (ссылка):
|
|
||||||
- Прочитать 2 байта: low_byte, high_byte
|
|
||||||
- offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит
|
|
||||||
- length = (high_byte & 0x0F) + 3 // 4 бита + 3
|
|
||||||
- Скопировать length байт из ring_buffer[offset...]:
|
|
||||||
для j от 0 до length-1:
|
|
||||||
byte = ring_buffer[(offset + j) & 0xFFF]
|
|
||||||
ring_buffer[ring_pos] = byte
|
|
||||||
ring_pos = (ring_pos + 1) & 0xFFF
|
|
||||||
записать byte в выходной буфер
|
|
||||||
|
|
||||||
3. Сдвинуть control_word вправо на 1 бит
|
|
||||||
4. flags_bits_remaining -= 1
|
|
||||||
```
|
|
||||||
|
|
||||||
### Подробная раскладка пары ссылки (2 байта)
|
|
||||||
|
|
||||||
```
|
|
||||||
Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
|
|
||||||
Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина − 3
|
|
||||||
|
|
||||||
offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095
|
|
||||||
length = (high & 0x0F) + 3 // Диапазон: 3–18
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
|
|
||||||
|
|
||||||
Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
|
|
||||||
|
|
||||||
### Параметры
|
|
||||||
|
|
||||||
| Параметр | Значение |
|
|
||||||
| -------------------------------- | ------------------------------ |
|
|
||||||
| Размер кольцевого буфера | 4096 байт |
|
|
||||||
| Начальная позиция записи | **4036** (0xFC4) |
|
|
||||||
| Начальное заполнение | 0x20 (пробел) |
|
|
||||||
| Количество листовых узлов дерева | 314 |
|
|
||||||
| Символы литералов | 0–255 (байты) |
|
|
||||||
| Символы длин | 256–313 (длина = символ − 253) |
|
|
||||||
| Начальная длина | 3 (при символе 256) |
|
|
||||||
| Максимальная длина | 60 (при символе 313) |
|
|
||||||
|
|
||||||
### Дерево Хаффмана
|
|
||||||
|
|
||||||
Дерево строится как **адаптивное** (dynamic, self-adjusting):
|
|
||||||
|
|
||||||
- **627 узлов**: 314 листовых + 313 внутренних.
|
|
||||||
- Все листья изначально имеют **вес 1**.
|
|
||||||
- Корень дерева — узел с индексом 0 (в массиве `parent`).
|
|
||||||
- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
|
|
||||||
- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
|
|
||||||
|
|
||||||
### Кодирование позиции
|
|
||||||
|
|
||||||
Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
|
|
||||||
|
|
||||||
- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов.
|
|
||||||
- Из потока считываются дополнительные биты, которые объединяются с базовым значением.
|
|
||||||
- Финальная позиция: `pos = (ring_pos − 1 − decoded_position) & 0xFFF`
|
|
||||||
|
|
||||||
**Таблицы инициализации** (d-коды):
|
|
||||||
|
|
||||||
```
|
|
||||||
Таблица базовых значений — byte_100371D0[6]:
|
|
||||||
{ 0x01, 0x03, 0x08, 0x0C, 0x18, 0x10 }
|
|
||||||
|
|
||||||
Таблица дополнительных битов — byte_100371D6[6]:
|
|
||||||
{ 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Алгоритм декомпрессии (высокоуровневый)
|
|
||||||
|
|
||||||
```
|
|
||||||
Инициализация:
|
|
||||||
ring_buffer[0..4095] = 0x20
|
|
||||||
ring_pos = 4036
|
|
||||||
Инициализировать дерево Хаффмана (314 листьев, все веса = 1)
|
|
||||||
Инициализировать таблицы d-кодов
|
|
||||||
|
|
||||||
Цикл:
|
|
||||||
1. Декодировать символ из потока по дереву Хаффмана:
|
|
||||||
- Начать с корня
|
|
||||||
- Читать биты, спускаться по дереву (0 = левый, 1 = правый)
|
|
||||||
- Пока не достигнут лист → символ = лист − 627
|
|
||||||
|
|
||||||
2. Обновить дерево Хаффмана для декодированного символа
|
|
||||||
|
|
||||||
3. Если символ < 256 (литерал):
|
|
||||||
- ring_buffer[ring_pos] = символ
|
|
||||||
- ring_pos = (ring_pos + 1) & 0xFFF
|
|
||||||
- Записать символ в выходной буфер
|
|
||||||
|
|
||||||
4. Если символ >= 256 (ссылка):
|
|
||||||
- length = символ − 253
|
|
||||||
- Декодировать позицию через d-код:
|
|
||||||
a) Прочитать 8 бит из потока
|
|
||||||
b) Найти d-код и дополнительные биты по таблице
|
|
||||||
c) Прочитать дополнительные биты
|
|
||||||
d) position = (ring_pos − 1 − full_position) & 0xFFF
|
|
||||||
- Скопировать length байт из ring_buffer[position...]
|
|
||||||
|
|
||||||
5. Если выходной буфер заполнен → завершить
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3.4. XOR + LZSS (методы 0x60 и 0xA0)
|
|
||||||
|
|
||||||
Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
|
|
||||||
|
|
||||||
### Алгоритм
|
|
||||||
|
|
||||||
1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
|
|
||||||
2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
|
|
||||||
3. Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной.
|
|
||||||
4. Освободить временный буфер.
|
|
||||||
|
|
||||||
- **0x60** — XOR + простой LZSS (раздел 3.2)
|
|
||||||
- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
|
|
||||||
|
|
||||||
### Начальное состояние XOR для данных
|
|
||||||
|
|
||||||
При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
|
|
||||||
|
|
||||||
## 3.5. Deflate (метод 0x100)
|
|
||||||
|
|
||||||
Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
|
|
||||||
|
|
||||||
### Общая структура
|
|
||||||
|
|
||||||
Данные состоят из последовательности блоков. Каждый блок начинается с:
|
|
||||||
|
|
||||||
- **1 бит** — `is_final`: признак последнего блока
|
|
||||||
- **2 бита** — `block_type`: тип блока
|
|
||||||
|
|
||||||
### Типы блоков
|
|
||||||
|
|
||||||
| block_type | Описание | Функция |
|
|
||||||
| ---------- | --------------------------- | ---------------- |
|
|
||||||
| 0 | Без сжатия (stored) | `sub_1001A750` |
|
|
||||||
| 1 | Фиксированные коды Хаффмана | `sub_1001A8C0` |
|
|
||||||
| 2 | Динамические коды Хаффмана | `sub_1001AA30` |
|
|
||||||
| 3 | Зарезервировано (ошибка) | Возвращает код 2 |
|
|
||||||
|
|
||||||
### Блок типа 0 (stored)
|
|
||||||
|
|
||||||
1. Отбросить оставшиеся биты до границы байта (выравнивание).
|
|
||||||
2. Прочитать 16 бит — `LEN` (длина блока).
|
|
||||||
3. Прочитать 16 бит — `NLEN` (дополнение длины, `NLEN == ~LEN & 0xFFFF`).
|
|
||||||
4. Проверить: `LEN == (uint16)(~NLEN)`. При несовпадении — ошибка.
|
|
||||||
5. Скопировать `LEN` байт из входного потока в выходной.
|
|
||||||
|
|
||||||
Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
|
|
||||||
|
|
||||||
### Блок типа 1 (фиксированные коды)
|
|
||||||
|
|
||||||
Стандартные коды Deflate:
|
|
||||||
|
|
||||||
- Литералы/длины: 288 кодов
|
|
||||||
- 0–143: 8-битные коды
|
|
||||||
- 144–255: 9-битные коды
|
|
||||||
- 256–279: 7-битные коды
|
|
||||||
- 280–287: 8-битные коды
|
|
||||||
- Дистанции: 30 кодов, все 5-битные
|
|
||||||
|
|
||||||
Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
|
|
||||||
|
|
||||||
### Блок типа 2 (динамические коды)
|
|
||||||
|
|
||||||
1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257–286.
|
|
||||||
2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30.
|
|
||||||
3. Прочитать 4 бита → `HCLEN` (количество кодов длин − 4). Диапазон: 4–19.
|
|
||||||
4. Прочитать `HCLEN` × 3 бит — длины кодов для алфавита длин.
|
|
||||||
5. Построить дерево Хаффмана для алфавита длин (19 символов).
|
|
||||||
6. С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций.
|
|
||||||
7. Построить два дерева Хаффмана: для литералов/длин и для дистанций.
|
|
||||||
8. Декодировать данные.
|
|
||||||
|
|
||||||
**Порядок кодов длин** (стандартный Deflate):
|
|
||||||
|
|
||||||
```
|
|
||||||
{ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }
|
|
||||||
```
|
|
||||||
|
|
||||||
Хранится в `dword_10037060`.
|
|
||||||
|
|
||||||
### Валидации
|
|
||||||
|
|
||||||
- `HLIT + 257 <= 286` (max 0x11E)
|
|
||||||
- `HDIST + 1 <= 30` (max 0x1E)
|
|
||||||
- При нарушении — возвращается ошибка 1.
|
|
||||||
|
|
||||||
## 3.6. Метод 0x00 (без сжатия)
|
|
||||||
|
|
||||||
Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 4. Внутренние структуры в памяти
|
|
||||||
|
|
||||||
## 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct NResArchive { // Размер: 0x68 (104 байта)
|
|
||||||
void* vtable; // +0: Указатель на таблицу виртуальных методов
|
|
||||||
int32_t entry_count; // +4: Количество записей
|
|
||||||
void* mapped_base; // +8: Базовый адрес mapped view
|
|
||||||
void* directory_ptr; // +12: Указатель на каталог записей в памяти
|
|
||||||
char* filename; // +16: Путь к файлу (_strdup)
|
|
||||||
int32_t ref_count; // +20: Счётчик ссылок
|
|
||||||
uint32_t last_release_time; // +24: timeGetTime() при последнем Release
|
|
||||||
// +28..+91: Для raw-режима — встроенная запись (единственный File entry)
|
|
||||||
NResArchive* next; // +92: Следующий архив в связном списке
|
|
||||||
uint8_t is_writable; // +100: Файл открыт для записи
|
|
||||||
uint8_t is_cacheable; // +101: Не выгружать при refcount = 0
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct RsLibHeader { // 56 байт (14 DWORD)
|
|
||||||
uint32_t magic; // +0: 'RsLi' (0x694C7352)
|
|
||||||
int32_t entry_count; // +4: Количество записей
|
|
||||||
uint32_t media_offset; // +8: Смещение медиа-оверлея
|
|
||||||
uint32_t reserved_0C; // +12: 0
|
|
||||||
HANDLE file_handle_2; // +16: -1 (дополнительный хэндл)
|
|
||||||
uint32_t reserved_14; // +20: 0
|
|
||||||
uint32_t reserved_18; // +24: —
|
|
||||||
uint32_t reserved_1C; // +28: 0
|
|
||||||
HANDLE mapping_handle_2; // +32: -1
|
|
||||||
uint32_t reserved_24; // +36: 0
|
|
||||||
uint32_t flag_28; // +40: (flags >> 7) & 1
|
|
||||||
HANDLE file_handle; // +44: Хэндл файла
|
|
||||||
HANDLE mapping_handle; // +48: Хэндл файлового маппинга
|
|
||||||
void* mapped_view; // +52: Указатель на mapped view
|
|
||||||
};
|
|
||||||
// Далее следуют entry_count записей по 64 байта каждая
|
|
||||||
```
|
|
||||||
|
|
||||||
### Внутренняя запись RsLi (64 байта)
|
|
||||||
|
|
||||||
```c
|
|
||||||
struct RsLibEntry { // 64 байта (16 DWORD)
|
|
||||||
char name[16]; // +0: Имя (12 из файла + 4 нуля)
|
|
||||||
int32_t flags; // +16: Флаги (sign-extended из int16)
|
|
||||||
int32_t sort_index; // +20: sort_to_original[i] (таблица индексов / XOR‑ключ)
|
|
||||||
uint32_t uncompressed_size; // +24: Размер несжатых данных (из поля 20 записи)
|
|
||||||
void* data_ptr; // +28: Указатель на данные в mapped view
|
|
||||||
uint32_t compressed_size; // +32: Размер сжатых данных (из поля 28 записи)
|
|
||||||
uint32_t reserved_24; // +36: 0
|
|
||||||
uint32_t reserved_28; // +40: 0
|
|
||||||
uint32_t reserved_2C; // +44: 0
|
|
||||||
void* loaded_data; // +48: Указатель на декомпрессированные данные
|
|
||||||
// +52..+63: дополнительные поля
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 5. Экспортируемые API-функции
|
|
||||||
|
|
||||||
## 5.1. NRes API
|
|
||||||
|
|
||||||
| Функция | Описание |
|
|
||||||
| ------------------------------ | ------------------------------------------------------------------------- |
|
|
||||||
| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` |
|
|
||||||
| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами |
|
|
||||||
| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
|
|
||||||
| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
|
|
||||||
|
|
||||||
## 5.2. RsLi API
|
|
||||||
|
|
||||||
| Функция | Описание |
|
|
||||||
| ------------------------------- | -------------------------------------------------------- |
|
|
||||||
| `rsOpenLib(path, flags)` | Открыть RsLi-библиотеку |
|
|
||||||
| `rsCloseLib(lib)` | Закрыть библиотеку |
|
|
||||||
| `rsLibNum(lib)` | Получить количество записей |
|
|
||||||
| `rsFind(lib, name)` | Найти запись по имени (→ индекс или −1) |
|
|
||||||
| `rsLoad(lib, index)` | Загрузить и декомпрессировать ресурс |
|
|
||||||
| `rsLoadFast(lib, index, flags)` | Быстрая загрузка (без декомпрессии если возможно) |
|
|
||||||
| `rsLoadPacked(lib, index)` | Загрузить в «упакованном» виде (отложенная декомпрессия) |
|
|
||||||
| `rsLoadByName(lib, name)` | `rsFind` + `rsLoad` |
|
|
||||||
| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса |
|
|
||||||
| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) |
|
|
||||||
| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс |
|
|
||||||
| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) |
|
|
||||||
| `ngiFree(ptr)` | Освободить память |
|
|
||||||
| `ngiGetMemSize(ptr)` | Получить размер выделенного блока |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Часть 6. Контрольные заметки для реализации
|
|
||||||
|
|
||||||
## 6.1. Кодировки и регистр
|
|
||||||
|
|
||||||
- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
|
|
||||||
- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
|
|
||||||
|
|
||||||
## 6.2. Порядок байт
|
|
||||||
|
|
||||||
Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
|
|
||||||
|
|
||||||
## 6.3. Выравнивание
|
|
||||||
|
|
||||||
- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
|
|
||||||
- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
|
|
||||||
|
|
||||||
## 6.4. Размер записей на диске
|
|
||||||
|
|
||||||
- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
|
|
||||||
- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
|
|
||||||
|
|
||||||
## 6.5. Кэширование и memory mapping
|
|
||||||
|
|
||||||
Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
|
|
||||||
|
|
||||||
## 6.6. Размер seed XOR
|
|
||||||
|
|
||||||
- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
|
|
||||||
- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
|
|
||||||
- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
|
|
||||||
|
|
||||||
## 6.7. Эмпирическая проверка на данных игры
|
|
||||||
|
|
||||||
- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
|
|
||||||
- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
|
|
||||||
- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`.
|
|
||||||
|
|
||||||
Подтверждённые нюансы:
|
|
||||||
|
|
||||||
- Для LZSS (метод `0x040`) рабочая раскладка нибблов в ссылке: `OOOO LLLL`, а не `LLLL OOOO`.
|
|
||||||
- Для Deflate (метод `0x100`) возможен случай `packed_size == фактический_конец + 1` на последней записи файла.
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Текстуры и материалы
|
|
||||||
|
|
||||||
На текущем этапе в дизассемблированных библиотеках **не найден полный декодер формата текстурного файла** (нет явных парсеров DDS/TGA/BMP и т.п.). Поэтому документ пока фиксирует:
|
|
||||||
|
|
||||||
- что можно достоверно вывести по рендер‑конфигу,
|
|
||||||
- что видно по структурам модели (materialIndex),
|
|
||||||
- какие места требуют дальнейшего анализа.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1) Материал в модели
|
|
||||||
|
|
||||||
В batch table модели (см. документацию по MSH/AniMesh) есть поле, очень похожее на:
|
|
||||||
|
|
||||||
- `materialIndex: u16` (batch + 2)
|
|
||||||
|
|
||||||
Это индекс, по которому рендерер выбирает:
|
|
||||||
|
|
||||||
- текстуру(ы),
|
|
||||||
- параметры (blend, alpha test, двухтекстурность и т.п.),
|
|
||||||
- “шейдер/пайплайн” (в терминах оригинального рендера — набор state’ов).
|
|
||||||
|
|
||||||
**Где лежит таблица материалов** (внутри модели или глобально) — требует подтверждения:
|
|
||||||
|
|
||||||
- вероятный кандидат — отдельный ресурс/таблица, на которую `materialIndex` ссылается.
|
|
||||||
- строковая таблица `Res10` может хранить имена материалов/текстур, но маппинг не доказан.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2) Переключатели рендера, влияющие на текстуры (из Ngi32.dll)
|
|
||||||
|
|
||||||
В `Ngi32.dll` есть набор runtime‑настроек (похоже, читаются из системных настроек/INI/registry), которые сильно влияют на текстурный пайплайн:
|
|
||||||
|
|
||||||
- `DisableMipmap`
|
|
||||||
- `DisableBilinear`
|
|
||||||
- `DisableTrilinear`
|
|
||||||
- `DisableMultiTexturing`
|
|
||||||
- `Disable32bitTextures` / `Force16bitTextures`
|
|
||||||
- `ForceSoftware`
|
|
||||||
- `ForceNoFiltering`
|
|
||||||
- `ForceHWTnL`
|
|
||||||
- `ForceNoHWTnL`
|
|
||||||
|
|
||||||
Практический вывод для порта:
|
|
||||||
|
|
||||||
- движок может работать **без мипмапов**, **без фильтрации**, и даже **без multitexturing**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3) “Две текстуры” и дополнительные UV‑потоки
|
|
||||||
|
|
||||||
В загрузчике модели присутствуют дополнительные per‑vertex ресурсы:
|
|
||||||
|
|
||||||
- Res15 (stride 8) — кандидат на UV1 (lightmap/second layer)
|
|
||||||
- Res16 (stride 8, split в 2×4) — кандидат на tangent/bitangent (normal mapping)
|
|
||||||
- Res18 (stride 4) — кандидат на vertex color / AO
|
|
||||||
|
|
||||||
Если материал реально поддерживает:
|
|
||||||
|
|
||||||
- вторую текстуру (detail map, lightmap),
|
|
||||||
- нормалмапы,
|
|
||||||
|
|
||||||
то где‑то должен быть код:
|
|
||||||
|
|
||||||
- который выбирает эти потоки как входные атрибуты вершинного шейдера/пайплайна,
|
|
||||||
- который активирует multi‑texturing.
|
|
||||||
|
|
||||||
Сейчас в найденных фрагментах это ещё **не подтверждено**, но структура данных “просится” именно туда.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4) Что нужно найти дальше (чтобы написать полноценную спецификацию материалов/текстур)
|
|
||||||
|
|
||||||
1. Место, где `materialIndex` разворачивается в набор render states:
|
|
||||||
- alpha blending / alpha test
|
|
||||||
- z‑write/z‑test
|
|
||||||
- culling
|
|
||||||
- 1‑pass vs 2‑pass (multi‑texturing)
|
|
||||||
2. Формат записи “material record”:
|
|
||||||
- какие поля
|
|
||||||
- ссылки на текстуры (ID, имя, индекс в таблице)
|
|
||||||
3. Формат “texture asset”:
|
|
||||||
- где хранится (внутри NRes или отдельным файлом)
|
|
||||||
- компрессия/палитра/мip’ы
|
|
||||||
4. Привязка строковой таблицы `Res10` к материалам:
|
|
||||||
- это имена материалов?
|
|
||||||
- это имена текстур?
|
|
||||||
- или это имена узлов/анимаций?
|
|
||||||
|
|
||||||
До подтверждения этих пунктов разумнее держать документацию как “архитектурную карту”, а не как точный байтовый формат.
|
|
||||||
10
libs/nres/Cargo.toml
Normal file
10
libs/nres/Cargo.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[package]
|
||||||
|
name = "libnres"
|
||||||
|
version = "0.1.4"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
byteorder = "1.4"
|
||||||
|
log = "0.4"
|
||||||
|
miette = "7.0"
|
||||||
|
thiserror = "2.0"
|
||||||
30
libs/nres/src/converter.rs
Normal file
30
libs/nres/src/converter.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use crate::error::ConverterError;
|
||||||
|
|
||||||
|
/// Method for converting u32 to u64.
|
||||||
|
pub fn u32_to_u64(value: u32) -> Result<u64, ConverterError> {
|
||||||
|
Ok(u64::from(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
45
libs/nres/src/error.rs
Normal file
45
libs/nres/src/error.rs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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 },
|
||||||
|
}
|
||||||
24
libs/nres/src/lib.rs
Normal file
24
libs/nres/src/lib.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
/// 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)
|
||||||
|
}
|
||||||
227
libs/nres/src/reader.rs
Normal file
227
libs/nres/src/reader.rs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
11
mkdocs.yml
11
mkdocs.yml
@@ -15,19 +15,10 @@ copyright: Copyright © 2023 — 2024 Valentin Popov
|
|||||||
# Configuration
|
# Configuration
|
||||||
theme:
|
theme:
|
||||||
name: material
|
name: material
|
||||||
language: ru
|
language: en
|
||||||
palette:
|
palette:
|
||||||
scheme: slate
|
scheme: slate
|
||||||
|
|
||||||
# Navigation
|
|
||||||
nav:
|
|
||||||
- Home: index.md
|
|
||||||
- Specs:
|
|
||||||
- NRes / RsLi: specs/nres.md
|
|
||||||
- 3D модели: specs/msh.md
|
|
||||||
- Текстуры и материалы: specs/textures.md
|
|
||||||
- Эффекты и частицы: specs/effects.md
|
|
||||||
|
|
||||||
# Additional configuration
|
# Additional configuration
|
||||||
extra:
|
extra:
|
||||||
social:
|
social:
|
||||||
|
|||||||
9
packer/Cargo.toml
Normal file
9
packer/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[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"
|
||||||
27
packer/README.md
Normal file
27
packer/README.md
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# 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.
|
||||||
175
packer/src/main.rs
Normal file
175
packer/src/main.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::{
|
||||||
|
fs::{self, File},
|
||||||
|
io::{BufReader, Read},
|
||||||
|
};
|
||||||
|
|
||||||
|
use byteorder::{ByteOrder, LittleEndian};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct ImportListElement {
|
||||||
|
pub extension: String,
|
||||||
|
pub index: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub unknown0: u32,
|
||||||
|
pub unknown1: u32,
|
||||||
|
pub unknown2: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ListElement {
|
||||||
|
pub extension: String,
|
||||||
|
pub index: u32,
|
||||||
|
pub name: String,
|
||||||
|
pub position: u32,
|
||||||
|
pub size: u32,
|
||||||
|
pub unknown0: u32,
|
||||||
|
pub unknown1: u32,
|
||||||
|
pub unknown2: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
|
||||||
|
let input = &args[1];
|
||||||
|
let output = &args[2];
|
||||||
|
|
||||||
|
pack(String::from(input), String::from(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pack(input: String, output: String) {
|
||||||
|
// Загружаем индекс-файл
|
||||||
|
let index_file = format!("{}/{}", input, "index.json");
|
||||||
|
let data = fs::read_to_string(index_file).unwrap();
|
||||||
|
let list: Vec<ImportListElement> = serde_json::from_str(&data).unwrap();
|
||||||
|
|
||||||
|
// Общий буфер хранения файлов
|
||||||
|
let mut content_buffer: Vec<u8> = Vec::new();
|
||||||
|
let mut list_buffer: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
|
// Общее количество файлов
|
||||||
|
let total_files: u32 = list.len() as u32;
|
||||||
|
|
||||||
|
for (index, item) in list.iter().enumerate() {
|
||||||
|
// Открываем дескриптор файла
|
||||||
|
let path = format!("{}/{}.{}", input, item.name, item.index);
|
||||||
|
let file = File::open(path).unwrap();
|
||||||
|
let metadata = file.metadata().unwrap();
|
||||||
|
|
||||||
|
// Считываем файл в буфер
|
||||||
|
let mut reader = BufReader::new(file);
|
||||||
|
let mut file_buffer: Vec<u8> = Vec::new();
|
||||||
|
reader.read_to_end(&mut file_buffer).unwrap();
|
||||||
|
|
||||||
|
// Выравнивание буфера
|
||||||
|
if index != 0 {
|
||||||
|
while !content_buffer.len().is_multiple_of(8) {
|
||||||
|
content_buffer.push(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получение позиции файла
|
||||||
|
let position = content_buffer.len() + 16;
|
||||||
|
|
||||||
|
// Записываем файл в буфер
|
||||||
|
content_buffer.extend(file_buffer);
|
||||||
|
|
||||||
|
// Формируем элемент
|
||||||
|
let element = ListElement {
|
||||||
|
extension: item.extension.to_string(),
|
||||||
|
index: item.index,
|
||||||
|
name: item.name.to_string(),
|
||||||
|
position: position as u32,
|
||||||
|
size: metadata.len() as u32,
|
||||||
|
unknown0: item.unknown0,
|
||||||
|
unknown1: item.unknown1,
|
||||||
|
unknown2: item.unknown2,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Создаем буфер из элемента
|
||||||
|
let mut element_buffer: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
|
// Пишем тип файла
|
||||||
|
let mut extension_buffer: [u8; 4] = [0; 4];
|
||||||
|
let mut file_extension_buffer = element.extension.into_bytes();
|
||||||
|
file_extension_buffer.resize(4, 0);
|
||||||
|
extension_buffer.copy_from_slice(&file_extension_buffer);
|
||||||
|
element_buffer.extend(extension_buffer);
|
||||||
|
|
||||||
|
// Пишем неизвестное значение #1
|
||||||
|
let mut unknown0_buffer: [u8; 4] = [0; 4];
|
||||||
|
LittleEndian::write_u32(&mut unknown0_buffer, element.unknown0);
|
||||||
|
element_buffer.extend(unknown0_buffer);
|
||||||
|
|
||||||
|
// Пишем неизвестное значение #2
|
||||||
|
let mut unknown1_buffer: [u8; 4] = [0; 4];
|
||||||
|
LittleEndian::write_u32(&mut unknown1_buffer, element.unknown1);
|
||||||
|
element_buffer.extend(unknown1_buffer);
|
||||||
|
|
||||||
|
// Пишем размер файла
|
||||||
|
let mut file_size_buffer: [u8; 4] = [0; 4];
|
||||||
|
LittleEndian::write_u32(&mut file_size_buffer, element.size);
|
||||||
|
element_buffer.extend(file_size_buffer);
|
||||||
|
|
||||||
|
// Пишем неизвестное значение #3
|
||||||
|
let mut unknown2_buffer: [u8; 4] = [0; 4];
|
||||||
|
LittleEndian::write_u32(&mut unknown2_buffer, element.unknown2);
|
||||||
|
element_buffer.extend(unknown2_buffer);
|
||||||
|
|
||||||
|
// Пишем название файла
|
||||||
|
let mut name_buffer: [u8; 36] = [0; 36];
|
||||||
|
let mut file_name_buffer = element.name.into_bytes();
|
||||||
|
file_name_buffer.resize(36, 0);
|
||||||
|
name_buffer.copy_from_slice(&file_name_buffer);
|
||||||
|
element_buffer.extend(name_buffer);
|
||||||
|
|
||||||
|
// Пишем позицию файла
|
||||||
|
let mut position_buffer: [u8; 4] = [0; 4];
|
||||||
|
LittleEndian::write_u32(&mut position_buffer, element.position);
|
||||||
|
element_buffer.extend(position_buffer);
|
||||||
|
|
||||||
|
// Пишем индекс файла
|
||||||
|
let mut index_buffer: [u8; 4] = [0; 4];
|
||||||
|
LittleEndian::write_u32(&mut index_buffer, element.index);
|
||||||
|
element_buffer.extend(index_buffer);
|
||||||
|
|
||||||
|
// Добавляем итоговый буфер в буфер элементов списка
|
||||||
|
list_buffer.extend(element_buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выравнивание буфера
|
||||||
|
while !content_buffer.len().is_multiple_of(8) {
|
||||||
|
content_buffer.push(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut header_buffer: Vec<u8> = Vec::new();
|
||||||
|
|
||||||
|
// Пишем первый тип файла
|
||||||
|
let mut header_type_1 = [0; 4];
|
||||||
|
LittleEndian::write_u32(&mut header_type_1, 1936020046_u32);
|
||||||
|
header_buffer.extend(header_type_1);
|
||||||
|
|
||||||
|
// Пишем второй тип файла
|
||||||
|
let mut header_type_2 = [0; 4];
|
||||||
|
LittleEndian::write_u32(&mut header_type_2, 256_u32);
|
||||||
|
header_buffer.extend(header_type_2);
|
||||||
|
|
||||||
|
// Пишем количество файлов
|
||||||
|
let mut header_total_files = [0; 4];
|
||||||
|
LittleEndian::write_u32(&mut header_total_files, total_files);
|
||||||
|
header_buffer.extend(header_total_files);
|
||||||
|
|
||||||
|
// Пишем общий размер файла
|
||||||
|
let mut header_total_size = [0; 4];
|
||||||
|
let total_size: u32 = ((content_buffer.len() + 16) as u32) + (total_files * 64);
|
||||||
|
LittleEndian::write_u32(&mut header_total_size, total_size);
|
||||||
|
header_buffer.extend(header_total_size);
|
||||||
|
|
||||||
|
let mut result_buffer: Vec<u8> = Vec::new();
|
||||||
|
result_buffer.extend(header_buffer);
|
||||||
|
result_buffer.extend(content_buffer);
|
||||||
|
result_buffer.extend(list_buffer);
|
||||||
|
|
||||||
|
fs::write(output, result_buffer).unwrap();
|
||||||
|
}
|
||||||
2
testdata/nres/.gitignore
vendored
2
testdata/nres/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
2
testdata/rsli/.gitignore
vendored
2
testdata/rsli/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
*
|
|
||||||
!.gitignore
|
|
||||||
107
tools/README.md
107
tools/README.md
@@ -1,107 +0,0 @@
|
|||||||
# Инструменты в каталоге `tools`
|
|
||||||
|
|
||||||
## `archive_roundtrip_validator.py`
|
|
||||||
|
|
||||||
Скрипт предназначен для **валидации документации по форматам NRes и RsLi на реальных данных игры**.
|
|
||||||
|
|
||||||
Что делает утилита:
|
|
||||||
|
|
||||||
- находит архивы по сигнатуре заголовка (а не по расширению файла);
|
|
||||||
- распаковывает архивы в структуру `manifest.json + entries/*`;
|
|
||||||
- собирает архивы обратно из `manifest.json`;
|
|
||||||
- выполняет проверку `unpack -> repack -> byte-compare`;
|
|
||||||
- формирует отчёт о расхождениях со спецификацией.
|
|
||||||
|
|
||||||
Скрипт не изменяет оригинальные файлы игры. Рабочие файлы создаются только в указанном `--workdir` (или во временной папке).
|
|
||||||
|
|
||||||
## Поддерживаемые сигнатуры
|
|
||||||
|
|
||||||
- `NRes` (`4E 52 65 73`)
|
|
||||||
- `RsLi` в файловом формате библиотеки: `NL 00 01`
|
|
||||||
|
|
||||||
## Основные команды
|
|
||||||
|
|
||||||
Сканирование архива по сигнатурам:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/archive_roundtrip_validator.py scan --input tmp/gamedata
|
|
||||||
```
|
|
||||||
|
|
||||||
Распаковка/упаковка одного NRes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/archive_roundtrip_validator.py nres-unpack \
|
|
||||||
--archive tmp/gamedata/sounds.lib \
|
|
||||||
--output tmp/work/nres_sounds
|
|
||||||
|
|
||||||
python3 tools/archive_roundtrip_validator.py nres-pack \
|
|
||||||
--manifest tmp/work/nres_sounds/manifest.json \
|
|
||||||
--output tmp/work/sounds.repacked.lib
|
|
||||||
```
|
|
||||||
|
|
||||||
Распаковка/упаковка одного RsLi:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/archive_roundtrip_validator.py rsli-unpack \
|
|
||||||
--archive tmp/gamedata/sprites.lib \
|
|
||||||
--output tmp/work/rsli_sprites
|
|
||||||
|
|
||||||
python3 tools/archive_roundtrip_validator.py rsli-pack \
|
|
||||||
--manifest tmp/work/rsli_sprites/manifest.json \
|
|
||||||
--output tmp/work/sprites.repacked.lib
|
|
||||||
```
|
|
||||||
|
|
||||||
Полная валидация документации на всём наборе данных:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/archive_roundtrip_validator.py validate \
|
|
||||||
--input tmp/gamedata \
|
|
||||||
--workdir tmp/validation_work \
|
|
||||||
--report tmp/validation_report.json \
|
|
||||||
--fail-on-diff
|
|
||||||
```
|
|
||||||
|
|
||||||
## Формат распаковки
|
|
||||||
|
|
||||||
Для каждого архива создаются:
|
|
||||||
|
|
||||||
- `manifest.json` — все поля заголовка, записи, индексы, смещения, контрольные суммы;
|
|
||||||
- `entries/*.bin` — payload-файлы.
|
|
||||||
|
|
||||||
Имена файлов в `entries` включают индекс записи, поэтому коллизии одинаковых имён внутри архива обрабатываются корректно.
|
|
||||||
|
|
||||||
## `init_testdata.py`
|
|
||||||
|
|
||||||
Скрипт инициализирует тестовые данные по сигнатурам архивов из спецификации:
|
|
||||||
|
|
||||||
- `NRes` (`4E 52 65 73`);
|
|
||||||
- `RsLi` (`NL 00 01`).
|
|
||||||
|
|
||||||
Что делает утилита:
|
|
||||||
|
|
||||||
- рекурсивно сканирует все файлы в `--input`;
|
|
||||||
- копирует найденные `NRes` в `--output/nres/`;
|
|
||||||
- копирует найденные `RsLi` в `--output/rsli/`;
|
|
||||||
- сохраняет относительный путь исходного файла внутри целевого каталога;
|
|
||||||
- создаёт целевые каталоги автоматически, если их нет.
|
|
||||||
|
|
||||||
Базовый запуск:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/init_testdata.py --input tmp/gamedata --output testdata
|
|
||||||
```
|
|
||||||
|
|
||||||
Если целевой файл уже существует, скрипт спрашивает подтверждение перезаписи (`yes/no/all/quit`).
|
|
||||||
|
|
||||||
Для перезаписи без вопросов используйте `--force`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python3 tools/init_testdata.py --input tmp/gamedata --output testdata --force
|
|
||||||
```
|
|
||||||
|
|
||||||
Проверки надёжности:
|
|
||||||
|
|
||||||
- `--input` должен существовать и быть каталогом;
|
|
||||||
- если `--output` указывает на существующий файл, скрипт завершится с ошибкой;
|
|
||||||
- если `--output` расположен внутри `--input`, каталог вывода исключается из сканирования;
|
|
||||||
- если `stdin` неинтерактивный и требуется перезапись, нужно явно указать `--force`.
|
|
||||||
@@ -1,944 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Roundtrip tools for NRes and RsLi archives.
|
|
||||||
|
|
||||||
The script can:
|
|
||||||
1) scan archives by header signature (ignores file extensions),
|
|
||||||
2) unpack / pack NRes archives,
|
|
||||||
3) unpack / pack RsLi archives,
|
|
||||||
4) validate docs assumptions by full roundtrip and byte-to-byte comparison.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import shutil
|
|
||||||
import struct
|
|
||||||
import tempfile
|
|
||||||
import zlib
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
MAGIC_NRES = b"NRes"
|
|
||||||
MAGIC_RSLI = b"NL\x00\x01"
|
|
||||||
|
|
||||||
|
|
||||||
class ArchiveFormatError(RuntimeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def sha256_hex(data: bytes) -> str:
|
|
||||||
return hashlib.sha256(data).hexdigest()
|
|
||||||
|
|
||||||
|
|
||||||
def safe_component(value: str, fallback: str = "item", max_len: int = 80) -> str:
|
|
||||||
clean = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._-")
|
|
||||||
if not clean:
|
|
||||||
clean = fallback
|
|
||||||
return clean[:max_len]
|
|
||||||
|
|
||||||
|
|
||||||
def first_diff(a: bytes, b: bytes) -> tuple[int | None, str | None]:
|
|
||||||
if a == b:
|
|
||||||
return None, None
|
|
||||||
limit = min(len(a), len(b))
|
|
||||||
for idx in range(limit):
|
|
||||||
if a[idx] != b[idx]:
|
|
||||||
return idx, f"{a[idx]:02x}!={b[idx]:02x}"
|
|
||||||
return limit, f"len {len(a)}!={len(b)}"
|
|
||||||
|
|
||||||
|
|
||||||
def load_json(path: Path) -> dict[str, Any]:
|
|
||||||
with path.open("r", encoding="utf-8") as handle:
|
|
||||||
return json.load(handle)
|
|
||||||
|
|
||||||
|
|
||||||
def dump_json(path: Path, payload: dict[str, Any]) -> None:
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
with path.open("w", encoding="utf-8") as handle:
|
|
||||||
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
|
||||||
handle.write("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def xor_stream(data: bytes, key16: int) -> bytes:
|
|
||||||
lo = key16 & 0xFF
|
|
||||||
hi = (key16 >> 8) & 0xFF
|
|
||||||
out = bytearray(len(data))
|
|
||||||
for i, value in enumerate(data):
|
|
||||||
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
|
|
||||||
out[i] = value ^ lo
|
|
||||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
|
|
||||||
def lzss_decompress_simple(data: bytes, expected_size: int) -> bytes:
|
|
||||||
ring = bytearray([0x20] * 0x1000)
|
|
||||||
ring_pos = 0xFEE
|
|
||||||
out = bytearray()
|
|
||||||
in_pos = 0
|
|
||||||
control = 0
|
|
||||||
bits_left = 0
|
|
||||||
|
|
||||||
while len(out) < expected_size and in_pos < len(data):
|
|
||||||
if bits_left == 0:
|
|
||||||
control = data[in_pos]
|
|
||||||
in_pos += 1
|
|
||||||
bits_left = 8
|
|
||||||
|
|
||||||
if control & 1:
|
|
||||||
if in_pos >= len(data):
|
|
||||||
break
|
|
||||||
byte = data[in_pos]
|
|
||||||
in_pos += 1
|
|
||||||
out.append(byte)
|
|
||||||
ring[ring_pos] = byte
|
|
||||||
ring_pos = (ring_pos + 1) & 0x0FFF
|
|
||||||
else:
|
|
||||||
if in_pos + 1 >= len(data):
|
|
||||||
break
|
|
||||||
low = data[in_pos]
|
|
||||||
high = data[in_pos + 1]
|
|
||||||
in_pos += 2
|
|
||||||
# Real files indicate nibble layout opposite to common LZSS variant:
|
|
||||||
# high nibble extends offset, low nibble stores (length - 3).
|
|
||||||
offset = low | ((high & 0xF0) << 4)
|
|
||||||
length = (high & 0x0F) + 3
|
|
||||||
for step in range(length):
|
|
||||||
byte = ring[(offset + step) & 0x0FFF]
|
|
||||||
out.append(byte)
|
|
||||||
ring[ring_pos] = byte
|
|
||||||
ring_pos = (ring_pos + 1) & 0x0FFF
|
|
||||||
if len(out) >= expected_size:
|
|
||||||
break
|
|
||||||
|
|
||||||
control >>= 1
|
|
||||||
bits_left -= 1
|
|
||||||
|
|
||||||
if len(out) != expected_size:
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"LZSS size mismatch: expected {expected_size}, got {len(out)}"
|
|
||||||
)
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
|
|
||||||
def decode_rsli_payload(
|
|
||||||
packed: bytes, method: int, sort_to_original: int, unpacked_size: int
|
|
||||||
) -> bytes:
|
|
||||||
key16 = sort_to_original & 0xFFFF
|
|
||||||
|
|
||||||
if method == 0x000:
|
|
||||||
out = packed
|
|
||||||
elif method == 0x020:
|
|
||||||
if len(packed) < unpacked_size:
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"method 0x20 packed too short: {len(packed)} < {unpacked_size}"
|
|
||||||
)
|
|
||||||
out = xor_stream(packed[:unpacked_size], key16)
|
|
||||||
elif method == 0x040:
|
|
||||||
out = lzss_decompress_simple(packed, unpacked_size)
|
|
||||||
elif method == 0x060:
|
|
||||||
out = lzss_decompress_simple(xor_stream(packed, key16), unpacked_size)
|
|
||||||
elif method == 0x100:
|
|
||||||
try:
|
|
||||||
out = zlib.decompress(packed, -15)
|
|
||||||
except zlib.error:
|
|
||||||
out = zlib.decompress(packed)
|
|
||||||
else:
|
|
||||||
raise ArchiveFormatError(f"unsupported RsLi method: 0x{method:03X}")
|
|
||||||
|
|
||||||
if len(out) != unpacked_size:
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"unpacked_size mismatch: expected {unpacked_size}, got {len(out)}"
|
|
||||||
)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def detect_archive_type(path: Path) -> str | None:
|
|
||||||
try:
|
|
||||||
with path.open("rb") as handle:
|
|
||||||
magic = handle.read(4)
|
|
||||||
except OSError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if magic == MAGIC_NRES:
|
|
||||||
return "nres"
|
|
||||||
if magic == MAGIC_RSLI:
|
|
||||||
return "rsli"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def scan_archives(root: Path) -> list[dict[str, Any]]:
|
|
||||||
found: list[dict[str, Any]] = []
|
|
||||||
for path in sorted(root.rglob("*")):
|
|
||||||
if not path.is_file():
|
|
||||||
continue
|
|
||||||
archive_type = detect_archive_type(path)
|
|
||||||
if not archive_type:
|
|
||||||
continue
|
|
||||||
found.append(
|
|
||||||
{
|
|
||||||
"path": str(path),
|
|
||||||
"relative_path": str(path.relative_to(root)),
|
|
||||||
"type": archive_type,
|
|
||||||
"size": path.stat().st_size,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def parse_nres(data: bytes, source: str = "<memory>") -> dict[str, Any]:
|
|
||||||
if len(data) < 16:
|
|
||||||
raise ArchiveFormatError(f"{source}: NRes too short ({len(data)} bytes)")
|
|
||||||
|
|
||||||
magic, version, entry_count, total_size = struct.unpack_from("<4sIII", data, 0)
|
|
||||||
if magic != MAGIC_NRES:
|
|
||||||
raise ArchiveFormatError(f"{source}: invalid NRes magic")
|
|
||||||
|
|
||||||
issues: list[str] = []
|
|
||||||
if total_size != len(data):
|
|
||||||
issues.append(
|
|
||||||
f"header.total_size={total_size} != actual_size={len(data)} (spec 1.2)"
|
|
||||||
)
|
|
||||||
if version != 0x100:
|
|
||||||
issues.append(f"version=0x{version:08X} != 0x00000100 (spec 1.2)")
|
|
||||||
|
|
||||||
directory_offset = total_size - entry_count * 64
|
|
||||||
if directory_offset < 16 or directory_offset > len(data):
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"{source}: invalid directory offset {directory_offset} for entry_count={entry_count}"
|
|
||||||
)
|
|
||||||
if directory_offset + entry_count * 64 != len(data):
|
|
||||||
issues.append(
|
|
||||||
"directory_offset + entry_count*64 != file_size (spec 1.3)"
|
|
||||||
)
|
|
||||||
|
|
||||||
entries: list[dict[str, Any]] = []
|
|
||||||
for index in range(entry_count):
|
|
||||||
offset = directory_offset + index * 64
|
|
||||||
if offset + 64 > len(data):
|
|
||||||
raise ArchiveFormatError(f"{source}: truncated directory entry {index}")
|
|
||||||
|
|
||||||
(
|
|
||||||
type_id,
|
|
||||||
attr1,
|
|
||||||
attr2,
|
|
||||||
size,
|
|
||||||
attr3,
|
|
||||||
name_raw,
|
|
||||||
data_offset,
|
|
||||||
sort_index,
|
|
||||||
) = struct.unpack_from("<IIIII36sII", data, offset)
|
|
||||||
name_bytes = name_raw.split(b"\x00", 1)[0]
|
|
||||||
name = name_bytes.decode("latin1", errors="replace")
|
|
||||||
entries.append(
|
|
||||||
{
|
|
||||||
"index": index,
|
|
||||||
"type_id": type_id,
|
|
||||||
"attr1": attr1,
|
|
||||||
"attr2": attr2,
|
|
||||||
"size": size,
|
|
||||||
"attr3": attr3,
|
|
||||||
"name": name,
|
|
||||||
"name_bytes_hex": name_bytes.hex(),
|
|
||||||
"name_raw_hex": name_raw.hex(),
|
|
||||||
"data_offset": data_offset,
|
|
||||||
"sort_index": sort_index,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Spec checks.
|
|
||||||
expected_sort = sorted(
|
|
||||||
range(entry_count),
|
|
||||||
key=lambda idx: bytes.fromhex(entries[idx]["name_bytes_hex"]).lower(),
|
|
||||||
)
|
|
||||||
current_sort = [item["sort_index"] for item in entries]
|
|
||||||
if current_sort != expected_sort:
|
|
||||||
issues.append(
|
|
||||||
"sort_index table does not match case-insensitive name order (spec 1.4)"
|
|
||||||
)
|
|
||||||
|
|
||||||
data_regions = sorted(
|
|
||||||
(
|
|
||||||
item["index"],
|
|
||||||
item["data_offset"],
|
|
||||||
item["size"],
|
|
||||||
)
|
|
||||||
for item in entries
|
|
||||||
)
|
|
||||||
for idx, data_offset, size in data_regions:
|
|
||||||
if data_offset % 8 != 0:
|
|
||||||
issues.append(f"entry {idx}: data_offset={data_offset} not aligned to 8 (spec 1.5)")
|
|
||||||
if data_offset < 16 or data_offset + size > directory_offset:
|
|
||||||
issues.append(
|
|
||||||
f"entry {idx}: data range [{data_offset}, {data_offset + size}) out of data area (spec 1.3)"
|
|
||||||
)
|
|
||||||
for i in range(len(data_regions) - 1):
|
|
||||||
_, start, size = data_regions[i]
|
|
||||||
_, next_start, _ = data_regions[i + 1]
|
|
||||||
if start + size > next_start:
|
|
||||||
issues.append(
|
|
||||||
f"entry overlap at data_offset={start}, next={next_start}"
|
|
||||||
)
|
|
||||||
padding = data[start + size : next_start]
|
|
||||||
if any(padding):
|
|
||||||
issues.append(
|
|
||||||
f"non-zero padding after data block at offset={start + size} (spec 1.5)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"format": "NRes",
|
|
||||||
"header": {
|
|
||||||
"magic": "NRes",
|
|
||||||
"version": version,
|
|
||||||
"entry_count": entry_count,
|
|
||||||
"total_size": total_size,
|
|
||||||
"directory_offset": directory_offset,
|
|
||||||
},
|
|
||||||
"entries": entries,
|
|
||||||
"issues": issues,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_nres_name_field(entry: dict[str, Any]) -> bytes:
|
|
||||||
if "name_bytes_hex" in entry:
|
|
||||||
raw = bytes.fromhex(entry["name_bytes_hex"])
|
|
||||||
else:
|
|
||||||
raw = entry.get("name", "").encode("latin1", errors="replace")
|
|
||||||
raw = raw[:35]
|
|
||||||
return raw + b"\x00" * (36 - len(raw))
|
|
||||||
|
|
||||||
|
|
||||||
def unpack_nres_file(archive_path: Path, out_dir: Path, source_root: Path | None = None) -> dict[str, Any]:
|
|
||||||
data = archive_path.read_bytes()
|
|
||||||
parsed = parse_nres(data, source=str(archive_path))
|
|
||||||
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
entries_dir = out_dir / "entries"
|
|
||||||
entries_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
manifest: dict[str, Any] = {
|
|
||||||
"format": "NRes",
|
|
||||||
"source_path": str(archive_path),
|
|
||||||
"source_relative_path": str(archive_path.relative_to(source_root)) if source_root else str(archive_path),
|
|
||||||
"header": parsed["header"],
|
|
||||||
"entries": [],
|
|
||||||
"issues": parsed["issues"],
|
|
||||||
"source_sha256": sha256_hex(data),
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in parsed["entries"]:
|
|
||||||
begin = entry["data_offset"]
|
|
||||||
end = begin + entry["size"]
|
|
||||||
if begin < 0 or end > len(data):
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"{archive_path}: entry {entry['index']} data range outside file"
|
|
||||||
)
|
|
||||||
payload = data[begin:end]
|
|
||||||
base = safe_component(entry["name"], fallback=f"entry_{entry['index']:05d}")
|
|
||||||
file_name = (
|
|
||||||
f"{entry['index']:05d}__{base}"
|
|
||||||
f"__t{entry['type_id']:08X}_a1{entry['attr1']:08X}_a2{entry['attr2']:08X}.bin"
|
|
||||||
)
|
|
||||||
(entries_dir / file_name).write_bytes(payload)
|
|
||||||
|
|
||||||
manifest_entry = dict(entry)
|
|
||||||
manifest_entry["data_file"] = f"entries/{file_name}"
|
|
||||||
manifest_entry["sha256"] = sha256_hex(payload)
|
|
||||||
manifest["entries"].append(manifest_entry)
|
|
||||||
|
|
||||||
dump_json(out_dir / "manifest.json", manifest)
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
|
|
||||||
def pack_nres_manifest(manifest_path: Path, out_file: Path) -> bytes:
|
|
||||||
manifest = load_json(manifest_path)
|
|
||||||
if manifest.get("format") != "NRes":
|
|
||||||
raise ArchiveFormatError(f"{manifest_path}: not an NRes manifest")
|
|
||||||
|
|
||||||
entries = manifest["entries"]
|
|
||||||
count = len(entries)
|
|
||||||
version = int(manifest.get("header", {}).get("version", 0x100))
|
|
||||||
|
|
||||||
out = bytearray(b"\x00" * 16)
|
|
||||||
data_offsets: list[int] = []
|
|
||||||
data_sizes: list[int] = []
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
payload_path = manifest_path.parent / entry["data_file"]
|
|
||||||
payload = payload_path.read_bytes()
|
|
||||||
offset = len(out)
|
|
||||||
out.extend(payload)
|
|
||||||
padding = (-len(out)) % 8
|
|
||||||
if padding:
|
|
||||||
out.extend(b"\x00" * padding)
|
|
||||||
data_offsets.append(offset)
|
|
||||||
data_sizes.append(len(payload))
|
|
||||||
|
|
||||||
directory_offset = len(out)
|
|
||||||
expected_sort = sorted(
|
|
||||||
range(count),
|
|
||||||
key=lambda idx: bytes.fromhex(entries[idx].get("name_bytes_hex", "")).lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
for index, entry in enumerate(entries):
|
|
||||||
name_field = build_nres_name_field(entry)
|
|
||||||
out.extend(
|
|
||||||
struct.pack(
|
|
||||||
"<IIIII36sII",
|
|
||||||
int(entry["type_id"]),
|
|
||||||
int(entry["attr1"]),
|
|
||||||
int(entry["attr2"]),
|
|
||||||
data_sizes[index],
|
|
||||||
int(entry["attr3"]),
|
|
||||||
name_field,
|
|
||||||
data_offsets[index],
|
|
||||||
expected_sort[index],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
total_size = len(out)
|
|
||||||
struct.pack_into("<4sIII", out, 0, MAGIC_NRES, version, count, total_size)
|
|
||||||
|
|
||||||
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
out_file.write_bytes(out)
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
|
|
||||||
def parse_rsli(data: bytes, source: str = "<memory>") -> dict[str, Any]:
|
|
||||||
if len(data) < 32:
|
|
||||||
raise ArchiveFormatError(f"{source}: RsLi too short ({len(data)} bytes)")
|
|
||||||
if data[:4] != MAGIC_RSLI:
|
|
||||||
raise ArchiveFormatError(f"{source}: invalid RsLi magic")
|
|
||||||
|
|
||||||
issues: list[str] = []
|
|
||||||
reserved_zero = data[2]
|
|
||||||
version = data[3]
|
|
||||||
entry_count = struct.unpack_from("<h", data, 4)[0]
|
|
||||||
presorted_flag = struct.unpack_from("<H", data, 14)[0]
|
|
||||||
seed = struct.unpack_from("<I", data, 20)[0]
|
|
||||||
|
|
||||||
if reserved_zero != 0:
|
|
||||||
issues.append(f"header[2]={reserved_zero} != 0 (spec 2.2)")
|
|
||||||
if version != 1:
|
|
||||||
issues.append(f"version={version} != 1 (spec 2.2)")
|
|
||||||
if entry_count < 0:
|
|
||||||
raise ArchiveFormatError(f"{source}: negative entry_count={entry_count}")
|
|
||||||
|
|
||||||
table_offset = 32
|
|
||||||
table_size = entry_count * 32
|
|
||||||
if table_offset + table_size > len(data):
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"{source}: encrypted table out of file bounds ({table_offset}+{table_size}>{len(data)})"
|
|
||||||
)
|
|
||||||
|
|
||||||
table_encrypted = data[table_offset : table_offset + table_size]
|
|
||||||
table_plain = xor_stream(table_encrypted, seed & 0xFFFF)
|
|
||||||
|
|
||||||
trailer: dict[str, Any] = {"present": False}
|
|
||||||
overlay_offset = 0
|
|
||||||
if len(data) >= 6 and data[-6:-4] == b"AO":
|
|
||||||
overlay_offset = struct.unpack_from("<I", data, len(data) - 4)[0]
|
|
||||||
trailer = {
|
|
||||||
"present": True,
|
|
||||||
"signature": "AO",
|
|
||||||
"overlay_offset": overlay_offset,
|
|
||||||
"raw_hex": data[-6:].hex(),
|
|
||||||
}
|
|
||||||
|
|
||||||
entries: list[dict[str, Any]] = []
|
|
||||||
sort_values: list[int] = []
|
|
||||||
for index in range(entry_count):
|
|
||||||
row = table_plain[index * 32 : (index + 1) * 32]
|
|
||||||
name_raw = row[0:12]
|
|
||||||
reserved4 = row[12:16]
|
|
||||||
flags_signed, sort_to_original = struct.unpack_from("<hh", row, 16)
|
|
||||||
unpacked_size, data_offset, packed_size = struct.unpack_from("<III", row, 20)
|
|
||||||
method = flags_signed & 0x1E0
|
|
||||||
name = name_raw.split(b"\x00", 1)[0].decode("latin1", errors="replace")
|
|
||||||
effective_offset = data_offset + overlay_offset
|
|
||||||
entries.append(
|
|
||||||
{
|
|
||||||
"index": index,
|
|
||||||
"name": name,
|
|
||||||
"name_raw_hex": name_raw.hex(),
|
|
||||||
"reserved_raw_hex": reserved4.hex(),
|
|
||||||
"flags_signed": flags_signed,
|
|
||||||
"flags_u16": flags_signed & 0xFFFF,
|
|
||||||
"method": method,
|
|
||||||
"sort_to_original": sort_to_original,
|
|
||||||
"unpacked_size": unpacked_size,
|
|
||||||
"data_offset": data_offset,
|
|
||||||
"effective_data_offset": effective_offset,
|
|
||||||
"packed_size": packed_size,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
sort_values.append(sort_to_original)
|
|
||||||
|
|
||||||
if effective_offset < 0:
|
|
||||||
issues.append(f"entry {index}: negative effective_data_offset={effective_offset}")
|
|
||||||
elif effective_offset + packed_size > len(data):
|
|
||||||
end = effective_offset + packed_size
|
|
||||||
if method == 0x100 and end == len(data) + 1:
|
|
||||||
issues.append(
|
|
||||||
f"entry {index}: deflate packed_size reaches EOF+1 ({end}); "
|
|
||||||
"observed in game data, likely decoder lookahead byte"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
issues.append(
|
|
||||||
f"entry {index}: packed range [{effective_offset}, {end}) out of file"
|
|
||||||
)
|
|
||||||
|
|
||||||
if presorted_flag == 0xABBA:
|
|
||||||
if sorted(sort_values) != list(range(entry_count)):
|
|
||||||
issues.append(
|
|
||||||
"presorted flag is 0xABBA but sort_to_original is not a permutation [0..N-1] (spec 2.2/2.4)"
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"format": "RsLi",
|
|
||||||
"header_raw_hex": data[:32].hex(),
|
|
||||||
"header": {
|
|
||||||
"magic": "NL\\x00\\x01",
|
|
||||||
"entry_count": entry_count,
|
|
||||||
"seed": seed,
|
|
||||||
"presorted_flag": presorted_flag,
|
|
||||||
},
|
|
||||||
"entries": entries,
|
|
||||||
"issues": issues,
|
|
||||||
"trailer": trailer,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def unpack_rsli_file(archive_path: Path, out_dir: Path, source_root: Path | None = None) -> dict[str, Any]:
|
|
||||||
data = archive_path.read_bytes()
|
|
||||||
parsed = parse_rsli(data, source=str(archive_path))
|
|
||||||
|
|
||||||
out_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
entries_dir = out_dir / "entries"
|
|
||||||
entries_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
manifest: dict[str, Any] = {
|
|
||||||
"format": "RsLi",
|
|
||||||
"source_path": str(archive_path),
|
|
||||||
"source_relative_path": str(archive_path.relative_to(source_root)) if source_root else str(archive_path),
|
|
||||||
"source_size": len(data),
|
|
||||||
"header_raw_hex": parsed["header_raw_hex"],
|
|
||||||
"header": parsed["header"],
|
|
||||||
"entries": [],
|
|
||||||
"issues": list(parsed["issues"]),
|
|
||||||
"trailer": parsed["trailer"],
|
|
||||||
"source_sha256": sha256_hex(data),
|
|
||||||
}
|
|
||||||
|
|
||||||
for entry in parsed["entries"]:
|
|
||||||
begin = int(entry["effective_data_offset"])
|
|
||||||
end = begin + int(entry["packed_size"])
|
|
||||||
packed = data[begin:end]
|
|
||||||
base = safe_component(entry["name"], fallback=f"entry_{entry['index']:05d}")
|
|
||||||
packed_name = f"{entry['index']:05d}__{base}__packed.bin"
|
|
||||||
(entries_dir / packed_name).write_bytes(packed)
|
|
||||||
|
|
||||||
manifest_entry = dict(entry)
|
|
||||||
manifest_entry["packed_file"] = f"entries/{packed_name}"
|
|
||||||
manifest_entry["packed_file_size"] = len(packed)
|
|
||||||
manifest_entry["packed_sha256"] = sha256_hex(packed)
|
|
||||||
|
|
||||||
try:
|
|
||||||
unpacked = decode_rsli_payload(
|
|
||||||
packed=packed,
|
|
||||||
method=int(entry["method"]),
|
|
||||||
sort_to_original=int(entry["sort_to_original"]),
|
|
||||||
unpacked_size=int(entry["unpacked_size"]),
|
|
||||||
)
|
|
||||||
unpacked_name = f"{entry['index']:05d}__{base}__unpacked.bin"
|
|
||||||
(entries_dir / unpacked_name).write_bytes(unpacked)
|
|
||||||
manifest_entry["unpacked_file"] = f"entries/{unpacked_name}"
|
|
||||||
manifest_entry["unpacked_sha256"] = sha256_hex(unpacked)
|
|
||||||
except ArchiveFormatError as exc:
|
|
||||||
manifest_entry["unpack_error"] = str(exc)
|
|
||||||
manifest["issues"].append(
|
|
||||||
f"entry {entry['index']}: cannot decode method 0x{entry['method']:03X}: {exc}"
|
|
||||||
)
|
|
||||||
|
|
||||||
manifest["entries"].append(manifest_entry)
|
|
||||||
|
|
||||||
dump_json(out_dir / "manifest.json", manifest)
|
|
||||||
return manifest
|
|
||||||
|
|
||||||
|
|
||||||
def _pack_i16(value: int) -> int:
|
|
||||||
if not (-32768 <= int(value) <= 32767):
|
|
||||||
raise ArchiveFormatError(f"int16 overflow: {value}")
|
|
||||||
return int(value)
|
|
||||||
|
|
||||||
|
|
||||||
def pack_rsli_manifest(manifest_path: Path, out_file: Path) -> bytes:
|
|
||||||
manifest = load_json(manifest_path)
|
|
||||||
if manifest.get("format") != "RsLi":
|
|
||||||
raise ArchiveFormatError(f"{manifest_path}: not an RsLi manifest")
|
|
||||||
|
|
||||||
entries = manifest["entries"]
|
|
||||||
count = len(entries)
|
|
||||||
|
|
||||||
header_raw = bytes.fromhex(manifest["header_raw_hex"])
|
|
||||||
if len(header_raw) != 32:
|
|
||||||
raise ArchiveFormatError(f"{manifest_path}: header_raw_hex must be 32 bytes")
|
|
||||||
header = bytearray(header_raw)
|
|
||||||
header[:4] = MAGIC_RSLI
|
|
||||||
struct.pack_into("<h", header, 4, count)
|
|
||||||
seed = int(manifest["header"]["seed"])
|
|
||||||
struct.pack_into("<I", header, 20, seed)
|
|
||||||
|
|
||||||
rows = bytearray()
|
|
||||||
packed_chunks: list[tuple[dict[str, Any], bytes]] = []
|
|
||||||
|
|
||||||
for entry in entries:
|
|
||||||
packed_path = manifest_path.parent / entry["packed_file"]
|
|
||||||
packed = packed_path.read_bytes()
|
|
||||||
declared_size = int(entry["packed_size"])
|
|
||||||
if len(packed) > declared_size:
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"{packed_path}: packed size {len(packed)} > manifest packed_size {declared_size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
data_offset = int(entry["data_offset"])
|
|
||||||
packed_chunks.append((entry, packed))
|
|
||||||
|
|
||||||
row = bytearray(32)
|
|
||||||
name_raw = bytes.fromhex(entry["name_raw_hex"])
|
|
||||||
reserved_raw = bytes.fromhex(entry["reserved_raw_hex"])
|
|
||||||
if len(name_raw) != 12 or len(reserved_raw) != 4:
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"entry {entry['index']}: invalid name/reserved raw length"
|
|
||||||
)
|
|
||||||
row[0:12] = name_raw
|
|
||||||
row[12:16] = reserved_raw
|
|
||||||
struct.pack_into(
|
|
||||||
"<hhIII",
|
|
||||||
row,
|
|
||||||
16,
|
|
||||||
_pack_i16(int(entry["flags_signed"])),
|
|
||||||
_pack_i16(int(entry["sort_to_original"])),
|
|
||||||
int(entry["unpacked_size"]),
|
|
||||||
data_offset,
|
|
||||||
declared_size,
|
|
||||||
)
|
|
||||||
rows.extend(row)
|
|
||||||
|
|
||||||
encrypted_table = xor_stream(bytes(rows), seed & 0xFFFF)
|
|
||||||
trailer = manifest.get("trailer", {})
|
|
||||||
trailer_raw = b""
|
|
||||||
if trailer.get("present"):
|
|
||||||
raw_hex = trailer.get("raw_hex", "")
|
|
||||||
trailer_raw = bytes.fromhex(raw_hex)
|
|
||||||
if len(trailer_raw) != 6:
|
|
||||||
raise ArchiveFormatError("trailer raw length must be 6 bytes")
|
|
||||||
|
|
||||||
source_size = manifest.get("source_size")
|
|
||||||
table_end = 32 + count * 32
|
|
||||||
if source_size is not None:
|
|
||||||
pre_trailer_size = int(source_size) - len(trailer_raw)
|
|
||||||
if pre_trailer_size < table_end:
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"invalid source_size={source_size}: smaller than header+table"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
pre_trailer_size = table_end
|
|
||||||
for entry, packed in packed_chunks:
|
|
||||||
pre_trailer_size = max(
|
|
||||||
pre_trailer_size, int(entry["data_offset"]) + len(packed)
|
|
||||||
)
|
|
||||||
|
|
||||||
out = bytearray(pre_trailer_size)
|
|
||||||
out[0:32] = header
|
|
||||||
out[32:table_end] = encrypted_table
|
|
||||||
occupied = bytearray(pre_trailer_size)
|
|
||||||
occupied[0:table_end] = b"\x01" * table_end
|
|
||||||
|
|
||||||
for entry, packed in packed_chunks:
|
|
||||||
base_offset = int(entry["data_offset"])
|
|
||||||
for index, byte in enumerate(packed):
|
|
||||||
pos = base_offset + index
|
|
||||||
if pos >= pre_trailer_size:
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"entry {entry['index']}: data write at {pos} beyond output size {pre_trailer_size}"
|
|
||||||
)
|
|
||||||
if occupied[pos] and out[pos] != byte:
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"entry {entry['index']}: overlapping packed data conflict at offset {pos}"
|
|
||||||
)
|
|
||||||
out[pos] = byte
|
|
||||||
occupied[pos] = 1
|
|
||||||
|
|
||||||
out.extend(trailer_raw)
|
|
||||||
if source_size is not None and len(out) != int(source_size):
|
|
||||||
raise ArchiveFormatError(
|
|
||||||
f"packed size {len(out)} != source_size {source_size} from manifest"
|
|
||||||
)
|
|
||||||
|
|
||||||
out_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
out_file.write_bytes(out)
|
|
||||||
return bytes(out)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_scan(args: argparse.Namespace) -> int:
|
|
||||||
root = Path(args.input).resolve()
|
|
||||||
archives = scan_archives(root)
|
|
||||||
if args.json:
|
|
||||||
print(json.dumps(archives, ensure_ascii=False, indent=2))
|
|
||||||
else:
|
|
||||||
print(f"Found {len(archives)} archive(s) in {root}")
|
|
||||||
for item in archives:
|
|
||||||
print(f"{item['type']:4} {item['size']:10d} {item['relative_path']}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_nres_unpack(args: argparse.Namespace) -> int:
|
|
||||||
archive_path = Path(args.archive).resolve()
|
|
||||||
out_dir = Path(args.output).resolve()
|
|
||||||
manifest = unpack_nres_file(archive_path, out_dir)
|
|
||||||
print(f"NRes unpacked: {archive_path}")
|
|
||||||
print(f"Manifest: {out_dir / 'manifest.json'}")
|
|
||||||
print(f"Entries : {len(manifest['entries'])}")
|
|
||||||
if manifest["issues"]:
|
|
||||||
print("Issues:")
|
|
||||||
for issue in manifest["issues"]:
|
|
||||||
print(f"- {issue}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_nres_pack(args: argparse.Namespace) -> int:
|
|
||||||
manifest_path = Path(args.manifest).resolve()
|
|
||||||
out_file = Path(args.output).resolve()
|
|
||||||
packed = pack_nres_manifest(manifest_path, out_file)
|
|
||||||
print(f"NRes packed: {out_file} ({len(packed)} bytes, sha256={sha256_hex(packed)})")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_rsli_unpack(args: argparse.Namespace) -> int:
|
|
||||||
archive_path = Path(args.archive).resolve()
|
|
||||||
out_dir = Path(args.output).resolve()
|
|
||||||
manifest = unpack_rsli_file(archive_path, out_dir)
|
|
||||||
print(f"RsLi unpacked: {archive_path}")
|
|
||||||
print(f"Manifest: {out_dir / 'manifest.json'}")
|
|
||||||
print(f"Entries : {len(manifest['entries'])}")
|
|
||||||
if manifest["issues"]:
|
|
||||||
print("Issues:")
|
|
||||||
for issue in manifest["issues"]:
|
|
||||||
print(f"- {issue}")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_rsli_pack(args: argparse.Namespace) -> int:
|
|
||||||
manifest_path = Path(args.manifest).resolve()
|
|
||||||
out_file = Path(args.output).resolve()
|
|
||||||
packed = pack_rsli_manifest(manifest_path, out_file)
|
|
||||||
print(f"RsLi packed: {out_file} ({len(packed)} bytes, sha256={sha256_hex(packed)})")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_validate(args: argparse.Namespace) -> int:
|
|
||||||
input_root = Path(args.input).resolve()
|
|
||||||
archives = scan_archives(input_root)
|
|
||||||
|
|
||||||
temp_created = False
|
|
||||||
if args.workdir:
|
|
||||||
workdir = Path(args.workdir).resolve()
|
|
||||||
workdir.mkdir(parents=True, exist_ok=True)
|
|
||||||
else:
|
|
||||||
workdir = Path(tempfile.mkdtemp(prefix="nres-rsli-validate-"))
|
|
||||||
temp_created = True
|
|
||||||
|
|
||||||
report: dict[str, Any] = {
|
|
||||||
"input_root": str(input_root),
|
|
||||||
"workdir": str(workdir),
|
|
||||||
"archives_total": len(archives),
|
|
||||||
"results": [],
|
|
||||||
"summary": {},
|
|
||||||
}
|
|
||||||
|
|
||||||
failures = 0
|
|
||||||
try:
|
|
||||||
for idx, item in enumerate(archives):
|
|
||||||
rel = item["relative_path"]
|
|
||||||
archive_path = input_root / rel
|
|
||||||
marker = f"{idx:04d}_{safe_component(rel, fallback='archive')}"
|
|
||||||
unpack_dir = workdir / "unpacked" / marker
|
|
||||||
repacked_file = workdir / "repacked" / f"{marker}.bin"
|
|
||||||
try:
|
|
||||||
if item["type"] == "nres":
|
|
||||||
manifest = unpack_nres_file(archive_path, unpack_dir, source_root=input_root)
|
|
||||||
repacked = pack_nres_manifest(unpack_dir / "manifest.json", repacked_file)
|
|
||||||
elif item["type"] == "rsli":
|
|
||||||
manifest = unpack_rsli_file(archive_path, unpack_dir, source_root=input_root)
|
|
||||||
repacked = pack_rsli_manifest(unpack_dir / "manifest.json", repacked_file)
|
|
||||||
else:
|
|
||||||
continue
|
|
||||||
|
|
||||||
original = archive_path.read_bytes()
|
|
||||||
match = original == repacked
|
|
||||||
diff_offset, diff_desc = first_diff(original, repacked)
|
|
||||||
issues = list(manifest.get("issues", []))
|
|
||||||
result = {
|
|
||||||
"relative_path": rel,
|
|
||||||
"type": item["type"],
|
|
||||||
"size_original": len(original),
|
|
||||||
"size_repacked": len(repacked),
|
|
||||||
"sha256_original": sha256_hex(original),
|
|
||||||
"sha256_repacked": sha256_hex(repacked),
|
|
||||||
"match": match,
|
|
||||||
"first_diff_offset": diff_offset,
|
|
||||||
"first_diff": diff_desc,
|
|
||||||
"issues": issues,
|
|
||||||
"entries": len(manifest.get("entries", [])),
|
|
||||||
"error": None,
|
|
||||||
}
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
|
||||||
result = {
|
|
||||||
"relative_path": rel,
|
|
||||||
"type": item["type"],
|
|
||||||
"size_original": item["size"],
|
|
||||||
"size_repacked": None,
|
|
||||||
"sha256_original": None,
|
|
||||||
"sha256_repacked": None,
|
|
||||||
"match": False,
|
|
||||||
"first_diff_offset": None,
|
|
||||||
"first_diff": None,
|
|
||||||
"issues": [f"processing error: {exc}"],
|
|
||||||
"entries": None,
|
|
||||||
"error": str(exc),
|
|
||||||
}
|
|
||||||
|
|
||||||
report["results"].append(result)
|
|
||||||
|
|
||||||
if not result["match"]:
|
|
||||||
failures += 1
|
|
||||||
if result["issues"] and args.fail_on_issues:
|
|
||||||
failures += 1
|
|
||||||
|
|
||||||
matches = sum(1 for row in report["results"] if row["match"])
|
|
||||||
mismatches = len(report["results"]) - matches
|
|
||||||
nres_count = sum(1 for row in report["results"] if row["type"] == "nres")
|
|
||||||
rsli_count = sum(1 for row in report["results"] if row["type"] == "rsli")
|
|
||||||
issues_total = sum(len(row["issues"]) for row in report["results"])
|
|
||||||
report["summary"] = {
|
|
||||||
"nres_count": nres_count,
|
|
||||||
"rsli_count": rsli_count,
|
|
||||||
"matches": matches,
|
|
||||||
"mismatches": mismatches,
|
|
||||||
"issues_total": issues_total,
|
|
||||||
}
|
|
||||||
|
|
||||||
if args.report:
|
|
||||||
dump_json(Path(args.report).resolve(), report)
|
|
||||||
|
|
||||||
print(f"Input root : {input_root}")
|
|
||||||
print(f"Work dir : {workdir}")
|
|
||||||
print(f"NRes archives : {nres_count}")
|
|
||||||
print(f"RsLi archives : {rsli_count}")
|
|
||||||
print(f"Roundtrip match: {matches}/{len(report['results'])}")
|
|
||||||
print(f"Doc issues : {issues_total}")
|
|
||||||
|
|
||||||
if mismatches:
|
|
||||||
print("\nMismatches:")
|
|
||||||
for row in report["results"]:
|
|
||||||
if row["match"]:
|
|
||||||
continue
|
|
||||||
print(
|
|
||||||
f"- {row['relative_path']} [{row['type']}] "
|
|
||||||
f"diff@{row['first_diff_offset']}: {row['first_diff']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if issues_total:
|
|
||||||
print("\nIssues:")
|
|
||||||
for row in report["results"]:
|
|
||||||
if not row["issues"]:
|
|
||||||
continue
|
|
||||||
print(f"- {row['relative_path']} [{row['type']}]")
|
|
||||||
for issue in row["issues"]:
|
|
||||||
print(f" * {issue}")
|
|
||||||
|
|
||||||
finally:
|
|
||||||
if temp_created or args.cleanup:
|
|
||||||
shutil.rmtree(workdir, ignore_errors=True)
|
|
||||||
|
|
||||||
if failures > 0:
|
|
||||||
return 1
|
|
||||||
if report["summary"].get("mismatches", 0) > 0 and args.fail_on_diff:
|
|
||||||
return 1
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="NRes/RsLi tools: scan, unpack, repack, and roundtrip validation."
|
|
||||||
)
|
|
||||||
sub = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
scan = sub.add_parser("scan", help="Scan files by header signatures.")
|
|
||||||
scan.add_argument("--input", required=True, help="Root directory to scan.")
|
|
||||||
scan.add_argument("--json", action="store_true", help="Print JSON output.")
|
|
||||||
scan.set_defaults(func=cmd_scan)
|
|
||||||
|
|
||||||
nres_unpack = sub.add_parser("nres-unpack", help="Unpack a single NRes archive.")
|
|
||||||
nres_unpack.add_argument("--archive", required=True, help="Path to NRes file.")
|
|
||||||
nres_unpack.add_argument("--output", required=True, help="Output directory.")
|
|
||||||
nres_unpack.set_defaults(func=cmd_nres_unpack)
|
|
||||||
|
|
||||||
nres_pack = sub.add_parser("nres-pack", help="Pack NRes archive from manifest.")
|
|
||||||
nres_pack.add_argument("--manifest", required=True, help="Path to manifest.json.")
|
|
||||||
nres_pack.add_argument("--output", required=True, help="Output file path.")
|
|
||||||
nres_pack.set_defaults(func=cmd_nres_pack)
|
|
||||||
|
|
||||||
rsli_unpack = sub.add_parser("rsli-unpack", help="Unpack a single RsLi archive.")
|
|
||||||
rsli_unpack.add_argument("--archive", required=True, help="Path to RsLi file.")
|
|
||||||
rsli_unpack.add_argument("--output", required=True, help="Output directory.")
|
|
||||||
rsli_unpack.set_defaults(func=cmd_rsli_unpack)
|
|
||||||
|
|
||||||
rsli_pack = sub.add_parser("rsli-pack", help="Pack RsLi archive from manifest.")
|
|
||||||
rsli_pack.add_argument("--manifest", required=True, help="Path to manifest.json.")
|
|
||||||
rsli_pack.add_argument("--output", required=True, help="Output file path.")
|
|
||||||
rsli_pack.set_defaults(func=cmd_rsli_pack)
|
|
||||||
|
|
||||||
validate = sub.add_parser(
|
|
||||||
"validate",
|
|
||||||
help="Scan all archives and run unpack->repack->byte-compare validation.",
|
|
||||||
)
|
|
||||||
validate.add_argument("--input", required=True, help="Root with game data files.")
|
|
||||||
validate.add_argument(
|
|
||||||
"--workdir",
|
|
||||||
help="Working directory for temporary unpack/repack files. "
|
|
||||||
"If omitted, a temporary directory is used and removed automatically.",
|
|
||||||
)
|
|
||||||
validate.add_argument("--report", help="Optional JSON report output path.")
|
|
||||||
validate.add_argument(
|
|
||||||
"--fail-on-diff",
|
|
||||||
action="store_true",
|
|
||||||
help="Return non-zero exit code if any byte mismatch exists.",
|
|
||||||
)
|
|
||||||
validate.add_argument(
|
|
||||||
"--fail-on-issues",
|
|
||||||
action="store_true",
|
|
||||||
help="Return non-zero exit code if any spec issue was detected.",
|
|
||||||
)
|
|
||||||
validate.add_argument(
|
|
||||||
"--cleanup",
|
|
||||||
action="store_true",
|
|
||||||
help="Remove --workdir after completion.",
|
|
||||||
)
|
|
||||||
validate.set_defaults(func=cmd_validate)
|
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
parser = build_parser()
|
|
||||||
args = parser.parse_args()
|
|
||||||
return int(args.func(args))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Initialize test data folders by archive signatures.
|
|
||||||
|
|
||||||
The script scans all files in --input and copies matching archives into:
|
|
||||||
--output/nres/<relative path>
|
|
||||||
--output/rsli/<relative path>
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import shutil
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
MAGIC_NRES = b"NRes"
|
|
||||||
MAGIC_RSLI = b"NL\x00\x01"
|
|
||||||
|
|
||||||
|
|
||||||
def is_relative_to(path: Path, base: Path) -> bool:
|
|
||||||
try:
|
|
||||||
path.relative_to(base)
|
|
||||||
except ValueError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def detect_archive_type(path: Path) -> str | None:
|
|
||||||
try:
|
|
||||||
with path.open("rb") as handle:
|
|
||||||
magic = handle.read(4)
|
|
||||||
except OSError as exc:
|
|
||||||
print(f"[warn] cannot read {path}: {exc}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
if magic == MAGIC_NRES:
|
|
||||||
return "nres"
|
|
||||||
if magic == MAGIC_RSLI:
|
|
||||||
return "rsli"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def scan_archives(input_root: Path, excluded_root: Path | None) -> list[tuple[Path, str]]:
|
|
||||||
found: list[tuple[Path, str]] = []
|
|
||||||
for path in sorted(input_root.rglob("*")):
|
|
||||||
if not path.is_file():
|
|
||||||
continue
|
|
||||||
if excluded_root and is_relative_to(path.resolve(), excluded_root):
|
|
||||||
continue
|
|
||||||
|
|
||||||
archive_type = detect_archive_type(path)
|
|
||||||
if archive_type:
|
|
||||||
found.append((path, archive_type))
|
|
||||||
return found
|
|
||||||
|
|
||||||
|
|
||||||
def confirm_overwrite(path: Path) -> str:
|
|
||||||
prompt = (
|
|
||||||
f"File exists: {path}\n"
|
|
||||||
"Overwrite? [y]es / [n]o / [a]ll / [q]uit (default: n): "
|
|
||||||
)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
answer = input(prompt).strip().lower()
|
|
||||||
except EOFError:
|
|
||||||
return "quit"
|
|
||||||
|
|
||||||
if answer in {"", "n", "no"}:
|
|
||||||
return "no"
|
|
||||||
if answer in {"y", "yes"}:
|
|
||||||
return "yes"
|
|
||||||
if answer in {"a", "all"}:
|
|
||||||
return "all"
|
|
||||||
if answer in {"q", "quit"}:
|
|
||||||
return "quit"
|
|
||||||
print("Please answer with y, n, a, or q.")
|
|
||||||
|
|
||||||
|
|
||||||
def copy_archives(
|
|
||||||
archives: list[tuple[Path, str]],
|
|
||||||
input_root: Path,
|
|
||||||
output_root: Path,
|
|
||||||
force: bool,
|
|
||||||
) -> int:
|
|
||||||
copied = 0
|
|
||||||
skipped = 0
|
|
||||||
overwritten = 0
|
|
||||||
overwrite_all = force
|
|
||||||
|
|
||||||
type_counts = {"nres": 0, "rsli": 0}
|
|
||||||
for _, archive_type in archives:
|
|
||||||
type_counts[archive_type] += 1
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"Found archives: total={len(archives)}, "
|
|
||||||
f"nres={type_counts['nres']}, rsli={type_counts['rsli']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
for source, archive_type in archives:
|
|
||||||
rel_path = source.relative_to(input_root)
|
|
||||||
destination = output_root / archive_type / rel_path
|
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
if destination.exists():
|
|
||||||
if destination.is_dir():
|
|
||||||
print(
|
|
||||||
f"[error] destination is a directory, expected file: {destination}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
if not overwrite_all:
|
|
||||||
if not sys.stdin.isatty():
|
|
||||||
print(
|
|
||||||
"[error] destination file exists but stdin is not interactive. "
|
|
||||||
"Use --force to overwrite without prompts.",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
decision = confirm_overwrite(destination)
|
|
||||||
if decision == "quit":
|
|
||||||
print("Aborted by user.")
|
|
||||||
return 130
|
|
||||||
if decision == "no":
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
if decision == "all":
|
|
||||||
overwrite_all = True
|
|
||||||
|
|
||||||
overwritten += 1
|
|
||||||
|
|
||||||
try:
|
|
||||||
shutil.copy2(source, destination)
|
|
||||||
except OSError as exc:
|
|
||||||
print(f"[error] failed to copy {source} -> {destination}: {exc}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
copied += 1
|
|
||||||
|
|
||||||
print(
|
|
||||||
f"Done: copied={copied}, overwritten={overwritten}, skipped={skipped}, "
|
|
||||||
f"output={output_root}"
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Initialize test data by scanning NRes/RsLi signatures."
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--input",
|
|
||||||
required=True,
|
|
||||||
help="Input directory to scan recursively.",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--output",
|
|
||||||
required=True,
|
|
||||||
help="Output root directory (archives go to nres/ and rsli/ subdirs).",
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--force",
|
|
||||||
action="store_true",
|
|
||||||
help="Overwrite destination files without confirmation prompts.",
|
|
||||||
)
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
|
||||||
args = build_parser().parse_args()
|
|
||||||
|
|
||||||
input_root = Path(args.input)
|
|
||||||
if not input_root.exists():
|
|
||||||
print(f"[error] input directory does not exist: {input_root}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
if not input_root.is_dir():
|
|
||||||
print(f"[error] input path is not a directory: {input_root}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
output_root = Path(args.output)
|
|
||||||
if output_root.exists() and not output_root.is_dir():
|
|
||||||
print(f"[error] output path exists and is not a directory: {output_root}", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
input_resolved = input_root.resolve()
|
|
||||||
output_resolved = output_root.resolve()
|
|
||||||
if input_resolved == output_resolved:
|
|
||||||
print("[error] input and output directories must be different.", file=sys.stderr)
|
|
||||||
return 2
|
|
||||||
|
|
||||||
excluded_root: Path | None = None
|
|
||||||
if is_relative_to(output_resolved, input_resolved):
|
|
||||||
excluded_root = output_resolved
|
|
||||||
print(f"Notice: output is inside input, skipping scan under: {excluded_root}")
|
|
||||||
|
|
||||||
archives = scan_archives(input_root, excluded_root)
|
|
||||||
|
|
||||||
output_root.mkdir(parents=True, exist_ok=True)
|
|
||||||
return copy_archives(archives, input_root, output_root, force=args.force)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
raise SystemExit(main())
|
|
||||||
14
tools/nres-cli/Cargo.toml
Normal file
14
tools/nres-cli/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "nres-cli"
|
||||||
|
version = "0.2.3"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
byteorder = "1.4"
|
||||||
|
clap = { version = "4.2", features = ["derive"] }
|
||||||
|
console = "0.16"
|
||||||
|
dialoguer = { version = "0.12", features = ["completion"] }
|
||||||
|
indicatif = "0.18"
|
||||||
|
libnres = { version = "0.1", path = "../../libs/nres" }
|
||||||
|
miette = { version = "7.0", features = ["fancy"] }
|
||||||
|
tempdir = "0.3"
|
||||||
6
tools/nres-cli/README.md
Normal file
6
tools/nres-cli/README.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
# 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.
|
||||||
198
tools/nres-cli/src/main.rs
Normal file
198
tools/nres-cli/src/main.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
8
tools/texture-decoder/Cargo.toml
Normal file
8
tools/texture-decoder/Cargo.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "texture-decoder"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
byteorder = "1.4.3"
|
||||||
|
image = "0.25.0"
|
||||||
13
tools/texture-decoder/README.md
Normal file
13
tools/texture-decoder/README.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Декодировщик текстур
|
||||||
|
|
||||||
|
Сборка:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/texture-decoder ./out/AIM_02.0 ./out/AIM_02.0.png
|
||||||
|
```
|
||||||
41
tools/texture-decoder/src/main.rs
Normal file
41
tools/texture-decoder/src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
use byteorder::ReadBytesExt;
|
||||||
|
use image::Rgba;
|
||||||
|
|
||||||
|
fn decode_texture(file_path: &str, output_path: &str) -> Result<(), std::io::Error> {
|
||||||
|
// Читаем файл
|
||||||
|
let mut file = std::fs::File::open(file_path)?;
|
||||||
|
let mut buffer: Vec<u8> = Vec::new();
|
||||||
|
file.read_to_end(&mut buffer)?;
|
||||||
|
|
||||||
|
// Декодируем метаданные
|
||||||
|
let mut cursor = std::io::Cursor::new(&buffer[4..]);
|
||||||
|
let img_width = cursor.read_u32::<byteorder::LittleEndian>()?;
|
||||||
|
let img_height = cursor.read_u32::<byteorder::LittleEndian>()?;
|
||||||
|
|
||||||
|
// Пропустить оставшиеся байты метаданных
|
||||||
|
cursor.set_position(20);
|
||||||
|
|
||||||
|
// Извлекаем данные изображения
|
||||||
|
let image_data = buffer[cursor.position() as usize..].to_vec();
|
||||||
|
let img =
|
||||||
|
image::ImageBuffer::<Rgba<u8>, _>::from_raw(img_width, img_height, image_data.to_vec())
|
||||||
|
.expect("Failed to decode image");
|
||||||
|
|
||||||
|
// Сохраняем изображение
|
||||||
|
img.save(output_path).unwrap();
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
|
||||||
|
let input = &args[1];
|
||||||
|
let output = &args[2];
|
||||||
|
|
||||||
|
if let Err(err) = decode_texture(input, output) {
|
||||||
|
eprintln!("Error: {}", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
9
tools/unpacker/Cargo.toml
Normal file
9
tools/unpacker/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "unpacker"
|
||||||
|
version = "0.1.1"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
byteorder = "1.4.3"
|
||||||
|
serde = { version = "1.0.160", features = ["derive"] }
|
||||||
|
serde_json = "1.0.96"
|
||||||
41
tools/unpacker/README.md
Normal file
41
tools/unpacker/README.md
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# NRes Game Resource Unpacker
|
||||||
|
|
||||||
|
At the moment, this is a demonstration of the NRes game resource unpacking algorithm in action.
|
||||||
|
It unpacks 100% of the NRes game resources for the game "Parkan: Iron Strategy".
|
||||||
|
The unpacked resources can be packed again using the [packer](../packer) utility and replace the original game files.
|
||||||
|
|
||||||
|
__Attention!__
|
||||||
|
This is a test version of the utility.
|
||||||
|
It overwrites existing files without asking.
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
To build the tools, you need to run the following command in the root directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
You can run the utility with the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./target/release/unpacker /path/to/file.ex /path/to/output
|
||||||
|
```
|
||||||
|
|
||||||
|
- `/path/to/file.ex`: This is the file containing the game resources that will be unpacked.
|
||||||
|
- `/path/to/output`: This is the directory where the unpacked files will be placed.
|
||||||
|
|
||||||
|
## How it Works
|
||||||
|
|
||||||
|
The structure describing the packed game resources is not fully understood yet.
|
||||||
|
Therefore, the utility saves unpacked files in the format `file_name.file_index` because some files have the same name.
|
||||||
|
|
||||||
|
Additionally, an `index.json` file is created, which is important for re-packing the files.
|
||||||
|
This file lists all the fields that game resources have in their packed form.
|
||||||
|
It is essential to preserve the file index for the game to function correctly, as the game engine looks for the necessary files by index.
|
||||||
|
|
||||||
|
Files can be replaced and packed back using the [packer](../packer).
|
||||||
|
The newly obtained game resource files are correctly processed by the game engine.
|
||||||
|
For example, sounds and 3D models of warbots' weapons were successfully replaced.
|
||||||
124
tools/unpacker/src/main.rs
Normal file
124
tools/unpacker/src/main.rs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs::File;
|
||||||
|
use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
|
||||||
|
|
||||||
|
use byteorder::{ByteOrder, LittleEndian};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct FileHeader {
|
||||||
|
pub size: u32,
|
||||||
|
pub total: u32,
|
||||||
|
pub type1: u32,
|
||||||
|
pub type2: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
|
pub struct ListElement {
|
||||||
|
pub extension: String,
|
||||||
|
pub index: u32,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub position: u32,
|
||||||
|
#[serde(skip_serializing)]
|
||||||
|
pub size: u32,
|
||||||
|
pub unknown0: u32,
|
||||||
|
pub unknown1: u32,
|
||||||
|
pub unknown2: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args: Vec<String> = env::args().collect();
|
||||||
|
|
||||||
|
let input = &args[1];
|
||||||
|
let output = &args[2];
|
||||||
|
|
||||||
|
unpack(String::from(input), String::from(output));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unpack(input: String, output: String) {
|
||||||
|
let file = File::open(input).unwrap();
|
||||||
|
let metadata = file.metadata().unwrap();
|
||||||
|
|
||||||
|
let mut reader = BufReader::new(file);
|
||||||
|
let mut list: Vec<ListElement> = Vec::new();
|
||||||
|
|
||||||
|
// Считываем заголовок файла
|
||||||
|
let mut header_buffer = [0u8; 16];
|
||||||
|
reader.seek(SeekFrom::Start(0)).unwrap();
|
||||||
|
reader.read_exact(&mut header_buffer).unwrap();
|
||||||
|
|
||||||
|
let file_header = FileHeader {
|
||||||
|
size: LittleEndian::read_u32(&header_buffer[12..16]),
|
||||||
|
total: LittleEndian::read_u32(&header_buffer[8..12]),
|
||||||
|
type1: LittleEndian::read_u32(&header_buffer[0..4]),
|
||||||
|
type2: LittleEndian::read_u32(&header_buffer[4..8]),
|
||||||
|
};
|
||||||
|
|
||||||
|
if file_header.type1 != 1936020046 || file_header.type2 != 256 {
|
||||||
|
panic!("this isn't NRes file");
|
||||||
|
}
|
||||||
|
|
||||||
|
if metadata.len() != file_header.size as u64 {
|
||||||
|
panic!("incorrect size")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Считываем список файлов
|
||||||
|
let list_files_start_position = file_header.size - (file_header.total * 64);
|
||||||
|
let list_files_size = file_header.total * 64;
|
||||||
|
|
||||||
|
let mut list_buffer = vec![0u8; list_files_size as usize];
|
||||||
|
reader
|
||||||
|
.seek(SeekFrom::Start(list_files_start_position as u64))
|
||||||
|
.unwrap();
|
||||||
|
reader.read_exact(&mut list_buffer).unwrap();
|
||||||
|
|
||||||
|
if !list_buffer.len().is_multiple_of(64) {
|
||||||
|
panic!("invalid files list")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..(list_buffer.len() / 64) {
|
||||||
|
let from = i * 64;
|
||||||
|
let to = (i * 64) + 64;
|
||||||
|
let chunk: &[u8] = &list_buffer[from..to];
|
||||||
|
|
||||||
|
let element_list = ListElement {
|
||||||
|
extension: String::from_utf8_lossy(&chunk[0..4])
|
||||||
|
.trim_matches(char::from(0))
|
||||||
|
.to_string(),
|
||||||
|
index: LittleEndian::read_u32(&chunk[60..64]),
|
||||||
|
name: String::from_utf8_lossy(&chunk[20..56])
|
||||||
|
.trim_matches(char::from(0))
|
||||||
|
.to_string(),
|
||||||
|
position: LittleEndian::read_u32(&chunk[56..60]),
|
||||||
|
size: LittleEndian::read_u32(&chunk[12..16]),
|
||||||
|
unknown0: LittleEndian::read_u32(&chunk[4..8]),
|
||||||
|
unknown1: LittleEndian::read_u32(&chunk[8..12]),
|
||||||
|
unknown2: LittleEndian::read_u32(&chunk[16..20]),
|
||||||
|
};
|
||||||
|
|
||||||
|
list.push(element_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Распаковываем файлы в директорию
|
||||||
|
for element in &list {
|
||||||
|
let path = format!("{}/{}.{}", output, element.name, element.index);
|
||||||
|
let mut file = File::create(path).unwrap();
|
||||||
|
|
||||||
|
let mut file_buffer = vec![0u8; element.size as usize];
|
||||||
|
reader
|
||||||
|
.seek(SeekFrom::Start(element.position as u64))
|
||||||
|
.unwrap();
|
||||||
|
reader.read_exact(&mut file_buffer).unwrap();
|
||||||
|
|
||||||
|
file.write_all(&file_buffer).unwrap();
|
||||||
|
file_buffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выгрузка списка файлов в JSON
|
||||||
|
let path = format!("{}/{}", output, "index.json");
|
||||||
|
let file = File::create(path).unwrap();
|
||||||
|
let mut writer = BufWriter::new(file);
|
||||||
|
serde_json::to_writer_pretty(&mut writer, &list).unwrap();
|
||||||
|
writer.flush().unwrap();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user