15 KiB
MSH animation
Документ фиксирует анимационную часть формата MSH (Res8, Res19) и runtime-алгоритм сэмплирования/смешивания, необходимый для 1:1 совместимого движка и toolchain (reader/writer/converter/editor).
Связанные документы:
- MSH core — общая структура модели и
Res1/Res2. - NRes / RsLi — контейнер и атрибуты записей.
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 байта.
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-компонент:
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 массив:
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->Res8pointer.model+0x1C->Res19pointer.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 (нормативно)
Алгоритм:
x = t - 0.5.frame = x87 FISTP(x)(через 64-битный промежуточный буфер).
Важно:
- это не «просто floor»;
- поведение зависит от x87 rounding mode (в игре используется стандартный control word).
Если нужен byte/behavior 1:1, надо повторить именно x87-ветку или её точный эквивалент.
3.2. Выбор keyIndex (нормативно)
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].
Ветки:
- fallback-ветка из п.3.2: возвращается строго
k0(безk1). - 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:
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 (нормативно)
Позиция смешивается отдельно:
pos = (1-blend) * posA + blend * posB;
outMatrix[3] = pos.x;
outMatrix[7] = pos.y;
outMatrix[11] = pos.z;
(sub_1000B8E0 подтверждает, что используются именно эти ячейки).
Reference pseudocode:
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 для редактора/конвертера
Рекомендуемое промежуточное представление:
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)
- Загрузить
Res1,Res8,Res19. - Проверить
Res8.size % 24 == 0,Res19.size % 2 == 0. - Для каждого узла
i(stride 38):- взять
hdr2/hdr3; - вычислить
start_iчерез предыдущийhdr3; - извлечь
keys[start_i..hdr3]; - если
hdr2 != 0xFFFF, взятьframe_map = Res19[hdr2 : hdr2 + frame_count].
- взять
- Валидировать, что map-значения либо
< hdr3, либо fallback (== hdr3канонично).
8. Алгоритм записи (writer)
Нормативный минимум для runtime-совместимости:
- Собрать keys всех узлов в один
Res8pool в node-order. - Записать
hdr3 = end_indexкаждого узла. - Вычислить
frame_countи записать вRes19.attr2. - Для узлов с map:
hdr2 = cursor;- append
frame_countслов вRes19; cursor += frame_count.
- Для узлов без map:
hdr2 = 0xFFFF. - Выставить атрибуты:
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. Валидация перед сохранением
Обязательные проверки:
Res8.size % 24 == 0,Res19.size % 2 == 0.- Для каждого узла:
fallbackKeyIndex < key_count. - Если
hdr2 != 0xFFFF:hdr2 + frame_count <= map_word_count. - Для map-сегмента узла:
- любое значение
< fallbackдолжно удовлетворятьvalue + 1 < key_count.
- любое значение
- В track узла:
timeстрого возрастает;- при наличии map минимум 2 ключа.
frame_count > 0(игровые ассеты используют минимум 1).
Рекомендуемые проверки (каноничность):
fallback_iстрого возрастает по узлам.- track каждого узла начинается с
time == 0. frame_count == max_end_time + 1.- 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)
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]
}