Files
fparkan/docs/specs/msh-core.md
Valentin Popov 8a69872576
All checks were successful
Test / Lint (push) Successful in 1m8s
Test / Test (push) Successful in 1m15s
Refactor code structure for improved readability and maintainability
2026-02-12 11:07:25 +00:00

24 KiB
Raw Blame History

MSH core

Документ фиксирует core-часть формата MSH на уровне, достаточном для:

  • реализации runtime-совместимого движка (поведение 1:1);
  • реализации reader/writer/editor/converter с lossless round-trip;
  • валидации ассетов и диагностики повреждений.

Связанные документы:

  • NRes / RsLi — контейнер, каталог, атрибуты, выравнивание.
  • MSH animation — детальная спецификация Res8/Res19.
  • Materials + Texm — материальная часть и текстуры.
  • Terrain + map loading — отдельная ветка terrain-ресурсов.

1. Область и источники

1.1. Что покрывает этот документ

Этот документ покрывает именно core-геометрию и её runtime-связи:

  • Res1 (node table),
  • Res2 (header + slots),
  • Res3/4/5 (позиции/нормали/UV0),
  • Res6 (индексы),
  • Res7 (triangle descriptors),
  • Res10 (node string table),
  • Res13 (batch table),
  • optional Res15/16/18/20,
  • точки стыка с анимацией (Res8/Res19).

1.2. Что не покрывает

  • детальную семантику материалов/текстурных фаз (см. materials-texm.md),
  • terrain-ветку (type 11/14/21 и связанные структуры, см. terrain-map-loading.md),
  • полную математику анимационного сэмплирования (см. msh-animation.md).

1.3. Источники реверса

Основные подтверждения:

  • tmp/disassembler1/AniMesh.dll.c:
    • sub_10015FD0 (загрузка ресурсов core-модели),
    • sub_100124D0 (поиск slot по node/lod/group),
    • sub_10012530 (доступ к строке узла в Res10),
    • sub_1000B2C0/sub_10013680 (tri/batch path),
    • sub_1000A460 (инициализация runtime-инстансов, копирование глобальных bounds).
  • tmp/disassembler2/AniMesh.dll.asm — подтверждение смещений/stride/ветвлений.
  • валидация corpus: testdata/nres (435 MSH моделей, нулевые ошибки в tools/msh_doc_validator.py).

2. Модель данных MSH (high-level)

MSH-модель — это NRes-контейнер, где ресурсы связаны не по порядку, а по type-id.

Базовая связь таблиц:

  1. Res1 для (node, lod, group) выбирает slotIndex.
  2. Res2.slot[slotIndex] даёт диапазоны triangle/batch (triStart/triCount, batchStart/batchCount).
  3. Res13.batch даёт indexStart/indexCount/baseVertex.
  4. Res6 даёт сырые uint16 индексы.
  5. Res3/4/5 дают vertex-атрибуты по baseVertex + index.

Ключевая особенность runtime:

  • скиннинг по узлам жёсткий (rigid attachment), без per-vertex bone weights в core-ресурсах.

3. Карта ресурсов и границы core

3.1. Ресурсы, которые читает core-loader (sub_10015FD0)

Type Ресурс Статус в core-loader Формат/stride
1 Node table required 38 байт/узел (основной случай)
2 Model header + slots required 0x8C + slotCount*0x44
3 Positions required 12
4 Packed normals обычно required 4
5 Packed UV0 обычно required 4
6 Index buffer required 2
7 Triangle descriptors обычно required 16
8 Anim key pool optional для статических 24
10 String table обычно required variable
13 Batch table required 20
15 Доп. stream optional 8
16 Tangent/bitangent stream optional 8
18 Vertex color stream optional 4
19 Anim mapping optional для статических 2
20 Доп. таблица optional variable

3.2. Ресурсы, которые встречаются в MSH, но вне этого документа

В corpus из 435 моделей стабильно встречаются также type 9 и type 17. Они не загружаются sub_10015FD0 и относятся к некоревым подсистемам (материалы/эффекты/прочие runtime-ветки).

3.3. Прямая MSH и вложенная MSH

Tooling должен поддерживать два режима входа:

  • файл уже является модельным NRes (magic NRes и содержит type 1/2/3/6/13),
  • файл-архив содержит .msh entry, внутри которой вложенный NRes модели.

4. Runtime-контракт загрузки (sub_10015FD0)

