Compare commits
20 Commits
f1bd98083b
...
c69cad6a26
| Author | SHA1 | Date | |
|---|---|---|---|
|
c69cad6a26
|
|||
|
a24910791e
|
|||
|
371a060eb6
|
|||
|
e08b5f3853
|
|||
|
5a97f2e429
|
|||
|
9e2dcb44a6
|
|||
|
828106ba81
|
|||
|
a7dd18fa1d
|
|||
|
f8cca32968
|
|||
|
ef93237724
|
|||
|
58a896221f
|
|||
|
3f48f53bd5
|
|||
|
2953f0c8c9
|
|||
|
022ec608f5
|
|||
|
54c94fddb5
|
|||
|
0def311fd1
|
|||
|
2f157d0972
|
|||
|
8f57a8f0f9
|
|||
|
40e7d88fd0
|
|||
|
afe6b9a29b
|
@@ -2,14 +2,8 @@
|
||||
"image": "mcr.microsoft.com/devcontainers/rust:latest",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"rust-lang.rust-analyzer"
|
||||
]
|
||||
"extensions": ["rust-lang.rust-analyzer"]
|
||||
}
|
||||
},
|
||||
"runArgs": [
|
||||
"--cap-add=SYS_PTRACE",
|
||||
"--security-opt",
|
||||
"seccomp=unconfined"
|
||||
]
|
||||
}
|
||||
"runArgs": ["--cap-add=SYS_PTRACE", "--security-opt", "seccomp=unconfined"]
|
||||
}
|
||||
|
||||
@@ -3,9 +3,6 @@ name: RenovateBot
|
||||
on:
|
||||
schedule:
|
||||
- cron: "@daily"
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
renovate:
|
||||
|
||||
@@ -3,11 +3,25 @@ name: Test
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: cargo test
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- run: cargo check --all
|
||||
- run: cargo test --all-features
|
||||
with:
|
||||
components: clippy
|
||||
- name: Cargo check
|
||||
run: cargo check --workspace --all-targets --all-features
|
||||
- name: Clippy (deny warnings)
|
||||
run: cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- name: Cargo test
|
||||
run: cargo test --workspace --all-features -- --nocapture
|
||||
|
||||
14
.github/dependabot.yml
vendored
14
.github/dependabot.yml
vendored
@@ -1,14 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
- package-ecosystem: "devcontainers"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
219
.gitignore
vendored
219
.gitignore
vendored
@@ -1 +1,218 @@
|
||||
/target
|
||||
*~
|
||||
|
||||
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder which might appear on any partition or disk
|
||||
.Trash-*
|
||||
|
||||
# .nfs files are created when an open file is removed but is still being accessed
|
||||
.nfs*
|
||||
|
||||
# General
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
# Icon must end with two \r
|
||||
Icon
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Files that might appear in the root of a volume
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
# Directories potentially created on remote AFP share
|
||||
.AppleDB
|
||||
.AppleDesktop
|
||||
Network Trash Folder
|
||||
Temporary Items
|
||||
.apdisk
|
||||
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
Thumbs.db:encryptable
|
||||
ehthumbs.db
|
||||
ehthumbs_vista.db
|
||||
|
||||
# Dump file
|
||||
*.stackdump
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows Installer files
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
tmp/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pdm
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# Poetry local configuration file
|
||||
poetry.toml
|
||||
|
||||
# ruff
|
||||
.ruff_cache/
|
||||
|
||||
# LSP config files
|
||||
pyrightconfig.json
|
||||
1710
Cargo.lock
generated
1710
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["libs/*", "tools/*", "packer"]
|
||||
resolver = "3"
|
||||
members = ["crates/*"]
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
|
||||
60
README.md
60
README.md
@@ -1,11 +1,55 @@
|
||||
# Utilities for the game "Parkan: Iron Strategy"
|
||||
# FParkan
|
||||
|
||||
This repository contains utilities, tools, and libraries for the game "Parkan: Iron Strategy."
|
||||
Open source проект с реализацией компонентов игрового движка игры **«Паркан: Железная Стратегия»** и набором [вспомогательных инструментов](tools) для исследования.
|
||||
|
||||
## List of projects
|
||||
## Описание
|
||||
|
||||
- [unpacker](unpacker): Text-based utility for unpacking game resources in the NRres format. Allows unpacking 100% of game resources.
|
||||
- [packer](packer): Text-based utility for packing game resources in the NRres format. Allows packing 100% of game resources.
|
||||
- [texture-decoder](texture-decoder): (WIP) Decoder for game textures. Decodes approximately 20% of game textures.
|
||||
- [libnres](libnres): _(Deprecation)_ Library for NRes files.
|
||||
- [nres-cli](nres-cli): _(Deprecation)_ Console tool for NRes files.
|
||||
Проект находится в активной разработке и включает:
|
||||
|
||||
- библиотеки для работы с форматами игровых архивов;
|
||||
- инструменты для валидации/подготовки тестовых данных;
|
||||
- спецификации форматов и сопутствующую документацию.
|
||||
|
||||
## Установка
|
||||
|
||||
Проект находится в начальной стадии, подробная инструкция по установке пока отсутствует.
|
||||
|
||||
## Документация
|
||||
|
||||
- локально: каталог [`docs/`](docs)
|
||||
- сайт: <https://fparkan.popov.link>
|
||||
|
||||
## Инструменты
|
||||
|
||||
Вспомогательные инструменты находятся в каталоге [`tools/`](tools).
|
||||
|
||||
- [tools/archive_roundtrip_validator.py](tools/archive_roundtrip_validator.py) — инструмент верификации документации по архивам `NRes`/`RsLi` на реальных файлах (включая `unpack -> repack -> byte-compare`).
|
||||
- [tools/init_testdata.py](tools/init_testdata.py) — подготовка тестовых данных по сигнатурам с раскладкой по каталогам.
|
||||
|
||||
## Библиотеки
|
||||
|
||||
- [crates/nres](crates/nres) — библиотека для работы с файлами архивов NRes (чтение, поиск, редактирование, сохранение).
|
||||
- [crates/rsli](crates/rsli) — библиотека для работы с файлами архивов RsLi (чтение, поиск, загрузка/распаковка поддерживаемых методов).
|
||||
|
||||
## Тестирование
|
||||
|
||||
Базовое тестирование проходит на синтетических тестах из репозитория.
|
||||
|
||||
Для дополнительного тестирования на реальных игровых ресурсах:
|
||||
|
||||
- используйте [tools/init_testdata.py](tools/init_testdata.py) для подготовки локального набора;
|
||||
- используйте оригинальную копию игры (диск или [GOG-версия](https://www.gog.com/en/game/parkan_iron_strategy));
|
||||
- игровые ресурсы в репозиторий не включаются, так как защищены авторским правом.
|
||||
|
||||
## Contributing & Support
|
||||
|
||||
Проект активно поддерживается и открыт для contribution. Issues и pull requests можно создавать в обоих репозиториях:
|
||||
|
||||
- **Primary development**: [valentineus/fparkan](https://code.popov.link/valentineus/fparkan/issues)
|
||||
- **GitHub mirror**: [valentineus/fparkan](https://github.com/valentineus/fparkan/issues)
|
||||
|
||||
Основная разработка ведётся в self-hosted репозитории.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Проект распространяется под лицензией **[GNU GPL v2](LICENSE.txt)**.
|
||||
|
||||
6
crates/nres/Cargo.toml
Normal file
6
crates/nres/Cargo.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "nres"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
42
crates/nres/README.md
Normal file
42
crates/nres/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# nres
|
||||
|
||||
Rust-библиотека для работы с архивами формата **NRes**.
|
||||
|
||||
## Что умеет
|
||||
|
||||
- Открытие архива из файла (`open_path`) и из памяти (`open_bytes`).
|
||||
- Поддержка `raw_mode` (весь файл как единый ресурс).
|
||||
- Чтение метаданных и итерация по записям.
|
||||
- Поиск по имени без учёта регистра (`find`).
|
||||
- Чтение данных ресурса (`read`, `read_into`, `raw_slice`).
|
||||
- Редактирование архива через `Editor`:
|
||||
- `add`, `replace_data`, `remove`.
|
||||
- `commit` с пересчётом `sort_index`, выравниванием по 8 байт и атомарной записью файла.
|
||||
|
||||
## Модель ошибок
|
||||
|
||||
Библиотека возвращает типизированные ошибки (`InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `DirectoryOutOfBounds`, `EntryDataOutOfBounds`, и др.) без паник в production-коде.
|
||||
|
||||
## Покрытие тестами
|
||||
|
||||
### Реальные файлы
|
||||
|
||||
- Рекурсивный прогон по `testdata/nres/**`.
|
||||
- Сейчас в наборе: **120 архивов**.
|
||||
- Для каждого архива проверяется:
|
||||
- чтение всех записей;
|
||||
- `read`/`read_into`/`raw_slice`;
|
||||
- `find`;
|
||||
- `unpack -> repack (Editor::commit)` с проверкой **byte-to-byte**.
|
||||
|
||||
### Синтетические тесты
|
||||
|
||||
- Проверка основных сценариев редактирования (`add/replace/remove/commit`).
|
||||
- Проверка валидации и ошибок:
|
||||
- `InvalidMagic`, `UnsupportedVersion`, `TotalSizeMismatch`, `InvalidEntryCount`, `DirectoryOutOfBounds`, `NameTooLong`, `EntryDataOutOfBounds`, `EntryIdOutOfRange`, `NameContainsNul`.
|
||||
|
||||
## Быстрый запуск тестов
|
||||
|
||||
```bash
|
||||
cargo test -p nres -- --nocapture
|
||||
```
|
||||
43
crates/nres/src/data.rs
Normal file
43
crates/nres/src/data.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
99
crates/nres/src/error.rs
Normal file
99
crates/nres/src/error.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
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 {}
|
||||
624
crates/nres/src/lib.rs
Normal file
624
crates/nres/src/lib.rs
Normal file
@@ -0,0 +1,624 @@
|
||||
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;
|
||||
543
crates/nres/src/tests.rs
Normal file
543
crates/nres/src/tests.rs
Normal file
@@ -0,0 +1,543 @@
|
||||
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);
|
||||
}
|
||||
7
crates/rsli/Cargo.toml
Normal file
7
crates/rsli/Cargo.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "rsli"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
58
crates/rsli/README.md
Normal file
58
crates/rsli/README.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# rsli
|
||||
|
||||
Rust-библиотека для чтения архивов формата **RsLi**.
|
||||
|
||||
## Что умеет
|
||||
|
||||
- Открытие библиотеки из файла (`open_path`, `open_path_with`).
|
||||
- Дешифрование таблицы записей (XOR stream cipher).
|
||||
- Поддержка AO-трейлера и media overlay (`allow_ao_trailer`).
|
||||
- Поддержка quirk для Deflate `EOF+1` (`allow_deflate_eof_plus_one`).
|
||||
- Поиск по имени (`find`, c приведением запроса к uppercase).
|
||||
- Загрузка данных:
|
||||
- `load`, `load_into`, `load_packed`, `unpack`, `load_fast`.
|
||||
|
||||
## Поддерживаемые методы упаковки
|
||||
|
||||
- `0x000` None
|
||||
- `0x020` XorOnly
|
||||
- `0x040` Lzss
|
||||
- `0x060` XorLzss
|
||||
- `0x080` LzssHuffman
|
||||
- `0x0A0` XorLzssHuffman
|
||||
- `0x100` Deflate
|
||||
|
||||
## Модель ошибок
|
||||
|
||||
Типизированные ошибки без паник в production-коде (`InvalidMagic`, `UnsupportedVersion`, `EntryTableOutOfBounds`, `PackedSizePastEof`, `DeflateEofPlusOneQuirkRejected`, `UnsupportedMethod`, и др.).
|
||||
|
||||
## Покрытие тестами
|
||||
|
||||
### Реальные файлы
|
||||
|
||||
- Рекурсивный прогон по `testdata/rsli/**`.
|
||||
- Сейчас в наборе: **2 архива**.
|
||||
- На реальных данных подтверждены и проходят byte-to-byte проверки методы:
|
||||
- `0x040` (LZSS)
|
||||
- `0x100` (Deflate)
|
||||
- Для каждого архива проверяется:
|
||||
- `load`/`load_into`/`load_packed`/`unpack`/`load_fast`;
|
||||
- `find`;
|
||||
- пересборка и сравнение **byte-to-byte**.
|
||||
|
||||
### Синтетические тесты
|
||||
|
||||
Из-за отсутствия реальных файлов для части методов добавлены синтетические архивы и тесты:
|
||||
|
||||
- Методы:
|
||||
- `0x000`, `0x020`, `0x060`, `0x080`, `0x0A0`.
|
||||
- Спецкейсы формата:
|
||||
- AO trailer + overlay;
|
||||
- Deflate `EOF+1` (оба режима: accepted/rejected);
|
||||
- некорректные заголовки/таблицы/смещения/методы.
|
||||
|
||||
## Быстрый запуск тестов
|
||||
|
||||
```bash
|
||||
cargo test -p rsli -- --nocapture
|
||||
```
|
||||
41
crates/rsli/src/data.rs
Normal file
41
crates/rsli/src/data.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
129
crates/rsli/src/error.rs
Normal file
129
crates/rsli/src/error.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
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 {}
|
||||
983
crates/rsli/src/lib.rs
Normal file
983
crates/rsli/src/lib.rs
Normal file
@@ -0,0 +1,983 @@
|
||||
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;
|
||||
847
crates/rsli/src/tests.rs
Normal file
847
crates/rsli/src/tests.rs
Normal file
@@ -0,0 +1,847 @@
|
||||
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);
|
||||
}
|
||||
69
docs/specs/effects.md
Normal file
69
docs/specs/effects.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Эффекты и частицы
|
||||
|
||||
Пока что — **не байтовая спецификация**, а “карта” по тому, что видно в библиотеках. Полную документацию по эффектам/шейдерам/частицам можно будет сделать после того, как:
|
||||
|
||||
- найдём формат эффекта (файл/ресурс),
|
||||
- найдём точку загрузки/парсинга,
|
||||
- найдём точки рендера (создание буферов/вершинного формата/материалов).
|
||||
|
||||
---
|
||||
|
||||
## 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”.
|
||||
314
docs/specs/msh.md
Normal file
314
docs/specs/msh.md
Normal file
@@ -0,0 +1,314 @@
|
||||
# 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) с материалами/узлами (кто именно как индексирует строки).
|
||||
718
docs/specs/nres.md
Normal file
718
docs/specs/nres.md
Normal file
@@ -0,0 +1,718 @@
|
||||
# Форматы игровых ресурсов
|
||||
|
||||
## Обзор
|
||||
|
||||
Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов:
|
||||
|
||||
1. **NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей.
|
||||
|
||||
2. **RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.
|
||||
|
||||
---
|
||||
|
||||
# Часть 1. Формат NRes
|
||||
|
||||
## 1.1. Общая структура файла
|
||||
|
||||
```
|
||||
┌──────────────────────────┐ Смещение 0
|
||||
│ Заголовок (16 байт) │
|
||||
├──────────────────────────┤ Смещение 16
|
||||
│ │
|
||||
│ Данные ресурсов │
|
||||
│ (выровнены по 8 байт) │
|
||||
│ │
|
||||
├──────────────────────────┤ Смещение = total_size - entry_count × 64
|
||||
│ Каталог записей │
|
||||
│ (entry_count × 64 байт) │
|
||||
└──────────────────────────┘ Смещение = total_size
|
||||
```
|
||||
|
||||
## 1.2. Заголовок файла (16 байт)
|
||||
|
||||
| Смещение | Размер | Тип | Значение | Описание |
|
||||
| -------- | ------ | ------- | ------------------- | ------------------------------------ |
|
||||
| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) |
|
||||
| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
|
||||
| 8 | 4 | int32 | — | Количество записей в каталоге |
|
||||
| 12 | 4 | int32 | — | Полный размер файла в байтах |
|
||||
|
||||
**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
|
||||
|
||||
## 1.3. Положение каталога в файле
|
||||
|
||||
Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
|
||||
|
||||
```
|
||||
directory_offset = total_size - entry_count × 64
|
||||
```
|
||||
|
||||
Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
|
||||
|
||||
## 1.4. Запись каталога (64 байта)
|
||||
|
||||
Каждая запись каталога занимает ровно **64 байта** (0x40):
|
||||
|
||||
| Смещение | Размер | Тип | Описание |
|
||||
| -------- | ------ | -------- | ------------------------------------------------- |
|
||||
| 0 | 4 | uint32 | Тип / идентификатор ресурса |
|
||||
| 4 | 4 | uint32 | Атрибут 1 (например, формат, дата, категория) |
|
||||
| 8 | 4 | uint32 | Атрибут 2 (например, подтип, метка времени) |
|
||||
| 12 | 4 | uint32 | Размер данных ресурса в байтах |
|
||||
| 16 | 4 | uint32 | Атрибут 3 (дополнительный параметр) |
|
||||
| 20 | 36 | char[36] | Имя файла (null-terminated, макс. 35 символов) |
|
||||
| 56 | 4 | uint32 | Смещение данных от начала файла |
|
||||
| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
|
||||
|
||||
### Поле «Имя файла» (смещение 20, 36 байт)
|
||||
|
||||
- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
|
||||
- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
|
||||
- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
|
||||
|
||||
### Поле «Индекс сортировки» (смещение 60)
|
||||
|
||||
Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
|
||||
|
||||
**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
|
||||
|
||||
### Поле «Смещение данных» (смещение 56)
|
||||
|
||||
Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
|
||||
|
||||
## 1.5. Выравнивание данных
|
||||
|
||||
При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
|
||||
|
||||
```c
|
||||
padding = ((data_size + 7) & ~7) - data_size;
|
||||
// Если padding > 0, записываются нулевые байты
|
||||
```
|
||||
|
||||
Таким образом, каждый блок данных начинается с адреса, кратного 8.
|
||||
|
||||
При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
|
||||
|
||||
## 1.6. Создание файла (API `niCreateResFile`)
|
||||
|
||||
При создании нового файла:
|
||||
|
||||
1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
|
||||
2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
|
||||
|
||||
При закрытии файла (`sub_100122D0`):
|
||||
|
||||
1. Заголовок переписывается в начало файла (16 байт).
|
||||
2. Вычисляется `total_size = data_end_offset + entry_count × 64`.
|
||||
3. Индексы сортировки пересчитываются.
|
||||
4. Каталог записей записывается в конец файла.
|
||||
|
||||
## 1.7. Режимы сортировки каталога
|
||||
|
||||
Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11):
|
||||
|
||||
| Режим | Порядок сортировки |
|
||||
| ----- | --------------------------------- |
|
||||
| 0 | Без сортировки (сброс) |
|
||||
| 1 | По атрибуту 1 (смещение 4) |
|
||||
| 2 | По атрибуту 2 (смещение 8) |
|
||||
| 3 | По (атрибут 1, атрибут 2) |
|
||||
| 4 | По типу ресурса (смещение 0) |
|
||||
| 5 | По (тип, атрибут 1) |
|
||||
| 6 | По (тип, атрибут 1) — идентичен 5 |
|
||||
| 7 | По (тип, атрибут 1, атрибут 2) |
|
||||
| 8 | По имени (регистронезависимо) |
|
||||
| 9 | По (тип, имя) |
|
||||
| 10 | По (атрибут 1, имя) |
|
||||
| 11 | По (атрибут 2, имя) |
|
||||
|
||||
## 1.8. Операция `niOpenResFileEx` — флаги открытия
|
||||
|
||||
Второй параметр — битовые флаги:
|
||||
|
||||
| Бит | Маска | Описание |
|
||||
| --- | ----- | ----------------------------------------------------------------------------------- |
|
||||
| 0 | 0x01 | Sequential scan hint (`FILE_FLAG_SEQUENTIAL_SCAN` вместо `FILE_FLAG_RANDOM_ACCESS`) |
|
||||
| 1 | 0x02 | Открыть для записи (read-write). Без флага — только чтение |
|
||||
| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
|
||||
| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
|
||||
|
||||
## 1.9. Виртуальное касание страниц
|
||||
|
||||
Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
|
||||
|
||||
```
|
||||
for (result = 0x10000; result < size; result += 4096);
|
||||
```
|
||||
|
||||
Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память.
|
||||
|
||||
---
|
||||
|
||||
# Часть 2. Формат RsLi
|
||||
|
||||
## 2.1. Общая структура файла
|
||||
|
||||
```
|
||||
┌───────────────────────────────┐ Смещение 0
|
||||
│ Заголовок файла (32 байта) │
|
||||
├───────────────────────────────┤ Смещение 32
|
||||
│ Таблица записей (зашифрована)│
|
||||
│ (entry_count × 32 байт) │
|
||||
├───────────────────────────────┤ Смещение 32 + entry_count × 32
|
||||
│ │
|
||||
│ Данные ресурсов │
|
||||
│ │
|
||||
├───────────────────────────────┤
|
||||
│ [Опциональный трейлер — 6 б] │
|
||||
└───────────────────────────────┘
|
||||
```
|
||||
|
||||
## 2.2. Заголовок файла (32 байта)
|
||||
|
||||
| Смещение | Размер | Тип | Значение | Описание |
|
||||
| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
|
||||
| 0 | 2 | char[2] | `NL` (0x4C4E) | Магическая сигнатура |
|
||||
| 2 | 1 | uint8 | `0x00` | Зарезервировано (должно быть 0) |
|
||||
| 3 | 1 | uint8 | `0x01` | Версия формата |
|
||||
| 4 | 2 | int16 | — | Количество записей (sign-extended при чтении) |
|
||||
| 6 | 8 | — | — | Зарезервировано / не используется |
|
||||
| 14 | 2 | uint16 | `0xABBA` или иное | Флаг предсортировки (см. ниже) |
|
||||
| 16 | 4 | — | — | Зарезервировано |
|
||||
| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
|
||||
| 24 | 8 | — | — | Зарезервировано |
|
||||
|
||||
### Флаг предсортировки (смещение 14)
|
||||
|
||||
- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XOR‑ключ для данных).
|
||||
- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XOR‑ключ (младшие 16 бит).
|
||||
|
||||
## 2.3. XOR-шифр таблицы записей
|
||||
|
||||
Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
|
||||
|
||||
### Начальное состояние
|
||||
|
||||
```
|
||||
seed = *(uint32*)(header + 20)
|
||||
lo = seed & 0xFF // Младший байт
|
||||
hi = (seed >> 8) & 0xFF // Второй байт
|
||||
```
|
||||
|
||||
### Алгоритм дешифровки (побайтовый)
|
||||
|
||||
Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
|
||||
|
||||
```
|
||||
step 1: lo = hi ^ ((lo << 1) & 0xFF) // Сдвиг lo влево на 1, XOR с hi
|
||||
step 2: decrypted[i] = lo ^ encrypted[i] // Расшифровка байта
|
||||
step 3: hi = lo ^ ((hi >> 1) & 0xFF) // Сдвиг hi вправо на 1, XOR с lo
|
||||
```
|
||||
|
||||
**Пример реализации:**
|
||||
|
||||
```python
|
||||
def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
||||
lo = seed & 0xFF
|
||||
hi = (seed >> 8) & 0xFF
|
||||
result = bytearray(len(encrypted_data))
|
||||
for i in range(len(encrypted_data)):
|
||||
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
|
||||
result[i] = lo ^ encrypted_data[i]
|
||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
||||
return bytes(result)
|
||||
```
|
||||
|
||||
Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
|
||||
|
||||
## 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
|
||||
|
||||
После дешифровки каждая запись имеет следующую структуру:
|
||||
|
||||
| Смещение | Размер | Тип | Описание |
|
||||
| -------- | ------ | -------- | -------------------------------------------------------------- |
|
||||
| 0 | 12 | char[12] | Имя ресурса (ASCII, обычно uppercase; строка читается до `\0`) |
|
||||
| 12 | 4 | — | Зарезервировано (движком игнорируется) |
|
||||
| 16 | 2 | int16 | **Флаги** (метод сжатия и атрибуты) |
|
||||
| 18 | 2 | int16 | **`sort_to_original[i]` / XOR‑ключ** (см. ниже) |
|
||||
| 20 | 4 | uint32 | **Размер распакованных данных** (`unpacked_size`) |
|
||||
| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) |
|
||||
| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) |
|
||||
|
||||
### Имена ресурсов
|
||||
|
||||
- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`.
|
||||
- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно.
|
||||
- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён.
|
||||
|
||||
### Поле `sort_to_original[i]` (смещение 18)
|
||||
|
||||
Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
|
||||
|
||||
- Таблица реализована “внутри записей”: значение берётся как `entry[i].sort_to_original` (где `i` — позиция двоичного поиска), а реальная запись для сравнения берётся как `entry[ sort_to_original[i] ]`.
|
||||
- Тем же значением (младшие 16 бит) инициализируется XOR‑шифр данных для методов, где он используется (0x20/0x60/0xA0). Поэтому при упаковке/шифровании данных ключ должен совпадать с итоговым `sort_to_original[i]` (см. флаг 0xABBA в разделе 2.2).
|
||||
|
||||
Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
|
||||
|
||||
## 2.5. Поле флагов (смещение 16 записи)
|
||||
|
||||
Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
|
||||
|
||||
```
|
||||
Биты [8:5] (маска 0x1E0): Метод сжатия/шифрования
|
||||
Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
|
||||
```
|
||||
|
||||
### Методы сжатия (биты 8–5, маска 0x1E0)
|
||||
|
||||
| Значение | Hex | Описание |
|
||||
| -------- | ----- | --------------------------------------- |
|
||||
| 0x000 | 0x00 | Без сжатия (копирование) |
|
||||
| 0x020 | 0x20 | Только XOR-шифр |
|
||||
| 0x040 | 0x40 | LZSS (простой вариант) |
|
||||
| 0x060 | 0x60 | XOR-шифр + LZSS (простой вариант) |
|
||||
| 0x080 | 0x80 | LZSS с адаптивным кодированием Хаффмана |
|
||||
| 0x0A0 | 0xA0 | XOR-шифр + LZSS с Хаффманом |
|
||||
| 0x100 | 0x100 | Deflate (аналог zlib/RFC 1951) |
|
||||
|
||||
Примечание: `rsGetPackMethod()` возвращает `flags & 0x1C0` (без бита 0x20). Поэтому:
|
||||
|
||||
- для 0x20 вернётся 0x00,
|
||||
- для 0x60 вернётся 0x40,
|
||||
- для 0xA0 вернётся 0x80.
|
||||
|
||||
### Бит 0x40 (выделение +0x12 и последующее `realloc`)
|
||||
|
||||
Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
|
||||
|
||||
Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
|
||||
|
||||
## 2.6. Размеры данных
|
||||
|
||||
В каждой записи на диске хранятся оба значения:
|
||||
|
||||
- `unpacked_size` (смещение 20) — размер распакованных данных.
|
||||
- `packed_size` (смещение 28) — размер упакованных данных (байт во входном потоке для выбранного метода).
|
||||
|
||||
Для метода 0x00 (без сжатия) обычно `packed_size == unpacked_size`.
|
||||
|
||||
`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
|
||||
|
||||
Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
|
||||
|
||||
## 2.7. Опциональный трейлер медиа (6 байт)
|
||||
|
||||
При открытии с флагом `a2 & 2`:
|
||||
|
||||
| Смещение от конца | Размер | Тип | Описание |
|
||||
| ----------------- | ------ | ------- | ----------------------- |
|
||||
| −6 | 2 | char[2] | Сигнатура `AO` (0x4F41) |
|
||||
| −4 | 4 | uint32 | Смещение медиа-оверлея |
|
||||
|
||||
Если трейлер присутствует, все смещения данных в записях корректируются: `effective_offset = entry_offset + media_overlay_offset`.
|
||||
|
||||
---
|
||||
|
||||
# Часть 3. Алгоритмы сжатия (формат RsLi)
|
||||
|
||||
## 3.1. XOR-шифр данных (метод 0x20)
|
||||
|
||||
Алгоритм идентичен XOR‑шифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
|
||||
|
||||
Важно про размер входа:
|
||||
|
||||
- В ветке **0x20** движок XOR‑ит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
|
||||
- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
|
||||
|
||||
### Инициализация
|
||||
|
||||
```
|
||||
key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
|
||||
lo = key16 & 0xFF
|
||||
hi = (key16 >> 8) & 0xFF
|
||||
```
|
||||
|
||||
### Дешифровка (псевдокод)
|
||||
|
||||
```
|
||||
for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
|
||||
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
|
||||
out[i] = in[i] ^ lo
|
||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
||||
```
|
||||
|
||||
## 3.2. LZSS — простой вариант (метод 0x40)
|
||||
|
||||
Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
|
||||
|
||||
### Параметры
|
||||
|
||||
| Параметр | Значение |
|
||||
| ----------------------------- | ------------------ |
|
||||
| Размер кольцевого буфера | 4096 байт (0x1000) |
|
||||
| Начальная позиция записи | 4078 (0xFEE) |
|
||||
| Начальное заполнение | 0x20 (пробел) |
|
||||
| Минимальная длина совпадения | 3 |
|
||||
| Максимальная длина совпадения | 18 (4 бита + 3) |
|
||||
|
||||
### Алгоритм декомпрессии
|
||||
|
||||
```
|
||||
Инициализация:
|
||||
ring_buffer[0..4095] = 0x20 (заполнить пробелами)
|
||||
ring_pos = 4078
|
||||
flags_byte = 0
|
||||
flags_bits_remaining = 0
|
||||
|
||||
Цикл (пока не заполнен выходной буфер И не исчерпан входной):
|
||||
|
||||
1. Если flags_bits_remaining == 0:
|
||||
- Прочитать 1 байт из входного потока → flags_byte
|
||||
- flags_bits_remaining = 8
|
||||
|
||||
Декодировать как:
|
||||
- Старший бит устанавливается в 0x7F (маркер)
|
||||
- Оставшиеся 7 бит — флаги текущей группы
|
||||
|
||||
Реально в коде: control_word = (flags_byte) | (0x7F << 8)
|
||||
Каждый бит проверяется сдвигом вправо.
|
||||
|
||||
2. Проверить младший бит control_word:
|
||||
|
||||
Если бит = 1 (литерал):
|
||||
- Прочитать 1 байт из входного потока → byte
|
||||
- ring_buffer[ring_pos] = byte
|
||||
- ring_pos = (ring_pos + 1) & 0xFFF
|
||||
- Записать byte в выходной буфер
|
||||
|
||||
Если бит = 0 (ссылка):
|
||||
- Прочитать 2 байта: low_byte, high_byte
|
||||
- offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит
|
||||
- length = (high_byte & 0x0F) + 3 // 4 бита + 3
|
||||
- Скопировать length байт из ring_buffer[offset...]:
|
||||
для j от 0 до length-1:
|
||||
byte = ring_buffer[(offset + j) & 0xFFF]
|
||||
ring_buffer[ring_pos] = byte
|
||||
ring_pos = (ring_pos + 1) & 0xFFF
|
||||
записать byte в выходной буфер
|
||||
|
||||
3. Сдвинуть control_word вправо на 1 бит
|
||||
4. flags_bits_remaining -= 1
|
||||
```
|
||||
|
||||
### Подробная раскладка пары ссылки (2 байта)
|
||||
|
||||
```
|
||||
Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
|
||||
Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина − 3
|
||||
|
||||
offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095
|
||||
length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
```
|
||||
|
||||
## 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
|
||||
|
||||
Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
|
||||
|
||||
### Параметры
|
||||
|
||||
| Параметр | Значение |
|
||||
| -------------------------------- | ------------------------------ |
|
||||
| Размер кольцевого буфера | 4096 байт |
|
||||
| Начальная позиция записи | **4036** (0xFC4) |
|
||||
| Начальное заполнение | 0x20 (пробел) |
|
||||
| Количество листовых узлов дерева | 314 |
|
||||
| Символы литералов | 0–255 (байты) |
|
||||
| Символы длин | 256–313 (длина = символ − 253) |
|
||||
| Начальная длина | 3 (при символе 256) |
|
||||
| Максимальная длина | 60 (при символе 313) |
|
||||
|
||||
### Дерево Хаффмана
|
||||
|
||||
Дерево строится как **адаптивное** (dynamic, self-adjusting):
|
||||
|
||||
- **627 узлов**: 314 листовых + 313 внутренних.
|
||||
- Все листья изначально имеют **вес 1**.
|
||||
- Корень дерева — узел с индексом 0 (в массиве `parent`).
|
||||
- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
|
||||
- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
|
||||
|
||||
### Кодирование позиции
|
||||
|
||||
Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
|
||||
|
||||
- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов.
|
||||
- Из потока считываются дополнительные биты, которые объединяются с базовым значением.
|
||||
- Финальная позиция: `pos = (ring_pos − 1 − decoded_position) & 0xFFF`
|
||||
|
||||
**Таблицы инициализации** (d-коды):
|
||||
|
||||
```
|
||||
Таблица базовых значений — byte_100371D0[6]:
|
||||
{ 0x01, 0x03, 0x08, 0x0C, 0x18, 0x10 }
|
||||
|
||||
Таблица дополнительных битов — byte_100371D6[6]:
|
||||
{ 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
|
||||
```
|
||||
|
||||
### Алгоритм декомпрессии (высокоуровневый)
|
||||
|
||||
```
|
||||
Инициализация:
|
||||
ring_buffer[0..4095] = 0x20
|
||||
ring_pos = 4036
|
||||
Инициализировать дерево Хаффмана (314 листьев, все веса = 1)
|
||||
Инициализировать таблицы d-кодов
|
||||
|
||||
Цикл:
|
||||
1. Декодировать символ из потока по дереву Хаффмана:
|
||||
- Начать с корня
|
||||
- Читать биты, спускаться по дереву (0 = левый, 1 = правый)
|
||||
- Пока не достигнут лист → символ = лист − 627
|
||||
|
||||
2. Обновить дерево Хаффмана для декодированного символа
|
||||
|
||||
3. Если символ < 256 (литерал):
|
||||
- ring_buffer[ring_pos] = символ
|
||||
- ring_pos = (ring_pos + 1) & 0xFFF
|
||||
- Записать символ в выходной буфер
|
||||
|
||||
4. Если символ >= 256 (ссылка):
|
||||
- length = символ − 253
|
||||
- Декодировать позицию через d-код:
|
||||
a) Прочитать 8 бит из потока
|
||||
b) Найти d-код и дополнительные биты по таблице
|
||||
c) Прочитать дополнительные биты
|
||||
d) position = (ring_pos − 1 − full_position) & 0xFFF
|
||||
- Скопировать length байт из ring_buffer[position...]
|
||||
|
||||
5. Если выходной буфер заполнен → завершить
|
||||
```
|
||||
|
||||
## 3.4. XOR + LZSS (методы 0x60 и 0xA0)
|
||||
|
||||
Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
|
||||
|
||||
### Алгоритм
|
||||
|
||||
1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
|
||||
2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
|
||||
3. Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной.
|
||||
4. Освободить временный буфер.
|
||||
|
||||
- **0x60** — XOR + простой LZSS (раздел 3.2)
|
||||
- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
|
||||
|
||||
### Начальное состояние XOR для данных
|
||||
|
||||
При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
|
||||
|
||||
## 3.5. Deflate (метод 0x100)
|
||||
|
||||
Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
|
||||
|
||||
### Общая структура
|
||||
|
||||
Данные состоят из последовательности блоков. Каждый блок начинается с:
|
||||
|
||||
- **1 бит** — `is_final`: признак последнего блока
|
||||
- **2 бита** — `block_type`: тип блока
|
||||
|
||||
### Типы блоков
|
||||
|
||||
| block_type | Описание | Функция |
|
||||
| ---------- | --------------------------- | ---------------- |
|
||||
| 0 | Без сжатия (stored) | `sub_1001A750` |
|
||||
| 1 | Фиксированные коды Хаффмана | `sub_1001A8C0` |
|
||||
| 2 | Динамические коды Хаффмана | `sub_1001AA30` |
|
||||
| 3 | Зарезервировано (ошибка) | Возвращает код 2 |
|
||||
|
||||
### Блок типа 0 (stored)
|
||||
|
||||
1. Отбросить оставшиеся биты до границы байта (выравнивание).
|
||||
2. Прочитать 16 бит — `LEN` (длина блока).
|
||||
3. Прочитать 16 бит — `NLEN` (дополнение длины, `NLEN == ~LEN & 0xFFFF`).
|
||||
4. Проверить: `LEN == (uint16)(~NLEN)`. При несовпадении — ошибка.
|
||||
5. Скопировать `LEN` байт из входного потока в выходной.
|
||||
|
||||
Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
|
||||
|
||||
### Блок типа 1 (фиксированные коды)
|
||||
|
||||
Стандартные коды Deflate:
|
||||
|
||||
- Литералы/длины: 288 кодов
|
||||
- 0–143: 8-битные коды
|
||||
- 144–255: 9-битные коды
|
||||
- 256–279: 7-битные коды
|
||||
- 280–287: 8-битные коды
|
||||
- Дистанции: 30 кодов, все 5-битные
|
||||
|
||||
Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
|
||||
|
||||
### Блок типа 2 (динамические коды)
|
||||
|
||||
1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257–286.
|
||||
2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30.
|
||||
3. Прочитать 4 бита → `HCLEN` (количество кодов длин − 4). Диапазон: 4–19.
|
||||
4. Прочитать `HCLEN` × 3 бит — длины кодов для алфавита длин.
|
||||
5. Построить дерево Хаффмана для алфавита длин (19 символов).
|
||||
6. С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций.
|
||||
7. Построить два дерева Хаффмана: для литералов/длин и для дистанций.
|
||||
8. Декодировать данные.
|
||||
|
||||
**Порядок кодов длин** (стандартный Deflate):
|
||||
|
||||
```
|
||||
{ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }
|
||||
```
|
||||
|
||||
Хранится в `dword_10037060`.
|
||||
|
||||
### Валидации
|
||||
|
||||
- `HLIT + 257 <= 286` (max 0x11E)
|
||||
- `HDIST + 1 <= 30` (max 0x1E)
|
||||
- При нарушении — возвращается ошибка 1.
|
||||
|
||||
## 3.6. Метод 0x00 (без сжатия)
|
||||
|
||||
Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
|
||||
|
||||
---
|
||||
|
||||
# Часть 4. Внутренние структуры в памяти
|
||||
|
||||
## 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
|
||||
|
||||
```c
|
||||
struct NResArchive { // Размер: 0x68 (104 байта)
|
||||
void* vtable; // +0: Указатель на таблицу виртуальных методов
|
||||
int32_t entry_count; // +4: Количество записей
|
||||
void* mapped_base; // +8: Базовый адрес mapped view
|
||||
void* directory_ptr; // +12: Указатель на каталог записей в памяти
|
||||
char* filename; // +16: Путь к файлу (_strdup)
|
||||
int32_t ref_count; // +20: Счётчик ссылок
|
||||
uint32_t last_release_time; // +24: timeGetTime() при последнем Release
|
||||
// +28..+91: Для raw-режима — встроенная запись (единственный File entry)
|
||||
NResArchive* next; // +92: Следующий архив в связном списке
|
||||
uint8_t is_writable; // +100: Файл открыт для записи
|
||||
uint8_t is_cacheable; // +101: Не выгружать при refcount = 0
|
||||
};
|
||||
```
|
||||
|
||||
## 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
|
||||
|
||||
```c
|
||||
struct RsLibHeader { // 56 байт (14 DWORD)
|
||||
uint32_t magic; // +0: 'RsLi' (0x694C7352)
|
||||
int32_t entry_count; // +4: Количество записей
|
||||
uint32_t media_offset; // +8: Смещение медиа-оверлея
|
||||
uint32_t reserved_0C; // +12: 0
|
||||
HANDLE file_handle_2; // +16: -1 (дополнительный хэндл)
|
||||
uint32_t reserved_14; // +20: 0
|
||||
uint32_t reserved_18; // +24: —
|
||||
uint32_t reserved_1C; // +28: 0
|
||||
HANDLE mapping_handle_2; // +32: -1
|
||||
uint32_t reserved_24; // +36: 0
|
||||
uint32_t flag_28; // +40: (flags >> 7) & 1
|
||||
HANDLE file_handle; // +44: Хэндл файла
|
||||
HANDLE mapping_handle; // +48: Хэндл файлового маппинга
|
||||
void* mapped_view; // +52: Указатель на mapped view
|
||||
};
|
||||
// Далее следуют entry_count записей по 64 байта каждая
|
||||
```
|
||||
|
||||
### Внутренняя запись RsLi (64 байта)
|
||||
|
||||
```c
|
||||
struct RsLibEntry { // 64 байта (16 DWORD)
|
||||
char name[16]; // +0: Имя (12 из файла + 4 нуля)
|
||||
int32_t flags; // +16: Флаги (sign-extended из int16)
|
||||
int32_t sort_index; // +20: sort_to_original[i] (таблица индексов / XOR‑ключ)
|
||||
uint32_t uncompressed_size; // +24: Размер несжатых данных (из поля 20 записи)
|
||||
void* data_ptr; // +28: Указатель на данные в mapped view
|
||||
uint32_t compressed_size; // +32: Размер сжатых данных (из поля 28 записи)
|
||||
uint32_t reserved_24; // +36: 0
|
||||
uint32_t reserved_28; // +40: 0
|
||||
uint32_t reserved_2C; // +44: 0
|
||||
void* loaded_data; // +48: Указатель на декомпрессированные данные
|
||||
// +52..+63: дополнительные поля
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Часть 5. Экспортируемые API-функции
|
||||
|
||||
## 5.1. NRes API
|
||||
|
||||
| Функция | Описание |
|
||||
| ------------------------------ | ------------------------------------------------------------------------- |
|
||||
| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` |
|
||||
| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами |
|
||||
| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
|
||||
| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
|
||||
|
||||
## 5.2. RsLi API
|
||||
|
||||
| Функция | Описание |
|
||||
| ------------------------------- | -------------------------------------------------------- |
|
||||
| `rsOpenLib(path, flags)` | Открыть RsLi-библиотеку |
|
||||
| `rsCloseLib(lib)` | Закрыть библиотеку |
|
||||
| `rsLibNum(lib)` | Получить количество записей |
|
||||
| `rsFind(lib, name)` | Найти запись по имени (→ индекс или −1) |
|
||||
| `rsLoad(lib, index)` | Загрузить и декомпрессировать ресурс |
|
||||
| `rsLoadFast(lib, index, flags)` | Быстрая загрузка (без декомпрессии если возможно) |
|
||||
| `rsLoadPacked(lib, index)` | Загрузить в «упакованном» виде (отложенная декомпрессия) |
|
||||
| `rsLoadByName(lib, name)` | `rsFind` + `rsLoad` |
|
||||
| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса |
|
||||
| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) |
|
||||
| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс |
|
||||
| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) |
|
||||
| `ngiFree(ptr)` | Освободить память |
|
||||
| `ngiGetMemSize(ptr)` | Получить размер выделенного блока |
|
||||
|
||||
---
|
||||
|
||||
# Часть 6. Контрольные заметки для реализации
|
||||
|
||||
## 6.1. Кодировки и регистр
|
||||
|
||||
- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
|
||||
- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
|
||||
|
||||
## 6.2. Порядок байт
|
||||
|
||||
Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
|
||||
|
||||
## 6.3. Выравнивание
|
||||
|
||||
- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
|
||||
- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
|
||||
|
||||
## 6.4. Размер записей на диске
|
||||
|
||||
- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
|
||||
- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
|
||||
|
||||
## 6.5. Кэширование и memory mapping
|
||||
|
||||
Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
|
||||
|
||||
## 6.6. Размер seed XOR
|
||||
|
||||
- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
|
||||
- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
|
||||
- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
|
||||
|
||||
## 6.7. Эмпирическая проверка на данных игры
|
||||
|
||||
- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
|
||||
- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
|
||||
- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`.
|
||||
|
||||
Подтверждённые нюансы:
|
||||
|
||||
- Для LZSS (метод `0x040`) рабочая раскладка нибблов в ссылке: `OOOO LLLL`, а не `LLLL OOOO`.
|
||||
- Для Deflate (метод `0x100`) возможен случай `packed_size == фактический_конец + 1` на последней записи файла.
|
||||
90
docs/specs/textures.md
Normal file
90
docs/specs/textures.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Текстуры и материалы
|
||||
|
||||
На текущем этапе в дизассемблированных библиотеках **не найден полный декодер формата текстурного файла** (нет явных парсеров 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` к материалам:
|
||||
- это имена материалов?
|
||||
- это имена текстур?
|
||||
- или это имена узлов/анимаций?
|
||||
|
||||
До подтверждения этих пунктов разумнее держать документацию как “архитектурную карту”, а не как точный байтовый формат.
|
||||
@@ -1,10 +0,0 @@
|
||||
[package]
|
||||
name = "libnres"
|
||||
version = "0.1.4"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
byteorder = "1.4"
|
||||
log = "0.4"
|
||||
miette = "7.0"
|
||||
thiserror = "2.0"
|
||||
@@ -1,30 +0,0 @@
|
||||
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),
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
extern crate miette;
|
||||
extern crate thiserror;
|
||||
|
||||
use miette::Diagnostic;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Diagnostic, Debug)]
|
||||
pub enum ConverterError {
|
||||
#[error("error converting an value")]
|
||||
#[diagnostic(code(libnres::infallible))]
|
||||
Infallible(#[from] std::convert::Infallible),
|
||||
|
||||
#[error("error converting an value")]
|
||||
#[diagnostic(code(libnres::try_from_int_error))]
|
||||
TryFromIntError(#[from] std::num::TryFromIntError),
|
||||
}
|
||||
|
||||
#[derive(Error, Diagnostic, Debug)]
|
||||
pub enum ReaderError {
|
||||
#[error(transparent)]
|
||||
#[diagnostic(code(libnres::convert_error))]
|
||||
ConvertValue(#[from] ConverterError),
|
||||
|
||||
#[error("incorrect header format")]
|
||||
#[diagnostic(code(libnres::list_type_error))]
|
||||
IncorrectHeader,
|
||||
|
||||
#[error("incorrect file size (expected {expected:?} bytes, received {received:?} bytes)")]
|
||||
#[diagnostic(code(libnres::file_size_error))]
|
||||
IncorrectSizeFile { expected: u32, received: u32 },
|
||||
|
||||
#[error(
|
||||
"incorrect size of the file list (not a multiple of {expected:?}, received {received:?})"
|
||||
)]
|
||||
#[diagnostic(code(libnres::list_size_error))]
|
||||
IncorrectSizeList { expected: u32, received: u32 },
|
||||
|
||||
#[error("resource file reading error")]
|
||||
#[diagnostic(code(libnres::io_error))]
|
||||
ReadFile(#[from] std::io::Error),
|
||||
|
||||
#[error("file is too small (must be at least {expected:?} bytes, received {received:?} byte)")]
|
||||
#[diagnostic(code(libnres::file_size_error))]
|
||||
SmallFile { expected: u32, received: u32 },
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
/// First constant value of the NRes file ("NRes" characters in numeric)
|
||||
pub const FILE_TYPE_1: u32 = 1936020046;
|
||||
/// Second constant value of the NRes file
|
||||
pub const FILE_TYPE_2: u32 = 256;
|
||||
/// Size of the element item (in bytes)
|
||||
pub const LIST_ELEMENT_SIZE: u32 = 64;
|
||||
/// Minimum allowed file size (in bytes)
|
||||
pub const MINIMUM_FILE_SIZE: u32 = 16;
|
||||
|
||||
static DEBUG: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
mod converter;
|
||||
mod error;
|
||||
pub mod reader;
|
||||
|
||||
/// Get debug status value
|
||||
pub fn get_debug() -> bool {
|
||||
DEBUG.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Change debug status value
|
||||
pub fn set_debug(value: bool) {
|
||||
DEBUG.store(value, std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
use std::io::{Read, Seek};
|
||||
|
||||
use byteorder::ByteOrder;
|
||||
|
||||
use crate::error::ReaderError;
|
||||
use crate::{converter, FILE_TYPE_1, FILE_TYPE_2, LIST_ELEMENT_SIZE, MINIMUM_FILE_SIZE};
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ListElement {
|
||||
/// Unknown parameter
|
||||
_unknown0: i32,
|
||||
/// Unknown parameter
|
||||
_unknown1: i32,
|
||||
/// Unknown parameter
|
||||
_unknown2: i32,
|
||||
/// File extension
|
||||
pub extension: String,
|
||||
/// Identifier or sequence number
|
||||
pub index: u32,
|
||||
/// File name
|
||||
pub name: String,
|
||||
/// Position in the file
|
||||
pub position: u32,
|
||||
/// File size (in bytes)
|
||||
pub size: u32,
|
||||
}
|
||||
|
||||
impl ListElement {
|
||||
/// Get full name of the file
|
||||
pub fn get_filename(&self) -> String {
|
||||
format!("{}.{}", self.name, self.extension)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FileHeader {
|
||||
/// File size
|
||||
size: u32,
|
||||
/// Number of files
|
||||
total: u32,
|
||||
/// First constant value
|
||||
type1: u32,
|
||||
/// Second constant value
|
||||
type2: u32,
|
||||
}
|
||||
|
||||
/// Get a packed file data
|
||||
pub fn get_file(file: &std::fs::File, element: &ListElement) -> Result<Vec<u8>, ReaderError> {
|
||||
let size = get_file_size(file)?;
|
||||
check_file_size(size)?;
|
||||
|
||||
let header = get_file_header(file)?;
|
||||
check_file_header(&header, size)?;
|
||||
|
||||
let data = get_element_data(file, element)?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Get a list of packed files
|
||||
pub fn get_list(file: &std::fs::File) -> Result<Vec<ListElement>, ReaderError> {
|
||||
let mut list: Vec<ListElement> = Vec::new();
|
||||
|
||||
let size = get_file_size(file)?;
|
||||
check_file_size(size)?;
|
||||
|
||||
let header = get_file_header(file)?;
|
||||
check_file_header(&header, size)?;
|
||||
|
||||
get_file_list(file, &header, &mut list)?;
|
||||
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
fn check_file_header(header: &FileHeader, size: u32) -> Result<(), ReaderError> {
|
||||
if header.type1 != FILE_TYPE_1 || header.type2 != FILE_TYPE_2 {
|
||||
return Err(ReaderError::IncorrectHeader);
|
||||
}
|
||||
|
||||
if header.size != size {
|
||||
return Err(ReaderError::IncorrectSizeFile {
|
||||
expected: size,
|
||||
received: header.size,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_file_size(size: u32) -> Result<(), ReaderError> {
|
||||
if size < MINIMUM_FILE_SIZE {
|
||||
return Err(ReaderError::SmallFile {
|
||||
expected: MINIMUM_FILE_SIZE,
|
||||
received: size,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_element_data(file: &std::fs::File, element: &ListElement) -> Result<Vec<u8>, ReaderError> {
|
||||
let position = converter::u32_to_u64(element.position)?;
|
||||
let size = converter::u32_to_usize(element.size)?;
|
||||
|
||||
let mut reader = std::io::BufReader::new(file);
|
||||
let mut buffer = vec![0u8; size];
|
||||
|
||||
if let Err(error) = reader.seek(std::io::SeekFrom::Start(position)) {
|
||||
return Err(ReaderError::ReadFile(error));
|
||||
};
|
||||
|
||||
if let Err(error) = reader.read_exact(&mut buffer) {
|
||||
return Err(ReaderError::ReadFile(error));
|
||||
};
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
fn get_element_position(index: u32) -> Result<(usize, usize), ReaderError> {
|
||||
let from = converter::u32_to_usize(index * LIST_ELEMENT_SIZE)?;
|
||||
let to = converter::u32_to_usize((index * LIST_ELEMENT_SIZE) + LIST_ELEMENT_SIZE)?;
|
||||
Ok((from, to))
|
||||
}
|
||||
|
||||
fn get_file_header(file: &std::fs::File) -> Result<FileHeader, ReaderError> {
|
||||
let mut reader = std::io::BufReader::new(file);
|
||||
let mut buffer = vec![0u8; MINIMUM_FILE_SIZE as usize];
|
||||
|
||||
if let Err(error) = reader.seek(std::io::SeekFrom::Start(0)) {
|
||||
return Err(ReaderError::ReadFile(error));
|
||||
};
|
||||
|
||||
if let Err(error) = reader.read_exact(&mut buffer) {
|
||||
return Err(ReaderError::ReadFile(error));
|
||||
};
|
||||
|
||||
let header = FileHeader {
|
||||
size: byteorder::LittleEndian::read_u32(&buffer[12..16]),
|
||||
total: byteorder::LittleEndian::read_u32(&buffer[8..12]),
|
||||
type1: byteorder::LittleEndian::read_u32(&buffer[0..4]),
|
||||
type2: byteorder::LittleEndian::read_u32(&buffer[4..8]),
|
||||
};
|
||||
|
||||
buffer.clear();
|
||||
Ok(header)
|
||||
}
|
||||
|
||||
fn get_file_list(
|
||||
file: &std::fs::File,
|
||||
header: &FileHeader,
|
||||
list: &mut Vec<ListElement>,
|
||||
) -> Result<(), ReaderError> {
|
||||
let (start_position, list_size) = get_list_position(header)?;
|
||||
let mut reader = std::io::BufReader::new(file);
|
||||
let mut buffer = vec![0u8; list_size];
|
||||
|
||||
if let Err(error) = reader.seek(std::io::SeekFrom::Start(start_position)) {
|
||||
return Err(ReaderError::ReadFile(error));
|
||||
};
|
||||
|
||||
if let Err(error) = reader.read_exact(&mut buffer) {
|
||||
return Err(ReaderError::ReadFile(error));
|
||||
}
|
||||
|
||||
let buffer_size = converter::usize_to_u32(buffer.len())?;
|
||||
|
||||
if buffer_size % LIST_ELEMENT_SIZE != 0 {
|
||||
return Err(ReaderError::IncorrectSizeList {
|
||||
expected: LIST_ELEMENT_SIZE,
|
||||
received: buffer_size,
|
||||
});
|
||||
}
|
||||
|
||||
for i in 0..(buffer_size / LIST_ELEMENT_SIZE) {
|
||||
let (from, to) = get_element_position(i)?;
|
||||
let chunk: &[u8] = &buffer[from..to];
|
||||
|
||||
let element = get_list_element(chunk)?;
|
||||
list.push(element);
|
||||
}
|
||||
|
||||
buffer.clear();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_file_size(file: &std::fs::File) -> Result<u32, ReaderError> {
|
||||
let metadata = match file.metadata() {
|
||||
Err(error) => return Err(ReaderError::ReadFile(error)),
|
||||
Ok(value) => value,
|
||||
};
|
||||
|
||||
let result = converter::u64_to_u32(metadata.len())?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn get_list_element(buffer: &[u8]) -> Result<ListElement, ReaderError> {
|
||||
let index = byteorder::LittleEndian::read_u32(&buffer[60..64]);
|
||||
let position = byteorder::LittleEndian::read_u32(&buffer[56..60]);
|
||||
let size = byteorder::LittleEndian::read_u32(&buffer[12..16]);
|
||||
let unknown0 = byteorder::LittleEndian::read_i32(&buffer[4..8]);
|
||||
let unknown1 = byteorder::LittleEndian::read_i32(&buffer[8..12]);
|
||||
let unknown2 = byteorder::LittleEndian::read_i32(&buffer[16..20]);
|
||||
|
||||
let extension = String::from_utf8_lossy(&buffer[0..4])
|
||||
.trim_matches(char::from(0))
|
||||
.to_string();
|
||||
|
||||
let name = String::from_utf8_lossy(&buffer[20..56])
|
||||
.trim_matches(char::from(0))
|
||||
.to_string();
|
||||
|
||||
Ok(ListElement {
|
||||
_unknown0: unknown0,
|
||||
_unknown1: unknown1,
|
||||
_unknown2: unknown2,
|
||||
extension,
|
||||
index,
|
||||
name,
|
||||
position,
|
||||
size,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_list_position(header: &FileHeader) -> Result<(u64, usize), ReaderError> {
|
||||
let position = converter::u32_to_u64(header.size - (header.total * LIST_ELEMENT_SIZE))?;
|
||||
let size = converter::u32_to_usize(header.total * LIST_ELEMENT_SIZE)?;
|
||||
Ok((position, size))
|
||||
}
|
||||
11
mkdocs.yml
11
mkdocs.yml
@@ -15,10 +15,19 @@ copyright: Copyright © 2023 — 2024 Valentin Popov
|
||||
# Configuration
|
||||
theme:
|
||||
name: material
|
||||
language: en
|
||||
language: ru
|
||||
palette:
|
||||
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
|
||||
extra:
|
||||
social:
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "packer"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
byteorder = "1.4.3"
|
||||
serde = { version = "1.0.160", features = ["derive"] }
|
||||
serde_json = "1.0.96"
|
||||
@@ -1,27 +0,0 @@
|
||||
# NRes Game Resource Packer
|
||||
|
||||
At the moment, this is a demonstration of the NRes game resource packing algorithm in action.
|
||||
It packs 100% of the NRes game resources for the game "Parkan: Iron Strategy".
|
||||
The hash sums of the resulting files match the original game files.
|
||||
|
||||
__Attention!__
|
||||
This is a test version of the utility. It overwrites the specified final file without asking.
|
||||
|
||||
## Building
|
||||
|
||||
To build the tools, you need to run the following command in the root directory:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
You can run the utility with the following command:
|
||||
|
||||
```bash
|
||||
./target/release/packer /path/to/unpack /path/to/file.ex
|
||||
```
|
||||
|
||||
- `/path/to/unpack`: This is the directory with the resources unpacked by the [unpacker](../unpacker) utility.
|
||||
- `/path/to/file.ex`: This is the final file that will be created.
|
||||
@@ -1,175 +0,0 @@
|
||||
use std::env;
|
||||
use std::{
|
||||
fs::{self, File},
|
||||
io::{BufReader, Read},
|
||||
};
|
||||
|
||||
use byteorder::{ByteOrder, LittleEndian};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ImportListElement {
|
||||
pub extension: String,
|
||||
pub index: u32,
|
||||
pub name: String,
|
||||
pub unknown0: u32,
|
||||
pub unknown1: u32,
|
||||
pub unknown2: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ListElement {
|
||||
pub extension: String,
|
||||
pub index: u32,
|
||||
pub name: String,
|
||||
pub position: u32,
|
||||
pub size: u32,
|
||||
pub unknown0: u32,
|
||||
pub unknown1: u32,
|
||||
pub unknown2: u32,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
let input = &args[1];
|
||||
let output = &args[2];
|
||||
|
||||
pack(String::from(input), String::from(output));
|
||||
}
|
||||
|
||||
fn pack(input: String, output: String) {
|
||||
// Загружаем индекс-файл
|
||||
let index_file = format!("{}/{}", input, "index.json");
|
||||
let data = fs::read_to_string(index_file).unwrap();
|
||||
let list: Vec<ImportListElement> = serde_json::from_str(&data).unwrap();
|
||||
|
||||
// Общий буфер хранения файлов
|
||||
let mut content_buffer: Vec<u8> = Vec::new();
|
||||
let mut list_buffer: Vec<u8> = Vec::new();
|
||||
|
||||
// Общее количество файлов
|
||||
let total_files: u32 = list.len() as u32;
|
||||
|
||||
for (index, item) in list.iter().enumerate() {
|
||||
// Открываем дескриптор файла
|
||||
let path = format!("{}/{}.{}", input, item.name, item.index);
|
||||
let file = File::open(path).unwrap();
|
||||
let metadata = file.metadata().unwrap();
|
||||
|
||||
// Считываем файл в буфер
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut file_buffer: Vec<u8> = Vec::new();
|
||||
reader.read_to_end(&mut file_buffer).unwrap();
|
||||
|
||||
// Выравнивание буфера
|
||||
if index != 0 {
|
||||
while !content_buffer.len().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
Normal file
2
testdata/nres/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
2
testdata/rsli/.gitignore
vendored
Normal file
2
testdata/rsli/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
107
tools/README.md
Normal file
107
tools/README.md
Normal file
@@ -0,0 +1,107 @@
|
||||
# Инструменты в каталоге `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`.
|
||||
944
tools/archive_roundtrip_validator.py
Normal file
944
tools/archive_roundtrip_validator.py
Normal file
@@ -0,0 +1,944 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Roundtrip tools for NRes and RsLi archives.
|
||||
|
||||
The script can:
|
||||
1) scan archives by header signature (ignores file extensions),
|
||||
2) unpack / pack NRes archives,
|
||||
3) unpack / pack RsLi archives,
|
||||
4) validate docs assumptions by full roundtrip and byte-to-byte comparison.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import struct
|
||||
import tempfile
|
||||
import zlib
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
MAGIC_NRES = b"NRes"
|
||||
MAGIC_RSLI = b"NL\x00\x01"
|
||||
|
||||
|
||||
class ArchiveFormatError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def sha256_hex(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def safe_component(value: str, fallback: str = "item", max_len: int = 80) -> str:
|
||||
clean = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._-")
|
||||
if not clean:
|
||||
clean = fallback
|
||||
return clean[:max_len]
|
||||
|
||||
|
||||
def first_diff(a: bytes, b: bytes) -> tuple[int | None, str | None]:
|
||||
if a == b:
|
||||
return None, None
|
||||
limit = min(len(a), len(b))
|
||||
for idx in range(limit):
|
||||
if a[idx] != b[idx]:
|
||||
return idx, f"{a[idx]:02x}!={b[idx]:02x}"
|
||||
return limit, f"len {len(a)}!={len(b)}"
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def dump_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
|
||||
|
||||
def xor_stream(data: bytes, key16: int) -> bytes:
|
||||
lo = key16 & 0xFF
|
||||
hi = (key16 >> 8) & 0xFF
|
||||
out = bytearray(len(data))
|
||||
for i, value in enumerate(data):
|
||||
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
|
||||
out[i] = value ^ lo
|
||||
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def lzss_decompress_simple(data: bytes, expected_size: int) -> bytes:
|
||||
ring = bytearray([0x20] * 0x1000)
|
||||
ring_pos = 0xFEE
|
||||
out = bytearray()
|
||||
in_pos = 0
|
||||
control = 0
|
||||
bits_left = 0
|
||||
|
||||
while len(out) < expected_size and in_pos < len(data):
|
||||
if bits_left == 0:
|
||||
control = data[in_pos]
|
||||
in_pos += 1
|
||||
bits_left = 8
|
||||
|
||||
if control & 1:
|
||||
if in_pos >= len(data):
|
||||
break
|
||||
byte = data[in_pos]
|
||||
in_pos += 1
|
||||
out.append(byte)
|
||||
ring[ring_pos] = byte
|
||||
ring_pos = (ring_pos + 1) & 0x0FFF
|
||||
else:
|
||||
if in_pos + 1 >= len(data):
|
||||
break
|
||||
low = data[in_pos]
|
||||
high = data[in_pos + 1]
|
||||
in_pos += 2
|
||||
# Real files indicate nibble layout opposite to common LZSS variant:
|
||||
# high nibble extends offset, low nibble stores (length - 3).
|
||||
offset = low | ((high & 0xF0) << 4)
|
||||
length = (high & 0x0F) + 3
|
||||
for step in range(length):
|
||||
byte = ring[(offset + step) & 0x0FFF]
|
||||
out.append(byte)
|
||||
ring[ring_pos] = byte
|
||||
ring_pos = (ring_pos + 1) & 0x0FFF
|
||||
if len(out) >= expected_size:
|
||||
break
|
||||
|
||||
control >>= 1
|
||||
bits_left -= 1
|
||||
|
||||
if len(out) != expected_size:
|
||||
raise ArchiveFormatError(
|
||||
f"LZSS size mismatch: expected {expected_size}, got {len(out)}"
|
||||
)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def decode_rsli_payload(
|
||||
packed: bytes, method: int, sort_to_original: int, unpacked_size: int
|
||||
) -> bytes:
|
||||
key16 = sort_to_original & 0xFFFF
|
||||
|
||||
if method == 0x000:
|
||||
out = packed
|
||||
elif method == 0x020:
|
||||
if len(packed) < unpacked_size:
|
||||
raise ArchiveFormatError(
|
||||
f"method 0x20 packed too short: {len(packed)} < {unpacked_size}"
|
||||
)
|
||||
out = xor_stream(packed[:unpacked_size], key16)
|
||||
elif method == 0x040:
|
||||
out = lzss_decompress_simple(packed, unpacked_size)
|
||||
elif method == 0x060:
|
||||
out = lzss_decompress_simple(xor_stream(packed, key16), unpacked_size)
|
||||
elif method == 0x100:
|
||||
try:
|
||||
out = zlib.decompress(packed, -15)
|
||||
except zlib.error:
|
||||
out = zlib.decompress(packed)
|
||||
else:
|
||||
raise ArchiveFormatError(f"unsupported RsLi method: 0x{method:03X}")
|
||||
|
||||
if len(out) != unpacked_size:
|
||||
raise ArchiveFormatError(
|
||||
f"unpacked_size mismatch: expected {unpacked_size}, got {len(out)}"
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def detect_archive_type(path: Path) -> str | None:
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
magic = handle.read(4)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
if magic == MAGIC_NRES:
|
||||
return "nres"
|
||||
if magic == MAGIC_RSLI:
|
||||
return "rsli"
|
||||
return None
|
||||
|
||||
|
||||
def scan_archives(root: Path) -> list[dict[str, Any]]:
|
||||
found: list[dict[str, Any]] = []
|
||||
for path in sorted(root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
archive_type = detect_archive_type(path)
|
||||
if not archive_type:
|
||||
continue
|
||||
found.append(
|
||||
{
|
||||
"path": str(path),
|
||||
"relative_path": str(path.relative_to(root)),
|
||||
"type": archive_type,
|
||||
"size": path.stat().st_size,
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
def parse_nres(data: bytes, source: str = "<memory>") -> dict[str, Any]:
|
||||
if len(data) < 16:
|
||||
raise ArchiveFormatError(f"{source}: NRes too short ({len(data)} bytes)")
|
||||
|
||||
magic, version, entry_count, total_size = struct.unpack_from("<4sIII", data, 0)
|
||||
if magic != MAGIC_NRES:
|
||||
raise ArchiveFormatError(f"{source}: invalid NRes magic")
|
||||
|
||||
issues: list[str] = []
|
||||
if total_size != len(data):
|
||||
issues.append(
|
||||
f"header.total_size={total_size} != actual_size={len(data)} (spec 1.2)"
|
||||
)
|
||||
if version != 0x100:
|
||||
issues.append(f"version=0x{version:08X} != 0x00000100 (spec 1.2)")
|
||||
|
||||
directory_offset = total_size - entry_count * 64
|
||||
if directory_offset < 16 or directory_offset > len(data):
|
||||
raise ArchiveFormatError(
|
||||
f"{source}: invalid directory offset {directory_offset} for entry_count={entry_count}"
|
||||
)
|
||||
if directory_offset + entry_count * 64 != len(data):
|
||||
issues.append(
|
||||
"directory_offset + entry_count*64 != file_size (spec 1.3)"
|
||||
)
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
for index in range(entry_count):
|
||||
offset = directory_offset + index * 64
|
||||
if offset + 64 > len(data):
|
||||
raise ArchiveFormatError(f"{source}: truncated directory entry {index}")
|
||||
|
||||
(
|
||||
type_id,
|
||||
attr1,
|
||||
attr2,
|
||||
size,
|
||||
attr3,
|
||||
name_raw,
|
||||
data_offset,
|
||||
sort_index,
|
||||
) = struct.unpack_from("<IIIII36sII", data, offset)
|
||||
name_bytes = name_raw.split(b"\x00", 1)[0]
|
||||
name = name_bytes.decode("latin1", errors="replace")
|
||||
entries.append(
|
||||
{
|
||||
"index": index,
|
||||
"type_id": type_id,
|
||||
"attr1": attr1,
|
||||
"attr2": attr2,
|
||||
"size": size,
|
||||
"attr3": attr3,
|
||||
"name": name,
|
||||
"name_bytes_hex": name_bytes.hex(),
|
||||
"name_raw_hex": name_raw.hex(),
|
||||
"data_offset": data_offset,
|
||||
"sort_index": sort_index,
|
||||
}
|
||||
)
|
||||
|
||||
# Spec checks.
|
||||
expected_sort = sorted(
|
||||
range(entry_count),
|
||||
key=lambda idx: bytes.fromhex(entries[idx]["name_bytes_hex"]).lower(),
|
||||
)
|
||||
current_sort = [item["sort_index"] for item in entries]
|
||||
if current_sort != expected_sort:
|
||||
issues.append(
|
||||
"sort_index table does not match case-insensitive name order (spec 1.4)"
|
||||
)
|
||||
|
||||
data_regions = sorted(
|
||||
(
|
||||
item["index"],
|
||||
item["data_offset"],
|
||||
item["size"],
|
||||
)
|
||||
for item in entries
|
||||
)
|
||||
for idx, data_offset, size in data_regions:
|
||||
if data_offset % 8 != 0:
|
||||
issues.append(f"entry {idx}: data_offset={data_offset} not aligned to 8 (spec 1.5)")
|
||||
if data_offset < 16 or data_offset + size > directory_offset:
|
||||
issues.append(
|
||||
f"entry {idx}: data range [{data_offset}, {data_offset + size}) out of data area (spec 1.3)"
|
||||
)
|
||||
for i in range(len(data_regions) - 1):
|
||||
_, start, size = data_regions[i]
|
||||
_, next_start, _ = data_regions[i + 1]
|
||||
if start + size > next_start:
|
||||
issues.append(
|
||||
f"entry overlap at data_offset={start}, next={next_start}"
|
||||
)
|
||||
padding = data[start + size : next_start]
|
||||
if any(padding):
|
||||
issues.append(
|
||||
f"non-zero padding after data block at offset={start + size} (spec 1.5)"
|
||||
)
|
||||
|
||||
return {
|
||||
"format": "NRes",
|
||||
"header": {
|
||||
"magic": "NRes",
|
||||
"version": version,
|
||||
"entry_count": entry_count,
|
||||
"total_size": total_size,
|
||||
"directory_offset": directory_offset,
|
||||
},
|
||||
"entries": entries,
|
||||
"issues": issues,
|
||||
}
|
||||
|
||||
|
||||
def build_nres_name_field(entry: dict[str, Any]) -> bytes:
|
||||
if "name_bytes_hex" in entry:
|
||||
raw = bytes.fromhex(entry["name_bytes_hex"])
|
||||
else:
|
||||
raw = entry.get("name", "").encode("latin1", errors="replace")
|
||||
raw = raw[:35]
|
||||
return raw + b"\x00" * (36 - len(raw))
|
||||
|
||||
|
||||
def unpack_nres_file(archive_path: Path, out_dir: Path, source_root: Path | None = None) -> dict[str, Any]:
|
||||
data = archive_path.read_bytes()
|
||||
parsed = parse_nres(data, source=str(archive_path))
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
entries_dir = out_dir / "entries"
|
||||
entries_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest: dict[str, Any] = {
|
||||
"format": "NRes",
|
||||
"source_path": str(archive_path),
|
||||
"source_relative_path": str(archive_path.relative_to(source_root)) if source_root else str(archive_path),
|
||||
"header": parsed["header"],
|
||||
"entries": [],
|
||||
"issues": parsed["issues"],
|
||||
"source_sha256": sha256_hex(data),
|
||||
}
|
||||
|
||||
for entry in parsed["entries"]:
|
||||
begin = entry["data_offset"]
|
||||
end = begin + entry["size"]
|
||||
if begin < 0 or end > len(data):
|
||||
raise ArchiveFormatError(
|
||||
f"{archive_path}: entry {entry['index']} data range outside file"
|
||||
)
|
||||
payload = data[begin:end]
|
||||
base = safe_component(entry["name"], fallback=f"entry_{entry['index']:05d}")
|
||||
file_name = (
|
||||
f"{entry['index']:05d}__{base}"
|
||||
f"__t{entry['type_id']:08X}_a1{entry['attr1']:08X}_a2{entry['attr2']:08X}.bin"
|
||||
)
|
||||
(entries_dir / file_name).write_bytes(payload)
|
||||
|
||||
manifest_entry = dict(entry)
|
||||
manifest_entry["data_file"] = f"entries/{file_name}"
|
||||
manifest_entry["sha256"] = sha256_hex(payload)
|
||||
manifest["entries"].append(manifest_entry)
|
||||
|
||||
dump_json(out_dir / "manifest.json", manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def pack_nres_manifest(manifest_path: Path, out_file: Path) -> bytes:
|
||||
manifest = load_json(manifest_path)
|
||||
if manifest.get("format") != "NRes":
|
||||
raise ArchiveFormatError(f"{manifest_path}: not an NRes manifest")
|
||||
|
||||
entries = manifest["entries"]
|
||||
count = len(entries)
|
||||
version = int(manifest.get("header", {}).get("version", 0x100))
|
||||
|
||||
out = bytearray(b"\x00" * 16)
|
||||
data_offsets: list[int] = []
|
||||
data_sizes: list[int] = []
|
||||
|
||||
for entry in entries:
|
||||
payload_path = manifest_path.parent / entry["data_file"]
|
||||
payload = payload_path.read_bytes()
|
||||
offset = len(out)
|
||||
out.extend(payload)
|
||||
padding = (-len(out)) % 8
|
||||
if padding:
|
||||
out.extend(b"\x00" * padding)
|
||||
data_offsets.append(offset)
|
||||
data_sizes.append(len(payload))
|
||||
|
||||
directory_offset = len(out)
|
||||
expected_sort = sorted(
|
||||
range(count),
|
||||
key=lambda idx: bytes.fromhex(entries[idx].get("name_bytes_hex", "")).lower(),
|
||||
)
|
||||
|
||||
for index, entry in enumerate(entries):
|
||||
name_field = build_nres_name_field(entry)
|
||||
out.extend(
|
||||
struct.pack(
|
||||
"<IIIII36sII",
|
||||
int(entry["type_id"]),
|
||||
int(entry["attr1"]),
|
||||
int(entry["attr2"]),
|
||||
data_sizes[index],
|
||||
int(entry["attr3"]),
|
||||
name_field,
|
||||
data_offsets[index],
|
||||
expected_sort[index],
|
||||
)
|
||||
)
|
||||
|
||||
total_size = len(out)
|
||||
struct.pack_into("<4sIII", out, 0, MAGIC_NRES, version, count, total_size)
|
||||
|
||||
out_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_file.write_bytes(out)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def parse_rsli(data: bytes, source: str = "<memory>") -> dict[str, Any]:
|
||||
if len(data) < 32:
|
||||
raise ArchiveFormatError(f"{source}: RsLi too short ({len(data)} bytes)")
|
||||
if data[:4] != MAGIC_RSLI:
|
||||
raise ArchiveFormatError(f"{source}: invalid RsLi magic")
|
||||
|
||||
issues: list[str] = []
|
||||
reserved_zero = data[2]
|
||||
version = data[3]
|
||||
entry_count = struct.unpack_from("<h", data, 4)[0]
|
||||
presorted_flag = struct.unpack_from("<H", data, 14)[0]
|
||||
seed = struct.unpack_from("<I", data, 20)[0]
|
||||
|
||||
if reserved_zero != 0:
|
||||
issues.append(f"header[2]={reserved_zero} != 0 (spec 2.2)")
|
||||
if version != 1:
|
||||
issues.append(f"version={version} != 1 (spec 2.2)")
|
||||
if entry_count < 0:
|
||||
raise ArchiveFormatError(f"{source}: negative entry_count={entry_count}")
|
||||
|
||||
table_offset = 32
|
||||
table_size = entry_count * 32
|
||||
if table_offset + table_size > len(data):
|
||||
raise ArchiveFormatError(
|
||||
f"{source}: encrypted table out of file bounds ({table_offset}+{table_size}>{len(data)})"
|
||||
)
|
||||
|
||||
table_encrypted = data[table_offset : table_offset + table_size]
|
||||
table_plain = xor_stream(table_encrypted, seed & 0xFFFF)
|
||||
|
||||
trailer: dict[str, Any] = {"present": False}
|
||||
overlay_offset = 0
|
||||
if len(data) >= 6 and data[-6:-4] == b"AO":
|
||||
overlay_offset = struct.unpack_from("<I", data, len(data) - 4)[0]
|
||||
trailer = {
|
||||
"present": True,
|
||||
"signature": "AO",
|
||||
"overlay_offset": overlay_offset,
|
||||
"raw_hex": data[-6:].hex(),
|
||||
}
|
||||
|
||||
entries: list[dict[str, Any]] = []
|
||||
sort_values: list[int] = []
|
||||
for index in range(entry_count):
|
||||
row = table_plain[index * 32 : (index + 1) * 32]
|
||||
name_raw = row[0:12]
|
||||
reserved4 = row[12:16]
|
||||
flags_signed, sort_to_original = struct.unpack_from("<hh", row, 16)
|
||||
unpacked_size, data_offset, packed_size = struct.unpack_from("<III", row, 20)
|
||||
method = flags_signed & 0x1E0
|
||||
name = name_raw.split(b"\x00", 1)[0].decode("latin1", errors="replace")
|
||||
effective_offset = data_offset + overlay_offset
|
||||
entries.append(
|
||||
{
|
||||
"index": index,
|
||||
"name": name,
|
||||
"name_raw_hex": name_raw.hex(),
|
||||
"reserved_raw_hex": reserved4.hex(),
|
||||
"flags_signed": flags_signed,
|
||||
"flags_u16": flags_signed & 0xFFFF,
|
||||
"method": method,
|
||||
"sort_to_original": sort_to_original,
|
||||
"unpacked_size": unpacked_size,
|
||||
"data_offset": data_offset,
|
||||
"effective_data_offset": effective_offset,
|
||||
"packed_size": packed_size,
|
||||
}
|
||||
)
|
||||
sort_values.append(sort_to_original)
|
||||
|
||||
if effective_offset < 0:
|
||||
issues.append(f"entry {index}: negative effective_data_offset={effective_offset}")
|
||||
elif effective_offset + packed_size > len(data):
|
||||
end = effective_offset + packed_size
|
||||
if method == 0x100 and end == len(data) + 1:
|
||||
issues.append(
|
||||
f"entry {index}: deflate packed_size reaches EOF+1 ({end}); "
|
||||
"observed in game data, likely decoder lookahead byte"
|
||||
)
|
||||
else:
|
||||
issues.append(
|
||||
f"entry {index}: packed range [{effective_offset}, {end}) out of file"
|
||||
)
|
||||
|
||||
if presorted_flag == 0xABBA:
|
||||
if sorted(sort_values) != list(range(entry_count)):
|
||||
issues.append(
|
||||
"presorted flag is 0xABBA but sort_to_original is not a permutation [0..N-1] (spec 2.2/2.4)"
|
||||
)
|
||||
|
||||
return {
|
||||
"format": "RsLi",
|
||||
"header_raw_hex": data[:32].hex(),
|
||||
"header": {
|
||||
"magic": "NL\\x00\\x01",
|
||||
"entry_count": entry_count,
|
||||
"seed": seed,
|
||||
"presorted_flag": presorted_flag,
|
||||
},
|
||||
"entries": entries,
|
||||
"issues": issues,
|
||||
"trailer": trailer,
|
||||
}
|
||||
|
||||
|
||||
def unpack_rsli_file(archive_path: Path, out_dir: Path, source_root: Path | None = None) -> dict[str, Any]:
|
||||
data = archive_path.read_bytes()
|
||||
parsed = parse_rsli(data, source=str(archive_path))
|
||||
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
entries_dir = out_dir / "entries"
|
||||
entries_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest: dict[str, Any] = {
|
||||
"format": "RsLi",
|
||||
"source_path": str(archive_path),
|
||||
"source_relative_path": str(archive_path.relative_to(source_root)) if source_root else str(archive_path),
|
||||
"source_size": len(data),
|
||||
"header_raw_hex": parsed["header_raw_hex"],
|
||||
"header": parsed["header"],
|
||||
"entries": [],
|
||||
"issues": list(parsed["issues"]),
|
||||
"trailer": parsed["trailer"],
|
||||
"source_sha256": sha256_hex(data),
|
||||
}
|
||||
|
||||
for entry in parsed["entries"]:
|
||||
begin = int(entry["effective_data_offset"])
|
||||
end = begin + int(entry["packed_size"])
|
||||
packed = data[begin:end]
|
||||
base = safe_component(entry["name"], fallback=f"entry_{entry['index']:05d}")
|
||||
packed_name = f"{entry['index']:05d}__{base}__packed.bin"
|
||||
(entries_dir / packed_name).write_bytes(packed)
|
||||
|
||||
manifest_entry = dict(entry)
|
||||
manifest_entry["packed_file"] = f"entries/{packed_name}"
|
||||
manifest_entry["packed_file_size"] = len(packed)
|
||||
manifest_entry["packed_sha256"] = sha256_hex(packed)
|
||||
|
||||
try:
|
||||
unpacked = decode_rsli_payload(
|
||||
packed=packed,
|
||||
method=int(entry["method"]),
|
||||
sort_to_original=int(entry["sort_to_original"]),
|
||||
unpacked_size=int(entry["unpacked_size"]),
|
||||
)
|
||||
unpacked_name = f"{entry['index']:05d}__{base}__unpacked.bin"
|
||||
(entries_dir / unpacked_name).write_bytes(unpacked)
|
||||
manifest_entry["unpacked_file"] = f"entries/{unpacked_name}"
|
||||
manifest_entry["unpacked_sha256"] = sha256_hex(unpacked)
|
||||
except ArchiveFormatError as exc:
|
||||
manifest_entry["unpack_error"] = str(exc)
|
||||
manifest["issues"].append(
|
||||
f"entry {entry['index']}: cannot decode method 0x{entry['method']:03X}: {exc}"
|
||||
)
|
||||
|
||||
manifest["entries"].append(manifest_entry)
|
||||
|
||||
dump_json(out_dir / "manifest.json", manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def _pack_i16(value: int) -> int:
|
||||
if not (-32768 <= int(value) <= 32767):
|
||||
raise ArchiveFormatError(f"int16 overflow: {value}")
|
||||
return int(value)
|
||||
|
||||
|
||||
def pack_rsli_manifest(manifest_path: Path, out_file: Path) -> bytes:
|
||||
manifest = load_json(manifest_path)
|
||||
if manifest.get("format") != "RsLi":
|
||||
raise ArchiveFormatError(f"{manifest_path}: not an RsLi manifest")
|
||||
|
||||
entries = manifest["entries"]
|
||||
count = len(entries)
|
||||
|
||||
header_raw = bytes.fromhex(manifest["header_raw_hex"])
|
||||
if len(header_raw) != 32:
|
||||
raise ArchiveFormatError(f"{manifest_path}: header_raw_hex must be 32 bytes")
|
||||
header = bytearray(header_raw)
|
||||
header[:4] = MAGIC_RSLI
|
||||
struct.pack_into("<h", header, 4, count)
|
||||
seed = int(manifest["header"]["seed"])
|
||||
struct.pack_into("<I", header, 20, seed)
|
||||
|
||||
rows = bytearray()
|
||||
packed_chunks: list[tuple[dict[str, Any], bytes]] = []
|
||||
|
||||
for entry in entries:
|
||||
packed_path = manifest_path.parent / entry["packed_file"]
|
||||
packed = packed_path.read_bytes()
|
||||
declared_size = int(entry["packed_size"])
|
||||
if len(packed) > declared_size:
|
||||
raise ArchiveFormatError(
|
||||
f"{packed_path}: packed size {len(packed)} > manifest packed_size {declared_size}"
|
||||
)
|
||||
|
||||
data_offset = int(entry["data_offset"])
|
||||
packed_chunks.append((entry, packed))
|
||||
|
||||
row = bytearray(32)
|
||||
name_raw = bytes.fromhex(entry["name_raw_hex"])
|
||||
reserved_raw = bytes.fromhex(entry["reserved_raw_hex"])
|
||||
if len(name_raw) != 12 or len(reserved_raw) != 4:
|
||||
raise ArchiveFormatError(
|
||||
f"entry {entry['index']}: invalid name/reserved raw length"
|
||||
)
|
||||
row[0:12] = name_raw
|
||||
row[12:16] = reserved_raw
|
||||
struct.pack_into(
|
||||
"<hhIII",
|
||||
row,
|
||||
16,
|
||||
_pack_i16(int(entry["flags_signed"])),
|
||||
_pack_i16(int(entry["sort_to_original"])),
|
||||
int(entry["unpacked_size"]),
|
||||
data_offset,
|
||||
declared_size,
|
||||
)
|
||||
rows.extend(row)
|
||||
|
||||
encrypted_table = xor_stream(bytes(rows), seed & 0xFFFF)
|
||||
trailer = manifest.get("trailer", {})
|
||||
trailer_raw = b""
|
||||
if trailer.get("present"):
|
||||
raw_hex = trailer.get("raw_hex", "")
|
||||
trailer_raw = bytes.fromhex(raw_hex)
|
||||
if len(trailer_raw) != 6:
|
||||
raise ArchiveFormatError("trailer raw length must be 6 bytes")
|
||||
|
||||
source_size = manifest.get("source_size")
|
||||
table_end = 32 + count * 32
|
||||
if source_size is not None:
|
||||
pre_trailer_size = int(source_size) - len(trailer_raw)
|
||||
if pre_trailer_size < table_end:
|
||||
raise ArchiveFormatError(
|
||||
f"invalid source_size={source_size}: smaller than header+table"
|
||||
)
|
||||
else:
|
||||
pre_trailer_size = table_end
|
||||
for entry, packed in packed_chunks:
|
||||
pre_trailer_size = max(
|
||||
pre_trailer_size, int(entry["data_offset"]) + len(packed)
|
||||
)
|
||||
|
||||
out = bytearray(pre_trailer_size)
|
||||
out[0:32] = header
|
||||
out[32:table_end] = encrypted_table
|
||||
occupied = bytearray(pre_trailer_size)
|
||||
occupied[0:table_end] = b"\x01" * table_end
|
||||
|
||||
for entry, packed in packed_chunks:
|
||||
base_offset = int(entry["data_offset"])
|
||||
for index, byte in enumerate(packed):
|
||||
pos = base_offset + index
|
||||
if pos >= pre_trailer_size:
|
||||
raise ArchiveFormatError(
|
||||
f"entry {entry['index']}: data write at {pos} beyond output size {pre_trailer_size}"
|
||||
)
|
||||
if occupied[pos] and out[pos] != byte:
|
||||
raise ArchiveFormatError(
|
||||
f"entry {entry['index']}: overlapping packed data conflict at offset {pos}"
|
||||
)
|
||||
out[pos] = byte
|
||||
occupied[pos] = 1
|
||||
|
||||
out.extend(trailer_raw)
|
||||
if source_size is not None and len(out) != int(source_size):
|
||||
raise ArchiveFormatError(
|
||||
f"packed size {len(out)} != source_size {source_size} from manifest"
|
||||
)
|
||||
|
||||
out_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_file.write_bytes(out)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def cmd_scan(args: argparse.Namespace) -> int:
|
||||
root = Path(args.input).resolve()
|
||||
archives = scan_archives(root)
|
||||
if args.json:
|
||||
print(json.dumps(archives, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(f"Found {len(archives)} archive(s) in {root}")
|
||||
for item in archives:
|
||||
print(f"{item['type']:4} {item['size']:10d} {item['relative_path']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_nres_unpack(args: argparse.Namespace) -> int:
|
||||
archive_path = Path(args.archive).resolve()
|
||||
out_dir = Path(args.output).resolve()
|
||||
manifest = unpack_nres_file(archive_path, out_dir)
|
||||
print(f"NRes unpacked: {archive_path}")
|
||||
print(f"Manifest: {out_dir / 'manifest.json'}")
|
||||
print(f"Entries : {len(manifest['entries'])}")
|
||||
if manifest["issues"]:
|
||||
print("Issues:")
|
||||
for issue in manifest["issues"]:
|
||||
print(f"- {issue}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_nres_pack(args: argparse.Namespace) -> int:
|
||||
manifest_path = Path(args.manifest).resolve()
|
||||
out_file = Path(args.output).resolve()
|
||||
packed = pack_nres_manifest(manifest_path, out_file)
|
||||
print(f"NRes packed: {out_file} ({len(packed)} bytes, sha256={sha256_hex(packed)})")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_rsli_unpack(args: argparse.Namespace) -> int:
|
||||
archive_path = Path(args.archive).resolve()
|
||||
out_dir = Path(args.output).resolve()
|
||||
manifest = unpack_rsli_file(archive_path, out_dir)
|
||||
print(f"RsLi unpacked: {archive_path}")
|
||||
print(f"Manifest: {out_dir / 'manifest.json'}")
|
||||
print(f"Entries : {len(manifest['entries'])}")
|
||||
if manifest["issues"]:
|
||||
print("Issues:")
|
||||
for issue in manifest["issues"]:
|
||||
print(f"- {issue}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_rsli_pack(args: argparse.Namespace) -> int:
|
||||
manifest_path = Path(args.manifest).resolve()
|
||||
out_file = Path(args.output).resolve()
|
||||
packed = pack_rsli_manifest(manifest_path, out_file)
|
||||
print(f"RsLi packed: {out_file} ({len(packed)} bytes, sha256={sha256_hex(packed)})")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_validate(args: argparse.Namespace) -> int:
|
||||
input_root = Path(args.input).resolve()
|
||||
archives = scan_archives(input_root)
|
||||
|
||||
temp_created = False
|
||||
if args.workdir:
|
||||
workdir = Path(args.workdir).resolve()
|
||||
workdir.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
workdir = Path(tempfile.mkdtemp(prefix="nres-rsli-validate-"))
|
||||
temp_created = True
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"input_root": str(input_root),
|
||||
"workdir": str(workdir),
|
||||
"archives_total": len(archives),
|
||||
"results": [],
|
||||
"summary": {},
|
||||
}
|
||||
|
||||
failures = 0
|
||||
try:
|
||||
for idx, item in enumerate(archives):
|
||||
rel = item["relative_path"]
|
||||
archive_path = input_root / rel
|
||||
marker = f"{idx:04d}_{safe_component(rel, fallback='archive')}"
|
||||
unpack_dir = workdir / "unpacked" / marker
|
||||
repacked_file = workdir / "repacked" / f"{marker}.bin"
|
||||
try:
|
||||
if item["type"] == "nres":
|
||||
manifest = unpack_nres_file(archive_path, unpack_dir, source_root=input_root)
|
||||
repacked = pack_nres_manifest(unpack_dir / "manifest.json", repacked_file)
|
||||
elif item["type"] == "rsli":
|
||||
manifest = unpack_rsli_file(archive_path, unpack_dir, source_root=input_root)
|
||||
repacked = pack_rsli_manifest(unpack_dir / "manifest.json", repacked_file)
|
||||
else:
|
||||
continue
|
||||
|
||||
original = archive_path.read_bytes()
|
||||
match = original == repacked
|
||||
diff_offset, diff_desc = first_diff(original, repacked)
|
||||
issues = list(manifest.get("issues", []))
|
||||
result = {
|
||||
"relative_path": rel,
|
||||
"type": item["type"],
|
||||
"size_original": len(original),
|
||||
"size_repacked": len(repacked),
|
||||
"sha256_original": sha256_hex(original),
|
||||
"sha256_repacked": sha256_hex(repacked),
|
||||
"match": match,
|
||||
"first_diff_offset": diff_offset,
|
||||
"first_diff": diff_desc,
|
||||
"issues": issues,
|
||||
"entries": len(manifest.get("entries", [])),
|
||||
"error": None,
|
||||
}
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
result = {
|
||||
"relative_path": rel,
|
||||
"type": item["type"],
|
||||
"size_original": item["size"],
|
||||
"size_repacked": None,
|
||||
"sha256_original": None,
|
||||
"sha256_repacked": None,
|
||||
"match": False,
|
||||
"first_diff_offset": None,
|
||||
"first_diff": None,
|
||||
"issues": [f"processing error: {exc}"],
|
||||
"entries": None,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
report["results"].append(result)
|
||||
|
||||
if not result["match"]:
|
||||
failures += 1
|
||||
if result["issues"] and args.fail_on_issues:
|
||||
failures += 1
|
||||
|
||||
matches = sum(1 for row in report["results"] if row["match"])
|
||||
mismatches = len(report["results"]) - matches
|
||||
nres_count = sum(1 for row in report["results"] if row["type"] == "nres")
|
||||
rsli_count = sum(1 for row in report["results"] if row["type"] == "rsli")
|
||||
issues_total = sum(len(row["issues"]) for row in report["results"])
|
||||
report["summary"] = {
|
||||
"nres_count": nres_count,
|
||||
"rsli_count": rsli_count,
|
||||
"matches": matches,
|
||||
"mismatches": mismatches,
|
||||
"issues_total": issues_total,
|
||||
}
|
||||
|
||||
if args.report:
|
||||
dump_json(Path(args.report).resolve(), report)
|
||||
|
||||
print(f"Input root : {input_root}")
|
||||
print(f"Work dir : {workdir}")
|
||||
print(f"NRes archives : {nres_count}")
|
||||
print(f"RsLi archives : {rsli_count}")
|
||||
print(f"Roundtrip match: {matches}/{len(report['results'])}")
|
||||
print(f"Doc issues : {issues_total}")
|
||||
|
||||
if mismatches:
|
||||
print("\nMismatches:")
|
||||
for row in report["results"]:
|
||||
if row["match"]:
|
||||
continue
|
||||
print(
|
||||
f"- {row['relative_path']} [{row['type']}] "
|
||||
f"diff@{row['first_diff_offset']}: {row['first_diff']}"
|
||||
)
|
||||
|
||||
if issues_total:
|
||||
print("\nIssues:")
|
||||
for row in report["results"]:
|
||||
if not row["issues"]:
|
||||
continue
|
||||
print(f"- {row['relative_path']} [{row['type']}]")
|
||||
for issue in row["issues"]:
|
||||
print(f" * {issue}")
|
||||
|
||||
finally:
|
||||
if temp_created or args.cleanup:
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
|
||||
if failures > 0:
|
||||
return 1
|
||||
if report["summary"].get("mismatches", 0) > 0 and args.fail_on_diff:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="NRes/RsLi tools: scan, unpack, repack, and roundtrip validation."
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
scan = sub.add_parser("scan", help="Scan files by header signatures.")
|
||||
scan.add_argument("--input", required=True, help="Root directory to scan.")
|
||||
scan.add_argument("--json", action="store_true", help="Print JSON output.")
|
||||
scan.set_defaults(func=cmd_scan)
|
||||
|
||||
nres_unpack = sub.add_parser("nres-unpack", help="Unpack a single NRes archive.")
|
||||
nres_unpack.add_argument("--archive", required=True, help="Path to NRes file.")
|
||||
nres_unpack.add_argument("--output", required=True, help="Output directory.")
|
||||
nres_unpack.set_defaults(func=cmd_nres_unpack)
|
||||
|
||||
nres_pack = sub.add_parser("nres-pack", help="Pack NRes archive from manifest.")
|
||||
nres_pack.add_argument("--manifest", required=True, help="Path to manifest.json.")
|
||||
nres_pack.add_argument("--output", required=True, help="Output file path.")
|
||||
nres_pack.set_defaults(func=cmd_nres_pack)
|
||||
|
||||
rsli_unpack = sub.add_parser("rsli-unpack", help="Unpack a single RsLi archive.")
|
||||
rsli_unpack.add_argument("--archive", required=True, help="Path to RsLi file.")
|
||||
rsli_unpack.add_argument("--output", required=True, help="Output directory.")
|
||||
rsli_unpack.set_defaults(func=cmd_rsli_unpack)
|
||||
|
||||
rsli_pack = sub.add_parser("rsli-pack", help="Pack RsLi archive from manifest.")
|
||||
rsli_pack.add_argument("--manifest", required=True, help="Path to manifest.json.")
|
||||
rsli_pack.add_argument("--output", required=True, help="Output file path.")
|
||||
rsli_pack.set_defaults(func=cmd_rsli_pack)
|
||||
|
||||
validate = sub.add_parser(
|
||||
"validate",
|
||||
help="Scan all archives and run unpack->repack->byte-compare validation.",
|
||||
)
|
||||
validate.add_argument("--input", required=True, help="Root with game data files.")
|
||||
validate.add_argument(
|
||||
"--workdir",
|
||||
help="Working directory for temporary unpack/repack files. "
|
||||
"If omitted, a temporary directory is used and removed automatically.",
|
||||
)
|
||||
validate.add_argument("--report", help="Optional JSON report output path.")
|
||||
validate.add_argument(
|
||||
"--fail-on-diff",
|
||||
action="store_true",
|
||||
help="Return non-zero exit code if any byte mismatch exists.",
|
||||
)
|
||||
validate.add_argument(
|
||||
"--fail-on-issues",
|
||||
action="store_true",
|
||||
help="Return non-zero exit code if any spec issue was detected.",
|
||||
)
|
||||
validate.add_argument(
|
||||
"--cleanup",
|
||||
action="store_true",
|
||||
help="Remove --workdir after completion.",
|
||||
)
|
||||
validate.set_defaults(func=cmd_validate)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
return int(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
204
tools/init_testdata.py
Normal file
204
tools/init_testdata.py
Normal file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Initialize test data folders by archive signatures.
|
||||
|
||||
The script scans all files in --input and copies matching archives into:
|
||||
--output/nres/<relative path>
|
||||
--output/rsli/<relative path>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
MAGIC_NRES = b"NRes"
|
||||
MAGIC_RSLI = b"NL\x00\x01"
|
||||
|
||||
|
||||
def is_relative_to(path: Path, base: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(base)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def detect_archive_type(path: Path) -> str | None:
|
||||
try:
|
||||
with path.open("rb") as handle:
|
||||
magic = handle.read(4)
|
||||
except OSError as exc:
|
||||
print(f"[warn] cannot read {path}: {exc}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
if magic == MAGIC_NRES:
|
||||
return "nres"
|
||||
if magic == MAGIC_RSLI:
|
||||
return "rsli"
|
||||
return None
|
||||
|
||||
|
||||
def scan_archives(input_root: Path, excluded_root: Path | None) -> list[tuple[Path, str]]:
|
||||
found: list[tuple[Path, str]] = []
|
||||
for path in sorted(input_root.rglob("*")):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if excluded_root and is_relative_to(path.resolve(), excluded_root):
|
||||
continue
|
||||
|
||||
archive_type = detect_archive_type(path)
|
||||
if archive_type:
|
||||
found.append((path, archive_type))
|
||||
return found
|
||||
|
||||
|
||||
def confirm_overwrite(path: Path) -> str:
|
||||
prompt = (
|
||||
f"File exists: {path}\n"
|
||||
"Overwrite? [y]es / [n]o / [a]ll / [q]uit (default: n): "
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
answer = input(prompt).strip().lower()
|
||||
except EOFError:
|
||||
return "quit"
|
||||
|
||||
if answer in {"", "n", "no"}:
|
||||
return "no"
|
||||
if answer in {"y", "yes"}:
|
||||
return "yes"
|
||||
if answer in {"a", "all"}:
|
||||
return "all"
|
||||
if answer in {"q", "quit"}:
|
||||
return "quit"
|
||||
print("Please answer with y, n, a, or q.")
|
||||
|
||||
|
||||
def copy_archives(
|
||||
archives: list[tuple[Path, str]],
|
||||
input_root: Path,
|
||||
output_root: Path,
|
||||
force: bool,
|
||||
) -> int:
|
||||
copied = 0
|
||||
skipped = 0
|
||||
overwritten = 0
|
||||
overwrite_all = force
|
||||
|
||||
type_counts = {"nres": 0, "rsli": 0}
|
||||
for _, archive_type in archives:
|
||||
type_counts[archive_type] += 1
|
||||
|
||||
print(
|
||||
f"Found archives: total={len(archives)}, "
|
||||
f"nres={type_counts['nres']}, rsli={type_counts['rsli']}"
|
||||
)
|
||||
|
||||
for source, archive_type in archives:
|
||||
rel_path = source.relative_to(input_root)
|
||||
destination = output_root / archive_type / rel_path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if destination.exists():
|
||||
if destination.is_dir():
|
||||
print(
|
||||
f"[error] destination is a directory, expected file: {destination}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
if not overwrite_all:
|
||||
if not sys.stdin.isatty():
|
||||
print(
|
||||
"[error] destination file exists but stdin is not interactive. "
|
||||
"Use --force to overwrite without prompts.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
decision = confirm_overwrite(destination)
|
||||
if decision == "quit":
|
||||
print("Aborted by user.")
|
||||
return 130
|
||||
if decision == "no":
|
||||
skipped += 1
|
||||
continue
|
||||
if decision == "all":
|
||||
overwrite_all = True
|
||||
|
||||
overwritten += 1
|
||||
|
||||
try:
|
||||
shutil.copy2(source, destination)
|
||||
except OSError as exc:
|
||||
print(f"[error] failed to copy {source} -> {destination}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
copied += 1
|
||||
|
||||
print(
|
||||
f"Done: copied={copied}, overwritten={overwritten}, skipped={skipped}, "
|
||||
f"output={output_root}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Initialize test data by scanning NRes/RsLi signatures."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input",
|
||||
required=True,
|
||||
help="Input directory to scan recursively.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
required=True,
|
||||
help="Output root directory (archives go to nres/ and rsli/ subdirs).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Overwrite destination files without confirmation prompts.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
|
||||
input_root = Path(args.input)
|
||||
if not input_root.exists():
|
||||
print(f"[error] input directory does not exist: {input_root}", file=sys.stderr)
|
||||
return 2
|
||||
if not input_root.is_dir():
|
||||
print(f"[error] input path is not a directory: {input_root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
output_root = Path(args.output)
|
||||
if output_root.exists() and not output_root.is_dir():
|
||||
print(f"[error] output path exists and is not a directory: {output_root}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
input_resolved = input_root.resolve()
|
||||
output_resolved = output_root.resolve()
|
||||
if input_resolved == output_resolved:
|
||||
print("[error] input and output directories must be different.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
excluded_root: Path | None = None
|
||||
if is_relative_to(output_resolved, input_resolved):
|
||||
excluded_root = output_resolved
|
||||
print(f"Notice: output is inside input, skipping scan under: {excluded_root}")
|
||||
|
||||
archives = scan_archives(input_root, excluded_root)
|
||||
|
||||
output_root.mkdir(parents=True, exist_ok=True)
|
||||
return copy_archives(archives, input_root, output_root, force=args.force)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,14 +0,0 @@
|
||||
[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"
|
||||
@@ -1,6 +0,0 @@
|
||||
# Console tool for NRes files (Deprecated)
|
||||
|
||||
## Commands
|
||||
|
||||
- `extract` - Extract game resources from a "NRes" file.
|
||||
- `ls` - Get a list of files in a "NRes" file.
|
||||
@@ -1,198 +0,0 @@
|
||||
extern crate core;
|
||||
extern crate libnres;
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use miette::{IntoDiagnostic, Result};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "NRes CLI")]
|
||||
#[command(about, author, version, long_about = None)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Commands {
|
||||
/// Check if the "NRes" file can be extract
|
||||
Check {
|
||||
/// "NRes" file
|
||||
file: String,
|
||||
},
|
||||
/// Print debugging information on the "NRes" file
|
||||
#[command(arg_required_else_help = true)]
|
||||
Debug {
|
||||
/// "NRes" file
|
||||
file: String,
|
||||
/// Filter results by file name
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
},
|
||||
/// Extract files or a file from the "NRes" file
|
||||
#[command(arg_required_else_help = true)]
|
||||
Extract {
|
||||
/// "NRes" file
|
||||
file: String,
|
||||
/// Overwrite files
|
||||
#[arg(short, long, default_value_t = false, value_name = "TRUE|FALSE")]
|
||||
force: bool,
|
||||
/// Outbound directory
|
||||
#[arg(short, long, value_name = "DIR")]
|
||||
out: String,
|
||||
},
|
||||
/// Print a list of files in the "NRes" file
|
||||
#[command(arg_required_else_help = true)]
|
||||
Ls {
|
||||
/// "NRes" file
|
||||
file: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub fn main() -> Result<()> {
|
||||
let stdout = console::Term::stdout();
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Check { file } => command_check(stdout, file)?,
|
||||
Commands::Debug { file, name } => command_debug(stdout, file, name)?,
|
||||
Commands::Extract { file, force, out } => command_extract(stdout, file, out, force)?,
|
||||
Commands::Ls { file } => command_ls(stdout, file)?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_check(_stdout: console::Term, file: String) -> Result<()> {
|
||||
let file = std::fs::File::open(file).into_diagnostic()?;
|
||||
let list = libnres::reader::get_list(&file).into_diagnostic()?;
|
||||
let tmp = tempdir::TempDir::new("nres").into_diagnostic()?;
|
||||
let bar = indicatif::ProgressBar::new(list.len() as u64);
|
||||
|
||||
bar.set_style(get_bar_style()?);
|
||||
|
||||
for element in list {
|
||||
bar.set_message(element.get_filename());
|
||||
|
||||
let path = tmp.path().join(element.get_filename());
|
||||
let mut output = std::fs::File::create(path).into_diagnostic()?;
|
||||
let mut buffer = libnres::reader::get_file(&file, &element).into_diagnostic()?;
|
||||
|
||||
output.write_all(&buffer).into_diagnostic()?;
|
||||
buffer.clear();
|
||||
bar.inc(1);
|
||||
}
|
||||
|
||||
bar.finish();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_debug(stdout: console::Term, file: String, name: Option<String>) -> Result<()> {
|
||||
let file = std::fs::File::open(file).into_diagnostic()?;
|
||||
let mut list = libnres::reader::get_list(&file).into_diagnostic()?;
|
||||
|
||||
let mut total_files_size: u32 = 0;
|
||||
let mut total_files_gap: u32 = 0;
|
||||
let mut total_files: u32 = 0;
|
||||
|
||||
for (index, item) in list.iter().enumerate() {
|
||||
total_files_size += item.size;
|
||||
total_files += 1;
|
||||
let mut gap = 0;
|
||||
|
||||
if index > 1 {
|
||||
let previous_item = &list[index - 1];
|
||||
gap = item.position - (previous_item.position + previous_item.size);
|
||||
}
|
||||
|
||||
total_files_gap += gap;
|
||||
}
|
||||
|
||||
if let Some(name) = name {
|
||||
list.retain(|item| item.name.contains(&name));
|
||||
};
|
||||
|
||||
for (index, item) in list.iter().enumerate() {
|
||||
let mut gap = 0;
|
||||
|
||||
if index > 1 {
|
||||
let previous_item = &list[index - 1];
|
||||
gap = item.position - (previous_item.position + previous_item.size);
|
||||
}
|
||||
|
||||
let text = format!("Index: {};\nGap: {};\nItem: {:#?};\n", index, gap, item);
|
||||
stdout.write_line(&text).into_diagnostic()?;
|
||||
}
|
||||
|
||||
let text = format!(
|
||||
"Total files: {};\nTotal files gap: {} (bytes);\nTotal files size: {} (bytes);",
|
||||
total_files, total_files_gap, total_files_size
|
||||
);
|
||||
|
||||
stdout.write_line(&text).into_diagnostic()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_extract(_stdout: console::Term, file: String, out: String, force: bool) -> Result<()> {
|
||||
let file = std::fs::File::open(file).into_diagnostic()?;
|
||||
let list = libnres::reader::get_list(&file).into_diagnostic()?;
|
||||
let bar = indicatif::ProgressBar::new(list.len() as u64);
|
||||
|
||||
bar.set_style(get_bar_style()?);
|
||||
|
||||
for element in list {
|
||||
bar.set_message(element.get_filename());
|
||||
|
||||
let path = format!("{}/{}", out, element.get_filename());
|
||||
|
||||
if !force && is_exist_file(&path) {
|
||||
let message = format!("File \"{}\" exists. Overwrite it?", path);
|
||||
|
||||
if !dialoguer::Confirm::new()
|
||||
.with_prompt(message)
|
||||
.interact()
|
||||
.into_diagnostic()?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = std::fs::File::create(path).into_diagnostic()?;
|
||||
let mut buffer = libnres::reader::get_file(&file, &element).into_diagnostic()?;
|
||||
|
||||
output.write_all(&buffer).into_diagnostic()?;
|
||||
buffer.clear();
|
||||
bar.inc(1);
|
||||
}
|
||||
|
||||
bar.finish();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn command_ls(stdout: console::Term, file: String) -> Result<()> {
|
||||
let file = std::fs::File::open(file).into_diagnostic()?;
|
||||
let list = libnres::reader::get_list(&file).into_diagnostic()?;
|
||||
|
||||
for element in list {
|
||||
stdout.write_line(&element.name).into_diagnostic()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_bar_style() -> Result<indicatif::ProgressStyle> {
|
||||
Ok(
|
||||
indicatif::ProgressStyle::with_template("[{bar:32}] {pos:>7}/{len:7} {msg}")
|
||||
.into_diagnostic()?
|
||||
.progress_chars("=>-"),
|
||||
)
|
||||
}
|
||||
|
||||
fn is_exist_file(path: &String) -> bool {
|
||||
let metadata = std::path::Path::new(path);
|
||||
metadata.exists()
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
[package]
|
||||
name = "texture-decoder"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
byteorder = "1.4.3"
|
||||
image = "0.25.0"
|
||||
@@ -1,13 +0,0 @@
|
||||
# Декодировщик текстур
|
||||
|
||||
Сборка:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
./target/release/texture-decoder ./out/AIM_02.0 ./out/AIM_02.0.png
|
||||
```
|
||||
@@ -1,41 +0,0 @@
|
||||
use std::io::Read;
|
||||
|
||||
use byteorder::ReadBytesExt;
|
||||
use image::Rgba;
|
||||
|
||||
fn decode_texture(file_path: &str, output_path: &str) -> Result<(), std::io::Error> {
|
||||
// Читаем файл
|
||||
let mut file = std::fs::File::open(file_path)?;
|
||||
let mut buffer: Vec<u8> = Vec::new();
|
||||
file.read_to_end(&mut buffer)?;
|
||||
|
||||
// Декодируем метаданные
|
||||
let mut cursor = std::io::Cursor::new(&buffer[4..]);
|
||||
let img_width = cursor.read_u32::<byteorder::LittleEndian>()?;
|
||||
let img_height = cursor.read_u32::<byteorder::LittleEndian>()?;
|
||||
|
||||
// Пропустить оставшиеся байты метаданных
|
||||
cursor.set_position(20);
|
||||
|
||||
// Извлекаем данные изображения
|
||||
let image_data = buffer[cursor.position() as usize..].to_vec();
|
||||
let img =
|
||||
image::ImageBuffer::<Rgba<u8>, _>::from_raw(img_width, img_height, image_data.to_vec())
|
||||
.expect("Failed to decode image");
|
||||
|
||||
// Сохраняем изображение
|
||||
img.save(output_path).unwrap();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
let input = &args[1];
|
||||
let output = &args[2];
|
||||
|
||||
if let Err(err) = decode_texture(input, output) {
|
||||
eprintln!("Error: {}", err)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
[package]
|
||||
name = "unpacker"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
byteorder = "1.4.3"
|
||||
serde = { version = "1.0.160", features = ["derive"] }
|
||||
serde_json = "1.0.96"
|
||||
@@ -1,41 +0,0 @@
|
||||
# NRes Game Resource Unpacker
|
||||
|
||||
At the moment, this is a demonstration of the NRes game resource unpacking algorithm in action.
|
||||
It unpacks 100% of the NRes game resources for the game "Parkan: Iron Strategy".
|
||||
The unpacked resources can be packed again using the [packer](../packer) utility and replace the original game files.
|
||||
|
||||
__Attention!__
|
||||
This is a test version of the utility.
|
||||
It overwrites existing files without asking.
|
||||
|
||||
## Building
|
||||
|
||||
To build the tools, you need to run the following command in the root directory:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
You can run the utility with the following command:
|
||||
|
||||
```bash
|
||||
./target/release/unpacker /path/to/file.ex /path/to/output
|
||||
```
|
||||
|
||||
- `/path/to/file.ex`: This is the file containing the game resources that will be unpacked.
|
||||
- `/path/to/output`: This is the directory where the unpacked files will be placed.
|
||||
|
||||
## How it Works
|
||||
|
||||
The structure describing the packed game resources is not fully understood yet.
|
||||
Therefore, the utility saves unpacked files in the format `file_name.file_index` because some files have the same name.
|
||||
|
||||
Additionally, an `index.json` file is created, which is important for re-packing the files.
|
||||
This file lists all the fields that game resources have in their packed form.
|
||||
It is essential to preserve the file index for the game to function correctly, as the game engine looks for the necessary files by index.
|
||||
|
||||
Files can be replaced and packed back using the [packer](../packer).
|
||||
The newly obtained game resource files are correctly processed by the game engine.
|
||||
For example, sounds and 3D models of warbots' weapons were successfully replaced.
|
||||
@@ -1,124 +0,0 @@
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
|
||||
|
||||
use byteorder::{ByteOrder, LittleEndian};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct FileHeader {
|
||||
pub size: u32,
|
||||
pub total: u32,
|
||||
pub type1: u32,
|
||||
pub type2: u32,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct ListElement {
|
||||
pub extension: String,
|
||||
pub index: u32,
|
||||
pub name: String,
|
||||
#[serde(skip_serializing)]
|
||||
pub position: u32,
|
||||
#[serde(skip_serializing)]
|
||||
pub size: u32,
|
||||
pub unknown0: u32,
|
||||
pub unknown1: u32,
|
||||
pub unknown2: u32,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
let input = &args[1];
|
||||
let output = &args[2];
|
||||
|
||||
unpack(String::from(input), String::from(output));
|
||||
}
|
||||
|
||||
fn unpack(input: String, output: String) {
|
||||
let file = File::open(input).unwrap();
|
||||
let metadata = file.metadata().unwrap();
|
||||
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut list: Vec<ListElement> = Vec::new();
|
||||
|
||||
// Считываем заголовок файла
|
||||
let mut header_buffer = [0u8; 16];
|
||||
reader.seek(SeekFrom::Start(0)).unwrap();
|
||||
reader.read_exact(&mut header_buffer).unwrap();
|
||||
|
||||
let file_header = FileHeader {
|
||||
size: LittleEndian::read_u32(&header_buffer[12..16]),
|
||||
total: LittleEndian::read_u32(&header_buffer[8..12]),
|
||||
type1: LittleEndian::read_u32(&header_buffer[0..4]),
|
||||
type2: LittleEndian::read_u32(&header_buffer[4..8]),
|
||||
};
|
||||
|
||||
if file_header.type1 != 1936020046 || file_header.type2 != 256 {
|
||||
panic!("this isn't NRes file");
|
||||
}
|
||||
|
||||
if metadata.len() != file_header.size as u64 {
|
||||
panic!("incorrect size")
|
||||
}
|
||||
|
||||
// Считываем список файлов
|
||||
let list_files_start_position = file_header.size - (file_header.total * 64);
|
||||
let list_files_size = file_header.total * 64;
|
||||
|
||||
let mut list_buffer = vec![0u8; list_files_size as usize];
|
||||
reader
|
||||
.seek(SeekFrom::Start(list_files_start_position as u64))
|
||||
.unwrap();
|
||||
reader.read_exact(&mut list_buffer).unwrap();
|
||||
|
||||
if !list_buffer.len().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