Files
fparkan/docs/specs/msh.md
Valentin Popov 5035d02220
All checks were successful
Test / Lint (push) Successful in 46s
Test / Test (push) Successful in 41s
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.
2026-02-10 23:27:43 +00:00

1419 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Форматы 3Dресурсов движка NGI
## Обзор
Библиотеки `AniMesh.dll`, `World3D.dll`, `Terrain.dll` и `Effect.dll` реализуют подсистемы трёхмерной графики движка NGI (Nikita Game Interface), используемого в игре *Parkan: Iron Strategy*. Данный документ описывает:
1. **MSH / AniMesh** — формат 3Dмоделей (геометрия, иерархия узлов, LOD, батчи, анимация).
2. **Материалы** — структура записи материала, система библиотек текстур/палитр, рендер‑конфигурация.
3. **Эффекты и частицы** — бинарный формат `FXID`, разбор команд и runtimeсвязывание.
Все данные хранятся в **littleendian** порядке (платформа x86/Win32).
Ресурсы моделей читаются из архивов **[NRes](nres.md)**.
---
# Часть 1. Формат 3Dмоделей (MSH / AniMesh)
## 1.1. Общая архитектура
Модель состоит из набора именованных ресурсов внутри одного NResархива. Каждый ресурс идентифицируется **целочисленным типом** (`resource_type`), который передаётся API функции `niReadData` (vtableметод `+0x18`) через связку `niFind` (vtableметод `+0x0C`, `+0x20`).
Рендер‑модель использует **rigidскининг по узлам** (нет pervertex 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`, после чего считывается поле.
---
### 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 вершин глобального boundinghull (`vec3[8]`) |
| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная boundingsphere: `center.xyz + radius` |
| `0x70..0x8B` | `0x1C` | `float[7]` | Глобальный «капсульный»/сегментный bound: `A.xyz`, `B.xyz`, `radius` |
Для рендера и 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** — axisaligned 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битных значений (littleendian). Их назначение пока не подтверждено; для инструментов рекомендуется сохранять и восстанавливать их «как есть».
- `+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.4.3. Восстановление счётчиков элементов по размерам ресурсов (практика для инструментов)
Для toolchain надёжнее считать count'ы по размерам ресурсов (а не по дублирующим полям других таблиц). Это полностью совпадает с тем, как рантайм использует fixed stride'ы в `sub_10015FD0`.
Берите **unpacked_size** (или фактический размер распакованного блока) соответствующего ресурса и вычисляйте:
- `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 присутствует)
**Валидация:**
- Любое деление должно быть **без остатка**; иначе ресурс повреждён или stride неверно угадан.
- Если присутствуют Res4/Res5/Res15/Res16/Res18, их count'ы по смыслу должны совпадать с `vertex_count` (или быть ≥ него, если формат допускает хвостовые данные — пока не наблюдалось).
- Для `slot_count` дополнительно проверьте, что `size(Res2) >= 0x8C`.
**Проверка на реальных данных (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 singleprecision).
**Stride:** 12 байт.
```c
struct Position {
float x; // +0
float y; // +4
float z; // +8
};
```
Чтение: `pos = *(float3*)(res3_data + 12 * vertexIndex)`.
---
## 1.7. Ресурс Res4 — Packed Normals
**Формат:** 4 байта на вершину.
**Stride:** 4 байта.
```c
struct PackedNormal {
int8_t nx; // +0
int8_t ny; // +1
int8_t nz; // +2
int8_t nw; // +3 (назначение не подтверждено: паддинг / знак / индекс)
};
```
### Алгоритм декодирования (подтверждено по AniMesh.dll)
> В движке используется делитель **127.0**, а не 128.0 (см. константу `127.0` рядом с `1024.0`/`32767.0`).
```
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):** используется ли он как часть нормали, как индекс или просто как выравнивание — не подтверждено. Рекомендация: игнорировать при первичном импорте.
---
## 1.8. Ресурс Res5 — Packed UV0
**Формат:** 4 байта на вершину (два `int16`).
**Stride:** 4 байта.
```c
struct PackedUV {
int16_t u; // +0
int16_t v; // +2
};
```
### Алгоритм декодирования
```
uv.u = (float)u / 1024.0
uv.v = (float)v / 1024.0
```
**Множитель:** `1.0 / 1024.0 = 0.0009765625`.
**Диапазон входных значений:** 32768..+32767 → выход ≈ 32.0..+31.999.
Значения >1.0 или <0.0 означают wrapping/repeat текстурных координат.
### Алгоритм кодирования (для экспортёра)
```
packed_u = (int16_t)round(uv.u * 1024.0)
packed_v = (int16_t)round(uv.v * 1024.0)
```
Результат обрезается (clamp) до диапазона `int16` (32768..+32767).
---
## 1.9. Ресурс Res6 — Index Buffer
**Формат:** массив `uint16` (беззнаковые 16битные индексы).
**Stride:** 2 байта.
Максимальное число вершин в одном batch: 65535.
Индексы используются совместно с `baseVertex` из batch table:
```
actual_vertex_index = index_buffer[indexStart + i] + baseVertex
```
---
## 1.10. Ресурс Res7 — Triangle Descriptors
**Формат:** массив записей по 16 байт. Одна запись на треугольник.
| Смещение | Размер | Тип | Описание |
|----------|--------|----------|---------------------------------------------|
| `+0x00` | 2 | `uint16` | `triFlags` — фильтрация/материал triуровня |
| `+0x02` | 2 | `uint16` | `linkTri0` — triref для связанного обхода |
| `+0x04` | 2 | `uint16` | `linkTri1` — triref для связанного обхода |
| `+0x06` | 2 | `uint16` | `linkTri2` — triref для связанного обхода |
| `+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 бита |
Расшифровка `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;
```
`linkTri*` передаются в `sub_1000B2C0` и используются для построения соседнего набора треугольников при коллизии/пикинге.
**Важно:** дескрипторы не хранят индексы вершин треугольника. Индексы берутся из Res6 (index buffer) через `indexStart`/`indexCount` соответствующего batch'а.
Дескрипторы используются при обходе треугольников для коллизии и пикинга. `triStart` из slot table указывает, с какого дескриптора начинать обход для данного слота.
---
## 1.11. Ресурс Res13 — Batch Table
**Формат:** массив записей по 20 байт. Batch — минимальная единица отрисовки.
| Смещение | Размер | Тип | Описание |
|----------|--------|--------|---------------------------------------------------------|
| 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` — смещение вершинного индекса |
### Использование при рендере
```
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флагов).
---
## 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) выполнить shortestpath коррекцию знака 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 .. nodeCount1):
a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform
b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0]
если slotIndex == 0xFFFF → пропустить узел
c. slot = slotTable[slotIndex]
d. // Frustum culling:
transformedAABB = transform(slot.aabb, nodeTransform)
если transformedAABB вне cameraFrustum → пропустить
// Альтернативно по сфере:
transformedCenter = nodeTransform × slot.sphereCenter
scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ)
если сфера вне frustum → пропустить
e. Для i = 0 .. slot.batchCount 1:
batch = batchTable[slot.batchStart + i]
// Фильтрация по batchFlags (если нужна)
// Установить материал:
setMaterial(batch.materialIndex)
// Установить transform:
setWorldMatrix(nodeTransform)
// Нарисовать:
DrawIndexedPrimitive(
baseVertex = batch.baseVertex,
indexStart = batch.indexStart,
indexCount = batch.indexCount,
primitiveType = TRIANGLE_LIST
)
```
---
## 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) → пропустить
// Получить индексы вершин:
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
// Получить позиции:
p0 = positions[idx0]
p1 = positions[idx1]
p2 = positions[idx2]
callback(triDesc, idx0, idx1, idx2, p0, p1, p2)
triDescIndex += 1
```
---
# Часть 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 байт)
Материал представлен записью размером **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 | Нет | Дополнительный параметр |
### Маппинг компонентов на D3D Material (предположительный)
По аналогии со стандартной структурой `D3DMATERIAL7`:
| Компонент | Вероятное назначение | Биты интерполяции |
|--------------|----------------------|-------------------|
| 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)
- Значение `< 0` означает «нет текстуры» → `texture_ptr = NULL`.
- Значение `≥ 0` используется как индекс в глобальном массиве текстурных объектов: `texture = texture_array[5 * textureIndex]`.
## 2.3. Алгоритм интерполяции материалов
Движок поддерживает **анимацию материалов** между ключевыми кадрами. Функция `sub_10003030`:
```
Вход: mat_a (исходный), mat_b (целевой), t (фактор 0..1), mask (битовая маска)
Выход: mat_result
Для каждого бита mask:
если бит установлен:
mat_result.component = mat_a.component + (mat_b.component - mat_a.component) × t
иначе:
mat_result.component = mat_a.component (без интерполяции)
mat_result.textureIndex = mat_a.textureIndex (всегда копируется без интерполяции)
```
### Режимы анимации материалов
Материал может иметь несколько фаз (phase) с разными режимами цикличности:
| Режим (flags & 7) | Описание |
|-------------------|-------------------------------------|
| 0 | Цикл: повтор с начала |
| 1 | Pingpong: туда‑обратно |
| 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).
---
## 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` хранятся как NResentries типа `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;
```
### 2.8.3. Опциональный `Page` chunk
Если после `sizeCore` остаются байты и в этой позиции стоит magic `"Page"` (`0x65676150`), парсер `sub_1000FF60` читает таблицу subrect:
```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. Контейнер ресурса эффекта
Эффекты в игровых архивах хранятся как NResentries типа:
- `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)`.
## 3.4. Runtime-классы команд (vtable mapping)
В `sub_10007650` для каждого opcode создаётся объект конкретного типа:
- `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`
`flags10 & 0x400` включает глобальный runtime-флаг менеджера эффекта (`manager+0xA0`).
## 3.5. Алгоритм загрузки эффекта (1:1)
```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` в этом наборе не встретился, но поддерживается парсером.
---
# Часть 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()` со следующими проверками:
- Валидация размера текстуры (`"Unsupported texture size"`).
- Создание D3Dтекстуры (`"Unable to create texture"`).
Ландшафт использует **микротекстуры** (microtexture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности.
## 4.3. Защита от пустых примитивов
Terrain.dll содержит проверки:
- `"Rendering empty primitive!"` — перед первым вызовом отрисовки.
- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки.
Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта).
---
# Часть 5. Контрольные заметки для реализации
## 5.1. Порядок байт
Все значения хранятся в **littleendian** порядке (платформа x86/Win32).
## 5.2. Выравнивание
- **NResресурсы:** данные каждого ресурса внутри NResархива выровнены по границе **8 байт** (0padding).
- **Внутренняя структура ресурсов:** таблицы 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)
```
---
# Часть 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. `OBJ -> MSH` сейчас реалистичен как **ограниченный static-экспорт** (позиции/индексы/часть batch/slot структуры).
2. `OBJ -> полноценный игровой ресурс` (без потерь, с поведением 1:1) пока недостижим без закрытия пунктов A/B/C.
3. До закрытия пунктов A/B/C рекомендуется использовать режим:
- геометрия экспортируется из `OBJ`;
- неизвестные/служебные поля берутся copy-through из референсного оригинального ассета той же структуры.