aboutsummaryrefslogtreecommitdiff
path: root/docs/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/specs')
-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.md834
-rw-r--r--docs/specs/materials-texm.md874
-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.md22
-rw-r--r--docs/specs/network.md5
-rw-r--r--docs/specs/nres.md718
-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
17 files changed, 3517 insertions, 0 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..7dd1d4b
--- /dev/null
+++ b/docs/specs/fxid.md
@@ -0,0 +1,834 @@
+# FXID
+
+Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для:
+
+- 1:1 загрузки и исполнения в совместимом runtime;
+- построения валидатора payload;
+- создания lossless-конвертера (`binary -> IR -> binary`);
+- создания редактора с безопасным редактированием полей.
+
+Связанный контейнер: [NRes / RsLi](nres.md).
+
+---
+
+## 1. Источники и статус восстановления
+
+Спецификация восстановлена по:
+
+- `tmp/disassembler1/Effect.dll.c`;
+- `tmp/disassembler2/Effect.dll.asm`;
+- интеграционным вызовам из `tmp/disassembler1/Terrain.dll.c`;
+- проверке реальных архивов `testdata/nres`.
+
+Ключевые функции:
+
+- parser FXID: `Effect.dll!sub_10007650`;
+- runtime loop: `sub_10003D30(case 28)`, `sub_10006170`, `sub_10008120`, `sub_10007D10`;
+- alpha/time: `sub_10005C60`;
+- exports: `CreateFxManager`, `InitializeSettings`.
+
+Проверка по данным:
+
+- `923/923` FXID payload валидны в `testdata/nres`.
+
+---
+
+## 2. Контейнер и runtime API
+
+### 2.1. NRes entry
+
+FXID хранится как NRes-entry:
+
+- `type_id = 0x44495846` (`"FXID"`).
+
+Наблюдение по датасету (923 эффекта):
+
+- `attr1 = 0`, `attr2 = 0`, `attr3 = 1`.
+
+### 2.2. Export API `Effect.dll`
+
+Экспортируются:
+
+- `CreateFxManager(int a1, int a2, int owner)`;
+- `InitializeSettings()`.
+
+`CreateFxManager` создаёт manager-объект (`0xB8` байт), инициализирует через `sub_10003AE0`, возвращает интерфейсный указатель (`base + 4`).
+
+### 2.3. Интерфейс менеджера
+
+Рабочая vtable (`off_1001E478`):
+
+| Смещение | Функция | Назначение |
+|---|---|---|
+| +0x08 | `sub_10003D30` | Event dispatcher (`4/20/23/24/28`) |
+| +0x10 | `sub_10004320` | Открыть/закэшировать FX resource |
+| +0x14 | `sub_10004590` | Создать runtime instance |
+| +0x18 | `sub_10004780` | Удалить instance |
+| +0x1C | `sub_100047B0` | Установить time/interp mode |
+| +0x20 | `sub_100047D0` | Установить scale |
+| +0x24 | `sub_10004830` | Установить позицию |
+| +0x28 | `sub_10004930` | Установить matrix transform |
+| +0x2C | `sub_10004B00` | Restart/retime |
+| +0x38 | `sub_10004BA0` | Duration modifier |
+| +0x3C | `sub_10004BD0` | Start/Enable |
+| +0x40 | `sub_10004C10` | Stop/Disable |
+| +0x44 | `sub_10004C50` | Bind emitter/context |
+| +0x48 | `sub_10004D50` | Сброс frame flags |
+
+`Terrain.dll` использует `QueryInterface(id=19)` для получения рабочего интерфейса.
+
+---
+
+## 3. Бинарный формат FXID payload
+
+Все значения little-endian.
+
+### 3.1. Header (60 байт, `0x3C`)
+
+```c
+struct FxHeader60 {
+ uint32_t cmd_count; // 0x00
+ uint32_t time_mode; // 0x04
+ float duration_sec; // 0x08
+ float phase_jitter; // 0x0C
+ uint32_t flags; // 0x10
+ uint32_t settings_id; // 0x14
+ float rand_shift_x; // 0x18
+ float rand_shift_y; // 0x1C
+ float rand_shift_z; // 0x20
+ float pivot_x; // 0x24
+ float pivot_y; // 0x28
+ float pivot_z; // 0x2C
+ float scale_x; // 0x30
+ float scale_y; // 0x34
+ float scale_z; // 0x38
+};
+```
+
+Командный поток начинается строго с `offset = 0x3C`.
+
+### 3.2. Header-поля (подтвержденная семантика)
+
+- `cmd_count`: число команд (engine итерирует ровно столько шагов).
+- `time_mode`: базовый режим вычисления alpha/time (`sub_10005C60`).
+- `duration_sec`: в runtime -> `duration_ms = duration_sec * 1000`.
+- `phase_jitter`: используется при `flags & 0x1`.
+- `flags`: runtime-gating/alpha/visibility (см. ниже).
+- `settings_id`: в `sub_1000EC40` используется `settings_id & 0xFF`.
+- `rand_shift_*`: используется при `flags & 0x8`.
+- `pivot_*`: используется в ветках `sub_10007D10`.
+- `scale_*`: копируется в runtime scale и влияет на матрицы.
+
+### 3.3. `flags` (битовая карта)
+
+| Бит | Маска | Наблюдаемое поведение |
+|---|---:|---|
+| 0 | `0x0001` | Random phase jitter (`phase_jitter`) |
+| 3 | `0x0008` | Random positional shift (`rand_shift_*`) |
+| 4 | `0x0010` | Visibility/occlusion ветки |
+| 5 | `0x0020` | Triangular remap в `sub_10005C60` |
+| 6 | `0x0040` | Инверсия начального active-state |
+| 7 | `0x0080` | Day/night filter (ветка A) |
+| 8 | `0x0100` | Day/night filter (ветка B, инверсия) |
+| 9 | `0x0200` | Alpha *= normalized lifetime |
+| 10 | `0x0400` | Установка manager bit1 (`+0xA0`) |
+| 11 | `0x0800` | Изменение gating в `sub_10007D10` |
+| 12 | `0x1000` | Установка manager-state bit `0x10` |
+
+Нерасшифрованные биты должны сохраняться 1:1.
+
+### 3.4. `time_mode` (`0..17`)
+
+Обозначения (`sub_10005C60`):
+
+- `t0 = instance.start_ms`, `t1 = instance.end_ms`;
+- `tn = (now_ms - t0) / (t1 - t0)`;
+- `prev = instance.cached_alpha` (`v4+52` в дизассембле).
+
+Режимы:
+
+- `0`: constant (`instance.alpha_const`, поле `v4+40`);
+- `1`: `tn`;
+- `2`: `fract(tn)`;
+- `3`: `1 - tn`;
+- `4`: external value из queue/world API (manager `+36`, id из `this+104[a2]`);
+- `5`: `|param33.xyz| / |param17.vecA.xyz|`;
+- `6`: `param33.x / param17.vecA.x`;
+- `7`: `param33.y / param17.vecA.y`;
+- `8`: `param33.z / param17.vecA.z`;
+- `9`: `|param36.xyz| / |param17.vecB.xyz|`;
+- `10`: `param36.x / param17.vecB.x`;
+- `11`: `param36.y / param17.vecB.y`;
+- `12`: `param36.z / param17.vecB.z`;
+- `13`: `1 - external_resource_value`;
+- `14`: `1 - queue_param(49)`;
+- `15`: `max(norm(param33/vecA), norm(param36/vecB))`;
+- `16`: external (`mode 4`) с нижним clamp к `prev` (`0` не зажимается);
+- `17`: external (`mode 4`) с верхним clamp к `prev` (`1` не зажимается).
+
+Post-обработка после mode:
+
+- если `flags & 0x200`: `alpha *= tn`;
+- если `flags & 0x20`: triangular remap (`alpha = (alpha < 0.5 ? alpha : 1-alpha) * 2`).
+
+---
+
+## 4. Командный поток
+
+### 4.1. Общий формат команды
+
+Каждая команда:
+
+- `uint32 cmd_word`;
+- далее body фиксированного размера по opcode.
+
+`cmd_word`:
+
+- `opcode = cmd_word & 0xFF`;
+- `enabled = (cmd_word >> 8) & 1`;
+- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1.
+
+Выравнивания между командами нет.
+
+### 4.2. Размеры
+
+| Opcode | Размер записи |
+|---:|---:|
+| 1 | 224 |
+| 2 | 148 |
+| 3 | 200 |
+| 4 | 204 |
+| 5 | 112 |
+| 6 | 4 |
+| 7 | 208 |
+| 8 | 248 |
+| 9 | 208 |
+| 10 | 208 |
+
+### 4.3. Opcode -> runtime-класс (vtable)
+
+| Opcode | `new(size)` | vtable |
+|---:|---:|---|
+| 1 | `0xF0` | `off_1001E78C` |
+| 2 | `0xA0` | `off_1001F048` |
+| 3 | `0xFC` | `off_1001E770` |
+| 4 | `0x104` | `off_1001E754` |
+| 5 | `0x54` | `off_1001E360` |
+| 6 | `0x1C` | `off_1001E738` |
+| 7 | `0x48` | `off_1001E228` |
+| 8 | `0xAC` | `off_1001E71C` |
+| 9 | `0x100` | `off_1001E700` |
+| 10 | `0x48` | `off_1001E24C` |
+
+### 4.4. Общий вызовной контракт команды
+
+После создания команды (`sub_10007650`):
+
+1. `cmd->enabled = cmd_word.bit8`.
+2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`).
+3. команда добавляется в список инстанса.
+
+В runtime cycle:
+
+- `vfunc +8`: update/compute (bool);
+- `vfunc +12`: emission/render callback;
+- `vfunc +20`: toggle active;
+- `vfunc +16`/`+24`: служебные функции (зависят от opcode).
+
+---
+
+## 5. Загрузка FXID (engine-accurate)
+
+`sub_10007650`:
+
+```c
+void FxLoad(FxInstance* fx, uint8_t* payload) {
+ FxHeader60* h = (FxHeader60*)payload;
+
+ fx->raw_header = h;
+ fx->mode = h->time_mode;
+ fx->end_ms = fx->start_ms + h->duration_sec * 1000.0f;
+ fx->scale = {h->scale_x, h->scale_y, h->scale_z};
+ fx->active_default = ((h->flags & 0x40) == 0);
+
+ uint8_t* ptr = payload + 0x3C;
+ for (uint32_t i = 0; i < h->cmd_count; ++i) {
+ uint32_t w = *(uint32_t*)ptr;
+ uint8_t op = (uint8_t)(w & 0xFF);
+
+ Command* cmd = CreateByOpcode(op, ptr); // может вернуть null
+ if (cmd) {
+ cmd->enabled = (w >> 8) & 1;
+
+ if (h->flags & 0x400) fx->manager_flags |= 0x0100;
+ if ((h->flags & 0x400) || cmd->enabled) fx->manager_flags |= 0x0010;
+
+ cmd->Init(fx->queue, fx);
+ fx->commands.push_back(cmd);
+ }
+
+ ptr += size_by_opcode(op); // без bounds checks в оригинале
+ }
+}
+```
+
+Критичные edge-case оригинала:
+
+- bounds checks отсутствуют;
+- при unknown opcode `ptr` не двигается (`advance = 0`);
+- при `new == null` команда пропускается, но `ptr` двигается.
+
+Фактический `advance` в `sub_10007650` задан hardcoded в DWORD:
+
+- `op1:+56`, `op2:+37`, `op3:+50`, `op4:+51`, `op5:+28`,
+- `op6:+1`, `op7:+52`, `op8:+62`, `op9:+52`, `op10:+52`,
+- `default:+0`.
+
+---
+
+## 6. Runtime lifecycle
+
+- `sub_10007470`: ctor instance.
+- `sub_10003D30(case 28)`: per-frame update manager.
+- `sub_10006170`: gate + alpha/time + command updates.
+- `sub_10008120` / `sub_10007D10`: update/render branches.
+- Start/Stop: `sub_10004BD0` / `sub_10004C10`.
+
+Event-codes `sub_10003D30`:
+
+- `4`: bootstrap/time init;
+- `20`: range-removal + index repair;
+- `23`: set manager bit0;
+- `24`: clear manager bit0;
+- `28`: main tick.
+
+---
+
+## 7. Общий тип `ResourceRef64`
+
+Для opcode `2/3/4/5/7/8/9/10` присутствует ссылка вида:
+
+```c
+struct ResourceRef64 {
+ char archive[32]; // null-terminated ASCII, case-insensitive compare
+ char name[32]; // null-terminated ASCII
+};
+```
+
+Поведение loader'а:
+
+- оба имени обязаны быть непустыми;
+- кэширование по `(_strcmpi archive, _strcmpi name)`;
+- загрузка/резолв через manager resource API.
+
+Наблюдение по данным:
+
+- для `opcode 2`: обычно `sounds.lib` + `*.wav`;
+- для остальных: обычно `material.lib` + material name.
+
+---
+
+## 8. Полная карта body по opcode (field-level)
+
+Смещения указаны от начала команды (включая `cmd_word`).
+
+### 8.1. Opcode 1 (`off_1001E78C`, size=224)
+
+Основные методы:
+
+- init: `sub_1000F4B0`;
+- update: `sub_1000F6E0`;
+- emit: `nullsub_2`;
+- toggle: `sub_1000F490`.
+
+```c
+struct FxCmd01 {
+ uint32_t word; // +0
+ uint32_t mode; // +4 (enum, см. ниже)
+ float t_start; // +8
+ float t_end; // +12
+
+ float p0_min[3]; // +16..24
+ float p0_max[3]; // +28..36
+
+ float p1_min[3]; // +40..48
+ float p1_max[3]; // +52..60
+
+ float q0_min[4]; // +64..76
+ float q0_max[4]; // +80..92
+
+ float q0_rand_span[4]; // +96..108 (все 4 читаются в sub_1000F6E0)
+
+ float scalar_min; // +112
+ float scalar_max; // +116
+ float scalar_rand_amp; // +120
+
+ float color_rgb[3]; // +124..132 (вызов manager+16)
+
+ float opaque_tail6[6]; // +136..156 (сохранять 1:1; в датасете почти всегда 0)
+
+ char opt_archive[32]; // +160..191 (редко, напр. "material.lib")
+ char opt_name[32]; // +192..223 (редко, напр. "light_w")
+};
+```
+
+Замечания по полям op1:
+
+- `+108` не резерв: участвует в random-выборке как 4-я компонента блока `+96..108`;
+- `+136..156` не читается vtable-методами класса `off_1001E78C` в `Effect.dll` (init/update/toggle/accessor), но должно сохраняться 1:1;
+- редкий кейс с ненулевыми `+136..156` и строками `+160/+192` зафиксирован в `effects.rlb:r_lightray_w`.
+
+`mode` (`+4`) -> параметры вызова manager (`sub_1000F4B0`):
+
+- `1 -> create_kind=1, flags=0x80000000`;
+- `2/5 -> create_kind=1, flags=0x00000000`;
+- `3 -> create_kind=3, flags=0x00000000`;
+- `4 -> create_kind=4, flags=0x00000000`;
+- `6 -> create_kind=1, flags=0xA0000000`;
+- `7 -> create_kind=1, flags=0x20000000`.
+
+### 8.2. Opcode 2 (`off_1001F048`, size=148)
+
+Основные методы:
+
+- init: `sub_10012D10`;
+- update: `sub_10012EB0`;
+- emit: `nullsub_2`;
+- toggle: `sub_10013170`.
+
+```c
+struct FxCmd02 {
+ uint32_t word; // +0
+ uint32_t mode; // +4 (0..3; влияет на sub_100065A0 mapping)
+ float t_start; // +8
+ float t_end; // +12
+
+ float a_min[3]; // +16..24
+ float a_max[3]; // +28..36
+
+ float b_min[3]; // +40..48
+ float b_max[3]; // +52..60
+
+ float c0_base; // +64
+ float c1_base; // +68
+ float c2_base; // +72
+ float c2_max; // +76
+
+ uint32_t param_910; // +80 (передаётся в manager cmd=910)
+
+ ResourceRef64 ref; // +84..147 (обычно sounds.lib + wav)
+};
+```
+
+`mode` -> внутренний map в `sub_100065A0`:
+
+- `0 -> 0`, `1 -> 512`, `2 -> 2`, `3 -> 514`.
+
+### 8.3. Opcode 3 (`off_1001E770`, size=200)
+
+Методы:
+
+- init: `sub_100103B0`;
+- update: `sub_100105F0`;
+- emit: `sub_100106C0`.
+
+```c
+struct FxCmd03 {
+ uint32_t word; // +0
+ uint32_t mode; // +4
+
+ float alpha_source; // +8 (>=0: norm time, <0: global time)
+ float alpha_pow_a; // +12
+ float alpha_pow_b; // +16
+
+ float out_min; // +20
+ float out_max; // +24
+ float out_pow; // +28
+
+ float active_t0; // +32
+ float active_t1; // +36
+
+ float v0_min[3]; // +40..48
+ float v0_max[3]; // +52..60
+
+ float pow0[3]; // +64..72
+
+ float v1_min[3]; // +76..84
+ float v1_max[3]; // +88..96
+
+ float v2_min[3]; // +100..108
+ float v2_max[3]; // +112..120
+
+ float pow1[3]; // +124..132
+
+ ResourceRef64 ref; // +136..199
+};
+```
+
+### 8.4. Opcode 4 (`off_1001E754`, size=204)
+
+Layout как opcode 3 + последний коэффициент:
+
+```c
+struct FxCmd04 {
+ FxCmd03 base; // +0..199
+ float dist_norm_inv_base; // +200 (используется в sub_100108C0/100109B0)
+};
+```
+
+`sub_100108C0`: `obj->inv = 1.0 / raw[200]`.
+
+### 8.5. Opcode 5 (`off_1001E360`, size=112)
+
+Методы:
+
+- init: `sub_100028A0`;
+- update: `sub_10002A20`;
+- emit: `sub_10002BE0`;
+- context update: `sub_10003070`.
+
+```c
+struct FxCmd05 {
+ uint32_t word; // +0
+ uint32_t mode; // +4 (в данных обычно 1)
+ uint32_t unused_08; // +8 (в текущем коде opcode5 не читается)
+ uint32_t unused_0C; // +12 (в текущем коде opcode5 не читается)
+
+ float active_t0; // +16
+ uint32_t max_segments; // +20
+ float active_t1_min; // +24
+ float active_t1_max; // +28
+
+ float step_norm; // +32
+ float segment_len; // +36
+ float alpha_source; // +40 (>=0 norm, <0 random)
+ float alpha_pow; // +44
+
+ ResourceRef64 ref; // +48..111
+};
+```
+
+### 8.6. Opcode 6 (`off_1001E738`, size=4)
+
+Только `cmd_word`:
+
+```c
+struct FxCmd06 {
+ uint32_t word; // +0
+};
+```
+
+`init/update/emit` фактически no-op (`sub_100030B0` возвращает `0`).
+
+### 8.7. Opcode 7 (`off_1001E228`, size=208)
+
+Методы:
+
+- init: `sub_10001720`;
+- update: `sub_10001230`;
+- emit: `sub_10001300`;
+- element accessor: `sub_10002780`.
+
+```c
+struct FxCmd07 {
+ uint32_t word; // +0
+ uint32_t mode; // +4
+
+ float eval_min; // +8
+ float eval_max; // +12
+ float eval_pow; // +16
+
+ float active_t0; // +20
+ float active_t1; // +24
+
+ float phase_span; // +28
+ float phase_rate; // +32
+
+ uint32_t count_a; // +36
+ uint32_t count_b; // +40
+
+ float set0_min[3]; // +44..52
+ float set0_max[3]; // +56..64
+ float set0_rand[3]; // +68..76
+ float set0_pow[3]; // +80..88
+
+ float set1_min[3]; // +92..100
+ float set1_max[3]; // +104..112
+ float set1_rand[3]; // +116..124
+ float set1_pow[3]; // +128..136
+
+ float gravity_or_drag_k; // +140
+
+ ResourceRef64 ref; // +144..207
+};
+```
+
+### 8.8. Opcode 8 (`off_1001E71C`, size=248)
+
+Методы:
+
+- init: `sub_10011230`;
+- update: `sub_100115C0`;
+- emit: `sub_10012030`.
+
+```c
+struct FxCmd08 {
+ uint32_t word; // +0
+ uint32_t mode; // +4
+
+ float eval_t0; // +8
+ float eval_t1; // +12
+
+ float gate_t0; // +16
+ float gate_t1; // +20
+
+ float period_min; // +24
+ float period_max; // +28
+ float phase_pow; // +32
+
+ uint32_t slots; // +36
+
+ float set0_min[3]; // +40..48
+ float set0_max[3]; // +52..60
+ float set0_rand[3]; // +64..72
+
+ float set1_min[3]; // +76..84
+ float set1_max[3]; // +88..96
+ float set1_rand[3]; // +100..108
+
+ float set2_rand[3]; // +112..120
+ float set2_pow[3]; // +124..132
+
+ float rmax_set0[3]; // +136..144 (bound/radius calc)
+ float rmax_set1[3]; // +148..156 (bound/radius calc)
+ float rmax_set2[3]; // +160..168 (bound/radius calc)
+
+ float render_pow[3]; // +172..180
+
+ ResourceRef64 ref; // +184..247
+};
+```
+
+### 8.9. Opcode 9 (`off_1001E700`, size=208)
+
+Layout как opcode 3 с двумя final-полями:
+
+```c
+struct FxCmd09 {
+ FxCmd03 base; // +0..199
+ uint32_t render_kind; // +200 (0/1/2 -> 3/5/6 in sub_100138C0)
+ uint32_t render_flag; // +204 (0 -> добавляет bit 0x08000000)
+};
+```
+
+Методы:
+
+- init/update как у opcode 3 (`sub_100103B0`, `sub_100105F0`);
+- emit: `sub_100138C0` -> формирует код рендера и вызывает `sub_100106C0`.
+
+### 8.10. Opcode 10 (`off_1001E24C`, size=208)
+
+Body-layout совпадает с opcode 7 (`FxCmd07`), но другой runtime класс.
+
+- init: `sub_10001A40`;
+- update: `sub_10001230`;
+- emit: `sub_10001300`;
+- element accessor: `sub_10002830`.
+
+Наблюдение по данным:
+
+- `mode` (`+4`) встречается как `16` или `32`.
+
+---
+
+## 9. Runtime-специфика по opcode (важные отличия)
+
+### 9.1. Opcode 1
+
+- создаёт handle через manager (`vfunc +48`);
+- задаёт флаги handle (`vfunc +52`);
+- в update пушит:
+ - позиционный вектор 1 (`vfunc +32`),
+ - позиционный вектор 2 (`vfunc +36`),
+ - 4-компонентный параметр (`vfunc +12`),
+ - scalar+rgb (`vfunc +16`).
+
+### 9.2. Opcode 2
+
+- `ResourceRef64` резолвится через `sub_100065A0` (режим-зависимая загрузка, в данных обычно `sounds.lib`/`wav`);
+- использует manager-команду id `910`.
+
+### 9.3. Opcode 3/4/9
+
+- общий core-emitter в `sub_100106C0`;
+- opcode 4 добавляет нормализацию по `raw+200`;
+- opcode 9 добавляет переключение render-кода (`raw+200/+204`).
+
+### 9.4. Opcode 5
+
+- держит массив внутренних сегментов (`332` байта/элемент, ctor `sub_100099F0`);
+- context-matrix приходит через `vfunc +24` (`sub_10003070`).
+
+### 9.5. Opcode 7/10
+
+- общий update/render (`sub_10001230`, `sub_10001300`);
+- разные внутренние element-форматы:
+ - opcode 7: `204` байта/элемент (`sub_100092D0`),
+ - opcode 10: `492` байта/элемент (`sub_1000BB40`).
+
+### 9.6. Opcode 8
+
+- самый тяжёлый спавнер, хранит ring/slot-структуры;
+- emit фаза (`sub_10012030`) использует `mode`, `render_pow`, per-slot transforms.
+
+---
+
+## 10. Спецификация инструментов
+
+### 10.1. Reader (strict)
+
+Алгоритм:
+
+1. `len(payload) >= 60`;
+2. читаем `cmd_count`;
+3. `ptr = 0x3C`;
+4. цикл `cmd_count`:
+ - `ptr + 4 <= len`;
+ - `opcode in 1..10`;
+ - `ptr + size(opcode) <= len`;
+ - `ptr += size(opcode)`;
+5. strict-tail: `ptr == len(payload)`.
+
+### 10.2. Reader (engine-compatible)
+
+Legacy-режим (опасный, только при необходимости byte-совместимости):
+
+- без bounds-check;
+- tolerant к unknown opcode как в оригинале.
+
+### 10.3. Writer (canonical)
+
+1. записать `FxHeader60`;
+2. `cmd_count = commands.len()`;
+3. команды сериализуются как `cmd_word + fixed-body`;
+4. размер payload: `0x3C + sum(size(op_i))`;
+5. без хвостовых байт.
+
+### 10.4. Editor (lossless)
+
+Правила:
+
+- все поля little-endian;
+- не менять fixed size команды;
+- не добавлять padding;
+- сохранять неизвестные биты (`cmd_word`, `header.flags`) copy-through;
+- для частично-известных полей поддерживать режим `opaque`.
+
+### 10.5. IR/JSON (рекомендуемая форма)
+
+```json
+{
+ "header": {
+ "time_mode": 1,
+ "duration_sec": 2.5,
+ "phase_jitter": 0.2,
+ "flags": 22,
+ "settings_id": 785,
+ "rand_shift": [0.0, 0.0, 0.0],
+ "pivot": [0.0, 0.0, 0.0],
+ "scale": [1.0, 1.0, 1.0]
+ },
+ "commands": [
+ {
+ "opcode": 8,
+ "word_raw": 264,
+ "enabled": 1,
+ "fields": {
+ "mode": 1065353216,
+ "eval_t0": 0.0,
+ "eval_t1": 1.0,
+ "resource": {"archive": "material.lib", "name": "fire_smoke"}
+ },
+ "opaque_extra_hex": "..."
+ }
+ ]
+}
+```
+
+---
+
+## 11. Проверка на реальных данных
+
+`testdata/nres`:
+
+- FXID payload: `923`;
+- валидация parser'а: `923/923 valid`.
+
+Распределение opcode:
+
+- `1: 618`
+- `2: 517`
+- `3: 1545`
+- `4: 202`
+- `5: 31`
+- `6: 0` (в датасете не встречен, но поддержан)
+- `7: 1161`
+- `8: 237`
+- `9: 266`
+- `10: 160`
+
+Подтверждённые `ResourceRef64` оффсеты:
+
+- op2 `+84`, op3/4/9 `+136`, op5 `+48`, op7/10 `+144`, op8 `+184`.
+
+Для op1 найден редкий расширенный хвост (`+160/+192`) в `effects.rlb:r_lightray_w`:
+
+- `material.lib` / `light_w`.
+
+---
+
+## 12. Практический чек-лист 1:1
+
+Для runtime-порта:
+
+- реализовать `FxHeader60` и parser `sub_10007650`;
+- реализовать opcode-классы с методами как в vtable;
+- учитывать start/stop/restart контракт manager API;
+- воспроизвести `sub_10005C60` + post-flags (`0x20`, `0x200`);
+- воспроизвести event loop `sub_10003D30(case 28)`.
+
+Для toolchain:
+
+- strict validator по разделу 10.1;
+- canonical writer по разделу 10.3;
+- field-aware editor + opaque fallback для неизвестных зон.
+
+---
+
+## 13. Что считать «полной» совместимостью
+
+Практический критерий завершения:
+
+1. Парсер и writer дают byte-identical round-trip для всех 923 FXID.
+2. Runtime-порт выдаёт совпадающие state transitions на одинаковом `dt/seed` (по ключевым полям instance + command state).
+3. Все opcode `1..10` поддержаны (включая `6`, даже если отсутствует в текущем датасете).
+4. `ResourceRef64` и mode-ветки (`op1`, `op2`, `op9`) совпадают с оригиналом.
+
+Эта страница покрывает весь наблюдаемый контракт формата/рантайма и полную карту body-полей по всем opcode.
+
+---
+
+## 14. Что осталось до «абсолютных 100%»
+
+Для практического 1:1 (парсер/writer/runtime на известном контенте) покрытие уже достаточно.
+Для «абсолютных 100%» на любых входах и во всех краевых режимах остаются 3 пункта:
+
+1. FP-детерминизм: оригинал опирается на x87-style вычисления; SSE/fast-math могут давать расхождения в alpha/таймингах.
+2. RNG parity: используется `sub_10002220` (16-bit генератор) и глобальные seed-состояния; для bit-exact воспроизведения нужны контрольные трассы оригинала.
+3. Редкие ветки данных: в текущем датасете нет opcode `6`, и почти не встречаются хвосты op1 (`+136..223`); для исчерпывающей валидации нужны дополнительные FXID-образцы.
+
+Что нужно собрать, чтобы закрыть это полностью:
+
+- frame-by-frame dump из оригинального runtime (alpha, manager flags, per-command state);
+- контрольные прогоны при фиксированном `dt` и seed;
+- минимум по одному ресурсу на каждую редкую ветку (`op6`, op1-tail с ненулевыми `+136..223`).
diff --git a/docs/specs/materials-texm.md b/docs/specs/materials-texm.md
new file mode 100644
index 0000000..baa80ae
--- /dev/null
+++ b/docs/specs/materials-texm.md
@@ -0,0 +1,874 @@
+# Materials, WEAR, MAT0 и Texm
+
+Документ описывает материальную подсистему движка (World3D/Ngi32) на уровне, достаточном для:
+
+- реализации runtime 1:1;
+- создания инструментов чтения/валидации;
+- создания инструментов конвертации и редактирования с lossless round-trip.
+
+Источник: дизассемблированные `tmp/disassembler1/*.c` и `tmp/disassembler2/*.asm`, плюс проверка на `tmp/gamedata`.
+
+---
+
+## 1. Идентификаторы и сущности
+
+| Сущность | ID (LE uint32) | ASCII | Где используется |
+|---|---:|---|---|
+| Material resource | `0x3054414D` | `MAT0` | `Material.lib` |
+| Wear resource | `0x52414557` | `WEAR` | `.wea` записи в world/mission `.rlb` |
+| Texture resource | `0x6D786554` | `Texm` | `Textures.lib`, `lightmap.lib`, другие `.lib/.rlb` |
+| Atlas tail chunk | `0x65676150` | `Page` | хвост payload `Texm` |
+
+Дополнительно: палитры загружаются отдельным путём (через `SetPalettesLib` + `sub_10002B40`) и не являются `Texm`.
+
+---
+
+## 2. Архитектура подсистемы
+
+### 2.1 Экспортируемые точки входа (World3D)
+
+- `LoadMatManager`
+- `SetPalettesLib`
+- `SetTexturesLib`
+- `SetMaterialLib`
+- `SetLightMapLib`
+- `SetGameTime`
+- `UnloadAllTextures`
+
+`Set*Lib` просто копируют строки путей в глобальные буферы; валидации пути нет.
+
+### 2.2 Дефолтные библиотеки (из `iron3d.dll`)
+
+- `Textures.lib`
+- `Material.lib`
+- `LightMap.lib`
+- `palettes.lib` (строка собирается как `'p' + "alettes.lib"`)
+
+### 2.3 Ключевые runtime-хранилища
+
+1. Менеджер материалов (`LoadMatManager`) — объект `0x470` байт.
+2. Кэш текстурных объектов.
+3. Кэш lightmap-объектов.
+4. Банк загруженных палитр.
+5. Глобальный пул определений материалов (`MAT0`).
+
+---
+
+## 3. Layout `MatManager` (0x470)
+
+Объект содержит 70 таблиц wear/lightmaps (не 140).
+
+```c
+// int-индексы относительно this (DWORD*), размер 284 DWORD = 0x470
+// [0] vtable
+// [1] callback iface
+// [2] callback data
+// [3..72] wearTablePtrs[70] // ptr на массив по 8 байт
+// [73..142] wearCounts[70]
+// [143] tableCount
+// [144..213] lightmapTablePtrs[70] // ptr на массив по 4 байта
+// [214..283] lightmapCounts[70]
+```
+
+### 3.1 Vtable методов (`off_100209E4`)
+
+| Индекс | Функция | Назначение |
+|---:|---|---|
+| 0 | `loc_10002CE0` | служебный/RTTI-заглушка |
+| 1 | `sub_10002D10` | деструктор + освобождение таблиц |
+| 2 | `PreLoadAllTextures` | экспорт, но фактически `retn 4` (заглушка) |
+| 3 | `sub_100031F0` | получить материал-фазу по `gameTime` |
+| 4 | `sub_10003AE0` | сбросить startTime записи wear к `SetGameTime()` |
+| 5 | `sub_10003680` | получить материал-фазу по нормализованному `t` |
+| 6 | `sub_10003B10` | загрузить wear/lightmaps (файл/ресурс) |
+| 7 | `sub_10003F80` | загрузить wear/lightmaps из буфера |
+| 8 | `sub_100031A0` | получить указатель на lightmap texture object |
+| 9 | `sub_10003AB0` | получить runtime-метаданные материала |
+| 10 | `sub_100031D0` | получить `wearCount` для таблицы |
+
+### 3.2 Кодирование material-handle
+
+`uint32 handle = (tableIndex << 16) | wearIndex`.
+
+- `HIWORD(handle)` -> индекс таблицы `0..69`
+- `LOWORD(handle)` -> индекс материала в wear-таблице
+
+---
+
+## 4. Глобальные кэши и их ёмкость
+
+Ёмкости подтверждены границами циклов/адресов в дизассемблере.
+
+### 4.1 Кэш текстур (`dword_1014E910`...)
+
+- Размер слота: `5 DWORD` (20 байт)
+- Ёмкость: `777`
+
+```c
+struct TextureSlot {
+ int32_t resIndex; // +0 индекс записи в NRes (не hash), -1 = свободно
+ void* textureObject; // +4
+ int32_t refCount; // +8
+ uint32_t lastZeroRefTime;// +12 время, когда refCount стал 0
+ uint32_t loadFlags; // +16 флаги загрузки
+};
+```
+
+`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC.
+
+### 4.2 Кэш lightmaps (`dword_10029C98`...)
+
+- Тот же layout `5 DWORD`
+- Ёмкость: `100`
+
+Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается.
+
+### 4.3 Пул материалов (`dword_100669F0`...)
+
+- Шаг: `92 DWORD` (`368` байт)
+- Ёмкость: `700`
+
+Фиксированные поля на шаг `i*92`:
+
+| DWORD offset | Byte offset | Поле |
+|---:|---:|---|
+| 0 | 0 | `nameResIndex` (`MAT0` entry index), `-1` = free |
+| 1 | 4 | `refCount` |
+| 2 | 8 | `phaseCount` |
+| 3 | 12 | `phaseArrayPtr` (`phaseCount * 76`) |
+| 4 | 16 | `animBlockCount` (`< 20`) |
+| 5..84 | 20..339 | `animBlocks[20]` по 16 байт |
+| 85 | 340 | metaA (`dword_10066B44`) |
+| 86 | 344 | metaB (`dword_10066B48`) |
+| 87 | 348 | metaC (`dword_10066B4C`) |
+| 88 | 352 | metaD (`dword_10066B50`) |
+| 89 | 356 | flagA (`dword_10066B54`) |
+| 90 | 360 | nibbleMode (`dword_10066B58`) |
+| 91 | 364 | flagB (`dword_10066B5C`) |
+
+### 4.4 Банк палитр
+
+- `dword_1013DA58[]`
+- Загружается до `286` элементов (26 букв * 11 вариантов)
+
+---
+
+## 5. Загрузка палитр (`sub_10002B40`)
+
+### 5.1 Генерация имён
+
+Движок перебирает:
+
+- буквы `'A'..'Z'`
+- суффиксы: `""`, `"0"`, `"1"`, ..., `"9"`
+
+И формирует имя:
+
+- `<Letter><Suffix>.PAL`
+- примеры: `A.PAL`, `A0.PAL`, ..., `Z9.PAL`
+
+### 5.2 Индекс палитры
+
+`paletteIndex = letterIndex * 11 + variantIndex`
+
+- `letterIndex = 0..25`
+- `variantIndex = 0..10` (`""`=0, `"0"`=1, ..., `"9"`=10)
+
+### 5.3 Поведение
+
+- Если запись не найдена: `paletteSlots[idx] = 0`
+- Если найдена: payload отдаётся в рендер (`render->method+60`)
+
+---
+
+## 6. Формат `MAT0` (`Material.lib`)
+
+### 6.1 Атрибуты NRes entry
+
+`sub_10004310` использует:
+
+- `entry.type` = `MAT0`
+- `entry.attr1` (bitfield runtime-флагов)
+- `entry.attr2` (версия/вариант заголовка payload)
+- `entry.attr3` не используется в runtime-парсере
+
+Маппинг `attr1`:
+
+- bit0 (`0x01`) -> добавить флаг `0x200000` в загрузку текстур фазы
+- bit1 (`0x02`) -> `flagA=1`; при некоторых HW-условиях дополнительно OR `0x80000`
+- bits2..5 -> `nibbleMode = (attr1 >> 2) & 0xF`
+- bit6 (`0x40`) -> `flagB=1`
+
+### 6.2 Payload layout
+
+```c
+struct Mat0Payload {
+ uint16_t phaseCount;
+ uint16_t animBlockCount; // должно быть < 20, иначе "Too many animations for material."
+
+ // Если attr2 >= 2:
+ uint8_t metaA8;
+ uint8_t metaB8;
+ // Если attr2 >= 3:
+ uint32_t metaC32;
+ // Если attr2 >= 4:
+ uint32_t metaD32;
+
+ PhaseRecordByte34 phases[phaseCount];
+ AnimBlockRaw anim[animBlockCount];
+};
+```
+
+Если `attr2 < 2`, runtime-значения по умолчанию:
+
+- `metaA = 255`
+- `metaB = 255`
+- `metaC = 1.0f` (`0x3F800000`)
+- `metaD = 0`
+
+### 6.3 `PhaseRecordByte34` -> runtime `76 bytes`
+
+Сырые 34 байта:
+
+```c
+struct PhaseRecordByte34 {
+ uint8_t p[18]; // параметры
+ char textureName[16];// если textureName[0]==0, текстуры нет
+};
+```
+
+Преобразование в runtime-структуру (точный порядок):
+
+| Из `p[i]` | В offset runtime | Преобразование |
+|---:|---:|---|
+| `p[0]` | `+16` | `p[0] / 255.0f` |
+| `p[1]` | `+20` | `p[1] / 255.0f` |
+| `p[2]` | `+24` | `p[2] / 255.0f` |
+| `p[3]` | `+28` | `p[3] * 0.01f` |
+| `p[4]` | `+0` | `p[4] / 255.0f` |
+| `p[5]` | `+4` | `p[5] / 255.0f` |
+| `p[6]` | `+8` | `p[6] / 255.0f` |
+| `p[7]` | `+12` | `p[7] / 255.0f` |
+| `p[8]` | `+32` | `p[8] / 255.0f` |
+| `p[9]` | `+36` | `p[9] / 255.0f` |
+| `p[10]` | `+40` | `p[10] / 255.0f` |
+| `p[11]` | `+44` | `p[11] / 255.0f` |
+| `p[12]` | `+48` | `p[12] / 255.0f` |
+| `p[13]` | `+52` | `p[13] / 255.0f` |
+| `p[14]` | `+56` | `p[14] / 255.0f` |
+| `p[15]` | `+60` | `p[15] / 255.0f` |
+| `p[16]` | `+64` | `uint32 = p[16]` |
+| `p[17]` | `+72` | `int32 = p[17]` |
+
+Текстура:
+
+- `textureName[0] == 0` -> `runtime[+68] = -1` и `runtime[+72] = -1`
+- иначе `runtime[+68] = LoadTexture(textureName, flags)`
+
+### 6.4 Runtime-запись фазы (76 байт)
+
+```c
+struct MaterialPhase76 {
+ float f0; // +0
+ float f1; // +4
+ float f2; // +8
+ float f3; // +12
+ float f4; // +16
+ float f5; // +20
+ float f6; // +24
+ float f7; // +28
+ float f8; // +32
+ float f9; // +36
+ float f10; // +40
+ float f11; // +44
+ float f12; // +48
+ float f13; // +52
+ float f14; // +56
+ float f15; // +60
+ uint32_t u16; // +64
+ int32_t texSlot; // +68 (индекс в texture cache, либо -1)
+ int32_t i18; // +72
+};
+```
+
+### 6.5 Анимационные блоки (`animBlockCount`, максимум 19)
+
+Каждый блок в payload:
+
+```c
+struct AnimBlockRaw {
+ uint32_t headerRaw; // mode = headerRaw & 7; interpMask = headerRaw >> 3
+ uint16_t keyCount;
+ struct KeyRaw {
+ uint16_t k0;
+ uint16_t k1;
+ uint16_t k2;
+ } keys[keyCount];
+};
+```
+
+Runtime-представление блока = 16 байт:
+
+```c
+struct AnimBlockRuntime {
+ uint32_t mode; // headerRaw & 7
+ uint32_t interpMask;// headerRaw >> 3
+ int32_t keyCount;
+ void* keysPtr; // массив keyCount * 8
+};
+```
+
+Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`).
+
+`k2` в `sub_100031F0/sub_10003680` не используется.
+Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате.
+
+### 6.6 Поиск и fallback
+
+При `LoadMaterial(name)`:
+
+- сначала точный поиск в `Material.lib`;
+- при промахе лог: `"Material %s not found."`;
+- fallback на `DEFAULT`;
+- если и `DEFAULT` не найден, берётся индекс `0`.
+
+---
+
+## 7. Выбор текущей material-фазы
+
+### 7.1 Интерполяция (`sub_10003030`)
+
+Интерполируются только следующие поля (по `interpMask`):
+
+- bit `0x02`: `+4,+8,+12`
+- bit `0x01`: `+20,+24,+28`
+- bit `0x04`: `+36,+40,+44`
+- bit `0x08`: `+52,+56,+60`
+- bit `0x10`: `+32`
+
+Не интерполируются и копируются из «текущей» фазы:
+
+- `+0,+16,+48,+64,+68,+72`
+
+### 7.2 Выбор по времени (`sub_100031F0`)
+
+Вход:
+
+- `handle` (`tableIndex|wearIndex`)
+- `animBlockIndex`
+- глобальное время `SetGameTime()` (`dword_10032A38`)
+
+Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte`).
+
+Режимы `mode = headerRaw & 7`:
+
+- `0`: loop
+- `1`: ping-pong
+- `2`: one-shot clamp
+- `3`: random (`rand() % cycleLength`)
+
+Важные детали 1:1:
+
+- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением);
+- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную).
+- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case).
+
+После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300`), который возвращается через out-параметр.
+
+### 7.3 Выбор по нормализованному `t` (`sub_10003680`)
+
+Аналогично `sub_100031F0`, но time берётся как `t * cycleLength`.
+
+Перед вычислением времени применяется runtime-нормализация:
+
+- если `t < 0.0` или `t > 1.0`, используется `t = 0.5`.
+
+### 7.4 Сброс времени записи
+
+`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()`.
+
+---
+
+## 8. Формат `WEAR` (текст)
+
+`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557`), обычно имя `*.wea`.
+
+### 8.1 Грамматика
+
+```text
+<wearCount:int>\n
+<legacyId:int> <materialName>\n // повторить wearCount раз
+
+[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка
+[LIGHTMAPS\n
+<lightmapCount:int>\n
+<legacyId:int> <lightmapName>\n // повторить lightmapCount раз]
+```
+
+- `<legacyId>` читается, но как ключ не используется.
+- Идентификатором реально является имя (`materialName` / `lightmapName`).
+
+### 8.2 Парсеры
+
+1. `sub_10003B10`: файл/ресурсный режим.
+2. `sub_10003F80`: парсер из строкового буфера.
+
+Различие важно для совместимости:
+
+- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf`.
+- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n`; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS`, иначе парсинг может съехать.
+
+### 8.3 Поведение и ошибки
+
+- `wearCount <= 0` (в текстовом файловом режиме) -> `"Illegal wear length."`
+- при невозможности открыть wear-файл/entry -> `"Wear <%s> doesn't exist."`
+- если найден блок `LIGHTMAPS` и `lightmapCount <= 0` -> `"Illegal lightmaps length."`
+- отсутствующий материал -> `"Material %s not found."` + fallback `DEFAULT`
+- отсутствующая lightmap -> `"LightMap %s not found."` и slot `-1`
+- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения.
+
+### 8.4 Ограничения runtime
+
+- Таблиц в `MatManager`: максимум 70 (физический layout).
+- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет.
+
+Инструментам нужно явно валидировать `tableCount < 70`.
+
+---
+
+## 9. Загрузка texture/lightmap по имени
+
+Общие функции:
+
+- `sub_10004B10` — texture (`Textures.lib`)
+- `sub_10004CB0` — lightmap (`LightMap.lib`)
+
+### 9.1 Валидация имени
+
+Алгоритм требует наличие `'.'` в позиции `0..16`.
+
+Иначе:
+
+- `"Bad texture name."`
+- возврат `-1`
+
+### 9.2 Palette index из суффикса
+
+После точки разбирается:
+
+- `L = toupper(name[dot+1])`
+- `D = name[dot+2]` (опционально)
+- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)`
+
+Если `idx < 0`, палитра не подставляется (`0`).
+Верхняя граница `idx` в runtime не проверяется.
+
+Практически в стоковых ассетах имена часто вида `NAME.0`; это даёт `idx < 0`, т.е. без палитровой привязки.
+Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива.
+
+### 9.3 Кэширование
+
+- Дедупликация по `resIndex`.
+- При повторном запросе увеличивается `refCount`, `lastZeroRefTime` сбрасывается в `0`.
+- При освобождении материала `refCount` texture/lightmap уменьшается.
+- texture: при `refCount -> 0` запоминается `lastZeroRefTime`; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд.
+- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor).
+
+---
+
+## 10. Формат `Texm`
+
+### 10.1 Заголовок 32 байта
+
+```c
+struct TexmHeader32 {
+ uint32_t magic; // 'Texm' = 0x6D786554
+ uint32_t width;
+ uint32_t height;
+ uint32_t mipCount;
+ uint32_t flags4;
+ uint32_t flags5;
+ uint32_t unk6;
+ uint32_t format;
+};
+```
+
+### 10.2 Поддерживаемые `format`
+
+Подтверждённые в данных:
+
+- `0` (палитровый 8-bit)
+- `565`
+- `4444`
+- `888`
+- `8888`
+
+Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации):
+
+- `556`
+- `88`
+
+### 10.3 Layout payload
+
+1. `TexmHeader32`
+2. если `format == 0`: palette table `256 * 4 = 1024` байта
+3. mip-chain пикселей
+4. опциональный `Page` chunk
+
+Расчёт:
+
+```c
+bytesPerPixel =
+ (format == 0) ? 1 :
+ (format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
+ 4;
+
+pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i));
+sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount;
+```
+
+### 10.4 `Page` chunk
+
+```c
+struct PageChunk {
+ uint32_t magic; // 'Page'
+ uint32_t rectCount;
+ struct Rect16 {
+ int16_t x;
+ int16_t w;
+ int16_t y;
+ int16_t h;
+ } rects[rectCount];
+};
+```
+
+Runtime конвертирует `Rect16` в:
+
+- пиксельные прямоугольники;
+- UV-границы с учётом возможного `mipSkip`.
+
+Формулы (`s = mipSkip`):
+
+- `x0 = x << s`, `x1 = (x + w) << s`
+- `y0 = y << s`, `y1 = (y + h) << s`
+- `u0 = x / (width << s)`, `du = w / (width << s)`
+- `v0 = y / (height << s)`, `dv = h / (height << s)`
+
+Также всегда добавляется базовый rect `[0]` на всю текстуру: пиксели `(0,0,width,height)`, UV `(0,0,1,1)`.
+
+### 10.5 Loader-поведение (`sub_1000FB30`)
+
+- Читает header в внутренние поля (`+56..+84`) напрямую:
+ - `+56 magic`, `+60 width`, `+64 height`, `+68 mipCount`,
+ - `+72 flags4`, `+76 flags5`, `+80 unk6`, `+84 format`.
+- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу.
+- Считает `sizeCore`, находит tail.
+- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page`.
+- Может уменьшать стартовый mip (`sub_1000F580`) в зависимости от размеров/формата/флагов.
+- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime.
+
+### 10.6 Политика `mipSkip` (`sub_1000F580`)
+
+`mipSkip` зависит от `flags5 & 0x72000000`, `width`, `height`, `mipCount`:
+
+- если `mipCount <= 1` -> `0`
+- если `flags5Mask == 0x02000000` -> `2` при `mipCount > 2`, иначе `1`
+- если `flags5Mask == 0x10000000` -> `1`
+- если `flags5Mask == 0x20000000`:
+ - `1`, если `width >= 256` или `height >= 256`
+ - иначе `0`
+- если `flags5Mask == 0x40000000`:
+ - если `width > 128` и `height > 128`: `2` при `mipCount > 2`, иначе `1`
+ - если `width == 128` или `height == 128`: `1`
+ - иначе `0`
+- иначе `0`
+
+Применение в loader:
+
+- `mipCount -= mipSkip`
+- `width >>= mipSkip`, `height >>= mipSkip`
+- `pixelDataOffset += bytesPerPixel * origWidth * origHeight` для `mipSkip==1`
+- `pixelDataOffset += bytesPerPixel * origWidth * origHeight * 1.25` для `mipSkip==2` (первые два уровня)
+
+---
+
+## 11. Флаги профиля/рендера (Ngi32)
+
+Ключ реестра: `HKCU\Software\Nikita\NgiTool`.
+
+Подтверждённые значения:
+
+- `Disable MultiTexturing`
+- `DisableMipmap`
+- `Force 16-bit textures`
+- `UseFirstCard`
+- `DisableD3DCalls`
+- `DisableDSound`
+- `ForceCpu`
+
+Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки.
+
+---
+
+## 12. Спецификация для toolchain (read/edit/write)
+
+### 12.1 Каноническая модель данных
+
+1. `MAT0`:
+- хранить исходные `attr1/attr2/attr3`;
+- хранить сырой payload + декодированную структуру;
+- при записи сохранять порядок/размеры секций точно.
+
+2. `WEAR`:
+- хранить строки wear/lightmaps как текст;
+- сохранять порядок строк;
+- допускать отсутствие блока `LIGHTMAPS`.
+- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`.
+
+3. `Texm`:
+- хранить header поля как есть (`flags4/flags5/unk6` не нормализовать);
+- хранить palette (если есть), mip data, `Page`.
+
+### 12.2 Правила lossless записи
+
+- Не менять значения `flags4/flags5/unk6` без явной причины.
+- Не менять `NRes` entry attrs, если цель — бинарный round-trip.
+- Для `MAT0`:
+ - `animBlockCount < 20`.
+ - `phaseCount` и фактический размер секции должны совпадать.
+ - textureName в фазе всегда укладывать в 16 байт и NUL-терминировать.
+- Для `Texm`:
+ - `magic == 'Texm'`.
+ - `mipCount > 0`, `width>0`, `height>0`.
+ - tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт.
+ - при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`.
+
+### 12.3 Рекомендованные валидации редактора
+
+- `WEAR`:
+ - `wearCount > 0`.
+ - число строк wear соответствует `wearCount`.
+ - если есть `LIGHTMAPS`, то `lightmapCount > 0` и число строк совпадает.
+ - для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS`.
+- `MAT0`:
+ - не выходить за payload при распаковке.
+ - все ссылки фаз/keys проверять на диапазоны.
+- `Texm`:
+ - `sizeCore <= payload_size`.
+ - проверка `Page` как `8 + rectCount*8`.
+ - предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime.
+
+---
+
+## 13. Проверка на реальных данных (`tmp/gamedata`)
+
+### 13.1 `Material.lib`
+
+- `905` entries, все `type=MAT0`
+- `attr2 = 6` у всех
+- `attr3 = 0` у всех
+- `phaseCount` до `29`
+- `animBlockCount` до `8` (ограничение runtime `<20` соблюдается)
+
+### 13.2 `Textures.lib`
+
+- `393` entries, все `type=Texm`
+- форматы: `8888(237), 888(52), 565(47), 4444(42), 0(15)`
+- `flags4`: `32(361), 0(32)`
+- `flags5`: `0(312), 0x04000000(81)`
+- `Page` chunk присутствует у `65` текстур
+
+### 13.3 `lightmap.lib`
+
+- `25` entries, все `Texm`
+- формат: `565`
+- `mipCount=1`
+- `flags5`: в основном `0`, встречается `0x00800000`
+
+### 13.4 `WEAR`
+
+- `439` entries `type=WEAR`
+- `attr1=0, attr2=0, attr3=1`
+- `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`)
+- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`.
+
+---
+
+## 14. Opaque-поля и границы знания
+
+Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`:
+
+- `MAT0`:
+ - `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений);
+ - `metaA/metaB/metaC/metaD` (в `World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено).
+- `Texm`:
+ - `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1).
+
+Это не блокирует реализацию движка/конвертеров 1:1.
+
+---
+
+## 15. Минимальные псевдокоды для реализации
+
+### 15.1 `parse_mat0(payload, attr2)`
+
+```python
+def parse_mat0(payload: bytes, attr2: int):
+ cur = 0
+ phase_count = u16(payload, cur); cur += 2
+ anim_count = u16(payload, cur); cur += 2
+ if anim_count >= 20:
+ raise ValueError("Too many animations for material")
+
+ if attr2 < 2:
+ metaA, metaB, metaC, metaD = 255, 255, 0x3F800000, 0
+ else:
+ metaA = u8(payload, cur); cur += 1
+ metaB = u8(payload, cur); cur += 1
+ metaC = u32(payload, cur) if attr2 >= 3 else 0x3F800000
+ cur += 4 if attr2 >= 3 else 0
+ metaD = u32(payload, cur) if attr2 >= 4 else 0
+ cur += 4 if attr2 >= 4 else 0
+
+ phases = [payload[cur + i*34 : cur + (i+1)*34] for i in range(phase_count)]
+ cur += 34 * phase_count
+
+ anim = []
+ for _ in range(anim_count):
+ raw = u32(payload, cur); cur += 4
+ key_count = u16(payload, cur); cur += 2
+ keys = [payload[cur + k*6 : cur + (k+1)*6] for k in range(key_count)]
+ cur += 6 * key_count
+ anim.append((raw, keys))
+
+ if cur != len(payload):
+ raise ValueError("MAT0 tail bytes")
+
+ return phase_count, anim_count, metaA, metaB, metaC, metaD, phases, anim
+```
+
+### 15.2 `parse_texm(payload)`
+
+```python
+def parse_texm(payload: bytes):
+ magic, w, h, mips, f4, f5, unk6, fmt = unpack_u32x8(payload, 0)
+ if magic != 0x6D786554:
+ raise ValueError("not Texm")
+
+ bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444, 88) else 4)
+ pix = 0
+ mw, mh = w, h
+ for _ in range(mips):
+ pix += mw * mh
+ mw = max(1, mw >> 1)
+ mh = max(1, mh >> 1)
+
+ core = 32 + (1024 if fmt == 0 else 0) + bpp * pix
+ if core > len(payload):
+ raise ValueError("truncated")
+
+ page = None
+ if core < len(payload):
+ if core + 8 > len(payload) or payload[core:core+4] != b"Page":
+ raise ValueError("tail without Page")
+ n = u32(payload, core + 4)
+ need = 8 + n * 8
+ if core + need != len(payload):
+ raise ValueError("invalid Page size")
+ page = [unpack_i16x4(payload, core + 8 + i*8) for i in range(n)]
+
+ return (w, h, mips, fmt, f4, f5, unk6, page)
+```
+
+### 15.3 `mip_skip_policy(flags5, width, height, mip_count)`
+
+```python
+def mip_skip_policy(flags5: int, width: int, height: int, mip_count: int) -> int:
+ if mip_count <= 1:
+ return 0
+
+ m = flags5 & 0x72000000
+ if m == 0x02000000:
+ return 2 if mip_count > 2 else 1
+ if m == 0x10000000:
+ return 1
+ if m == 0x20000000:
+ return 1 if (width >= 256 or height >= 256) else 0
+ if m == 0x40000000:
+ if width > 128 and height > 128:
+ return 2 if mip_count > 2 else 1
+ if width == 128 or height == 128:
+ return 1
+ return 0
+```
+
+### 15.4 `parse_wear_buffer_compatible(text)`
+
+```python
+def parse_wear_buffer_compatible(text: str):
+ lines = text.splitlines()
+ i = 0
+
+ wear_count = int(lines[i].strip()); i += 1
+ if wear_count <= 0:
+ raise ValueError("Illegal wear length.")
+
+ wear = []
+ for _ in range(wear_count):
+ legacy, name = lines[i].split(maxsplit=1)
+ wear.append((int(legacy), name.strip()))
+ i += 1
+
+ lightmaps = []
+ tail = lines[i:] if i < len(lines) else []
+ if tail and tail[0].strip() == "":
+ # sub_10003F80-совместимый разделитель перед LIGHTMAPS
+ i += 1
+ tail = lines[i:]
+
+ if tail and tail[0].strip().upper() == "LIGHTMAPS":
+ i += 1
+ if i >= len(lines):
+ raise ValueError("Illegal lightmaps length.")
+ light_count = int(lines[i].strip()); i += 1
+ if light_count <= 0:
+ raise ValueError("Illegal lightmaps length.")
+ for _ in range(light_count):
+ legacy, name = lines[i].split(maxsplit=1)
+ lightmaps.append((int(legacy), name.strip()))
+ i += 1
+
+ return wear, lightmaps
+```
+
+### 15.5 `select_phase_time_1to1(...)`
+
+```python
+def select_phase_time_1to1(game_time: int, start_time: int, keys, mode: int):
+ # keys: list[(phase_index, t_start, t_end)], t_end последнего = cycle_len
+ cycle_len = keys[-1][2]
+ if cycle_len <= 0:
+ return 0, 0.0
+
+ # unsigned div/mod как в runtime
+ delta = (game_time - start_time) & 0xFFFFFFFF
+ q = delta // cycle_len
+ r = delta % cycle_len
+
+ if mode == 1: # ping-pong
+ if q & 1:
+ r = cycle_len - r
+ elif mode == 2: # one-shot
+ if q > 0:
+ k = len(keys) - 1
+ return k, 0.0
+ elif mode == 3: # random
+ r = rand32() % cycle_len
+ start_time = r # side effect как в sub_100031F0
+
+ k = find_segment(keys, r) # t_start <= r < t_end
+ kn = 0 if (k + 1 == len(keys)) else (k + 1)
+ t0, t1 = keys[k][1], keys[k][2]
+ alpha = 0.0 if t1 == t0 else (r - t0) / float(t1 - t0)
+ return (k, kn), alpha
+```
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
new file mode 100644
index 0000000..e2623f8
--- /dev/null
+++ b/docs/specs/msh.md
@@ -0,0 +1,22 @@
+# Форматы 3D-ресурсов движка NGI
+
+Этот документ теперь является обзором и точкой входа в набор отдельных спецификаций.
+
+## Структура спецификаций
+
+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) — контрольные заметки, декодирование и открытые вопросы.
+
+## Связанные спецификации
+
+- [NRes / RsLi](nres.md)
+
+## Принцип декомпозиции
+
+- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
+- 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/nres.md b/docs/specs/nres.md
new file mode 100644
index 0000000..32ccb1b
--- /dev/null
+++ b/docs/specs/nres.md
@@ -0,0 +1,718 @@
+# Форматы игровых ресурсов
+
+## Обзор
+
+Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов:
+
+1. **NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей.
+
+2. **RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.
+
+---
+
+## Часть 1. Формат NRes
+
+### 1.1. Общая структура файла
+
+```
+┌──────────────────────────┐ Смещение 0
+│ Заголовок (16 байт) │
+├──────────────────────────┤ Смещение 16
+│ │
+│ Данные ресурсов │
+│ (выровнены по 8 байт) │
+│ │
+├──────────────────────────┤ Смещение = total_size - entry_count × 64
+│ Каталог записей │
+│ (entry_count × 64 байт) │
+└──────────────────────────┘ Смещение = total_size
+```
+
+### 1.2. Заголовок файла (16 байт)
+
+| Смещение | Размер | Тип | Значение | Описание |
+| -------- | ------ | ------- | ------------------- | ------------------------------------ |
+| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) |
+| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
+| 8 | 4 | int32 | — | Количество записей в каталоге |
+| 12 | 4 | int32 | — | Полный размер файла в байтах |
+
+**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
+
+### 1.3. Положение каталога в файле
+
+Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
+
+```
+directory_offset = total_size - entry_count × 64
+```
+
+Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
+
+### 1.4. Запись каталога (64 байта)
+
+Каждая запись каталога занимает ровно **64 байта** (0x40):
+
+| Смещение | Размер | Тип | Описание |
+| -------- | ------ | -------- | ------------------------------------------------- |
+| 0 | 4 | uint32 | Тип / идентификатор ресурса |
+| 4 | 4 | uint32 | Атрибут 1 (например, формат, дата, категория) |
+| 8 | 4 | uint32 | Атрибут 2 (например, подтип, метка времени) |
+| 12 | 4 | uint32 | Размер данных ресурса в байтах |
+| 16 | 4 | uint32 | Атрибут 3 (дополнительный параметр) |
+| 20 | 36 | char[36] | Имя файла (null-terminated, макс. 35 символов) |
+| 56 | 4 | uint32 | Смещение данных от начала файла |
+| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
+
+#### Поле «Имя файла» (смещение 20, 36 байт)
+
+- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
+- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
+- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
+
+#### Поле «Индекс сортировки» (смещение 60)
+
+Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
+
+**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
+
+#### Поле «Смещение данных» (смещение 56)
+
+Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
+
+### 1.5. Выравнивание данных
+
+При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
+
+```c
+padding = ((data_size + 7) & ~7) - data_size;
+// Если padding > 0, записываются нулевые байты
+```
+
+Таким образом, каждый блок данных начинается с адреса, кратного 8.
+
+При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
+
+### 1.6. Создание файла (API `niCreateResFile`)
+
+При создании нового файла:
+
+1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
+2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
+
+При закрытии файла (`sub_100122D0`):
+
+1. Заголовок переписывается в начало файла (16 байт).
+2. Вычисляется `total_size = data_end_offset + entry_count × 64`.
+3. Индексы сортировки пересчитываются.
+4. Каталог записей записывается в конец файла.
+
+### 1.7. Режимы сортировки каталога
+
+Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11):
+
+| Режим | Порядок сортировки |
+| ----- | --------------------------------- |
+| 0 | Без сортировки (сброс) |
+| 1 | По атрибуту 1 (смещение 4) |
+| 2 | По атрибуту 2 (смещение 8) |
+| 3 | По (атрибут 1, атрибут 2) |
+| 4 | По типу ресурса (смещение 0) |
+| 5 | По (тип, атрибут 1) |
+| 6 | По (тип, атрибут 1) — идентичен 5 |
+| 7 | По (тип, атрибут 1, атрибут 2) |
+| 8 | По имени (регистронезависимо) |
+| 9 | По (тип, имя) |
+| 10 | По (атрибут 1, имя) |
+| 11 | По (атрибут 2, имя) |
+
+### 1.8. Операция `niOpenResFileEx` — флаги открытия
+
+Второй параметр — битовые флаги:
+
+| Бит | Маска | Описание |
+| --- | ----- | ----------------------------------------------------------------------------------- |
+| 0 | 0x01 | Sequential scan hint (`FILE_FLAG_SEQUENTIAL_SCAN` вместо `FILE_FLAG_RANDOM_ACCESS`) |
+| 1 | 0x02 | Открыть для записи (read-write). Без флага — только чтение |
+| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
+| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
+
+### 1.9. Виртуальное касание страниц
+
+Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
+
+```
+for (result = 0x10000; result < size; result += 4096);
+```
+
+Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память.
+
+---
+
+## Часть 2. Формат RsLi
+
+### 2.1. Общая структура файла
+
+```
+┌───────────────────────────────┐ Смещение 0
+│ Заголовок файла (32 байта) │
+├───────────────────────────────┤ Смещение 32
+│ Таблица записей (зашифрована)│
+│ (entry_count × 32 байт) │
+├───────────────────────────────┤ Смещение 32 + entry_count × 32
+│ │
+│ Данные ресурсов │
+│ │
+├───────────────────────────────┤
+│ [Опциональный трейлер — 6 б] │
+└───────────────────────────────┘
+```
+
+### 2.2. Заголовок файла (32 байта)
+
+| Смещение | Размер | Тип | Значение | Описание |
+| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
+| 0 | 2 | char[2] | `NL` (0x4C4E) | Магическая сигнатура |
+| 2 | 1 | uint8 | `0x00` | Зарезервировано (должно быть 0) |
+| 3 | 1 | uint8 | `0x01` | Версия формата |
+| 4 | 2 | int16 | — | Количество записей (sign-extended при чтении) |
+| 6 | 8 | — | — | Зарезервировано / не используется |
+| 14 | 2 | uint16 | `0xABBA` или иное | Флаг предсортировки (см. ниже) |
+| 16 | 4 | — | — | Зарезервировано |
+| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
+| 24 | 8 | — | — | Зарезервировано |
+
+#### Флаг предсортировки (смещение 14)
+
+- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XOR‑ключ для данных).
+- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XOR‑ключ (младшие 16 бит).
+
+### 2.3. XOR-шифр таблицы записей
+
+Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
+
+#### Начальное состояние
+
+```
+seed = *(uint32*)(header + 20)
+lo = seed & 0xFF // Младший байт
+hi = (seed >> 8) & 0xFF // Второй байт
+```
+
+#### Алгоритм дешифровки (побайтовый)
+
+Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
+
+```
+step 1: lo = hi ^ ((lo << 1) & 0xFF) // Сдвиг lo влево на 1, XOR с hi
+step 2: decrypted[i] = lo ^ encrypted[i] // Расшифровка байта
+step 3: hi = lo ^ ((hi >> 1) & 0xFF) // Сдвиг hi вправо на 1, XOR с lo
+```
+
+**Пример реализации:**
+
+```python
+def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
+ lo = seed & 0xFF
+ hi = (seed >> 8) & 0xFF
+ result = bytearray(len(encrypted_data))
+ for i in range(len(encrypted_data)):
+ lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
+ result[i] = lo ^ encrypted_data[i]
+ hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
+ return bytes(result)
+```
+
+Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
+
+### 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
+
+После дешифровки каждая запись имеет следующую структуру:
+
+| Смещение | Размер | Тип | Описание |
+| -------- | ------ | -------- | -------------------------------------------------------------- |
+| 0 | 12 | char[12] | Имя ресурса (ASCII, обычно uppercase; строка читается до `\0`) |
+| 12 | 4 | — | Зарезервировано (движком игнорируется) |
+| 16 | 2 | int16 | **Флаги** (метод сжатия и атрибуты) |
+| 18 | 2 | int16 | **`sort_to_original[i]` / XOR‑ключ** (см. ниже) |
+| 20 | 4 | uint32 | **Размер распакованных данных** (`unpacked_size`) |
+| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) |
+| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) |
+
+#### Имена ресурсов
+
+- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`.
+- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно.
+- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён.
+
+#### Поле `sort_to_original[i]` (смещение 18)
+
+Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
+
+- Таблица реализована “внутри записей”: значение берётся как `entry[i].sort_to_original` (где `i` — позиция двоичного поиска), а реальная запись для сравнения берётся как `entry[ sort_to_original[i] ]`.
+- Тем же значением (младшие 16 бит) инициализируется XOR‑шифр данных для методов, где он используется (0x20/0x60/0xA0). Поэтому при упаковке/шифровании данных ключ должен совпадать с итоговым `sort_to_original[i]` (см. флаг 0xABBA в разделе 2.2).
+
+Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
+
+### 2.5. Поле флагов (смещение 16 записи)
+
+Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
+
+```
+Биты [8:5] (маска 0x1E0): Метод сжатия/шифрования
+Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
+```
+
+#### Методы сжатия (биты 8–5, маска 0x1E0)
+
+| Значение | Hex | Описание |
+| -------- | ----- | --------------------------------------- |
+| 0x000 | 0x00 | Без сжатия (копирование) |
+| 0x020 | 0x20 | Только XOR-шифр |
+| 0x040 | 0x40 | LZSS (простой вариант) |
+| 0x060 | 0x60 | XOR-шифр + LZSS (простой вариант) |
+| 0x080 | 0x80 | LZSS с адаптивным кодированием Хаффмана |
+| 0x0A0 | 0xA0 | XOR-шифр + LZSS с Хаффманом |
+| 0x100 | 0x100 | Deflate (аналог zlib/RFC 1951) |
+
+Примечание: `rsGetPackMethod()` возвращает `flags & 0x1C0` (без бита 0x20). Поэтому:
+
+- для 0x20 вернётся 0x00,
+- для 0x60 вернётся 0x40,
+- для 0xA0 вернётся 0x80.
+
+#### Бит 0x40 (выделение +0x12 и последующее `realloc`)
+
+Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
+
+Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
+
+### 2.6. Размеры данных
+
+В каждой записи на диске хранятся оба значения:
+
+- `unpacked_size` (смещение 20) — размер распакованных данных.
+- `packed_size` (смещение 28) — размер упакованных данных (байт во входном потоке для выбранного метода).
+
+Для метода 0x00 (без сжатия) обычно `packed_size == unpacked_size`.
+
+`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
+
+Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
+
+### 2.7. Опциональный трейлер медиа (6 байт)
+
+При открытии с флагом `a2 & 2`:
+
+| Смещение от конца | Размер | Тип | Описание |
+| ----------------- | ------ | ------- | ----------------------- |
+| −6 | 2 | char[2] | Сигнатура `AO` (0x4F41) |
+| −4 | 4 | uint32 | Смещение медиа-оверлея |
+
+Если трейлер присутствует, все смещения данных в записях корректируются: `effective_offset = entry_offset + media_overlay_offset`.
+
+---
+
+## Часть 3. Алгоритмы сжатия (формат RsLi)
+
+### 3.1. XOR-шифр данных (метод 0x20)
+
+Алгоритм идентичен XOR‑шифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
+
+Важно про размер входа:
+
+- В ветке **0x20** движок XOR‑ит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
+- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
+
+#### Инициализация
+
+```
+key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
+lo = key16 & 0xFF
+hi = (key16 >> 8) & 0xFF
+```
+
+#### Дешифровка (псевдокод)
+
+```
+for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
+ lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
+ out[i] = in[i] ^ lo
+ hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
+```
+
+### 3.2. LZSS — простой вариант (метод 0x40)
+
+Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
+
+#### Параметры
+
+| Параметр | Значение |
+| ----------------------------- | ------------------ |
+| Размер кольцевого буфера | 4096 байт (0x1000) |
+| Начальная позиция записи | 4078 (0xFEE) |
+| Начальное заполнение | 0x20 (пробел) |
+| Минимальная длина совпадения | 3 |
+| Максимальная длина совпадения | 18 (4 бита + 3) |
+
+#### Алгоритм декомпрессии
+
+```
+Инициализация:
+ ring_buffer[0..4095] = 0x20 (заполнить пробелами)
+ ring_pos = 4078
+ flags_byte = 0
+ flags_bits_remaining = 0
+
+Цикл (пока не заполнен выходной буфер И не исчерпан входной):
+
+ 1. Если flags_bits_remaining == 0:
+ - Прочитать 1 байт из входного потока → flags_byte
+ - flags_bits_remaining = 8
+
+ Декодировать как:
+ - Старший бит устанавливается в 0x7F (маркер)
+ - Оставшиеся 7 бит — флаги текущей группы
+
+ Реально в коде: control_word = (flags_byte) | (0x7F << 8)
+ Каждый бит проверяется сдвигом вправо.
+
+ 2. Проверить младший бит control_word:
+
+ Если бит = 1 (литерал):
+ - Прочитать 1 байт из входного потока → byte
+ - ring_buffer[ring_pos] = byte
+ - ring_pos = (ring_pos + 1) & 0xFFF
+ - Записать byte в выходной буфер
+
+ Если бит = 0 (ссылка):
+ - Прочитать 2 байта: low_byte, high_byte
+ - offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит
+ - length = (high_byte & 0x0F) + 3 // 4 бита + 3
+ - Скопировать length байт из ring_buffer[offset...]:
+ для j от 0 до length-1:
+ byte = ring_buffer[(offset + j) & 0xFFF]
+ ring_buffer[ring_pos] = byte
+ ring_pos = (ring_pos + 1) & 0xFFF
+ записать byte в выходной буфер
+
+ 3. Сдвинуть control_word вправо на 1 бит
+ 4. flags_bits_remaining -= 1
+```
+
+#### Подробная раскладка пары ссылки (2 байта)
+
+```
+Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
+Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина − 3
+
+offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095
+length = (high & 0x0F) + 3 // Диапазон: 3–18
+```
+
+### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
+
+Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
+
+#### Параметры
+
+| Параметр | Значение |
+| -------------------------------- | ------------------------------ |
+| Размер кольцевого буфера | 4096 байт |
+| Начальная позиция записи | **4036** (0xFC4) |
+| Начальное заполнение | 0x20 (пробел) |
+| Количество листовых узлов дерева | 314 |
+| Символы литералов | 0–255 (байты) |
+| Символы длин | 256–313 (длина = символ − 253) |
+| Начальная длина | 3 (при символе 256) |
+| Максимальная длина | 60 (при символе 313) |
+
+#### Дерево Хаффмана
+
+Дерево строится как **адаптивное** (dynamic, self-adjusting):
+
+- **627 узлов**: 314 листовых + 313 внутренних.
+- Все листья изначально имеют **вес 1**.
+- Корень дерева — узел с индексом 0 (в массиве `parent`).
+- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
+- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
+
+#### Кодирование позиции
+
+Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
+
+- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов.
+- Из потока считываются дополнительные биты, которые объединяются с базовым значением.
+- Финальная позиция: `pos = (ring_pos − 1 − decoded_position) & 0xFFF`
+
+**Таблицы инициализации** (d-коды):
+
+```
+Таблица базовых значений — byte_100371D0[6]:
+ { 0x01, 0x03, 0x08, 0x0C, 0x18, 0x10 }
+
+Таблица дополнительных битов — byte_100371D6[6]:
+ { 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
+```
+
+#### Алгоритм декомпрессии (высокоуровневый)
+
+```
+Инициализация:
+ ring_buffer[0..4095] = 0x20
+ ring_pos = 4036
+ Инициализировать дерево Хаффмана (314 листьев, все веса = 1)
+ Инициализировать таблицы d-кодов
+
+Цикл:
+ 1. Декодировать символ из потока по дереву Хаффмана:
+ - Начать с корня
+ - Читать биты, спускаться по дереву (0 = левый, 1 = правый)
+ - Пока не достигнут лист → символ = лист − 627
+
+ 2. Обновить дерево Хаффмана для декодированного символа
+
+ 3. Если символ < 256 (литерал):
+ - ring_buffer[ring_pos] = символ
+ - ring_pos = (ring_pos + 1) & 0xFFF
+ - Записать символ в выходной буфер
+
+ 4. Если символ >= 256 (ссылка):
+ - length = символ − 253
+ - Декодировать позицию через d-код:
+ a) Прочитать 8 бит из потока
+ b) Найти d-код и дополнительные биты по таблице
+ c) Прочитать дополнительные биты
+ d) position = (ring_pos − 1 − full_position) & 0xFFF
+ - Скопировать length байт из ring_buffer[position...]
+
+ 5. Если выходной буфер заполнен → завершить
+```
+
+### 3.4. XOR + LZSS (методы 0x60 и 0xA0)
+
+Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
+
+#### Алгоритм
+
+1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
+2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
+3. Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной.
+4. Освободить временный буфер.
+
+- **0x60** — XOR + простой LZSS (раздел 3.2)
+- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
+
+#### Начальное состояние XOR для данных
+
+При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
+
+### 3.5. Deflate (метод 0x100)
+
+Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
+
+#### Общая структура
+
+Данные состоят из последовательности блоков. Каждый блок начинается с:
+
+- **1 бит** — `is_final`: признак последнего блока
+- **2 бита** — `block_type`: тип блока
+
+#### Типы блоков
+
+| block_type | Описание | Функция |
+| ---------- | --------------------------- | ---------------- |
+| 0 | Без сжатия (stored) | `sub_1001A750` |
+| 1 | Фиксированные коды Хаффмана | `sub_1001A8C0` |
+| 2 | Динамические коды Хаффмана | `sub_1001AA30` |
+| 3 | Зарезервировано (ошибка) | Возвращает код 2 |
+
+#### Блок типа 0 (stored)
+
+1. Отбросить оставшиеся биты до границы байта (выравнивание).
+2. Прочитать 16 бит — `LEN` (длина блока).
+3. Прочитать 16 бит — `NLEN` (дополнение длины, `NLEN == ~LEN & 0xFFFF`).
+4. Проверить: `LEN == (uint16)(~NLEN)`. При несовпадении — ошибка.
+5. Скопировать `LEN` байт из входного потока в выходной.
+
+Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
+
+#### Блок типа 1 (фиксированные коды)
+
+Стандартные коды Deflate:
+
+- Литералы/длины: 288 кодов
+ - 0–143: 8-битные коды
+ - 144–255: 9-битные коды
+ - 256–279: 7-битные коды
+ - 280–287: 8-битные коды
+- Дистанции: 30 кодов, все 5-битные
+
+Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
+
+#### Блок типа 2 (динамические коды)
+
+1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257–286.
+2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30.
+3. Прочитать 4 бита → `HCLEN` (количество кодов длин − 4). Диапазон: 4–19.
+4. Прочитать `HCLEN` × 3 бит — длины кодов для алфавита длин.
+5. Построить дерево Хаффмана для алфавита длин (19 символов).
+6. С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций.
+7. Построить два дерева Хаффмана: для литералов/длин и для дистанций.
+8. Декодировать данные.
+
+**Порядок кодов длин** (стандартный Deflate):
+
+```
+{ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }
+```
+
+Хранится в `dword_10037060`.
+
+#### Валидации
+
+- `HLIT + 257 <= 286` (max 0x11E)
+- `HDIST + 1 <= 30` (max 0x1E)
+- При нарушении — возвращается ошибка 1.
+
+### 3.6. Метод 0x00 (без сжатия)
+
+Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
+
+---
+
+## Часть 4. Внутренние структуры в памяти
+
+### 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
+
+```c
+struct NResArchive { // Размер: 0x68 (104 байта)
+ void* vtable; // +0: Указатель на таблицу виртуальных методов
+ int32_t entry_count; // +4: Количество записей
+ void* mapped_base; // +8: Базовый адрес mapped view
+ void* directory_ptr; // +12: Указатель на каталог записей в памяти
+ char* filename; // +16: Путь к файлу (_strdup)
+ int32_t ref_count; // +20: Счётчик ссылок
+ uint32_t last_release_time; // +24: timeGetTime() при последнем Release
+ // +28..+91: Для raw-режима — встроенная запись (единственный File entry)
+ NResArchive* next; // +92: Следующий архив в связном списке
+ uint8_t is_writable; // +100: Файл открыт для записи
+ uint8_t is_cacheable; // +101: Не выгружать при refcount = 0
+};
+```
+
+### 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
+
+```c
+struct RsLibHeader { // 56 байт (14 DWORD)
+ uint32_t magic; // +0: 'RsLi' (0x694C7352)
+ int32_t entry_count; // +4: Количество записей
+ uint32_t media_offset; // +8: Смещение медиа-оверлея
+ uint32_t reserved_0C; // +12: 0
+ HANDLE file_handle_2; // +16: -1 (дополнительный хэндл)
+ uint32_t reserved_14; // +20: 0
+ uint32_t reserved_18; // +24: —
+ uint32_t reserved_1C; // +28: 0
+ HANDLE mapping_handle_2; // +32: -1
+ uint32_t reserved_24; // +36: 0
+ uint32_t flag_28; // +40: (flags >> 7) & 1
+ HANDLE file_handle; // +44: Хэндл файла
+ HANDLE mapping_handle; // +48: Хэндл файлового маппинга
+ void* mapped_view; // +52: Указатель на mapped view
+};
+// Далее следуют entry_count записей по 64 байта каждая
+```
+
+#### Внутренняя запись RsLi (64 байта)
+
+```c
+struct RsLibEntry { // 64 байта (16 DWORD)
+ char name[16]; // +0: Имя (12 из файла + 4 нуля)
+ int32_t flags; // +16: Флаги (sign-extended из int16)
+ int32_t sort_index; // +20: sort_to_original[i] (таблица индексов / XOR‑ключ)
+ uint32_t uncompressed_size; // +24: Размер несжатых данных (из поля 20 записи)
+ void* data_ptr; // +28: Указатель на данные в mapped view
+ uint32_t compressed_size; // +32: Размер сжатых данных (из поля 28 записи)
+ uint32_t reserved_24; // +36: 0
+ uint32_t reserved_28; // +40: 0
+ uint32_t reserved_2C; // +44: 0
+ void* loaded_data; // +48: Указатель на декомпрессированные данные
+ // +52..+63: дополнительные поля
+};
+```
+
+---
+
+## Часть 5. Экспортируемые API-функции
+
+### 5.1. NRes API
+
+| Функция | Описание |
+| ------------------------------ | ------------------------------------------------------------------------- |
+| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` |
+| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами |
+| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
+| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
+
+### 5.2. RsLi API
+
+| Функция | Описание |
+| ------------------------------- | -------------------------------------------------------- |
+| `rsOpenLib(path, flags)` | Открыть RsLi-библиотеку |
+| `rsCloseLib(lib)` | Закрыть библиотеку |
+| `rsLibNum(lib)` | Получить количество записей |
+| `rsFind(lib, name)` | Найти запись по имени (→ индекс или −1) |
+| `rsLoad(lib, index)` | Загрузить и декомпрессировать ресурс |
+| `rsLoadFast(lib, index, flags)` | Быстрая загрузка (без декомпрессии если возможно) |
+| `rsLoadPacked(lib, index)` | Загрузить в «упакованном» виде (отложенная декомпрессия) |
+| `rsLoadByName(lib, name)` | `rsFind` + `rsLoad` |
+| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса |
+| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) |
+| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс |
+| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) |
+| `ngiFree(ptr)` | Освободить память |
+| `ngiGetMemSize(ptr)` | Получить размер выделенного блока |
+
+---
+
+## Часть 6. Контрольные заметки для реализации
+
+### 6.1. Кодировки и регистр
+
+- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
+- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
+
+### 6.2. Порядок байт
+
+Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
+
+### 6.3. Выравнивание
+
+- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
+- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
+
+### 6.4. Размер записей на диске
+
+- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
+- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
+
+### 6.5. Кэширование и memory mapping
+
+Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
+
+### 6.6. Размер seed XOR
+
+- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
+- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
+- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
+
+### 6.7. Эмпирическая проверка на данных игры
+
+- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
+- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
+- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`.
+
+Подтверждённые нюансы:
+
+- Для LZSS (метод `0x040`) рабочая раскладка нибблов в ссылке: `OOOO LLLL`, а не `LLLL OOOO`.
+- Для Deflate (метод `0x100`) возможен случай `packed_size == фактический_конец + 1` на последней записи файла.
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-компонентов движка.