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

421 lines
15 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 animation
Документ фиксирует анимационную часть формата 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.
---
## 2. Ресурсы и поля модели
### 2.1. Res8 — key pool (нормативно)
`Res8` — массив ключей фиксированного шага 24 байта.
```c
struct AnimKey24 {
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
};
```
Декодирование quaternion-компонент:
```c
float q = (float)s16 * (1.0f / 32767.0f);
```
Атрибуты NRes:
- `attr1 = size / 24` (количество ключей).
- `attr2 = 0` (в observed corpus).
- `attr3 = 4` (не stride; это фактический runtime-инвариант формата).
### 2.2. Res19 — frame->segment map (нормативно)
`Res19` — непрерывный `uint16` массив:
```c
uint16_t map_words[]; // count = size / 2
```
Атрибуты 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`.
### 2.4. Поля runtime-модели, задействованные анимацией (нормативно)
Инициализация в `sub_10015FD0`:
- `model+0x18` -> `Res8` pointer.
- `model+0x1C` -> `Res19` pointer.
- `model+0x9C` <- `NResEntry(Res19).attr2` (`animFrameCount`).
---
## 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` (нормативно)
```c
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];
}
```
Критично:
- runtime не проверяет bounds у `fallback` и `mapStart + frame`; некорректные данные приводят к OOB.
### 3.3. Сэмплирование ключей (нормативно)
`k0 = Res8[keyIndex]`.
Ветки:
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]`).
Сравнение `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 (нормативно)
Позиция смешивается отдельно:
```c
pos = (1-blend) * posA + blend * posB;
outMatrix[3] = pos.x;
outMatrix[7] = pos.y;
outMatrix[11] = pos.z;
```
(`sub_1000B8E0` подтверждает, что используются именно эти ячейки).
Reference pseudocode:
```c
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];
}
```
---
## 5. Каноническая модель данных для toolchain
Ниже правила, по которым удобно строить editor/writer. Они верифицированы на corpus (435 моделей), и совпадают с тем, как устроены оригинальные ассеты.
### 5.1. Декомпозиция key pool на track-и узлов (канонично)
Для `Res1` stride 38:
- `fallback_i = node[i].hdr3`.
- `start_i = (i == 0) ? 0 : (fallback_{i-1} + 1)`.
- track узла `i` = `Res8[start_i .. fallback_i]`.
Наблюдаемые инварианты:
- `fallback_i` строго возрастает по `i`.
- track всегда непустой (`fallback_i >= start_i`).
- для узлов без map (`hdr2 == 0xFFFF`) track длиной ровно 1 ключ.
- для узлов с map track длиной минимум 2 ключа.
### 5.2. Временная ось ключей (канонично)
В observed corpus:
- `time` всех ключей — целые неотрицательные float (`0.0, 1.0, ...`).
- внутри track: строго возрастают.
- `time(start_i) == 0.0` у каждого узла.
- глобальный `Res19.attr2 == max_i(time(fallback_i)) + 1`.
### 5.3. Компоновка Res19 map-блоков (канонично)
Если `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 для редактора/конвертера
Рекомендуемое промежуточное представление:
```c
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
};
```
Где `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`.
Каноничный writer (рекомендуется):
- генерирует map по правилу §5.4;
- fallback-фреймы записывает `== fallback`;
- для статических узлов использует 1 ключ (`time=0`, `hdr2=0xFFFF`).
---
## 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]` (каноничный диапазон ассетов).
---
## 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]
}
```