aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-19 03:46:23 +0300
committerValentin Popov <valentin@popov.link>2026-02-19 03:46:23 +0300
commit0e19660eb5122c8c52d5e909927884ad5c50b813 (patch)
tree6a53c24544ca828f08c2b6872d568b1edc1a4cef /docs
parent8a69872576eed41a918643be52a80fe74a054974 (diff)
downloadfparkan-0e19660eb5122c8c52d5e909927884ad5c50b813.tar.xz
fparkan-0e19660eb5122c8c52d5e909927884ad5c50b813.zip
Refactor documentation structure and add new specifications
- Updated MSH documentation to reflect changes in material, wear, and texture specifications. - Introduced new `render.md` file detailing the render pipeline process. - Removed outdated sections from `runtime-pipeline.md` and redirected to `render.md`. - Added detailed specifications for `Texm` texture format and `WEAR` wear table. - Updated navigation in `mkdocs.yml` to align with new documentation structure.
Diffstat (limited to 'docs')
-rw-r--r--docs/specs/fxid.md846
-rw-r--r--docs/specs/material.md130
-rw-r--r--docs/specs/materials-texm.md878
-rw-r--r--docs/specs/msh-animation.md545
-rw-r--r--docs/specs/msh-core.md730
-rw-r--r--docs/specs/msh.md12
-rw-r--r--docs/specs/render.md147
-rw-r--r--docs/specs/runtime-pipeline.md121
-rw-r--r--docs/specs/texture.md125
-rw-r--r--docs/specs/wear.md82
10 files changed, 785 insertions, 2831 deletions
diff --git a/docs/specs/fxid.md b/docs/specs/fxid.md
index 7dd1d4b..22d02d8 100644
--- a/docs/specs/fxid.md
+++ b/docs/specs/fxid.md
@@ -1,89 +1,20 @@
# FXID
-Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для:
-
-- 1:1 загрузки и исполнения в совместимом runtime;
-- построения валидатора payload;
-- создания lossless-конвертера (`binary -> IR -> binary`);
-- создания редактора с безопасным редактированием полей.
+`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy.
+Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов.
Связанный контейнер: [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"`).
+## 1. Контейнер
-Наблюдение по датасету (923 эффекта):
+- Тип ресурса в `NRes`: `0x44495846` (`FXID`).
+- Значения `attr1/attr2/attr3` в типовых игровых данных стабильны, но при редактуре их нужно сохранять как есть.
-- `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
+## 2. Бинарный формат
Все значения little-endian.
-### 3.1. Header (60 байт, `0x3C`)
+### 2.1. Заголовок (60 байт)
```c
struct FxHeader60 {
@@ -105,94 +36,26 @@ struct FxHeader60 {
};
```
-Командный поток начинается строго с `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. Общий формат команды
+Поток команд начинается строго с `offset = 0x3C`.
+
+### 2.2. Команда
Каждая команда:
-- `uint32 cmd_word`;
-- далее body фиксированного размера по opcode.
+1. `uint32 cmd_word`
+2. body фиксированного размера, зависящего от `opcode`
-`cmd_word`:
+Поля `cmd_word`:
-- `opcode = cmd_word & 0xFF`;
-- `enabled = (cmd_word >> 8) & 1`;
-- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1.
+- `opcode = cmd_word & 0xFF`
+- `enabled = (cmd_word >> 8) & 1`
+- `bits 9..31` нужно сохранять 1:1
Выравнивания между командами нет.
-### 4.2. Размеры
+### 2.3. Размеры команд
-| Opcode | Размер записи |
+| Opcode | Размер |
|---:|---:|
| 1 | 224 |
| 2 | 148 |
@@ -205,630 +68,121 @@ Post-обработка после mode:
| 9 | 208 |
| 10 | 208 |
-### 4.3. Opcode -> runtime-класс (vtable)
+## 3. Смысл заголовка
-| 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` |
+- `cmd_count`: число команд в потоке.
+- `time_mode`: способ вычисления текущего коэффициента эффекта.
+- `duration_sec`: длительность (в рантайме переводится в миллисекунды).
+- `phase_jitter`: амплитуда случайного фазового сдвига.
+- `flags`: флаги поведения (видимость, альфа-модификаторы, режимы гейтинга).
+- `settings_id`: индекс профиля/настроек эффекта.
+- `rand_shift_*`: случайный пространственный сдвиг.
+- `pivot_*`: локальная опора.
+- `scale_*`: базовый масштаб инстанса эффекта.
-### 4.4. Общий вызовной контракт команды
+## 4. Флаги заголовка
-После создания команды (`sub_10007650`):
+Практически важные биты:
-1. `cmd->enabled = cmd_word.bit8`.
-2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`).
-3. команда добавляется в список инстанса.
+- `0x0001`: случайный сдвиг фазы
+- `0x0008`: случайный пространственный сдвиг (`rand_shift_*`)
+- `0x0010`: ветки видимости/окклюзии
+- `0x0020`: треугольный ремап альфы
+- `0x0040`: инверсия исходного active-state
+- `0x0080`, `0x0100`: фильтрация по времени суток
+- `0x0200`: умножение альфы на нормализованное время жизни
+- `0x0400`, `0x1000`: дополнительные биты состояния менеджера эффекта
+- `0x0800`: дополнительный гейтинг
-В runtime cycle:
+Неизвестные биты должны сохраняться без изменений.
-- `vfunc +8`: update/compute (bool);
-- `vfunc +12`: emission/render callback;
-- `vfunc +20`: toggle active;
-- `vfunc +16`/`+24`: служебные функции (зависят от opcode).
+## 5. `time_mode` (0..17)
----
+База:
-## 5. Загрузка FXID (engine-accurate)
+- `tn = (now - start) / (end - start)`
+- `prev = предыдущая вычисленная альфа`
-`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`.
-
----
+- константный режим;
+- линейный (`tn`), обратный (`1-tn`), циклический (`fract(tn)`);
+- режимы от внешних параметров мира/очереди;
+- режимы на основе норм векторов состояния;
+- режимы с ограничением вниз/вверх относительно `prev`.
-## 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`.
+- при `flags & 0x0200` применяется `alpha *= tn`;
+- при `flags & 0x0020` применяется triangular remap.
-Event-codes `sub_10003D30`:
+## 6. Resource-ссылки внутри команд
-- `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` присутствует ссылка вида:
+Для 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")
+ char archive[32];
+ char name[32];
};
```
-Замечания по полям 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`.
+- строки ASCII, нуль-терминированные;
+- сравнение имён регистронезависимое;
+- обычно:
+ - `opcode 2`: `sounds.lib` + `*.wav`
+ - остальные: `material.lib` + имя материала/эффекта.
-`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
+## 7. Runtime-контракт исполнения
- 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
+1. Заголовок копируется в runtime-состояние.
+2. Вычисляется `end_time`.
+3. Для каждой команды создаётся runtime-объект по `opcode`.
+4. В объект копируется `enabled`.
+5. Объект инициализируется контекстом эффекта.
- 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
+1. Вычисляется текущий коэффициент/альфа по `time_mode` и `flags`.
+2. Выполняется update каждой команды.
+3. Выполняется emit/render часть активных команд.
+4. Применяются события Start/Stop/Restart.
- float active_t0; // +32
- float active_t1; // +36
+## 8. Строгий парсер (рекомендуемый)
- float v0_min[3]; // +40..48
- float v0_max[3]; // +52..60
+1. Проверить `len(payload) >= 60`.
+2. Прочитать `cmd_count`.
+3. Идти от `ptr = 0x3C`.
+4. Для каждой команды:
+ - проверить `ptr + 4 <= len`;
+ - прочитать `opcode`;
+ - проверить, что `opcode` поддержан;
+ - проверить `ptr + size(opcode) <= len`;
+ - сдвинуть `ptr += size(opcode)`.
+5. Проверить `ptr == len(payload)`.
- float pow0[3]; // +64..72
+## 9. Writer и редактор
- float v1_min[3]; // +76..84
- float v1_max[3]; // +88..96
+Для lossless-совместимости:
- 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%»
+- пересчитывать только `cmd_count` и размеры контейнера;
+- сохранять порядок команд.
-Для практического 1:1 (парсер/writer/runtime на известном контенте) покрытие уже достаточно.
-Для «абсолютных 100%» на любых входах и во всех краевых режимах остаются 3 пункта:
+## 10. Что требуется для 1:1 переноса
-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-образцы.
+1. Полная поддержка opcode `1..10`.
+2. Точный контракт вычисления `time_mode` и `flags`.
+3. Точное поведение `ResourceRef64`.
+4. Повторяемый RNG и одинаковая политика плавающей точки.
-Что нужно собрать, чтобы закрыть это полностью:
+## 11. Статус валидации
-- frame-by-frame dump из оригинального runtime (alpha, manager flags, per-command state);
-- контрольные прогоны при фиксированном `dt` и seed;
-- минимум по одному ресурсу на каждую редкую ветку (`op6`, op1-tail с ненулевыми `+136..223`).
+- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
+- В текущем рабочем окружении нет полного набора игровых архивов (`testdata` без payload), поэтому массовая повторная проверка корпуса здесь не выполнялась.
diff --git a/docs/specs/material.md b/docs/specs/material.md
new file mode 100644
index 0000000..cd7eea5
--- /dev/null
+++ b/docs/specs/material.md
@@ -0,0 +1,130 @@
+# Material (`MAT0`)
+
+`MAT0` описывает материал и его фазовую анимацию.
+
+Связанные страницы:
+
+- [Wear table (`WEAR`)](wear.md)
+- [Texture (`Texm`)](texture.md)
+- [Render pipeline](render.md)
+
+## 1. Контейнер
+
+- Тип ресурса: `0x3054414D` (`MAT0`).
+- Обычно хранится в `Material.lib`.
+- `attr1` используется как битовое поле runtime-флагов материала.
+- `attr2` задаёт версию заголовка payload.
+
+## 2. Бинарный layout
+
+```c
+struct Mat0Payload {
+ uint16_t phaseCount;
+ uint16_t animBlockCount; // должно быть < 20
+
+ // если attr2 >= 2
+ uint8_t metaA8;
+ uint8_t metaB8;
+ // если attr2 >= 3
+ uint32_t metaC32;
+ // если attr2 >= 4
+ uint32_t metaD32;
+
+ PhaseRecord34 phases[phaseCount];
+ AnimBlockRaw anim[animBlockCount];
+};
+```
+
+Если `attr2 < 2`, используются runtime-значения по умолчанию:
+
+- `metaA = 255`
+- `metaB = 255`
+- `metaC = 1.0f`
+- `metaD = 0`
+
+## 3. Фазы материала
+
+```c
+struct PhaseRecord34 {
+ uint8_t params[18];
+ char textureName[16];
+};
+```
+
+В рантайме запись разворачивается в структуру ~76 байт:
+
+- набор коэффициентов цвета/освещения/прозрачности;
+- индекс слота текстуры;
+- дополнительные целочисленные поля.
+
+`textureName`:
+
+- пустая строка -> фаза без текстуры (`texSlot = -1`);
+- непустая строка -> загрузка текстуры по имени.
+
+## 4. Анимационные блоки
+
+```c
+struct AnimBlockRaw {
+ uint32_t headerRaw; // mode = low 3 bits, interpMask = остальные
+ uint16_t keyCount;
+ KeyRaw keys[keyCount];
+};
+
+struct KeyRaw {
+ uint16_t k0;
+ uint16_t k1;
+ uint16_t k2; // opaque, сохранять 1:1
+};
+```
+
+`k2` нельзя удалять или нормализовать: это часть бинарного контракта.
+
+## 5. Выбор текущей фазы
+
+Материал выбирает фазу по времени и по режиму анимации блока:
+
+- loop;
+- ping-pong;
+- one-shot с clamp;
+- random-offset.
+
+При смешивании интерполируется только часть полей, остальные копируются из активной фазы.
+Для 1:1 совместимости важно сохранить эту выборочную интерполяцию.
+
+## 6. Загрузка и fallback
+
+При запросе материала по имени:
+
+1. Точный поиск по имени.
+2. Если не найдено — fallback на `DEFAULT`.
+3. Если `DEFAULT` отсутствует — используется запись с индексом `0`.
+
+## 7. Атрибуты и флаги
+
+Практически важные биты `attr1`:
+
+- бит загрузки текстурной фазы с расширенными флагами;
+- флаги аппаратного профиля;
+- 4-битный режим (`nibbleMode`);
+- дополнительный флаг material-поведения.
+
+Неизвестные биты должны сохраняться без изменений.
+
+## 8. Ограничения
+
+- `animBlockCount < 20`
+- `phaseCount` и фактический размер секции фаз должны совпадать
+- `textureName` должен быть NUL-terminated и укладываться в 16 байт
+
+## 9. Правила writer/editor
+
+1. Сохранять `attr1/attr2/attr3`.
+2. Не менять `metaA/B/C/D` без явного запроса.
+3. Сохранять opaque-поля анимации (включая `k2`) 1:1.
+4. Проверять выход за границы payload при парсинге.
+
+## 10. Статус валидации
+
+- Инварианты MAT0 зафиксированы в текущем toolchain проекта (`docs/specs` + `tools`).
+- В этом окружении нет полного игрового корпуса, поэтому статистика по всем материалам не пересчитывалась.
diff --git a/docs/specs/materials-texm.md b/docs/specs/materials-texm.md
index baa80ae..0397c84 100644
--- a/docs/specs/materials-texm.md
+++ b/docs/specs/materials-texm.md
@@ -1,874 +1,8 @@
-# Materials, WEAR, MAT0 и Texm
+# Materials, WEAR, 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
-```
+- [Material (`MAT0`)](material.md)
+- [Wear table (`WEAR`)](wear.md)
+- [Texture (`Texm`)](texture.md)
+- [Render pipeline](render.md)
diff --git a/docs/specs/msh-animation.md b/docs/specs/msh-animation.md
index ccfac35..8aa2796 100644
--- a/docs/specs/msh-animation.md
+++ b/docs/specs/msh-animation.md
@@ -1,517 +1,112 @@
# MSH animation
-Документ фиксирует анимационную часть формата MSH (`Res8`, `Res19`) и runtime-алгоритм сэмплирования/смешивания, необходимый для 1:1 совместимого движка и toolchain (reader/writer/converter/editor).
+`MSH animation` описывает связку `Res8 + Res19` и runtime-правила сэмплирования/смешивания поз.
-Связанные документы:
-- [MSH core](msh-core.md) — общая структура модели и `Res1`/`Res2`.
-- [NRes / RsLi](nres.md) — контейнер и атрибуты записей.
+Связанные страницы:
----
+- [MSH core](msh-core.md)
+- [Render pipeline](render.md)
-## 1. Область и источники
+## 1. Ресурсы анимации
-Спецификация основана на:
-- `tmp/disassembler1/AniMesh.dll.c` (псевдо-C): `sub_10015FD0`, `sub_10012880`, `sub_10012560`.
-- `tmp/disassembler2/AniMesh.dll.asm` (ASM): подтверждение x87-пути (`FISTP`) и ветвлений.
-- `tmp/disassembler1/Ngi32.dll.c` (псевдо-C): `sub_10002F90`, `sub_10014540`, `sub_10014630`, `sub_10015D80`, `sub_10017E60`, `sub_10017F50`, `sub_10006D00`, `niGetProcAddress`.
-- `tmp/disassembler2/Ngi32.dll.asm` (ASM): подтверждение таблицы `g_FastProc` и FPU control-word setup.
-- валидации corpus (`testdata`): 435 моделей `*.msh`.
-
-Ниже разделено на:
-- **Нормативно**: обязательно для runtime-совместимости.
-- **Канонично**: как устроены исходные ассеты; важно для детерминированного writer/editor.
-
----
-
-## 2. Ресурсы и поля модели
-
-### 2.1. Res8 — key pool (нормативно)
-
-`Res8` — массив ключей фиксированного шага 24 байта.
+### 1.1. `Res8` (пул ключей)
```c
struct AnimKey24 {
- float pos_x; // +0x00
- float pos_y; // +0x04
- float pos_z; // +0x08
- float time; // +0x0C
- int16_t qx; // +0x10
- int16_t qy; // +0x12
- int16_t qz; // +0x14
- int16_t qw; // +0x16
+ float pos_x;
+ float pos_y;
+ float pos_z;
+ float time;
+ int16_t qx;
+ int16_t qy;
+ int16_t qz;
+ int16_t qw;
};
```
-Декодирование quaternion-компонент:
-
-```c
-float q = (float)s16 * (1.0f / 32767.0f);
-```
-
-Атрибуты NRes:
-- `attr1 = size / 24` (количество ключей).
-- `attr2 = 0` (в observed corpus).
-- `attr3 = 4` (не stride; это фактический runtime-инвариант формата).
-
-### 2.2. Res19 — frame->segment map (нормативно)
-
-`Res19` — непрерывный `uint16` массив:
-
-```c
-uint16_t map_words[]; // count = size / 2
-```
-
-Атрибуты NRes:
-- `attr1 = size / 2` (число `uint16` слов).
-- `attr2 = animFrameCount` (глобальная длина таймлайна модели в кадрах).
-- `attr3 = 2`.
-
-### 2.3. Связь с Res1 node header (нормативно)
-
-Для `Res1` со stride 38 (основной формат):
-- `hdr2` (`node + 0x04`) = `mapStart` (`0xFFFF` => map для узла отсутствует).
-- `hdr3` (`node + 0x06`) = `fallbackKeyIndex` (индекс ключа в `Res8`).
-
-Runtime читает эти поля напрямую в `sub_10012880`.
-
-### 2.4. Поля runtime-модели, задействованные анимацией (нормативно)
-
-Инициализация в `sub_10015FD0`:
-- `model+0x18` -> `Res8` pointer.
-- `model+0x1C` -> `Res19` pointer.
-- `model+0x9C` <- `NResEntry(Res19).attr2` (`animFrameCount`).
-
----
-
-## 3. Runtime-сэмплирование узла (`sub_10012880`)
-
-Функция возвращает:
-- quaternion (4 float) в буфер `outQuat`,
-- позицию (3 float) в `outPos`.
-
-Вход:
-- `t` — sample time.
-- текущий `nodeIndex` берётся из runtime-объекта (не из аргумента).
-
-### 3.1. Вычисление frame index (нормативно)
-
-Алгоритм:
-1. `x = t - 0.5`.
-2. `frame = x87 FISTP(x)` (через 64-битный промежуточный буфер).
-
-Важно:
-- это не «просто floor»;
-- поведение зависит от x87 control word.
-
-В оригинальном runtime control word приводится к каноничному виду в `Ngi32::sub_10006D00`:
-- `cw = (cw & 0xF0FF) | 0x003F`;
-- это даёт `round-to-nearest` (RC=00), precision control `PC=00` и маскирование x87-исключений.
-
-Если нужен byte/behavior 1:1, надо повторить именно x87-ветку или её точный эквивалент.
-
-### 3.2. Выбор `keyIndex` (нормативно)
-
-```c
-node = Res1 + nodeIndex * 38;
-mapStart = u16(node + 4); // hdr2
-fallback = u16(node + 6); // hdr3
-
-if ((uint32_t)frame >= animFrameCount
- || mapStart == 0xFFFF
- || map_words[mapStart + (uint32_t)frame] >= fallback) {
- keyIndex = fallback;
-} else {
- keyIndex = map_words[mapStart + (uint32_t)frame];
-}
-```
-
-Критично:
-- runtime не проверяет bounds у `fallback` и `mapStart + frame`; некорректные данные приводят к OOB.
-
-### 3.3. Сэмплирование ключей (нормативно)
-
-`k0 = Res8[keyIndex]`.
-
-Ветки:
-1. fallback-ветка из п.3.2: возвращается строго `k0` (без `k1`).
-2. map-ветка:
- - если `t == k0.time` -> вернуть `k0`;
- - иначе берётся `k1 = Res8[keyIndex + 1]`;
- - если `t == k1.time` -> вернуть `k1`;
- - иначе:
- - `alpha = (t - k0.time) / (k1.time - k0.time)`;
- - `pos = lerp(k0.pos, k1.pos, alpha)`;
- - `quat = fastproc_interp(k0.quat, k1.quat, alpha)` (`g_FastProc[17]`).
-
-Сравнение `t == key.time` строгое (битовая float-эквивалентность по FPU compare), без epsilon.
-
-### 3.4. Порядок quaternion-компонент в runtime (нормативно)
-
-В `Res8` компоненты лежат как `qx,qy,qz,qw`, но в runtime-буферы они попадают в порядке:
-- `outQuat[0] = qw`;
-- `outQuat[1] = qx`;
-- `outQuat[2] = qy`;
-- `outQuat[3] = qz`.
-
-То есть все `g_FastProc`-пути в анимации работают с quaternion в порядке `float4 = [w, x, y, z]`.
-
----
-
-## 4. Runtime-смешивание двух сэмплов (`sub_10012560`)
-
-`sub_10012560(this, tA, tB, blend, outMatrix4x4)` смешивает две позы.
-
-### 4.1. Валидация входов (нормативно)
-
-Выбор доступных сэмплов:
-- `hasA = (blend < 1.0f) && (tA >= 0.0f)`.
-- `hasB = (blend > 0.0f) && (tB >= 0.0f)`.
-
-Ветки:
-- только `hasA`: матрица из A.
-- только `hasB`: матрица из B.
-- оба: полноценное смешивание.
-- ни одного: в оригинале путь не защищён (caller contract).
-
-### 4.2. Смешивание quaternion (нормативно)
-
-Перед интерполяцией выполняется shortest-path flip:
-
-```c
-if (|qA + qB|^2 < |qA - qB|^2) {
- qB = -qB;
-}
-```
-
-Далее:
-- `q = fastproc_blend(qA, qB, blend)` (`g_FastProc[22]`);
-- `outMatrix = quat_to_matrix(q)` (`g_FastProc[14]`).
-
-### 4.3. Смешивание translation (нормативно)
-
-Позиция смешивается отдельно:
-
-```c
-pos = (1-blend) * posA + blend * posB;
-outMatrix[3] = pos.x;
-outMatrix[7] = pos.y;
-outMatrix[11] = pos.z;
-```
-
-(`sub_1000B8E0` подтверждает, что используются именно эти ячейки).
-
-### 4.4. Точные `g_FastProc[14/17/22]` (нормативно)
-
-`niGetProcAddress(i)` в `Ngi32` возвращает `g_FastProc[i]` (таблица function pointers).
-В `AniMesh` используются:
-- `call [g_FastProc + 0x38]` -> index 14 -> `quat_to_matrix`.
-- `call [g_FastProc + 0x44]` -> index 17 -> `quat_interp`.
-- `call [g_FastProc + 0x58]` -> index 22 -> `quat_blend`.
-
-Связь с символами `Ngi32` (по адресам таблицы):
-- `g_FastProc` base = `0x1003A058`;
-- index 14 -> `0x1003A090`;
-- index 17 -> `0x1003A09C`;
-- index 22 -> `0x1003A0B0`.
-
-Назначения по CPU-веткам (`sub_10002F90`) и семантика:
-- scalar path: `14=sub_10017E60` (или `sub_10014540`), `17=22=sub_10017F50` (или `sub_10014630`);
-- SIMD path (`dword_1003A168`): `14=sub_1001D830`, `17=22=sub_10015D80`;
-- все варианты эквивалентны по математике.
-
-Точная формула `quat_to_matrix` для `q=[w,x,y,z]`:
-
-```c
-m[0] = 1 - 2*(y*y + z*z);
-m[1] = 2*(x*y + w*z);
-m[2] = 2*(x*z - w*y);
-m[3] = 0;
-
-m[4] = 2*(x*y - w*z);
-m[5] = 1 - 2*(x*x + z*z);
-m[6] = 2*(y*z + w*x);
-m[7] = 0;
-
-m[8] = 2*(x*z + w*y);
-m[9] = 2*(y*z - w*x);
-m[10] = 1 - 2*(x*x + y*y);
-m[11] = 0;
-
-m[12] = 0;
-m[13] = 0;
-m[14] = 0;
-m[15] = 1;
-```
-
-Точная формула `quat_interp`/`quat_blend` (`index 17` и `22`, один и тот же алгоритм):
-
-```c
-float dot = dot4(q0, q1);
-float sign = 1.0f;
-if (dot < 0.0f) { dot = -dot; sign = -1.0f; }
-
-float w0, w1;
-if (1.0f - dot <= 9.9999997e-6f) {
- w0 = 1.0f - a;
- w1 = a;
-} else {
- float theta = acos(dot);
- float inv_sin_theta = 1.0f / sin(theta);
- w1 = sin(a * theta) * inv_sin_theta;
- w0 = cos(a * theta) - w1 * dot;
-}
-w1 *= sign;
-out = w0 * q0 + w1 * q1;
-```
-
-Примечание: явной нормализации `out` в конце нет; используется закрытая форма SLERP-весов.
-
-Reference pseudocode:
-
-```c
-void blend_pose(Model *m, float tA, float tB, float blend, float out_m[16]) {
- bool hasA = (blend < 1.0f) && (tA >= 0.0f);
- bool hasB = (blend > 0.0f) && (tB >= 0.0f);
-
- float qA[4], qB[4], pA[3], pB[3];
- if (hasA) sample_node_pose(m, m->node_index, tA, qA, pA);
- if (hasB) sample_node_pose(m, m->node_index, tB, qB, pB);
-
- if (hasA && !hasB) { quat_to_matrix(qA, out_m); set_translation(out_m, pA); return; }
- if (!hasA && hasB) { quat_to_matrix(qB, out_m); set_translation(out_m, pB); return; }
- // !hasA && !hasB: undefined by design, caller does not use this path.
-
- if (dot4(qA + qB, qA + qB) < dot4(qA - qB, qA - qB)) negate4(qB);
- float q[4];
- fastproc_quat_blend(qA, qB, blend, q); // g_FastProc[22]
- quat_to_matrix(q, out_m); // g_FastProc[14]
-
- float p[3];
- p[0] = (1.0f - blend) * pA[0] + blend * pB[0];
- p[1] = (1.0f - blend) * pA[1] + blend * pB[1];
- p[2] = (1.0f - blend) * pA[2] + blend * pB[2];
- out_m[3] = p[0];
- out_m[7] = p[1];
- out_m[11] = p[2];
-}
-```
-
----
-
-## 5. Каноническая модель данных для toolchain
+Декодирование quaternion-компонент: `q = s16 / 32767.0`.
-Ниже правила, по которым удобно строить editor/writer. Они верифицированы на corpus (435 моделей), и совпадают с тем, как устроены оригинальные ассеты.
-
-### 5.1. Декомпозиция key pool на track-и узлов (канонично)
-
-Для `Res1` stride 38:
-- `fallback_i = node[i].hdr3`.
-- `start_i = (i == 0) ? 0 : (fallback_{i-1} + 1)`.
-- track узла `i` = `Res8[start_i .. fallback_i]`.
-
-Наблюдаемые инварианты:
-- `fallback_i` строго возрастает по `i`.
-- track всегда непустой (`fallback_i >= start_i`).
-- для узлов без map (`hdr2 == 0xFFFF`) track длиной ровно 1 ключ.
-- для узлов с map track длиной минимум 2 ключа.
-
-### 5.2. Временная ось ключей (канонично)
-
-В observed corpus:
-- `time` всех ключей — целые неотрицательные float (`0.0, 1.0, ...`).
-- внутри track: строго возрастают.
-- `time(start_i) == 0.0` у каждого узла.
-- глобальный `Res19.attr2 == max_i(time(fallback_i)) + 1`.
-
-### 5.3. Компоновка Res19 map-блоков (канонично)
-
-Если `Res19.size > 0`:
-- map-блоки есть только у узлов с `hdr2 != 0xFFFF`;
-- длина блока каждого такого узла: `frameCount = Res19.attr2`;
-- блоки идут подряд, без дыр и overlap;
-- итог: `Res19.attr1 == animated_node_count * frameCount`.
-
-Если модель статическая:
-- `Res19.size == 0`, `Res19.attr1 == 0`, `Res19.attr2 == 1`, `Res19.attr3 == 2`;
-- у всех узлов `hdr2 == 0xFFFF`.
-
-### 5.4. Семантика `map_words[f]` в каноничном writer
-
-Для кадра `f` и track `keys[start..end]`:
-- если `f < keys[start].time` или `f >= keys[end].time` -> писать `fallback = end`;
-- иначе писать индекс левого ключа сегмента (`start <= idx < end`) такого, что:
- - `keys[idx].time <= f < keys[idx+1].time`.
-
-В исходных данных fallback-фреймы кодируются значением `== fallback` (не просто `>= fallback`).
-
----
-
-## 6. Reference IR для редактора/конвертера
-
-Рекомендуемое промежуточное представление:
+### 1.2. `Res19` (карта кадров)
```c
-struct NodeAnimTrack {
- uint32_t node_index;
- bool has_map; // hdr2 != 0xFFFF
- uint16_t fallback_key; // hdr3 (derived on write)
- vector<AnimKey> keys; // local keys for node
- vector<uint16_t> frame_map; // optional, size == frame_count when has_map
-};
-
-struct AnimModel {
- uint32_t frame_count; // Res19.attr2
- vector<NodeAnimTrack> tracks; // in node order
-};
+uint16_t map_words[]; // size/2 элементов
```
-Где `AnimKey`:
-- `pos: float3`,
-- `time: float`,
-- `quat_raw: int16[4]` (для lossless),
-- `quat_decoded: float4` (опционально для API/UI).
-
----
-
-## 7. Алгоритм чтения (reader)
-
-1. Загрузить `Res1`, `Res8`, `Res19`.
-2. Проверить `Res8.size % 24 == 0`, `Res19.size % 2 == 0`.
-3. Для каждого узла `i` (stride 38):
- - взять `hdr2/hdr3`;
- - вычислить `start_i` через предыдущий `hdr3`;
- - извлечь `keys[start_i..hdr3]`;
- - если `hdr2 != 0xFFFF`, взять `frame_map = Res19[hdr2 : hdr2 + frame_count]`.
-4. Валидировать, что map-значения либо `< hdr3`, либо fallback (`== hdr3` канонично).
+`Res19.attr2` хранит глобальную длину таймлайна (число кадров).
----
+### 1.3. Связь с `Res1`
-## 8. Алгоритм записи (writer)
+Для каждого узла:
-Нормативный минимум для runtime-совместимости:
+- `anim_map_start` (`hdr2`) — начало блока в `Res19` или `0xFFFF`.
+- `fallback_key` (`hdr3`) — индекс fallback-ключа в `Res8`.
-1. Собрать keys всех узлов в один `Res8` pool в node-order.
-2. Записать `hdr3 = end_index` каждого узла.
-3. Вычислить `frame_count` и записать в `Res19.attr2`.
-4. Для узлов с map:
- - `hdr2 = cursor`;
- - append `frame_count` слов в `Res19`;
- - `cursor += frame_count`.
-5. Для узлов без map: `hdr2 = 0xFFFF`.
-6. Выставить атрибуты:
- - `Res8.attr1 = key_count`, `Res8.attr2 = 0`, `Res8.attr3 = 4`;
- - `Res19.attr1 = map_word_count`, `Res19.attr3 = 2`.
+## 2. Сэмплирование узла
-Каноничный writer (рекомендуется):
-- генерирует map по правилу §5.4;
-- fallback-фреймы записывает `== fallback`;
-- для статических узлов использует 1 ключ (`time=0`, `hdr2=0xFFFF`).
+Вход: время `t`, текущий узел.
+Выход: `quat(w,x,y,z)` и `pos(x,y,z)`.
----
+### 2.1. Индекс кадра
-## 9. Валидация перед сохранением
+Движок использует x87-совместимое округление для выражения `t - 0.5`.
+Для 1:1 повторения нужно сохранить ту же политику плавающей точки.
-Обязательные проверки:
+### 2.2. Выбор key index
-1. `Res8.size % 24 == 0`, `Res19.size % 2 == 0`.
-2. Для каждого узла: `fallbackKeyIndex < key_count`.
-3. Если `hdr2 != 0xFFFF`: `hdr2 + frame_count <= map_word_count`.
-4. Для map-сегмента узла:
- - любое значение `< fallback` должно удовлетворять `value + 1 < key_count`.
-5. В track узла:
- - `time` строго возрастает;
- - при наличии map минимум 2 ключа.
-6. `frame_count > 0` (игровые ассеты используют минимум 1).
+1. Если кадр вне диапазона `frame_count` -> `fallback_key`.
+2. Если `anim_map_start == 0xFFFF` -> `fallback_key`.
+3. Иначе берётся `map_words[anim_map_start + frame]`:
+ - если значение `>= fallback_key`, тоже используется `fallback_key`;
+ - иначе используется значение из map.
-Рекомендуемые проверки (каноничность):
+### 2.3. Интерполяция
-1. `fallback_i` строго возрастает по узлам.
-2. track каждого узла начинается с `time == 0`.
-3. `frame_count == max_end_time + 1`.
-4. map-блоки узлов без дыр/overlap.
+Если выбран fallback, возвращается ровно этот ключ без интерполяции.
----
+Иначе:
-## 10. Edge cases и совместимость
+1. Берутся соседние ключи `k0` и `k1`.
+2. Если `t` точно равен `k0.time` или `k1.time`, возвращается соответствующий ключ.
+3. Иначе:
+ - `alpha = (t - k0.time) / (k1.time - k0.time)`
+ - `pos = lerp(k0.pos, k1.pos, alpha)`
+ - `quat = slerp_like(k0.quat, k1.quat, alpha)`
-### 10.1. `Res19.size == 0`
+Кватернион в runtime хранится в порядке `[w, x, y, z]`.
-Поддерживается runtime-ом:
-- `frame_count` обычно 1;
-- `hdr2 == 0xFFFF` у всех узлов;
-- сэмплирование всегда через fallback key (`hdr3`).
+## 3. Смешивание двух сэмплов
-### 10.2. Узлы без map
+При blending между позами A и B:
-Это нормальный режим для статических/квазистатических узлов:
-- `hdr2 = 0xFFFF`;
-- `hdr3` указывает на единственный ключ узла (канонично).
+1. Выбираются валидные стороны по `blend` и валидности времени.
+2. Если активна одна сторона, берётся она.
+3. Если активны обе:
+ - применяется shortest-path flip для `qB`;
+ - выполняется quaternion blend;
+ - позиция смешивается линейно.
-### 10.3. `Res1.attr3 == 24` (legacy outlier)
+Матрица строится из quaternion, а translation подставляется отдельным шагом.
-В corpus встречается единично (`MTCHECK.MSH`, `testdata/nres/system.rlb`):
-- `Res1.attr3 = 24`;
-- `Res8` содержит 1 ключ;
-- `Res19.size == 0`.
+## 4. Каноника writer
-Алгоритм `sub_10012880` адресует node как stride 38, поэтому этот случай нельзя интерпретировать правилами текущего 38-byte формата. Практически это отдельный legacy-формат/legacy-path вне описанного runtime-контракта.
+Рекомендуемые правила:
-### 10.4. Квантование quaternion при экспорте
-
-Для новых данных:
-- используйте `round(q * 32767)`;
-- clamp к `[-32767, 32767]` (каноничный диапазон ассетов).
-
----
-
-## 11. Reference pseudocode (1:1 runtime path)
-
-```c
-void sample_node_pose(Model *m, int node_idx, float t, float out_quat[4], float out_pos[3]) {
- Node38 *node = (Node38 *)((uint8_t *)m->res1 + node_idx * 38);
- uint16_t map_start = node->hdr2;
- uint16_t fallback = node->hdr3;
- uint32_t frame_cnt = m->anim_frame_count; // Res19.attr2
-
- int32_t frame = x87_fistp_i32((double)t - 0.5); // strict path
-
- uint16_t key_idx;
- if ((uint32_t)frame >= frame_cnt ||
- map_start == 0xFFFF ||
- m->res19[map_start + (uint32_t)frame] >= fallback) {
- key_idx = fallback;
- decode_key_quat_pos(&m->res8[key_idx], out_quat, out_pos);
- return;
- }
-
- key_idx = m->res19[map_start + (uint32_t)frame];
- AnimKey24 *k0 = &m->res8[key_idx];
- if (t == k0->time) {
- decode_key_quat_pos(k0, out_quat, out_pos);
- return;
- }
-
- AnimKey24 *k1 = &m->res8[key_idx + 1];
- if (t == k1->time) {
- decode_key_quat_pos(k1, out_quat, out_pos);
- return;
- }
-
- float a = (t - k0->time) / (k1->time - k0->time);
- out_pos[0] = lerp(k0->pos_x, k1->pos_x, a);
- out_pos[1] = lerp(k0->pos_y, k1->pos_y, a);
- out_pos[2] = lerp(k0->pos_z, k1->pos_z, a);
- fastproc_quat_interp(decode_quat(k0), decode_quat(k1), a, out_quat); // g_FastProc[17]
-}
-```
+1. Ключи узлов писать подряд в `Res8` в порядке узлов.
+2. `fallback_key` узла указывает на последний ключ его трека.
+3. Для узлов с map выделять блок длины `frame_count` в `Res19`.
+4. Для статических узлов: `anim_map_start = 0xFFFF`, один ключ с `time=0`.
+5. `Res8.attr1 = key_count`, `Res8.attr3 = 4`.
+6. `Res19.attr1 = map_word_count`, `Res19.attr2 = frame_count`, `Res19.attr3 = 2`.
-## 12. Границы полноты
+## 5. Валидация перед сохранением
-Для основного формата (`Res1` stride 38 + `Res8` + `Res19`) эта страница покрывает runtime и toolchain-поведение на уровне, достаточном для 1:1 реализации (reader/writer/converter/editor).
+- `Res8.size % 24 == 0`
+- `Res19.size % 2 == 0`
+- каждый `fallback_key < key_count`
+- для узла с map: `anim_map_start + frame_count <= map_word_count`
+- внутри трека времена ключей строго возрастают
-Единственный подтверждённый неполный сегмент:
-- legacy `Res1.attr3 == 24` (`MTCHECK.MSH`), для которого в `AniMesh` не найден отдельный открытый decode-path в рамках текущего реверса.
+## 6. Статус валидации
-Для абсолютных 100% по всем историческим вариантам формата дополнительно нужно:
-- найти и дореверсить runtime-код, который реально обрабатывает `Res1.attr3==24` (если он есть в других модулях/ветках);
-- получить больше образцов `*.msh` с `attr3==24` для проверки writer/validator-инвариантов.
+- Форматные проверки включены в `tools/msh_doc_validator.py`.
+- В текущем окружении полный игровой корпус MSH не подключен в `testdata`, поэтому массовый прогон здесь не выполнялся.
diff --git a/docs/specs/msh-core.md b/docs/specs/msh-core.md
index a80496a..6a33049 100644
--- a/docs/specs/msh-core.md
+++ b/docs/specs/msh-core.md
@@ -1,678 +1,178 @@
# MSH core
-Документ фиксирует core-часть формата MSH на уровне, достаточном для:
+`MSH core` описывает геометрию, слоты, батчи и базовые таблицы модели.
+Документ покрывает контракт, необходимый для 1:1 воспроизведения рендера и коллизии.
-- реализации runtime-совместимого движка (поведение 1:1);
-- реализации reader/writer/editor/converter с lossless round-trip;
-- валидации ассетов и диагностики повреждений.
+Связанные страницы:
-Связанные документы:
+- [MSH animation](msh-animation.md)
+- [Material](material.md)
+- [Texture (Texm)](texture.md)
+- [Render pipeline](render.md)
+- [NRes / RsLi](nres.md)
-- [NRes / RsLi](nres.md) — контейнер, каталог, атрибуты, выравнивание.
-- [MSH animation](msh-animation.md) — детальная спецификация `Res8`/`Res19`.
-- [Materials + Texm](materials-texm.md) — материальная часть и текстуры.
-- [Terrain + map loading](terrain-map-loading.md) — отдельная ветка terrain-ресурсов.
+## 1. Общая модель
----
+MSH-модель хранится как `NRes`-контейнер.
+Связь таблиц строится по `type`, а не по порядку записей.
-## 1. Область и источники
+Базовый путь геометрии:
-### 1.1. Что покрывает этот документ
+1. `Res1` выбирает slot по `(node, lod, group)`.
+2. `Res2.slot` задаёт диапазоны треугольников и батчей.
+3. `Res13` задаёт диапазон индексов и `baseVertex`.
+4. `Res6` даёт `uint16` индексы.
+5. `Res3/Res4/Res5` дают вершины, нормали и UV.
-Этот документ покрывает именно **core-геометрию и её runtime-связи**:
+## 2. Карта core-ресурсов
-- `Res1` (node table),
-- `Res2` (header + slots),
-- `Res3/4/5` (позиции/нормали/UV0),
-- `Res6` (индексы),
-- `Res7` (triangle descriptors),
-- `Res10` (node string table),
-- `Res13` (batch table),
-- optional `Res15/16/18/20`,
-- точки стыка с анимацией (`Res8/Res19`).
-
-### 1.2. Что не покрывает
-
-- детальную семантику материалов/текстурных фаз (см. `materials-texm.md`),
-- terrain-ветку (`type 11/14/21` и связанные структуры, см. `terrain-map-loading.md`),
-- полную математику анимационного сэмплирования (см. `msh-animation.md`).
-
-### 1.3. Источники реверса
-
-Основные подтверждения:
-
-- `tmp/disassembler1/AniMesh.dll.c`:
- - `sub_10015FD0` (загрузка ресурсов core-модели),
- - `sub_100124D0` (поиск slot по node/lod/group),
- - `sub_10012530` (доступ к строке узла в `Res10`),
- - `sub_1000B2C0`/`sub_10013680` (tri/batch path),
- - `sub_1000A460` (инициализация runtime-инстансов, копирование глобальных bounds).
-- `tmp/disassembler2/AniMesh.dll.asm` — подтверждение смещений/stride/ветвлений.
-- валидация corpus: `testdata/nres` (435 MSH моделей, нулевые ошибки в `tools/msh_doc_validator.py`).
-
----
-
-## 2. Модель данных MSH (high-level)
-
-MSH-модель — это NRes-контейнер, где ресурсы связаны **не по порядку, а по type-id**.
-
-Базовая связь таблиц:
-
-1. `Res1` для `(node, lod, group)` выбирает `slotIndex`.
-2. `Res2.slot[slotIndex]` даёт диапазоны triangle/batch (`triStart/triCount`, `batchStart/batchCount`).
-3. `Res13.batch` даёт `indexStart/indexCount/baseVertex`.
-4. `Res6` даёт сырые `uint16` индексы.
-5. `Res3/4/5` дают vertex-атрибуты по `baseVertex + index`.
-
-Ключевая особенность runtime:
-
-- скиннинг по узлам жёсткий (rigid attachment), без per-vertex bone weights в core-ресурсах.
-
----
-
-## 3. Карта ресурсов и границы core
-
-### 3.1. Ресурсы, которые читает core-loader (`sub_10015FD0`)
-
-| Type | Ресурс | Статус в core-loader | Формат/stride |
+| Type | Ресурс | Обязательность | Stride / layout |
|---:|---|---|---|
-| 1 | Node table | required | 38 байт/узел (основной случай) |
-| 2 | Model header + slots | required | `0x8C + slotCount*0x44` |
-| 3 | Positions | required | 12 |
-| 4 | Packed normals | обычно required | 4 |
-| 5 | Packed UV0 | обычно required | 4 |
-| 6 | Index buffer | required | 2 |
-| 7 | Triangle descriptors | обычно required | 16 |
-| 8 | Anim key pool | optional для статических | 24 |
-| 10 | String table | обычно required | variable |
-| 13 | Batch table | required | 20 |
-| 15 | Доп. stream | optional | 8 |
-| 16 | Tangent/bitangent stream | optional | 8 |
-| 18 | Vertex color stream | optional | 4 |
-| 19 | Anim mapping | optional для статических | 2 |
-| 20 | Доп. таблица | optional | variable |
-
-### 3.2. Ресурсы, которые встречаются в MSH, но вне этого документа
-
-В corpus из 435 моделей стабильно встречаются также `type 9` и `type 17`.
-Они **не загружаются** `sub_10015FD0` и относятся к некоревым подсистемам (материалы/эффекты/прочие runtime-ветки).
-
-### 3.3. Прямая MSH и вложенная MSH
-
-Tooling должен поддерживать два режима входа:
-
-- файл уже является модельным NRes (`magic NRes` и содержит `type 1/2/3/6/13`),
-- файл-архив содержит `.msh` entry, внутри которой вложенный NRes модели.
-
----
-
-## 4. Runtime-контракт загрузки (`sub_10015FD0`)
-
-`sub_10015FD0` заполняет структуру модели размером `0xA4` байт и строит derived pointers/stride.
-
-### 4.1. Порядок `find/open`
-
-Фактический порядок загрузки:
-
-1. `type 1 -> this+0x00`
-2. `type 2 -> this+0x04`
-3. `type 3 -> this+0x0C`
-4. `type 4 -> this+0x10`
-5. `type 5 -> this+0x14`
-6. `type 10 -> this+0x20`
-7. `type 8 -> this+0x18`
-8. `type 19 -> this+0x1C`
-9. `type 7 -> this+0x24`
-10. `type 13 -> this+0x28`
-11. `type 6 -> this+0x2C`
-12. `type 15 -> this+0x34`
-13. `type 16 -> this+0x38`
-14. `type 18 -> this+0x64` (через отдельный `find`, optional)
-15. `type 20 -> this+0x30` (optional)
-
-### 4.2. Derived-поля (стримы)
-
-После загрузки ставятся derived-поля:
-
-- `this+0x08 = Res2 + 0x8C` (начало slot table),
-- `this+0x3C = Res3`, `this+0x40 = 12`,
-- `this+0x44 = Res4`, `this+0x48 = 4`,
-- `this+0x5C = Res5`, `this+0x60 = 4`,
-- `this+0x8C = Res15`, `this+0x90 = 8`,
-- `this+0x94 = 0` (инициализация нулём).
-
-Для `Res16`:
-
-- если есть: `this+0x4C = Res16`, `this+0x50 = 8`, `this+0x54 = Res16+4`, `this+0x58 = 8`;
-- если нет: `this+0x4C = 0`, `this+0x54 = 0` (stride остаются несущественными, т.к. указатели нулевые).
-
-Для `Res18`:
-
-- если найден: `this+0x64 = ptr`, `this+0x68 = 4`;
-- иначе: `this+0x64 = 0`, `this+0x68 = 0`.
-
-### 4.3. Метаданные из каталога NRes
-
-- `this+0x9C` получает `entry(type19).attr2` (читается из поля `+8` каталожной записи, индекс `entry * 64`).
-- `this+0xA0` получает `entry(type20).attr1` (поле `+4`) только если `type20` существует и успешно открыт; иначе `0`.
-
----
-
-## 5. Бинарные структуры core-ресурсов
-
-Все структуры little-endian.
-
-### 5.1. `Res1` — Node table
-
-Базовый stride: `38` байт (`19 * uint16`).
+| 1 | Node table | обязательный | обычно 38 байт |
+| 2 | Header + slots | обязательный | `0x8C + n*68` |
+| 3 | Positions | обязательный | 12 |
+| 4 | Packed normals | обычно обязательный | 4 |
+| 5 | Packed UV0 | обычно обязательный | 4 |
+| 6 | Index buffer | обязательный | 2 |
+| 7 | Tri descriptors | для коллизии/пикинга | 16 |
+| 8 | Anim key pool | для анимированных | 24 |
+| 10 | Node strings | опциональный | variable |
+| 13 | Batch table | обязательный | 20 |
+| 15 | Доп. stream | опциональный | 8 |
+| 16 | Доп. stream | опциональный | 8 |
+| 18 | Доп. stream | опциональный | 4 |
+| 19 | Anim map | для анимированных | 2 |
+| 20 | Доп. таблица | опциональный | variable |
+
+## 3. Основные структуры
+
+### 3.1. `Res1` (узлы)
```c
struct Node38 {
- uint16_t hdr0; // +0
- uint16_t hdr1; // +2
- uint16_t hdr2; // +4
- uint16_t hdr3; // +6
- uint16_t slotIndex[15]; // +8: [lod0 g0..g4][lod1 g0..g4][lod2 g0..g4]
+ uint16_t hdr0;
+ uint16_t parent_or_link;
+ uint16_t anim_map_start;
+ uint16_t fallback_key;
+ uint16_t slotIndex[15]; // lod0:g0..g4, lod1:g0..g4, lod2:g0..g4
};
```
-#### Подтверждённые поля
-
-- `hdr1`: parent/index-link (используется при построении инстанса), `0xFFFF` = нет.
-- `hdr2`: `mapStart` для `Res19` (см. `msh-animation.md`), `0xFFFF` = нет map.
-- `hdr3`: fallback key index в `Res8`.
-- `hdr0`: node flags (есть битовые проверки, но полная доменная семантика не закрыта).
-
-#### Адресация slot (runtime-функция `sub_100124D0`)
+Формула slot-выбора:
```c
-uint16_t get_slot_index(const Node38* node_table, uint32_t nodeIndex, int lod, int group, int current_lod) {
- int use_lod = (lod == -1) ? current_lod : lod;
- int word_index = 4 + (int)nodeIndex * 19 + use_lod * 5 + group;
- return *(uint16_t*)((const uint8_t*)node_table + word_index * 2);
-}
+slot = node.slotIndex[lod * 5 + group]
```
-`0xFFFF` означает "слот отсутствует".
+`0xFFFF` означает отсутствие слота.
-#### Вариант stride=24
-
-В corpus есть единичный служебный outlier с `Res1.attr3 = 24`.
-Для 1:1 editing существующих ассетов требуется copy-through этого варианта.
-Новая генерация должна ориентироваться на stride `38`, если нет чёткой цели поддержать legacy-вариант.
-
----
-
-### 5.2. `Res2` — Model header + Slot table
-
-```
-Res2:
- [0x00 .. 0x8B] model header (140 bytes)
- [0x8C .. end] slot records (68 bytes each)
-```
-
-#### 5.2.1. Header (0x8C)
-
-Runtime копирует блоки как float-массивы:
-
-- `0x00..0x5F` (`24 float`) — глобальный hull (`vec3[8]`),
-- `0x60..0x6F` (`4 float`) — глобальная sphere (`center.xyz + radius`),
-- `0x70..0x8B` (`7 float`) — сегмент/капсула (`A.xyz`, `B.xyz`, `radius`).
-
-#### 5.2.2. Slot record (68 bytes)
+### 3.2. `Res2` (header + slot records)
```c
struct Slot68 {
- uint16_t triStart; // +0
- uint16_t triCount; // +2
- uint16_t batchStart; // +4
- uint16_t batchCount; // +6
-
- float aabbMin[3]; // +8
- float aabbMax[3]; // +20
- float sphereCenter[3]; // +32
- float sphereRadius; // +44
-
- uint32_t unk30; // +48
- uint32_t unk34; // +52
- uint32_t unk38; // +56
- uint32_t unk3C; // +60
- uint32_t unk40; // +64
-};
-```
-
-`triCount` подтверждён как длина диапазона:
-
-```c
-triId >= triStart && triId < triStart + triCount
-```
-
-Хвост `unk30..unk40` должен сохраняться без изменений в editor/writer.
-
-#### 5.2.3. Bounds semantics
-
-- Slot bounds локальны относительно узла.
-- При world-трансформации sphere radius масштабируется по `max(scaleX, scaleY, scaleZ)` при неравномерном scale.
-
----
-
-### 5.3. `Res3` — Positions
-
-```c
-struct Position12 {
- float x;
- float y;
- float z;
-};
-```
-
-Stride `12`.
-
----
-
-### 5.4. `Res4` — Packed normals
-
-```c
-struct PackedNormal4 {
- int8_t nx;
- int8_t ny;
- int8_t nz;
- int8_t nw; // семантика 4-го байта не зафиксирована
+ uint16_t triStart;
+ uint16_t triCount;
+ uint16_t batchStart;
+ uint16_t batchCount;
+ float aabbMin[3];
+ float aabbMax[3];
+ float sphereCenter[3];
+ float sphereRadius;
+ uint32_t opaque[5];
};
```
-Декодирование:
-
-```c
-normal = clamp((float)n / 127.0f, -1.0f, 1.0f)
-```
-
-- делитель строго `127.0`;
-- clamp обязателен из-за `-128 / 127.0`.
-
-Кодирование (writer):
+`opaque[5]` должны сохраняться 1:1.
-```c
-int8_t q = (int8_t)clamp(round(v * 127.0f), -128, 127);
-```
-
----
+### 3.3. `Res3`, `Res4`, `Res5`, `Res6`
-### 5.5. `Res5` — Packed UV0
-
-```c
-struct PackedUV4 {
- int16_t u;
- int16_t v;
-};
-```
+- `Res3`: `float3` позиции (`stride=12`)
+- `Res4`: `int8[4]` packed normal (`stride=4`)
+- `Res5`: `int16[2]` UV (`stride=4`)
+- `Res6`: `uint16` индексы (`stride=2`)
Декодирование:
-```c
-uv = packed / 1024.0f
-```
-
-Кодирование:
-
-```c
-int16_t q = (int16_t)clamp(round(uv * 1024.0f), -32768, 32767);
-```
-
----
-
-### 5.6. `Res6` — Index buffer
+- normal = `clamp(n / 127.0, -1..1)`
+- uv = `packed / 1024.0`
-Массив `uint16`, stride `2`.
-
-Runtime-путь:
-
-```c
-vertexIndex = Res6[indexStart + i] + batch.baseVertex;
-```
-
-`indexStart` хранится в элементах, не в байтах.
-
----
-
-### 5.7. `Res7` — Triangle descriptors (16 bytes)
+### 3.4. `Res7` и `Res13`
```c
struct TriDesc16 {
- uint16_t triFlags; // +0
- uint16_t linkTri0; // +2
- uint16_t linkTri1; // +4
- uint16_t linkTri2; // +6
- int16_t nX; // +8
- int16_t nY; // +10
- int16_t nZ; // +12
- uint16_t selPacked; // +14
+ uint16_t triFlags;
+ uint16_t link0;
+ uint16_t link1;
+ uint16_t link2;
+ int16_t nx;
+ int16_t ny;
+ int16_t nz;
+ uint16_t selPacked;
};
-```
-
-- `nX/nY/nZ` декодируются через `1/32767`.
-- `linkTri*` используются в tri-neighbour/collision path.
-
-Раскладка `selPacked` (3 селектора по 2 бита):
-
-```c
-sel0 = (selPacked >> 0) & 0x3; if (sel0 == 3) sel0 = 0xFFFF;
-sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF;
-sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF;
-```
-
----
-
-### 5.8. `Res13` — Batch table (20 bytes)
-```c
struct Batch20 {
- uint16_t batchFlags; // +0
- uint16_t materialIndex; // +2
- uint16_t unk4; // +4
- uint16_t unk6; // +6
- uint16_t indexCount; // +8
- uint32_t indexStart; // +10
- uint16_t unk14; // +14
- uint32_t baseVertex; // +16
-};
-```
-
-`unk4/unk6/unk14` семантически не закрыты; writer/editor должны сохранять.
-
----
-
-### 5.9. `Res10` — Node string table
-
-Последовательность записей variable-length:
-
-```c
-struct Res10Record {
- uint32_t len; // длина строки без '\0'
- char text[]; // если len>0: len+1 байт (с '\0'), иначе payload нет
+ uint16_t batchFlags;
+ uint16_t materialIndex;
+ uint16_t opaque4;
+ uint16_t opaque6;
+ uint16_t indexCount;
+ uint32_t indexStart;
+ uint16_t opaque14;
+ uint32_t baseVertex;
};
```
-Переход:
-
-```c
-next = cur + 4 + (len ? len + 1 : 0);
-```
-
-`sub_10012530` возвращает:
-
-- `NULL`, если `len == 0`,
-- `record + 4`, если `len > 0`.
-
-Индекс записи в `Res10` соответствует `nodeIndex`.
-
----
-
-### 5.10. Optional streams
-
-#### `Res15` (stride 8)
-
-Дополнительный поток на вершину (семантика не полностью подтверждена).
-
-#### `Res16` (stride 8, split 2x4)
-
-Runtime делит поток на два interleaved подпотока:
-
-- stream A: `base+0`, stride 8,
-- stream B: `base+4`, stride 8.
-
-В corpus из `testdata/nres` этот ресурс не встретился, но loader поддерживает.
-
-#### `Res18` (stride 4)
-
-Vertex color / доп. packed-канал. В corpus встречается на части моделей.
-
-#### `Res20`
-
-Доп. таблица неизвестной доменной семантики. Loader хранит pointer и метаданные каталога (`attr1`).
-
----
-
-### 5.11. Точки стыка с анимацией (`Res8`/`Res19`)
-
-Core-loader загружает:
-
-- `Res8` в `this+0x18`,
-- `Res19` в `this+0x1C`,
-- `Res19.attr2` в `this+0x9C`.
-
-Полный runtime-алгоритм сэмплирования/смешивания описан в [MSH animation](msh-animation.md).
-
----
-
-## 6. Runtime-алгоритмы core
-
-### 6.1. Slot lookup (`sub_100124D0`)
-
-Вход: runtime-node-instance, `group`, `lod`.
+`selPacked` хранит 3 селектора по 2 бита; значение `3` трактуется как `0xFFFF`.
-1. Если нет model pointer -> `NULL`.
-2. `lod == -1` -> подставить `current_lod` инстанса.
-3. Вычислить `slotIndex` через формулу `4 + node*19 + lod*5 + group`.
-4. Если `slotIndex == 0xFFFF` -> `NULL`.
-5. Иначе вернуть `Res2.slotBase + slotIndex * 68`.
+## 4. Runtime-обход модели
-### 6.2. Node string lookup (`sub_10012530`)
-
-1. Идти по `Res10`-записям `nodeIndex` раз.
-2. Возвращать `NULL` или `char*` по правилу `len==0`.
-
-### 6.3. Геометрический обход для рендера
-
-Reference-путь, эквивалентный runtime-логике:
+Псевдокод рендера:
```c
for each node:
slot = resolve_slot(node, lod, group)
- if (!slot) continue
-
- for b in [slot.batchStart .. slot.batchStart + slot.batchCount):
- batch = Res13[b]
- for i in [0 .. batch.indexCount):
- idx = Res6[batch.indexStart + i]
- vtx = batch.baseVertex + idx
-
- pos = Res3[vtx]
- nrm = decode_res4(Res4[vtx])
- uv0 = decode_res5(Res5[vtx])
-```
-
-### 6.4. Tri/collision path (обобщённо)
-
-- `sub_1000B2C0` и `sub_10013680` используют tri-диапазоны слота + `Res7` link/select-поля.
-- Для collision/picking-контекста должны быть валидны:
- - `slot.triStart + slot.triCount <= triDescCount`,
- - `linkTri*` либо `0xFFFF`, либо `< triDescCount`.
-
----
-
-## 7. Инварианты и валидация (reader)
-
-### 7.1. Базовые проверки целостности
-
-- каждый fixed-stride ресурс делится на stride без остатка;
-- `Res2.size >= 0x8C`;
-- `(Res2.size - 0x8C) % 68 == 0`;
-- `Res2.attr1 == slotCount`, `Res2.attr3 == 68`;
-- `Res3.attr3 == 12`, `Res4.attr3 == 4`, `Res5.attr3 == 4`, `Res6.attr3 == 2`, `Res7.attr3 == 16`, `Res13.attr3 == 20`;
-- `Res8.attr3 == 4` (не stride), `Res19.attr3 == 2`, `Res10.attr3 == 0` (в observed assets).
-
-### 7.2. Cross-table проверки
-
-- `slot.batchStart + slot.batchCount <= batchCount`;
-- `slot.triStart + slot.triCount <= triDescCount`;
-- `batch.indexStart + batch.indexCount <= indexCount`;
-- `batch.baseVertex + max(indexSlice) < vertexCount`;
-- все `Res1.slotIndex[*]` либо `0xFFFF`, либо `< slotCount`;
-- для `Res10`: парсинг ровно `nodeCount` записей без хвостовых байт;
-- для `Res7.linkTri*`: либо `0xFFFF`, либо `< triDescCount`.
-
-### 7.3. Strict vs tolerant режим
-
-Рекомендуется 2 режима reader:
-
-- `strict`: любое нарушение инвариантов -> ошибка;
-- `tolerant`: безопасно отбрасывать/игнорировать только локально повреждённые диапазоны (без OOB).
-
----
-
-## 8. Правила writer/editor
-
-### 8.1. Обязательная политика для 1:1 editing
-
-- сохранять неизвестные поля (`Slot68.unk*`, `Batch20.unk*`, `Node.hdr0` и т.д.) без модификации, если нет осознанного пересчёта;
-- сохранять неизвестные resource types и их payload/атрибуты;
-- не полагаться на порядок ресурсов в контейнере: lookup в runtime идёт по type-id.
-
-### 8.2. Пересчёт атрибутов каталога
-
-При записи изменённых ресурсов:
-
-- `attr1` = count (или форматно-специфичное значение),
-- `attr2` — по формату/семантике ресурса,
-- `attr3` — stride/константа формата.
+ if slot == none: continue
-Практические правила для core:
+ if culled(slot.bounds, node_transform): continue
-- `Res1`: `attr1=nodeCount`, `attr3=38` (или исходный вариант, если copy-through legacy), `attr2` лучше сохранять из исходника;
-- `Res2`: `attr1=slotCount`, `attr2=0`, `attr3=68`;
-- `Res3/4/5/6/7/13/15/16/18`: `attr1=size/stride`, `attr2=0`, `attr3=stride`;
-- `Res8`: `attr1=size/24`, `attr3=4`;
-- `Res10`: `attr1=nodeCount`, `attr2=0`, `attr3=0`;
-- `Res19`: `attr1=size/2`, `attr2=frameCount`, `attr3=2`.
+ for b in slot.batchRange:
+ batch = batches[b]
+ bind_material(batch.materialIndex)
-### 8.3. Матрица зависимостей при редактировании
-
-| Операция | Какие ресурсы обновлять |
-|---|---|
-| Смещение/деформация вершин | `Res3`, при необходимости `Res4`, bounds в `Res2` |
-| Изменение UV | `Res5` (и опционально `Res15`) |
-| Изменение topology (индексы/треугольники) | `Res6`, `Res13`, `Res7`, диапазоны `Res2.slot` |
-| Изменение LOD/group назначения | `Res1.slotIndex`, возможно `Res2.slot` |
-| Изменение имени узла | `Res10` |
-| Изменение иерархии/анимации узлов | `Res1.hdr1/hdr2/hdr3`, `Res8`, `Res19` |
-| Добавление/удаление slot | `Res2`, ссылки из `Res1`, диапазоны batch/tri |
-
-### 8.4. Детерминированная сериализация
-
-- little-endian для всех чисел;
-- без внутреннего padding в таблицах ресурсов;
-- выравнивание блоков ресурсов в NRes по 8 байт (через контейнер).
-
----
-
-## 9. Рекомендованный canonical IR для toolchain
-
-Минимальный IR для безопасного round-trip:
-
-```c
-struct ModelCoreIR {
- // raw payloads for unknown/passthrough types
- map<uint32_t, RawResource> raw_passthrough;
-
- vector<Node> nodes; // Res1 decoded (hdr + matrix)
- Header140 header; // Res2[0x00..0x8B]
- vector<Slot> slots; // Res2 slot table (включая unk tail)
-
- vector<float3> positions; // Res3
- vector<PackedNormal4> normals_raw; // Res4 raw + optional decoded cache
- vector<PackedUV4> uv0_raw; // Res5 raw + optional decoded cache
-
- vector<uint16_t> indices; // Res6
- vector<TriDesc16> tri; // Res7
- vector<Batch20> batches; // Res13
- vector<optional<string>> node_names; // Res10
-
- optional<vector<uint8_t>> res15_raw;
- optional<vector<uint8_t>> res16_raw;
- optional<vector<uint32_t>> colors_raw; // Res18
- optional<RawResource> res20_raw;
-
- // animation bridge
- optional<vector<AnimKey24>> anim_keys; // Res8
- optional<vector<uint16_t>> anim_map_words; // Res19
- uint32_t anim_frame_count;
-};
+ draw_indexed(
+ baseVertex = batch.baseVertex,
+ indexStart = batch.indexStart,
+ indexCount = batch.indexCount
+ )
```
-Принцип: где семантика неполная, хранить raw и переизлучать байт-в-байт.
-
----
-
-## 10. Практика конвертации
-
-### 10.1. MSH -> OBJ/GLTF
-
-- `Res3` напрямую в позиции;
-- `Res6 + Res13` в faces;
-- нормали/UV декодировать через коэффициенты `1/127`, `1/1024`;
-- при экспорте по LOD/group использовать `Res1` матрицу слотов, а не "все batch подряд" (если нужен runtime-эквивалент);
-- пометить ограничения: core не содержит классический weight-скиннинг.
-
-### 10.2. Обратный импорт (OBJ/GLTF -> MSH)
-
-Для 1:1 ожидаемого поведения импортёр должен:
-
-- строить корректные `Res13` диапазоны,
-- строить/обновлять `Res2.slot` ranges и bounds,
-- поддерживать quantization при упаковке (`Res4/Res5`),
-- сохранять unknown-поля таблиц, если вход был редактированием существующей модели.
-
----
-
-## 11. Наблюдения по corpus (testdata/nres)
-
-Сводка по 435 MSH-моделям:
-
-- валидны все 435/435 по `tools/msh_doc_validator.py`;
-- основной порядок типов:
- - `414`: `(1,2,3,4,5,15,13,6,7,8,19,9,10,17)`
- - `21`: `(1,2,3,4,5,18,15,13,6,7,8,19,9,10,17,20)`
-- `Res1.attr3`: `38` в 434 моделях, `24` в 1 модели;
-- `Res18` и `Res20` встречаются в 21 модели;
-- `Res16` в данном corpus не встретился;
-- `Res8/Res19` присутствуют во всех моделях, но `Res19.attr2=1` часто соответствует статике.
-
----
-
-## 12. Открытые вопросы (не блокируют 1:1)
-
-- точная доменная семантика `Node.hdr0` битов;
-- полные имена/назначения `Batch20.unk4/unk6/unk14`;
-- назначение `Slot68.unk30..unk40`;
-- полная семантика `Res15/Res16/Res18/Res20` payload beyond stride-level;
-- точная семантика 4-го байта в `PackedNormal4`.
-
-Для runtime/reader/writer это не критично при условии byte-preserving policy.
+## 5. Критические инварианты
----
+Обязательно проверять:
-## 13. Чеклист реализации 1:1
+- `Res2.size >= 0x8C`
+- `(Res2.size - 0x8C) % 68 == 0`
+- `batchStart + batchCount` не выходит за `Res13`
+- `triStart + triCount` не выходит за `Res7`
+- `indexStart + indexCount` не выходит за `Res6`
+- `baseVertex + max(indexSlice) < vertexCount`
+- `slotIndex == 0xFFFF` или `< slotCount`
-### 13.1. Engine runtime
+## 6. Важные edge-cases
-- реализован loader-порядок как в `sub_10015FD0`;
-- slot lookup по формуле `4 + node*19 + lod*5 + group`;
-- декодирование `Res4` через `/127.0` с clamp;
-- декодирование `Res5` через `/1024.0`;
-- tri селекторы `selPacked` трактуются как 2-битные с `3 -> 0xFFFF`;
-- корректная обработка `0xFFFF` sentinel во всех таблицах.
+- Встречается редкий вариант `Res1.attr3 = 24`; для существующих ассетов нужен copy-through.
+- Для строгого writer лучше генерировать `Res1` в основном формате `38` байт/узел.
+- Неизвестные поля таблиц нельзя нормализовать или обнулять.
-### 13.2. Reader/validator
+## 7. Правила для writer/editor
-- строгая проверка stride/размеров/диапазонов;
-- OOB-защита всех индексных доступов;
-- поддержка both direct-model и nested `.msh` payload.
+1. Сохранять неизвестные поля и неизвестные `type`-ресурсы.
+2. Пересчитывать только явно вычислимые атрибуты (`attr1/attr3` и size-зависимые поля).
+3. Не менять порядок/контент opaque-данных без явной цели.
+4. Сериализовать little-endian, без внутреннего padding.
-### 13.3. Writer/editor
+## 8. Статус валидации
-- стабильный пересчёт `attr1/attr2/attr3`;
-- сохранение unknown fields и unknown resource types;
-- детерминированная сериализация NRes (8-byte align);
-- regression-проверка round-trip: `decode -> encode -> decode` без расхождений структуры/диапазонов.
+- Инварианты формата реализованы в `tools/msh_doc_validator.py`.
+- В текущем окружении нет загруженного полного корпуса игровых MSH в `testdata`, поэтому массовый прогон по ассетам здесь не выполнялся.
diff --git a/docs/specs/msh.md b/docs/specs/msh.md
index e2623f8..a4e29b6 100644
--- a/docs/specs/msh.md
+++ b/docs/specs/msh.md
@@ -6,11 +6,13 @@
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) — контрольные заметки, декодирование и открытые вопросы.
+3. [Material (`MAT0`)](material.md) — формат материала и фазовая анимация.
+4. [Wear (`WEAR`)](wear.md) — текстовая таблица привязки материалов/lightmap.
+5. [Texture (`Texm`)](texture.md) — форматы текстур, mip-chain и `Page`.
+6. [FXID](fxid.md) — контейнер эффекта и поток команд.
+7. [Render pipeline](render.md) — полный процесс рендера кадра.
+8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
+9. [3D implementation notes](msh-notes.md) — контрольные заметки и открытые вопросы.
## Связанные спецификации
diff --git a/docs/specs/render.md b/docs/specs/render.md
new file mode 100644
index 0000000..2994049
--- /dev/null
+++ b/docs/specs/render.md
@@ -0,0 +1,147 @@
+# Render pipeline
+
+Документ описывает полный процесс рендера кадра в движке Parkan: Iron Strategy, без привязки к внутренним адресам/именам дизассемблера.
+
+Связанные страницы:
+
+- [MSH core](msh-core.md)
+- [MSH animation](msh-animation.md)
+- [Material (`MAT0`)](material.md)
+- [Wear table (`WEAR`)](wear.md)
+- [Texture (`Texm`)](texture.md)
+- [FXID](fxid.md)
+
+## 1. Инициализация рендера
+
+На старте движок:
+
+1. Выбирает видеодрайвер (software или аппаратный).
+2. Создаёт render backend.
+3. Подключает библиотеки ресурсов:
+ - `Material.lib`
+ - `Textures.lib`
+ - `LightMap.lib`
+ - `palettes.lib`
+4. Инициализирует менеджеры:
+ - material manager
+ - texture/lightmap cache
+ - effect manager
+5. Загружает базовые world-ресурсы (включая наборы объектов сцены).
+
+## 2. Структура кадра
+
+Кадр выполняется как последовательность:
+
+1. `Simulation update`
+2. `Animation sampling`
+3. `Visibility / culling`
+4. `Material + texture resolve`
+5. `Mesh draw`
+6. `FX update + draw`
+7. `UI/overlay draw`
+8. `Present`
+
+## 3. Geometry path
+
+### 3.1. Подготовка инстансов
+
+Для каждого видимого объекта:
+
+1. Вычисляется `world transform`.
+2. Выбирается `LOD`.
+3. Для каждого узла выбирается slot через `Res1`.
+
+### 3.2. Culling
+
+Сначала отсекаются узлы/слоты по bounds (`AABB/sphere`) из `Res2`.
+
+### 3.3. Батчи
+
+Для каждого прошедшего slot:
+
+1. Берутся батчи из диапазона `Res13`.
+2. По `materialIndex` выбирается активный материал.
+3. По фазе материала выбирается текстура/lightmap.
+4. Выполняется `DrawIndexedPrimitive`:
+ - индексный диапазон: `indexStart/indexCount`
+ - базовая вершина: `baseVertex`
+ - индексы читаются из `Res6`
+ - вершины/атрибуты читаются из `Res3/Res4/Res5` (+ optional streams)
+
+## 4. Animation path
+
+Для анимированных моделей:
+
+1. Для узла выбирается ключ через `Res19` и fallback-логику.
+2. Декодируются `pos + quat` из `Res8`.
+3. При необходимости выполняется blending двух сэмплов.
+4. Узловая матрица передаётся в geometry path.
+
+## 5. Material path
+
+Material pipeline на кадре:
+
+1. По material handle выбирается запись `MAT0`.
+2. По игровому времени выбирается текущая фаза.
+3. Применяются коэффициенты фазы (цвет/альфа/параметры).
+4. Резолвятся ссылки на texture/lightmap.
+5. Невалидные ссылки обрабатываются fallback-стратегией.
+
+## 6. Texture path
+
+При резолве текстуры:
+
+1. Ищется `Texm` entry по имени.
+2. Проверяется и декодируется заголовок.
+3. При необходимости применяется `mipSkip`.
+4. Для indexed-формата подключается палитра.
+5. Optional `Page` chunk интерпретируется как atlas-таблица.
+6. Объект текстуры кладётся/берётся из cache.
+
+## 7. FX path
+
+Эффекты выполняются параллельно mesh-рендеру:
+
+1. Для активных инстансов FX вычисляется runtime-коэффициент (`time_mode + flags`).
+2. Команды FX обновляют внутреннее состояние.
+3. Команды emit-этапа формируют примитивы/батчи эффектов.
+4. Эффекты рисуются в 3D-кадре с собственным счётчиком батчей.
+
+## 8. Псевдокод кадра
+
+```c
+void RenderFrame(Scene* scene, Camera* cam, float dt) {
+ UpdateGame(scene, dt);
+
+ for (Object* obj : scene->objects) {
+ if (!obj->visible) continue;
+
+ UpdateObjectAnimation(obj, scene->time);
+ BuildObjectNodeTransforms(obj);
+ }
+
+ BeginFrame(cam);
+
+ for (Object* obj : scene->objects) {
+ if (!obj->visible) continue;
+ RenderObjectMeshes(obj, cam);
+ }
+
+ UpdateAndRenderFx(scene, dt, cam);
+ RenderUI(scene);
+ Present();
+}
+```
+
+## 9. Критичные условия для 1:1
+
+1. Та же политика округления/FP для анимации и FX.
+2. Та же логика fallback по материалам и текстурам.
+3. Та же очередность стадий кадра.
+4. Тот же контракт интерпретации `Res1/Res2/Res13/Res6`.
+5. Тот же контракт `FXID` командного потока.
+
+## 10. Статус валидации
+
+- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущим runtime-кодом приложения и импортами движковых DLL.
+- Детальные инварианты форматов зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
diff --git a/docs/specs/runtime-pipeline.md b/docs/specs/runtime-pipeline.md
index 7021c82..329afc1 100644
--- a/docs/specs/runtime-pipeline.md
+++ b/docs/specs/runtime-pipeline.md
@@ -1,123 +1,8 @@
# Runtime pipeline
-Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов.
+Актуальный документ по полному кадру находится здесь:
----
+- [Render pipeline](render.md)
-## 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/texture.md b/docs/specs/texture.md
new file mode 100644
index 0000000..5fa1e9d
--- /dev/null
+++ b/docs/specs/texture.md
@@ -0,0 +1,125 @@
+# Texture (`Texm`)
+
+`Texm` — основной формат текстур движка.
+
+Связанные страницы:
+
+- [Material (`MAT0`)](material.md)
+- [Wear table (`WEAR`)](wear.md)
+- [Render pipeline](render.md)
+
+## 1. Контейнер
+
+- Тип ресурса: `0x6D786554` (`Texm`).
+- Используется в `Textures.lib`, `LightMap.lib` и других `NRes` архивах.
+
+## 2. Заголовок
+
+```c
+struct TexmHeader32 {
+ uint32_t magic; // 'Texm'
+ uint32_t width;
+ uint32_t height;
+ uint32_t mipCount;
+ uint32_t flags4;
+ uint32_t flags5;
+ uint32_t unk6;
+ uint32_t format;
+};
+```
+
+## 3. Поддерживаемые форматы
+
+Базовые форматы:
+
+- `0` (8-bit indexed + palette)
+- `565`
+- `4444`
+- `888`
+- `8888`
+
+Дополнительные ветки загрузки поддерживают также `556` и `88`.
+
+## 4. Layout payload
+
+1. `TexmHeader32` (32 байта)
+2. palette `1024` байта, если `format == 0`
+3. mip-chain пикселей
+4. optional `Page` chunk
+
+Расчёт ядра:
+
+```c
+bytesPerPixel =
+ (format == 0) ? 1 :
+ (format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
+ 4;
+
+pixelCount = sum(max(1, width>>i) * max(1, height>>i), i=0..mipCount-1);
+sizeCore = 32 + (format==0 ? 1024 : 0) + bytesPerPixel * pixelCount;
+```
+
+## 5. `Page` chunk
+
+```c
+struct PageChunk {
+ uint32_t magic; // 'Page'
+ uint32_t rectCount;
+ Rect16 rects[rectCount];
+};
+
+struct Rect16 {
+ int16_t x;
+ int16_t w;
+ int16_t y;
+ int16_t h;
+};
+```
+
+`Page` задаёт atlas-прямоугольники для выборки под-областей текстуры.
+
+## 6. Mip-skip политика
+
+Загрузчик может пропускать первые mip-уровни в зависимости от:
+
+- `flags5`,
+- размеров текстуры,
+- количества mip.
+
+После `mipSkip`:
+
+- уменьшаются `width/height/mipCount`;
+- сдвигается начало пиксельных данных;
+- `Page`-координаты пересчитываются в соответствии с новым базовым уровнем.
+
+## 7. Палитры
+
+Для части текстур движок связывает палитру по суффиксу имени.
+
+Практический формат:
+
+- буква `A..Z` + вариант `""` или `0..9`
+- всего `26 * 11 = 286` возможных слотов палитр.
+
+Невалидные суффиксы нужно считать ошибкой входных данных в инструментах.
+
+## 8. Кэширование
+
+Движок ведёт отдельные кэши:
+
+- общий texture cache;
+- lightmap cache.
+
+Для обычных текстур используется отложенный сбор неиспользуемых слотов (по времени нулевого refcount).
+
+## 9. Правила writer/editor
+
+1. Не нормализовать `flags4/flags5/unk6`.
+2. Сохранять payload без лишних хвостовых байт.
+3. Если есть `Page`, его размер должен быть ровно `8 + rectCount * 8`.
+4. Проверять `width > 0`, `height > 0`, `mipCount > 0`.
+
+## 10. Статус валидации
+
+- Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`.
+- В текущем окружении нет полного игрового набора текстур в `testdata`, поэтому массовая перепроверка не запускалась.
diff --git a/docs/specs/wear.md b/docs/specs/wear.md
new file mode 100644
index 0000000..61c799d
--- /dev/null
+++ b/docs/specs/wear.md
@@ -0,0 +1,82 @@
+# Wear table (`WEAR`)
+
+`WEAR` — текстовый ресурс, который связывает слоты wear с именами материалов и lightmap.
+
+Связанные страницы:
+
+- [Material (`MAT0`)](material.md)
+- [Texture (`Texm`)](texture.md)
+
+## 1. Контейнер
+
+- Тип ресурса: `0x52414557` (`WEAR`).
+- Обычно хранится как `*.wea` внутри world/mission архивов.
+
+## 2. Формат текста
+
+```text
+<wearCount:int>
+<legacyId:int> <materialName>
+... (wearCount строк)
+
+[пустая строка]
+[LIGHTMAPS
+<lightmapCount:int>
+<legacyId:int> <lightmapName>
+... (lightmapCount строк)]
+```
+
+`legacyId` читается, но логика выбора работает по имени.
+
+## 3. Совместимость парсинга
+
+В движке используются два режима чтения (`из файла` и `из буфера`), у которых различается обработка блока `LIGHTMAPS`.
+
+Практическое правило для полного совпадения:
+
+- если присутствует блок `LIGHTMAPS`, перед строкой `LIGHTMAPS` должна быть пустая строка-разделитель.
+
+## 4. Runtime-ограничения
+
+- Число wear-таблиц в менеджере ограничено: максимум `70`.
+- Для `wearCount <= 0` ресурс считается некорректным.
+- Для `LIGHTMAPS` блока `lightmapCount <= 0` — также ошибка формата.
+
+## 5. Поведение резолва
+
+### 5.1. Материал
+
+Для каждого wear-слота:
+
+1. Ищется материал по имени.
+2. Если не найден — используется fallback (`DEFAULT`, затем индекс 0).
+
+### 5.2. Lightmap
+
+Для каждого lightmap-слота:
+
+1. Ищется текстура lightmap по имени.
+2. Если не найдено — слот получает `-1`.
+
+## 6. Handle-кодирование
+
+Движок кодирует ссылку на material-slot как:
+
+```c
+handle = (tableIndex << 16) | wearIndex
+```
+
+- `tableIndex` — номер wear-таблицы.
+- `wearIndex` — индекс строки внутри таблицы.
+
+## 7. Правила writer/editor
+
+1. Сохранять порядок строк.
+2. Не переставлять и не нормализовать `legacyId`.
+3. Для совместимости buffer-парсинга сохранять пустую строку перед `LIGHTMAPS`.
+4. Проверять, что число строк соответствует `wearCount`/`lightmapCount`.
+
+## 8. Статус валидации
+
+- Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном.
+- Массовый прогон по полному игровому набору в этом окружении не выполнялся из-за отсутствия корпуса данных в `testdata`.