1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
|
# 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]
}
```
|