diff options
| author | Valentin Popov <valentin@popov.link> | 2026-02-12 13:17:41 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-02-12 13:17:41 +0300 |
| commit | 669fb40a70fb975f75fa986921b9daaac1f14a0c (patch) | |
| tree | f2a5646dd083b7489a262d3c9a02dd234795d0c4 | |
| parent | 9c0df3d299aad73e866e6a474d0aa12a4c3bbd5a (diff) | |
| download | fparkan-669fb40a70fb975f75fa986921b9daaac1f14a0c.tar.xz fparkan-669fb40a70fb975f75fa986921b9daaac1f14a0c.zip | |
Add terrain map documentation validator
This commit introduces a new Python script, `terrain_map_doc_validator.py`, which validates terrain and map documentation assumptions against actual game data. The validator checks for the presence and correctness of various data chunks in the `Land.msh` and `Land.map` files, reporting any issues found during the validation process. It also generates a summary report of the validation results, including counts of errors and warnings, and statistics related to the map and mesh data.
| -rw-r--r-- | docs/specs/terrain-map-loading.md | 513 | ||||
| -rw-r--r-- | tools/terrain_map_doc_validator.py | 809 |
2 files changed, 1305 insertions, 17 deletions
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/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()) |
