aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-12 00:12:05 +0300
committerValentin Popov <valentin@popov.link>2026-02-12 00:12:05 +0300
commit041b1a6cb3159463fe81f4b2d18cb968d6f3fd87 (patch)
treef7190906dbd235d8d97c0ac664c40014005d7b90 /docs
parent5035d022206bf9ace54a43b4d65abe0b9fc0f361 (diff)
downloadfparkan-041b1a6cb3159463fe81f4b2d18cb968d6f3fd87.tar.xz
fparkan-041b1a6cb3159463fe81f4b2d18cb968d6f3fd87.zip
Добавлены спецификации для сетевой подсистемы, системы звука, загрузки ландшафта, интерфейса пользователя и пайплайна выполнения. Обновлен файл навигации mkdocs.yml для включения новых документов.
Diffstat (limited to 'docs')
-rw-r--r--docs/specs/ai.md5
-rw-r--r--docs/specs/arealmap.md5
-rw-r--r--docs/specs/behavior.md5
-rw-r--r--docs/specs/control.md5
-rw-r--r--docs/specs/fxid.md112
-rw-r--r--docs/specs/materials-texm.md299
-rw-r--r--docs/specs/missions.md5
-rw-r--r--docs/specs/msh-animation.md105
-rw-r--r--docs/specs/msh-core.md492
-rw-r--r--docs/specs/msh-notes.md277
-rw-r--r--docs/specs/msh.md1426
-rw-r--r--docs/specs/network.md5
-rw-r--r--docs/specs/runtime-pipeline.md123
-rw-r--r--docs/specs/sound.md5
-rw-r--r--docs/specs/terrain-map-loading.md32
-rw-r--r--docs/specs/ui.md5
16 files changed, 1495 insertions, 1411 deletions
diff --git a/docs/specs/ai.md b/docs/specs/ai.md
new file mode 100644
index 0000000..545c07b
--- /dev/null
+++ b/docs/specs/ai.md
@@ -0,0 +1,5 @@
+# AI system
+
+Документ описывает подсистему искусственного интеллекта: принятие решений, pathfinding и стратегическое поведение противников.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ai.dll`.
diff --git a/docs/specs/arealmap.md b/docs/specs/arealmap.md
new file mode 100644
index 0000000..cac2743
--- /dev/null
+++ b/docs/specs/arealmap.md
@@ -0,0 +1,5 @@
+# ArealMap
+
+Документ описывает формат и структуру карты мира: зоны/сектора, координаты, размещение объектов и связь с terrain и миссиями.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ArealMap.dll`.
diff --git a/docs/specs/behavior.md b/docs/specs/behavior.md
new file mode 100644
index 0000000..9ffd2dc
--- /dev/null
+++ b/docs/specs/behavior.md
@@ -0,0 +1,5 @@
+# Behavior system
+
+Документ описывает поведенческую логику юнитов: state machine/behavior-паттерны, взаимодействия и базовые правила боевого поведения.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Behavior.dll`.
diff --git a/docs/specs/control.md b/docs/specs/control.md
new file mode 100644
index 0000000..a2d3d44
--- /dev/null
+++ b/docs/specs/control.md
@@ -0,0 +1,5 @@
+# Control system
+
+Документ описывает подсистему управления: mapping ввода (клавиатура, мышь, геймпад), обработку событий и буферизацию команд.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Control.dll`.
diff --git a/docs/specs/fxid.md b/docs/specs/fxid.md
new file mode 100644
index 0000000..d4ff66d
--- /dev/null
+++ b/docs/specs/fxid.md
@@ -0,0 +1,112 @@
+# FXID
+
+Документ описывает контейнер ресурса эффекта и формат команд эффекта.
+
+---
+
+## 3.2. Контейнер ресурса эффекта
+
+Эффекты в игровых архивах хранятся как NRes‑entries типа:
+
+- `0x44495846` (`"FXID"`).
+
+Парсер эффекта находится в `Effect.dll!sub_10007650`.
+
+## 3.3. Формат payload эффекта
+
+### 3.3.1. Header (первые 60 байт)
+
+```c
+struct FxHeader60 {
+ uint32_t cmdCount; // +0x00
+ uint32_t globalFlags; // +0x04
+ float durationSec; // +0x08 (дальше умножается на 1000.0)
+ uint32_t unk0C; // +0x0C
+ uint32_t flags10; // +0x10 (используются биты 0x40 и 0x400)
+ uint8_t reserved[0x2C];// +0x14..+0x3B
+};
+```
+
+Поток команд начинается строго с `offset 0x3C`.
+
+### 3.3.2. Командный поток
+
+Каждая команда начинается с `uint32 cmdWord`, где:
+
+- `opcode = cmdWord & 0xFF`;
+- `enabled = (cmdWord >> 8) & 1` (копируется в `obj+4`).
+
+Размер команды зависит от opcode и прибавляется в **байтах** (`add edi, ...` в ASM):
+
+| Opcode | Размер записи |
+|--------|---------------|
+| 1 | 224 |
+| 2 | 148 |
+| 3 | 200 |
+| 4 | 204 |
+| 5 | 112 |
+| 6 | 4 |
+| 7 | 208 |
+| 8 | 248 |
+| 9 | 208 |
+| 10 | 208 |
+
+Никакого межкомандного выравнивания нет: следующая команда сразу после `size(opcode)`.
+
+## 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` в этом наборе не встретился, но поддерживается парсером.
+
+---
+
diff --git a/docs/specs/materials-texm.md b/docs/specs/materials-texm.md
new file mode 100644
index 0000000..4c8c8f4
--- /dev/null
+++ b/docs/specs/materials-texm.md
@@ -0,0 +1,299 @@
+# Materials + Texm
+
+Документ описывает материалы, текстуры, палитры, блоки `WEAR` / `LIGHTMAPS` и формат `Texm`.
+
+---
+
+## 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 | 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` строк. Каждая строка имеет вид:
+
+- `<int> <пробелы> <materialName>`
+
+Где:
+
+- `<int>` парсится, но фактически не используется как ключ (движок обрабатывает записи последовательно).
+- `<materialName>` — имя материала, которое движок ищет в менеджере материалов.
+ - Если материал не найден, пишется `"Material %s not found."` и используется fallback `"DEFAULT"`.
+
+> Практическая рекомендация для инструментов: считайте `<int>` как необязательный “legacy-id”, а истинным идентификатором материала делайте строку `<materialName>`.
+
+### 2.7.2. Блок LIGHTMAPS
+
+После чтения wear-списка движок последовательно читает токены (`fscanf("%s")`) до тех пор, пока не встретит слово **`LIGHTMAPS`**.
+
+Затем:
+
+1) Читается `lightmapCount`:
+
+- `lightmapCount` (обязательно `> 0`, иначе ошибка `"Illegal lightmaps length."`)
+
+2) Далее следует `lightmapCount` строк вида:
+
+- `<int> <пробелы> <lightmapName>`
+
+Где:
+
+- `<int>` парсится, но фактически не используется как ключ (аналогично wear).
+- `<lightmapName>` — имя лайтмапы; если ресурс не найден, пишется `"LightMap %s not found."`.
+
+### 2.7.3. Валидация имени лайтмапы (деталь движка)
+
+Перед загрузкой лайтмапы выполняется проверка имени:
+
+- в имени должна встречаться точка `.` **в пределах первых 16 символов**, иначе ошибка `"Bad texture name."`;
+- далее движок использует подстроку после точки в вычислениях внутренних индексов/кэша (на практике полезно придерживаться шаблона вида `NAME.A1`, `NAME.B2` и т.п.).
+
+---
+## 2.8. Формат текстурного ассета `Texm` (Ngi32.dll)
+
+Текстуры из `Textures.lib` хранятся как NRes‑entries типа `0x6D786554` (`"Texm"`).
+
+### 2.8.1. Заголовок `Texm` (32 байта)
+
+```c
+struct TexmHeader32 {
+ uint32_t magic; // 0x6D786554 ('Texm')
+ uint32_t width; // base width
+ uint32_t height; // base height
+ uint32_t mipCount; // количество уровней
+ uint32_t flags4; // наблюдаются 0 или 32
+ uint32_t flags5; // наблюдаются 0 или 0x04000000
+ uint32_t unk6; // служебное поле (часто 0, иногда ненулевое)
+ uint32_t format; // код пиксельного формата
+};
+```
+
+Подтверждённые `format`:
+
+- `0` — paletted 8-bit (индекс + palette);
+- `565`, `556`, `4444` — 16-bit семейство;
+- `888`, `8888` — 32-bit семейство.
+
+### 2.8.2. Layout payload
+
+После заголовка:
+
+1) если `format == 0`: palette блок 1024 байта (`256 × 4`);
+2) далее mip-chain пикселей;
+3) опционально chunk атласа `Page`.
+
+Размер mip-chain:
+
+```c
+bytesPerPixel = (format == 0 ? 1 : format in {565,556,4444} ? 2 : 4);
+pixelBytes = bytesPerPixel * sum_{i=0..mipCount-1}(max(1,width>>i) * max(1,height>>i));
+```
+
+Итого «чистый» размер без `Page`:
+
+```c
+sizeCore = 32 + (format == 0 ? 1024 : 0) + pixelBytes;
+```
+
+### 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.
+
+---
diff --git a/docs/specs/missions.md b/docs/specs/missions.md
new file mode 100644
index 0000000..6f351d0
--- /dev/null
+++ b/docs/specs/missions.md
@@ -0,0 +1,5 @@
+# Missions
+
+Документ описывает формат миссий и сценариев: начальное состояние, триггеры и связь миссий с картой мира.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `MisLoad.dll`.
diff --git a/docs/specs/msh-animation.md b/docs/specs/msh-animation.md
new file mode 100644
index 0000000..811fa00
--- /dev/null
+++ b/docs/specs/msh-animation.md
@@ -0,0 +1,105 @@
+# MSH animation
+
+Документ описывает анимационные ресурсы MSH: `Res8`, `Res19` и runtime-интерполяцию.
+
+---
+
+## 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.
+
+---
+
diff --git a/docs/specs/msh-core.md b/docs/specs/msh-core.md
new file mode 100644
index 0000000..82aec18
--- /dev/null
+++ b/docs/specs/msh-core.md
@@ -0,0 +1,492 @@
+# MSH core
+
+Документ описывает core-часть формата MSH: геометрию, узлы, батчи, LOD и slot-матрицу.
+
+Связанный формат контейнера: [NRes / RsLi](nres.md).
+
+---
+
+## 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.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, дополнительные данные для эффектов/деформаций.
+
+---
+
diff --git a/docs/specs/msh-notes.md b/docs/specs/msh-notes.md
new file mode 100644
index 0000000..1bd4808
--- /dev/null
+++ b/docs/specs/msh-notes.md
@@ -0,0 +1,277 @@
+# 3D implementation notes
+
+Контрольные заметки, сводки алгоритмов и остаточные семантические вопросы по 3D-подсистемам.
+
+---
+
+## 5.1. Порядок байт
+
+Все значения хранятся в **little‑endian** порядке (платформа x86/Win32).
+
+## 5.2. Выравнивание
+
+- **NRes‑ресурсы:** данные каждого ресурса внутри NRes‑архива выровнены по границе **8 байт** (0‑padding).
+- **Внутренняя структура ресурсов:** таблицы Res1/Res2/Res7/Res13 не имеют межзаписевого выравнивания — записи идут подряд.
+- **Vertex streams:** stride'ы фиксированы (12/4/8 байт) — вершинные данные идут подряд без паддинга.
+
+## 5.3. Размеры записей на диске
+
+| Ресурс | Запись | Размер (байт) | Stride |
+|--------|-----------|---------------|-------------------------|
+| Res1 | Node | 38 | 38 (19×u16) |
+| Res2 | Slot | 68 | 68 |
+| Res3 | Position | 12 | 12 (3×f32) |
+| Res4 | Normal | 4 | 4 (4×s8) |
+| Res5 | UV0 | 4 | 4 (2×s16) |
+| Res6 | Index | 2 | 2 (u16) |
+| Res7 | TriDesc | 16 | 16 |
+| Res8 | AnimKey | 24 | 24 |
+| Res10 | StringRec | переменный | `4 + (len ? len+1 : 0)` |
+| Res13 | Batch | 20 | 20 |
+| Res19 | AnimMap | 2 | 2 (u16) |
+| Res15 | VtxStr | 8 | 8 |
+| Res16 | VtxStr | 8 | 8 (2×4) |
+| Res18 | VtxStr | 4 | 4 |
+
+## 5.4. Вычисление количества элементов
+
+Количество записей вычисляется из размера ресурса:
+
+```
+count = resource_data_size / record_stride
+```
+
+Например:
+
+- `vertex_count = res3_size / 12`
+- `index_count = res6_size / 2`
+- `batch_count = res13_size / 20`
+- `slot_count = (res2_size - 140) / 68`
+- `node_count = res1_size / 38`
+- `tri_desc_count = res7_size / 16`
+- `anim_key_count = res8_size / 24`
+- `anim_map_count = res19_size / 2`
+
+Для Res10 нет фиксированного stride: нужно последовательно проходить записи `u32 len` + `(len ? len+1 : 0)` байт.
+
+## 5.5. Идентификация ресурсов в NRes
+
+Ресурсы модели идентифицируются по полю `type` (смещение 0) в каталожной записи NRes. Загрузчик использует `niFindRes(archive, type, subtype)` для поиска, где `type` — число (1, 2, 3, ... 20), а `subtype` (byte) — уточнение (из аргумента загрузчика).
+
+## 5.6. Минимальный набор для рендера
+
+Для статической модели без анимации достаточно:
+
+| Ресурс | Обязательность |
+|--------|------------------------------------------------|
+| Res1 | Да |
+| Res2 | Да |
+| Res3 | Да |
+| Res4 | Рекомендуется |
+| Res5 | Рекомендуется |
+| Res6 | Да |
+| Res7 | Для коллизии |
+| Res13 | Да |
+| Res10 | Желательно (узловые имена/поведенческие ветки) |
+| Res8 | Нет (анимация) |
+| Res19 | Нет (анимация) |
+| Res15 | Нет |
+| Res16 | Нет |
+| Res18 | Нет |
+| Res20 | Нет |
+
+## 5.7. Сводка алгоритмов декодирования
+
+### Позиции (Res3)
+
+```python
+def decode_position(data, vertex_index):
+ offset = vertex_index * 12
+ x = struct.unpack_from('<f', data, offset)[0]
+ y = struct.unpack_from('<f', data, offset + 4)[0]
+ z = struct.unpack_from('<f', data, offset + 8)[0]
+ return (x, y, z)
+```
+
+### Нормали (Res4)
+
+```python
+def decode_normal(data, vertex_index):
+ offset = vertex_index * 4
+ nx = struct.unpack_from('<b', data, offset)[0] # int8
+ ny = struct.unpack_from('<b', data, offset + 1)[0]
+ nz = struct.unpack_from('<b', data, offset + 2)[0]
+ # nw = data[offset + 3] # не используется
+ return (
+ max(-1.0, min(1.0, nx / 127.0)),
+ max(-1.0, min(1.0, ny / 127.0)),
+ max(-1.0, min(1.0, nz / 127.0)),
+ )
+```
+
+### UV‑координаты (Res5)
+
+```python
+def decode_uv(data, vertex_index):
+ offset = vertex_index * 4
+ u = struct.unpack_from('<h', data, offset)[0] # int16
+ v = struct.unpack_from('<h', data, offset + 2)[0]
+ return (u / 1024.0, v / 1024.0)
+```
+
+### Кодирование нормали (для экспортёра)
+
+```python
+def encode_normal(nx, ny, nz):
+ return (
+ max(-128, min(127, int(round(nx * 127.0)))),
+ max(-128, min(127, int(round(ny * 127.0)))),
+ max(-128, min(127, int(round(nz * 127.0)))),
+ 0 # nw = 0 (безопасное значение)
+ )
+```
+
+### Кодирование UV (для экспортёра)
+
+```python
+def encode_uv(u, v):
+ return (
+ max(-32768, min(32767, int(round(u * 1024.0)))),
+ max(-32768, min(32767, int(round(v * 1024.0))))
+ )
+```
+
+### Строки узлов (Res10)
+
+```python
+def parse_res10_for_nodes(buf: bytes, node_count: int) -> list[str | None]:
+ out = []
+ off = 0
+ for _ in range(node_count):
+ ln = struct.unpack_from('<I', buf, off)[0]
+ off += 4
+ if ln == 0:
+ out.append(None)
+ continue
+ raw = buf[off:off + ln + 1] # len + '\0'
+ out.append(raw[:-1].decode('ascii', errors='replace'))
+ off += ln + 1
+ return out
+```
+
+### Ключ анимации (Res8) и mapping (Res19)
+
+```python
+def decode_anim_key24(buf: bytes, idx: int):
+ o = idx * 24
+ px, py, pz, t = struct.unpack_from('<4f', buf, o)
+ qx, qy, qz, qw = struct.unpack_from('<4h', buf, o + 16)
+ s = 1.0 / 32767.0
+ return (px, py, pz), t, (qx * s, qy * s, qz * s, qw * s)
+```
+
+### Эффектный поток (FXID)
+
+```python
+FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208}
+
+def parse_fx_payload(raw: bytes):
+ cmd_count = struct.unpack_from('<I', raw, 0)[0]
+ ptr = 0x3C
+ cmds = []
+ for _ in range(cmd_count):
+ w = struct.unpack_from('<I', raw, ptr)[0]
+ op = w & 0xFF
+ enabled = (w >> 8) & 1
+ size = FX_CMD_SIZE[op]
+ cmds.append((op, enabled, ptr, size))
+ ptr += size
+ if ptr != len(raw):
+ raise ValueError('tail bytes after command stream')
+ return cmds
+```
+
+### Texm (header + mips + Page)
+
+```python
+def parse_texm(raw: bytes):
+ magic, w, h, mips, f4, f5, unk6, fmt = struct.unpack_from('<8I', raw, 0)
+ assert magic == 0x6D786554 # 'Texm'
+ bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4)
+ pix_sum = 0
+ mw, mh = w, h
+ for _ in range(mips):
+ pix_sum += mw * mh
+ mw = max(1, mw >> 1)
+ mh = max(1, mh >> 1)
+ off = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum
+ page = None
+ if off + 8 <= len(raw) and raw[off:off+4] == b'Page':
+ n = struct.unpack_from('<I', raw, off + 4)[0]
+ page = [struct.unpack_from('<4h', raw, off + 8 + i * 8) for i in range(n)]
+ return (w, h, mips, fmt, f4, f5, unk6, page)
+```
+
+---
+
+# Часть 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 из референсного оригинального ассета той же структуры.
diff --git a/docs/specs/msh.md b/docs/specs/msh.md
index 7819569..e2623f8 100644
--- a/docs/specs/msh.md
+++ b/docs/specs/msh.md
@@ -1,1418 +1,22 @@
-# Форматы 3D‑ресурсов движка NGI
+# Форматы 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‑связывание.
+1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица.
+2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция.
+3. [Materials + Texm](materials-texm.md) — материалы, текстуры, палитры, `WEAR`, `LIGHTMAPS`, `Texm`.
+4. [FXID](fxid.md) — контейнер эффекта и команды runtime-потока.
+5. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
+6. [Runtime pipeline](runtime-pipeline.md) — межмодульное поведение движка в кадре.
+7. [3D implementation notes](msh-notes.md) — контрольные заметки, декодирование и открытые вопросы.
-Все данные хранятся в **little‑endian** порядке (платформа x86/Win32).
-Ресурсы моделей читаются из архивов **[NRes](nres.md)**.
+## Связанные спецификации
----
+- [NRes / RsLi](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<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 | 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` строк. Каждая строка имеет вид:
-
-- `<int> <пробелы> <materialName>`
-
-Где:
-
-- `<int>` парсится, но фактически не используется как ключ (движок обрабатывает записи последовательно).
-- `<materialName>` — имя материала, которое движок ищет в менеджере материалов.
- - Если материал не найден, пишется `"Material %s not found."` и используется fallback `"DEFAULT"`.
-
-> Практическая рекомендация для инструментов: считайте `<int>` как необязательный “legacy-id”, а истинным идентификатором материала делайте строку `<materialName>`.
-
-### 2.7.2. Блок LIGHTMAPS
-
-После чтения wear-списка движок последовательно читает токены (`fscanf("%s")`) до тех пор, пока не встретит слово **`LIGHTMAPS`**.
-
-Затем:
-
-1) Читается `lightmapCount`:
-
-- `lightmapCount` (обязательно `> 0`, иначе ошибка `"Illegal lightmaps length."`)
-
-2) Далее следует `lightmapCount` строк вида:
-
-- `<int> <пробелы> <lightmapName>`
-
-Где:
-
-- `<int>` парсится, но фактически не используется как ключ (аналогично wear).
-- `<lightmapName>` — имя лайтмапы; если ресурс не найден, пишется `"LightMap %s not found."`.
-
-### 2.7.3. Валидация имени лайтмапы (деталь движка)
-
-Перед загрузкой лайтмапы выполняется проверка имени:
-
-- в имени должна встречаться точка `.` **в пределах первых 16 символов**, иначе ошибка `"Bad texture name."`;
-- далее движок использует подстроку после точки в вычислениях внутренних индексов/кэша (на практике полезно придерживаться шаблона вида `NAME.A1`, `NAME.B2` и т.п.).
-
----
-## 2.8. Формат текстурного ассета `Texm` (Ngi32.dll)
-
-Текстуры из `Textures.lib` хранятся как NRes‑entries типа `0x6D786554` (`"Texm"`).
-
-### 2.8.1. Заголовок `Texm` (32 байта)
-
-```c
-struct TexmHeader32 {
- uint32_t magic; // 0x6D786554 ('Texm')
- uint32_t width; // base width
- uint32_t height; // base height
- uint32_t mipCount; // количество уровней
- uint32_t flags4; // наблюдаются 0 или 32
- uint32_t flags5; // наблюдаются 0 или 0x04000000
- uint32_t unk6; // служебное поле (часто 0, иногда ненулевое)
- uint32_t format; // код пиксельного формата
-};
-```
-
-Подтверждённые `format`:
-
-- `0` — paletted 8-bit (индекс + palette);
-- `565`, `556`, `4444` — 16-bit семейство;
-- `888`, `8888` — 32-bit семейство.
-
-### 2.8.2. Layout payload
-
-После заголовка:
-
-1) если `format == 0`: palette блок 1024 байта (`256 × 4`);
-2) далее mip-chain пикселей;
-3) опционально chunk атласа `Page`.
-
-Размер mip-chain:
-
-```c
-bytesPerPixel = (format == 0 ? 1 : format in {565,556,4444} ? 2 : 4);
-pixelBytes = bytesPerPixel * sum_{i=0..mipCount-1}(max(1,width>>i) * max(1,height>>i));
-```
-
-Итого «чистый» размер без `Page`:
-
-```c
-sizeCore = 32 + (format == 0 ? 1024 : 0) + pixelBytes;
-```
-
-### 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. Контейнер ресурса эффекта
-
-Эффекты в игровых архивах хранятся как NRes‑entries типа:
-
-- `0x44495846` (`"FXID"`).
-
-Парсер эффекта находится в `Effect.dll!sub_10007650`.
-
-## 3.3. Формат payload эффекта
-
-### 3.3.1. Header (первые 60 байт)
-
-```c
-struct FxHeader60 {
- uint32_t cmdCount; // +0x00
- uint32_t globalFlags; // +0x04
- float durationSec; // +0x08 (дальше умножается на 1000.0)
- uint32_t unk0C; // +0x0C
- uint32_t flags10; // +0x10 (используются биты 0x40 и 0x400)
- uint8_t reserved[0x2C];// +0x14..+0x3B
-};
-```
-
-Поток команд начинается строго с `offset 0x3C`.
-
-### 3.3.2. Командный поток
-
-Каждая команда начинается с `uint32 cmdWord`, где:
-
-- `opcode = cmdWord & 0xFF`;
-- `enabled = (cmdWord >> 8) & 1` (копируется в `obj+4`).
-
-Размер команды зависит от opcode и прибавляется в **байтах** (`add edi, ...` в ASM):
-
-| Opcode | Размер записи |
-|--------|---------------|
-| 1 | 224 |
-| 2 | 148 |
-| 3 | 200 |
-| 4 | 204 |
-| 5 | 112 |
-| 6 | 4 |
-| 7 | 208 |
-| 8 | 248 |
-| 9 | 208 |
-| 10 | 208 |
-
-Никакого межкомандного выравнивания нет: следующая команда сразу после `size(opcode)`.
-
-## 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('<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 из референсного оригинального ассета той же структуры.
+- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
+- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько DLL и не является форматом на диске.
diff --git a/docs/specs/network.md b/docs/specs/network.md
new file mode 100644
index 0000000..1950e8a
--- /dev/null
+++ b/docs/specs/network.md
@@ -0,0 +1,5 @@
+# Network system
+
+Документ описывает сетевую подсистему: протокол обмена, синхронизацию состояния и сетевую архитектуру (client-server/P2P).
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Net.dll`.
diff --git a/docs/specs/runtime-pipeline.md b/docs/specs/runtime-pipeline.md
new file mode 100644
index 0000000..7021c82
--- /dev/null
+++ b/docs/specs/runtime-pipeline.md
@@ -0,0 +1,123 @@
+# Runtime pipeline
+
+Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов.
+
+---
+
+## 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
+```
+
+---
+
+
+---
+
+## 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‑рендер‑пайплайн.
+
diff --git a/docs/specs/sound.md b/docs/specs/sound.md
new file mode 100644
index 0000000..da2a6ee
--- /dev/null
+++ b/docs/specs/sound.md
@@ -0,0 +1,5 @@
+# Sound system
+
+Документ описывает аудиоподсистему: форматы звуковых ресурсов, воспроизведение эффектов и голосов, а также интеграцию со звуковым API.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга звуковых модулей движка.
diff --git a/docs/specs/terrain-map-loading.md b/docs/specs/terrain-map-loading.md
new file mode 100644
index 0000000..0fb6e1f
--- /dev/null
+++ b/docs/specs/terrain-map-loading.md
@@ -0,0 +1,32 @@
+# Terrain + map loading
+
+Документ описывает подсистему ландшафта и привязку terrain-данных к миру.
+
+---
+
+## 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 прохода для ландшафта).
diff --git a/docs/specs/ui.md b/docs/specs/ui.md
new file mode 100644
index 0000000..9d71dfd
--- /dev/null
+++ b/docs/specs/ui.md
@@ -0,0 +1,5 @@
+# UI system
+
+Документ описывает интерфейсную подсистему: ресурсы UI, шрифты, minimap, layout и обработку пользовательского ввода в интерфейсе.
+
+> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга UI-компонентов движка.