2026-02-11 21:12:05 +00:00
# MSH animation
2026-02-12 10:36:15 +00:00
Документ фиксирует анимационную часть формата MSH (`Res8` , `Res19` ) и runtime-алгоритм сэмплирования/смешивания, необходимый для 1:1 совместимого движка и toolchain (reader/writer/converter/editor).
Связанные документы:
- [MSH core ](msh-core.md ) — общая структура модели и `Res1` /`Res2` .
- [NRes / RsLi ](nres.md ) — контейнер и атрибуты записей.
---
## 1. Область и источники
Спецификация основана на:
- `tmp/disassembler1/AniMesh.dll.c` (псевдо-C): `sub_10015FD0` , `sub_10012880` , `sub_10012560` .
- `tmp/disassembler2/AniMesh.dll.asm` (ASM): подтверждение x87-пути (`FISTP` ) и ветвлений.
- валидации corpus (`testdata` ): 435 моделей `*.msh` .
Ниже разделено на:
- **Нормативно**: обязательно для runtime-совместимости.
- **Канонично**: как устроены исходные ассеты; важно для детерминированного writer/editor.
2026-02-11 21:12:05 +00:00
---
2026-02-12 10:36:15 +00:00
## 2. Ресурсы и поля модели
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
### 2.1. Res8 — key pool (нормативно)
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
`Res8` — массив ключей фиксированного шага 24 байта.
2026-02-11 21:12:05 +00:00
```c
struct AnimKey24 {
2026-02-12 10:36:15 +00:00
float pos_x; // +0x00
float pos_y; // +0x04
float pos_z; // +0x08
float time; // +0x0C
int16_t qx; // +0x10
int16_t qy; // +0x12
int16_t qz; // +0x14
int16_t qw; // +0x16
2026-02-11 21:12:05 +00:00
};
```
Декодирование quaternion-компонент:
```c
2026-02-12 10:36:15 +00:00
float q = (float)s16 * (1.0f / 32767.0f);
2026-02-11 21:12:05 +00:00
```
2026-02-12 10:36:15 +00:00
Атрибуты NRes:
- `attr1 = size / 24` (количество ключей).
- `attr2 = 0` (в observed corpus).
- `attr3 = 4` (не stride; это фактический runtime-инвариант формата).
### 2.2. Res19 — frame->segment map (нормативно)
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
`Res19` — непрерывный `uint16` массив:
2026-02-11 21:12:05 +00:00
```c
2026-02-12 10:36:15 +00:00
uint16_t map_words[]; // count = size / 2
2026-02-11 21:12:05 +00:00
```
2026-02-12 10:36:15 +00:00
Атрибуты NRes:
- `attr1 = size / 2` (число `uint16` слов).
- `attr2 = animFrameCount` (глобальная длина таймлайна модели в кадрах).
- `attr3 = 2` .
### 2.3. Связь с Res1 node header (нормативно)
Для `Res1` с о stride 38 (основной формат):
- `hdr2` (`node + 0x04` ) = `mapStart` (`0xFFFF` => map для узла отсутствует).
- `hdr3` (`node + 0x06` ) = `fallbackKeyIndex` (индекс ключа в `Res8` ).
Runtime читает эти поля напрямую в `sub_10012880` .
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
### 2.4. Поля runtime-модели, задействованные анимацией (нормативно)
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
Инициализация в `sub_10015FD0` :
- `model+0x18` -> `Res8` pointer.
- `model+0x1C` -> `Res19` pointer.
- `model+0x9C` <- `NResEntry(Res19).attr2` (`animFrameCount` ).
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
---
## 3. Runtime-сэмплирование узла (`sub_10012880`)
Функция возвращает:
- quaternion (4 float) в буфер `outQuat` ,
- позицию (3 float) в `outPos` .
Вход:
- `t` — sample time.
- текущий `nodeIndex` берётся из runtime-объекта (не из аргумента).
### 3.1. Вычисление frame index (нормативно)
Алгоритм:
1. `x = t - 0.5` .
2. `frame = x87 FISTP(x)` (через 64-битный промежуточный буфер).
Важно:
- это не «просто floor»;
- поведение зависит от x87 rounding mode (в игре используется стандартный control word).
Если нужен byte/behavior 1:1, надо повторить именно x87-ветку или её точный эквивалент.
### 3.2. Выбор `keyIndex` (нормативно)
2026-02-11 21:12:05 +00:00
```c
2026-02-12 10:36:15 +00:00
node = Res1 + nodeIndex * 38;
mapStart = u16(node + 4); // hdr2
fallback = u16(node + 6); // hdr3
if ((uint32_t)frame >= animFrameCount
|| mapStart == 0xFFFF
|| map_words[mapStart + (uint32_t)frame] >= fallback) {
keyIndex = fallback;
} else {
keyIndex = map_words[mapStart + (uint32_t)frame];
}
2026-02-11 21:12:05 +00:00
```
2026-02-12 10:36:15 +00:00
Критично:
- runtime не проверяет bounds у `fallback` и `mapStart + frame` ; некорректные данные приводят к OOB.
### 3.3. Сэмплирование ключей (нормативно)
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
`k0 = Res8[keyIndex]` .
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
Ветки:
1. fallback-ветка из п.3.2: возвращается строго `k0` (без `k1` ).
2. map-ветка:
- если `t == k0.time` -> вернуть `k0` ;
- иначе берётся `k1 = Res8[keyIndex + 1]` ;
- если `t == k1.time` -> вернуть `k1` ;
- иначе:
- `alpha = (t - k0.time) / (k1.time - k0.time)` ;
- `pos = lerp(k0.pos, k1.pos, alpha)` ;
- `quat = fastproc_interp(k0.quat, k1.quat, alpha)` (`g_FastProc[17]` ).
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
Сравнение `t == key.time` строгое (битовая float-эквивалентность по FPU compare), без epsilon.
---
## 4. Runtime-смешивание двух сэмплов (`sub_10012560`)
`sub_10012560(this, tA, tB, blend, outMatrix4x4)` смешивает две позы.
### 4.1. Валидация входов (нормативно)
Выбор доступных сэмплов:
- `hasA = (blend < 1.0f) && (tA >= 0.0f)` .
- `hasB = (blend > 0.0f) && (tB >= 0.0f)` .
Ветки:
- только `hasA` : матрица из A.
- только `hasB` : матрица из B.
- о б а : полноценное смешивание.
- ни одного: в оригинале путь не защищён (caller contract).
### 4.2. Смешивание quaternion (нормативно)
Перед интерполяцией выполняется shortest-path flip:
```c
if (|qA + qB|^2 < |qA - qB|^2) {
qB = -qB;
}
```
Далее:
- `q = fastproc_blend(qA, qB, blend)` (`g_FastProc[22]` );
- `outMatrix = quat_to_matrix(q)` (`g_FastProc[14]` ).
### 4.3. Смешивание translation (нормативно)
Позиция смешивается отдельно:
2026-02-11 21:12:05 +00:00
```c
2026-02-12 10:36:15 +00:00
pos = (1-blend) * posA + blend * posB;
outMatrix[3] = pos.x;
outMatrix[7] = pos.y;
outMatrix[11] = pos.z;
2026-02-11 21:12:05 +00:00
```
2026-02-12 10:36:15 +00:00
(`sub_1000B8E0` подтверждает, что используются именно эти ячейки).
Reference pseudocode:
2026-02-11 21:12:05 +00:00
```c
2026-02-12 10:36:15 +00:00
void blend_pose(Model *m, float tA, float tB, float blend, float out_m[16]) {
bool hasA = (blend < 1.0f) && (tA >= 0.0f);
bool hasB = (blend > 0.0f) && (tB >= 0.0f);
float qA[4], qB[4], pA[3], pB[3];
if (hasA) sample_node_pose(m, m->node_index, tA, qA, pA);
if (hasB) sample_node_pose(m, m->node_index, tB, qB, pB);
if (hasA && !hasB) { quat_to_matrix(qA, out_m); set_translation(out_m, pA); return; }
if (!hasA && hasB) { quat_to_matrix(qB, out_m); set_translation(out_m, pB); return; }
// !hasA && !hasB: undefined by design, caller does not use this path.
if (dot4(qA + qB, qA + qB) < dot4(qA - qB, qA - qB)) negate4(qB);
float q[4];
fastproc_quat_blend(qA, qB, blend, q); // g_FastProc[22]
quat_to_matrix(q, out_m); // g_FastProc[14]
float p[3];
p[0] = (1.0f - blend) * pA[0] + blend * pB[0];
p[1] = (1.0f - blend) * pA[1] + blend * pB[1];
p[2] = (1.0f - blend) * pA[2] + blend * pB[2];
out_m[3] = p[0];
out_m[7] = p[1];
out_m[11] = p[2];
}
2026-02-11 21:12:05 +00:00
```
2026-02-12 10:36:15 +00:00
---
## 5. Каноническая модель данных для toolchain
Ниже правила, по которым удобно строить editor/writer. Они верифицированы на corpus (435 моделей), и совпадают с тем, как устроены оригинальные ассеты.
### 5.1. Декомпозиция key pool на track-и узлов (канонично)
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
Для `Res1` stride 38:
- `fallback_i = node[i].hdr3` .
- `start_i = (i == 0) ? 0 : (fallback_{i-1} + 1)` .
- track узла `i` = `Res8[start_i .. fallback_i]` .
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
Наблюдаемые инварианты:
- `fallback_i` строго возрастает по `i` .
- track всегда непустой (`fallback_i >= start_i` ).
- для узлов без map (`hdr2 == 0xFFFF` ) track длиной ровно 1 ключ.
- для узлов с map track длиной минимум 2 ключа.
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
### 5.2. Временная ось ключей (канонично)
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
В observed corpus:
- `time` всех ключей — целые неотрицательные float (`0.0, 1.0, ...` ).
- внутри track: строго возрастают.
- `time(start_i) == 0.0` у каждого узла.
- глобальный `Res19.attr2 == max_i(time(fallback_i)) + 1` .
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
### 5.3. Компоновка Res19 map-блоков (канонично)
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
Если `Res19.size > 0` :
- map-блоки есть только у узлов с `hdr2 != 0xFFFF` ;
- длина блока каждого такого узла: `frameCount = Res19.attr2` ;
- блоки идут подряд, без дыр и overlap;
- итог: `Res19.attr1 == animated_node_count * frameCount` .
Если модель статическая:
- `Res19.size == 0` , `Res19.attr1 == 0` , `Res19.attr2 == 1` , `Res19.attr3 == 2` ;
- у всех узлов `hdr2 == 0xFFFF` .
### 5.4. Семантика `map_words[f]` в каноничном writer
Для кадра `f` и track `keys[start..end]` :
- если `f < keys[start].time` или `f >= keys[end].time` -> писать `fallback = end` ;
- иначе писать индекс левого ключа сегмента (`start <= idx < end` ) такого, что:
- `keys[idx].time <= f < keys[idx+1].time` .
В исходных данных fallback-фреймы кодируются значением `== fallback` (не просто `>= fallback` ).
---
## 6. Reference IR для редактора/конвертера
Рекомендуемое промежуточное представление:
2026-02-11 21:12:05 +00:00
```c
2026-02-12 10:36:15 +00:00
struct NodeAnimTrack {
uint32_t node_index;
bool has_map; // hdr2 != 0xFFFF
uint16_t fallback_key; // hdr3 (derived on write)
vector<AnimKey> keys; // local keys for node
vector<uint16_t> frame_map; // optional, size == frame_count when has_map
};
struct AnimModel {
uint32_t frame_count; // Res19.attr2
vector<NodeAnimTrack> tracks; // in node order
};
2026-02-11 21:12:05 +00:00
```
2026-02-12 10:36:15 +00:00
Где `AnimKey` :
- `pos: float3` ,
- `time: float` ,
- `quat_raw: int16[4]` (для lossless),
- `quat_decoded: float4` (опционально для API/UI).
---
## 7. Алгоритм чтения (reader)
1. Загрузить `Res1` , `Res8` , `Res19` .
2. Проверить `Res8.size % 24 == 0` , `Res19.size % 2 == 0` .
3. Для каждого узла `i` (stride 38):
- взять `hdr2/hdr3` ;
- вычислить `start_i` через предыдущий `hdr3` ;
- извлечь `keys[start_i..hdr3]` ;
- если `hdr2 != 0xFFFF` , взять `frame_map = Res19[hdr2 : hdr2 + frame_count]` .
4. Валидировать, что map-значения либо `< hdr3` , либо fallback (`== hdr3` канонично).
---
## 8. Алгоритм записи (writer)
Нормативный минимум для runtime-совместимости:
1. Собрать keys всех узлов в один `Res8` pool в node-order.
2. Записать `hdr3 = end_index` каждого узла.
3. Вычислить `frame_count` и записать в `Res19.attr2` .
4. Для узлов с map:
- `hdr2 = cursor` ;
- append `frame_count` слов в `Res19` ;
- `cursor += frame_count` .
5. Для узлов без map: `hdr2 = 0xFFFF` .
6. Выставить атрибуты:
- `Res8.attr1 = key_count` , `Res8.attr2 = 0` , `Res8.attr3 = 4` ;
- `Res19.attr1 = map_word_count` , `Res19.attr3 = 2` .
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
Каноничный writer (рекомендуется):
- генерирует map по правилу §5.4;
- fallback-фреймы записывает `== fallback` ;
- для статических узлов использует 1 ключ (`time=0` , `hdr2=0xFFFF` ).
2026-02-11 21:12:05 +00:00
2026-02-12 10:36:15 +00:00
---
## 9. Валидация перед сохранением
Обязательные проверки:
1. `Res8.size % 24 == 0` , `Res19.size % 2 == 0` .
2. Для каждого узла: `fallbackKeyIndex < key_count` .
3. Если `hdr2 != 0xFFFF` : `hdr2 + frame_count <= map_word_count` .
4. Для map-сегмента узла:
- любое значение `< fallback` должно удовлетворять `value + 1 < key_count` .
5. В track узла:
- `time` строго возрастает;
- при наличии map минимум 2 ключа.
6. `frame_count > 0` (игровые ассеты используют минимум 1).
Рекомендуемые проверки (каноничность):
1. `fallback_i` строго возрастает по узлам.
2. track каждого узла начинается с `time == 0` .
3. `frame_count == max_end_time + 1` .
4. map-блоки узлов без дыр/overlap.
---
## 10. Edge cases и совместимость
### 10.1. `Res19.size == 0`
Поддерживается runtime-ом:
- `frame_count` обычно 1;
- `hdr2 == 0xFFFF` у всех узлов;
- сэмплирование всегда через fallback key (`hdr3` ).
### 10.2. Узлы без map
Это нормальный режим для статических/квазистатических узлов:
- `hdr2 = 0xFFFF` ;
- `hdr3` указывает на единственный ключ узла (канонично).
### 10.3. `Res1.attr3 == 24` (legacy outlier)
В corpus встречается единично (`MTCHECK.MSH` ).
Алгоритм из `sub_10012880` адресует node как stride 38, поэтому этот вариант нужно трактовать как отдельный legacy-формат и не применять к нему правила `hdr2/hdr3` из данного документа без дополнительного реверса.
### 10.4. Квантование quaternion при экспорте
Для новых данных:
- используйте `round(q * 32767)` ;
- clamp к `[-32767, 32767]` (каноничный диапазон ассетов).
2026-02-11 21:12:05 +00:00
---
2026-02-12 10:36:15 +00:00
## 11. Reference pseudocode (1:1 runtime path)
```c
void sample_node_pose(Model *m, int node_idx, float t, float out_quat[4], float out_pos[3]) {
Node38 * node = (Node38 * )((uint8_t * )m->res1 + node_idx * 38);
uint16_t map_start = node->hdr2;
uint16_t fallback = node->hdr3;
uint32_t frame_cnt = m->anim_frame_count; // Res19.attr2
int32_t frame = x87_fistp_i32((double)t - 0.5); // strict path
uint16_t key_idx;
if ((uint32_t)frame >= frame_cnt ||
map_start == 0xFFFF ||
m->res19[map_start + (uint32_t)frame] >= fallback) {
key_idx = fallback;
decode_key_quat_pos(&m->res8[key_idx], out_quat, out_pos);
return;
}
key_idx = m->res19[map_start + (uint32_t)frame];
AnimKey24 *k0 = &m->res8[key_idx];
if (t == k0->time) {
decode_key_quat_pos(k0, out_quat, out_pos);
return;
}
AnimKey24 *k1 = &m->res8[key_idx + 1];
if (t == k1->time) {
decode_key_quat_pos(k1, out_quat, out_pos);
return;
}
float a = (t - k0->time) / (k1->time - k0->time);
out_pos[0] = lerp(k0->pos_x, k1->pos_x, a);
out_pos[1] = lerp(k0->pos_y, k1->pos_y, a);
out_pos[2] = lerp(k0->pos_z, k1->pos_z, a);
fastproc_quat_interp(decode_quat(k0), decode_quat(k1), a, out_quat); // g_FastProc[17]
}
```