Files
fparkan/docs/specs/terrain-map-loading.md
Valentin Popov 669fb40a70 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.
2026-02-12 10:17:41 +00:00

23 KiB
Raw Blame History

Terrain + map loading

Документ описывает полный 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

Связанные спецификации:


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 для 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 & 0x3
  • edge1 = (byte26 >> 2) & 0x3
  • edge2 = (byte26 >> 4) & 0x3

3.4. Маски флагов face

Во многих запросах применяется фильтр:

(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 записи списка:

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:

// 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

  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

Запуск:

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.