sub_10015FD0 заполняет структуру модели размером 0xA4 байт и строит derived pointers/stride.

4.1. Порядок find/open

Фактический порядок загрузки:

  1. type 1 -> this+0x00
  2. type 2 -> this+0x04
  3. type 3 -> this+0x0C
  4. type 4 -> this+0x10
  5. type 5 -> this+0x14
  6. type 10 -> this+0x20
  7. type 8 -> this+0x18
  8. type 19 -> this+0x1C
  9. type 7 -> this+0x24
  10. type 13 -> this+0x28
  11. type 6 -> this+0x2C
  12. type 15 -> this+0x34
  13. type 16 -> this+0x38
  14. type 18 -> this+0x64 (через отдельный find, optional)
  15. type 20 -> this+0x30 (optional)

4.2. Derived-поля (стримы)

После загрузки ставятся derived-поля:

  • this+0x08 = Res2 + 0x8C (начало slot table),
  • this+0x3C = Res3, this+0x40 = 12,
  • this+0x44 = Res4, this+0x48 = 4,
  • this+0x5C = Res5, this+0x60 = 4,
  • this+0x8C = Res15, this+0x90 = 8,
  • this+0x94 = 0 (инициализация нулём).

Для Res16:

  • если есть: this+0x4C = Res16, this+0x50 = 8, this+0x54 = Res16+4, this+0x58 = 8;
  • если нет: this+0x4C = 0, this+0x54 = 0 (stride остаются несущественными, т.к. указатели нулевые).

Для Res18:

  • если найден: this+0x64 = ptr, this+0x68 = 4;
  • иначе: this+0x64 = 0, this+0x68 = 0.

4.3. Метаданные из каталога NRes

  • this+0x9C получает entry(type19).attr2 (читается из поля +8 каталожной записи, индекс entry * 64).
  • this+0xA0 получает entry(type20).attr1 (поле +4) только если type20 существует и успешно открыт; иначе 0.

5. Бинарные структуры core-ресурсов

Все структуры little-endian.

5.1. Res1 — Node table

Базовый stride: 38 байт (19 * uint16).

struct Node38 {
    uint16_t hdr0;            // +0
    uint16_t hdr1;            // +2
    uint16_t hdr2;            // +4
    uint16_t hdr3;            // +6
    uint16_t slotIndex[15];   // +8: [lod0 g0..g4][lod1 g0..g4][lod2 g0..g4]
};

Подтверждённые поля

  • hdr1: parent/index-link (используется при построении инстанса), 0xFFFF = нет.
  • hdr2: mapStart для Res19 (см. msh-animation.md), 0xFFFF = нет map.
  • hdr3: fallback key index в Res8.
  • hdr0: node flags (есть битовые проверки, но полная доменная семантика не закрыта).

Адресация slot (runtime-функция sub_100124D0)

uint16_t get_slot_index(const Node38* node_table, uint32_t nodeIndex, int lod, int group, int current_lod) {
    int use_lod = (lod == -1) ? current_lod : lod;
    int word_index = 4 + (int)nodeIndex * 19 + use_lod * 5 + group;
    return *(uint16_t*)((const uint8_t*)node_table + word_index * 2);
}

0xFFFF означает "слот отсутствует".

Вариант stride=24

В corpus есть единичный служебный outlier с Res1.attr3 = 24. Для 1:1 editing существующих ассетов требуется copy-through этого варианта. Новая генерация должна ориентироваться на stride 38, если нет чёткой цели поддержать legacy-вариант.


5.2. Res2 — Model header + Slot table

Res2:
  [0x00 .. 0x8B]   model header (140 bytes)
  [0x8C .. end]    slot records (68 bytes each)

5.2.1. Header (0x8C)

Runtime копирует блоки как float-массивы:

  • 0x00..0x5F (24 float) — глобальный hull (vec3[8]),
  • 0x60..0x6F (4 float) — глобальная sphere (center.xyz + radius),
  • 0x70..0x8B (7 float) — сегмент/капсула (A.xyz, B.xyz, radius).

5.2.2. Slot record (68 bytes)

struct Slot68 {
    uint16_t triStart;      // +0
    uint16_t triCount;      // +2
    uint16_t batchStart;    // +4
    uint16_t batchCount;    // +6

    float aabbMin[3];       // +8
    float aabbMax[3];       // +20
    float sphereCenter[3];  // +32
    float sphereRadius;     // +44

