# Форматы 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‑связывание. Все данные хранятся в **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`, после чего считывается поле. --- ### 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` | Для рендера и 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.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 single‑precision). **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` — 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 бита | Расшифровка `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) выполнить 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] 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.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 | 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). --- ## 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` строк. Каждая строка имеет вид: - ` <пробелы> ` Где: - `` парсится, но фактически не используется как ключ (движок обрабатывает записи последовательно). - `` — имя материала, которое движок ищет в менеджере материалов. - Если материал не найден, пишется `"Material %s not found."` и используется fallback `"DEFAULT"`. > Практическая рекомендация для инструментов: считайте `` как необязательный “legacy-id”, а истинным идентификатором материала делайте строку ``. ### 2.7.2. Блок LIGHTMAPS После чтения wear-списка движок последовательно читает токены (`fscanf("%s")`) до тех пор, пока не встретит слово **`LIGHTMAPS`**. Затем: 1) Читается `lightmapCount`: - `lightmapCount` (обязательно `> 0`, иначе ошибка `"Illegal lightmaps length."`) 2) Далее следует `lightmapCount` строк вида: - ` <пробелы> ` Где: - `` парсится, но фактически не используется как ключ (аналогично wear). - `` — имя лайтмапы; если ресурс не найден, пишется `"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; ``` ### 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<> 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"`). Ландшафт использует **микротекстуры** (micro‑texture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности. ## 4.3. Защита от пустых примитивов Terrain.dll содержит проверки: - `"Rendering empty primitive!"` — перед первым вызовом отрисовки. - `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки. Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта). --- # Часть 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(' list[str | None]: out = [] off = 0 for _ in range(node_count): ln = struct.unpack_from('> 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(' 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 из референсного оригинального ассета той же структуры.