diff options
Diffstat (limited to 'docs/specs/msh-animation.md')
| -rw-r--r-- | docs/specs/msh-animation.md | 545 |
1 files changed, 70 insertions, 475 deletions
diff --git a/docs/specs/msh-animation.md b/docs/specs/msh-animation.md index ccfac35..8aa2796 100644 --- a/docs/specs/msh-animation.md +++ b/docs/specs/msh-animation.md @@ -1,517 +1,112 @@ # MSH animation -Документ фиксирует анимационную часть формата MSH (`Res8`, `Res19`) и runtime-алгоритм сэмплирования/смешивания, необходимый для 1:1 совместимого движка и toolchain (reader/writer/converter/editor). +`MSH animation` описывает связку `Res8 + Res19` и runtime-правила сэмплирования/смешивания поз. -Связанные документы: -- [MSH core](msh-core.md) — общая структура модели и `Res1`/`Res2`. -- [NRes / RsLi](nres.md) — контейнер и атрибуты записей. +Связанные страницы: ---- +- [MSH core](msh-core.md) +- [Render pipeline](render.md) -## 1. Область и источники +## 1. Ресурсы анимации -Спецификация основана на: -- `tmp/disassembler1/AniMesh.dll.c` (псевдо-C): `sub_10015FD0`, `sub_10012880`, `sub_10012560`. -- `tmp/disassembler2/AniMesh.dll.asm` (ASM): подтверждение x87-пути (`FISTP`) и ветвлений. -- `tmp/disassembler1/Ngi32.dll.c` (псевдо-C): `sub_10002F90`, `sub_10014540`, `sub_10014630`, `sub_10015D80`, `sub_10017E60`, `sub_10017F50`, `sub_10006D00`, `niGetProcAddress`. -- `tmp/disassembler2/Ngi32.dll.asm` (ASM): подтверждение таблицы `g_FastProc` и FPU control-word setup. -- валидации corpus (`testdata`): 435 моделей `*.msh`. - -Ниже разделено на: -- **Нормативно**: обязательно для runtime-совместимости. -- **Канонично**: как устроены исходные ассеты; важно для детерминированного writer/editor. - ---- - -## 2. Ресурсы и поля модели - -### 2.1. Res8 — key pool (нормативно) - -`Res8` — массив ключей фиксированного шага 24 байта. +### 1.1. `Res8` (пул ключей) ```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 + float pos_x; + float pos_y; + float pos_z; + float time; + int16_t qx; + int16_t qy; + int16_t qz; + int16_t qw; }; ``` -Декодирование 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 control word. - -В оригинальном runtime control word приводится к каноничному виду в `Ngi32::sub_10006D00`: -- `cw = (cw & 0xF0FF) | 0x003F`; -- это даёт `round-to-nearest` (RC=00), precision control `PC=00` и маскирование x87-исключений. - -Если нужен 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. - -### 3.4. Порядок quaternion-компонент в runtime (нормативно) - -В `Res8` компоненты лежат как `qx,qy,qz,qw`, но в runtime-буферы они попадают в порядке: -- `outQuat[0] = qw`; -- `outQuat[1] = qx`; -- `outQuat[2] = qy`; -- `outQuat[3] = qz`. - -То есть все `g_FastProc`-пути в анимации работают с quaternion в порядке `float4 = [w, x, y, z]`. - ---- - -## 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` подтверждает, что используются именно эти ячейки). - -### 4.4. Точные `g_FastProc[14/17/22]` (нормативно) - -`niGetProcAddress(i)` в `Ngi32` возвращает `g_FastProc[i]` (таблица function pointers). -В `AniMesh` используются: -- `call [g_FastProc + 0x38]` -> index 14 -> `quat_to_matrix`. -- `call [g_FastProc + 0x44]` -> index 17 -> `quat_interp`. -- `call [g_FastProc + 0x58]` -> index 22 -> `quat_blend`. - -Связь с символами `Ngi32` (по адресам таблицы): -- `g_FastProc` base = `0x1003A058`; -- index 14 -> `0x1003A090`; -- index 17 -> `0x1003A09C`; -- index 22 -> `0x1003A0B0`. - -Назначения по CPU-веткам (`sub_10002F90`) и семантика: -- scalar path: `14=sub_10017E60` (или `sub_10014540`), `17=22=sub_10017F50` (или `sub_10014630`); -- SIMD path (`dword_1003A168`): `14=sub_1001D830`, `17=22=sub_10015D80`; -- все варианты эквивалентны по математике. - -Точная формула `quat_to_matrix` для `q=[w,x,y,z]`: - -```c -m[0] = 1 - 2*(y*y + z*z); -m[1] = 2*(x*y + w*z); -m[2] = 2*(x*z - w*y); -m[3] = 0; - -m[4] = 2*(x*y - w*z); -m[5] = 1 - 2*(x*x + z*z); -m[6] = 2*(y*z + w*x); -m[7] = 0; - -m[8] = 2*(x*z + w*y); -m[9] = 2*(y*z - w*x); -m[10] = 1 - 2*(x*x + y*y); -m[11] = 0; - -m[12] = 0; -m[13] = 0; -m[14] = 0; -m[15] = 1; -``` - -Точная формула `quat_interp`/`quat_blend` (`index 17` и `22`, один и тот же алгоритм): - -```c -float dot = dot4(q0, q1); -float sign = 1.0f; -if (dot < 0.0f) { dot = -dot; sign = -1.0f; } - -float w0, w1; -if (1.0f - dot <= 9.9999997e-6f) { - w0 = 1.0f - a; - w1 = a; -} else { - float theta = acos(dot); - float inv_sin_theta = 1.0f / sin(theta); - w1 = sin(a * theta) * inv_sin_theta; - w0 = cos(a * theta) - w1 * dot; -} -w1 *= sign; -out = w0 * q0 + w1 * q1; -``` - -Примечание: явной нормализации `out` в конце нет; используется закрытая форма SLERP-весов. - -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 +Декодирование quaternion-компонент: `q = s16 / 32767.0`. -Ниже правила, по которым удобно строить 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 для редактора/конвертера - -Рекомендуемое промежуточное представление: +### 1.2. `Res19` (карта кадров) ```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 -}; +uint16_t map_words[]; // size/2 элементов ``` -Где `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` канонично). +`Res19.attr2` хранит глобальную длину таймлайна (число кадров). ---- +### 1.3. Связь с `Res1` -## 8. Алгоритм записи (writer) +Для каждого узла: -Нормативный минимум для runtime-совместимости: +- `anim_map_start` (`hdr2`) — начало блока в `Res19` или `0xFFFF`. +- `fallback_key` (`hdr3`) — индекс fallback-ключа в `Res8`. -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`. +## 2. Сэмплирование узла -Каноничный writer (рекомендуется): -- генерирует map по правилу §5.4; -- fallback-фреймы записывает `== fallback`; -- для статических узлов использует 1 ключ (`time=0`, `hdr2=0xFFFF`). +Вход: время `t`, текущий узел. +Выход: `quat(w,x,y,z)` и `pos(x,y,z)`. ---- +### 2.1. Индекс кадра -## 9. Валидация перед сохранением +Движок использует x87-совместимое округление для выражения `t - 0.5`. +Для 1:1 повторения нужно сохранить ту же политику плавающей точки. -Обязательные проверки: +### 2.2. Выбор key index -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. Если кадр вне диапазона `frame_count` -> `fallback_key`. +2. Если `anim_map_start == 0xFFFF` -> `fallback_key`. +3. Иначе берётся `map_words[anim_map_start + frame]`: + - если значение `>= fallback_key`, тоже используется `fallback_key`; + - иначе используется значение из map. -Рекомендуемые проверки (каноничность): +### 2.3. Интерполяция -1. `fallback_i` строго возрастает по узлам. -2. track каждого узла начинается с `time == 0`. -3. `frame_count == max_end_time + 1`. -4. map-блоки узлов без дыр/overlap. +Если выбран fallback, возвращается ровно этот ключ без интерполяции. ---- +Иначе: -## 10. Edge cases и совместимость +1. Берутся соседние ключи `k0` и `k1`. +2. Если `t` точно равен `k0.time` или `k1.time`, возвращается соответствующий ключ. +3. Иначе: + - `alpha = (t - k0.time) / (k1.time - k0.time)` + - `pos = lerp(k0.pos, k1.pos, alpha)` + - `quat = slerp_like(k0.quat, k1.quat, alpha)` -### 10.1. `Res19.size == 0` +Кватернион в runtime хранится в порядке `[w, x, y, z]`. -Поддерживается runtime-ом: -- `frame_count` обычно 1; -- `hdr2 == 0xFFFF` у всех узлов; -- сэмплирование всегда через fallback key (`hdr3`). +## 3. Смешивание двух сэмплов -### 10.2. Узлы без map +При blending между позами A и B: -Это нормальный режим для статических/квазистатических узлов: -- `hdr2 = 0xFFFF`; -- `hdr3` указывает на единственный ключ узла (канонично). +1. Выбираются валидные стороны по `blend` и валидности времени. +2. Если активна одна сторона, берётся она. +3. Если активны обе: + - применяется shortest-path flip для `qB`; + - выполняется quaternion blend; + - позиция смешивается линейно. -### 10.3. `Res1.attr3 == 24` (legacy outlier) +Матрица строится из quaternion, а translation подставляется отдельным шагом. -В corpus встречается единично (`MTCHECK.MSH`, `testdata/nres/system.rlb`): -- `Res1.attr3 = 24`; -- `Res8` содержит 1 ключ; -- `Res19.size == 0`. +## 4. Каноника writer -Алгоритм `sub_10012880` адресует node как stride 38, поэтому этот случай нельзя интерпретировать правилами текущего 38-byte формата. Практически это отдельный legacy-формат/legacy-path вне описанного runtime-контракта. +Рекомендуемые правила: -### 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] -} -``` +1. Ключи узлов писать подряд в `Res8` в порядке узлов. +2. `fallback_key` узла указывает на последний ключ его трека. +3. Для узлов с map выделять блок длины `frame_count` в `Res19`. +4. Для статических узлов: `anim_map_start = 0xFFFF`, один ключ с `time=0`. +5. `Res8.attr1 = key_count`, `Res8.attr3 = 4`. +6. `Res19.attr1 = map_word_count`, `Res19.attr2 = frame_count`, `Res19.attr3 = 2`. -## 12. Границы полноты +## 5. Валидация перед сохранением -Для основного формата (`Res1` stride 38 + `Res8` + `Res19`) эта страница покрывает runtime и toolchain-поведение на уровне, достаточном для 1:1 реализации (reader/writer/converter/editor). +- `Res8.size % 24 == 0` +- `Res19.size % 2 == 0` +- каждый `fallback_key < key_count` +- для узла с map: `anim_map_start + frame_count <= map_word_count` +- внутри трека времена ключей строго возрастают -Единственный подтверждённый неполный сегмент: -- legacy `Res1.attr3 == 24` (`MTCHECK.MSH`), для которого в `AniMesh` не найден отдельный открытый decode-path в рамках текущего реверса. +## 6. Статус валидации -Для абсолютных 100% по всем историческим вариантам формата дополнительно нужно: -- найти и дореверсить runtime-код, который реально обрабатывает `Res1.attr3==24` (если он есть в других модулях/ветках); -- получить больше образцов `*.msh` с `attr3==24` для проверки writer/validator-инвариантов. +- Форматные проверки включены в `tools/msh_doc_validator.py`. +- В текущем окружении полный игровой корпус MSH не подключен в `testdata`, поэтому массовый прогон здесь не выполнялся. |
