diff options
| author | Valentin Popov <valentin@popov.link> | 2026-02-11 02:27:43 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-02-11 02:27:43 +0300 |
| commit | 5035d022206bf9ace54a43b4d65abe0b9fc0f361 (patch) | |
| tree | 80e6566ec817e14ed12ce25326a83b3172bca3a2 | |
| parent | ba1789f10607f5a6cba5863128d31f776b8e59cc (diff) | |
| download | fparkan-5035d022206bf9ace54a43b4d65abe0b9fc0f361.tar.xz fparkan-5035d022206bf9ace54a43b4d65abe0b9fc0f361.zip | |
Add MSH geometry export and preview rendering tools
- Implemented msh_export_obj.py for exporting NGI MSH geometry to Wavefront OBJ format, including model selection and geometry extraction.
- Added msh_preview_renderer.py for rendering NGI MSH models to binary PPM images, featuring a primitive software renderer with customizable parameters.
- Both tools utilize the same NRes parsing logic and provide command-line interfaces for listing models and exporting or rendering geometry.
| -rw-r--r-- | docs/specs/effects.md | 69 | ||||
| -rw-r--r-- | docs/specs/msh.md | 1518 | ||||
| -rw-r--r-- | docs/specs/textures.md | 90 | ||||
| -rw-r--r-- | mkdocs.yml | 6 | ||||
| -rw-r--r-- | tools/README.md | 94 | ||||
| -rw-r--r-- | tools/msh_doc_validator.py | 1000 | ||||
| -rw-r--r-- | tools/msh_export_obj.py | 357 | ||||
| -rw-r--r-- | tools/msh_preview_renderer.py | 481 |
8 files changed, 3245 insertions, 370 deletions
diff --git a/docs/specs/effects.md b/docs/specs/effects.md deleted file mode 100644 index 2e9681a..0000000 --- a/docs/specs/effects.md +++ /dev/null @@ -1,69 +0,0 @@ -# Эффекты и частицы - -Пока что — **не байтовая спецификация**, а “карта” по тому, что видно в библиотеках. Полную документацию по эффектам/шейдерам/частицам можно будет сделать после того, как: - -- найдём формат эффекта (файл/ресурс), -- найдём точку загрузки/парсинга, -- найдём точки рендера (создание буферов/вершинного формата/материалов). - ---- - -## 1) Что видно по `Effect.dll` - -- Есть экспорт `CreateFxManager(...)`, который создаёт менеджер эффектов и регистрирует его в движке. -- Внутри много логики “сообщений/команд” через виртуальные вызовы (похоже на общий компонентный интерфейс). -- Явного парсера формата эффекта (по типу “читать заголовок, читать эмиттеры…”) в найденных местах пока не идентифицировано. - ---- - -## 2) Что видно по `Terrain.dll` (рендер‑статистика частиц) - -В `Terrain.dll` есть отладочная/статистическая телеметрия: - -- количество отрендеренных частиц (`Rendered particles`) -- количество батчей (`Rendered batches`) -- количество отрендеренных треугольников - -Это подтверждает: - -- частицы рендерятся батчами, -- они интегрированы в общий 3D‑рендер (через тот же графический слой). - ---- - -## 3) Что важно для совместимости - -Даже без точного формата эффекта, из поведения оригинала следует: - -- Эффекты/частицы завязаны на общий набор рендер‑фич (фильтрация/мультитекстурность/блендинг). -- На слабом железе (и для минимализма) должны работать деградации: - - без мипмапов, - - без bilinear/trilinear, - - без multitexturing, - - возможно с 16‑бит текстурами. - ---- - -## 4) План “докопать” до формата эффектов - -1. Найти **точку создания эффекта по имени/ID**: - - поискать места, где в строки/лог пишется имя эффекта, - - найти функции, которые принимают “путь/имя” и возвращают handle. - -2. Найти **точку загрузки данных**: - - чтение из NRes/RsLi ресурса, - - распаковка/декодирование. - -3. Зафиксировать **структуру данных эффекта в памяти**: - - эмиттеры, - - спауны, - - lifetime, - - ключи размера/цвета, - - привязка к текстурам/материалам. - -4. Найти рендер‑код: - - какой vertex format у частицы, - - как формируются квадраты/ленты (billboard/trail), - - какие state’ы включаются. - -После этого можно будет выпустить полноценный документ “FX format”. diff --git a/docs/specs/msh.md b/docs/specs/msh.md index ae52919..7819569 100644 --- a/docs/specs/msh.md +++ b/docs/specs/msh.md @@ -1,314 +1,1418 @@ -# 3D модели (MSH / AniMesh) +# Форматы 3D‑ресурсов движка NGI -Документ описывает **модельные ресурсы** старого движка по результатам анализа `AniMesh.dll` и сопутствующих библиотек. +## Обзор + +Библиотеки `AniMesh.dll`, `World3D.dll`, `Terrain.dll` и `Effect.dll` реализуют подсистемы трёхмерной графики движка NGI (Nikita Game Interface), используемого в игре *Parkan: Iron Strategy*. Данный документ описывает: + +1. **MSH / AniMesh** — формат 3D‑моделей (геометрия, иерархия узлов, LOD, батчи, анимация). +2. **Материалы** — структура записи материала, система библиотек текстур/палитр, рендер‑конфигурация. +3. **Эффекты и частицы** — бинарный формат `FXID`, разбор команд и runtime‑связывание. + +Все данные хранятся в **little‑endian** порядке (платформа x86/Win32). +Ресурсы моделей читаются из архивов **[NRes](nres.md)**. + +--- + +# Часть 1. Формат 3D‑моделей (MSH / AniMesh) + +## 1.1. Общая архитектура + +Модель состоит из набора именованных ресурсов внутри одного NRes‑архива. Каждый ресурс идентифицируется **целочисленным типом** (`resource_type`), который передаётся API функции `niReadData` (vtable‑метод `+0x18`) через связку `niFind` (vtable‑метод `+0x0C`, `+0x20`). + +Рендер‑модель использует **rigid‑скининг по узлам** (нет per‑vertex bone weights). Каждый batch геометрии привязан к одному узлу и рисуется с матрицей этого узла. + +## 1.2. Общая структура файла модели + +``` +┌────────────────────────────────────┐ +│ NRes‑заголовок (16 байт) │ +├────────────────────────────────────┤ +│ Ресурсы (произвольный порядок): │ +│ Res1 — Node table │ +│ Res2 — Model header + Slots │ +│ Res3 — Vertex positions │ +│ Res4 — Packed normals │ +│ Res5 — Packed UV0 │ +│ Res6 — Index buffer │ +│ Res7 — Triangle descriptors │ +│ Res8 — Keyframe data │ +│ Res10 — String table │ +│ Res13 — Batch table │ +│ Res19 — Animation mapping │ +│ [Res15] — UV1 / доп. поток │ +│ [Res16] — Tangent/Bitangent │ +│ [Res18] — Vertex color │ +│ [Res20] — Доп. таблица │ +├────────────────────────────────────┤ +│ NRes‑каталог │ +└────────────────────────────────────┘ +``` + +Ресурсы в квадратных скобках — **опциональные**. Загрузчик проверяет их наличие перед чтением (`niFindRes` возвращает `−1` при отсутствии). + +## 1.3. Порядок загрузки ресурсов (из `sub_10015FD0` в AniMesh.dll) + +Функция `sub_10015FD0` выполняет инициализацию внутренней структуры модели размером **0xA4** (164 байта). Ниже приведён точный порядок загрузки и маппинг ресурсов на поля структуры: + +| Шаг | Тип ресурса | Поле структуры | Описание | +|-----|-------------|----------------|-----------------------------------------| +| 1 | 1 | `+0x00` | Node table (Res1) | +| 2 | 2 | `+0x04` | Model header (Res2) | +| 3 | 3 | `+0x0C` | Vertex positions (Res3) | +| 4 | 4 | `+0x10` | Packed normals (Res4) | +| 5 | 5 | `+0x14` | Packed UV0 (Res5) | +| 6 | 10 (0x0A) | `+0x20` | String table (Res10) | +| 7 | 8 | `+0x18` | Keyframe / animation track data (Res8) | +| 8 | 19 (0x13) | `+0x1C` | Animation mapping (Res19) | +| 9 | 7 | `+0x24` | Triangle descriptors (Res7) | +| 10 | 13 (0x0D) | `+0x28` | Batch table (Res13) | +| 11 | 6 | `+0x2C` | Index buffer (Res6) | +| 12 | 15 (0x0F) | `+0x34` | Доп. vertex stream (Res15), опционально | +| 13 | 16 (0x10) | `+0x38` | Доп. vertex stream (Res16), опционально | +| 14 | 18 (0x12) | `+0x64` | Vertex color (Res18), опционально | +| 15 | 20 (0x14) | `+0x30` | Доп. таблица (Res20), опционально | + +### Производные поля (вычисляются после загрузки) + +| Поле | Формула | Описание | +|---------|-------------------------|------------------------------------------------------------------------------------------------| +| `+0x08` | `Res2_ptr + 0x8C` | Указатель на slot table (140 байт от начала Res2) | +| `+0x3C` | `= Res3_ptr` | Копия указателя positions (stream ptr) | +| `+0x40` | `= 0x0C` (12) | Stride позиций: `sizeof(float3)` | +| `+0x44` | `= Res4_ptr` | Копия указателя normals (stream ptr) | +| `+0x48` | `= 4` | Stride нормалей: 4 байта | +| `+0x4C` | `Res16_ptr` или `0` | Stream A Res16 (tangent) | +| `+0x50` | `= 8` если `+0x4C != 0` | Stride stream A (используется только при наличии Res16) | +| `+0x54` | `Res16_ptr + 4` или `0` | Stream B Res16 (bitangent) | +| `+0x58` | `= 8` если `+0x54 != 0` | Stride stream B (используется только при наличии Res16) | +| `+0x5C` | `= Res5_ptr` | Копия указателя UV0 (stream ptr) | +| `+0x60` | `= 4` | Stride UV0: 4 байта | +| `+0x68` | `= 4` или `0` | Stride Res18 (если найден) | +| `+0x8C` | `= Res15_ptr` | Копия указателя Res15 | +| `+0x90` | `= 8` | Stride Res15: 8 байт | +| `+0x94` | `= 0` | Зарезервировано/unk94: инициализируется нулём при загрузке; не является флагом Res18 | +| `+0x9C` | NRes entry Res19 `+8` | Метаданные из каталожной записи Res19 | +| `+0xA0` | NRes entry Res20 `+4` | Метаданные из каталожной записи Res20 (заполняется только если Res20 найден и открыт, иначе 0) | + +**Примечание к метаданным:** поле `+0x9C` читается из каталожной записи NRes для ресурса 19 (смещение `+8` в записи каталога, т.е. `attribute_2`). Поле `+0xA0` — из каталожной записи для ресурса 20 (смещение `+4`, т.е. `attribute_1`) **только если Res20 найден и `niOpenRes` вернул ненулевой указатель**; иначе `+0xA0 = 0`. Индекс записи определяется как `entry_index * 64`, после чего считывается поле. --- -## 0) Термины +### 1.3.1. Ссылки на функции и паттерны вызовов (для проверки реверса) + +- `AniMesh.dll!sub_10015FD0` — загрузка ресурсов модели через vtable интерфейса NRes: + - `niFindRes(type, ...)` вызывается через `call [vtable+0x20]` + - `niOpenRes(...)` / чтение указателя — через `call [vtable+0x18]` +- `AniMesh.dll!sub_10015FD0` выставляет производные поля (`Res2_ptr+0x8C`, stride'ы), обнуляет `model+0x94`, и при отсутствии Res16 обнуляет только указатели потоков (`+0x4C`, `+0x54`). +- `AniMesh.dll!sub_10004840` / `sub_10004870` / `sub_100048A0` — использование runtime mapping‑таблицы (`+0x18`, индекс `boneId*4`) и таблицы указателей треков (`+0x08`) после построения анимационного объекта. + + +## 1.4. Ресурс Res2 — Model Header (140 байт) + Slot Table + +Ресурс Res2 содержит: + +``` +┌───────────────────────────────────┐ Смещение 0 +│ Model Header (140 байт = 0x8C) │ +├───────────────────────────────────┤ Смещение 140 (0x8C) +│ Slot Table │ +│ (slot_count × 68 байт) │ +└───────────────────────────────────┘ +``` + +### 1.4.1. Model Header (первые 140 байт) + +Поле `Res2[0x00..0x8B]` используется как **35 float** (без внутренних таблиц/индексов). Это подтверждено прямыми копированиями в `AniMesh.dll!sub_1000A460`: + +- `qmemcpy(this+0x54, Res2+0x00, 0x60)` — первые 24 float; +- копирование `Res2+0x60` размером `0x10` — ещё 4 float; +- `qmemcpy(this+0x134, Res2+0x70, 0x1C)` — ещё 7 float. + +Итоговая раскладка: + +| Диапазон | Размер | Тип | Семантика | +|--------------|--------|-------------|----------------------------------------------------------------------| +| `0x00..0x5F` | `0x60` | `float[24]` | 8 вершин глобального bounding‑hull (`vec3[8]`) | +| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная bounding‑sphere: `center.xyz + radius` | +| `0x70..0x8B` | `0x1C` | `float[7]` | Глобальный «капсульный»/сегментный bound: `A.xyz`, `B.xyz`, `radius` | -- **Модель** — набор геометрии + иерархия узлов (node/bone) + дополнительные таблицы (батчи/слоты/треки). -- **Node** — узел иерархии (часть/кость). Визуально: “кусок” модели, которому можно применять transform (rigid). -- **LOD** — уровень детализации. В коде обнаружены **3 уровня LOD: 0..2** (и “текущий” LOD через `-1`). -- **Slot** — связка “(node, LOD, group) → диапазоны геометрии + bounds”. -- **Batch** — рендер‑пакет: “материал + диапазон индексов + baseVertex”. +Для рендера и broadphase движок использует как слот‑bounds (`Res2 slot`), так и этот глобальный набор bounds (в зависимости от контекста вызова/LOD и наличия слота). + +### 1.4.2. Slot Table (массив записей по 68 байт) + +Slot — ключевая структура, связывающая узел иерархии с конкретной геометрией для конкретного LOD и группы. Каждая запись — **68 байт** (0x44). + +**Важно:** смещения в таблице ниже указаны в **десятичном формате** (байты). В скобках приведён hex‑эквивалент (например, 48 (0x30)). + + +| Смещение | Размер | Тип | Описание | +|-----------|--------|----------|-----------------------------------------------------| +| 0 | 2 | uint16 | `triStart` — индекс первого треугольника в Res7 | +| 2 | 2 | uint16 | `triCount` — длина диапазона треугольников (`Res7`) | +| 4 | 2 | uint16 | `batchStart` — индекс первого batch'а в Res13 | +| 6 | 2 | uint16 | `batchCount` — количество batch'ей | +| 8 | 4 | float | `aabbMin.x` | +| 12 | 4 | float | `aabbMin.y` | +| 16 | 4 | float | `aabbMin.z` | +| 20 | 4 | float | `aabbMax.x` | +| 24 | 4 | float | `aabbMax.y` | +| 28 | 4 | float | `aabbMax.z` | +| 32 | 4 | float | `sphereCenter.x` | +| 36 | 4 | float | `sphereCenter.y` | +| 40 | 4 | float | `sphereCenter.z` | +| 44 (0x2C) | 4 | float | `sphereRadius` | +| 48 (0x30) | 20 | 5×uint32 | Хвостовые поля: `unk30..unk40` (см. §1.4.2.1) | + +**AABB** — axis‑aligned bounding box в локальных координатах узла. +**Bounding Sphere** — описанная сфера в локальных координатах узла. + +#### 1.4.2.1. Точная семантика `triStart/triCount` + +В `AniMesh.dll!sub_1000B2C0` слот считается «владельцем» треугольника `triId`, если: + +```c +triId >= slot.triStart && triId < slot.triStart + slot.triCount +``` + +Это прямое доказательство, что `slot +0x02` — именно **count диапазона**, а не флаги. + +#### 1.4.2.2. Хвост слота (20 байт = 5×uint32) + +Последние 20 байт записи слота трактуем как 5 последовательных 32‑битных значений (little‑endian). Их назначение пока не подтверждено; для инструментов рекомендуется сохранять и восстанавливать их «как есть». + +- `+48 (0x30)`: `unk30` (uint32) +- `+52 (0x34)`: `unk34` (uint32) +- `+56 (0x38)`: `unk38` (uint32) +- `+60 (0x3C)`: `unk3C` (uint32) +- `+64 (0x40)`: `unk40` (uint32) + +Для culling при рендере: AABB/sphere трансформируются матрицей узла и инстанса. При неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (подтверждено по коду). --- -## 1) Архитектура модели в движке (как это реально рисуется) +### 1.4.3. Восстановление счётчиков элементов по размерам ресурсов (практика для инструментов) + +Для toolchain надёжнее считать count'ы по размерам ресурсов (а не по дублирующим полям других таблиц). Это полностью совпадает с тем, как рантайм использует fixed stride'ы в `sub_10015FD0`. + +Берите **unpacked_size** (или фактический размер распакованного блока) соответствующего ресурса и вычисляйте: -### 1.1 Рендер‑модель: rigid‑скининг (по узлам), без весов вершин +- `node_count` = `size(Res1) / 38` +- `vertex_count` = `size(Res3) / 12` +- `normals_count` = `size(Res4) / 4` +- `uv0_count` = `size(Res5) / 4` +- `index_count` = `size(Res6) / 2` +- `tri_count` = `index_count / 3` (если примитивы — список треугольников) +- `tri_desc_count` = `size(Res7) / 16` +- `batch_count` = `size(Res13) / 20` +- `slot_count` = `(size(Res2) - 0x8C) / 0x44` +- `anim_key_count` = `size(Res8) / 24` +- `anim_map_count` = `size(Res19) / 2` +- `uv1_count` = `size(Res15) / 8` (если Res15 присутствует) +- `tbn_count` = `size(Res16) / 8` (если Res16 присутствует; tangent/bitangent по 4 байта, stride 8) +- `color_count` = `size(Res18) / 4` (если Res18 присутствует) -По коду выборка геометрии делается так: +**Валидация:** -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’е) +- Любое деление должно быть **без остатка**; иначе ресурс повреждён или stride неверно угадан. +- Если присутствуют Res4/Res5/Res15/Res16/Res18, их count'ы по смыслу должны совпадать с `vertex_count` (или быть ≥ него, если формат допускает хвостовые данные — пока не наблюдалось). +- Для `slot_count` дополнительно проверьте, что `size(Res2) >= 0x8C`. -**Важно:** в “модельном” формате не видно классических skin weights (4 bone indices + 4 weights). Это очень похоже на “rigid parts”: каждый batch/часть привязан к одному узлу (или группе узлов) и рендерится с матрицей этого узла. +**Проверка на реальных данных (435 MSH):** + +- `Res2.attr1 == (size-140)/68`, `Res2.attr2 == 0`, `Res2.attr3 == 68`; +- `Res7.attr1 == size/16`, `Res7.attr3 == 16`; +- `Res8.attr1 == size/24`, `Res8.attr3 == 4`; +- `Res19.attr1 == size/2`, `Res19.attr3 == 2`; +- для `Res1` почти всегда `attr3 == 38` (один служебный outlier: `MTCHECK.MSH` с `attr3 == 24`). + +Эти формулы достаточны, чтобы реализовать распаковщик/просмотрщик геометрии и батчей даже без полного понимания полей заголовка Res2. + +## 1.5. Ресурс Res1 — Node Table (38 байт на узел) + +Node table — компактная карта слотов по уровням LOD и группам. Каждый узел занимает **38 байт** (19 × `uint16`). + +### Адресация слота + +Движок вычисляет индекс слова в таблице: + +``` +word_index = nodeIndex × 19 + lod × 5 + group + 4 +slot_index = node_table[word_index] // uint16, 0xFFFF = нет слота +``` + +Параметры: + +- `lod`: 0..2 (три уровня детализации). Значение `−1` → подставляется `current_lod` из инстанса. +- `group`: 0..4 (пять групп). На практике чаще всего используется `group = 0`. + +### Раскладка записи узла (38 байт) + +``` +┌───────────────────────────────────────────────────────┐ +│ Header: 4 × uint16 (8 байт) │ +│ hdr0, hdr1, hdr2, hdr3 │ +├───────────────────────────────────────────────────────┤ +│ SlotIndex matrix: 3 LOD × 5 groups = 15 × uint16 │ +│ LOD 0: group[0..4] │ +│ LOD 1: group[0..4] │ +│ LOD 2: group[0..4] │ +└───────────────────────────────────────────────────────┘ +``` + +| Смещение | Размер | Тип | Описание | +|----------|--------|------------|-----------------------------------------| +| 0 | 8 | uint16[4] | Заголовок узла (`hdr0..hdr3`, см. ниже) | +| 8 | 30 | uint16[15] | Матрица слотов: `slotIndex[lod][group]` | + +`slotIndex = 0xFFFF` означает «слот отсутствует» — узел при данном LOD и группе не рисуется. + +Подтверждённые семантики полей `hdr*`: + +- `hdr1` (`+0x02`) — parent/index-link при построении инстанса (в `sub_1000A460` читается как индекс связанного узла, `0xFFFF` = нет связи). +- `hdr2` (`+0x04`) — `mapStart` для Res19 (`0xFFFF` = нет карты; fallback по `hdr3`). +- `hdr3` (`+0x06`) — `fallbackKeyIndex`/верхняя граница для map‑значений (используется в `sub_10012880`). + +`hdr0` (`+0x00`) по коду участвует в битовых проверках (`&0x40`, `byte+1 & 8`) и несёт флаги узла. + +**Группы (group 0..4):** в рантайме это ортогональный индекс к LOD (матрица 5×3 на узел). Имена групп в оригинальных ресурсах не подписаны; для 1:1 нужно сохранять группы как «сырой» индекс 0..4 без переинтерпретации. + +--- + +## 1.6. Ресурс Res3 — Vertex Positions + +**Формат:** массив `float3` (IEEE 754 single‑precision). +**Stride:** 12 байт. + +```c +struct Position { + float x; // +0 + float y; // +4 + float z; // +8 +}; +``` + +Чтение: `pos = *(float3*)(res3_data + 12 * vertexIndex)`. --- -## 2) Набор ресурсов модели (что лежит внутри “файла модели”) +## 1.7. Ресурс Res4 — Packed Normals + +**Формат:** 4 байта на вершину. +**Stride:** 4 байта. -Ниже перечислены ресурсы, которые гарантированно встречаются в загрузчике `AniMesh`: +```c +struct PackedNormal { + int8_t nx; // +0 + int8_t ny; // +1 + int8_t nz; // +2 + int8_t nw; // +3 (назначение не подтверждено: паддинг / знак / индекс) +}; +``` -- **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; точная семантика пока не восстановлена). +### Алгоритм декодирования (подтверждено по AniMesh.dll) -Опциональные (встречаются условно, если ресурс присутствует): +> В движке используется делитель **127.0**, а не 128.0 (см. константу `127.0` рядом с `1024.0`/`32767.0`). -- **Res15** — per‑vertex stream, stride 8 (семантика не подтверждена). -- **Res16** — per‑vertex stream, stride 8, при этом движок создаёт **два “под‑потока” по 4 байта** (см. ниже). -- **Res18** — per‑vertex stream, stride 4 (семантика не подтверждена). -- **Res20** — дополнительный массив + отдельное “count/meta” поле из заголовка ресурса. +``` +normal.x = clamp((float)nx / 127.0, -1.0, 1.0) +normal.y = clamp((float)ny / 127.0, -1.0, 1.0) +normal.z = clamp((float)nz / 127.0, -1.0, 1.0) +``` + +**Множитель:** `1.0 / 127.0 ≈ 0.0078740157`. +**Диапазон входных значений:** −128..+127 → выход ≈ −1.007874..+1.0 → **после клампа** −1.0..+1.0. +**Почему нужен кламп:** значение `-128` при делении на `127.0` даёт модуль чуть больше 1. +**4‑й байт (nw):** используется ли он как часть нормали, как индекс или просто как выравнивание — не подтверждено. Рекомендация: игнорировать при первичном импорте. --- -## 3) Декодирование базовой геометрии +## 1.8. Ресурс Res5 — Packed UV0 + +**Формат:** 4 байта на вершину (два `int16`). +**Stride:** 4 байта. -### 3.1 Positions (Res3) +```c +struct PackedUV { + int16_t u; // +0 + int16_t v; // +2 +}; +``` -- Структура: массив `float3`. -- Stride: `12`. -- Использование: `pos = *(float3*)(res3 + 12*vertexIndex)`. +### Алгоритм декодирования -### 3.2 UV0 (Res5) — packed s16 +``` +uv.u = (float)u / 1024.0 +uv.v = (float)v / 1024.0 +``` -- Stride: `4`. -- Формат: `int16 u, int16 v` -- Нормализация (из кода): `uv = (u, v) * (1/1024)` +**Множитель:** `1.0 / 1024.0 = 0.0009765625`. +**Диапазон входных значений:** −32768..+32767 → выход ≈ −32.0..+31.999. +Значения >1.0 или <0.0 означают wrapping/repeat текстурных координат. -То есть: +### Алгоритм кодирования (для экспортёра) -- `u_float = (int16)u / 1024.0` -- `v_float = (int16)v / 1024.0` +``` +packed_u = (int16_t)round(uv.u * 1024.0) +packed_v = (int16_t)round(uv.v * 1024.0) +``` -### 3.3 Normals (Res4) — packed s8 +Результат обрезается (clamp) до диапазона `int16` (−32768..+32767). + +--- -- Stride: `4`. -- Формат (минимально подтверждено): `int8 nx, int8 ny, int8 nz, int8 nw(?)` -- Нормализация (из кода): множитель `1/128 = 0.0078125` +## 1.9. Ресурс Res6 — Index Buffer -То есть: +**Формат:** массив `uint16` (беззнаковые 16‑битные индексы). +**Stride:** 2 байта. -- `n = (nx, ny, nz) / 128.0` +Максимальное число вершин в одном batch: 65535. +Индексы используются совместно с `baseVertex` из batch table: -4‑й байт пока не подтверждён (встречается как паддинг/знак/индекс — нужно дальше копать). +``` +actual_vertex_index = index_buffer[indexStart + i] + baseVertex +``` --- -## 4) Таблицы, задающие разбиение геометрии +## 1.10. Ресурс Res7 — Triangle Descriptors -### 4.1 Batch table (Res13), запись 20 байт +**Формат:** массив записей по 16 байт. Одна запись на треугольник. -Batch используется в рендере и в обходе треугольников. Из обхода достоверно: +| Смещение | Размер | Тип | Описание | +|----------|--------|----------|---------------------------------------------| +| `+0x00` | 2 | `uint16` | `triFlags` — фильтрация/материал tri‑уровня | +| `+0x02` | 2 | `uint16` | `linkTri0` — tri‑ref для связанного обхода | +| `+0x04` | 2 | `uint16` | `linkTri1` — tri‑ref для связанного обхода | +| `+0x06` | 2 | `uint16` | `linkTri2` — tri‑ref для связанного обхода | +| `+0x08` | 2 | `int16` | `nX` (packed, scale `1/32767`) | +| `+0x0A` | 2 | `int16` | `nY` (packed, scale `1/32767`) | +| `+0x0C` | 2 | `int16` | `nZ` (packed, scale `1/32767`) | +| `+0x0E` | 2 | `uint16` | `selPacked` — 3 селектора по 2 бита | -- `indexCount` читается как `u16` по смещению `+8`. -- `indexStart` используется как **u32 по смещению `+10`** (движок читает dword и умножает на 2 для смещения в u16‑индексах). -- `baseVertex` читается как `u32` по смещению `+16`. +Расшифровка `selPacked` (`AniMesh.dll!sub_10013680`): -Рекомендуемая реконструкция: +```c +sel0 = selPacked & 0x3; if (sel0 == 3) sel0 = 0xFFFF; +sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF; +sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF; +``` -- `+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` — смещение вершинного индекса (в вершинах). +`linkTri*` передаются в `sub_1000B2C0` и используются для построения соседнего набора треугольников при коллизии/пикинге. + +**Важно:** дескрипторы не хранят индексы вершин треугольника. Индексы берутся из Res6 (index buffer) через `indexStart`/`indexCount` соответствующего batch'а. + +Дескрипторы используются при обходе треугольников для коллизии и пикинга. `triStart` из slot table указывает, с какого дескриптора начинать обход для данного слота. + +--- -### 4.2 Triangle descriptors (Res7), запись 16 байт +## 1.11. Ресурс Res13 — Batch Table -Треугольные дескрипторы используются при итерации треугольников (коллизии/выбор/тесты): +**Формат:** массив записей по 20 байт. Batch — минимальная единица отрисовки. -- `+0 u16 triFlags` — используется для фильтрации (битовая маска) -- Остальные поля пока не подтверждены (вероятно: доп. флаги, группа, precomputed normal, ID поверхности и т.п.) +| Смещение | Размер | Тип | Описание | +|----------|--------|--------|---------------------------------------------------------| +| 0 | 2 | uint16 | `batchFlags` — битовая маска для фильтрации | +| 2 | 2 | uint16 | `materialIndex` — индекс материала | +| 4 | 2 | uint16 | `unk4` — неподтверждённое поле | +| 6 | 2 | uint16 | `unk6` — вероятный `nodeIndex` (привязка batch к кости) | +| 8 | 2 | uint16 | `indexCount` — число индексов (кратно 3) | +| 10 | 4 | uint32 | `indexStart` — стартовый индекс в Res6 (в элементах) | +| 14 | 2 | uint16 | `unk14` — неподтверждённое поле | +| 16 | 4 | uint32 | `baseVertex` — смещение вершинного индекса | -**Важно:** индексы вершин треугольника берутся **из index buffer (Res6)** через `indexStart/indexCount` batch’а. TriDesc не хранит сами индексы. +### Использование при рендере + +``` +for i in 0 .. indexCount-1: + raw_index = index_buffer[indexStart + i] + vertex_index = raw_index + baseVertex + position = res3[vertex_index] + normal = decode_normal(res4[vertex_index]) + uv = decode_uv(res5[vertex_index]) +``` + +**Примечание:** движок читает `indexStart` как `uint32` и умножает на 2 для получения байтового смещения в массиве `uint16`. + +--- + +## 1.12. Ресурс Res10 — String Table + +Res10 — это **последовательность записей, индексируемых по `nodeIndex`** (см. `AniMesh.dll!sub_10012530`). + +Формат одной записи: + +```c +struct Res10Record { + uint32_t len; // число символов без терминирующего '\0' + char text[]; // если len > 0: хранится len+1 байт (включая '\0') + // если len == 0: payload отсутствует +}; +``` + +Переход к следующей записи: + +```c +next = cur + 4 + (len ? (len + 1) : 0); +``` + +`sub_10012530` возвращает: + +- `NULL`, если `len == 0`; +- `record + 4`, если `len > 0` (указатель на C‑строку). + +Это значение используется в `sub_1000A460` для проверки имени текущего узла (например, поиск подстроки `"central"` при обработке node‑флагов). --- -## 5) Slot table (Res2 + смещение 140), запись 68 байт +## 1.13. Ресурсы анимации: Res8 и Res19 + +- **Res8** — массив анимационных ключей фиксированного размера 24 байта. +- **Res19** — `uint16` mapping‑массив «frame → keyIndex` (с per-node смещением). + +### 1.13.1. Формат Res8 (ключ 24 байта) + +```c +struct AnimKey24 { + float posX; // +0x00 + float posY; // +0x04 + float posZ; // +0x08 + float time; // +0x0C + int16_t qx; // +0x10 + int16_t qy; // +0x12 + int16_t qz; // +0x14 + int16_t qw; // +0x16 +}; +``` + +Декодирование quaternion-компонент: + +```c +q = s16 * (1.0f / 32767.0f) +``` + +### 1.13.2. Формат Res19 + +Res19 читается как непрерывный массив `uint16`: + +```c +uint16_t map[]; // размер = size(Res19)/2 +``` + +Per-node управление mapping'ом берётся из заголовка узла Res1: + +- `node.hdr2` (`Res1 + 0x04`) = `mapStart` (`0xFFFF` => map отсутствует); +- `node.hdr3` (`Res1 + 0x06`) = `fallbackKeyIndex` и одновременно верхняя граница валидного `map`‑значения. + +### 1.13.3. Выбор ключа для времени `t` (`sub_10012880`) + +1) Вычислить frame‑индекс: + +```c +frame = (int64)(t - 0.5f); // x87 FISTP-путь +``` + +Для строгой 1:1 эмуляции используйте именно поведение x87 `FISTP` (а не «упрощённый floor»), т.к. путь в оригинале опирается на FPU rounding mode. + +2) Проверка условий fallback: + +- `frame >= model.animFrameCount` (`model+0x9C`, из `NResEntry(Res19).attr2`); +- `mapStart == 0xFFFF`; +- `map[mapStart + frame] >= fallbackKeyIndex`. + +Если любое условие истинно: + +```c +keyIndex = fallbackKeyIndex; +``` + +Иначе: + +```c +keyIndex = map[mapStart + frame]; +``` + +3) Сэмплирование: + +- `k0 = Res8[keyIndex]` +- `k1 = Res8[keyIndex + 1]` (для интерполяции сегмента) + +Пути: + +- если `t == k0.time` → взять `k0`; +- если `t == k1.time` → взять `k1`; +- иначе `alpha = (t - k0.time) / (k1.time - k0.time)`, `pos = lerp(k0.pos, k1.pos, alpha)`, rotation смешивается через fastproc‑интерполятор quaternion. + +### 1.13.4. Межкадровое смешивание (`sub_10012560`) + +Функция смешивает два сэмпла (например, из двух animation time-позиций) с коэффициентом `blend`: + +1) получить два `(quat, pos)` через `sub_10012880`; +2) выполнить shortest‑path коррекцию знака quaternion: + +```c +if (|q0 + q1|^2 < |q0 - q1|^2) q1 = -q1; +``` + +3) смешать quaternion (fastproc) и построить orientation‑матрицу; +4) translation писать отдельно как `lerp(pos0, pos1, blend)` в ячейки `m[3], m[7], m[11]`. + +### 1.13.5. Что хранится в `Res19.attr2` + +При загрузке `sub_10015FD0` записывает `NResEntry(Res19).attr2` в `model+0x9C`. +Это поле используется как верхняя граница frame‑индекса в п.1.13.3. + +--- + +## 1.14. Опциональные vertex streams + +### Res15 — Дополнительный vertex stream (stride 8) + +- **Stride:** 8 байт на вершину. +- **Кандидаты:** `float2 uv1` (lightmap / second UV layer), 4 × `int16` (2 UV‑пары), либо иной формат. +- Загружается условно — если ресурс 15 отсутствует, указатель равен `NULL`. + +### Res16 — Tangent / Bitangent (stride 8, split 2×4) + +- **Stride:** 8 байт на вершину (2 подпотока по 4 байта). +- При загрузке движок создаёт **два перемежающихся (interleaved) подпотока**: + - Stream A: `base + 0`, stride 8 — 4 байта (кандидат: packed tangent, `int8 × 4`) + - Stream B: `base + 4`, stride 8 — 4 байта (кандидат: packed bitangent, `int8 × 4`) +- Если ресурс 16 отсутствует, оба указателя обнуляются. +- **Важно:** в оригинальном `sub_10015FD0` при отсутствии Res16 страйды `+0x50/+0x58` явным образом не обнуляются; это безопасно, потому что оба указателя равны `NULL` и код не должен обращаться к потокам без проверки указателя. +- Декодирование предположительно аналогично нормалям: `component / 127.0` (как Res4), но требует подтверждения; при импорте — кламп в [-1..1]. + +### Res18 — Vertex Color (stride 4) + +- **Stride:** 4 байта на вершину. +- **Кандидаты:** `D3DCOLOR` (BGRA), packed параметры освещения, vertex AO. +- Загружается условно (через проверку `niFindRes` на возврат `−1`). + +### Res20 — Дополнительная таблица + +- Присутствует не всегда. +- Из каталожной записи NRes считывается поле `attribute_1` (смещение `+4`) и сохраняется как метаданные. +- **Кандидаты:** vertex remap, дополнительные данные для эффектов/деформаций. + +--- + +## 1.15. Алгоритм рендера модели (реконструкция) + +``` +Вход: model, instanceTransform, cameraFrustum + +1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам). + +2. Для каждого node (nodeIndex = 0 .. nodeCount−1): + a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform + + b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0] + если slotIndex == 0xFFFF → пропустить узел + + c. slot = slotTable[slotIndex] -Slot — ключевая структура, по которой движок: + d. // Frustum culling: + transformedAABB = transform(slot.aabb, nodeTransform) + если transformedAABB вне cameraFrustum → пропустить -- получает bounds (AABB + sphere), -- получает диапазон batch’ей для рендера/обхода, -- получает стартовый индекс треугольников (triStart) в TriDesc. + // Альтернативно по сфере: + transformedCenter = nodeTransform × slot.sphereCenter + scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ) + если сфера вне frustum → пропустить -В коде Slot читается как `u16`‑поля + как `float`‑поля (AABB/sphere). Подтверждённая раскладка: + e. Для i = 0 .. slot.batchCount − 1: + batch = batchTable[slot.batchStart + i] -### 5.1 Заголовок slot (первые 8 байт) + // Фильтрация по batchFlags (если нужна) -- `+0 u16 triStart` — индекс первого треугольника в `Res7` (TriDesc), используемый в обходе. -- `+2 u16 slotFlagsOrUnk` — пока не восстановлено (не путать с batchFlags/triFlags). -- `+4 u16 batchStart` — индекс первого batch’а в `Res13`. -- `+6 u16 batchCount` — количество batch’ей. + // Установить материал: + setMaterial(batch.materialIndex) -### 5.2 AABB (локальные границы, 24 байта) + // Установить transform: + setWorldMatrix(nodeTransform) -- `+8 float aabbMin.x` -- `+12 float aabbMin.y` -- `+16 float aabbMin.z` -- `+20 float aabbMax.x` -- `+24 float aabbMax.y` -- `+28 float aabbMax.z` + // Нарисовать: + DrawIndexedPrimitive( + baseVertex = batch.baseVertex, + indexStart = batch.indexStart, + indexCount = batch.indexCount, + primitiveType = TRIANGLE_LIST + ) +``` -### 5.3 Bounding sphere (локальные границы, 16 байт) +--- + +## 1.16. Алгоритм обхода треугольников (коллизия / пикинг) + +``` +Вход: model, nodeIndex, lod, group, filterMask, callback + +1. slotIndex = nodeTable[nodeIndex].slotMatrix[lod][group] + если slotIndex == 0xFFFF → выход + +2. slot = slotTable[slotIndex] + triDescIndex = slot.triStart + +3. Для каждого batch в диапазоне [slot.batchStart .. slot.batchStart + slot.batchCount − 1]: + batch = batchTable[batchIndex] + triCount = batch.indexCount / 3 // округление: (indexCount + 2) / 3 + + Для t = 0 .. triCount − 1: + triDesc = triDescTable[triDescIndex] + + // Фильтрация: + если (triDesc.triFlags & filterMask) → пропустить -- `+32 float sphereCenter.x` -- `+36 float sphereCenter.y` -- `+40 float sphereCenter.z` -- `+44 float sphereRadius` + // Получить индексы вершин: + idx0 = indexBuffer[batch.indexStart + t*3 + 0] + batch.baseVertex + idx1 = indexBuffer[batch.indexStart + t*3 + 1] + batch.baseVertex + idx2 = indexBuffer[batch.indexStart + t*3 + 2] + batch.baseVertex -### 5.4 Хвост (20 байт) + // Получить позиции: + p0 = positions[idx0] + p1 = positions[idx1] + p2 = positions[idx2] -- `+48..+67` — не используется в найденных вызовах bounds/рендера; назначение неизвестно. Возможные кандидаты: LOD‑дистанции, доп. bounds, служебные поля экспортёра. + callback(triDesc, idx0, idx1, idx2, p0, p1, p2) + + triDescIndex += 1 +``` --- -## 6) Node table (Res1), запись 19 \* u16 на узел (38 байт) +# Часть 2. Материалы и текстуры + +## 2.1. Архитектура материальной системы + +Материальная подсистема реализована в `World3D.dll` и включает: + +- **Менеджер материалов** (`LoadMatManager`) — объект размером 0x470 байт (1136), хранящий до 140 таблиц материалов (поле `+572`, `this[143]`). +- **Библиотека палитр** (`SetPalettesLib`) — NRes‑архив с палитрами. +- **Библиотека текстур** (`SetTexturesLib`) — путь к файлу/каталогу текстур. +- **Библиотека материалов** (`SetMaterialLib`) — NRes‑архив с данными материалов. +- **Библиотека lightmap'ов** (`SetLightMapLib`) — опциональная. + +### Загрузка палитр (sub_10002B40) + +Палитры загружаются из NRes‑архива по именам. Система перебирает буквы `'A'`..'Z'` (26 категорий) × 11 суффиксов, формируя имена вида `"A<suffix>.pal"`. Каждая палитра загружается через `niOpenResFile` → `niReadData` и регистрируется как текстурный объект в графическом движке. + +Максимальное количество палитр: 26 × 11 = **286**. + +## 2.2. Запись материала (76 байт) -Node table — это не “матрицы узлов”, а компактная карта слотов по LOD и группам. +Материал представлен записью размером **76 байт** (19 DWORD). Поля восстановлены из функции интерполяции `sub_10003030` и функций `sub_100031F0` / `sub_10003680`. -Движок вычисляет адрес слова так: +| Смещение | Размер | Тип | Интерполяция | Описание | +|----------|--------|--------|--------------|--------------------------------------| +| 0 | 4 | uint32 | Нет | `flags` — тип/режим материала | +| 4 | 4 | float | Бит 1 (0x02) | Цветовой компонент A — R | +| 8 | 4 | float | Бит 1 (0x02) | Цветовой компонент A — G | +| 12 | 4 | float | Бит 1 (0x02) | Цветовой компонент A — B | +| 16 | 4 | — | Нет | Зарезервировано / паддинг | +| 20 | 4 | float | Бит 0 (0x01) | Цветовой компонент B — R | +| 24 | 4 | float | Бит 0 (0x01) | Цветовой компонент B — G | +| 28 | 4 | float | Бит 0 (0x01) | Цветовой компонент B — B | +| 32 | 4 | float | Бит 4 (0x10) | Скалярный параметр (power / opacity) | +| 36 | 4 | float | Бит 2 (0x04) | Цветовой компонент C — R | +| 40 | 4 | float | Бит 2 (0x04) | Цветовой компонент C — G | +| 44 | 4 | float | Бит 2 (0x04) | Цветовой компонент C — B | +| 48 | 4 | — | Нет | Зарезервировано / паддинг | +| 52 | 4 | float | Бит 3 (0x08) | Цветовой компонент D — R | +| 56 | 4 | float | Бит 3 (0x08) | Цветовой компонент D — G | +| 60 | 4 | float | Бит 3 (0x08) | Цветовой компонент D — B | +| 64 | 4 | — | Нет | Зарезервировано / паддинг | +| 68 | 4 | int32 | Нет | `textureIndex` — индекс текстуры | +| 72 | 4 | int32 | Нет | Дополнительный параметр | -`wordIndex = nodeIndex * 19 + lod * 5 + group + 4` +### Маппинг компонентов на D3D Material (предположительный) -где: +По аналогии со стандартной структурой `D3DMATERIAL7`: -- `lod` в диапазоне `0..2` (**три уровня LOD**) -- `group` в диапазоне `0..4` (**пять групп слотов**) -- если вместо `lod` передать `-1`, движок подставит `current_lod` из инстанса. +| Компонент | Вероятное назначение | Биты интерполяции | +|--------------|----------------------|-------------------| +| A (+4..+12) | Diffuse (RGB) | 0x02 | +| B (+20..+28) | Ambient (RGB) | 0x01 | +| C (+36..+44) | Specular (RGB) | 0x04 | +| D (+52..+60) | Emissive (RGB) | 0x08 | +| (+32) | Specular power | 0x10 | -Из этого следует структура узла: +### Поле textureIndex (+68) -### 6.1 Заголовок узла (первые 4 u16) +- Значение `< 0` означает «нет текстуры» → `texture_ptr = NULL`. +- Значение `≥ 0` используется как индекс в глобальном массиве текстурных объектов: `texture = texture_array[5 * textureIndex]`. -- `u16 hdr0` -- `u16 hdr1` -- `u16 hdr2` -- `u16 hdr3` +## 2.3. Алгоритм интерполяции материалов -Семантика заголовка узла **пока не восстановлена** (кандидаты: parent/firstChild/nextSibling/flags). +Движок поддерживает **анимацию материалов** между ключевыми кадрами. Функция `sub_10003030`: -### 6.2 SlotIndex‑матрица: 3 LOD \* 5 groups = 15 u16 +``` +Вход: mat_a (исходный), mat_b (целевой), t (фактор 0..1), mask (битовая маска) -Дальше идут 15 слов: +Выход: mat_result -- для `lod=0`: `slotIndex[group0..4]` -- для `lod=1`: `slotIndex[group0..4]` -- для `lod=2`: `slotIndex[group0..4]` +Для каждого бита mask: + если бит установлен: + mat_result.component = mat_a.component + (mat_b.component - mat_a.component) × t + иначе: + mat_result.component = mat_a.component (без интерполяции) -`slotIndex` — это индекс в slot table (`Res2+140`), либо `0xFFFF` если слота нет. +mat_result.textureIndex = mat_a.textureIndex (всегда копируется без интерполяции) +``` -**Группы (0..4)**: в коде чаще всего используется `group=0`. Остальные группы встречаются как параметр обхода, но назначение (например, “коллизия”, “тени”, “декали”, “альфа‑геометрия” и т.п.) пока не доказано. В документации ниже они называются просто `group`. +### Режимы анимации материалов + +Материал может иметь несколько фаз (phase) с разными режимами цикличности: + +| Режим (flags & 7) | Описание | +|-------------------|-------------------------------------| +| 0 | Цикл: повтор с начала | +| 1 | Ping‑pong: туда‑обратно | +| 2 | Однократное воспроизведение (clamp) | +| 3 | Случайный кадр (random) | + +## 2.4. Глобальный массив текстур + +Текстуры хранятся в глобальном массиве записей по **20 байт** (5 DWORD): + +```c +struct TextureSlot { // 20 байт + int32_t name_hash; // +0: Хэш/ID имени текстуры (-1 = свободен) + void* texture_object; // +4: Указатель на объект текстуры D3D + int32_t ref_count; // +8: Счётчик ссылок + uint32_t last_release; // +12: Время последнего Release + uint32_t extra; // +16: Дополнительный флаг +}; +``` + +Функция `UnloadAllTextures` обнуляет все слоты, вызывая деструктор для каждого ненулевого `texture_object`. + +## 2.5. Глобальный массив определений материалов + +Определения материалов хранятся в глобальном массиве записей по **368 байт** (92 DWORD): + +```c +struct MaterialDef { // 368 байт (92 DWORD) + int32_t name_hash; // dword_100669F0[92*i]: -1 = свободен + int32_t ref_count; // dword_100669F4[92*i]: Счётчик ссылок + int32_t phase_count; // dword_100669F8[92*i]: Число текстурных фаз + void* record_ptr; // dword_100669FC[92*i]: Указатель на массив записей по 76 байт + int32_t anim_phase_count; // dword_10066A00[92*i]: Число фаз анимации + // +20..+367: данные фаз анимации (до 22 фаз × 16 байт) +}; +``` + +## 2.6. Переключатели рендера (из Ngi32.dll) + +Движок читает настройки из реестра Windows (`HKCU\Software\Nikita\NgiTool`). Подтверждённые ключи: + +| Ключ реестра | Глобальная переменная | Описание | +|--------------------------|-----------------------|---------------------------------| +| `Disable MultiTexturing` | `dword_1003A184` | Отключить мультитекстурирование | +| `DisableMipmap` | `dword_1003A174` | Отключить мипмап‑фильтрацию | +| `Force 16-bit textures` | `dword_1003A180` | Принудительно 16‑бит текстуры | +| `UseFirstCard` | `dword_100340EC` | Использовать первую видеокарту | +| `DisableD3DCalls` | `dword_1003A178` | Отключить вызовы D3D (отладка) | +| `DisableDSound` | `dword_1003A17C` | Отключить DirectSound | +| `ForceCpu` | (комбинированный) | Режим рендера: SW/HW TnL/Mixed | + +### Значения ForceCpu и их влияние на рендер + +| ForceCpu | Force SSE | Force 3DNow | Force FXCH | Force MMX | +|----------|-----------|-------------|------------|-----------| +| 2 | Да | Нет | Нет | Нет | +| 3 | Нет | Да | Нет | Нет | +| 4 | Да | Да | Нет | Нет | +| 5 | Да | Да | Да | Да | +| 6 | Да | Да | Да | Нет | +| 7 | Нет | Нет | Нет | Да | + +### Практические выводы для порта + +Движок спроектирован для работы **без** следующих функций (graceful degradation): + +- Мипмапы. +- Bilinear/trilinear фильтрация. +- Мультитекстурирование (2‑й текстурный слой). +- 32‑битные текстуры (fallback на 16‑бит). +- Аппаратный T&L (software fallback). --- -## 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)` (так делает оригинальный код). +## 2.7. Текстовый файл WEAR + LIGHTMAPS (World3D.dll) + +`World3D.dll` содержит парсер текстового файла (режим `rt`), который задаёт: + +- список **материалов (wear)**, используемых в сцене/объекте; +- список **лайтмап (lightmaps)**. + +Формат читается через `fgets`/`sscanf`/`fscanf`, поэтому он чувствителен к структуре строк и ключевому слову `LIGHTMAPS`. + +### 2.7.1. Блок WEAR (материалы) + +1) **Первая строка файла** — целое число: + +- `wearCount` (обязательно `> 0`, иначе ошибка `"Illegal wear length."`) + +2) Далее следует `wearCount` строк. Каждая строка имеет вид: + +- `<int> <пробелы> <materialName>` + +Где: + +- `<int>` парсится, но фактически не используется как ключ (движок обрабатывает записи последовательно). +- `<materialName>` — имя материала, которое движок ищет в менеджере материалов. + - Если материал не найден, пишется `"Material %s not found."` и используется fallback `"DEFAULT"`. + +> Практическая рекомендация для инструментов: считайте `<int>` как необязательный “legacy-id”, а истинным идентификатором материала делайте строку `<materialName>`. + +### 2.7.2. Блок LIGHTMAPS + +После чтения wear-списка движок последовательно читает токены (`fscanf("%s")`) до тех пор, пока не встретит слово **`LIGHTMAPS`**. + +Затем: + +1) Читается `lightmapCount`: + +- `lightmapCount` (обязательно `> 0`, иначе ошибка `"Illegal lightmaps length."`) + +2) Далее следует `lightmapCount` строк вида: + +- `<int> <пробелы> <lightmapName>` + +Где: + +- `<int>` парсится, но фактически не используется как ключ (аналогично wear). +- `<lightmapName>` — имя лайтмапы; если ресурс не найден, пишется `"LightMap %s not found."`. + +### 2.7.3. Валидация имени лайтмапы (деталь движка) + +Перед загрузкой лайтмапы выполняется проверка имени: + +- в имени должна встречаться точка `.` **в пределах первых 16 символов**, иначе ошибка `"Bad texture name."`; +- далее движок использует подстроку после точки в вычислениях внутренних индексов/кэша (на практике полезно придерживаться шаблона вида `NAME.A1`, `NAME.B2` и т.п.). --- +## 2.8. Формат текстурного ассета `Texm` (Ngi32.dll) + +Текстуры из `Textures.lib` хранятся как NRes‑entries типа `0x6D786554` (`"Texm"`). + +### 2.8.1. Заголовок `Texm` (32 байта) + +```c +struct TexmHeader32 { + uint32_t magic; // 0x6D786554 ('Texm') + uint32_t width; // base width + uint32_t height; // base height + uint32_t mipCount; // количество уровней + uint32_t flags4; // наблюдаются 0 или 32 + uint32_t flags5; // наблюдаются 0 или 0x04000000 + uint32_t unk6; // служебное поле (часто 0, иногда ненулевое) + uint32_t format; // код пиксельного формата +}; +``` + +Подтверждённые `format`: + +- `0` — paletted 8-bit (индекс + palette); +- `565`, `556`, `4444` — 16-bit семейство; +- `888`, `8888` — 32-bit семейство. + +### 2.8.2. Layout payload + +После заголовка: + +1) если `format == 0`: palette блок 1024 байта (`256 × 4`); +2) далее mip-chain пикселей; +3) опционально chunk атласа `Page`. + +Размер mip-chain: + +```c +bytesPerPixel = (format == 0 ? 1 : format in {565,556,4444} ? 2 : 4); +pixelBytes = bytesPerPixel * sum_{i=0..mipCount-1}(max(1,width>>i) * max(1,height>>i)); +``` + +Итого «чистый» размер без `Page`: + +```c +sizeCore = 32 + (format == 0 ? 1024 : 0) + pixelBytes; +``` -## 8) Обход треугольников (коллизия/пикинг/дебаг) +### 2.8.3. Опциональный `Page` chunk -В движке есть универсальный обход: +Если после `sizeCore` остаются байты и в этой позиции стоит magic `"Page"` (`0x65676150`), парсер `sub_1000FF60` читает таблицу subrect: -- Идём по 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 + индекс) +```c +struct PageChunk { + uint32_t magic; // 'Page' + uint32_t count; + struct Rect16 { + int16_t x; + int16_t w; + int16_t y; + int16_t h; + } rects[count]; +}; +``` + +Для каждого rect рантайм строит: + +- пиксельные границы (`x0,y0,x1,y1`); +- нормализованные UV (`u0,v0,u1,v1`) с делителем `1/(width<<mipSkip)` и `1/(height<<mipSkip)`. + +`mipSkip` вычисляется `sub_1000F580` (уровень, с которого реально начинается загрузка в GPU в зависимости от формата/ограничений). + +### 2.8.4. Palette в формате `format==0` + +В `sub_1000FB30` palette конвертируется в локальную 32-bit таблицу; байты источника читаются как BGR-порядок (четвёртый байт входной записи не используется напрямую в базовом пути), итоговая alpha зависит от флагов runtime-конфига. + +### 2.8.5. Проверка на реальных данных + +Для всех 393 entries в `Textures.lib`: + +- `magic == 'Texm'`; +- размеры совпадают с `sizeCore` либо `sizeCore + PageChunk (+pad до 8 байт NRes)`; +- при наличии хвоста в `sizeCore` всегда обнаруживается валидный `Page` chunk. --- +# Часть 3. Эффекты и частицы + +## 3.1. Архитектурный обзор + +Подсистема эффектов реализована в `Effect.dll` и интегрирована в рендер через `Terrain.dll`. + +### Экспорты Effect.dll + +| Функция | Описание | +|----------------------|--------------------------------------------------------| +| `CreateFxManager` | Создать менеджер эффектов (3 параметра: int, int, int) | +| `InitializeSettings` | Инициализировать настройки эффектов | + +`CreateFxManager` возвращает объект‑менеджер, который регистрируется в движке и управляет всеми эффектами. + +### Телеметрия из Terrain.dll + +Terrain.dll содержит отладочную статистику рендера: + +``` +"Rendered meshes : %d" +"Rendered primitives : %d" +"Rendered faces : %d" +"Rendered particles/batches : %d/%d" +``` + +Из этого следует: + +- Частицы рендерятся **батчами** (группами). +- Статистика частиц отделена от статистики мешей. +- Частицы интегрированы в общий 3D‑рендер‑пайплайн. + +## 3.2. Контейнер ресурса эффекта + +Эффекты в игровых архивах хранятся как NRes‑entries типа: + +- `0x44495846` (`"FXID"`). + +Парсер эффекта находится в `Effect.dll!sub_10007650`. + +## 3.3. Формат payload эффекта + +### 3.3.1. Header (первые 60 байт) + +```c +struct FxHeader60 { + uint32_t cmdCount; // +0x00 + uint32_t globalFlags; // +0x04 + float durationSec; // +0x08 (дальше умножается на 1000.0) + uint32_t unk0C; // +0x0C + uint32_t flags10; // +0x10 (используются биты 0x40 и 0x400) + uint8_t reserved[0x2C];// +0x14..+0x3B +}; +``` + +Поток команд начинается строго с `offset 0x3C`. + +### 3.3.2. Командный поток + +Каждая команда начинается с `uint32 cmdWord`, где: + +- `opcode = cmdWord & 0xFF`; +- `enabled = (cmdWord >> 8) & 1` (копируется в `obj+4`). + +Размер команды зависит от opcode и прибавляется в **байтах** (`add edi, ...` в ASM): + +| Opcode | Размер записи | +|--------|---------------| +| 1 | 224 | +| 2 | 148 | +| 3 | 200 | +| 4 | 204 | +| 5 | 112 | +| 6 | 4 | +| 7 | 208 | +| 8 | 248 | +| 9 | 208 | +| 10 | 208 | + +Никакого межкомандного выравнивания нет: следующая команда сразу после `size(opcode)`. -## 9) Опциональные vertex streams (Res15/16/18/20) — текущий статус +## 3.4. Runtime-классы команд (vtable mapping) -Эти ресурсы загружаются, но в найденных местах пока **нет однозначного декодера**. Что точно видно по загрузчику: +В `sub_10007650` для каждого opcode создаётся объект конкретного типа: -- **Res15**: stride 8, массив на вершину. - - кандидаты: `float2 uv1` (lightmap), либо 4×`int16` (2 UV‑пары), либо что‑то иное. +- `op1` → `off_1001E78C` +- `op2` → `off_1001F048` +- `op3` → `off_1001E770` +- `op4` → `off_1001E754` +- `op5` → `off_1001E360` +- `op6` → `off_1001E738` +- `op7` → `off_1001E228` +- `op8` → `off_1001E71C` +- `op9` → `off_1001E700` +- `op10` → `off_1001E24C` -- **Res16**: stride 8, но движок создаёт два “под‑потока”: - - streamA = res16 + 0, stride 8 - - streamB = res16 + 4, stride 8 Это сильно похоже на “два packed‑вектора по 4 байта”, например `tangent` и `bitangent` (s8×4). +`flags10 & 0x400` включает глобальный runtime-флаг менеджера эффекта (`manager+0xA0`). -- **Res18**: stride 4, массив на вершину. - - кандидаты: `D3DCOLOR` (RGBA), либо packed‑параметры освещения/окклюзии. +## 3.5. Алгоритм загрузки эффекта (1:1) -- **Res20**: присутствует не всегда; отдельно читается `count/meta` поле из заголовка ресурса. - - кандидаты: дополнительная таблица соответствий (vertex remap), либо ускорение для эффектов/деформаций. +```c +read header60 +ptr = data + 0x3C +for i in 0..cmdCount-1: + op = ptr[0] & 0xFF + obj = new CommandClass(op) + obj->enabled = (ptr[0] >> 8) & 1 + obj->raw = ptr + manager.attach(obj) + ptr += sizeByOpcode(op) +``` + +Ошибка формата: + +- неизвестный opcode; +- выход за пределы буфера до обработки `cmdCount`; +- непустой «хвост» после `cmdCount` команд (для строгого валидатора). + +## 3.6. Проверка на реальных данных + +Для `testdata/nres/effects.rlb` (923 entries): + +- `opcode` всегда в диапазоне `1..10`; +- stream полностью покрывает payload без хвоста; +- частоты opcode: + - `1: 618` + - `2: 517` + - `3: 1545` + - `4: 202` + - `5: 31` + - `7: 1161` + - `8: 237` + - `9: 266` + - `10: 160` + - `6` в этом наборе не встретился, но поддерживается парсером. --- -## 10) Как “создавать” модели (экспортёр / конвертер) — практическая рекомендация +# Часть 4. Terrain (из Terrain.dll) + +## 4.1. Обзор + +`Terrain.dll` отвечает за рендер ландшафта (terrain), включая: + +- Рендер мешей ландшафта (`"Rendered meshes"`, `"Rendered primitives"`, `"Rendered faces"`). +- Рендер частиц (`"Rendered particles/batches"`). +- Создание текстур (`"CTexture::CTexture()"` — конструктор текстуры). +- Микротекстуры (`"Unable to find microtexture mapping"`). + +## 4.2. Текстуры ландшафта -Чтобы собрать совместимый формат (минимум, достаточный для рендера и коллизии), нужно: +В Terrain.dll присутствует конструктор текстуры `CTexture::CTexture()` со следующими проверками: -1. Сформировать единый массив вершин: - - positions (Res3) - - packed normals (Res4) — если хотите сохранить оригинальную упаковку - - packed uv0 (Res5) +- Валидация размера текстуры (`"Unsupported texture size"`). +- Создание D3D‑текстуры (`"Unable to create texture"`). -2. Сформировать index buffer (Res6) u16. +Ландшафт использует **микротекстуры** (micro‑texture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности. -3. Сформировать batch table (Res13): - - сгруппировать треугольники по (материал, узел/часть, режим) - - записать `baseVertex`, `indexStart`, `indexCount` - - заполнить неизвестные поля нулями (пока нет доказанной семантики). +## 4.3. Защита от пустых примитивов -4. Сформировать triangle descriptor table (Res7): - - на каждый треугольник 16 байт - - минимум: `triFlags=0` - - остальное — 0. +Terrain.dll содержит проверки: -5. Сформировать slot table (Res2+140): - - для каждого (node, lod, group) задать: - - triStart (индекс начала triDesc для обхода) - - batchStart/batchCount - - AABB и bounding sphere в локальных координатах узла/части - - неиспользуемые поля хвоста = 0. +- `"Rendering empty primitive!"` — перед первым вызовом отрисовки. +- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки. -6. Сформировать node table (Res1): - - для каждого node: - - 4 заголовочных u16 (пока можно 0) - - 15 slotIndex’ов (LOD0..2 × group0..4), `0xFFFF` где нет слота. +Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта). -7. Анимацию/Res8/Res19/Res11: - - если не нужна — можно отсутствующими, но надо проверить, что загрузчик/движок допускает “статическую” модель без этих ресурсов (в оригинале много логики завязано на них). +--- + +# Часть 5. Контрольные заметки для реализации + +## 5.1. Порядок байт + +Все значения хранятся в **little‑endian** порядке (платформа x86/Win32). + +## 5.2. Выравнивание + +- **NRes‑ресурсы:** данные каждого ресурса внутри NRes‑архива выровнены по границе **8 байт** (0‑padding). +- **Внутренняя структура ресурсов:** таблицы Res1/Res2/Res7/Res13 не имеют межзаписевого выравнивания — записи идут подряд. +- **Vertex streams:** stride'ы фиксированы (12/4/8 байт) — вершинные данные идут подряд без паддинга. + +## 5.3. Размеры записей на диске + +| Ресурс | Запись | Размер (байт) | Stride | +|--------|-----------|---------------|-------------------------| +| Res1 | Node | 38 | 38 (19×u16) | +| Res2 | Slot | 68 | 68 | +| Res3 | Position | 12 | 12 (3×f32) | +| Res4 | Normal | 4 | 4 (4×s8) | +| Res5 | UV0 | 4 | 4 (2×s16) | +| Res6 | Index | 2 | 2 (u16) | +| Res7 | TriDesc | 16 | 16 | +| Res8 | AnimKey | 24 | 24 | +| Res10 | StringRec | переменный | `4 + (len ? len+1 : 0)` | +| Res13 | Batch | 20 | 20 | +| Res19 | AnimMap | 2 | 2 (u16) | +| Res15 | VtxStr | 8 | 8 | +| Res16 | VtxStr | 8 | 8 (2×4) | +| Res18 | VtxStr | 4 | 4 | + +## 5.4. Вычисление количества элементов + +Количество записей вычисляется из размера ресурса: + +``` +count = resource_data_size / record_stride +``` + +Например: + +- `vertex_count = res3_size / 12` +- `index_count = res6_size / 2` +- `batch_count = res13_size / 20` +- `slot_count = (res2_size - 140) / 68` +- `node_count = res1_size / 38` +- `tri_desc_count = res7_size / 16` +- `anim_key_count = res8_size / 24` +- `anim_map_count = res19_size / 2` + +Для Res10 нет фиксированного stride: нужно последовательно проходить записи `u32 len` + `(len ? len+1 : 0)` байт. + +## 5.5. Идентификация ресурсов в NRes + +Ресурсы модели идентифицируются по полю `type` (смещение 0) в каталожной записи NRes. Загрузчик использует `niFindRes(archive, type, subtype)` для поиска, где `type` — число (1, 2, 3, ... 20), а `subtype` (byte) — уточнение (из аргумента загрузчика). + +## 5.6. Минимальный набор для рендера + +Для статической модели без анимации достаточно: + +| Ресурс | Обязательность | +|--------|------------------------------------------------| +| Res1 | Да | +| Res2 | Да | +| Res3 | Да | +| Res4 | Рекомендуется | +| Res5 | Рекомендуется | +| Res6 | Да | +| Res7 | Для коллизии | +| Res13 | Да | +| Res10 | Желательно (узловые имена/поведенческие ветки) | +| Res8 | Нет (анимация) | +| Res19 | Нет (анимация) | +| Res15 | Нет | +| Res16 | Нет | +| Res18 | Нет | +| Res20 | Нет | + +## 5.7. Сводка алгоритмов декодирования + +### Позиции (Res3) + +```python +def decode_position(data, vertex_index): + offset = vertex_index * 12 + x = struct.unpack_from('<f', data, offset)[0] + y = struct.unpack_from('<f', data, offset + 4)[0] + z = struct.unpack_from('<f', data, offset + 8)[0] + return (x, y, z) +``` + +### Нормали (Res4) + +```python +def decode_normal(data, vertex_index): + offset = vertex_index * 4 + nx = struct.unpack_from('<b', data, offset)[0] # int8 + ny = struct.unpack_from('<b', data, offset + 1)[0] + nz = struct.unpack_from('<b', data, offset + 2)[0] + # nw = data[offset + 3] # не используется + return ( + max(-1.0, min(1.0, nx / 127.0)), + max(-1.0, min(1.0, ny / 127.0)), + max(-1.0, min(1.0, nz / 127.0)), + ) +``` + +### UV‑координаты (Res5) + +```python +def decode_uv(data, vertex_index): + offset = vertex_index * 4 + u = struct.unpack_from('<h', data, offset)[0] # int16 + v = struct.unpack_from('<h', data, offset + 2)[0] + return (u / 1024.0, v / 1024.0) +``` + +### Кодирование нормали (для экспортёра) + +```python +def encode_normal(nx, ny, nz): + return ( + max(-128, min(127, int(round(nx * 127.0)))), + max(-128, min(127, int(round(ny * 127.0)))), + max(-128, min(127, int(round(nz * 127.0)))), + 0 # nw = 0 (безопасное значение) + ) +``` + +### Кодирование UV (для экспортёра) + +```python +def encode_uv(u, v): + return ( + max(-32768, min(32767, int(round(u * 1024.0)))), + max(-32768, min(32767, int(round(v * 1024.0)))) + ) +``` + +### Строки узлов (Res10) + +```python +def parse_res10_for_nodes(buf: bytes, node_count: int) -> list[str | None]: + out = [] + off = 0 + for _ in range(node_count): + ln = struct.unpack_from('<I', buf, off)[0] + off += 4 + if ln == 0: + out.append(None) + continue + raw = buf[off:off + ln + 1] # len + '\0' + out.append(raw[:-1].decode('ascii', errors='replace')) + off += ln + 1 + return out +``` + +### Ключ анимации (Res8) и mapping (Res19) + +```python +def decode_anim_key24(buf: bytes, idx: int): + o = idx * 24 + px, py, pz, t = struct.unpack_from('<4f', buf, o) + qx, qy, qz, qw = struct.unpack_from('<4h', buf, o + 16) + s = 1.0 / 32767.0 + return (px, py, pz), t, (qx * s, qy * s, qz * s, qw * s) +``` + +### Эффектный поток (FXID) + +```python +FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208} + +def parse_fx_payload(raw: bytes): + cmd_count = struct.unpack_from('<I', raw, 0)[0] + ptr = 0x3C + cmds = [] + for _ in range(cmd_count): + w = struct.unpack_from('<I', raw, ptr)[0] + op = w & 0xFF + enabled = (w >> 8) & 1 + size = FX_CMD_SIZE[op] + cmds.append((op, enabled, ptr, size)) + ptr += size + if ptr != len(raw): + raise ValueError('tail bytes after command stream') + return cmds +``` + +### Texm (header + mips + Page) + +```python +def parse_texm(raw: bytes): + magic, w, h, mips, f4, f5, unk6, fmt = struct.unpack_from('<8I', raw, 0) + assert magic == 0x6D786554 # 'Texm' + bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4) + pix_sum = 0 + mw, mh = w, h + for _ in range(mips): + pix_sum += mw * mh + mw = max(1, mw >> 1) + mh = max(1, mh >> 1) + off = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum + page = None + if off + 8 <= len(raw) and raw[off:off+4] == b'Page': + n = struct.unpack_from('<I', raw, off + 4)[0] + page = [struct.unpack_from('<4h', raw, off + 8 + i * 8) for i in range(n)] + return (w, h, mips, fmt, f4, f5, unk6, page) +``` --- -## 11) Что ещё нужно восстановить, чтобы документация стала “закрывающей” на 100% +# Часть 6. Остаточные семантические вопросы + +Пункты ниже **не блокируют 1:1-парсинг/рендер/интерполяцию** (все бинарные структуры уже определены), но их человеко‑читаемая трактовка может быть уточнена дополнительно. + +## 6.1. Batch table — смысл `unk4/unk6/unk14` + +Физическое расположение полей известно, но доменное имя/назначение не зафиксировано: + +- `unk4` (`+0x04`) +- `unk6` (`+0x06`) +- `unk14` (`+0x0E`) + +## 6.2. Node flags и имена групп + +- Биты в `Res1.hdr0` используются в ряде рантайм‑веток, но их «геймдизайн‑имена» неизвестны. +- Для group‑индекса `0..4` не найдено текстовых label'ов в ресурсах; для совместимости нужно сохранять числовой индекс как есть. + +## 6.3. Slot tail `unk30..unk40` + +Хвост слота (`+0x30..+0x43`, `5×uint32`) стабильно присутствует в формате, но движок не делает явной семантической декомпозиции этих пяти слов в path'ах загрузки/рендера/коллизии. + +## 6.4. Effect command payload semantics + +Container/stream формально полностью восстановлен (header, opcode, размеры, инстанцирование). Остаётся необязательная задача: дать «человеко‑читаемые» имена каждому полю внутри payload конкретных opcode. + +## 6.5. Поля `TexmHeader.flags4/flags5/unk6` + +Бинарный layout и декодер известны, но значения этих трёх полей в контенте используются контекстно; для 1:1 достаточно хранить/восстанавливать их без модификации. + +## 6.6. Что пока не хватает для полноценного обратного экспорта (`OBJ -> MSH/NRes`) + +Ниже перечислено то, что нужно закрыть для **lossless round-trip** и 1:1‑поведения при импорте внешней геометрии обратно в формат игры. + +### A) Неполная «авторская» семантика бинарных таблиц + +1. `Res2` header (`первые 0x8C`): не зафиксированы все поля и правила их вычисления при генерации нового файла (а не copy-through из оригинала). +2. `Res7` tri-descriptor: для 16‑байтной записи декодирован базовый каркас, но остаётся неформализованной часть служебных бит/полей, нужных для стабильной генерации adjacency/служебной топологии. +3. `Res13` поля `unk4/unk6/unk14`: для парсинга достаточно, но для генерации «канонических» значений из голого `OBJ` правила не определены. +4. `Res2` slot tail (`unk30..unk40`): семантика не разложена, поэтому при экспорте новых ассетов нет детерминированной формулы заполнения. + +### B) Анимационный path ещё не закрыт как writer + +1. Нужен полный writer для `Res8/Res19`: + - точная спецификация байтового формата на запись; + - правила генерации mapping (`Res19`) по узлам/кадрам; + - жёсткая фиксация округления как в x87 path (включая edge-case на границах кадра). +2. Правила биндинга узлов/строк (`Res10`) и `slotFlags` к runtime‑сущностям пока описаны частично и требуют формализации именно для импорта новых данных. + +### C) Материалы, текстуры, эффекты для «полного ассета» + +1. Для `Texm` не завершён writer, покрывающий все используемые режимы (включая palette path, mip-chain, `Page`, и правила заполнения служебных полей). +2. Для `FXID` известен контейнер/длины команд, но не завершена field-level семантика payload всех opcode для генерации новых эффектов, эквивалентных оригинальному пайплайну. +3. Экспорт только `OBJ` покрывает геометрию; для игрового ассета нужен sidecar-слой (материалы/текстуры/эффекты/анимация), иначе импорт неизбежно неполный. + +### D) Что это означает на практике -1. Точная семантика `batch.unk6` (вероятный nodeIndex) и `batch.unk4/unk14`. -2. Полная раскладка TriDesc16 (кроме triFlags). -3. Назначение `slotFlagsOrUnk`. -4. Семантика групп `group=1..4` в node‑таблице. -5. Назначение и декодирование Res15/Res16/Res18/Res20. -6. Связь строковой таблицы (Res10) с материалами/узлами (кто именно как индексирует строки). +1. `OBJ -> MSH` сейчас реалистичен как **ограниченный static-экспорт** (позиции/индексы/часть batch/slot структуры). +2. `OBJ -> полноценный игровой ресурс` (без потерь, с поведением 1:1) пока недостижим без закрытия пунктов A/B/C. +3. До закрытия пунктов A/B/C рекомендуется использовать режим: + - геометрия экспортируется из `OBJ`; + - неизвестные/служебные поля берутся copy-through из референсного оригинального ассета той же структуры. diff --git a/docs/specs/textures.md b/docs/specs/textures.md deleted file mode 100644 index 72e1462..0000000 --- a/docs/specs/textures.md +++ /dev/null @@ -1,90 +0,0 @@ -# Текстуры и материалы - -На текущем этапе в дизассемблированных библиотеках **не найден полный декодер формата текстурного файла** (нет явных парсеров DDS/TGA/BMP и т.п.). Поэтому документ пока фиксирует: - -- что можно достоверно вывести по рендер‑конфигу, -- что видно по структурам модели (materialIndex), -- какие места требуют дальнейшего анализа. - ---- - -## 1) Материал в модели - -В batch table модели (см. документацию по MSH/AniMesh) есть поле, очень похожее на: - -- `materialIndex: u16` (batch + 2) - -Это индекс, по которому рендерер выбирает: - -- текстуру(ы), -- параметры (blend, alpha test, двухтекстурность и т.п.), -- “шейдер/пайплайн” (в терминах оригинального рендера — набор state’ов). - -**Где лежит таблица материалов** (внутри модели или глобально) — требует подтверждения: - -- вероятный кандидат — отдельный ресурс/таблица, на которую `materialIndex` ссылается. -- строковая таблица `Res10` может хранить имена материалов/текстур, но маппинг не доказан. - ---- - -## 2) Переключатели рендера, влияющие на текстуры (из Ngi32.dll) - -В `Ngi32.dll` есть набор runtime‑настроек (похоже, читаются из системных настроек/INI/registry), которые сильно влияют на текстурный пайплайн: - -- `DisableMipmap` -- `DisableBilinear` -- `DisableTrilinear` -- `DisableMultiTexturing` -- `Disable32bitTextures` / `Force16bitTextures` -- `ForceSoftware` -- `ForceNoFiltering` -- `ForceHWTnL` -- `ForceNoHWTnL` - -Практический вывод для порта: - -- движок может работать **без мипмапов**, **без фильтрации**, и даже **без multitexturing**. - ---- - -## 3) “Две текстуры” и дополнительные UV‑потоки - -В загрузчике модели присутствуют дополнительные per‑vertex ресурсы: - -- Res15 (stride 8) — кандидат на UV1 (lightmap/second layer) -- Res16 (stride 8, split в 2×4) — кандидат на tangent/bitangent (normal mapping) -- Res18 (stride 4) — кандидат на vertex color / AO - -Если материал реально поддерживает: - -- вторую текстуру (detail map, lightmap), -- нормалмапы, - -то где‑то должен быть код: - -- который выбирает эти потоки как входные атрибуты вершинного шейдера/пайплайна, -- который активирует multi‑texturing. - -Сейчас в найденных фрагментах это ещё **не подтверждено**, но структура данных “просится” именно туда. - ---- - -## 4) Что нужно найти дальше (чтобы написать полноценную спецификацию материалов/текстур) - -1. Место, где `materialIndex` разворачивается в набор render states: - - alpha blending / alpha test - - z‑write/z‑test - - culling - - 1‑pass vs 2‑pass (multi‑texturing) -2. Формат записи “material record”: - - какие поля - - ссылки на текстуры (ID, имя, индекс в таблице) -3. Формат “texture asset”: - - где хранится (внутри NRes или отдельным файлом) - - компрессия/палитра/мip’ы -4. Привязка строковой таблицы `Res10` к материалам: - - это имена материалов? - - это имена текстур? - - или это имена узлов/анимаций? - -До подтверждения этих пунктов разумнее держать документацию как “архитектурную карту”, а не как точный байтовый формат. @@ -23,10 +23,8 @@ theme: nav: - Home: index.md - Specs: - - NRes / RsLi: specs/nres.md - - 3D модели: specs/msh.md - - Текстуры и материалы: specs/textures.md - - Эффекты и частицы: specs/effects.md + - NRes / RsLi: specs/nres.md + - Форматы 3D‑ресурсов: specs/msh.md # Additional configuration extra: diff --git a/tools/README.md b/tools/README.md index 19de2e5..2418567 100644 --- a/tools/README.md +++ b/tools/README.md @@ -105,3 +105,97 @@ python3 tools/init_testdata.py --input tmp/gamedata --output testdata --force - если `--output` указывает на существующий файл, скрипт завершится с ошибкой; - если `--output` расположен внутри `--input`, каталог вывода исключается из сканирования; - если `stdin` неинтерактивный и требуется перезапись, нужно явно указать `--force`. + +## `msh_doc_validator.py` + +Скрипт валидирует ключевые инварианты из документации `/Users/valentineus/Developer/personal/fparkan/docs/specs/msh.md` на реальных данных. + +Проверяемые группы: + +- модели `*.msh` (вложенные `NRes` в архивах `NRes`); +- текстуры `Texm` (`type_id = 0x6D786554`); +- эффекты `FXID` (`type_id = 0x44495846`). + +Что проверяет для моделей: + +- обязательные ресурсы (`Res1/2/3/6/13`) и известные опциональные (`Res4/5/7/8/10/15/16/18/19`); +- `size/attr1/attr3` и шаги структур по таблицам; +- диапазоны индексов, батчей и ссылок между таблицами; +- разбор `Res10` как `len + bytes + NUL` для каждого узла; +- матрицу слотов в `Res1` (LOD/group) и границы по `Res2/Res7/Res13/Res19`. + +Быстрый запуск: + +```bash +python3 tools/msh_doc_validator.py scan --input testdata/nres +python3 tools/msh_doc_validator.py validate --input testdata/nres --print-limit 20 +``` + +С отчётом в JSON: + +```bash +python3 tools/msh_doc_validator.py validate \ + --input testdata/nres \ + --report tmp/msh_validation_report.json \ + --fail-on-warnings +``` + +## `msh_preview_renderer.py` + +Примитивный программный рендерер моделей `*.msh` без внешних зависимостей. + +- вход: архив `NRes` (например `animals.rlb`) или прямой payload модели; +- выход: изображение `PPM` (`P6`); +- использует `Res3` (позиции), `Res6` (индексы), `Res13` (батчи), `Res1/Res2` (выбор слотов по `lod/group`). + +Показать доступные модели в архиве: + +```bash +python3 tools/msh_preview_renderer.py list-models --archive testdata/nres/animals.rlb +``` + +Сгенерировать тестовый рендер: + +```bash +python3 tools/msh_preview_renderer.py render \ + --archive testdata/nres/animals.rlb \ + --model A_L_01.msh \ + --output tmp/renders/A_L_01.ppm \ + --width 800 \ + --height 600 \ + --lod 0 \ + --group 0 \ + --wireframe +``` + +Ограничения: + +- инструмент предназначен для smoke-теста геометрии, а не для пиксельно-точного рендера движка; +- текстуры/материалы/эффектные проходы не эмулируются. + +## `msh_export_obj.py` + +Экспортирует геометрию `*.msh` в `Wavefront OBJ`, чтобы открыть модель в Blender/MeshLab. + +- вход: `NRes` архив (например `animals.rlb`) или прямой payload модели; +- выбор геометрии: через `Res1` slot matrix (`lod/group`) как в рендерере; +- опция `--all-batches` экспортирует все батчи, игнорируя slot matrix. + +Показать модели в архиве: + +```bash +python3 tools/msh_export_obj.py list-models --archive testdata/nres/animals.rlb +``` + +Экспорт в OBJ: + +```bash +python3 tools/msh_export_obj.py export \ + --archive testdata/nres/animals.rlb \ + --model A_L_01.msh \ + --output tmp/renders/A_L_01.obj \ + --lod 0 \ + --group 0 +``` + +Файл `OBJ` можно открыть напрямую в Blender (`File -> Import -> Wavefront (.obj)`). diff --git a/tools/msh_doc_validator.py b/tools/msh_doc_validator.py new file mode 100644 index 0000000..ff096a4 --- /dev/null +++ b/tools/msh_doc_validator.py @@ -0,0 +1,1000 @@ +#!/usr/bin/env python3 +""" +Validate assumptions from docs/specs/msh.md on real game archives. + +The tool checks three groups: +1) MSH model payloads (nested NRes in *.msh entries), +2) Texm texture payloads, +3) FXID effect payloads. +""" + +from __future__ import annotations + +import argparse +import json +import math +import struct +from collections import Counter +from pathlib import Path +from typing import Any + +import archive_roundtrip_validator as arv + +MAGIC_NRES = b"NRes" +MAGIC_PAGE = b"Page" + +TYPE_FXID = 0x44495846 +TYPE_TEXM = 0x6D786554 + +FX_CMD_SIZE = {1: 224, 2: 148, 3: 200, 4: 204, 5: 112, 6: 4, 7: 208, 8: 248, 9: 208, 10: 208} +TEXM_KNOWN_FORMATS = {0, 565, 556, 4444, 888, 8888} + + +def _add_issue( + issues: list[dict[str, Any]], + severity: str, + category: str, + archive: Path, + entry_name: str | None, + message: str, +) -> None: + issues.append( + { + "severity": severity, + "category": category, + "archive": str(archive), + "entry": entry_name, + "message": message, + } + ) + + +def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: + start = int(entry["data_offset"]) + end = start + int(entry["size"]) + return blob[start:end] + + +def _entry_by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: + by_type: dict[int, list[dict[str, Any]]] = {} + for item in entries: + by_type.setdefault(int(item["type_id"]), []).append(item) + return by_type + + +def _expect_single_resource( + by_type: dict[int, list[dict[str, Any]]], + type_id: int, + label: str, + issues: list[dict[str, Any]], + archive: Path, + model_name: str, + required: bool, +) -> dict[str, Any] | None: + rows = by_type.get(type_id, []) + if not rows: + if required: + _add_issue( + issues, + "error", + "model-resource", + archive, + model_name, + f"missing required resource type={type_id} ({label})", + ) + return None + if len(rows) > 1: + _add_issue( + issues, + "warning", + "model-resource", + archive, + model_name, + f"multiple resources type={type_id} ({label}); using first entry", + ) + return rows[0] + + +def _check_fixed_stride( + *, + entry: dict[str, Any], + stride: int, + label: str, + issues: list[dict[str, Any]], + archive: Path, + model_name: str, + enforce_attr3: bool = True, + enforce_attr2_zero: bool = True, +) -> int: + size = int(entry["size"]) + attr1 = int(entry["attr1"]) + attr2 = int(entry["attr2"]) + attr3 = int(entry["attr3"]) + + count = -1 + if size % stride != 0: + _add_issue( + issues, + "error", + "model-stride", + archive, + model_name, + f"{label}: size={size} is not divisible by stride={stride}", + ) + else: + count = size // stride + if attr1 != count: + _add_issue( + issues, + "error", + "model-attr", + archive, + model_name, + f"{label}: attr1={attr1} != size/stride={count}", + ) + if enforce_attr3 and attr3 != stride: + _add_issue( + issues, + "error", + "model-attr", + archive, + model_name, + f"{label}: attr3={attr3} != {stride}", + ) + if enforce_attr2_zero and attr2 != 0: + _add_issue( + issues, + "warning", + "model-attr", + archive, + model_name, + f"{label}: attr2={attr2} (expected 0 in known assets)", + ) + return count + + +def _validate_res10( + data: bytes, + node_count: int, + issues: list[dict[str, Any]], + archive: Path, + model_name: str, +) -> None: + off = 0 + for idx in range(node_count): + if off + 4 > len(data): + _add_issue( + issues, + "error", + "res10", + archive, + model_name, + f"record {idx}: missing u32 length (offset={off}, size={len(data)})", + ) + return + ln = struct.unpack_from("<I", data, off)[0] + off += 4 + need = ln + 1 if ln else 0 + if off + need > len(data): + _add_issue( + issues, + "error", + "res10", + archive, + model_name, + f"record {idx}: out of bounds (len={ln}, need={need}, offset={off}, size={len(data)})", + ) + return + if ln and data[off + ln] != 0: + _add_issue( + issues, + "warning", + "res10", + archive, + model_name, + f"record {idx}: missing trailing NUL at payload end", + ) + off += need + + if off != len(data): + _add_issue( + issues, + "error", + "res10", + archive, + model_name, + f"tail bytes after node records: consumed={off}, size={len(data)}", + ) + + +def _validate_model_payload( + model_blob: bytes, + archive: Path, + model_name: str, + issues: list[dict[str, Any]], + counters: Counter[str], +) -> None: + counters["models_total"] += 1 + + if model_blob[:4] != MAGIC_NRES: + _add_issue( + issues, + "error", + "model-container", + archive, + model_name, + "payload is not NRes (missing magic)", + ) + return + + try: + parsed = arv.parse_nres(model_blob, source=f"{archive}:{model_name}") + except Exception as exc: # pylint: disable=broad-except + _add_issue( + issues, + "error", + "model-container", + archive, + model_name, + f"cannot parse nested NRes: {exc}", + ) + return + + for item in parsed.get("issues", []): + _add_issue(issues, "warning", "model-container", archive, model_name, str(item)) + + entries = parsed["entries"] + by_type = _entry_by_type(entries) + + res1 = _expect_single_resource(by_type, 1, "Res1", issues, archive, model_name, True) + res2 = _expect_single_resource(by_type, 2, "Res2", issues, archive, model_name, True) + res3 = _expect_single_resource(by_type, 3, "Res3", issues, archive, model_name, True) + res4 = _expect_single_resource(by_type, 4, "Res4", issues, archive, model_name, False) + res5 = _expect_single_resource(by_type, 5, "Res5", issues, archive, model_name, False) + res6 = _expect_single_resource(by_type, 6, "Res6", issues, archive, model_name, True) + res7 = _expect_single_resource(by_type, 7, "Res7", issues, archive, model_name, False) + res8 = _expect_single_resource(by_type, 8, "Res8", issues, archive, model_name, False) + res10 = _expect_single_resource(by_type, 10, "Res10", issues, archive, model_name, False) + res13 = _expect_single_resource(by_type, 13, "Res13", issues, archive, model_name, True) + res15 = _expect_single_resource(by_type, 15, "Res15", issues, archive, model_name, False) + res16 = _expect_single_resource(by_type, 16, "Res16", issues, archive, model_name, False) + res18 = _expect_single_resource(by_type, 18, "Res18", issues, archive, model_name, False) + res19 = _expect_single_resource(by_type, 19, "Res19", issues, archive, model_name, False) + + if not (res1 and res2 and res3 and res6 and res13): + return + + # Res1 + res1_stride = int(res1["attr3"]) + if res1_stride not in (38, 24): + _add_issue( + issues, + "warning", + "res1", + archive, + model_name, + f"unexpected Res1 stride attr3={res1_stride} (known: 38 or 24)", + ) + if res1_stride <= 0: + _add_issue(issues, "error", "res1", archive, model_name, f"invalid Res1 stride={res1_stride}") + return + if int(res1["size"]) % res1_stride != 0: + _add_issue( + issues, + "error", + "res1", + archive, + model_name, + f"Res1 size={res1['size']} not divisible by stride={res1_stride}", + ) + return + node_count = int(res1["size"]) // res1_stride + if int(res1["attr1"]) != node_count: + _add_issue( + issues, + "error", + "res1", + archive, + model_name, + f"Res1 attr1={res1['attr1']} != node_count={node_count}", + ) + + # Res2 + res2_size = int(res2["size"]) + res2_attr1 = int(res2["attr1"]) + res2_attr2 = int(res2["attr2"]) + res2_attr3 = int(res2["attr3"]) + if res2_size < 0x8C: + _add_issue(issues, "error", "res2", archive, model_name, f"Res2 too small: size={res2_size}") + return + slot_bytes = res2_size - 0x8C + slot_count = -1 + if slot_bytes % 68 != 0: + _add_issue( + issues, + "error", + "res2", + archive, + model_name, + f"Res2 slot area not divisible by 68: slot_bytes={slot_bytes}", + ) + else: + slot_count = slot_bytes // 68 + if res2_attr1 != slot_count: + _add_issue( + issues, + "error", + "res2", + archive, + model_name, + f"Res2 attr1={res2_attr1} != slot_count={slot_count}", + ) + if res2_attr2 != 0: + _add_issue( + issues, + "warning", + "res2", + archive, + model_name, + f"Res2 attr2={res2_attr2} (expected 0 in known assets)", + ) + if res2_attr3 != 68: + _add_issue( + issues, + "error", + "res2", + archive, + model_name, + f"Res2 attr3={res2_attr3} != 68", + ) + + # Fixed-stride resources + vertex_count = _check_fixed_stride( + entry=res3, + stride=12, + label="Res3", + issues=issues, + archive=archive, + model_name=model_name, + ) + _ = _check_fixed_stride( + entry=res4, + stride=4, + label="Res4", + issues=issues, + archive=archive, + model_name=model_name, + ) if res4 else None + _ = _check_fixed_stride( + entry=res5, + stride=4, + label="Res5", + issues=issues, + archive=archive, + model_name=model_name, + ) if res5 else None + index_count = _check_fixed_stride( + entry=res6, + stride=2, + label="Res6", + issues=issues, + archive=archive, + model_name=model_name, + ) + tri_desc_count = _check_fixed_stride( + entry=res7, + stride=16, + label="Res7", + issues=issues, + archive=archive, + model_name=model_name, + ) if res7 else -1 + anim_key_count = _check_fixed_stride( + entry=res8, + stride=24, + label="Res8", + issues=issues, + archive=archive, + model_name=model_name, + enforce_attr3=False, # format stores attr3=4 in data set + ) if res8 else -1 + if res8 and int(res8["attr3"]) != 4: + _add_issue( + issues, + "error", + "res8", + archive, + model_name, + f"Res8 attr3={res8['attr3']} != 4", + ) + if res13: + batch_count = _check_fixed_stride( + entry=res13, + stride=20, + label="Res13", + issues=issues, + archive=archive, + model_name=model_name, + ) + else: + batch_count = -1 + if res15: + _check_fixed_stride( + entry=res15, + stride=8, + label="Res15", + issues=issues, + archive=archive, + model_name=model_name, + ) + if res16: + _check_fixed_stride( + entry=res16, + stride=8, + label="Res16", + issues=issues, + archive=archive, + model_name=model_name, + ) + if res18: + _check_fixed_stride( + entry=res18, + stride=4, + label="Res18", + issues=issues, + archive=archive, + model_name=model_name, + ) + + if res19: + anim_map_count = _check_fixed_stride( + entry=res19, + stride=2, + label="Res19", + issues=issues, + archive=archive, + model_name=model_name, + enforce_attr3=False, + enforce_attr2_zero=False, + ) + if int(res19["attr3"]) != 2: + _add_issue( + issues, + "error", + "res19", + archive, + model_name, + f"Res19 attr3={res19['attr3']} != 2", + ) + else: + anim_map_count = -1 + + # Res10 + if res10: + if int(res10["attr1"]) != int(res1["attr1"]): + _add_issue( + issues, + "error", + "res10", + archive, + model_name, + f"Res10 attr1={res10['attr1']} != Res1.attr1={res1['attr1']}", + ) + if int(res10["attr3"]) != 0: + _add_issue( + issues, + "warning", + "res10", + archive, + model_name, + f"Res10 attr3={res10['attr3']} (known assets use 0)", + ) + _validate_res10(_entry_payload(model_blob, res10), node_count, issues, archive, model_name) + + # Cross-table checks. + if vertex_count > 0 and (res4 and int(res4["size"]) // 4 != vertex_count): + _add_issue(issues, "error", "model-cross", archive, model_name, "Res4 count != Res3 count") + if vertex_count > 0 and (res5 and int(res5["size"]) // 4 != vertex_count): + _add_issue(issues, "error", "model-cross", archive, model_name, "Res5 count != Res3 count") + + indices: list[int] = [] + if index_count > 0: + res6_data = _entry_payload(model_blob, res6) + indices = list(struct.unpack_from(f"<{index_count}H", res6_data, 0)) + + if batch_count > 0: + res13_data = _entry_payload(model_blob, res13) + for batch_idx in range(batch_count): + b_off = batch_idx * 20 + ( + _batch_flags, + _mat_idx, + _unk4, + _unk6, + idx_count, + idx_start, + _unk14, + base_vertex, + ) = struct.unpack_from("<HHHHHIHI", res13_data, b_off) + end = idx_start + idx_count + if index_count > 0 and end > index_count: + _add_issue( + issues, + "error", + "res13", + archive, + model_name, + f"batch {batch_idx}: index range [{idx_start}, {end}) outside Res6 count={index_count}", + ) + continue + if idx_count % 3 != 0: + _add_issue( + issues, + "warning", + "res13", + archive, + model_name, + f"batch {batch_idx}: indexCount={idx_count} is not divisible by 3", + ) + if vertex_count > 0 and index_count > 0 and idx_count > 0: + raw_slice = indices[idx_start:end] + max_raw = max(raw_slice) + if base_vertex + max_raw >= vertex_count: + _add_issue( + issues, + "error", + "res13", + archive, + model_name, + f"batch {batch_idx}: baseVertex+maxIndex={base_vertex + max_raw} >= vertex_count={vertex_count}", + ) + + if slot_count > 0: + res2_data = _entry_payload(model_blob, res2) + for slot_idx in range(slot_count): + s_off = 0x8C + slot_idx * 68 + tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", res2_data, s_off) + if tri_desc_count > 0 and tri_start + tri_count > tri_desc_count: + _add_issue( + issues, + "error", + "res2-slot", + archive, + model_name, + f"slot {slot_idx}: tri range [{tri_start}, {tri_start + tri_count}) outside Res7 count={tri_desc_count}", + ) + if batch_count > 0 and batch_start + slot_batch_count > batch_count: + _add_issue( + issues, + "error", + "res2-slot", + archive, + model_name, + f"slot {slot_idx}: batch range [{batch_start}, {batch_start + slot_batch_count}) outside Res13 count={batch_count}", + ) + # Slot bounds are 10 float values. + for f_idx in range(10): + value = struct.unpack_from("<f", res2_data, s_off + 8 + f_idx * 4)[0] + if not math.isfinite(value): + _add_issue( + issues, + "error", + "res2-slot", + archive, + model_name, + f"slot {slot_idx}: non-finite bound float at field {f_idx}", + ) + break + + if tri_desc_count > 0: + res7_data = _entry_payload(model_blob, res7) + for tri_idx in range(tri_desc_count): + t_off = tri_idx * 16 + _flags, l0, l1, l2 = struct.unpack_from("<4H", res7_data, t_off) + for link in (l0, l1, l2): + if link != 0xFFFF and link >= tri_desc_count: + _add_issue( + issues, + "error", + "res7", + archive, + model_name, + f"tri {tri_idx}: link {link} outside tri_desc_count={tri_desc_count}", + ) + _ = struct.unpack_from("<H", res7_data, t_off + 14)[0] + + # Node-level constraints for slot matrix / animation mapping. + if res1_stride == 38: + res1_data = _entry_payload(model_blob, res1) + map_words: list[int] = [] + if anim_map_count > 0 and res19: + res19_data = _entry_payload(model_blob, res19) + map_words = list(struct.unpack_from(f"<{anim_map_count}H", res19_data, 0)) + frame_count = int(res19["attr2"]) if res19 else 0 + + for node_idx in range(node_count): + n_off = node_idx * 38 + hdr2 = struct.unpack_from("<H", res1_data, n_off + 4)[0] + hdr3 = struct.unpack_from("<H", res1_data, n_off + 6)[0] + # Slot matrix: 15 uint16 at +8. + for w_idx in range(15): + slot_idx = struct.unpack_from("<H", res1_data, n_off + 8 + w_idx * 2)[0] + if slot_idx != 0xFFFF and slot_count > 0 and slot_idx >= slot_count: + _add_issue( + issues, + "error", + "res1-slot", + archive, + model_name, + f"node {node_idx}: slotIndex[{w_idx}]={slot_idx} outside slot_count={slot_count}", + ) + + if anim_key_count > 0 and hdr3 != 0xFFFF and hdr3 >= anim_key_count: + _add_issue( + issues, + "error", + "res1-anim", + archive, + model_name, + f"node {node_idx}: fallbackKeyIndex={hdr3} outside Res8 count={anim_key_count}", + ) + if map_words and hdr2 != 0xFFFF and frame_count > 0: + end = hdr2 + frame_count + if end > len(map_words): + _add_issue( + issues, + "error", + "res19-map", + archive, + model_name, + f"node {node_idx}: map range [{hdr2}, {end}) outside Res19 count={len(map_words)}", + ) + + counters["models_ok"] += 1 + + +def _validate_texm_payload( + payload: bytes, + archive: Path, + entry_name: str, + issues: list[dict[str, Any]], + counters: Counter[str], +) -> None: + counters["texm_total"] += 1 + + if len(payload) < 32: + _add_issue( + issues, + "error", + "texm", + archive, + entry_name, + f"payload too small: {len(payload)}", + ) + return + + magic, width, height, mip_count, flags4, flags5, unk6, fmt = struct.unpack_from("<8I", payload, 0) + if magic != TYPE_TEXM: + _add_issue(issues, "error", "texm", archive, entry_name, f"magic=0x{magic:08X} != Texm") + return + if width == 0 or height == 0: + _add_issue(issues, "error", "texm", archive, entry_name, f"invalid size {width}x{height}") + return + if mip_count == 0: + _add_issue(issues, "error", "texm", archive, entry_name, "mipCount=0") + return + if fmt not in TEXM_KNOWN_FORMATS: + _add_issue( + issues, + "error", + "texm", + archive, + entry_name, + f"unknown format code {fmt}", + ) + return + if flags4 not in (0, 32): + _add_issue( + issues, + "warning", + "texm", + archive, + entry_name, + f"flags4={flags4} (known values: 0 or 32)", + ) + if flags5 not in (0, 0x04000000, 0x00800000): + _add_issue( + issues, + "warning", + "texm", + archive, + entry_name, + f"flags5=0x{flags5:08X} (known values: 0, 0x00800000, 0x04000000)", + ) + + bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4) + pix_sum = 0 + w = width + h = height + for _ in range(mip_count): + pix_sum += w * h + w = max(1, w >> 1) + h = max(1, h >> 1) + size_core = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum + if size_core > len(payload): + _add_issue( + issues, + "error", + "texm", + archive, + entry_name, + f"sizeCore={size_core} exceeds payload size={len(payload)}", + ) + return + + tail = len(payload) - size_core + if tail > 0: + off = size_core + if tail < 8: + _add_issue( + issues, + "error", + "texm", + archive, + entry_name, + f"tail too short for Page chunk: tail={tail}", + ) + return + if payload[off : off + 4] != MAGIC_PAGE: + _add_issue( + issues, + "error", + "texm", + archive, + entry_name, + f"tail is present but no Page magic at offset {off}", + ) + return + rect_count = struct.unpack_from("<I", payload, off + 4)[0] + need = 8 + rect_count * 8 + if need > tail: + _add_issue( + issues, + "error", + "texm", + archive, + entry_name, + f"Page chunk truncated: need={need}, tail={tail}", + ) + return + if need != tail: + _add_issue( + issues, + "error", + "texm", + archive, + entry_name, + f"extra bytes after Page chunk: tail={tail}, pageSize={need}", + ) + return + + _ = unk6 # carried as raw field in spec, semantics intentionally unknown. + counters["texm_ok"] += 1 + + +def _validate_fxid_payload( + payload: bytes, + archive: Path, + entry_name: str, + issues: list[dict[str, Any]], + counters: Counter[str], +) -> None: + counters["fxid_total"] += 1 + + if len(payload) < 60: + _add_issue( + issues, + "error", + "fxid", + archive, + entry_name, + f"payload too small: {len(payload)}", + ) + return + + cmd_count = struct.unpack_from("<I", payload, 0)[0] + ptr = 0x3C + for idx in range(cmd_count): + if ptr + 4 > len(payload): + _add_issue( + issues, + "error", + "fxid", + archive, + entry_name, + f"command {idx}: missing header at offset={ptr}", + ) + return + word = struct.unpack_from("<I", payload, ptr)[0] + opcode = word & 0xFF + if opcode not in FX_CMD_SIZE: + _add_issue( + issues, + "error", + "fxid", + archive, + entry_name, + f"command {idx}: unknown opcode={opcode} at offset={ptr}", + ) + return + size = FX_CMD_SIZE[opcode] + if ptr + size > len(payload): + _add_issue( + issues, + "error", + "fxid", + archive, + entry_name, + f"command {idx}: truncated, need end={ptr + size}, payload={len(payload)}", + ) + return + ptr += size + + if ptr != len(payload): + _add_issue( + issues, + "error", + "fxid", + archive, + entry_name, + f"tail bytes after command stream: parsed_end={ptr}, payload={len(payload)}", + ) + return + + counters["fxid_ok"] += 1 + + +def _scan_nres_files(root: Path) -> list[Path]: + rows = arv.scan_archives(root) + out: list[Path] = [] + for item in rows: + if item["type"] != "nres": + continue + out.append(root / item["relative_path"]) + return out + + +def run_validation(input_root: Path) -> dict[str, Any]: + archives = _scan_nres_files(input_root) + issues: list[dict[str, Any]] = [] + counters: Counter[str] = Counter() + + for archive_path in archives: + counters["archives_total"] += 1 + data = archive_path.read_bytes() + try: + parsed = arv.parse_nres(data, source=str(archive_path)) + except Exception as exc: # pylint: disable=broad-except + _add_issue(issues, "error", "archive", archive_path, None, f"cannot parse NRes: {exc}") + continue + + for item in parsed.get("issues", []): + _add_issue(issues, "warning", "archive", archive_path, None, str(item)) + + for entry in parsed["entries"]: + name = str(entry["name"]) + payload = _entry_payload(data, entry) + type_id = int(entry["type_id"]) + + if name.lower().endswith(".msh"): + _validate_model_payload(payload, archive_path, name, issues, counters) + + if type_id == TYPE_TEXM: + _validate_texm_payload(payload, archive_path, name, issues, counters) + + if type_id == TYPE_FXID: + _validate_fxid_payload(payload, archive_path, name, issues, counters) + + errors = sum(1 for row in issues if row["severity"] == "error") + warnings = sum(1 for row in issues if row["severity"] == "warning") + + return { + "input_root": str(input_root), + "summary": { + "archives_total": counters["archives_total"], + "models_total": counters["models_total"], + "models_ok": counters["models_ok"], + "texm_total": counters["texm_total"], + "texm_ok": counters["texm_ok"], + "fxid_total": counters["fxid_total"], + "fxid_ok": counters["fxid_ok"], + "errors": errors, + "warnings": warnings, + "issues_total": len(issues), + }, + "issues": issues, + } + + +def cmd_scan(args: argparse.Namespace) -> int: + root = Path(args.input).resolve() + report = run_validation(root) + summary = report["summary"] + print(f"Input root : {root}") + print(f"NRes archives : {summary['archives_total']}") + print(f"MSH models : {summary['models_total']}") + print(f"Texm textures : {summary['texm_total']}") + print(f"FXID effects : {summary['fxid_total']}") + return 0 + + +def cmd_validate(args: argparse.Namespace) -> int: + root = Path(args.input).resolve() + report = run_validation(root) + summary = report["summary"] + + if args.report: + arv.dump_json(Path(args.report).resolve(), report) + + print(f"Input root : {root}") + print(f"NRes archives : {summary['archives_total']}") + print(f"MSH models : {summary['models_ok']}/{summary['models_total']} valid") + print(f"Texm textures : {summary['texm_ok']}/{summary['texm_total']} valid") + print(f"FXID effects : {summary['fxid_ok']}/{summary['fxid_total']} valid") + print(f"Issues : {summary['issues_total']} (errors={summary['errors']}, warnings={summary['warnings']})") + + if report["issues"]: + limit = max(1, int(args.print_limit)) + print("\nSample issues:") + for item in report["issues"][:limit]: + where = item["archive"] + if item["entry"]: + where = f"{where}::{item['entry']}" + print(f"- [{item['severity']}] [{item['category']}] {where}: {item['message']}") + if len(report["issues"]) > limit: + print(f"... and {len(report['issues']) - limit} more issue(s)") + + if summary["errors"] > 0: + return 1 + if args.fail_on_warnings and summary["warnings"] > 0: + return 1 + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Validate docs/specs/msh.md assumptions on real archives." + ) + sub = parser.add_subparsers(dest="command", required=True) + + scan = sub.add_parser("scan", help="Quick scan and counts (models/textures/effects).") + scan.add_argument("--input", required=True, help="Root directory with game/test archives.") + scan.set_defaults(func=cmd_scan) + + validate = sub.add_parser("validate", help="Run full spec validation.") + validate.add_argument("--input", required=True, help="Root directory with game/test archives.") + validate.add_argument("--report", help="Optional JSON report output path.") + validate.add_argument( + "--print-limit", + type=int, + default=50, + help="How many issues to print to stdout (default: 50).", + ) + validate.add_argument( + "--fail-on-warnings", + action="store_true", + help="Return non-zero if warnings are present.", + ) + 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()) diff --git a/tools/msh_export_obj.py b/tools/msh_export_obj.py new file mode 100644 index 0000000..75a9602 --- /dev/null +++ b/tools/msh_export_obj.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +""" +Export NGI MSH geometry to Wavefront OBJ. + +The exporter is intended for inspection/debugging and uses the same +batch/slot selection logic as msh_preview_renderer.py. +""" + +from __future__ import annotations + +import argparse +import math +import struct +from pathlib import Path +from typing import Any + +import archive_roundtrip_validator as arv + +MAGIC_NRES = b"NRes" + + +def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: + start = int(entry["data_offset"]) + end = start + int(entry["size"]) + return blob[start:end] + + +def _parse_nres(blob: bytes, source: str) -> dict[str, Any]: + if blob[:4] != MAGIC_NRES: + raise RuntimeError(f"{source}: not an NRes payload") + return arv.parse_nres(blob, source=source) + + +def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: + out: dict[int, list[dict[str, Any]]] = {} + for row in entries: + out.setdefault(int(row["type_id"]), []).append(row) + return out + + +def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]: + rows = by_type.get(type_id, []) + if not rows: + raise RuntimeError(f"missing resource type {type_id} ({label})") + return rows[0] + + +def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]: + root_blob = archive_path.read_bytes() + parsed = _parse_nres(root_blob, str(archive_path)) + + msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")] + if msh_entries: + chosen: dict[str, Any] | None = None + if model_name: + model_l = model_name.lower() + for row in msh_entries: + name_l = str(row["name"]).lower() + if name_l == model_l: + chosen = row + break + if chosen is None: + for row in msh_entries: + if str(row["name"]).lower().startswith(model_l): + chosen = row + break + else: + chosen = msh_entries[0] + + if chosen is None: + names = ", ".join(str(row["name"]) for row in msh_entries[:12]) + raise RuntimeError( + f"model '{model_name}' not found in {archive_path}. Available: {names}" + ) + return _entry_payload(root_blob, chosen), str(chosen["name"]) + + by_type = _by_type(parsed["entries"]) + if all(k in by_type for k in (1, 2, 3, 6, 13)): + return root_blob, archive_path.name + + raise RuntimeError( + f"{archive_path} does not contain .msh entries and does not look like a direct model payload" + ) + + +def _extract_geometry( + model_blob: bytes, + *, + lod: int, + group: int, + max_faces: int, + all_batches: bool, +) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]: + parsed = _parse_nres(model_blob, "<model>") + by_type = _by_type(parsed["entries"]) + + res1 = _get_single(by_type, 1, "Res1") + res2 = _get_single(by_type, 2, "Res2") + res3 = _get_single(by_type, 3, "Res3") + res6 = _get_single(by_type, 6, "Res6") + res13 = _get_single(by_type, 13, "Res13") + + pos_blob = _entry_payload(model_blob, res3) + if len(pos_blob) % 12 != 0: + raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}") + vertex_count = len(pos_blob) // 12 + positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)] + + idx_blob = _entry_payload(model_blob, res6) + if len(idx_blob) % 2 != 0: + raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}") + index_count = len(idx_blob) // 2 + indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0)) + + batch_blob = _entry_payload(model_blob, res13) + if len(batch_blob) % 20 != 0: + raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}") + batch_count = len(batch_blob) // 20 + batches: list[tuple[int, int, int, int]] = [] + for i in range(batch_count): + off = i * 20 + idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0] + idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0] + base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0] + batches.append((idx_count, idx_start, base_vertex, i)) + + res2_blob = _entry_payload(model_blob, res2) + if len(res2_blob) < 0x8C: + raise RuntimeError("Res2 is too small (< 0x8C)") + slot_blob = res2_blob[0x8C:] + if len(slot_blob) % 68 != 0: + raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}") + slot_count = len(slot_blob) // 68 + slots: list[tuple[int, int, int, int]] = [] + for i in range(slot_count): + off = i * 68 + tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off) + slots.append((tri_start, tri_count, batch_start, slot_batch_count)) + + res1_blob = _entry_payload(model_blob, res1) + node_stride = int(res1["attr3"]) + node_count = int(res1["attr1"]) + node_slot_indices: list[int] = [] + if not all_batches and node_stride >= 38 and len(res1_blob) >= node_count * node_stride: + if lod < 0 or lod > 2: + raise RuntimeError(f"lod must be 0..2 (got {lod})") + if group < 0 or group > 4: + raise RuntimeError(f"group must be 0..4 (got {group})") + matrix_index = lod * 5 + group + for n in range(node_count): + off = n * node_stride + 8 + matrix_index * 2 + slot_idx = struct.unpack_from("<H", res1_blob, off)[0] + if slot_idx == 0xFFFF: + continue + if slot_idx >= slot_count: + continue + node_slot_indices.append(slot_idx) + + faces: list[tuple[int, int, int]] = [] + used_batches = 0 + used_slots = 0 + + def append_batch(batch_idx: int) -> None: + nonlocal used_batches + if batch_idx < 0 or batch_idx >= len(batches): + return + idx_count, idx_start, base_vertex, _ = batches[batch_idx] + if idx_count < 3: + return + end = idx_start + idx_count + if end > len(indices): + return + used_batches += 1 + tri_count = idx_count // 3 + for t in range(tri_count): + i0 = indices[idx_start + t * 3 + 0] + base_vertex + i1 = indices[idx_start + t * 3 + 1] + base_vertex + i2 = indices[idx_start + t * 3 + 2] + base_vertex + if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count: + continue + faces.append((i0, i1, i2)) + if len(faces) >= max_faces: + return + + if node_slot_indices: + for slot_idx in node_slot_indices: + if len(faces) >= max_faces: + break + _tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx] + used_slots += 1 + for bi in range(batch_start, batch_start + slot_batch_count): + append_batch(bi) + if len(faces) >= max_faces: + break + else: + for bi in range(batch_count): + append_batch(bi) + if len(faces) >= max_faces: + break + + if not faces: + raise RuntimeError("no faces selected for export") + + meta = { + "vertex_count": vertex_count, + "index_count": index_count, + "batch_count": batch_count, + "slot_count": slot_count, + "node_count": node_count, + "used_slots": used_slots, + "used_batches": used_batches, + "face_count": len(faces), + } + return positions, faces, meta + + +def _compute_vertex_normals( + positions: list[tuple[float, float, float]], + faces: list[tuple[int, int, int]], +) -> list[tuple[float, float, float]]: + acc = [[0.0, 0.0, 0.0] for _ in positions] + for i0, i1, i2 in faces: + p0 = positions[i0] + p1 = positions[i1] + p2 = positions[i2] + ux = p1[0] - p0[0] + uy = p1[1] - p0[1] + uz = p1[2] - p0[2] + vx = p2[0] - p0[0] + vy = p2[1] - p0[1] + vz = p2[2] - p0[2] + nx = uy * vz - uz * vy + ny = uz * vx - ux * vz + nz = ux * vy - uy * vx + acc[i0][0] += nx + acc[i0][1] += ny + acc[i0][2] += nz + acc[i1][0] += nx + acc[i1][1] += ny + acc[i1][2] += nz + acc[i2][0] += nx + acc[i2][1] += ny + acc[i2][2] += nz + + normals: list[tuple[float, float, float]] = [] + for nx, ny, nz in acc: + ln = math.sqrt(nx * nx + ny * ny + nz * nz) + if ln <= 1e-12: + normals.append((0.0, 1.0, 0.0)) + else: + normals.append((nx / ln, ny / ln, nz / ln)) + return normals + + +def _write_obj( + output_path: Path, + object_name: str, + positions: list[tuple[float, float, float]], + faces: list[tuple[int, int, int]], +) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + normals = _compute_vertex_normals(positions, faces) + + with output_path.open("w", encoding="utf-8", newline="\n") as out: + out.write("# Exported by msh_export_obj.py\n") + out.write(f"o {object_name}\n") + for x, y, z in positions: + out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n") + for nx, ny, nz in normals: + out.write(f"vn {nx:.9g} {ny:.9g} {nz:.9g}\n") + for i0, i1, i2 in faces: + a = i0 + 1 + b = i1 + 1 + c = i2 + 1 + out.write(f"f {a}//{a} {b}//{b} {c}//{c}\n") + + +def cmd_list_models(args: argparse.Namespace) -> int: + archive_path = Path(args.archive).resolve() + blob = archive_path.read_bytes() + parsed = _parse_nres(blob, str(archive_path)) + rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")] + print(f"Archive: {archive_path}") + print(f"MSH entries: {len(rows)}") + for row in rows: + print(f"- {row['name']}") + return 0 + + +def cmd_export(args: argparse.Namespace) -> int: + archive_path = Path(args.archive).resolve() + output_path = Path(args.output).resolve() + + model_blob, model_label = _pick_model_payload(archive_path, args.model) + positions, faces, meta = _extract_geometry( + model_blob, + lod=int(args.lod), + group=int(args.group), + max_faces=int(args.max_faces), + all_batches=bool(args.all_batches), + ) + obj_name = Path(model_label).stem or "msh_model" + _write_obj(output_path, obj_name, positions, faces) + + print(f"Exported model : {model_label}") + print(f"Output OBJ : {output_path}") + print(f"Object name : {obj_name}") + print( + "Geometry : " + f"vertices={meta['vertex_count']}, faces={meta['face_count']}, " + f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}" + ) + print( + "Mode : " + f"lod={args.lod}, group={args.group}, all_batches={bool(args.all_batches)}" + ) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Export NGI MSH geometry to Wavefront OBJ." + ) + sub = parser.add_subparsers(dest="command", required=True) + + list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.") + list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).") + list_models.set_defaults(func=cmd_list_models) + + export = sub.add_parser("export", help="Export one model to OBJ.") + export.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.") + export.add_argument( + "--model", + help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.", + ) + export.add_argument("--output", required=True, help="Output .obj path.") + export.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).") + export.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).") + export.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).") + export.add_argument( + "--all-batches", + action="store_true", + help="Ignore slot matrix selection and export all batches.", + ) + export.set_defaults(func=cmd_export) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/msh_preview_renderer.py b/tools/msh_preview_renderer.py new file mode 100644 index 0000000..53b4e63 --- /dev/null +++ b/tools/msh_preview_renderer.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +""" +Primitive software renderer for NGI MSH models. + +Output format: binary PPM (P6), no external dependencies. +""" + +from __future__ import annotations + +import argparse +import math +import struct +from pathlib import Path +from typing import Any + +import archive_roundtrip_validator as arv + +MAGIC_NRES = b"NRes" + + +def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: + start = int(entry["data_offset"]) + end = start + int(entry["size"]) + return blob[start:end] + + +def _parse_nres(blob: bytes, source: str) -> dict[str, Any]: + if blob[:4] != MAGIC_NRES: + raise RuntimeError(f"{source}: not an NRes payload") + return arv.parse_nres(blob, source=source) + + +def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: + out: dict[int, list[dict[str, Any]]] = {} + for row in entries: + out.setdefault(int(row["type_id"]), []).append(row) + return out + + +def _pick_model_payload(archive_path: Path, model_name: str | None) -> tuple[bytes, str]: + root_blob = archive_path.read_bytes() + parsed = _parse_nres(root_blob, str(archive_path)) + + msh_entries = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")] + if msh_entries: + chosen: dict[str, Any] | None = None + if model_name: + model_l = model_name.lower() + for row in msh_entries: + name_l = str(row["name"]).lower() + if name_l == model_l: + chosen = row + break + if chosen is None: + for row in msh_entries: + if str(row["name"]).lower().startswith(model_l): + chosen = row + break + else: + chosen = msh_entries[0] + + if chosen is None: + names = ", ".join(str(row["name"]) for row in msh_entries[:12]) + raise RuntimeError( + f"model '{model_name}' not found in {archive_path}. Available: {names}" + ) + return _entry_payload(root_blob, chosen), str(chosen["name"]) + + # Fallback: treat file itself as a model NRes payload. + by_type = _by_type(parsed["entries"]) + if all(k in by_type for k in (1, 2, 3, 6, 13)): + return root_blob, archive_path.name + + raise RuntimeError( + f"{archive_path} does not contain .msh entries and does not look like a direct model payload" + ) + + +def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]: + rows = by_type.get(type_id, []) + if not rows: + raise RuntimeError(f"missing resource type {type_id} ({label})") + return rows[0] + + +def _extract_geometry( + model_blob: bytes, + *, + lod: int, + group: int, + max_faces: int, +) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]: + parsed = _parse_nres(model_blob, "<model>") + by_type = _by_type(parsed["entries"]) + + res1 = _get_single(by_type, 1, "Res1") + res2 = _get_single(by_type, 2, "Res2") + res3 = _get_single(by_type, 3, "Res3") + res6 = _get_single(by_type, 6, "Res6") + res13 = _get_single(by_type, 13, "Res13") + + # Positions + pos_blob = _entry_payload(model_blob, res3) + if len(pos_blob) % 12 != 0: + raise RuntimeError(f"Res3 size is not divisible by 12: {len(pos_blob)}") + vertex_count = len(pos_blob) // 12 + positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)] + + # Indices + idx_blob = _entry_payload(model_blob, res6) + if len(idx_blob) % 2 != 0: + raise RuntimeError(f"Res6 size is not divisible by 2: {len(idx_blob)}") + index_count = len(idx_blob) // 2 + indices = list(struct.unpack_from(f"<{index_count}H", idx_blob, 0)) + + # Batches + batch_blob = _entry_payload(model_blob, res13) + if len(batch_blob) % 20 != 0: + raise RuntimeError(f"Res13 size is not divisible by 20: {len(batch_blob)}") + batch_count = len(batch_blob) // 20 + batches: list[tuple[int, int, int, int]] = [] + for i in range(batch_count): + off = i * 20 + # Keep only fields used by renderer: + # indexCount, indexStart, baseVertex + idx_count = struct.unpack_from("<H", batch_blob, off + 8)[0] + idx_start = struct.unpack_from("<I", batch_blob, off + 10)[0] + base_vertex = struct.unpack_from("<I", batch_blob, off + 16)[0] + batches.append((idx_count, idx_start, base_vertex, i)) + + # Slots + res2_blob = _entry_payload(model_blob, res2) + if len(res2_blob) < 0x8C: + raise RuntimeError("Res2 is too small (< 0x8C)") + slot_blob = res2_blob[0x8C:] + if len(slot_blob) % 68 != 0: + raise RuntimeError(f"Res2 slot area is not divisible by 68: {len(slot_blob)}") + slot_count = len(slot_blob) // 68 + slots: list[tuple[int, int, int, int]] = [] + for i in range(slot_count): + off = i * 68 + tri_start, tri_count, batch_start, slot_batch_count = struct.unpack_from("<4H", slot_blob, off) + slots.append((tri_start, tri_count, batch_start, slot_batch_count)) + + # Nodes / slot matrix + res1_blob = _entry_payload(model_blob, res1) + node_stride = int(res1["attr3"]) + node_count = int(res1["attr1"]) + node_slot_indices: list[int] = [] + if node_stride >= 38 and len(res1_blob) >= node_count * node_stride: + if lod < 0 or lod > 2: + raise RuntimeError(f"lod must be 0..2 (got {lod})") + if group < 0 or group > 4: + raise RuntimeError(f"group must be 0..4 (got {group})") + matrix_index = lod * 5 + group + for n in range(node_count): + off = n * node_stride + 8 + matrix_index * 2 + slot_idx = struct.unpack_from("<H", res1_blob, off)[0] + if slot_idx == 0xFFFF: + continue + if slot_idx >= slot_count: + continue + node_slot_indices.append(slot_idx) + + # Build triangle list. + faces: list[tuple[int, int, int]] = [] + used_batches = 0 + used_slots = 0 + + def append_batch(batch_idx: int) -> None: + nonlocal used_batches + if batch_idx < 0 or batch_idx >= len(batches): + return + idx_count, idx_start, base_vertex, _ = batches[batch_idx] + if idx_count < 3: + return + end = idx_start + idx_count + if end > len(indices): + return + used_batches += 1 + tri_count = idx_count // 3 + for t in range(tri_count): + i0 = indices[idx_start + t * 3 + 0] + base_vertex + i1 = indices[idx_start + t * 3 + 1] + base_vertex + i2 = indices[idx_start + t * 3 + 2] + base_vertex + if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count: + continue + faces.append((i0, i1, i2)) + if len(faces) >= max_faces: + return + + if node_slot_indices: + for slot_idx in node_slot_indices: + if len(faces) >= max_faces: + break + _tri_start, _tri_count, batch_start, slot_batch_count = slots[slot_idx] + used_slots += 1 + for bi in range(batch_start, batch_start + slot_batch_count): + append_batch(bi) + if len(faces) >= max_faces: + break + else: + # Fallback if slot matrix is unavailable: draw all batches. + for bi in range(batch_count): + append_batch(bi) + if len(faces) >= max_faces: + break + + meta = { + "vertex_count": vertex_count, + "index_count": index_count, + "batch_count": batch_count, + "slot_count": slot_count, + "node_count": node_count, + "used_slots": used_slots, + "used_batches": used_batches, + "face_count": len(faces), + } + if not faces: + raise RuntimeError("no faces selected for rendering") + return positions, faces, meta + + +def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as handle: + handle.write(f"P6\n{width} {height}\n255\n".encode("ascii")) + handle.write(rgb) + + +def _render_software( + positions: list[tuple[float, float, float]], + faces: list[tuple[int, int, int]], + *, + width: int, + height: int, + yaw_deg: float, + pitch_deg: float, + wireframe: bool, +) -> bytearray: + xs = [p[0] for p in positions] + ys = [p[1] for p in positions] + zs = [p[2] for p in positions] + cx = (min(xs) + max(xs)) * 0.5 + cy = (min(ys) + max(ys)) * 0.5 + cz = (min(zs) + max(zs)) * 0.5 + span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs)) + radius = max(span * 0.5, 1e-3) + + yaw = math.radians(yaw_deg) + pitch = math.radians(pitch_deg) + cyaw = math.cos(yaw) + syaw = math.sin(yaw) + cpitch = math.cos(pitch) + spitch = math.sin(pitch) + + camera_dist = radius * 3.2 + scale = min(width, height) * 0.95 + + # Transform all vertices once. + vx: list[float] = [] + vy: list[float] = [] + vz: list[float] = [] + sx: list[float] = [] + sy: list[float] = [] + for x, y, z in positions: + x0 = x - cx + y0 = y - cy + z0 = z - cz + x1 = cyaw * x0 + syaw * z0 + z1 = -syaw * x0 + cyaw * z0 + y2 = cpitch * y0 - spitch * z1 + z2 = spitch * y0 + cpitch * z1 + camera_dist + if z2 < 1e-3: + z2 = 1e-3 + vx.append(x1) + vy.append(y2) + vz.append(z2) + sx.append(width * 0.5 + (x1 / z2) * scale) + sy.append(height * 0.5 - (y2 / z2) * scale) + + rgb = bytearray([16, 18, 24] * (width * height)) + zbuf = [float("inf")] * (width * height) + light_dir = (0.35, 0.45, 1.0) + l_len = math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2) + light = (light_dir[0] / l_len, light_dir[1] / l_len, light_dir[2] / l_len) + + def edge(ax: float, ay: float, bx: float, by: float, px: float, py: float) -> float: + return (px - ax) * (by - ay) - (py - ay) * (bx - ax) + + for i0, i1, i2 in faces: + x0 = sx[i0] + y0 = sy[i0] + x1 = sx[i1] + y1 = sy[i1] + x2 = sx[i2] + y2 = sy[i2] + area = edge(x0, y0, x1, y1, x2, y2) + if area == 0.0: + continue + + # Shading from camera-space normal. + ux = vx[i1] - vx[i0] + uy = vy[i1] - vy[i0] + uz = vz[i1] - vz[i0] + wx = vx[i2] - vx[i0] + wy = vy[i2] - vy[i0] + wz = vz[i2] - vz[i0] + nx = uy * wz - uz * wy + ny = uz * wx - ux * wz + nz = ux * wy - uy * wx + n_len = math.sqrt(nx * nx + ny * ny + nz * nz) + if n_len > 0.0: + nx /= n_len + ny /= n_len + nz /= n_len + intensity = nx * light[0] + ny * light[1] + nz * light[2] + if intensity < 0.0: + intensity = 0.0 + shade = int(45 + 200 * intensity) + color = (shade, shade, min(255, shade + 18)) + + minx = int(max(0, math.floor(min(x0, x1, x2)))) + maxx = int(min(width - 1, math.ceil(max(x0, x1, x2)))) + miny = int(max(0, math.floor(min(y0, y1, y2)))) + maxy = int(min(height - 1, math.ceil(max(y0, y1, y2)))) + if minx > maxx or miny > maxy: + continue + + z0 = vz[i0] + z1 = vz[i1] + z2 = vz[i2] + + for py in range(miny, maxy + 1): + fy = py + 0.5 + row = py * width + for px in range(minx, maxx + 1): + fx = px + 0.5 + w0 = edge(x1, y1, x2, y2, fx, fy) + w1 = edge(x2, y2, x0, y0, fx, fy) + w2 = edge(x0, y0, x1, y1, fx, fy) + if area > 0: + if w0 < 0 or w1 < 0 or w2 < 0: + continue + else: + if w0 > 0 or w1 > 0 or w2 > 0: + continue + inv_area = 1.0 / area + bz0 = w0 * inv_area + bz1 = w1 * inv_area + bz2 = w2 * inv_area + depth = bz0 * z0 + bz1 * z1 + bz2 * z2 + idx = row + px + if depth >= zbuf[idx]: + continue + zbuf[idx] = depth + p = idx * 3 + rgb[p + 0] = color[0] + rgb[p + 1] = color[1] + rgb[p + 2] = color[2] + + if wireframe: + def draw_line(xa: float, ya: float, xb: float, yb: float) -> None: + x0i = int(round(xa)) + y0i = int(round(ya)) + x1i = int(round(xb)) + y1i = int(round(yb)) + dx = abs(x1i - x0i) + sx_step = 1 if x0i < x1i else -1 + dy = -abs(y1i - y0i) + sy_step = 1 if y0i < y1i else -1 + err = dx + dy + x = x0i + y = y0i + while True: + if 0 <= x < width and 0 <= y < height: + p = (y * width + x) * 3 + rgb[p + 0] = 240 + rgb[p + 1] = 245 + rgb[p + 2] = 255 + if x == x1i and y == y1i: + break + e2 = 2 * err + if e2 >= dy: + err += dy + x += sx_step + if e2 <= dx: + err += dx + y += sy_step + + for i0, i1, i2 in faces: + draw_line(sx[i0], sy[i0], sx[i1], sy[i1]) + draw_line(sx[i1], sy[i1], sx[i2], sy[i2]) + draw_line(sx[i2], sy[i2], sx[i0], sy[i0]) + + return rgb + + +def cmd_list_models(args: argparse.Namespace) -> int: + archive_path = Path(args.archive).resolve() + blob = archive_path.read_bytes() + parsed = _parse_nres(blob, str(archive_path)) + rows = [row for row in parsed["entries"] if str(row["name"]).lower().endswith(".msh")] + print(f"Archive: {archive_path}") + print(f"MSH entries: {len(rows)}") + for row in rows: + print(f"- {row['name']}") + return 0 + + +def cmd_render(args: argparse.Namespace) -> int: + archive_path = Path(args.archive).resolve() + output_path = Path(args.output).resolve() + + model_blob, model_label = _pick_model_payload(archive_path, args.model) + positions, faces, meta = _extract_geometry( + model_blob, + lod=int(args.lod), + group=int(args.group), + max_faces=int(args.max_faces), + ) + rgb = _render_software( + positions, + faces, + width=int(args.width), + height=int(args.height), + yaw_deg=float(args.yaw), + pitch_deg=float(args.pitch), + wireframe=bool(args.wireframe), + ) + _write_ppm(output_path, int(args.width), int(args.height), rgb) + + print(f"Rendered model: {model_label}") + print(f"Output : {output_path}") + print( + "Geometry : " + f"vertices={meta['vertex_count']}, faces={meta['face_count']}, " + f"batches={meta['used_batches']}/{meta['batch_count']}, slots={meta['used_slots']}/{meta['slot_count']}" + ) + print(f"Mode : lod={args.lod}, group={args.group}, wireframe={bool(args.wireframe)}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Primitive NGI MSH renderer (software, dependency-free)." + ) + sub = parser.add_subparsers(dest="command", required=True) + + list_models = sub.add_parser("list-models", help="List .msh entries in an NRes archive.") + list_models.add_argument("--archive", required=True, help="Path to archive (e.g. animals.rlb).") + list_models.set_defaults(func=cmd_list_models) + + render = sub.add_parser("render", help="Render one model to PPM image.") + render.add_argument("--archive", required=True, help="Path to NRes archive or direct model payload.") + render.add_argument( + "--model", + help="Model entry name (*.msh) inside archive. If omitted, first .msh is used.", + ) + render.add_argument("--output", required=True, help="Output .ppm file path.") + render.add_argument("--lod", type=int, default=0, help="LOD index 0..2 (default: 0).") + render.add_argument("--group", type=int, default=0, help="Group index 0..4 (default: 0).") + render.add_argument("--max-faces", type=int, default=120000, help="Face limit (default: 120000).") + render.add_argument("--width", type=int, default=1280, help="Image width (default: 1280).") + render.add_argument("--height", type=int, default=720, help="Image height (default: 720).") + render.add_argument("--yaw", type=float, default=35.0, help="Yaw angle in degrees (default: 35).") + render.add_argument("--pitch", type=float, default=18.0, help="Pitch angle in degrees (default: 18).") + render.add_argument("--wireframe", action="store_true", help="Draw white wireframe overlay.") + render.set_defaults(func=cmd_render) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) |
