diff options
| -rw-r--r-- | .gitea/workflows/docs-deploy.yml | 2 | ||||
| -rw-r--r-- | crates/nres/Cargo.toml | 2 | ||||
| -rw-r--r-- | docs/specs/msh-animation.md | 510 | ||||
| -rw-r--r-- | docs/specs/msh-core.md | 846 | ||||
| -rw-r--r-- | docs/specs/terrain-map-loading.md | 513 | ||||
| -rw-r--r-- | tools/fxid_abs100_audit.py | 262 | ||||
| -rw-r--r-- | tools/terrain_map_doc_validator.py | 809 | ||||
| -rw-r--r-- | tools/terrain_map_preview_renderer.py | 679 |
8 files changed, 3225 insertions, 398 deletions
diff --git a/.gitea/workflows/docs-deploy.yml b/.gitea/workflows/docs-deploy.yml index b4788e2..7656a88 100644 --- a/.gitea/workflows/docs-deploy.yml +++ b/.gitea/workflows/docs-deploy.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v6 with: - python-version: "3.12" + python-version: "3.14" - name: Install docs dependencies run: pip install -r requirements.txt diff --git a/crates/nres/Cargo.toml b/crates/nres/Cargo.toml index 25f3494..38b8822 100644 --- a/crates/nres/Cargo.toml +++ b/crates/nres/Cargo.toml @@ -7,4 +7,4 @@ edition = "2021" common = { path = "../common" } [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem"] } +windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } 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`. diff --git a/tools/fxid_abs100_audit.py b/tools/fxid_abs100_audit.py new file mode 100644 index 0000000..79f3b92 --- /dev/null +++ b/tools/fxid_abs100_audit.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +""" +Deterministic audit for FXID "absolute parity" checklist. + +What this script produces: +1) strict parsing stats across all FXID payloads in NRes archives, +2) opcode histogram and rare-branch counters (op6, op1 tail usage), +3) reference vectors for RNG core (sub_10002220 semantics). +""" + +from __future__ import annotations + +import argparse +import json +import struct +from collections import Counter +from pathlib import Path +from typing import Any + +import archive_roundtrip_validator as arv + +TYPE_FXID = 0x44495846 +FX_CMD_SIZE = {1: 224, 2: 148, 3: 200, 4: 204, 5: 112, 6: 4, 7: 208, 8: 248, 9: 208, 10: 208} + + +def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: + start = int(entry["data_offset"]) + end = start + int(entry["size"]) + return blob[start:end] + + +def _cstr32(raw: bytes) -> str: + return raw.split(b"\x00", 1)[0].decode("latin1", errors="replace") + + +def _rng_step_sub_10002220(state32: int) -> tuple[int, int]: + """ + sub_10002220 semantics in 32-bit packed state form: + lo = state[15:0], hi = state[31:16] + new_lo = hi ^ (lo << 1) + new_hi = (hi >> 1) ^ new_lo + return new_hi (u16), update state=(new_hi<<16)|new_lo + """ + lo = state32 & 0xFFFF + hi = (state32 >> 16) & 0xFFFF + new_lo = (hi ^ ((lo << 1) & 0xFFFF)) & 0xFFFF + new_hi = ((hi >> 1) ^ new_lo) & 0xFFFF + return ((new_hi << 16) | new_lo), new_hi + + +def _rng_vectors() -> dict[str, Any]: + seeds = [0x00000000, 0x00000001, 0x12345678, 0x89ABCDEF, 0xFFFFFFFF] + out: list[dict[str, Any]] = [] + for seed in seeds: + state = seed + outputs: list[int] = [] + states: list[int] = [] + for _ in range(16): + state, value = _rng_step_sub_10002220(state) + outputs.append(value) + states.append(state) + out.append( + { + "seed_hex": f"0x{seed:08X}", + "outputs_u16_hex": [f"0x{x:04X}" for x in outputs], + "states_u32_hex": [f"0x{x:08X}" for x in states], + } + ) + return {"generator": "sub_10002220", "vectors": out} + + +def run_audit(root: Path) -> dict[str, Any]: + counters: Counter[str] = Counter() + opcode_hist: Counter[int] = Counter() + issues: list[dict[str, Any]] = [] + op1_tail6_samples: list[dict[str, Any]] = [] + op1_optref_samples: list[dict[str, Any]] = [] + + for item in arv.scan_archives(root): + if item["type"] != "nres": + continue + archive_path = root / item["relative_path"] + counters["archives_total"] += 1 + data = archive_path.read_bytes() + try: + parsed = arv.parse_nres(data, source=str(archive_path)) + except Exception as exc: # pylint: disable=broad-except + issues.append( + { + "severity": "error", + "archive": str(archive_path), + "entry": None, + "message": f"cannot parse NRes: {exc}", + } + ) + continue + + for entry in parsed["entries"]: + if int(entry["type_id"]) != TYPE_FXID: + continue + counters["fxid_total"] += 1 + payload = _entry_payload(data, entry) + entry_name = str(entry["name"]) + + if len(payload) < 60: + issues.append( + { + "severity": "error", + "archive": str(archive_path), + "entry": entry_name, + "message": f"payload too small: {len(payload)}", + } + ) + continue + + cmd_count = struct.unpack_from("<I", payload, 0)[0] + ptr = 0x3C + ok = True + for idx in range(cmd_count): + if ptr + 4 > len(payload): + issues.append( + { + "severity": "error", + "archive": str(archive_path), + "entry": entry_name, + "message": f"command {idx}: missing header at offset={ptr}", + } + ) + ok = False + break + + word = struct.unpack_from("<I", payload, ptr)[0] + opcode = word & 0xFF + size = FX_CMD_SIZE.get(opcode) + if size is None: + issues.append( + { + "severity": "error", + "archive": str(archive_path), + "entry": entry_name, + "message": f"command {idx}: unknown opcode={opcode} at offset={ptr}", + } + ) + ok = False + break + + if ptr + size > len(payload): + issues.append( + { + "severity": "error", + "archive": str(archive_path), + "entry": entry_name, + "message": f"command {idx}: truncated end={ptr + size}, payload={len(payload)}", + } + ) + ok = False + break + + opcode_hist[opcode] += 1 + if opcode == 6: + counters["op6_commands"] += 1 + if opcode == 1: + tail6 = payload[ptr + 136 : ptr + 160] + if any(tail6): + counters["op1_tail6_nonzero"] += 1 + if len(op1_tail6_samples) < 16: + dwords = list(struct.unpack("<6I", tail6)) + op1_tail6_samples.append( + { + "archive": str(archive_path), + "entry": entry_name, + "cmd_index": idx, + "tail6_u32_hex": [f"0x{x:08X}" for x in dwords], + } + ) + + archive_s = _cstr32(payload[ptr + 160 : ptr + 192]) + name_s = _cstr32(payload[ptr + 192 : ptr + 224]) + if archive_s or name_s: + counters["op1_optref_nonempty"] += 1 + if len(op1_optref_samples) < 16: + op1_optref_samples.append( + { + "archive": str(archive_path), + "entry": entry_name, + "cmd_index": idx, + "opt_archive": archive_s, + "opt_name": name_s, + } + ) + + ptr += size + + if ok and ptr != len(payload): + issues.append( + { + "severity": "error", + "archive": str(archive_path), + "entry": entry_name, + "message": f"tail bytes after command stream: parsed_end={ptr}, payload={len(payload)}", + } + ) + ok = False + + if ok: + counters["fxid_ok"] += 1 + + return { + "input_root": str(root), + "summary": { + "archives_total": counters["archives_total"], + "fxid_total": counters["fxid_total"], + "fxid_ok": counters["fxid_ok"], + "issues_total": len(issues), + "op6_commands": counters["op6_commands"], + "op1_tail6_nonzero": counters["op1_tail6_nonzero"], + "op1_optref_nonempty": counters["op1_optref_nonempty"], + }, + "opcode_histogram": {str(k): opcode_hist[k] for k in sorted(opcode_hist)}, + "op1_tail6_samples": op1_tail6_samples, + "op1_optref_samples": op1_optref_samples, + "rng_reference": _rng_vectors(), + "rng_states_fx_path": [ + {"state": "dword_10023688", "seed_init": "sub_10002660", "used_by": ["sub_10001720", "sub_10001A40"]}, + {"state": "dword_100238C0", "seed_init": "sub_10003A50", "used_by": ["sub_10002BE0"]}, + {"state": "dword_10024110", "seed_init": "sub_10009180", "used_by": ["sub_10008120", "sub_10007D10"]}, + {"state": "dword_10024810", "seed_init": "sub_1000D370", "used_by": ["sub_1000BF30", "sub_1000C1A0"]}, + {"state": "dword_10024A48", "seed_init": "sub_1000F420", "used_by": ["sub_1000EC50"]}, + {"state": "dword_10024C80", "seed_init": "sub_10010370", "used_by": ["sub_1000F6E0"]}, + {"state": "dword_100250F0", "seed_init": "sub_10012C70", "used_by": ["sub_10011230", "sub_100115C0"]}, + ], + "issues": issues, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="FXID absolute parity audit.") + parser.add_argument("--input", required=True, help="Root directory with game/test archives.") + parser.add_argument("--report", required=True, help="Output JSON report path.") + args = parser.parse_args() + + root = Path(args.input).resolve() + report_path = Path(args.report).resolve() + payload = run_audit(root) + report_path.parent.mkdir(parents=True, exist_ok=True) + report_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + summary = payload["summary"] + print(f"Input root : {root}") + print(f"NRes archives : {summary['archives_total']}") + print(f"FXID payloads : {summary['fxid_ok']}/{summary['fxid_total']} valid") + print(f"Issues : {summary['issues_total']}") + print(f"Opcode6 commands : {summary['op6_commands']}") + print(f"Op1 tail6 nonzero : {summary['op1_tail6_nonzero']}") + print(f"Op1 optref non-empty : {summary['op1_optref_nonempty']}") + print(f"Report : {report_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/terrain_map_doc_validator.py b/tools/terrain_map_doc_validator.py new file mode 100644 index 0000000..63c3077 --- /dev/null +++ b/tools/terrain_map_doc_validator.py @@ -0,0 +1,809 @@ +#!/usr/bin/env python3 +""" +Validate terrain/map documentation assumptions against real game data. + +Targets: +- tmp/gamedata/DATA/MAPS/**/Land.msh +- tmp/gamedata/DATA/MAPS/**/Land.map +""" + +from __future__ import annotations + +import argparse +import json +import math +import struct +from collections import Counter, defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import archive_roundtrip_validator as arv + +MAGIC_NRES = b"NRes" + +REQUIRED_MSH_TYPES = (1, 2, 3, 4, 5, 11, 18, 21) +OPTIONAL_MSH_TYPES = (14,) +EXPECTED_MSH_ORDER = (1, 2, 3, 4, 5, 18, 14, 11, 21) + +MSH_STRIDES = { + 1: 38, + 3: 12, + 4: 4, + 5: 4, + 11: 4, + 14: 4, + 18: 4, + 21: 28, +} + +SLOT_TABLE_OFFSET = 0x8C + + +@dataclass +class ValidationIssue: + severity: str # error | warning + category: str + resource: str + message: str + + +class TerrainMapDocValidator: + def __init__(self) -> None: + self.issues: list[ValidationIssue] = [] + self.stats: dict[str, Any] = { + "maps_total": 0, + "msh_total": 0, + "map_total": 0, + "msh_type_orders": Counter(), + "msh_attr_triplets": defaultdict(Counter), # type_id -> Counter[(a1,a2,a3)] + "msh_type11_header_words": Counter(), + "msh_type21_flags_top": Counter(), + "map_logic_flags": Counter(), + "map_class_ids": Counter(), # record +40 + "map_poly_count": Counter(), + "map_vertex_count_min": None, + "map_vertex_count_max": None, + "map_cell_dims": Counter(), + "map_reserved_u12": Counter(), + "map_reserved_u36": Counter(), + "map_reserved_u44": Counter(), + "map_area_delta_abs_max": 0.0, + "map_area_delta_rel_max": 0.0, + "map_area_rel_gt_05_count": 0, + "map_normal_len_min": None, + "map_normal_len_max": None, + "map_records_total": 0, + } + + def add_issue(self, severity: str, category: str, resource: Path, message: str) -> None: + self.issues.append( + ValidationIssue( + severity=severity, + category=category, + resource=str(resource), + message=message, + ) + ) + + def _entry_payload(self, blob: bytes, entry: dict[str, Any]) -> bytes: + start = int(entry["data_offset"]) + end = start + int(entry["size"]) + return blob[start:end] + + def _entry_by_type(self, entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: + by_type: dict[int, list[dict[str, Any]]] = {} + for item in entries: + by_type.setdefault(int(item["type_id"]), []).append(item) + return by_type + + def _expect_single_type( + self, + *, + by_type: dict[int, list[dict[str, Any]]], + type_id: int, + label: str, + resource: Path, + required: bool, + ) -> dict[str, Any] | None: + rows = by_type.get(type_id, []) + if not rows: + if required: + self.add_issue( + "error", + "msh-chunk", + resource, + f"missing required chunk type={type_id} ({label})", + ) + return None + if len(rows) > 1: + self.add_issue( + "warning", + "msh-chunk", + resource, + f"multiple chunks type={type_id} ({label}); using first", + ) + return rows[0] + + def _check_stride( + self, + *, + resource: Path, + entry: dict[str, Any], + stride: int, + label: str, + ) -> int: + size = int(entry["size"]) + attr1 = int(entry["attr1"]) + attr2 = int(entry["attr2"]) + attr3 = int(entry["attr3"]) + self.stats["msh_attr_triplets"][int(entry["type_id"])][(attr1, attr2, attr3)] += 1 + + if size % stride != 0: + self.add_issue( + "error", + "msh-stride", + resource, + f"{label}: size={size} is not divisible by stride={stride}", + ) + return -1 + + count = size // stride + if attr1 != count: + self.add_issue( + "error", + "msh-attr", + resource, + f"{label}: attr1={attr1} != size/stride={count}", + ) + if attr3 != stride: + self.add_issue( + "error", + "msh-attr", + resource, + f"{label}: attr3={attr3} != {stride}", + ) + if attr2 != 0 and int(entry["type_id"]) not in (1,): + # type 1 has non-zero attr2 in real assets, others are expected zero. + self.add_issue( + "warning", + "msh-attr", + resource, + f"{label}: attr2={attr2} (expected 0 for this chunk type)", + ) + return count + + def validate_msh(self, path: Path) -> None: + self.stats["msh_total"] += 1 + blob = path.read_bytes() + if blob[:4] != MAGIC_NRES: + self.add_issue("error", "msh-container", path, "file is not NRes") + return + + try: + parsed = arv.parse_nres(blob, source=str(path)) + except Exception as exc: # pylint: disable=broad-except + self.add_issue("error", "msh-container", path, f"failed to parse NRes: {exc}") + return + + for issue in parsed.get("issues", []): + self.add_issue("warning", "msh-nres", path, issue) + + entries = parsed["entries"] + types_order = tuple(int(item["type_id"]) for item in entries) + self.stats["msh_type_orders"][types_order] += 1 + if types_order != EXPECTED_MSH_ORDER: + self.add_issue( + "warning", + "msh-order", + path, + f"unexpected chunk order {types_order}, expected {EXPECTED_MSH_ORDER}", + ) + + by_type = self._entry_by_type(entries) + + chunks: dict[int, dict[str, Any]] = {} + for type_id in REQUIRED_MSH_TYPES: + chunk = self._expect_single_type( + by_type=by_type, + type_id=type_id, + label=f"type{type_id}", + resource=path, + required=True, + ) + if chunk: + chunks[type_id] = chunk + for type_id in OPTIONAL_MSH_TYPES: + chunk = self._expect_single_type( + by_type=by_type, + type_id=type_id, + label=f"type{type_id}", + resource=path, + required=False, + ) + if chunk: + chunks[type_id] = chunk + + for type_id, stride in MSH_STRIDES.items(): + chunk = chunks.get(type_id) + if not chunk: + continue + self._check_stride(resource=path, entry=chunk, stride=stride, label=f"type{type_id}") + + # type 2 includes 0x8C-byte header + 68-byte slot table entries. + type2 = chunks.get(2) + if type2: + size = int(type2["size"]) + attr1 = int(type2["attr1"]) + attr2 = int(type2["attr2"]) + attr3 = int(type2["attr3"]) + self.stats["msh_attr_triplets"][2][(attr1, attr2, attr3)] += 1 + if attr3 != 68: + self.add_issue( + "error", + "msh-attr", + path, + f"type2: attr3={attr3} != 68", + ) + if attr2 != 0: + self.add_issue( + "warning", + "msh-attr", + path, + f"type2: attr2={attr2} (expected 0)", + ) + if size < SLOT_TABLE_OFFSET: + self.add_issue( + "error", + "msh-size", + path, + f"type2: size={size} < header_size={SLOT_TABLE_OFFSET}", + ) + elif (size - SLOT_TABLE_OFFSET) % 68 != 0: + self.add_issue( + "error", + "msh-size", + path, + f"type2: (size - 0x8C) is not divisible by 68 (size={size})", + ) + else: + slots_by_size = (size - SLOT_TABLE_OFFSET) // 68 + if attr1 != slots_by_size: + self.add_issue( + "error", + "msh-attr", + path, + f"type2: attr1={attr1} != (size-0x8C)/68={slots_by_size}", + ) + + verts = chunks.get(3) + face = chunks.get(21) + slots = chunks.get(2) + nodes = chunks.get(1) + type11 = chunks.get(11) + + if verts and face: + vcount = int(verts["attr1"]) + face_payload = self._entry_payload(blob, face) + fcount = int(face["attr1"]) + if len(face_payload) >= 28: + for idx in range(fcount): + off = idx * 28 + if off + 28 > len(face_payload): + self.add_issue( + "error", + "msh-face", + path, + f"type21 truncated at face {idx}", + ) + break + flags = struct.unpack_from("<I", face_payload, off)[0] + self.stats["msh_type21_flags_top"][flags] += 1 + i0, i1, i2 = struct.unpack_from("<HHH", face_payload, off + 8) + for name, value in (("i0", i0), ("i1", i1), ("i2", i2)): + if value >= vcount: + self.add_issue( + "error", + "msh-face-index", + path, + f"type21[{idx}].{name}={value} out of range vertex_count={vcount}", + ) + n0, n1, n2 = struct.unpack_from("<HHH", face_payload, off + 14) + for name, value in (("n0", n0), ("n1", n1), ("n2", n2)): + if value != 0xFFFF and value >= fcount: + self.add_issue( + "error", + "msh-face-neighbour", + path, + f"type21[{idx}].{name}={value} out of range face_count={fcount}", + ) + + if slots and face: + slot_count = int(slots["attr1"]) + face_count = int(face["attr1"]) + slot_payload = self._entry_payload(blob, slots) + need = SLOT_TABLE_OFFSET + slot_count * 68 + if len(slot_payload) < need: + self.add_issue( + "error", + "msh-slot", + path, + f"type2 payload too short: size={len(slot_payload)}, need_at_least={need}", + ) + else: + if len(slot_payload) != need: + self.add_issue( + "warning", + "msh-slot", + path, + f"type2 payload has trailing bytes: size={len(slot_payload)}, expected={need}", + ) + for idx in range(slot_count): + off = SLOT_TABLE_OFFSET + idx * 68 + tri_start, tri_count = struct.unpack_from("<HH", slot_payload, off) + if tri_start + tri_count > face_count: + self.add_issue( + "error", + "msh-slot-range", + path, + f"type2 slot[{idx}] range [{tri_start}, {tri_start + tri_count}) exceeds face_count={face_count}", + ) + + if nodes and slots: + node_payload = self._entry_payload(blob, nodes) + slot_count = int(slots["attr1"]) + node_count = int(nodes["attr1"]) + for node_idx in range(node_count): + off = node_idx * 38 + if off + 38 > len(node_payload): + self.add_issue( + "error", + "msh-node", + path, + f"type1 truncated at node {node_idx}", + ) + break + for j in range(19): + slot_id = struct.unpack_from("<H", node_payload, off + j * 2)[0] + if slot_id != 0xFFFF and slot_id >= slot_count: + self.add_issue( + "error", + "msh-node-slot", + path, + f"type1 node[{node_idx}] slot[{j}]={slot_id} out of range slot_count={slot_count}", + ) + + if type11: + payload = self._entry_payload(blob, type11) + if len(payload) >= 8: + w0, w1 = struct.unpack_from("<II", payload, 0) + self.stats["msh_type11_header_words"][(w0, w1)] += 1 + else: + self.add_issue( + "error", + "msh-type11", + path, + f"type11 payload too short: {len(payload)}", + ) + + def _update_minmax(self, key_min: str, key_max: str, value: float) -> None: + if self.stats[key_min] is None or value < self.stats[key_min]: + self.stats[key_min] = value + if self.stats[key_max] is None or value > self.stats[key_max]: + self.stats[key_max] = value + + def validate_map(self, path: Path) -> None: + self.stats["map_total"] += 1 + blob = path.read_bytes() + if blob[:4] != MAGIC_NRES: + self.add_issue("error", "map-container", path, "file is not NRes") + return + + try: + parsed = arv.parse_nres(blob, source=str(path)) + except Exception as exc: # pylint: disable=broad-except + self.add_issue("error", "map-container", path, f"failed to parse NRes: {exc}") + return + + for issue in parsed.get("issues", []): + self.add_issue("warning", "map-nres", path, issue) + + entries = parsed["entries"] + if len(entries) != 1 or int(entries[0]["type_id"]) != 12: + self.add_issue( + "error", + "map-chunk", + path, + f"expected single chunk type=12, got {[int(e['type_id']) for e in entries]}", + ) + return + + entry = entries[0] + areal_count = int(entry["attr1"]) + if areal_count <= 0: + self.add_issue("error", "map-areal", path, f"invalid areal_count={areal_count}") + return + + payload = self._entry_payload(blob, entry) + ptr = 0 + records: list[dict[str, Any]] = [] + + for idx in range(areal_count): + if ptr + 56 > len(payload): + self.add_issue( + "error", + "map-record", + path, + f"truncated areal header at index={idx}, ptr={ptr}, size={len(payload)}", + ) + return + + anchor_x, anchor_y, anchor_z = struct.unpack_from("<fff", payload, ptr) + u12 = struct.unpack_from("<I", payload, ptr + 12)[0] + area_f = struct.unpack_from("<f", payload, ptr + 16)[0] + nx, ny, nz = struct.unpack_from("<fff", payload, ptr + 20) + logic_flag = struct.unpack_from("<I", payload, ptr + 32)[0] + u36 = struct.unpack_from("<I", payload, ptr + 36)[0] + class_id = struct.unpack_from("<I", payload, ptr + 40)[0] + u44 = struct.unpack_from("<I", payload, ptr + 44)[0] + vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48) + + self.stats["map_records_total"] += 1 + self.stats["map_logic_flags"][logic_flag] += 1 + self.stats["map_class_ids"][class_id] += 1 + self.stats["map_poly_count"][poly_count] += 1 + self.stats["map_reserved_u12"][u12] += 1 + self.stats["map_reserved_u36"][u36] += 1 + self.stats["map_reserved_u44"][u44] += 1 + self._update_minmax("map_vertex_count_min", "map_vertex_count_max", float(vertex_count)) + + normal_len = math.sqrt(nx * nx + ny * ny + nz * nz) + self._update_minmax("map_normal_len_min", "map_normal_len_max", normal_len) + if abs(normal_len - 1.0) > 1e-3: + self.add_issue( + "warning", + "map-normal", + path, + f"record[{idx}] normal length={normal_len:.6f} (expected ~1.0)", + ) + + vertices_off = ptr + 56 + vertices_size = 12 * vertex_count + if vertices_off + vertices_size > len(payload): + self.add_issue( + "error", + "map-vertices", + path, + f"record[{idx}] vertices out of bounds", + ) + return + + vertices: list[tuple[float, float, float]] = [] + for i in range(vertex_count): + vertices.append(struct.unpack_from("<fff", payload, vertices_off + i * 12)) + + if vertex_count >= 3: + # signed shoelace area in XY. + shoelace = 0.0 + for i in range(vertex_count): + x1, y1, _ = vertices[i] + x2, y2, _ = vertices[(i + 1) % vertex_count] + shoelace += x1 * y2 - x2 * y1 + area_xy = abs(shoelace) * 0.5 + delta = abs(area_xy - area_f) + if delta > self.stats["map_area_delta_abs_max"]: + self.stats["map_area_delta_abs_max"] = delta + rel_delta = delta / max(1.0, area_xy) + if rel_delta > self.stats["map_area_delta_rel_max"]: + self.stats["map_area_delta_rel_max"] = rel_delta + if rel_delta > 0.05: + self.stats["map_area_rel_gt_05_count"] += 1 + + links_off = vertices_off + vertices_size + link_count = vertex_count + 3 * poly_count + links_size = 8 * link_count + if links_off + links_size > len(payload): + self.add_issue( + "error", + "map-links", + path, + f"record[{idx}] link table out of bounds", + ) + return + + edge_links: list[tuple[int, int]] = [] + for i in range(vertex_count): + area_ref, edge_ref = struct.unpack_from("<ii", payload, links_off + i * 8) + edge_links.append((area_ref, edge_ref)) + + poly_links_off = links_off + 8 * vertex_count + poly_links: list[tuple[int, int]] = [] + for i in range(3 * poly_count): + area_ref, edge_ref = struct.unpack_from("<ii", payload, poly_links_off + i * 8) + poly_links.append((area_ref, edge_ref)) + + p = links_off + links_size + for poly_idx in range(poly_count): + if p + 4 > len(payload): + self.add_issue( + "error", + "map-poly", + path, + f"record[{idx}] poly header truncated at poly_idx={poly_idx}", + ) + return + n = struct.unpack_from("<I", payload, p)[0] + poly_size = 4 * (3 * n + 1) + if p + poly_size > len(payload): + self.add_issue( + "error", + "map-poly", + path, + f"record[{idx}] poly data out of bounds at poly_idx={poly_idx}", + ) + return + p += poly_size + + records.append( + { + "index": idx, + "anchor": (anchor_x, anchor_y, anchor_z), + "logic": logic_flag, + "class_id": class_id, + "vertex_count": vertex_count, + "poly_count": poly_count, + "edge_links": edge_links, + "poly_links": poly_links, + } + ) + ptr = p + + vertex_counts = [int(item["vertex_count"]) for item in records] + for rec in records: + idx = int(rec["index"]) + for link_idx, (area_ref, edge_ref) in enumerate(rec["edge_links"]): + if area_ref == -1: + if edge_ref != -1: + self.add_issue( + "warning", + "map-link", + path, + f"record[{idx}] edge_link[{link_idx}] has area_ref=-1 but edge_ref={edge_ref}", + ) + continue + if area_ref < 0 or area_ref >= areal_count: + self.add_issue( + "error", + "map-link", + path, + f"record[{idx}] edge_link[{link_idx}] area_ref={area_ref} out of range", + ) + continue + dst_vcount = vertex_counts[area_ref] + if edge_ref < 0 or edge_ref >= dst_vcount: + self.add_issue( + "error", + "map-link", + path, + f"record[{idx}] edge_link[{link_idx}] edge_ref={edge_ref} out of range dst_vertex_count={dst_vcount}", + ) + + for link_idx, (area_ref, edge_ref) in enumerate(rec["poly_links"]): + if area_ref == -1: + if edge_ref != -1: + self.add_issue( + "warning", + "map-poly-link", + path, + f"record[{idx}] poly_link[{link_idx}] has area_ref=-1 but edge_ref={edge_ref}", + ) + continue + if area_ref < 0 or area_ref >= areal_count: + self.add_issue( + "error", + "map-poly-link", + path, + f"record[{idx}] poly_link[{link_idx}] area_ref={area_ref} out of range", + ) + + if ptr + 8 > len(payload): + self.add_issue( + "error", + "map-cells", + path, + f"missing cells header at ptr={ptr}, size={len(payload)}", + ) + return + + cells_x, cells_y = struct.unpack_from("<II", payload, ptr) + self.stats["map_cell_dims"][(cells_x, cells_y)] += 1 + ptr += 8 + if cells_x <= 0 or cells_y <= 0: + self.add_issue( + "error", + "map-cells", + path, + f"invalid cells dimensions {cells_x}x{cells_y}", + ) + return + + for x in range(cells_x): + for y in range(cells_y): + if ptr + 2 > len(payload): + self.add_issue( + "error", + "map-cells", + path, + f"truncated hitCount at cell ({x},{y})", + ) + return + hit_count = struct.unpack_from("<H", payload, ptr)[0] + ptr += 2 + need = 2 * hit_count + if ptr + need > len(payload): + self.add_issue( + "error", + "map-cells", + path, + f"truncated areaIds at cell ({x},{y}), hitCount={hit_count}", + ) + return + for i in range(hit_count): + area_id = struct.unpack_from("<H", payload, ptr + 2 * i)[0] + if area_id >= areal_count: + self.add_issue( + "error", + "map-cells", + path, + f"cell ({x},{y}) has area_id={area_id} out of range areal_count={areal_count}", + ) + ptr += need + + if ptr != len(payload): + self.add_issue( + "error", + "map-size", + path, + f"payload tail mismatch: consumed={ptr}, payload_size={len(payload)}", + ) + + def validate(self, maps_root: Path) -> None: + msh_paths = sorted(maps_root.rglob("Land.msh")) + map_paths = sorted(maps_root.rglob("Land.map")) + + msh_by_dir = {path.parent: path for path in msh_paths} + map_by_dir = {path.parent: path for path in map_paths} + + all_dirs = sorted(set(msh_by_dir) | set(map_by_dir)) + self.stats["maps_total"] = len(all_dirs) + + for folder in all_dirs: + msh_path = msh_by_dir.get(folder) + map_path = map_by_dir.get(folder) + if msh_path is None: + self.add_issue("error", "pairing", folder, "missing Land.msh") + continue + if map_path is None: + self.add_issue("error", "pairing", folder, "missing Land.map") + continue + self.validate_msh(msh_path) + self.validate_map(map_path) + + def build_report(self) -> dict[str, Any]: + errors = [i for i in self.issues if i.severity == "error"] + warnings = [i for i in self.issues if i.severity == "warning"] + + # Convert counters/defaultdicts to JSON-friendly dicts. + msh_orders = { + str(list(order)): count + for order, count in self.stats["msh_type_orders"].most_common() + } + msh_attrs = { + str(type_id): {str(list(k)): v for k, v in counter.most_common()} + for type_id, counter in self.stats["msh_attr_triplets"].items() + } + type11_hdr = { + str(list(key)): value + for key, value in self.stats["msh_type11_header_words"].most_common() + } + type21_flags = { + f"0x{key:08X}": value + for key, value in self.stats["msh_type21_flags_top"].most_common(32) + } + + return { + "summary": { + "maps_total": self.stats["maps_total"], + "msh_total": self.stats["msh_total"], + "map_total": self.stats["map_total"], + "issues_total": len(self.issues), + "errors_total": len(errors), + "warnings_total": len(warnings), + }, + "stats": { + "msh_type_orders": msh_orders, + "msh_attr_triplets": msh_attrs, + "msh_type11_header_words": type11_hdr, + "msh_type21_flags_top": type21_flags, + "map_logic_flags": dict(self.stats["map_logic_flags"]), + "map_class_ids": dict(self.stats["map_class_ids"]), + "map_poly_count": dict(self.stats["map_poly_count"]), + "map_vertex_count_min": self.stats["map_vertex_count_min"], + "map_vertex_count_max": self.stats["map_vertex_count_max"], + "map_cell_dims": {str(list(k)): v for k, v in self.stats["map_cell_dims"].items()}, + "map_reserved_u12": dict(self.stats["map_reserved_u12"]), + "map_reserved_u36": dict(self.stats["map_reserved_u36"]), + "map_reserved_u44": dict(self.stats["map_reserved_u44"]), + "map_area_delta_abs_max": self.stats["map_area_delta_abs_max"], + "map_area_delta_rel_max": self.stats["map_area_delta_rel_max"], + "map_area_rel_gt_05_count": self.stats["map_area_rel_gt_05_count"], + "map_normal_len_min": self.stats["map_normal_len_min"], + "map_normal_len_max": self.stats["map_normal_len_max"], + "map_records_total": self.stats["map_records_total"], + }, + "issues": [ + { + "severity": item.severity, + "category": item.category, + "resource": item.resource, + "message": item.message, + } + for item in self.issues + ], + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate terrain/map doc assumptions") + parser.add_argument( + "--maps-root", + type=Path, + default=Path("tmp/gamedata/DATA/MAPS"), + help="Root directory containing MAPS/**/Land.msh and Land.map", + ) + parser.add_argument( + "--report-json", + type=Path, + default=None, + help="Optional path to save full JSON report", + ) + parser.add_argument( + "--fail-on-warning", + action="store_true", + help="Return non-zero exit code on warnings too", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + validator = TerrainMapDocValidator() + validator.validate(args.maps_root) + report = validator.build_report() + + print( + json.dumps( + report["summary"], + indent=2, + ensure_ascii=False, + ) + ) + + if args.report_json: + args.report_json.parent.mkdir(parents=True, exist_ok=True) + with args.report_json.open("w", encoding="utf-8") as handle: + json.dump(report, handle, indent=2, ensure_ascii=False) + handle.write("\n") + print(f"report written: {args.report_json}") + + has_errors = report["summary"]["errors_total"] > 0 + has_warnings = report["summary"]["warnings_total"] > 0 + if has_errors: + return 1 + if args.fail_on_warning and has_warnings: + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/terrain_map_preview_renderer.py b/tools/terrain_map_preview_renderer.py new file mode 100644 index 0000000..86d72d7 --- /dev/null +++ b/tools/terrain_map_preview_renderer.py @@ -0,0 +1,679 @@ +#!/usr/bin/env python3 +""" +Software 3D renderer for terrain Land.msh + Land.map overlay. + +Output format: binary PPM (P6), dependency-free. +""" + +from __future__ import annotations + +import argparse +import math +import struct +from pathlib import Path +from typing import Any + +import archive_roundtrip_validator as arv + +MAGIC_NRES = b"NRes" + + +def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes: + start = int(entry["data_offset"]) + end = start + int(entry["size"]) + return blob[start:end] + + +def _parse_nres(blob: bytes, source: str) -> dict[str, Any]: + if blob[:4] != MAGIC_NRES: + raise RuntimeError(f"{source}: not an NRes payload") + return arv.parse_nres(blob, source=source) + + +def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]: + out: dict[int, list[dict[str, Any]]] = {} + for row in entries: + out.setdefault(int(row["type_id"]), []).append(row) + return out + + +def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]: + rows = by_type.get(type_id, []) + if not rows: + raise RuntimeError(f"missing resource type {type_id} ({label})") + return rows[0] + + +def _downsample_faces( + faces: list[tuple[int, int, int]], + max_faces: int, +) -> list[tuple[int, int, int]]: + if max_faces <= 0 or len(faces) <= max_faces: + return faces + step = len(faces) / max_faces + out: list[tuple[int, int, int]] = [] + pos = 0.0 + while len(out) < max_faces and int(pos) < len(faces): + out.append(faces[int(pos)]) + pos += step + return out + + +def load_terrain_msh( + path: Path, + *, + max_faces: int, +) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]: + blob = path.read_bytes() + parsed = _parse_nres(blob, str(path)) + by_type = _by_type(parsed["entries"]) + + res3 = _get_single(by_type, 3, "positions") + res21 = _get_single(by_type, 21, "terrain faces") + + pos_blob = _entry_payload(blob, res3) + if len(pos_blob) % 12 != 0: + raise RuntimeError(f"{path}: type 3 payload size is not divisible by 12") + vertex_count = len(pos_blob) // 12 + positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)] + + face_blob = _entry_payload(blob, res21) + if len(face_blob) % 28 != 0: + raise RuntimeError(f"{path}: type 21 payload size is not divisible by 28") + all_faces: list[tuple[int, int, int]] = [] + raw_face_count = len(face_blob) // 28 + dropped = 0 + for i in range(raw_face_count): + off = i * 28 + i0, i1, i2 = struct.unpack_from("<HHH", face_blob, off + 8) + if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count: + dropped += 1 + continue + all_faces.append((i0, i1, i2)) + + faces = _downsample_faces(all_faces, max_faces) + meta = { + "vertex_count": vertex_count, + "face_count_raw": raw_face_count, + "face_count_valid": len(all_faces), + "face_count_rendered": len(faces), + "face_dropped_invalid": dropped, + } + return positions, faces, meta + + +def load_areal_map(path: Path) -> tuple[list[dict[str, Any]], dict[str, int]]: + blob = path.read_bytes() + parsed = _parse_nres(blob, str(path)) + by_type = _by_type(parsed["entries"]) + chunk = _get_single(by_type, 12, "ArealMapGeometry") + + payload = _entry_payload(blob, chunk) + areal_count = int(chunk["attr1"]) + ptr = 0 + areals: list[dict[str, Any]] = [] + for idx in range(areal_count): + if ptr + 56 > len(payload): + raise RuntimeError(f"{path}: truncated areal header at index={idx}") + class_id = struct.unpack_from("<I", payload, ptr + 40)[0] + vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48) + verts_off = ptr + 56 + verts_size = 12 * vertex_count + if verts_off + verts_size > len(payload): + raise RuntimeError(f"{path}: areal[{idx}] vertices out of bounds") + verts = [struct.unpack_from("<3f", payload, verts_off + 12 * i) for i in range(vertex_count)] + + links_off = verts_off + verts_size + links_size = 8 * (vertex_count + 3 * poly_count) + p = links_off + links_size + for _ in range(poly_count): + if p + 4 > len(payload): + raise RuntimeError(f"{path}: areal[{idx}] poly header out of bounds") + n = struct.unpack_from("<I", payload, p)[0] + p += 4 * (3 * n + 1) + if p > len(payload): + raise RuntimeError(f"{path}: areal[{idx}] poly data out of bounds") + + areals.append( + { + "index": idx, + "class_id": class_id, + "vertices": verts, + } + ) + ptr = p + + if ptr + 8 > len(payload): + raise RuntimeError(f"{path}: missing cells section") + cells_x, cells_y = struct.unpack_from("<II", payload, ptr) + ptr += 8 + for _x in range(cells_x): + for _y in range(cells_y): + if ptr + 2 > len(payload): + raise RuntimeError(f"{path}: cells section truncated") + hit_count = struct.unpack_from("<H", payload, ptr)[0] + ptr += 2 + 2 * hit_count + if ptr > len(payload): + raise RuntimeError(f"{path}: cells section out of bounds") + if ptr != len(payload): + raise RuntimeError(f"{path}: trailing bytes in chunk12 parse ({len(payload) - ptr})") + + meta = { + "areal_count": areal_count, + "cells_x": cells_x, + "cells_y": cells_y, + } + return areals, meta + + +def _color_for_class(class_id: int) -> tuple[int, int, int]: + x = (class_id * 1103515245 + 12345) & 0x7FFFFFFF + r = 60 + (x & 0x7F) + g = 60 + ((x >> 7) & 0x7F) + b = 60 + ((x >> 14) & 0x7F) + return r, g, b + + +def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as handle: + handle.write(f"P6\n{width} {height}\n255\n".encode("ascii")) + handle.write(rgb) + + +def _write_obj( + path: Path, + terrain_positions: list[tuple[float, float, float]], + terrain_faces: list[tuple[int, int, int]], + areals: list[dict[str, Any]], + *, + include_areals: bool, +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as out: + out.write("# Exported by terrain_map_preview_renderer.py\n") + out.write("o terrain\n") + for x, y, z in terrain_positions: + out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n") + for i0, i1, i2 in terrain_faces: + # OBJ indices are 1-based. + out.write(f"f {i0 + 1} {i1 + 1} {i2 + 1}\n") + + if include_areals and areals: + base = len(terrain_positions) + area_vertex_counts: list[int] = [] + out.write("o areal_edges\n") + for area in areals: + verts = area["vertices"] + area_vertex_counts.append(len(verts)) + for x, y, z in verts: + out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n") + + ptr = base + for area_idx, area in enumerate(areals): + cnt = area_vertex_counts[area_idx] + if cnt < 2: + ptr += cnt + continue + # closed polyline. + line = [str(ptr + i + 1) for i in range(cnt)] + line.append(str(ptr + 1)) + out.write("l " + " ".join(line) + "\n") + ptr += cnt + + +def _render_scene( + terrain_positions: list[tuple[float, float, float]], + terrain_faces: list[tuple[int, int, int]], + areals: list[dict[str, Any]], + *, + width: int, + height: int, + yaw_deg: float, + pitch_deg: float, + wireframe: bool, + areal_overlay: bool, +) -> bytearray: + all_positions = list(terrain_positions) + if areal_overlay: + for area in areals: + all_positions.extend(area["vertices"]) + if not all_positions: + raise RuntimeError("scene is empty") + + xs = [p[0] for p in all_positions] + ys = [p[1] for p in all_positions] + zs = [p[2] for p in all_positions] + cx = (min(xs) + max(xs)) * 0.5 + cy = (min(ys) + max(ys)) * 0.5 + cz = (min(zs) + max(zs)) * 0.5 + span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs)) + radius = max(span * 0.5, 1e-3) + + yaw = math.radians(yaw_deg) + pitch = math.radians(pitch_deg) + cyaw = math.cos(yaw) + syaw = math.sin(yaw) + cpitch = math.cos(pitch) + spitch = math.sin(pitch) + camera_dist = radius * 3.2 + scale = min(width, height) * 0.96 + + # Terrain transform cache. + vx: list[float] = [] + vy: list[float] = [] + vz: list[float] = [] + sx: list[float] = [] + sy: list[float] = [] + for x, y, z in terrain_positions: + x0 = x - cx + y0 = y - cy + z0 = z - cz + x1 = cyaw * x0 + syaw * z0 + z1 = -syaw * x0 + cyaw * z0 + y2 = cpitch * y0 - spitch * z1 + z2 = spitch * y0 + cpitch * z1 + camera_dist + if z2 < 1e-3: + z2 = 1e-3 + vx.append(x1) + vy.append(y2) + vz.append(z2) + sx.append(width * 0.5 + (x1 / z2) * scale) + sy.append(height * 0.5 - (y2 / z2) * scale) + + def project_point(x: float, y: float, z: float) -> tuple[float, float, float]: + x0 = x - cx + y0 = y - cy + z0 = z - cz + x1 = cyaw * x0 + syaw * z0 + z1 = -syaw * x0 + cyaw * z0 + y2 = cpitch * y0 - spitch * z1 + z2 = spitch * y0 + cpitch * z1 + camera_dist + if z2 < 1e-3: + z2 = 1e-3 + px = width * 0.5 + (x1 / z2) * scale + py = height * 0.5 - (y2 / z2) * scale + return px, py, z2 + + rgb = bytearray([14, 16, 20] * (width * height)) + zbuf = [float("inf")] * (width * height) + light_dir = (0.35, 0.45, 1.0) + l_len = math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2) + light = (light_dir[0] / l_len, light_dir[1] / l_len, light_dir[2] / l_len) + + def edge(ax: float, ay: float, bx: float, by: float, px: float, py: float) -> float: + return (px - ax) * (by - ay) - (py - ay) * (bx - ax) + + for i0, i1, i2 in terrain_faces: + x0 = sx[i0] + y0 = sy[i0] + x1 = sx[i1] + y1 = sy[i1] + x2 = sx[i2] + y2 = sy[i2] + area = edge(x0, y0, x1, y1, x2, y2) + if area == 0.0: + continue + + ux = vx[i1] - vx[i0] + uy = vy[i1] - vy[i0] + uz = vz[i1] - vz[i0] + wx = vx[i2] - vx[i0] + wy = vy[i2] - vy[i0] + wz = vz[i2] - vz[i0] + nx = uy * wz - uz * wy + ny = uz * wx - ux * wz + nz = ux * wy - uy * wx + n_len = math.sqrt(nx * nx + ny * ny + nz * nz) + if n_len > 0.0: + nx /= n_len + ny /= n_len + nz /= n_len + intensity = nx * light[0] + ny * light[1] + nz * light[2] + if intensity < 0.0: + intensity = 0.0 + shade = int(45 + 185 * intensity) + color = (min(255, shade + 6), min(255, shade + 14), min(255, shade + 28)) + + minx = int(max(0, math.floor(min(x0, x1, x2)))) + maxx = int(min(width - 1, math.ceil(max(x0, x1, x2)))) + miny = int(max(0, math.floor(min(y0, y1, y2)))) + maxy = int(min(height - 1, math.ceil(max(y0, y1, y2)))) + if minx > maxx or miny > maxy: + continue + + z0 = vz[i0] + z1 = vz[i1] + z2 = vz[i2] + inv_area = 1.0 / area + for py in range(miny, maxy + 1): + fy = py + 0.5 + row = py * width + for px in range(minx, maxx + 1): + fx = px + 0.5 + w0 = edge(x1, y1, x2, y2, fx, fy) + w1 = edge(x2, y2, x0, y0, fx, fy) + w2 = edge(x0, y0, x1, y1, fx, fy) + if area > 0: + if w0 < 0 or w1 < 0 or w2 < 0: + continue + else: + if w0 > 0 or w1 > 0 or w2 > 0: + continue + bz0 = w0 * inv_area + bz1 = w1 * inv_area + bz2 = w2 * inv_area + depth = bz0 * z0 + bz1 * z1 + bz2 * z2 + idx = row + px + if depth >= zbuf[idx]: + continue + zbuf[idx] = depth + p = idx * 3 + rgb[p + 0] = color[0] + rgb[p + 1] = color[1] + rgb[p + 2] = color[2] + + def draw_line( + xa: float, + ya: float, + xb: float, + yb: float, + color: tuple[int, int, int], + ) -> None: + x0i = int(round(xa)) + y0i = int(round(ya)) + x1i = int(round(xb)) + y1i = int(round(yb)) + dx = abs(x1i - x0i) + sx_step = 1 if x0i < x1i else -1 + dy = -abs(y1i - y0i) + sy_step = 1 if y0i < y1i else -1 + err = dx + dy + x = x0i + y = y0i + while True: + if 0 <= x < width and 0 <= y < height: + p = (y * width + x) * 3 + rgb[p + 0] = color[0] + rgb[p + 1] = color[1] + rgb[p + 2] = color[2] + if x == x1i and y == y1i: + break + e2 = 2 * err + if e2 >= dy: + err += dy + x += sx_step + if e2 <= dx: + err += dx + y += sy_step + + if wireframe: + wf = (225, 232, 246) + for i0, i1, i2 in terrain_faces: + draw_line(sx[i0], sy[i0], sx[i1], sy[i1], wf) + draw_line(sx[i1], sy[i1], sx[i2], sy[i2], wf) + draw_line(sx[i2], sy[i2], sx[i0], sy[i0], wf) + + if areal_overlay: + for area in areals: + verts = area["vertices"] + if len(verts) < 2: + continue + color = _color_for_class(int(area["class_id"])) + projected = [project_point(x, y, z + 0.35) for x, y, z in verts] + for i in range(len(projected)): + x0, y0, _ = projected[i] + x1, y1, _ = projected[(i + 1) % len(projected)] + draw_line(x0, y0, x1, y1, color) + + return rgb + + +def cmd_render(args: argparse.Namespace) -> int: + msh_path = Path(args.land_msh).resolve() + map_path = Path(args.land_map).resolve() if args.land_map else None + output_path = Path(args.output).resolve() + + positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces)) + areals: list[dict[str, Any]] = [] + map_meta: dict[str, int] = {"areal_count": 0, "cells_x": 0, "cells_y": 0} + if map_path: + areals, map_meta = load_areal_map(map_path) + + rgb = _render_scene( + positions, + faces, + areals, + width=int(args.width), + height=int(args.height), + yaw_deg=float(args.yaw), + pitch_deg=float(args.pitch), + wireframe=bool(args.wireframe), + areal_overlay=bool(args.overlay_areals), + ) + _write_ppm(output_path, int(args.width), int(args.height), rgb) + + print(f"Rendered terrain : {msh_path}") + if map_path: + print(f"Areal overlay : {map_path}") + print(f"Output : {output_path}") + print( + "Terrain geometry : " + f"vertices={terrain_meta['vertex_count']}, " + f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']} " + f"(raw={terrain_meta['face_count_raw']}, dropped={terrain_meta['face_dropped_invalid']})" + ) + if map_path: + print( + "Areal map : " + f"areals={map_meta['areal_count']}, cells={map_meta['cells_x']}x{map_meta['cells_y']}" + ) + return 0 + + +def cmd_export_obj(args: argparse.Namespace) -> int: + msh_path = Path(args.land_msh).resolve() + map_path = Path(args.land_map).resolve() if args.land_map else None + output_path = Path(args.output).resolve() + + positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces)) + areals: list[dict[str, Any]] = [] + if map_path and bool(args.include_areals): + areals, _ = load_areal_map(map_path) + + _write_obj( + output_path, + positions, + faces, + areals, + include_areals=bool(args.include_areals), + ) + + areal_vertices = sum(len(a["vertices"]) for a in areals) + print(f"Terrain source : {msh_path}") + if map_path: + print(f"Areal source : {map_path}") + print(f"OBJ output : {output_path}") + print( + "Terrain geometry : " + f"vertices={terrain_meta['vertex_count']}, " + f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']}" + ) + if bool(args.include_areals): + print(f"Areal edges : areals={len(areals)}, extra_vertices={areal_vertices}") + return 0 + + +def cmd_render_turntable(args: argparse.Namespace) -> int: + msh_path = Path(args.land_msh).resolve() + map_path = Path(args.land_map).resolve() if args.land_map else None + output_dir = Path(args.output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + + frames = int(args.frames) + if frames <= 0: + raise RuntimeError("--frames must be > 0") + + positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces)) + areals: list[dict[str, Any]] = [] + if map_path: + areals, _ = load_areal_map(map_path) + + yaw_start = float(args.yaw_start) + yaw_end = float(args.yaw_end) + if frames == 1: + yaws = [yaw_start] + else: + step = (yaw_end - yaw_start) / (frames - 1) + yaws = [yaw_start + i * step for i in range(frames)] + + prefix = str(args.prefix) + for i, yaw in enumerate(yaws): + rgb = _render_scene( + positions, + faces, + areals, + width=int(args.width), + height=int(args.height), + yaw_deg=yaw, + pitch_deg=float(args.pitch), + wireframe=bool(args.wireframe), + areal_overlay=bool(args.overlay_areals), + ) + out = output_dir / f"{prefix}_{i:03d}.ppm" + _write_ppm(out, int(args.width), int(args.height), rgb) + + print(f"Turntable source : {msh_path}") + if map_path: + print(f"Areal source : {map_path}") + print(f"Output dir : {output_dir}") + print(f"Frames : {frames} ({yaws[0]:.3f} -> {yaws[-1]:.3f} yaw)") + print( + "Terrain geometry : " + f"vertices={terrain_meta['vertex_count']}, faces={terrain_meta['face_count_rendered']}" + ) + return 0 + + +def cmd_render_batch(args: argparse.Namespace) -> int: + maps_root = Path(args.maps_root).resolve() + output_dir = Path(args.output_dir).resolve() + msh_paths = sorted(maps_root.rglob("Land.msh")) + if not msh_paths: + raise RuntimeError(f"no Land.msh files under {maps_root}") + + rendered = 0 + skipped = 0 + for msh_path in msh_paths: + map_path = msh_path.with_name("Land.map") + if not map_path.exists(): + skipped += 1 + continue + rel = msh_path.parent.relative_to(maps_root) + out = output_dir / f"{rel.as_posix().replace('/', '__')}.ppm" + cmd_render( + argparse.Namespace( + land_msh=str(msh_path), + land_map=str(map_path), + output=str(out), + max_faces=args.max_faces, + width=args.width, + height=args.height, + yaw=args.yaw, + pitch=args.pitch, + wireframe=args.wireframe, + overlay_areals=args.overlay_areals, + ) + ) + rendered += 1 + + print(f"Batch summary: rendered={rendered}, skipped_no_map={skipped}, output_dir={output_dir}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Software 3D terrain renderer (Land.msh + optional Land.map overlay)." + ) + sub = parser.add_subparsers(dest="command", required=True) + + render = sub.add_parser("render", help="Render one terrain map to PPM.") + render.add_argument("--land-msh", required=True, help="Path to Land.msh") + render.add_argument("--land-map", help="Path to Land.map (optional)") + render.add_argument("--output", required=True, help="Output .ppm path") + render.add_argument("--max-faces", type=int, default=220000, help="Face limit (default: 220000)") + render.add_argument("--width", type=int, default=1280, help="Image width (default: 1280)") + render.add_argument("--height", type=int, default=720, help="Image height (default: 720)") + render.add_argument("--yaw", type=float, default=38.0, help="Yaw angle in degrees (default: 38)") + render.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)") + render.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay") + render.add_argument( + "--overlay-areals", + action="store_true", + help="Draw ArealMap polygon overlay", + ) + render.set_defaults(func=cmd_render) + + export_obj = sub.add_parser("export-obj", help="Export terrain (and optional areal edges) to OBJ.") + export_obj.add_argument("--land-msh", required=True, help="Path to Land.msh") + export_obj.add_argument("--land-map", help="Path to Land.map (optional)") + export_obj.add_argument("--output", required=True, help="Output .obj path") + export_obj.add_argument("--max-faces", type=int, default=0, help="Face limit (0 = all)") + export_obj.add_argument( + "--include-areals", + action="store_true", + help="Export areal polygons as OBJ polyline object", + ) + export_obj.set_defaults(func=cmd_export_obj) + + turn = sub.add_parser("render-turntable", help="Render turntable frame sequence to PPM.") + turn.add_argument("--land-msh", required=True, help="Path to Land.msh") + turn.add_argument("--land-map", help="Path to Land.map (optional)") + turn.add_argument("--output-dir", required=True, help="Output directory for frames") + turn.add_argument("--prefix", default="frame", help="Frame filename prefix (default: frame)") + turn.add_argument("--frames", type=int, default=36, help="Frame count (default: 36)") + turn.add_argument("--yaw-start", type=float, default=0.0, help="Start yaw in degrees (default: 0)") + turn.add_argument("--yaw-end", type=float, default=360.0, help="End yaw in degrees (default: 360)") + turn.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)") + turn.add_argument("--max-faces", type=int, default=160000, help="Face limit (default: 160000)") + turn.add_argument("--width", type=int, default=960, help="Image width (default: 960)") + turn.add_argument("--height", type=int, default=540, help="Image height (default: 540)") + turn.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay") + turn.add_argument( + "--overlay-areals", + action="store_true", + help="Draw ArealMap polygon overlay", + ) + turn.set_defaults(func=cmd_render_turntable) + + batch = sub.add_parser("render-batch", help="Render all MAPS/**/Land.msh under root.") + batch.add_argument( + "--maps-root", + default="tmp/gamedata/DATA/MAPS", + help="Root directory with MAPS subfolders (default: tmp/gamedata/DATA/MAPS)", + ) + batch.add_argument("--output-dir", required=True, help="Directory for output PPM files") + batch.add_argument("--max-faces", type=int, default=90000, help="Face limit per map (default: 90000)") + batch.add_argument("--width", type=int, default=960, help="Image width (default: 960)") + batch.add_argument("--height", type=int, default=540, help="Image height (default: 540)") + batch.add_argument("--yaw", type=float, default=38.0, help="Yaw angle in degrees (default: 38)") + batch.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)") + batch.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay") + batch.add_argument( + "--overlay-areals", + action="store_true", + help="Draw ArealMap polygon overlay", + ) + batch.set_defaults(func=cmd_render_batch) + + return parser + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + return int(args.func(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) |