    uint32_t unk30;         // +48
    uint32_t unk34;         // +52
    uint32_t unk38;         // +56
    uint32_t unk3C;         // +60
    uint32_t unk40;         // +64
};

triCount подтверждён как длина диапазона:

triId >= triStart && triId < triStart + triCount

Хвост unk30..unk40 должен сохраняться без изменений в editor/writer.

5.2.3. Bounds semantics

  • Slot bounds локальны относительно узла.
  • При world-трансформации sphere radius масштабируется по max(scaleX, scaleY, scaleZ) при неравномерном scale.

5.3. Res3 — Positions

struct Position12 {
    float x;
    float y;
    float z;
};

Stride 12.


5.4. Res4 — Packed normals

struct PackedNormal4 {
    int8_t nx;
    int8_t ny;
    int8_t nz;
    int8_t nw; // семантика 4-го байта не зафиксирована
};

Декодирование:

normal = clamp((float)n / 127.0f, -1.0f, 1.0f)
  • делитель строго 127.0;
  • clamp обязателен из-за -128 / 127.0.

Кодирование (writer):

int8_t q = (int8_t)clamp(round(v * 127.0f), -128, 127);

5.5. Res5 — Packed UV0

struct PackedUV4 {
    int16_t u;
    int16_t v;
};

Декодирование:

uv = packed / 1024.0f

Кодирование:

int16_t q = (int16_t)clamp(round(uv * 1024.0f), -32768, 32767);

5.6. Res6 — Index buffer

Массив uint16, stride 2.

Runtime-путь:

vertexIndex = Res6[indexStart + i] + batch.baseVertex;

indexStart хранится в элементах, не в байтах.


5.7. Res7 — Triangle descriptors (16 bytes)

struct TriDesc16 {
    uint16_t triFlags;    // +0
    uint16_t linkTri0;    // +2
    uint16_t linkTri1;    // +4
    uint16_t linkTri2;    // +6
    int16_t  nX;          // +8
    int16_t  nY;          // +10
    int16_t  nZ;          // +12
    uint16_t selPacked;   // +14
};
  • nX/nY/nZ декодируются через 1/32767.
  • linkTri* используются в tri-neighbour/collision path.

Раскладка selPacked (3 селектора по 2 бита):

sel0 = (selPacked >> 0) & 0x3; if (sel0 == 3) sel0 = 0xFFFF;
sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF;
sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF;

5.8. Res13 — Batch table (20 bytes)

struct Batch20 {
    uint16_t batchFlags;     // +0
    uint16_t materialIndex;  // +2
    uint16_t unk4;           // +4
    uint16_t unk6;           // +6
    uint16_t indexCount;     // +8
    uint32_t indexStart;     // +10
    uint16_t unk14;          // +14
    uint32_t baseVertex;     // +16
};

unk4/unk6/unk14 семантически не закрыты; writer/editor должны сохранять.


5.9. Res10 — Node string table

Последовательность записей variable-length:

struct Res10Record {
    uint32_t len;   // длина строки без '\0'
    char text[];    // если len>0: len+1 байт (с '\0'), иначе payload нет
};

Переход:

next = cur + 4 + (len ? len + 1 : 0);

sub_10012530 возвращает:

  • NULL, если len == 0,
  • record + 4, если len > 0.

Индекс записи в Res10 соответствует nodeIndex.


5.10. Optional streams

Res15 (stride 8)

Дополнительный поток на вершину (семантика не полностью подтверждена).

Res16 (stride 8, split 2x4)

Runtime делит поток на два interleaved подпотока:

  • stream A: base+0, stride 8,
  • stream B: base+4, stride 8.

В corpus из testdata/nres этот ресурс не встретился, но loader поддерживает.

Res18 (stride 4)

Vertex color / доп. packed-канал. В corpus встречается на части моделей.

Res20

Доп. таблица неизвестной доменной семантики. Loader хранит pointer и метаданные каталога (attr1).


5.11. Точки стыка с анимацией (Res8/Res19)

Core-loader загружает:

  • Res8 в this+0x18,
  • Res19 в this+0x1C,
  • Res19.attr2 в this+0x9C.

Полный runtime-алгоритм сэмплирования/смешивания описан в MSH animation.


6. Runtime-алгоритмы core

6.1. Slot lookup (sub_100124D0)

Вход: runtime-node-instance, group, lod.

  1. Если нет model pointer -> NULL.
  2. lod == -1 -> подставить current_lod инстанса.
  3. Вычислить slotIndex через формулу 4 + node*19 + lod*5 + group.
  4. Если slotIndex == 0xFFFF -> NULL.
  5. Иначе вернуть Res2.slotBase + slotIndex * 68.

