Compare commits
5 Commits
54c94fddb5
...
ef93237724
| Author | SHA1 | Date | |
|---|---|---|---|
|
ef93237724
|
|||
|
58a896221f
|
|||
|
3f48f53bd5
|
|||
|
2953f0c8c9
|
|||
|
022ec608f5
|
140
.gitignore
vendored
140
.gitignore
vendored
@@ -77,4 +77,142 @@ Cargo.lock
|
|||||||
**/*.rs.bk
|
**/*.rs.bk
|
||||||
|
|
||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
*.pdb
|
*.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
|
||||||
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) с материалами/узлами (кто именно как индексирует строки).
|
||||||
@@ -298,6 +298,8 @@ def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
|
|||||||
|
|
||||||
`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
|
`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
|
||||||
|
|
||||||
|
Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
|
||||||
|
|
||||||
## 2.7. Опциональный трейлер медиа (6 байт)
|
## 2.7. Опциональный трейлер медиа (6 байт)
|
||||||
|
|
||||||
При открытии с флагом `a2 & 2`:
|
При открытии с флагом `a2 & 2`:
|
||||||
@@ -385,8 +387,8 @@ for i in range(N): # N = unpacked_size (для 0x20) или pack
|
|||||||
|
|
||||||
Если бит = 0 (ссылка):
|
Если бит = 0 (ссылка):
|
||||||
- Прочитать 2 байта: low_byte, high_byte
|
- Прочитать 2 байта: low_byte, high_byte
|
||||||
- offset = low_byte | ((high_byte & 0x0F) << 8) // 12 бит
|
- offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит
|
||||||
- length = ((high_byte >> 4) & 0x0F) + 3 // 4 бита + 3
|
- length = (high_byte & 0x0F) + 3 // 4 бита + 3
|
||||||
- Скопировать length байт из ring_buffer[offset...]:
|
- Скопировать length байт из ring_buffer[offset...]:
|
||||||
для j от 0 до length-1:
|
для j от 0 до length-1:
|
||||||
byte = ring_buffer[(offset + j) & 0xFFF]
|
byte = ring_buffer[(offset + j) & 0xFFF]
|
||||||
@@ -402,10 +404,10 @@ for i in range(N): # N = unpacked_size (для 0x20) или pack
|
|||||||
|
|
||||||
```
|
```
|
||||||
Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
|
Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
|
||||||
Байт 1 (high): LLLLOOOO L = длина − 3, O = биты [11:8] смещения
|
Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина − 3
|
||||||
|
|
||||||
offset = low | ((high & 0x0F) << 8) // Диапазон: 0–4095
|
offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095
|
||||||
length = (high >> 4) + 3 // Диапазон: 3–18
|
length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||||
```
|
```
|
||||||
|
|
||||||
## 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
|
## 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
|
||||||
@@ -703,3 +705,14 @@ struct RsLibEntry { // 64 байта (16 DWORD)
|
|||||||
- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
|
- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
|
||||||
- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
|
- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
|
||||||
- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
|
- **Данные при комбинированном 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` к материалам:
|
||||||
|
- это имена материалов?
|
||||||
|
- это имена текстур?
|
||||||
|
- или это имена узлов/анимаций?
|
||||||
|
|
||||||
|
До подтверждения этих пунктов разумнее держать документацию как “архитектурную карту”, а не как точный байтовый формат.
|
||||||
@@ -24,6 +24,9 @@ nav:
|
|||||||
- Home: index.md
|
- Home: index.md
|
||||||
- Specs:
|
- Specs:
|
||||||
- NRes / RsLi: specs/nres.md
|
- NRes / RsLi: specs/nres.md
|
||||||
|
- 3D модели: specs/msh.md
|
||||||
|
- Текстуры и материалы: specs/textures.md
|
||||||
|
- Эффекты и частицы: specs/effects.md
|
||||||
|
|
||||||
# Additional configuration
|
# Additional configuration
|
||||||
extra:
|
extra:
|
||||||
|
|||||||
71
tools/README.md
Normal file
71
tools/README.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Инструменты в каталоге `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` включают индекс записи, поэтому коллизии одинаковых имён внутри архива обрабатываются корректно.
|
||||||
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())
|
||||||
Reference in New Issue
Block a user