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.
23 KiB
Terrain + map loading
Документ описывает полный runtime-пайплайн загрузки ландшафта и карты (Terrain.dll + ArealMap.dll) и требования к toolchain для 1:1 совместимости (чтение, конвертация, редактирование, обратная сборка).
Источник реверса:
tmp/disassembler1/Terrain.dll.ctmp/disassembler1/ArealMap.dll.ctmp/disassembler2/Terrain.dll.asmtmp/disassembler2/ArealMap.dll.asm
Связанные спецификации:
1. Назначение подсистем
1.1. Terrain.dll
Отвечает за:
- загрузку и хранение terrain-геометрии из
*.msh(NRes); - фильтрацию и выборку треугольников для коллизий/трассировки/рендера;
- рендер terrain-примитивов и связанного shading;
- использование микро-текстурного канала (chunk type 18).
Характерные runtime-строки:
CLandscape::CLandscape()Unable to find microtexture mapping chunkRendering empty primitive!Rendering empty primitive2!
1.2. ArealMap.dll
Отвечает за:
- загрузку геометрии ареалов из
*.map(NRes, chunk type 12); - построение связей "ареал <-> соседи/подграфы";
- grid-ускорение по ячейкам карты;
- runtime-доступ к
ISystemArealMap(интерфейс id770) и ареалам (id771).
Характерные runtime-строки:
SystemArealMap panic: Cannot load ArealMapGeometrySystemArealMap panic: Cannot find chunk in resourceSystemArealMap panic: ArealMap Cells are emptySystemArealMap 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. Порядок инициализации (высокоуровнево)
- Получение
3DRenderи3DSound. - Загрузка
MatManager(*.wea),LightManager,CollManager,FxManager. - Создание
SystemArealMapчерезCreateSystemArealMap(..., "<level>.map", ...). - Открытие terrain-библиотеки
niOpenResFile("<level>.msh"). - Загрузка terrain-chunk-ов (см. §3).
- Построение 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 для Res1/Res2:
Res1:uint16[19]на node;Res2: header + slot table (0x8C + N * 0x44).
3.3. Terrain face record (type 21, 28 bytes)
Подтвержденные поля из runtime-декодирования face:
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 & 0x3edge1 = (byte26 >> 2) & 0x3edge2 = (byte26 >> 4) & 0x3
3.4. Маски флагов face
Во многих запросах применяется фильтр:
(faceFlags & requiredMask) == requiredMask &&
(faceFlags | ~forbiddenMask) == ~forbiddenMask
Эквивалентно: "все required-биты выставлены, forbidden-биты отсутствуют".
Подтверждено активное использование битов:
0x8(особая обработка в трассировке)0x20000x200000x1000000x200000
Кроме "полной" 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).
Дальше запросы:
- переводят world AABB в диапазон grid-ячеек (
floor(...)); - берут диапазон face через
Res1/Res2(slottriStart/triCount); - дополняют кандидаты из cell-списков (chunk type 11);
- применяют маски флагов;
- выполняют геометрию (plane/intersection/point-in-triangle).
3.6. Cell-списки по ячейкам (type 11 и runtime-массивы)
В CLandscape после инициализации используются три параллельных массива по ячейкам (cellsX * cellsY):
this+31588(sub_100164B0ctor): массив записей по12байт, каждая запись содержит динамический буфер8-байтовых элементов;this+31592(sub_100164E0ctor): массив записей по12байт, каждая запись содержит динамический буфер4-байтовых элементов;this+31596(sub_1001F880ctor): массив записей по12байт для runtime-объектов/агентов (буфер4-байтовых идентификаторов/указателей).
Общий header записи списка:
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:
niOpenResFile("<level>.map");- поиск chunk type
12; - чтение chunk-данных;
- разбор
ArealMapGeometry.
При ошибках выдаются panic-строки SystemArealMap panic: ....
4.2. Верхний уровень chunk 12
Используются:
entry.attr1(из каталога NRes) какareal_count;entry[+0x0C]как размер payload chunk для контроля полного разбора.
Данные chunk:
areal_countпеременных записей ареалов;- секция grid-ячеек (
cellsX/cellsY+ списки попаданий).
4.3. Переменная запись ареала
Полностью подтверждённые элементы layout:
// 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
После массива ареалов идёт:
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
- Никаких "переупорядочиваний по вкусу": сохранять порядок chunk-ов, если не требуется явная нормализация.
- Все неизвестные поля сохранять побайтно.
- При roundtrip обеспечивать byte-identical для неизмененных сущностей.
- Валидации должны повторять 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. Рекомендуемая архитектура редактора
Parser:- NRes-слой;
TerrainMsh-слой;ArealMapChunk12-слой.
Model:- явные известные поля;
raw_unknownдля непросаженных блоков.
Writer:- стабильная сериализация;
- проверка контрольных инвариантов перед записью.
Verifier:- roundtrip hash/byte-compare;
- runtime-совместимые asserts.
7. Практический чеклист "движок 1:1"
Для runtime-совместимого движка нужно реализовать:
- NRes API-уровень (
niOpenResFile,niOpenResInMem, поиск chunk по type, получение data/attrs). CLandscapeпайплайн загрузки*.msh+ менеджеров +CreateSystemArealMap.- Terrain face decode (28-byte запись), mask-фильтр, spatial grid queries.
- Загрузчик
ArealMapGeometry(chunk 12) с той же валидацией и packed-cell логикой. - Пост-обработку ареалов (пересвязка, корректировки опорных точек).
- Поддержку
BuildDat.lstдля объектных категорий/схем.
8. Нерасшифрованные зоны (важно для редакторов)
Ниже поля, которые пока нельзя безопасно "пересобирать по смыслу":
- семантика
class_id(record + 40) на уровне геймдизайна/скриптов (числовое поле подтверждено, но человекочитаемая таблица соответствий не восстановлена полностью); - ветки формата для
poly_count > 0(в retailtmp/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
Запуск:
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индексы вершин/соседей валидны;type2slot-таблица валидна по формуле0x8C + 68*N.
- порядок chunk-ов всегда
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проходят строгую валидацию ссылочной целостности.
- всегда один chunk
Сводный результат текущего набора данных:
issues_total = 0,errors_total = 0,warnings_total = 0.