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
|
||||
|
||||
# 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`).
|
||||
|
||||
Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
|
||||
|
||||
## 2.7. Опциональный трейлер медиа (6 байт)
|
||||
|
||||
При открытии с флагом `a2 & 2`:
|
||||
@@ -385,8 +387,8 @@ for i in range(N): # N = unpacked_size (для 0x20) или pack
|
||||
|
||||
Если бит = 0 (ссылка):
|
||||
- Прочитать 2 байта: low_byte, high_byte
|
||||
- offset = low_byte | ((high_byte & 0x0F) << 8) // 12 бит
|
||||
- length = ((high_byte >> 4) & 0x0F) + 3 // 4 бита + 3
|
||||
- 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]
|
||||
@@ -402,10 +404,10 @@ for i in range(N): # N = unpacked_size (для 0x20) или pack
|
||||
|
||||
```
|
||||
Байт 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
|
||||
length = (high >> 4) + 3 // Диапазон: 3–18
|
||||
offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095
|
||||
length = (high & 0x0F) + 3 // Диапазон: 3–18
|
||||
```
|
||||
|
||||
## 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**: 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` к материалам:
|
||||
- это имена материалов?
|
||||
- это имена текстур?
|
||||
- или это имена узлов/анимаций?
|
||||
|
||||
До подтверждения этих пунктов разумнее держать документацию как “архитектурную карту”, а не как точный байтовый формат.
|
||||
@@ -24,6 +24,9 @@ nav:
|
||||
- Home: index.md
|
||||
- Specs:
|
||||
- NRes / RsLi: specs/nres.md
|
||||
- 3D модели: specs/msh.md
|
||||
- Текстуры и материалы: specs/textures.md
|
||||
- Эффекты и частицы: specs/effects.md
|
||||
|
||||
# Additional configuration
|
||||
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