Files
fparkan/docs/specs/msh-core.md

493 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# MSH core
Документ описывает core-часть формата MSH: геометрию, узлы, батчи, LOD и slot-матрицу.
Связанный формат контейнера: [NRes / RsLi](nres.md).
---
## 1.1. Общая архитектура
Модель состоит из набора именованных ресурсов внутри одного NResархива. Каждый ресурс идентифицируется **целочисленным типом** (`resource_type`), который передаётся API функции `niReadData` (vtableметод `+0x18`) через связку `niFind` (vtableметод `+0x0C`, `+0x20`).
Рендер‑модель использует **rigidскининг по узлам** (нет pervertex bone weights). Каждый batch геометрии привязан к одному узлу и рисуется с матрицей этого узла.
## 1.2. Общая структура файла модели
```
┌────────────────────────────────────┐
│ NResзаголовок (16 байт) │
├────────────────────────────────────┤
│ Ресурсы (произвольный порядок): │
│ Res1 — Node table │
│ Res2 — Model header + Slots │
│ Res3 — Vertex positions │
│ Res4 — Packed normals │
│ Res5 — Packed UV0 │
│ Res6 — Index buffer │
│ Res7 — Triangle descriptors │
│ Res8 — Keyframe data │
│ Res10 — String table │
│ Res13 — Batch table │
│ Res19 — Animation mapping │
│ [Res15] — UV1 / доп. поток │
│ [Res16] — Tangent/Bitangent │
│ [Res18] — Vertex color │
│ [Res20] — Доп. таблица │
├────────────────────────────────────┤
│ NResкаталог
└────────────────────────────────────┘
```
Ресурсы в квадратных скобках — **опциональные**. Загрузчик проверяет их наличие перед чтением (`niFindRes` возвращает `1` при отсутствии).
## 1.3. Порядок загрузки ресурсов (из `sub_10015FD0` в AniMesh.dll)
Функция `sub_10015FD0` выполняет инициализацию внутренней структуры модели размером **0xA4** (164 байта). Ниже приведён точный порядок загрузки и маппинг ресурсов на поля структуры:
| Шаг | Тип ресурса | Поле структуры | Описание |
|-----|-------------|----------------|-----------------------------------------|
| 1 | 1 | `+0x00` | Node table (Res1) |
| 2 | 2 | `+0x04` | Model header (Res2) |
| 3 | 3 | `+0x0C` | Vertex positions (Res3) |
| 4 | 4 | `+0x10` | Packed normals (Res4) |
| 5 | 5 | `+0x14` | Packed UV0 (Res5) |
| 6 | 10 (0x0A) | `+0x20` | String table (Res10) |
| 7 | 8 | `+0x18` | Keyframe / animation track data (Res8) |
| 8 | 19 (0x13) | `+0x1C` | Animation mapping (Res19) |
| 9 | 7 | `+0x24` | Triangle descriptors (Res7) |
| 10 | 13 (0x0D) | `+0x28` | Batch table (Res13) |
| 11 | 6 | `+0x2C` | Index buffer (Res6) |
| 12 | 15 (0x0F) | `+0x34` | Доп. vertex stream (Res15), опционально |
| 13 | 16 (0x10) | `+0x38` | Доп. vertex stream (Res16), опционально |
| 14 | 18 (0x12) | `+0x64` | Vertex color (Res18), опционально |
| 15 | 20 (0x14) | `+0x30` | Доп. таблица (Res20), опционально |
### Производные поля (вычисляются после загрузки)
| Поле | Формула | Описание |
|---------|-------------------------|------------------------------------------------------------------------------------------------|
| `+0x08` | `Res2_ptr + 0x8C` | Указатель на slot table (140 байт от начала Res2) |
| `+0x3C` | `= Res3_ptr` | Копия указателя positions (stream ptr) |
| `+0x40` | `= 0x0C` (12) | Stride позиций: `sizeof(float3)` |
| `+0x44` | `= Res4_ptr` | Копия указателя normals (stream ptr) |
| `+0x48` | `= 4` | Stride нормалей: 4 байта |
| `+0x4C` | `Res16_ptr` или `0` | Stream A Res16 (tangent) |
| `+0x50` | `= 8` если `+0x4C != 0` | Stride stream A (используется только при наличии Res16) |
| `+0x54` | `Res16_ptr + 4` или `0` | Stream B Res16 (bitangent) |
| `+0x58` | `= 8` если `+0x54 != 0` | Stride stream B (используется только при наличии Res16) |
| `+0x5C` | `= Res5_ptr` | Копия указателя UV0 (stream ptr) |
| `+0x60` | `= 4` | Stride UV0: 4 байта |
| `+0x68` | `= 4` или `0` | Stride Res18 (если найден) |
| `+0x8C` | `= Res15_ptr` | Копия указателя Res15 |
| `+0x90` | `= 8` | Stride Res15: 8 байт |
| `+0x94` | `= 0` | Зарезервировано/unk94: инициализируется нулём при загрузке; не является флагом Res18 |
| `+0x9C` | NRes entry Res19 `+8` | Метаданные из каталожной записи Res19 |
| `+0xA0` | NRes entry Res20 `+4` | Метаданные из каталожной записи Res20 (заполняется только если Res20 найден и открыт, иначе 0) |
**Примечание к метаданным:** поле `+0x9C` читается из каталожной записи NRes для ресурса 19 (смещение `+8` в записи каталога, т.е. `attribute_2`). Поле `+0xA0` — из каталожной записи для ресурса 20 (смещение `+4`, т.е. `attribute_1`) **только если Res20 найден и `niOpenRes` вернул ненулевой указатель**; иначе `+0xA0 = 0`. Индекс записи определяется как `entry_index * 64`, после чего считывается поле.
---
### 1.3.1. Ссылки на функции и паттерны вызовов (для проверки реверса)
- `AniMesh.dll!sub_10015FD0` — загрузка ресурсов модели через vtable интерфейса NRes:
- `niFindRes(type, ...)` вызывается через `call [vtable+0x20]`
- `niOpenRes(...)` / чтение указателя — через `call [vtable+0x18]`
- `AniMesh.dll!sub_10015FD0` выставляет производные поля (`Res2_ptr+0x8C`, stride'ы), обнуляет `model+0x94`, и при отсутствии Res16 обнуляет только указатели потоков (`+0x4C`, `+0x54`).
- `AniMesh.dll!sub_10004840` / `sub_10004870` / `sub_100048A0` — использование runtime mappingтаблицы (`+0x18`, индекс `boneId*4`) и таблицы указателей треков (`+0x08`) после построения анимационного объекта.
## 1.4. Ресурс Res2 — Model Header (140 байт) + Slot Table
Ресурс Res2 содержит:
```
┌───────────────────────────────────┐ Смещение 0
│ Model Header (140 байт = 0x8C) │
├───────────────────────────────────┤ Смещение 140 (0x8C)
│ Slot Table │
│ (slot_count × 68 байт) │
└───────────────────────────────────┘
```
### 1.4.1. Model Header (первые 140 байт)
Поле `Res2[0x00..0x8B]` используется как **35 float** (без внутренних таблиц/индексов). Это подтверждено прямыми копированиями в `AniMesh.dll!sub_1000A460`:
- `qmemcpy(this+0x54, Res2+0x00, 0x60)` — первые 24 float;
- копирование `Res2+0x60` размером `0x10` — ещё 4 float;
- `qmemcpy(this+0x134, Res2+0x70, 0x1C)` — ещё 7 float.
Итоговая раскладка:
| Диапазон | Размер | Тип | Семантика |
|--------------|--------|-------------|----------------------------------------------------------------------|
| `0x00..0x5F` | `0x60` | `float[24]` | 8 вершин глобального boundinghull (`vec3[8]`) |
| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная boundingsphere: `center.xyz + radius` |
| `0x70..0x8B` | `0x1C` | `float[7]` | Глобальный «капсульный»/сегментный bound: `A.xyz`, `B.xyz`, `radius` |
Для рендера и broadphase движок использует как слотbounds (`Res2 slot`), так и этот глобальный набор bounds (в зависимости от контекста вызова/LOD и наличия слота).
### 1.4.2. Slot Table (массив записей по 68 байт)
Slot — ключевая структура, связывающая узел иерархии с конкретной геометрией для конкретного LOD и группы. Каждая запись — **68 байт** (0x44).
**Важно:** смещения в таблице ниже указаны в **десятичном формате** (байты). В скобках приведён hexэквивалент (например, 48 (0x30)).
| Смещение | Размер | Тип | Описание |
|-----------|--------|----------|-----------------------------------------------------|
| 0 | 2 | uint16 | `triStart` — индекс первого треугольника в Res7 |
| 2 | 2 | uint16 | `triCount` — длина диапазона треугольников (`Res7`) |
| 4 | 2 | uint16 | `batchStart` — индекс первого batch'а в Res13 |
| 6 | 2 | uint16 | `batchCount` — количество batch'ей |
| 8 | 4 | float | `aabbMin.x` |
| 12 | 4 | float | `aabbMin.y` |
| 16 | 4 | float | `aabbMin.z` |
| 20 | 4 | float | `aabbMax.x` |
| 24 | 4 | float | `aabbMax.y` |
| 28 | 4 | float | `aabbMax.z` |
| 32 | 4 | float | `sphereCenter.x` |
| 36 | 4 | float | `sphereCenter.y` |
| 40 | 4 | float | `sphereCenter.z` |
| 44 (0x2C) | 4 | float | `sphereRadius` |
| 48 (0x30) | 20 | 5×uint32 | Хвостовые поля: `unk30..unk40` (см. §1.4.2.1) |
**AABB** — axisaligned bounding box в локальных координатах узла.
**Bounding Sphere** — описанная сфера в локальных координатах узла.
#### 1.4.2.1. Точная семантика `triStart/triCount`
В `AniMesh.dll!sub_1000B2C0` слот считается «владельцем» треугольника `triId`, если:
```c
triId >= slot.triStart && triId < slot.triStart + slot.triCount
```
Это прямое доказательство, что `slot +0x02` — именно **count диапазона**, а не флаги.
#### 1.4.2.2. Хвост слота (20 байт = 5×uint32)
Последние 20 байт записи слота трактуем как 5 последовательных 32битных значений (littleendian). Их назначение пока не подтверждено; для инструментов рекомендуется сохранять и восстанавливать их «как есть».
- `+48 (0x30)`: `unk30` (uint32)
- `+52 (0x34)`: `unk34` (uint32)
- `+56 (0x38)`: `unk38` (uint32)
- `+60 (0x3C)`: `unk3C` (uint32)
- `+64 (0x40)`: `unk40` (uint32)
Для culling при рендере: AABB/sphere трансформируются матрицей узла и инстанса. При неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (подтверждено по коду).
---
### 1.4.3. Восстановление счётчиков элементов по размерам ресурсов (практика для инструментов)
Для toolchain надёжнее считать count'ы по размерам ресурсов (а не по дублирующим полям других таблиц). Это полностью совпадает с тем, как рантайм использует fixed stride'ы в `sub_10015FD0`.
Берите **unpacked_size** (или фактический размер распакованного блока) соответствующего ресурса и вычисляйте:
- `node_count` = `size(Res1) / 38`
- `vertex_count` = `size(Res3) / 12`
- `normals_count` = `size(Res4) / 4`
- `uv0_count` = `size(Res5) / 4`
- `index_count` = `size(Res6) / 2`
- `tri_count` = `index_count / 3` (если примитивы — список треугольников)
- `tri_desc_count` = `size(Res7) / 16`
- `batch_count` = `size(Res13) / 20`
- `slot_count` = `(size(Res2) - 0x8C) / 0x44`
- `anim_key_count` = `size(Res8) / 24`
- `anim_map_count` = `size(Res19) / 2`
- `uv1_count` = `size(Res15) / 8` (если Res15 присутствует)
- `tbn_count` = `size(Res16) / 8` (если Res16 присутствует; tangent/bitangent по 4 байта, stride 8)
- `color_count` = `size(Res18) / 4` (если Res18 присутствует)
**Валидация:**
- Любое деление должно быть **без остатка**; иначе ресурс повреждён или stride неверно угадан.
- Если присутствуют Res4/Res5/Res15/Res16/Res18, их count'ы по смыслу должны совпадать с `vertex_count` (или быть ≥ него, если формат допускает хвостовые данные — пока не наблюдалось).
- Для `slot_count` дополнительно проверьте, что `size(Res2) >= 0x8C`.
**Проверка на реальных данных (435 MSH):**
- `Res2.attr1 == (size-140)/68`, `Res2.attr2 == 0`, `Res2.attr3 == 68`;
- `Res7.attr1 == size/16`, `Res7.attr3 == 16`;
- `Res8.attr1 == size/24`, `Res8.attr3 == 4`;
- `Res19.attr1 == size/2`, `Res19.attr3 == 2`;
- для `Res1` почти всегда `attr3 == 38` (один служебный outlier: `MTCHECK.MSH` с `attr3 == 24`).
Эти формулы достаточны, чтобы реализовать распаковщик/просмотрщик геометрии и батчей даже без полного понимания полей заголовка Res2.
## 1.5. Ресурс Res1 — Node Table (38 байт на узел)
Node table — компактная карта слотов по уровням LOD и группам. Каждый узел занимает **38 байт** (19 × `uint16`).
### Адресация слота
Движок вычисляет индекс слова в таблице:
```
word_index = nodeIndex × 19 + lod × 5 + group + 4
slot_index = node_table[word_index] // uint16, 0xFFFF = нет слота
```
Параметры:
- `lod`: 0..2 (три уровня детализации). Значение `1` → подставляется `current_lod` из инстанса.
- `group`: 0..4 (пять групп). На практике чаще всего используется `group = 0`.
### Раскладка записи узла (38 байт)
```
┌───────────────────────────────────────────────────────┐
│ Header: 4 × uint16 (8 байт) │
│ hdr0, hdr1, hdr2, hdr3 │
├───────────────────────────────────────────────────────┤
│ SlotIndex matrix: 3 LOD × 5 groups = 15 × uint16 │
│ LOD 0: group[0..4] │
│ LOD 1: group[0..4] │
│ LOD 2: group[0..4] │
└───────────────────────────────────────────────────────┘
```
| Смещение | Размер | Тип | Описание |
|----------|--------|------------|-----------------------------------------|
| 0 | 8 | uint16[4] | Заголовок узла (`hdr0..hdr3`, см. ниже) |
| 8 | 30 | uint16[15] | Матрица слотов: `slotIndex[lod][group]` |
`slotIndex = 0xFFFF` означает «слот отсутствует» — узел при данном LOD и группе не рисуется.
Подтверждённые семантики полей `hdr*`:
- `hdr1` (`+0x02`) — parent/index-link при построении инстанса (в `sub_1000A460` читается как индекс связанного узла, `0xFFFF` = нет связи).
- `hdr2` (`+0x04`) — `mapStart` для Res19 (`0xFFFF` = нет карты; fallback по `hdr3`).
- `hdr3` (`+0x06`) — `fallbackKeyIndex`/верхняя граница для mapзначений (используется в `sub_10012880`).
`hdr0` (`+0x00`) по коду участвует в битовых проверках (`&0x40`, `byte+1 & 8`) и несёт флаги узла.
**Группы (group 0..4):** в рантайме это ортогональный индекс к LOD (матрица 5×3 на узел). Имена групп в оригинальных ресурсах не подписаны; для 1:1 нужно сохранять группы как «сырой» индекс 0..4 без переинтерпретации.
---
## 1.6. Ресурс Res3 — Vertex Positions
**Формат:** массив `float3` (IEEE 754 singleprecision).
**Stride:** 12 байт.
```c
struct Position {
float x; // +0
float y; // +4
float z; // +8
};
```
Чтение: `pos = *(float3*)(res3_data + 12 * vertexIndex)`.
---
## 1.7. Ресурс Res4 — Packed Normals
**Формат:** 4 байта на вершину.
**Stride:** 4 байта.
```c
struct PackedNormal {
int8_t nx; // +0
int8_t ny; // +1
int8_t nz; // +2
int8_t nw; // +3 (назначение не подтверждено: паддинг / знак / индекс)
};
```
### Алгоритм декодирования (подтверждено по AniMesh.dll)
> В движке используется делитель **127.0**, а не 128.0 (см. константу `127.0` рядом с `1024.0`/`32767.0`).
```
normal.x = clamp((float)nx / 127.0, -1.0, 1.0)
normal.y = clamp((float)ny / 127.0, -1.0, 1.0)
normal.z = clamp((float)nz / 127.0, -1.0, 1.0)
```
**Множитель:** `1.0 / 127.0 ≈ 0.0078740157`.
**Диапазон входных значений:** 128..+127 → выход ≈ 1.007874..+1.0 → **после клампа** 1.0..+1.0.
**Почему нужен кламп:** значение `-128` при делении на `127.0` даёт модуль чуть больше 1.
**4й байт (nw):** используется ли он как часть нормали, как индекс или просто как выравнивание — не подтверждено. Рекомендация: игнорировать при первичном импорте.
---
## 1.8. Ресурс Res5 — Packed UV0
**Формат:** 4 байта на вершину (два `int16`).
**Stride:** 4 байта.
```c
struct PackedUV {
int16_t u; // +0
int16_t v; // +2
};
```
### Алгоритм декодирования
```
uv.u = (float)u / 1024.0
uv.v = (float)v / 1024.0
```
**Множитель:** `1.0 / 1024.0 = 0.0009765625`.
**Диапазон входных значений:** 32768..+32767 → выход ≈ 32.0..+31.999.
Значения >1.0 или <0.0 означают wrapping/repeat текстурных координат.
### Алгоритм кодирования (для экспортёра)
```
packed_u = (int16_t)round(uv.u * 1024.0)
packed_v = (int16_t)round(uv.v * 1024.0)
```
Результат обрезается (clamp) до диапазона `int16` (32768..+32767).
---
## 1.9. Ресурс Res6 — Index Buffer
**Формат:** массив `uint16` (беззнаковые 16битные индексы).
**Stride:** 2 байта.
Максимальное число вершин в одном batch: 65535.
Индексы используются совместно с `baseVertex` из batch table:
```
actual_vertex_index = index_buffer[indexStart + i] + baseVertex
```
---
## 1.10. Ресурс Res7 — Triangle Descriptors
**Формат:** массив записей по 16 байт. Одна запись на треугольник.
| Смещение | Размер | Тип | Описание |
|----------|--------|----------|---------------------------------------------|
| `+0x00` | 2 | `uint16` | `triFlags` — фильтрация/материал triуровня |
| `+0x02` | 2 | `uint16` | `linkTri0` — triref для связанного обхода |
| `+0x04` | 2 | `uint16` | `linkTri1` — triref для связанного обхода |
| `+0x06` | 2 | `uint16` | `linkTri2` — triref для связанного обхода |
| `+0x08` | 2 | `int16` | `nX` (packed, scale `1/32767`) |
| `+0x0A` | 2 | `int16` | `nY` (packed, scale `1/32767`) |
| `+0x0C` | 2 | `int16` | `nZ` (packed, scale `1/32767`) |
| `+0x0E` | 2 | `uint16` | `selPacked` — 3 селектора по 2 бита |
Расшифровка `selPacked` (`AniMesh.dll!sub_10013680`):
```c
sel0 = selPacked & 0x3; if (sel0 == 3) sel0 = 0xFFFF;
sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF;
sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF;
```
`linkTri*` передаются в `sub_1000B2C0` и используются для построения соседнего набора треугольников при коллизии/пикинге.
**Важно:** дескрипторы не хранят индексы вершин треугольника. Индексы берутся из Res6 (index buffer) через `indexStart`/`indexCount` соответствующего batch'а.
Дескрипторы используются при обходе треугольников для коллизии и пикинга. `triStart` из slot table указывает, с какого дескриптора начинать обход для данного слота.
---
## 1.11. Ресурс Res13 — Batch Table
**Формат:** массив записей по 20 байт. Batch — минимальная единица отрисовки.
| Смещение | Размер | Тип | Описание |
|----------|--------|--------|---------------------------------------------------------|
| 0 | 2 | uint16 | `batchFlags` — битовая маска для фильтрации |
| 2 | 2 | uint16 | `materialIndex` — индекс материала |
| 4 | 2 | uint16 | `unk4` — неподтверждённое поле |
| 6 | 2 | uint16 | `unk6` — вероятный `nodeIndex` (привязка batch к кости) |
| 8 | 2 | uint16 | `indexCount` — число индексов (кратно 3) |
| 10 | 4 | uint32 | `indexStart` — стартовый индекс в Res6 (в элементах) |
| 14 | 2 | uint16 | `unk14` — неподтверждённое поле |
| 16 | 4 | uint32 | `baseVertex` — смещение вершинного индекса |
### Использование при рендере
```
for i in 0 .. indexCount-1:
raw_index = index_buffer[indexStart + i]
vertex_index = raw_index + baseVertex
position = res3[vertex_index]
normal = decode_normal(res4[vertex_index])
uv = decode_uv(res5[vertex_index])
```
**Примечание:** движок читает `indexStart` как `uint32` и умножает на 2 для получения байтового смещения в массиве `uint16`.
---
## 1.12. Ресурс Res10 — String Table
Res10 — это **последовательность записей, индексируемых по `nodeIndex`** (см. `AniMesh.dll!sub_10012530`).
Формат одной записи:
```c
struct Res10Record {
uint32_t len; // число символов без терминирующего '\0'
char text[]; // если len > 0: хранится len+1 байт (включая '\0')
// если len == 0: payload отсутствует
};
```
Переход к следующей записи:
```c
next = cur + 4 + (len ? (len + 1) : 0);
```
`sub_10012530` возвращает:
- `NULL`, если `len == 0`;
- `record + 4`, если `len > 0` (указатель на Cстроку).
Это значение используется в `sub_1000A460` для проверки имени текущего узла (например, поиск подстроки `"central"` при обработке nodeфлагов).
---
---
## 1.14. Опциональные vertex streams
### Res15 — Дополнительный vertex stream (stride 8)
- **Stride:** 8 байт на вершину.
- **Кандидаты:** `float2 uv1` (lightmap / second UV layer), 4 × `int16` (2 UVпары), либо иной формат.
- Загружается условно — если ресурс 15 отсутствует, указатель равен `NULL`.
### Res16 — Tangent / Bitangent (stride 8, split 2×4)
- **Stride:** 8 байт на вершину (2 подпотока по 4 байта).
- При загрузке движок создаёт **два перемежающихся (interleaved) подпотока**:
- Stream A: `base + 0`, stride 8 — 4 байта (кандидат: packed tangent, `int8 × 4`)
- Stream B: `base + 4`, stride 8 — 4 байта (кандидат: packed bitangent, `int8 × 4`)
- Если ресурс 16 отсутствует, оба указателя обнуляются.
- **Важно:** в оригинальном `sub_10015FD0` при отсутствии Res16 страйды `+0x50/+0x58` явным образом не обнуляются; это безопасно, потому что оба указателя равны `NULL` и код не должен обращаться к потокам без проверки указателя.
- Декодирование предположительно аналогично нормалям: `component / 127.0` (как Res4), но требует подтверждения; при импорте — кламп в [-1..1].
### Res18 — Vertex Color (stride 4)
- **Stride:** 4 байта на вершину.
- **Кандидаты:** `D3DCOLOR` (BGRA), packed параметры освещения, vertex AO.
- Загружается условно (через проверку `niFindRes` на возврат `1`).
### Res20 — Дополнительная таблица
- Присутствует не всегда.
- Из каталожной записи NRes считывается поле `attribute_1` (смещение `+4`) и сохраняется как метаданные.
- **Кандидаты:** vertex remap, дополнительные данные для эффектов/деформаций.
---