6.2. Node string lookup (sub_10012530)

  1. Идти по Res10-записям nodeIndex раз.
  2. Возвращать NULL или char* по правилу len==0.

6.3. Геометрический обход для рендера

Reference-путь, эквивалентный runtime-логике:

for each node:
    slot = resolve_slot(node, lod, group)
    if (!slot) continue

    for b in [slot.batchStart .. slot.batchStart + slot.batchCount):
        batch = Res13[b]
        for i in [0 .. batch.indexCount):
            idx = Res6[batch.indexStart + i]
            vtx = batch.baseVertex + idx

            pos = Res3[vtx]
            nrm = decode_res4(Res4[vtx])
            uv0 = decode_res5(Res5[vtx])

6.4. Tri/collision path (обобщённо)

  • sub_1000B2C0 и sub_10013680 используют tri-диапазоны слота + Res7 link/select-поля.
  • Для collision/picking-контекста должны быть валидны:
    • slot.triStart + slot.triCount <= triDescCount,
    • linkTri* либо 0xFFFF, либо < triDescCount.

7. Инварианты и валидация (reader)

7.1. Базовые проверки целостности

  • каждый fixed-stride ресурс делится на stride без остатка;
  • Res2.size >= 0x8C;
  • (Res2.size - 0x8C) % 68 == 0;
  • Res2.attr1 == slotCount, Res2.attr3 == 68;
  • Res3.attr3 == 12, Res4.attr3 == 4, Res5.attr3 == 4, Res6.attr3 == 2, Res7.attr3 == 16, Res13.attr3 == 20;
  • Res8.attr3 == 4 (не stride), Res19.attr3 == 2, Res10.attr3 == 0 (в observed assets).

7.2. Cross-table проверки

  • slot.batchStart + slot.batchCount <= batchCount;
  • slot.triStart + slot.triCount <= triDescCount;
  • batch.indexStart + batch.indexCount <= indexCount;
  • batch.baseVertex + max(indexSlice) < vertexCount;
  • все Res1.slotIndex[*] либо 0xFFFF, либо < slotCount;
  • для Res10: парсинг ровно nodeCount записей без хвостовых байт;
  • для Res7.linkTri*: либо 0xFFFF, либо < triDescCount.

7.3. Strict vs tolerant режим

Рекомендуется 2 режима reader:

  • strict: любое нарушение инвариантов -> ошибка;
  • tolerant: безопасно отбрасывать/игнорировать только локально повреждённые диапазоны (без OOB).

8. Правила writer/editor

8.1. Обязательная политика для 1:1 editing

  • сохранять неизвестные поля (Slot68.unk*, Batch20.unk*, Node.hdr0 и т.д.) без модификации, если нет осознанного пересчёта;
  • сохранять неизвестные resource types и их payload/атрибуты;
  • не полагаться на порядок ресурсов в контейнере: lookup в runtime идёт по type-id.

8.2. Пересчёт атрибутов каталога

При записи изменённых ресурсов:

  • attr1 = count (или форматно-специфичное значение),
  • attr2 — по формату/семантике ресурса,
  • attr3 — stride/константа формата.

Практические правила для core:

  • Res1: attr1=nodeCount, attr3=38 (или исходный вариант, если copy-through legacy), attr2 лучше сохранять из исходника;
  • Res2: attr1=slotCount, attr2=0, attr3=68;
  • Res3/4/5/6/7/13/15/16/18: attr1=size/stride, attr2=0, attr3=stride;
  • Res8: attr1=size/24, attr3=4;
  • Res10: attr1=nodeCount, attr2=0, attr3=0;
  • Res19: attr1=size/2, attr2=frameCount, attr3=2.

8.3. Матрица зависимостей при редактировании

Операция Какие ресурсы обновлять
Смещение/деформация вершин Res3, при необходимости Res4, bounds в Res2
Изменение UV Res5 (и опционально Res15)
Изменение topology (индексы/треугольники) Res6, Res13, Res7, диапазоны Res2.slot
Изменение LOD/group назначения Res1.slotIndex, возможно Res2.slot
Изменение имени узла Res10
Изменение иерархии/анимации узлов Res1.hdr1/hdr2/hdr3, Res8, Res19
Добавление/удаление slot Res2, ссылки из Res1, диапазоны batch/tri

