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.
This commit is contained in:
@@ -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)
|
||||
|
||||
---
|
||||
|
||||
## 4.1. Обзор
|
||||
## 1. Назначение подсистем
|
||||
|
||||
`Terrain.dll` отвечает за рендер ландшафта (terrain), включая:
|
||||
### 1.1. `Terrain.dll`
|
||||
|
||||
- Рендер мешей ландшафта (`"Rendered meshes"`, `"Rendered primitives"`, `"Rendered faces"`).
|
||||
- Рендер частиц (`"Rendered particles/batches"`).
|
||||
- Создание текстур (`"CTexture::CTexture()"` — конструктор текстуры).
|
||||
- Микротекстуры (`"Unable to find microtexture mapping"`).
|
||||
Отвечает за:
|
||||
|
||||
## 4.2. Текстуры ландшафта
|
||||
- загрузку и хранение terrain-геометрии из `*.msh` (NRes);
|
||||
- фильтрацию и выборку треугольников для коллизий/трассировки/рендера;
|
||||
- рендер terrain-примитивов и связанного shading;
|
||||
- использование микро-текстурного канала (chunk type 18).
|
||||
|
||||
В Terrain.dll присутствует конструктор текстуры `CTexture::CTexture()` со следующими проверками:
|
||||
Характерные runtime-строки:
|
||||
|
||||
- Валидация размера текстуры (`"Unsupported texture size"`).
|
||||
- Создание D3D‑текстуры (`"Unable to create texture"`).
|
||||
- `CLandscape::CLandscape()`
|
||||
- `Unable to find microtexture mapping chunk`
|
||||
- `Rendering empty primitive!`
|
||||
- `Rendering empty primitive2!`
|
||||
|
||||
Ландшафт использует **микротекстуры** (micro‑texture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности.
|
||||
### 1.2. `ArealMap.dll`
|
||||
|
||||
## 4.3. Защита от пустых примитивов
|
||||
Отвечает за:
|
||||
|
||||
Terrain.dll содержит проверки:
|
||||
- загрузку геометрии ареалов из `*.map` (NRes, chunk type 12);
|
||||
- построение связей "ареал <-> соседи/подграфы";
|
||||
- grid-ускорение по ячейкам карты;
|
||||
- runtime-доступ к `ISystemArealMap` (интерфейс id `770`) и ареалам (id `771`).
|
||||
|
||||
- `"Rendering empty primitive!"` — перед первым вызовом отрисовки.
|
||||
- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки.
|
||||
Характерные runtime-строки:
|
||||
|
||||
Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта).
|
||||
- `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. Формат `*.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()`.
|
||||
|
||||
---
|
||||
|
||||
## 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`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Эмпирическая верификация (retail `tmp/gamedata`)
|
||||
|
||||
Для массовой проверки спецификации добавлен валидатор:
|
||||
|
||||
- `tools/terrain_map_doc_validator.py`
|
||||
|
||||
Запуск:
|
||||
|
||||
```bash
|
||||
python3 tools/terrain_map_doc_validator.py \
|
||||
--maps-root tmp/gamedata/DATA/MAPS \
|
||||
--report-json tmp/terrain_map_doc_validator.report.json
|
||||
```
|
||||
|
||||
Проверенные инварианты (на 33 картах, 2026-02-12):
|
||||
|
||||
- `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` проходят строгую валидацию ссылочной целостности.
|
||||
|
||||
Сводный результат текущего набора данных:
|
||||
|
||||
- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`.
|
||||
|
||||
809
tools/terrain_map_doc_validator.py
Normal file
809
tools/terrain_map_doc_validator.py
Normal file
@@ -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())
|
||||
Reference in New Issue
Block a user