aboutsummaryrefslogtreecommitdiff
path: root/docs/specs
diff options
context:
space:
mode:
Diffstat (limited to 'docs/specs')
-rw-r--r--docs/specs/msh-animation.md510
-rw-r--r--docs/specs/msh-core.md846
-rw-r--r--docs/specs/terrain-map-loading.md513
3 files changed, 1473 insertions, 396 deletions
diff --git a/docs/specs/msh-animation.md b/docs/specs/msh-animation.md
index 811fa00..ccfac35 100644
--- a/docs/specs/msh-animation.md
+++ b/docs/specs/msh-animation.md
@@ -1,105 +1,517 @@
# MSH animation
-Документ описывает анимационные ресурсы MSH: `Res8`, `Res19` и runtime-интерполяцию.
+Документ фиксирует анимационную часть формата MSH (`Res8`, `Res19`) и runtime-алгоритм сэмплирования/смешивания, необходимый для 1:1 совместимого движка и toolchain (reader/writer/converter/editor).
+
+Связанные документы:
+- [MSH core](msh-core.md) — общая структура модели и `Res1`/`Res2`.
+- [NRes / RsLi](nres.md) — контейнер и атрибуты записей.
---
-## 1.13. Ресурсы анимации: Res8 и Res19
+## 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`.
-- **Res8** — массив анимационных ключей фиксированного размера 24 байта.
-- **Res19** — `uint16` mapping‑массив «frame → keyIndex` (с per-node смещением).
+Ниже разделено на:
+- **Нормативно**: обязательно для runtime-совместимости.
+- **Канонично**: как устроены исходные ассеты; важно для детерминированного writer/editor.
+
+---
-### 1.13.1. Формат Res8 (ключ 24 байта)
+## 2. Ресурсы и поля модели
+
+### 2.1. Res8 — key pool (нормативно)
+
+`Res8` — массив ключей фиксированного шага 24 байта.
```c
struct AnimKey24 {
- float posX; // +0x00
- float posY; // +0x04
- float posZ; // +0x08
- float time; // +0x0C
- int16_t qx; // +0x10
- int16_t qy; // +0x12
- int16_t qz; // +0x14
- int16_t qw; // +0x16
+ 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
};
```
Декодирование quaternion-компонент:
```c
-q = s16 * (1.0f / 32767.0f)
+float q = (float)s16 * (1.0f / 32767.0f);
```
-### 1.13.2. Формат Res19
+Атрибуты NRes:
+- `attr1 = size / 24` (количество ключей).
+- `attr2 = 0` (в observed corpus).
+- `attr3 = 4` (не stride; это фактический runtime-инвариант формата).
+
+### 2.2. Res19 — frame->segment map (нормативно)
-Res19 читается как непрерывный массив `uint16`:
+`Res19` — непрерывный `uint16` массив:
```c
-uint16_t map[]; // размер = size(Res19)/2
+uint16_t map_words[]; // count = size / 2
```
-Per-node управление mapping'ом берётся из заголовка узла Res1:
+Атрибуты 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.
-- `node.hdr2` (`Res1 + 0x04`) = `mapStart` (`0xFFFF` => map отсутствует);
-- `node.hdr3` (`Res1 + 0x06`) = `fallbackKeyIndex` и одновременно верхняя граница валидного `map`‑значения.
+В оригинальном runtime control word приводится к каноничному виду в `Ngi32::sub_10006D00`:
+- `cw = (cw & 0xF0FF) | 0x003F`;
+- это даёт `round-to-nearest` (RC=00), precision control `PC=00` и маскирование x87-исключений.
-### 1.13.3. Выбор ключа для времени `t` (`sub_10012880`)
+Если нужен byte/behavior 1:1, надо повторить именно x87-ветку или её точный эквивалент.
-1) Вычислить frame‑индекс:
+### 3.2. Выбор `keyIndex` (нормативно)
```c
-frame = (int64)(t - 0.5f); // x87 FISTP-путь
+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];
+}
```
-Для строгой 1:1 эмуляции используйте именно поведение x87 `FISTP` (а не «упрощённый floor»), т.к. путь в оригинале опирается на FPU rounding mode.
+Критично:
+- 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`.
-2) Проверка условий fallback:
+То есть все `g_FastProc`-пути в анимации работают с quaternion в порядке `float4 = [w, x, y, z]`.
-- `frame >= model.animFrameCount` (`model+0x9C`, из `NResEntry(Res19).attr2`);
-- `mapStart == 0xFFFF`;
-- `map[mapStart + frame] >= fallbackKeyIndex`.
+---
+
+## 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
-keyIndex = fallbackKeyIndex;
+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
-keyIndex = map[mapStart + frame];
+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];
+}
```
-3) Сэмплирование:
+---
+
+## 5. Каноническая модель данных для toolchain
+
+Ниже правила, по которым удобно строить 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`.
-- `k0 = Res8[keyIndex]`
-- `k1 = Res8[keyIndex + 1]` (для интерполяции сегмента)
+### 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`.
-- если `t == k0.time` → взять `k0`;
-- если `t == k1.time` → взять `k1`;
-- иначе `alpha = (t - k0.time) / (k1.time - k0.time)`, `pos = lerp(k0.pos, k1.pos, alpha)`, rotation смешивается через fastproc‑интерполятор quaternion.
+В исходных данных fallback-фреймы кодируются значением `== fallback` (не просто `>= fallback`).
-### 1.13.4. Межкадровое смешивание (`sub_10012560`)
+---
-Функция смешивает два сэмпла (например, из двух animation time-позиций) с коэффициентом `blend`:
+## 6. Reference IR для редактора/конвертера
-1) получить два `(quat, pos)` через `sub_10012880`;
-2) выполнить shortest‑path коррекцию знака quaternion:
+Рекомендуемое промежуточное представление:
```c
-if (|q0 + q1|^2 < |q0 - q1|^2) q1 = -q1;
+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
+};
```
-3) смешать quaternion (fastproc) и построить orientation‑матрицу;
-4) translation писать отдельно как `lerp(pos0, pos1, blend)` в ячейки `m[3], m[7], m[11]`.
+Где `AnimKey`:
+- `pos: float3`,
+- `time: float`,
+- `quat_raw: int16[4]` (для lossless),
+- `quat_decoded: float4` (опционально для API/UI).
-### 1.13.5. Что хранится в `Res19.attr2`
+---
+
+## 7. Алгоритм чтения (reader)
-При загрузке `sub_10015FD0` записывает `NResEntry(Res19).attr2` в `model+0x9C`.
-Это поле используется как верхняя граница frame‑индекса в п.1.13.3.
+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` канонично).
---
+## 8. Алгоритм записи (writer)
+
+Нормативный минимум для runtime-совместимости:
+
+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`.
+
+Каноничный writer (рекомендуется):
+- генерирует map по правилу §5.4;
+- fallback-фреймы записывает `== fallback`;
+- для статических узлов использует 1 ключ (`time=0`, `hdr2=0xFFFF`).
+
+---
+
+## 9. Валидация перед сохранением
+
+Обязательные проверки:
+
+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. `fallback_i` строго возрастает по узлам.
+2. track каждого узла начинается с `time == 0`.
+3. `frame_count == max_end_time + 1`.
+4. map-блоки узлов без дыр/overlap.
+
+---
+
+## 10. Edge cases и совместимость
+
+### 10.1. `Res19.size == 0`
+
+Поддерживается runtime-ом:
+- `frame_count` обычно 1;
+- `hdr2 == 0xFFFF` у всех узлов;
+- сэмплирование всегда через fallback key (`hdr3`).
+
+### 10.2. Узлы без map
+
+Это нормальный режим для статических/квазистатических узлов:
+- `hdr2 = 0xFFFF`;
+- `hdr3` указывает на единственный ключ узла (канонично).
+
+### 10.3. `Res1.attr3 == 24` (legacy outlier)
+
+В corpus встречается единично (`MTCHECK.MSH`, `testdata/nres/system.rlb`):
+- `Res1.attr3 = 24`;
+- `Res8` содержит 1 ключ;
+- `Res19.size == 0`.
+
+Алгоритм `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]
+}
+```
+
+## 12. Границы полноты
+
+Для основного формата (`Res1` stride 38 + `Res8` + `Res19`) эта страница покрывает runtime и toolchain-поведение на уровне, достаточном для 1:1 реализации (reader/writer/converter/editor).
+
+Единственный подтверждённый неполный сегмент:
+- legacy `Res1.attr3 == 24` (`MTCHECK.MSH`), для которого в `AniMesh` не найден отдельный открытый decode-path в рамках текущего реверса.
+
+Для абсолютных 100% по всем историческим вариантам формата дополнительно нужно:
+- найти и дореверсить runtime-код, который реально обрабатывает `Res1.attr3==24` (если он есть в других модулях/ветках);
+- получить больше образцов `*.msh` с `attr3==24` для проверки writer/validator-инвариантов.
diff --git a/docs/specs/msh-core.md b/docs/specs/msh-core.md
index 82aec18..a80496a 100644
--- a/docs/specs/msh-core.md
+++ b/docs/specs/msh-core.md
@@ -1,492 +1,678 @@
# MSH core
-Документ описывает core-часть формата MSH: геометрию, узлы, батчи, LOD и slot-матрицу.
+Документ фиксирует core-часть формата MSH на уровне, достаточном для:
-Связанный формат контейнера: [NRes / RsLi](nres.md).
+- реализации runtime-совместимого движка (поведение 1:1);
+- реализации reader/writer/editor/converter с lossless round-trip;
+- валидации ассетов и диагностики повреждений.
+
+Связанные документы:
+
+- [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.1. Общая архитектура
+## 1. Область и источники
-Модель состоит из набора именованных ресурсов внутри одного NRes‑архива. Каждый ресурс идентифицируется **целочисленным типом** (`resource_type`), который передаётся API функции `niReadData` (vtable‑метод `+0x18`) через связку `niFind` (vtable‑метод `+0x0C`, `+0x20`).
+### 1.1. Что покрывает этот документ
-Рендер‑модель использует **rigid‑скининг по узлам** (нет per‑vertex bone weights). Каждый batch геометрии привязан к одному узлу и рисуется с матрицей этого узла.
+Этот документ покрывает именно **core-геометрию и её runtime-связи**:
-## 1.2. Общая структура файла модели
+- `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`).
-```
-┌────────────────────────────────────┐
-│ NRes‑заголовок (16 байт) │
-├────────────────────────────────────┤
-│ Ресурсы (произвольный порядок): │
-│ Res1 — Node table │
-│ Res2 — Model header + Slots │
-│ Res3 — Vertex positions │
-│ Res4 — Packed normals │
-│ Res5 — Packed UV0 │
-│ Res6 — Index buffer │
-│ Res7 — Triangle descriptors │
-│ Res8 — Keyframe data │
-│ Res10 — String table │
-│ Res13 — Batch table │
-│ Res19 — Animation mapping │
-│ [Res15] — UV1 / доп. поток │
-│ [Res16] — Tangent/Bitangent │
-│ [Res18] — Vertex color │
-│ [Res20] — Доп. таблица │
-├────────────────────────────────────┤
-│ NRes‑каталог │
-└────────────────────────────────────┘
-```
+### 1.2. Что не покрывает
-Ресурсы в квадратных скобках — **опциональные**. Загрузчик проверяет их наличие перед чтением (`niFindRes` возвращает `−1` при отсутствии).
-
-## 1.3. Порядок загрузки ресурсов (из `sub_10015FD0` в AniMesh.dll)
-
-Функция `sub_10015FD0` выполняет инициализацию внутренней структуры модели размером **0xA4** (164 байта). Ниже приведён точный порядок загрузки и маппинг ресурсов на поля структуры:
-
-| Шаг | Тип ресурса | Поле структуры | Описание |
-|-----|-------------|----------------|-----------------------------------------|
-| 1 | 1 | `+0x00` | Node table (Res1) |
-| 2 | 2 | `+0x04` | Model header (Res2) |
-| 3 | 3 | `+0x0C` | Vertex positions (Res3) |
-| 4 | 4 | `+0x10` | Packed normals (Res4) |
-| 5 | 5 | `+0x14` | Packed UV0 (Res5) |
-| 6 | 10 (0x0A) | `+0x20` | String table (Res10) |
-| 7 | 8 | `+0x18` | Keyframe / animation track data (Res8) |
-| 8 | 19 (0x13) | `+0x1C` | Animation mapping (Res19) |
-| 9 | 7 | `+0x24` | Triangle descriptors (Res7) |
-| 10 | 13 (0x0D) | `+0x28` | Batch table (Res13) |
-| 11 | 6 | `+0x2C` | Index buffer (Res6) |
-| 12 | 15 (0x0F) | `+0x34` | Доп. vertex stream (Res15), опционально |
-| 13 | 16 (0x10) | `+0x38` | Доп. vertex stream (Res16), опционально |
-| 14 | 18 (0x12) | `+0x64` | Vertex color (Res18), опционально |
-| 15 | 20 (0x14) | `+0x30` | Доп. таблица (Res20), опционально |
-
-### Производные поля (вычисляются после загрузки)
-
-| Поле | Формула | Описание |
-|---------|-------------------------|------------------------------------------------------------------------------------------------|
-| `+0x08` | `Res2_ptr + 0x8C` | Указатель на slot table (140 байт от начала Res2) |
-| `+0x3C` | `= Res3_ptr` | Копия указателя positions (stream ptr) |
-| `+0x40` | `= 0x0C` (12) | Stride позиций: `sizeof(float3)` |
-| `+0x44` | `= Res4_ptr` | Копия указателя normals (stream ptr) |
-| `+0x48` | `= 4` | Stride нормалей: 4 байта |
-| `+0x4C` | `Res16_ptr` или `0` | Stream A Res16 (tangent) |
-| `+0x50` | `= 8` если `+0x4C != 0` | Stride stream A (используется только при наличии Res16) |
-| `+0x54` | `Res16_ptr + 4` или `0` | Stream B Res16 (bitangent) |
-| `+0x58` | `= 8` если `+0x54 != 0` | Stride stream B (используется только при наличии Res16) |
-| `+0x5C` | `= Res5_ptr` | Копия указателя UV0 (stream ptr) |
-| `+0x60` | `= 4` | Stride UV0: 4 байта |
-| `+0x68` | `= 4` или `0` | Stride Res18 (если найден) |
-| `+0x8C` | `= Res15_ptr` | Копия указателя Res15 |
-| `+0x90` | `= 8` | Stride Res15: 8 байт |
-| `+0x94` | `= 0` | Зарезервировано/unk94: инициализируется нулём при загрузке; не является флагом Res18 |
-| `+0x9C` | NRes entry Res19 `+8` | Метаданные из каталожной записи Res19 |
-| `+0xA0` | NRes entry Res20 `+4` | Метаданные из каталожной записи Res20 (заполняется только если Res20 найден и открыт, иначе 0) |
-
-**Примечание к метаданным:** поле `+0x9C` читается из каталожной записи NRes для ресурса 19 (смещение `+8` в записи каталога, т.е. `attribute_2`). Поле `+0xA0` — из каталожной записи для ресурса 20 (смещение `+4`, т.е. `attribute_1`) **только если Res20 найден и `niOpenRes` вернул ненулевой указатель**; иначе `+0xA0 = 0`. Индекс записи определяется как `entry_index * 64`, после чего считывается поле.
+- детальную семантику материалов/текстурных фаз (см. `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`).
---
-### 1.3.1. Ссылки на функции и паттерны вызовов (для проверки реверса)
+## 2. Модель данных MSH (high-level)
-- `AniMesh.dll!sub_10015FD0` — загрузка ресурсов модели через vtable интерфейса NRes:
- - `niFindRes(type, ...)` вызывается через `call [vtable+0x20]`
- - `niOpenRes(...)` / чтение указателя — через `call [vtable+0x18]`
-- `AniMesh.dll!sub_10015FD0` выставляет производные поля (`Res2_ptr+0x8C`, stride'ы), обнуляет `model+0x94`, и при отсутствии Res16 обнуляет только указатели потоков (`+0x4C`, `+0x54`).
-- `AniMesh.dll!sub_10004840` / `sub_10004870` / `sub_100048A0` — использование runtime mapping‑таблицы (`+0x18`, индекс `boneId*4`) и таблицы указателей треков (`+0x08`) после построения анимационного объекта.
+MSH-модель — это NRes-контейнер, где ресурсы связаны **не по порядку, а по type-id**.
+Базовая связь таблиц:
-## 1.4. Ресурс Res2 — Model Header (140 байт) + Slot Table
+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`.
-Ресурс Res2 содержит:
+Ключевая особенность runtime:
-```
-┌───────────────────────────────────┐ Смещение 0
-│ Model Header (140 байт = 0x8C) │
-├───────────────────────────────────┤ Смещение 140 (0x8C)
-│ Slot Table │
-│ (slot_count × 68 байт) │
-└───────────────────────────────────┘
-```
+- скиннинг по узлам жёсткий (rigid attachment), без per-vertex bone weights в core-ресурсах.
-### 1.4.1. Model Header (первые 140 байт)
+---
-Поле `Res2[0x00..0x8B]` используется как **35 float** (без внутренних таблиц/индексов). Это подтверждено прямыми копированиями в `AniMesh.dll!sub_1000A460`:
+## 3. Карта ресурсов и границы core
-- `qmemcpy(this+0x54, Res2+0x00, 0x60)` — первые 24 float;
-- копирование `Res2+0x60` размером `0x10` — ещё 4 float;
-- `qmemcpy(this+0x134, Res2+0x70, 0x1C)` — ещё 7 float.
+### 3.1. Ресурсы, которые читает core-loader (`sub_10015FD0`)
-Итоговая раскладка:
+| Type | Ресурс | Статус в core-loader | Формат/stride |
+|---:|---|---|---|
+| 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 |
-| Диапазон | Размер | Тип | Семантика |
-|--------------|--------|-------------|----------------------------------------------------------------------|
-| `0x00..0x5F` | `0x60` | `float[24]` | 8 вершин глобального bounding‑hull (`vec3[8]`) |
-| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная bounding‑sphere: `center.xyz + radius` |
-| `0x70..0x8B` | `0x1C` | `float[7]` | Глобальный «капсульный»/сегментный bound: `A.xyz`, `B.xyz`, `radius` |
+### 3.2. Ресурсы, которые встречаются в MSH, но вне этого документа
-Для рендера и broadphase движок использует как слот‑bounds (`Res2 slot`), так и этот глобальный набор bounds (в зависимости от контекста вызова/LOD и наличия слота).
+В corpus из 435 моделей стабильно встречаются также `type 9` и `type 17`.
+Они **не загружаются** `sub_10015FD0` и относятся к некоревым подсистемам (материалы/эффекты/прочие runtime-ветки).
-### 1.4.2. Slot Table (массив записей по 68 байт)
+### 3.3. Прямая MSH и вложенная MSH
-Slot — ключевая структура, связывающая узел иерархии с конкретной геометрией для конкретного LOD и группы. Каждая запись — **68 байт** (0x44).
+Tooling должен поддерживать два режима входа:
-**Важно:** смещения в таблице ниже указаны в **десятичном формате** (байты). В скобках приведён hex‑эквивалент (например, 48 (0x30)).
+- файл уже является модельным NRes (`magic NRes` и содержит `type 1/2/3/6/13`),
+- файл-архив содержит `.msh` entry, внутри которой вложенный NRes модели.
+---
-| Смещение | Размер | Тип | Описание |
-|-----------|--------|----------|-----------------------------------------------------|
-| 0 | 2 | uint16 | `triStart` — индекс первого треугольника в Res7 |
-| 2 | 2 | uint16 | `triCount` — длина диапазона треугольников (`Res7`) |
-| 4 | 2 | uint16 | `batchStart` — индекс первого batch'а в Res13 |
-| 6 | 2 | uint16 | `batchCount` — количество batch'ей |
-| 8 | 4 | float | `aabbMin.x` |
-| 12 | 4 | float | `aabbMin.y` |
-| 16 | 4 | float | `aabbMin.z` |
-| 20 | 4 | float | `aabbMax.x` |
-| 24 | 4 | float | `aabbMax.y` |
-| 28 | 4 | float | `aabbMax.z` |
-| 32 | 4 | float | `sphereCenter.x` |
-| 36 | 4 | float | `sphereCenter.y` |
-| 40 | 4 | float | `sphereCenter.z` |
-| 44 (0x2C) | 4 | float | `sphereRadius` |
-| 48 (0x30) | 20 | 5×uint32 | Хвостовые поля: `unk30..unk40` (см. §1.4.2.1) |
+## 4. Runtime-контракт загрузки (`sub_10015FD0`)
-**AABB** — axis‑aligned bounding box в локальных координатах узла.
-**Bounding Sphere** — описанная сфера в локальных координатах узла.
+`sub_10015FD0` заполняет структуру модели размером `0xA4` байт и строит derived pointers/stride.
-#### 1.4.2.1. Точная семантика `triStart/triCount`
+### 4.1. Порядок `find/open`
-В `AniMesh.dll!sub_1000B2C0` слот считается «владельцем» треугольника `triId`, если:
+Фактический порядок загрузки:
-```c
-triId >= slot.triStart && triId < slot.triStart + slot.triCount
-```
+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` (инициализация нулём).
-Это прямое доказательство, что `slot +0x02` — именно **count диапазона**, а не флаги.
+Для `Res16`:
-#### 1.4.2.2. Хвост слота (20 байт = 5×uint32)
+- если есть: `this+0x4C = Res16`, `this+0x50 = 8`, `this+0x54 = Res16+4`, `this+0x58 = 8`;
+- если нет: `this+0x4C = 0`, `this+0x54 = 0` (stride остаются несущественными, т.к. указатели нулевые).
-Последние 20 байт записи слота трактуем как 5 последовательных 32‑битных значений (little‑endian). Их назначение пока не подтверждено; для инструментов рекомендуется сохранять и восстанавливать их «как есть».
+Для `Res18`:
-- `+48 (0x30)`: `unk30` (uint32)
-- `+52 (0x34)`: `unk34` (uint32)
-- `+56 (0x38)`: `unk38` (uint32)
-- `+60 (0x3C)`: `unk3C` (uint32)
-- `+64 (0x40)`: `unk40` (uint32)
+- если найден: `this+0x64 = ptr`, `this+0x68 = 4`;
+- иначе: `this+0x64 = 0`, `this+0x68 = 0`.
-Для culling при рендере: AABB/sphere трансформируются матрицей узла и инстанса. При неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (подтверждено по коду).
+### 4.3. Метаданные из каталога NRes
+
+- `this+0x9C` получает `entry(type19).attr2` (читается из поля `+8` каталожной записи, индекс `entry * 64`).
+- `this+0xA0` получает `entry(type20).attr1` (поле `+4`) только если `type20` существует и успешно открыт; иначе `0`.
---
-### 1.4.3. Восстановление счётчиков элементов по размерам ресурсов (практика для инструментов)
+## 5. Бинарные структуры core-ресурсов
+
+Все структуры little-endian.
-Для toolchain надёжнее считать count'ы по размерам ресурсов (а не по дублирующим полям других таблиц). Это полностью совпадает с тем, как рантайм использует fixed stride'ы в `sub_10015FD0`.
+### 5.1. `Res1` — Node table
-Берите **unpacked_size** (или фактический размер распакованного блока) соответствующего ресурса и вычисляйте:
+Базовый stride: `38` байт (`19 * uint16`).
-- `node_count` = `size(Res1) / 38`
-- `vertex_count` = `size(Res3) / 12`
-- `normals_count` = `size(Res4) / 4`
-- `uv0_count` = `size(Res5) / 4`
-- `index_count` = `size(Res6) / 2`
-- `tri_count` = `index_count / 3` (если примитивы — список треугольников)
-- `tri_desc_count` = `size(Res7) / 16`
-- `batch_count` = `size(Res13) / 20`
-- `slot_count` = `(size(Res2) - 0x8C) / 0x44`
-- `anim_key_count` = `size(Res8) / 24`
-- `anim_map_count` = `size(Res19) / 2`
-- `uv1_count` = `size(Res15) / 8` (если Res15 присутствует)
-- `tbn_count` = `size(Res16) / 8` (если Res16 присутствует; tangent/bitangent по 4 байта, stride 8)
-- `color_count` = `size(Res18) / 4` (если Res18 присутствует)
+```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]
+};
+```
-**Валидация:**
+#### Подтверждённые поля
-- Любое деление должно быть **без остатка**; иначе ресурс повреждён или stride неверно угадан.
-- Если присутствуют Res4/Res5/Res15/Res16/Res18, их count'ы по смыслу должны совпадать с `vertex_count` (или быть ≥ него, если формат допускает хвостовые данные — пока не наблюдалось).
-- Для `slot_count` дополнительно проверьте, что `size(Res2) >= 0x8C`.
+- `hdr1`: parent/index-link (используется при построении инстанса), `0xFFFF` = нет.
+- `hdr2`: `mapStart` для `Res19` (см. `msh-animation.md`), `0xFFFF` = нет map.
+- `hdr3`: fallback key index в `Res8`.
+- `hdr0`: node flags (есть битовые проверки, но полная доменная семантика не закрыта).
-**Проверка на реальных данных (435 MSH):**
+#### Адресация slot (runtime-функция `sub_100124D0`)
-- `Res2.attr1 == (size-140)/68`, `Res2.attr2 == 0`, `Res2.attr3 == 68`;
-- `Res7.attr1 == size/16`, `Res7.attr3 == 16`;
-- `Res8.attr1 == size/24`, `Res8.attr3 == 4`;
-- `Res19.attr1 == size/2`, `Res19.attr3 == 2`;
-- для `Res1` почти всегда `attr3 == 38` (один служебный outlier: `MTCHECK.MSH` с `attr3 == 24`).
+```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);
+}
+```
-Эти формулы достаточны, чтобы реализовать распаковщик/просмотрщик геометрии и батчей даже без полного понимания полей заголовка Res2.
+`0xFFFF` означает "слот отсутствует".
-## 1.5. Ресурс Res1 — Node Table (38 байт на узел)
+#### Вариант stride=24
-Node table — компактная карта слотов по уровням LOD и группам. Каждый узел занимает **38 байт** (19 × `uint16`).
+В corpus есть единичный служебный outlier с `Res1.attr3 = 24`.
+Для 1:1 editing существующих ассетов требуется copy-through этого варианта.
+Новая генерация должна ориентироваться на stride `38`, если нет чёткой цели поддержать legacy-вариант.
-### Адресация слота
+---
-Движок вычисляет индекс слова в таблице:
+### 5.2. `Res2` — Model header + Slot table
```
-word_index = nodeIndex × 19 + lod × 5 + group + 4
-slot_index = node_table[word_index] // uint16, 0xFFFF = нет слота
+Res2:
+ [0x00 .. 0x8B] model header (140 bytes)
+ [0x8C .. end] slot records (68 bytes each)
```
-Параметры:
+#### 5.2.1. Header (0x8C)
-- `lod`: 0..2 (три уровня детализации). Значение `−1` → подставляется `current_lod` из инстанса.
-- `group`: 0..4 (пять групп). На практике чаще всего используется `group = 0`.
+Runtime копирует блоки как float-массивы:
-### Раскладка записи узла (38 байт)
+- `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)
+
+```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
+};
```
-┌───────────────────────────────────────────────────────┐
-│ Header: 4 × uint16 (8 байт) │
-│ hdr0, hdr1, hdr2, hdr3 │
-├───────────────────────────────────────────────────────┤
-│ SlotIndex matrix: 3 LOD × 5 groups = 15 × uint16 │
-│ LOD 0: group[0..4] │
-│ LOD 1: group[0..4] │
-│ LOD 2: group[0..4] │
-└───────────────────────────────────────────────────────┘
+
+`triCount` подтверждён как длина диапазона:
+
+```c
+triId >= triStart && triId < triStart + triCount
```
-| Смещение | Размер | Тип | Описание |
-|----------|--------|------------|-----------------------------------------|
-| 0 | 8 | uint16[4] | Заголовок узла (`hdr0..hdr3`, см. ниже) |
-| 8 | 30 | uint16[15] | Матрица слотов: `slotIndex[lod][group]` |
+Хвост `unk30..unk40` должен сохраняться без изменений в editor/writer.
-`slotIndex = 0xFFFF` означает «слот отсутствует» — узел при данном LOD и группе не рисуется.
+#### 5.2.3. Bounds semantics
-Подтверждённые семантики полей `hdr*`:
+- Slot bounds локальны относительно узла.
+- При world-трансформации sphere radius масштабируется по `max(scaleX, scaleY, scaleZ)` при неравномерном scale.
+
+---
-- `hdr1` (`+0x02`) — parent/index-link при построении инстанса (в `sub_1000A460` читается как индекс связанного узла, `0xFFFF` = нет связи).
-- `hdr2` (`+0x04`) — `mapStart` для Res19 (`0xFFFF` = нет карты; fallback по `hdr3`).
-- `hdr3` (`+0x06`) — `fallbackKeyIndex`/верхняя граница для map‑значений (используется в `sub_10012880`).
+### 5.3. `Res3` — Positions
-`hdr0` (`+0x00`) по коду участвует в битовых проверках (`&0x40`, `byte+1 & 8`) и несёт флаги узла.
+```c
+struct Position12 {
+ float x;
+ float y;
+ float z;
+};
+```
-**Группы (group 0..4):** в рантайме это ортогональный индекс к LOD (матрица 5×3 на узел). Имена групп в оригинальных ресурсах не подписаны; для 1:1 нужно сохранять группы как «сырой» индекс 0..4 без переинтерпретации.
+Stride `12`.
---
-## 1.6. Ресурс Res3 — Vertex Positions
-
-**Формат:** массив `float3` (IEEE 754 single‑precision).
-**Stride:** 12 байт.
+### 5.4. `Res4` — Packed normals
```c
-struct Position {
- float x; // +0
- float y; // +4
- float z; // +8
+struct PackedNormal4 {
+ int8_t nx;
+ int8_t ny;
+ int8_t nz;
+ int8_t nw; // семантика 4-го байта не зафиксирована
};
```
-Чтение: `pos = *(float3*)(res3_data + 12 * vertexIndex)`.
+Декодирование:
----
+```c
+normal = clamp((float)n / 127.0f, -1.0f, 1.0f)
+```
-## 1.7. Ресурс Res4 — Packed Normals
+- делитель строго `127.0`;
+- clamp обязателен из-за `-128 / 127.0`.
-**Формат:** 4 байта на вершину.
-**Stride:** 4 байта.
+Кодирование (writer):
```c
-struct PackedNormal {
- int8_t nx; // +0
- int8_t ny; // +1
- int8_t nz; // +2
- int8_t nw; // +3 (назначение не подтверждено: паддинг / знак / индекс)
-};
+int8_t q = (int8_t)clamp(round(v * 127.0f), -128, 127);
```
-### Алгоритм декодирования (подтверждено по AniMesh.dll)
+---
-> В движке используется делитель **127.0**, а не 128.0 (см. константу `127.0` рядом с `1024.0`/`32767.0`).
+### 5.5. `Res5` — Packed UV0
+```c
+struct PackedUV4 {
+ int16_t u;
+ int16_t v;
+};
```
-normal.x = clamp((float)nx / 127.0, -1.0, 1.0)
-normal.y = clamp((float)ny / 127.0, -1.0, 1.0)
-normal.z = clamp((float)nz / 127.0, -1.0, 1.0)
+
+Декодирование:
+
+```c
+uv = packed / 1024.0f
```
-**Множитель:** `1.0 / 127.0 ≈ 0.0078740157`.
-**Диапазон входных значений:** −128..+127 → выход ≈ −1.007874..+1.0 → **после клампа** −1.0..+1.0.
-**Почему нужен кламп:** значение `-128` при делении на `127.0` даёт модуль чуть больше 1.
-**4‑й байт (nw):** используется ли он как часть нормали, как индекс или просто как выравнивание — не подтверждено. Рекомендация: игнорировать при первичном импорте.
+Кодирование:
+
+```c
+int16_t q = (int16_t)clamp(round(uv * 1024.0f), -32768, 32767);
+```
---
-## 1.8. Ресурс Res5 — Packed UV0
+### 5.6. `Res6` — Index buffer
+
+Массив `uint16`, stride `2`.
+
+Runtime-путь:
+
+```c
+vertexIndex = Res6[indexStart + i] + batch.baseVertex;
+```
+
+`indexStart` хранится в элементах, не в байтах.
+
+---
-**Формат:** 4 байта на вершину (два `int16`).
-**Stride:** 4 байта.
+### 5.7. `Res7` — Triangle descriptors (16 bytes)
```c
-struct PackedUV {
- int16_t u; // +0
- int16_t v; // +2
+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
};
```
-### Алгоритм декодирования
+- `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;
```
-uv.u = (float)u / 1024.0
-uv.v = (float)v / 1024.0
+
+---
+
+### 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
+};
```
-**Множитель:** `1.0 / 1024.0 = 0.0009765625`.
-**Диапазон входных значений:** −32768..+32767 → выход ≈ −32.0..+31.999.
-Значения >1.0 или <0.0 означают wrapping/repeat текстурных координат.
+`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 нет
+};
```
-packed_u = (int16_t)round(uv.u * 1024.0)
-packed_v = (int16_t)round(uv.v * 1024.0)
+
+Переход:
+
+```c
+next = cur + 4 + (len ? len + 1 : 0);
```
-Результат обрезается (clamp) до диапазона `int16` (−32768..+32767).
+`sub_10012530` возвращает:
+
+- `NULL`, если `len == 0`,
+- `record + 4`, если `len > 0`.
+
+Индекс записи в `Res10` соответствует `nodeIndex`.
---
-## 1.9. Ресурс Res6 — Index Buffer
+### 5.10. Optional streams
-**Формат:** массив `uint16` (беззнаковые 16‑битные индексы).
-**Stride:** 2 байта.
+#### `Res15` (stride 8)
-Максимальное число вершин в одном batch: 65535.
-Индексы используются совместно с `baseVertex` из batch table:
+Дополнительный поток на вершину (семантика не полностью подтверждена).
-```
-actual_vertex_index = index_buffer[indexStart + i] + baseVertex
-```
+#### `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).
---
-## 1.10. Ресурс Res7 — Triangle Descriptors
+## 6. Runtime-алгоритмы core
+
+### 6.1. Slot lookup (`sub_100124D0`)
+
+Вход: runtime-node-instance, `group`, `lod`.
+
+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`.
+
+### 6.2. Node string lookup (`sub_10012530`)
-**Формат:** массив записей по 16 байт. Одна запись на треугольник.
+1. Идти по `Res10`-записям `nodeIndex` раз.
+2. Возвращать `NULL` или `char*` по правилу `len==0`.
-| Смещение | Размер | Тип | Описание |
-|----------|--------|----------|---------------------------------------------|
-| `+0x00` | 2 | `uint16` | `triFlags` — фильтрация/материал tri‑уровня |
-| `+0x02` | 2 | `uint16` | `linkTri0` — tri‑ref для связанного обхода |
-| `+0x04` | 2 | `uint16` | `linkTri1` — tri‑ref для связанного обхода |
-| `+0x06` | 2 | `uint16` | `linkTri2` — tri‑ref для связанного обхода |
-| `+0x08` | 2 | `int16` | `nX` (packed, scale `1/32767`) |
-| `+0x0A` | 2 | `int16` | `nY` (packed, scale `1/32767`) |
-| `+0x0C` | 2 | `int16` | `nZ` (packed, scale `1/32767`) |
-| `+0x0E` | 2 | `uint16` | `selPacked` — 3 селектора по 2 бита |
+### 6.3. Геометрический обход для рендера
-Расшифровка `selPacked` (`AniMesh.dll!sub_10013680`):
+Reference-путь, эквивалентный runtime-логике:
```c
-sel0 = selPacked & 0x3; if (sel0 == 3) sel0 = 0xFFFF;
-sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF;
-sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF;
+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])
```
-`linkTri*` передаются в `sub_1000B2C0` и используются для построения соседнего набора треугольников при коллизии/пикинге.
+### 6.4. Tri/collision path (обобщённо)
-**Важно:** дескрипторы не хранят индексы вершин треугольника. Индексы берутся из Res6 (index buffer) через `indexStart`/`indexCount` соответствующего batch'а.
-
-Дескрипторы используются при обходе треугольников для коллизии и пикинга. `triStart` из slot table указывает, с какого дескриптора начинать обход для данного слота.
+- `sub_1000B2C0` и `sub_10013680` используют tri-диапазоны слота + `Res7` link/select-поля.
+- Для collision/picking-контекста должны быть валидны:
+ - `slot.triStart + slot.triCount <= triDescCount`,
+ - `linkTri*` либо `0xFFFF`, либо `< triDescCount`.
---
-## 1.11. Ресурс Res13 — Batch Table
+## 7. Инварианты и валидация (reader)
-**Формат:** массив записей по 20 байт. Batch — минимальная единица отрисовки.
+### 7.1. Базовые проверки целостности
-| Смещение | Размер | Тип | Описание |
-|----------|--------|--------|---------------------------------------------------------|
-| 0 | 2 | uint16 | `batchFlags` — битовая маска для фильтрации |
-| 2 | 2 | uint16 | `materialIndex` — индекс материала |
-| 4 | 2 | uint16 | `unk4` — неподтверждённое поле |
-| 6 | 2 | uint16 | `unk6` — вероятный `nodeIndex` (привязка batch к кости) |
-| 8 | 2 | uint16 | `indexCount` — число индексов (кратно 3) |
-| 10 | 4 | uint32 | `indexStart` — стартовый индекс в Res6 (в элементах) |
-| 14 | 2 | uint16 | `unk14` — неподтверждённое поле |
-| 16 | 4 | uint32 | `baseVertex` — смещение вершинного индекса |
+- каждый 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 проверки
-```
-for i in 0 .. indexCount-1:
- raw_index = index_buffer[indexStart + i]
- vertex_index = raw_index + baseVertex
- position = res3[vertex_index]
- normal = decode_normal(res4[vertex_index])
- uv = decode_uv(res5[vertex_index])
-```
+- `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 режим
-**Примечание:** движок читает `indexStart` как `uint32` и умножает на 2 для получения байтового смещения в массиве `uint16`.
+Рекомендуется 2 режима reader:
+
+- `strict`: любое нарушение инвариантов -> ошибка;
+- `tolerant`: безопасно отбрасывать/игнорировать только локально повреждённые диапазоны (без OOB).
---
-## 1.12. Ресурс Res10 — String Table
+## 8. Правила writer/editor
+
+### 8.1. Обязательная политика для 1:1 editing
+
+- сохранять неизвестные поля (`Slot68.unk*`, `Batch20.unk*`, `Node.hdr0` и т.д.) без модификации, если нет осознанного пересчёта;
+- сохранять неизвестные resource types и их payload/атрибуты;
+- не полагаться на порядок ресурсов в контейнере: lookup в runtime идёт по type-id.
+
+### 8.2. Пересчёт атрибутов каталога
-Res10 — это **последовательность записей, индексируемых по `nodeIndex`** (см. `AniMesh.dll!sub_10012530`).
+При записи изменённых ресурсов:
-Формат одной записи:
+- `attr1` = count (или форматно-специфичное значение),
+- `attr2` — по формату/семантике ресурса,
+- `attr3` — stride/константа формата.
+
+Практические правила для core:
+
+- `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`.
+
+### 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 Res10Record {
- uint32_t len; // число символов без терминирующего '\0'
- char text[]; // если len > 0: хранится len+1 байт (включая '\0')
- // если len == 0: payload отсутствует
+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;
};
```
-Переход к следующей записи:
+Принцип: где семантика неполная, хранить raw и переизлучать байт-в-байт.
-```c
-next = cur + 4 + (len ? (len + 1) : 0);
-```
+---
-`sub_10012530` возвращает:
+## 10. Практика конвертации
+
+### 10.1. MSH -> OBJ/GLTF
-- `NULL`, если `len == 0`;
-- `record + 4`, если `len > 0` (указатель на C‑строку).
+- `Res3` напрямую в позиции;
+- `Res6 + Res13` в faces;
+- нормали/UV декодировать через коэффициенты `1/127`, `1/1024`;
+- при экспорте по LOD/group использовать `Res1` матрицу слотов, а не "все batch подряд" (если нужен runtime-эквивалент);
+- пометить ограничения: core не содержит классический weight-скиннинг.
-Это значение используется в `sub_1000A460` для проверки имени текущего узла (например, поиск подстроки `"central"` при обработке node‑флагов).
+### 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` часто соответствует статике.
---
-## 1.14. Опциональные vertex streams
+## 12. Открытые вопросы (не блокируют 1:1)
-### Res15 — Дополнительный vertex stream (stride 8)
+- точная доменная семантика `Node.hdr0` битов;
+- полные имена/назначения `Batch20.unk4/unk6/unk14`;
+- назначение `Slot68.unk30..unk40`;
+- полная семантика `Res15/Res16/Res18/Res20` payload beyond stride-level;
+- точная семантика 4-го байта в `PackedNormal4`.
-- **Stride:** 8 байт на вершину.
-- **Кандидаты:** `float2 uv1` (lightmap / second UV layer), 4 × `int16` (2 UV‑пары), либо иной формат.
-- Загружается условно — если ресурс 15 отсутствует, указатель равен `NULL`.
+Для runtime/reader/writer это не критично при условии byte-preserving policy.
-### Res16 — Tangent / Bitangent (stride 8, split 2×4)
+---
-- **Stride:** 8 байт на вершину (2 подпотока по 4 байта).
-- При загрузке движок создаёт **два перемежающихся (interleaved) подпотока**:
- - Stream A: `base + 0`, stride 8 — 4 байта (кандидат: packed tangent, `int8 × 4`)
- - Stream B: `base + 4`, stride 8 — 4 байта (кандидат: packed bitangent, `int8 × 4`)
-- Если ресурс 16 отсутствует, оба указателя обнуляются.
-- **Важно:** в оригинальном `sub_10015FD0` при отсутствии Res16 страйды `+0x50/+0x58` явным образом не обнуляются; это безопасно, потому что оба указателя равны `NULL` и код не должен обращаться к потокам без проверки указателя.
-- Декодирование предположительно аналогично нормалям: `component / 127.0` (как Res4), но требует подтверждения; при импорте — кламп в [-1..1].
+## 13. Чеклист реализации 1:1
-### Res18 — Vertex Color (stride 4)
+### 13.1. Engine runtime
-- **Stride:** 4 байта на вершину.
-- **Кандидаты:** `D3DCOLOR` (BGRA), packed параметры освещения, vertex AO.
-- Загружается условно (через проверку `niFindRes` на возврат `−1`).
+- реализован 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 во всех таблицах.
-### Res20 — Дополнительная таблица
+### 13.2. Reader/validator
-- Присутствует не всегда.
-- Из каталожной записи NRes считывается поле `attribute_1` (смещение `+4`) и сохраняется как метаданные.
-- **Кандидаты:** vertex remap, дополнительные данные для эффектов/деформаций.
+- строгая проверка stride/размеров/диапазонов;
+- OOB-защита всех индексных доступов;
+- поддержка both direct-model и nested `.msh` payload.
----
+### 13.3. Writer/editor
+
+- стабильный пересчёт `attr1/attr2/attr3`;
+- сохранение unknown fields и unknown resource types;
+- детерминированная сериализация NRes (8-byte align);
+- regression-проверка round-trip: `decode -> encode -> decode` без расхождений структуры/диапазонов.
diff --git a/docs/specs/terrain-map-loading.md b/docs/specs/terrain-map-loading.md
index 0fb6e1f..34f6249 100644
--- a/docs/specs/terrain-map-loading.md
+++ b/docs/specs/terrain-map-loading.md
@@ -1,32 +1,511 @@
# Terrain + map loading
-Документ описывает подсистему ландшафта и привязку terrain-данных к миру.
+Документ описывает полный runtime-пайплайн загрузки ландшафта и карты (`Terrain.dll` + `ArealMap.dll`) и требования к toolchain для 1:1 совместимости (чтение, конвертация, редактирование, обратная сборка).
+
+Источник реверса:
+
+- `tmp/disassembler1/Terrain.dll.c`
+- `tmp/disassembler1/ArealMap.dll.c`
+- `tmp/disassembler2/Terrain.dll.asm`
+- `tmp/disassembler2/ArealMap.dll.asm`
+
+Связанные спецификации:
+
+- [NRes / RsLi](nres.md)
+- [MSH core](msh-core.md)
+- [ArealMap](arealmap.md)
+
+---
+
+## 1. Назначение подсистем
+
+### 1.1. `Terrain.dll`
+
+Отвечает за:
+
+- загрузку и хранение terrain-геометрии из `*.msh` (NRes);
+- фильтрацию и выборку треугольников для коллизий/трассировки/рендера;
+- рендер terrain-примитивов и связанного shading;
+- использование микро-текстурного канала (chunk type 18).
+
+Характерные runtime-строки:
+
+- `CLandscape::CLandscape()`
+- `Unable to find microtexture mapping chunk`
+- `Rendering empty primitive!`
+- `Rendering empty primitive2!`
+
+### 1.2. `ArealMap.dll`
+
+Отвечает за:
+
+- загрузку геометрии ареалов из `*.map` (NRes, chunk type 12);
+- построение связей "ареал <-> соседи/подграфы";
+- grid-ускорение по ячейкам карты;
+- runtime-доступ к `ISystemArealMap` (интерфейс id `770`) и ареалам (id `771`).
+
+Характерные runtime-строки:
+
+- `SystemArealMap panic: Cannot load ArealMapGeometry`
+- `SystemArealMap panic: Cannot find chunk in resource`
+- `SystemArealMap panic: ArealMap Cells are empty`
+- `SystemArealMap panic: Incorrect ArealMap`
+
+---
+
+## 2. End-to-End загрузка уровня
+
+### 2.1. Имена файлов уровня
+
+В `CLandscape::CLandscape()` базовое имя уровня `levelBase` разворачивается в:
+
+- `levelBase + ".msh"`: terrain-геометрия;
+- `levelBase + ".map"`: геометрия ареалов/навигация;
+- `levelBase + "1.wea"` и `levelBase + "2.wea"`: weather/материалы.
+
+### 2.2. Порядок инициализации (высокоуровнево)
+
+1. Получение `3DRender` и `3DSound`.
+2. Загрузка `MatManager` (`*.wea`), `LightManager`, `CollManager`, `FxManager`.
+3. Создание `SystemArealMap` через `CreateSystemArealMap(..., "<level>.map", ...)`.
+4. Открытие terrain-библиотеки `niOpenResFile("<level>.msh")`.
+5. Загрузка terrain-chunk-ов (см. §3).
+6. Построение runtime-границ, grid-ускорителей и рабочих массивов.
+
+Критичные ошибки на любом шаге приводят к `ngiProcessError`/panic.
+
+---
+
+## 3. Формат terrain `*.msh` (NRes)
+
+### 3.1. Используемые chunk type в `Terrain.dll`
+
+Порядок загрузки в `CLandscape::CLandscape()`:
+
+| Порядок | Type | Обяз. | Использование (подтверждено кодом) |
+|---|---:|---|---|
+| 1 | 3 | да | поток позиций (`stride = 12`) |
+| 2 | 4 | да | поток packed normal (`stride = 4`) |
+| 3 | 5 | да | UV-поток (`stride = 4`) |
+| 4 | 18 | да | microtexture mapping (`stride = 4`) |
+| 5 | 14 | нет | опциональный доп. поток (`stride = 4`, отсутствует на части карт) |
+| 6 | 21 | да | таблица terrain-face (по 28 байт) |
+| 7 | 2 | да | header + slot-таблицы (используются диапазоны face) |
+| 8 | 1 | да | node/grid-таблица (stride 38) |
+| 9 | 11 | да | доп. индекс/ускоритель для запросов (cell->list) |
+
+Ключевые проверки:
+
+- отсутствие type `18` вызывает `Unable to find microtexture mapping chunk`;
+- отсутствие остальных обязательных чанков вызывает `Unable to open file`.
+
+### 3.2. Node/slot структура для terrain
+
+Terrain-код использует те же stride и адресацию, что и core-описание:
+
+- node-запись: `38` байт;
+- slot-запись: `68` байт;
+- доступ к первому slot-index: `node + 8`;
+- tri-диапазон в slot: `slot + 140` (offset 0 внутри slot), `slot + 142` (offset 2).
+
+Это согласуется с [MSH core](msh-core.md) для `Res1/Res2`:
+
+- `Res1`: `uint16[19]` на node;
+- `Res2`: header + slot table (`0x8C + N * 0x44`).
+
+### 3.3. Terrain face record (type 21, 28 bytes)
+
+Подтвержденные поля из runtime-декодирования face:
+
+```c
+struct TerrainFace28 {
+ uint32_t flags; // +0
+ uint8_t materialId; // +4 (читается как byte)
+ uint8_t auxByte; // +5
+ uint16_t unk06; // +6
+ uint16_t i0; // +8 (индекс вершины)
+ uint16_t i1; // +10
+ uint16_t i2; // +12
+ uint16_t n0; // +14 (сосед, 0xFFFF -> нет)
+ uint16_t n1; // +16
+ uint16_t n2; // +18
+ int16_t nx; // +20 packed normal component
+ int16_t ny; // +22
+ int16_t nz; // +24
+ uint8_t edgeClass; // +26 (три 2-бит значения)
+ uint8_t unk27; // +27
+};
+```
+
+`edgeClass` декодируется как:
+
+- `edge0 = byte26 & 0x3`
+- `edge1 = (byte26 >> 2) & 0x3`
+- `edge2 = (byte26 >> 4) & 0x3`
+
+### 3.4. Маски флагов face
+
+Во многих запросах применяется фильтр:
+
+```c
+(faceFlags & requiredMask) == requiredMask &&
+(faceFlags | ~forbiddenMask) == ~forbiddenMask
+```
+
+Эквивалентно: "все required-биты выставлены, forbidden-биты отсутствуют".
+
+Подтверждено активное использование битов:
+
+- `0x8` (особая обработка в трассировке)
+- `0x2000`
+- `0x20000`
+- `0x100000`
+- `0x200000`
+
+Кроме "полной" 32-бит маски, runtime использует компактные маски в API-запросах.
+
+Подтверждённый remap `full -> compactMain16` (функции `sub_10013FC0`, `sub_1004BA00`, `sub_1004BB40`):
+
+| Full bit | Compact bit |
+|---:|---:|
+| `0x00000001` | `0x0001` |
+| `0x00000008` | `0x0002` |
+| `0x00000010` | `0x0004` |
+| `0x00000020` | `0x0008` |
+| `0x00001000` | `0x0010` |
+| `0x00004000` | `0x0020` |
+| `0x00000002` | `0x0040` |
+| `0x00000400` | `0x0080` |
+| `0x00000800` | `0x0100` |
+| `0x00020000` | `0x0200` |
+| `0x00002000` | `0x0400` |
+| `0x00000200` | `0x0800` |
+| `0x00000004` | `0x1000` |
+| `0x00000040` | `0x2000` |
+| `0x00200000` | `0x8000` |
+
+Подтверждённый remap `full -> compactMaterial6` (функции `sub_10014090`, `sub_10015540`, `sub_1004BB40`):
+
+| Full bit | Compact bit |
+|---:|---:|
+| `0x00000100` | `0x01` |
+| `0x00008000` | `0x02` |
+| `0x00010000` | `0x04` |
+| `0x00040000` | `0x08` |
+| `0x00080000` | `0x10` |
+| `0x00000080` | `0x20` |
+
+Подтверждённый remap `compact -> full` (функция `sub_10015680`):
+
+- `a2[4]`/`a2[5]` (compactMain16 required/forbidden) + `a2[6]`/`a2[7]` (compactMaterial6 required/forbidden)
+- разворачиваются в `fullRequired/fullForbidden` в `this[4]/this[5]`.
+
+Для toolchain это означает:
+
+- если редактируется только бинарник `type 21`, достаточно сохранять `flags` как есть;
+- если реализуется API-совместимый runtime-слой, нужно поддерживать оба представления (`full` и `compact`) и точный remap выше.
+
+### 3.5. Grid-ускоритель terrain-запросов
+
+Runtime строит grid descriptor с параметрами:
+
+- origin (`baseX/baseY`);
+- масштабные коэффициенты (`invSizeX/invSizeY`);
+- размеры сетки (`cellsX`, `cellsY`).
+
+Дальше запросы:
+
+1. переводят world AABB в диапазон grid-ячеек (`floor(...)`);
+2. берут диапазон face через `Res1/Res2` (slot `triStart/triCount`);
+3. дополняют кандидаты из cell-списков (chunk type 11);
+4. применяют маски флагов;
+5. выполняют геометрию (plane/intersection/point-in-triangle).
+
+### 3.6. Cell-списки по ячейкам (`type 11` и runtime-массивы)
+
+В `CLandscape` после инициализации используются три параллельных массива по ячейкам (`cellsX * cellsY`):
+
+- `this+31588` (`sub_100164B0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `8`-байтовых элементов;
+- `this+31592` (`sub_100164E0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `4`-байтовых элементов;
+- `this+31596` (`sub_1001F880` ctor): массив записей по `12` байт для runtime-объектов/агентов (буфер `4`-байтовых идентификаторов/указателей).
+
+Общий header записи списка:
+
+```c
+struct CellListHdr {
+ void* ptr; // +0
+ int count; // +4
+ int capacity; // +8
+};
+```
+
+Подтвержденные element-layout:
+
+- `this+31588`: элемент `8` байт (`uint32_t id`, `uint32_t aux`), добавление через `sub_10012E20` пишет `aux = 0`;
+- `this+31592`: элемент `4` байта (`uint32_t id`);
+- `this+31596`: элемент `4` байта (runtime object handle/pointer id).
+
+Практический вывод для редактора:
+
+- `type 11` должен считаться источником cell-ускорителя;
+- неизвестные/дополнительные поля внутри списков должны сохраняться как есть;
+- нельзя "нормализовать" или переупорядочивать списки без полного пересчёта всех зависимых runtime-структур.
---
-## 4.1. Обзор
+## 4. Формат `*.map` (ArealMapGeometry, chunk type 12)
+
+### 4.1. Точка входа
+
+`CreateSystemArealMap(..., "<level>.map", ...)` вызывает `sub_1001E0D0`:
+
+1. `niOpenResFile("<level>.map")`;
+2. поиск chunk type `12`;
+3. чтение chunk-данных;
+4. разбор `ArealMapGeometry`.
+
+При ошибках выдаются panic-строки `SystemArealMap panic: ...`.
+
+### 4.2. Верхний уровень chunk 12
+
+Используются:
+
+- `entry.attr1` (из каталога NRes) как `areal_count`;
+- `entry[+0x0C]` как размер payload chunk для контроля полного разбора.
+
+Данные chunk:
+
+1. `areal_count` переменных записей ареалов;
+2. секция grid-ячеек (`cellsX/cellsY` + списки попаданий).
+
+### 4.3. Переменная запись ареала
+
+Полностью подтверждённые элементы layout:
+
+```c
+// record = начало записи ареала
+float anchor_x = *(float*)(record + 0);
+float anchor_y = *(float*)(record + 4);
+float anchor_z = *(float*)(record + 8);
+float reserved_12 = *(float*)(record + 12); // в retail-данных всегда 0
+float area_metric = *(float*)(record + 16); // предрасчитанная площадь ареала
+float normal_x = *(float*)(record + 20);
+float normal_y = *(float*)(record + 24);
+float normal_z = *(float*)(record + 28); // unit vector (|n| ~= 1)
+uint32_t logic_flag = *(uint32_t*)(record + 32); // активно используется в runtime
+uint32_t reserved_36 = *(uint32_t*)(record + 36); // в retail-данных всегда 0
+uint32_t class_id = *(uint32_t*)(record + 40); // runtime-class/type id ареала
+uint32_t reserved_44 = *(uint32_t*)(record + 44); // в retail-данных всегда 0
+uint32_t vertex_count = *(uint32_t*)(record + 48);
+uint32_t poly_count = *(uint32_t*)(record + 52);
+float* vertices = (float*)(record + 56); // float3[vertex_count]
+
+// сразу после vertices:
+// EdgeLink8[vertex_count + 3*poly_count]
+// где EdgeLink8 = { int32_t area_ref; int32_t edge_ref; }
+// первые vertex_count записей используются как per-edge соседство границы ареала.
+EdgeLink8* links = (EdgeLink8*)(record + 56 + 12 * vertex_count);
+
+uint8_t* p = (uint8_t*)(links + (vertex_count + 3 * poly_count));
+for (i=0; i<poly_count; i++) {
+ uint32_t n = *(uint32_t*)p;
+ p += 4 * (3*n + 1);
+}
+// p -> начало следующей записи ареала
+```
+
+То есть для toolchain:
+
+- поля `+0/+4/+8`, `+16`, `+20..+28`, `+32`, `+40`, `+48`, `+52` являются runtime-значимыми;
+- для `links[0..vertex_count-1]` подтверждена интерпретация как `(area_ref, edge_ref)`:
+ - `area_ref == -1 && edge_ref == -1` = нет соседа;
+ - иначе `area_ref` указывает на индекс ареала, `edge_ref` — на индекс ребра в целевом ареале;
+- при редактировании безопасно работать через parser+writer этой формулы;
+- неизвестные байты внутри записи должны сохраняться без изменений.
+
+Дополнительно по runtime-поведению:
+
+- `anchor_x/anchor_y` валидируются на попадание внутрь полигона; при промахе движок делает случайный re-seed позиции (см. §4.5);
+- `logic_flag` по смещению `+32` используется как gating-условие в логике `SystemArealMap`.
+
+### 4.4. Секция grid-ячеек в chunk 12
+
+После массива ареалов идёт:
+
+```c
+uint32_t cellsX;
+uint32_t cellsY;
+for (x in 0..cellsX-1) {
+ for (y in 0..cellsY-1) {
+ uint16_t hitCount;
+ uint16_t areaIds[hitCount];
+ }
+}
+```
+
+Runtime упаковывает метаданные ячейки в `uint32`:
+
+- high 10 bits: `hitCount` (`value >> 22`);
+- low 22 bits: `startIndex` (1-based индекс в общем `uint16`-пуле areaIds).
+
+Контроль целостности:
+
+- после разбора `ptr_end - chunk_begin` должен строго совпасть с `entry[+0x0C]`;
+- иначе `SystemArealMap panic: Incorrect ArealMap`.
+
+### 4.5. Нормализация геометрии при загрузке
+
+Если опорная точка ареала не попадает внутрь его полигона:
+
+- до 100 попыток случайного сдвига в радиусе ~30;
+- затем до 200 попыток в радиусе ~100.
+
+Это runtime-correction; для 1:1-офлайн инструментов лучше генерировать валидные данные, чтобы не зависеть от недетерминизма `rand()`.
+
+---
-`Terrain.dll` отвечает за рендер ландшафта (terrain), включая:
+## 5. `BuildDat.lst` и объектные категории ареалов
+
+`ArealMap.dll` инициализирует 12 категорий и читает `BuildDat.lst`.
+
+Хардкод-категории (имя -> mask):
+
+| Имя | Маска |
+|---|---:|
+| `Bunker_Small` | `0x80010000` |
+| `Bunker_Medium` | `0x80020000` |
+| `Bunker_Large` | `0x80040000` |
+| `Generator` | `0x80000002` |
+| `Mine` | `0x80000004` |
+| `Storage` | `0x80000008` |
+| `Plant` | `0x80000010` |
+| `Hangar` | `0x80000040` |
+| `MainTeleport` | `0x80000200` |
+| `Institute` | `0x80000400` |
+| `Tower_Medium` | `0x80100000` |
+| `Tower_Large` | `0x80200000` |
+
+Файл `BuildDat.lst` парсится секционно; при сбое формата используется panic `BuildDat.lst is corrupted`.
+
+---
+
+## 6. Требования к toolchain (конвертер/ридер/редактор)
+
+### 6.1. Общие принципы 1:1
+
+1. Никаких "переупорядочиваний по вкусу": сохранять порядок chunk-ов, если не требуется явная нормализация.
+2. Все неизвестные поля сохранять побайтно.
+3. При roundtrip обеспечивать byte-identical для неизмененных сущностей.
+4. Валидации должны повторять runtime-ожидания (размеры, count-формулы, обязательность chunk-ов).
+
+### 6.2. Для terrain `*.msh`
+
+Обязательные проверки:
+
+- наличие chunk types `1,2,3,4,5,11,18,21`;
+- type `14` опционален;
+- для `type 2`: `size >= 0x8C`, `(size - 0x8C) % 68 == 0`, `attr1 == (size - 0x8C) / 68`;
+- `type21_size % 28 == 0`;
+- индексы `i0/i1/i2` в `TerrainFace28` не выходят за `vertex_count` (type 3);
+- `slot.triStart + slot.triCount` не выходит за `face_count`.
+
+Сериализация:
+
+- `flags`, соседи, `edgeClass`, material байты в `TerrainFace28` сохранять как есть;
+- содержимое `type 11`-derived cell-списков (`id`, `aux`) сохранять без "починки";
+- для packed normal не делать "улучшений" нормализации, если цель 1:1.
+
+### 6.3. Для `*.map` (chunk 12)
+
+Обязательные проверки:
+
+- chunk type `12` существует;
+- `areal_count > 0`;
+- `cellsX > 0 && cellsY > 0`;
+- `|normal_x,normal_y,normal_z| ~= 1` для каждого ареала;
+- `links[0..vertex_count-1]` валидны (`-1/-1` или корректные `(area_ref, edge_ref)`);
+- полный consumed-bytes строго равен `entry[+0x0C]`.
+
+При редактировании:
+
+- перестраивать только то, что действительно изменено;
+- пересчитывать cell-списки и packed `cellMeta` синхронно;
+- сохранять неизвестные части записи ареала без изменений.
+
+### 6.4. Рекомендуемая архитектура редактора
+
+1. `Parser`:
+ - NRes-слой;
+ - `TerrainMsh`-слой;
+ - `ArealMapChunk12`-слой.
+2. `Model`:
+ - явные известные поля;
+ - `raw_unknown` для непросаженных блоков.
+3. `Writer`:
+ - стабильная сериализация;
+ - проверка контрольных инвариантов перед записью.
+4. `Verifier`:
+ - roundtrip hash/byte-compare;
+ - runtime-совместимые asserts.
+
+---
+
+## 7. Практический чеклист "движок 1:1"
+
+Для runtime-совместимого движка нужно реализовать:
+
+1. NRes API-уровень (`niOpenResFile`, `niOpenResInMem`, поиск chunk по type, получение data/attrs).
+2. `CLandscape` пайплайн загрузки `*.msh` + менеджеров + `CreateSystemArealMap`.
+3. Terrain face decode (28-byte запись), mask-фильтр, spatial grid queries.
+4. Загрузчик `ArealMapGeometry` (chunk 12) с той же валидацией и packed-cell логикой.
+5. Пост-обработку ареалов (пересвязка, корректировки опорных точек).
+6. Поддержку `BuildDat.lst` для объектных категорий/схем.
+
+---
+
+## 8. Нерасшифрованные зоны (важно для редакторов)
+
+Ниже поля, которые пока нельзя безопасно "пересобирать по смыслу":
+
+- семантика `class_id` (`record + 40`) на уровне геймдизайна/скриптов (числовое поле подтверждено, но человекочитаемая таблица соответствий не восстановлена полностью);
+- ветки формата для `poly_count > 0` (в retail `tmp/gamedata` это всегда `0`, поэтому поведение этих веток подтверждено только по коду, без живых образцов);
+- человекочитаемая семантика части битов `TerrainFace28.flags` (при этом remap и бинарные значения подтверждены);
+- семантика поля `aux` во `8`-байтовом элементе cell-списка (`this+31588`, второй `uint32_t`), которое в известных runtime-путях инициализируется нулем.
+
+Правило до полного реверса: `preserve-as-is`.
+
+---
-- Рендер мешей ландшафта (`"Rendered meshes"`, `"Rendered primitives"`, `"Rendered faces"`).
-- Рендер частиц (`"Rendered particles/batches"`).
-- Создание текстур (`"CTexture::CTexture()"` — конструктор текстуры).
-- Микротекстуры (`"Unable to find microtexture mapping"`).
+## 9. Эмпирическая верификация (retail `tmp/gamedata`)
-## 4.2. Текстуры ландшафта
+Для массовой проверки спецификации добавлен валидатор:
-В Terrain.dll присутствует конструктор текстуры `CTexture::CTexture()` со следующими проверками:
+- `tools/terrain_map_doc_validator.py`
-- Валидация размера текстуры (`"Unsupported texture size"`).
-- Создание D3D‑текстуры (`"Unable to create texture"`).
+Запуск:
-Ландшафт использует **микротекстуры** (micro‑texture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности.
+```bash
+python3 tools/terrain_map_doc_validator.py \
+ --maps-root tmp/gamedata/DATA/MAPS \
+ --report-json tmp/terrain_map_doc_validator.report.json
+```
-## 4.3. Защита от пустых примитивов
+Проверенные инварианты (на 33 картах, 2026-02-12):
-Terrain.dll содержит проверки:
+- `Land.msh`:
+ - порядок chunk-ов всегда `[1,2,3,4,5,18,14,11,21]`;
+ - `type11` первые dword всегда `[5767168, 4718593]`;
+ - `type21` индексы вершин/соседей валидны;
+ - `type2` slot-таблица валидна по формуле `0x8C + 68*N`.
+- `Land.map`:
+ - всегда один chunk `type 12`;
+ - `cellsX == cellsY == 128` на всех картах;
+ - `poly_count == 0` для всех `34662` записей ареалов в retail-наборе;
+ - `record+12`, `record+36`, `record+44` всегда `0`;
+ - `area_metric` (`record+16`) стабильно коррелирует с площадью XY-полигона (макс. абсолютное отклонение `51.39`, макс. относительное `14.73%`, `18` кейсов > `5%`);
+ - `normal` в `record+20..28` всегда unit (диапазон длины `0.9999998758..1.0000001194`);
+ - link-таблицы `EdgeLink8` проходят строгую валидацию ссылочной целостности.
-- `"Rendering empty primitive!"` — перед первым вызовом отрисовки.
-- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки.
+Сводный результат текущего набора данных:
-Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта).
+- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`.