8.4. Детерминированная сериализация

  • little-endian для всех чисел;
  • без внутреннего padding в таблицах ресурсов;
  • выравнивание блоков ресурсов в NRes по 8 байт (через контейнер).

9. Рекомендованный canonical IR для toolchain

Минимальный IR для безопасного round-trip:

struct ModelCoreIR {
    // raw payloads for unknown/passthrough types
    map<uint32_t, RawResource> raw_passthrough;

    vector<Node> nodes;          // Res1 decoded (hdr + matrix)
    Header140 header;            // Res2[0x00..0x8B]
    vector<Slot> slots;          // Res2 slot table (включая unk tail)

    vector<float3> positions;    // Res3
    vector<PackedNormal4> normals_raw; // Res4 raw + optional decoded cache
    vector<PackedUV4> uv0_raw;   // Res5 raw + optional decoded cache

    vector<uint16_t> indices;    // Res6
    vector<TriDesc16> tri;       // Res7
    vector<Batch20> batches;     // Res13
    vector<optional<string>> node_names; // Res10

    optional<vector<uint8_t>> res15_raw;
    optional<vector<uint8_t>> res16_raw;
    optional<vector<uint32_t>> colors_raw; // Res18
    optional<RawResource> res20_raw;

    // animation bridge
    optional<vector<AnimKey24>> anim_keys;    // Res8
    optional<vector<uint16_t>> anim_map_words; // Res19
    uint32_t anim_frame_count;
};

Принцип: где семантика неполная, хранить raw и переизлучать байт-в-байт.


10. Практика конвертации

10.1. MSH -> OBJ/GLTF

  • Res3 напрямую в позиции;
  • Res6 + Res13 в faces;
  • нормали/UV декодировать через коэффициенты 1/127, 1/1024;
  • при экспорте по LOD/group использовать Res1 матрицу слотов, а не "все batch подряд" (если нужен runtime-эквивалент);
  • пометить ограничения: core не содержит классический weight-скиннинг.

10.2. Обратный импорт (OBJ/GLTF -> MSH)

Для 1:1 ожидаемого поведения импортёр должен:

  • строить корректные Res13 диапазоны,
  • строить/обновлять Res2.slot ranges и bounds,
  • поддерживать quantization при упаковке (Res4/Res5),
  • сохранять unknown-поля таблиц, если вход был редактированием существующей модели.

11. Наблюдения по corpus (testdata/nres)

Сводка по 435 MSH-моделям:

  • валидны все 435/435 по tools/msh_doc_validator.py;
  • основной порядок типов:
    • 414: (1,2,3,4,5,15,13,6,7,8,19,9,10,17)
    • 21: (1,2,3,4,5,18,15,13,6,7,8,19,9,10,17,20)
  • Res1.attr3: 38 в 434 моделях, 24 в 1 модели;
  • Res18 и Res20 встречаются в 21 модели;
  • Res16 в данном corpus не встретился;
  • Res8/Res19 присутствуют во всех моделях, но Res19.attr2=1 часто соответствует статике.

12. Открытые вопросы (не блокируют 1:1)

  • точная доменная семантика Node.hdr0 битов;
  • полные имена/назначения Batch20.unk4/unk6/unk14;
  • назначение Slot68.unk30..unk40;
  • полная семантика Res15/Res16/Res18/Res20 payload beyond stride-level;
  • точная семантика 4-го байта в PackedNormal4.

Для runtime/reader/writer это не критично при условии byte-preserving policy.


13. Чеклист реализации 1:1

13.1. Engine runtime

  • реализован loader-порядок как в sub_10015FD0;
  • slot lookup по формуле 4 + node*19 + lod*5 + group;
  • декодирование Res4 через /127.0 с clamp;
  • декодирование Res5 через /1024.0;
  • tri селекторы selPacked трактуются как 2-битные с 3 -> 0xFFFF;
  • корректная обработка 0xFFFF sentinel во всех таблицах.

13.2. Reader/validator

  • строгая проверка stride/размеров/диапазонов;
  • OOB-защита всех индексных доступов;
  • поддержка both direct-model и nested .msh payload.

13.3. Writer/editor

  • стабильный пересчёт attr1/attr2/attr3;
  • сохранение unknown fields и unknown resource types;
  • детерминированная сериализация NRes (8-byte align);
  • regression-проверка round-trip: decode -> encode -> decode без расхождений структуры/диапазонов.