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
|
# MSH
Файл `*.msh` является NRes-контейнером. Geometry, узлы, slots, batches,
animation и служебные streams лежат в entries с разными `type_id`.
## Entry map
```text
type 1 nodes and slot selection
type 2 header 0x8C + Slot68 records
type 3 positions float3
type 4 packed normals
type 5 packed UV0
type 6 index buffer u16
type 7 triangle descriptors
type 8 animation keys
type 9 service stream
type 10 strings and node names
type 13 Batch20 records
type 15 auxiliary stream
type 16 optional vertex-colour data
type 17 auxiliary data
type 18 packed UV1 (optional secondary coordinates)
type 19 animation frame map
type 20 rare auxiliary table
```
Reader ищет entries по type, но сохраняет исходный порядок для roundtrip.
Поле `ModelAsset.type16_present` отмечает только наличие entry типа 16. Данные
payload сохраняются, но этот путь рендера пока не преобразует их в цвета
вершин.
## Node and slot selection
Type 1 обычно состоит из records по 38 bytes:
```c
struct Node38 {
uint16_t hdr0;
uint16_t parent_or_link;
uint16_t anim_map_start;
uint16_t fallback_key;
uint16_t slot_index[15];
};
```
В исходном layout это 3 состояния по 5 LOD: `slot_index[state * 5 + lod]`.
Публичные Rust-обёртки пока сохраняют исторические имена `Lod` и `Group`,
поэтому в `selected_slot` аргумент `Lod` задаёт native state (0..2), а
`Group` задаёт native LOD (0..4). `0xFFFF` означает отсутствие геометрии для
комбинации state/LOD.
Validated `ModelAsset` сохраняет decoded type 8 keys и type 19 map как
`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
а `node38_fallback_hierarchy` собирает parent-before-child hierarchy.
`node38_sampled_hierarchy` использует type 19 map в пределах объявленного
числа кадров и интерполирует соседние type 8 keys; при отсутствии usable map
или выходе за frame count используется fallback key. Для покомпонентного
sampling `node38_sampled_hierarchy_at_times` принимает отдельный optional float
time для каждого узла: `Some(time)` выбирает type 19 frame по округлению
`time - 0.5` к ближайшему целому с ties-to-even, затем интерполирует выбранный
type 8 key и его следующий key в исходный float time; `None` использует
fallback key. Значения должны быть конечными и неотрицательными. Это
portable-поведение при default-nearest rounding; полного совпадения со всеми
режимами округления x87 оно не заявляет. `parent_or_link == 0xFFFF`
означает root, иначе это parent index, который должен быть меньше индекса
child. Модель с нарушенным порядком не получает придуманную hierarchy.
Type 8 записывает quaternion в порядке WXYZ, после decode `AnimKey24` хранит
его как XYZW. Native AniMesh `+0x12560` строит local matrix через NGI32
`g_FastProc + 0x38`; scalar implementation выдаёт transpose обычной active
`Pose` basis (`M01=2(xy+zw)`, `M02=2(xz-yw)`, `M10=2(xy-zw)`). Поэтому путь
Node38 MSH сопрягает source-local quaternion ровно один раз после sampling и
до hierarchy composition (меняет знаки компонент `x`, `y`, `z`). Для fallback
poses применяется та же граница. Общий animation decoder и другие форматы
анимации не изменяются.
В legacy-camera static preview без `--static-animation-frame <u16>` используются
сохранённые начальные значения из явной ссылки каждого prototype на `.ctl`.
Узлы без control binding получают time `0`. Для каждой component instance
обрабатываются её CTLD bindings, поэтому общий deduplicated MSH не смешивает
controls разных компонентов. Строки идут в source order, и последняя включённая
строка для одного узла заменяет предыдущую. Flag `0x01` выполняет один wrap
значения blend на единицу, иначе значение ограничивается `[0, 1]`; flag `0x02`
инвертирует результат (`1 - blend`), а строки с `0x04` пропускаются. Выбранный
float time равен `(1 - blend) * frame_a + blend * frame_b`. Если он выходит за
type 19 map, MSH sampler использует fallback key; если после одной wrap/clamp и
invert получается отрицательное или нечисловое время, preview завершится
понятной ошибкой. Явный `--static-animation-frame` остаётся global override
для всех компонентов. Parent pose поворачивает child translation, затем
translation суммируется, а rotations умножаются; после полученной global pose
применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия намеренно
дублируется на draw-range узла, потому что один source vertex может быть
нарисован разными node poses. Это только начальная статическая pose: она не
запускает игровой Control/AI и не обещает полного совпадения с runtime или
всеми x87 rounding modes.
## Vertex streams
Основные vertex streams имеют фиксированный source index. У type 3 `attr1`
задаёт количество вершин, а `attr3` — размер записи в байтах. Type 4
содержит четыре signed bytes нормали. Type 5 и optional type 18 содержат по
два little-endian `uint16` на вершину:
```c
struct PackedUv16x2 {
uint16_t u;
uint16_t v;
};
```
Type 5 — primary UV0, type 18 — secondary UV1 для lightmap/detail stage.
Для type 18 loader требует `attr3 == 4`, совпадение `attr1` и длины payload,
а также одинаковое число записей с type 3. Обе пары декодируются как
`packed / 1024.0`; renderer обращается к UV1 по тому же `source_index`, что и
к UV0. Отсутствующий type 18 означает, что secondary stage не имеет authored
координат.
## Slot and batch
Type 2 содержит header `0x8C`, затем `Slot68`:
```c
struct Slot68 {
uint16_t tri_start;
uint16_t tri_count;
uint16_t batch_start;
uint16_t batch_count;
float aabb_min[3];
float aabb_max[3];
float sphere_center[3];
float sphere_radius;
uint32_t opaque[5];
};
```
Type 13 задаёт draw ranges:
```c
#pragma pack(push, 1)
struct Batch20 {
uint16_t batch_flags;
uint16_t material_index_hi; // +0x02, native high selector/slot field
uint8_t material_index; // +0x04, WEAR material selector
uint8_t lightmap_index; // +0x05, 0xFF means no lightmap
uint16_t local_batch_index; // +0x06
uint16_t index_count;
uint32_t index_start;
uint16_t vertex_count; // +0x0E
uint32_t base_vertex;
};
#pragma pack(pop)
```
Byte `material_index` выбирает строку WEAR; high word at `+0x02` сохраняется
отдельно для native overrides. Слово `local_batch_index` остаётся raw-полем
`+0x06`: в пути CBuilding helper retry оно вместе с базой модели задаёт
связанный корень Node38, а у отдельной `ModelAsset` база равна нулю. Вне этого
пути слово сохраняется без вывода о его значении. Loader проверяет, что
`index_start + index_count` остаётся внутри type 6, а каждый использованный
индекс через `base_vertex` попадает в position stream. `vertex_count` и
`local_batch_index` доступны renderer для исходного draw contract.
## Type 7 и сегментный запрос
Type 7 необязателен для обычного MSH preview. Если stream есть, каждые 16 bytes
описывают одну грань: `tri_flags` находится по `+0`, четыре неинтерпретируемых
слова — по `+2`, `+4`, `+6` и `+14`, а signed `normal[3]` — по `+8`, `+0xA` и
`+0xC`. `ModelAsset.triangle_descriptors` сохраняет декодированную таблицу как
`Option`; отсутствие Type 7 не делает статическую модель недействительной.
`query_slot_segment` использует Node38 header mask, Batch20 leading DWORD
`batch_flags | (material_index_hi << 16)` и `tri_flags`. Для каждого поля
include mask требует все установленные биты, exclude mask пропускает запись при
любом совпавшем бите. Descriptor cursor начинается с `Slot68.tri_start` и
сдвигается на `Batch20.index_count / 3` после каждой записи, даже если batch
или грань отфильтрованы. Индексы грани читаются из type 6 с учётом
`base_vertex`.
Правило восстановлено для GOG-бинарей `AniMesh.dll`
(`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) и
`Ngi32.dll` (`bab9840d94f4e4e74ffc26677724fa896cf4823845504d09a9e025f80016edf5`).
Callback `AniMesh.dll+0x11090` переводит signed type-7 normal множителем
`1 / 32767`; функция `AniMesh.dll+0x11EB0` записывает этот scale в глобальное
поле. Callback вызывает FastProc slot `+0xB4`: в этой сборке он указывает на
`Ngi32.dll+0x1D560`. После обычной загрузки DLL порог `Ngi32.dll+0x37224`
равен нулю, поэтому plane test принимает только переход от положительного
расстояния к неположительному (`dot(normal, end - start) < 0`, расстояние в
начале `> 0`, в конце `<= 0`). `Batch20.batch_flags & 0x0002` разрешает повторный
plane test в обратном направлении, только если прямой plane test не пересёк
плоскость. Пересечение проверяется export ordinal 202
(`Ngi32.dll+0x1E60`, `mrnPointInPoly`): проекция выбирает ось с наибольшим по
модулю normal component, а точка проходит грань, пока ни одно edge expression
не меньше нуля. Равенство нулю принимается, поэтому попадание на ребро или
вершину считается попаданием. Поздний кандидат с тем же squared distance
заменяет предыдущий; расстояние считается от исходного первого endpoint.
Native helper получает точку пересечения через `RCPSS` и один шаг уточнения;
Rust использует обычное деление `f32`, поэтому последние биты координат могут
отличаться. Игнорируемый тест с ресурсами проверяет исходную стену, дверной
проём и обратное направление на `fortif.rlb/fr_l_plant.msh`.
### Мировой отрезок по выбранным узлам
Локальный запрос выше проверяет один узел и slot модели. Runtime-функция
`query_building_segment_world` принимает мировой отрезок и исходный список
выбранных узлов со slot-ами и их преобразованиями `world_from_node`. Первый
проход сохраняет порядок этого списка, переводит концы отрезка обратной
матрицей и выбирает ближайшее попадание в мире.
```text
segment_node = inverse(world_from_node) * segment_world
hit_world = world_from_node * hit_node
distance² = dot(hit_world - original_start, hit_world - original_start)
```
`original_start` остаётся началом первоначального мирового отрезка, даже когда
запрос перезапускается. Если ближайший batch имеет bit `0x0008`, его ключ
`local_batch_index` выбирает новый корень Node38, а отрезок начинается в
мировой точке helper-попадания и идёт к исходному концу. Runtime обходит
метаданные этого поддерева даже у узлов без переданной геометрии; сами slot-
запросы выполняются только для узлов, присутствующих во входном списке. Обход
проверяет сначала допустимых потомков, затем сам узел. Header bit `0x0010`
прекращает обход его потомков; bit `0x0004` у дочернего узла исключает всю эту ветвь, но bit
`0x0004` у текущего корня не мешает запросу его собственной геометрии. Слово
родителя `0xFFFF` не связывает узел с дочерним корнем, а helper-ключ `0xFFFF`
считается ошибкой данных.
В каждом проходе batch `0x0200` и type-7 triangle flags `0x0024` исключаются.
Ближайшее попадание и разрешение равных расстояний сохраняют правила локального
запроса; расстояние измеряется от первоначального начала. Счётчик ограничивает
все пересечения: при посещении 200 запрос завершается ошибкой до проверки bit
`0x0008`.
Эта схема сверена с GOG `Terrain.dll` SHA-256
`af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead` (RVA
`0x35430`, `0x35961`) и `AniMesh.dll` SHA-256
`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e` (RVA
`0x134D0`, `0x10A50`).
Игнорируемый тест загружает настоящий объект PLANT 4 миссии
AutoDemo и использует его исходную CTLD-привязку Node3: `frame_a = 0`,
`frame_b = 1`, начальное смешивание `0`. В контроллере корневого PLANT нет
записей tag-21. В
закрытой начальной позе запрос, вызванный через обновление камеры приложения,
останавливает движение. Затем тест вручную задаёт время `1` только для
Node3, пересчитывает pose и преобразование присоединения и повторяет тот же
путь обновления камеры; отрезок проходит через проём. Это проверяет подачу
двух заданных в MSH поз в коллизию приложения, а не открытие двери событием
миссии и не её видимое обновление в игровой сцене.
Интерактивный preview создаёт отдельную запись для каждого отрисовываемого
корневого строения класса `FORT` и его отрисовываемых компонентов. На каждом
движении камера получает текущую Node38-иерархию компонента и его актуальное
преобразование присоединения, переводит выбранные узлы и slot-ы в мировой
запрос и выполняет его после ограничения цели рельефом. Попадание сохраняет
прежнюю позицию камеры, но не отменяет новый поворот; повтор ожидающего кадра
не применяет шаг движения второй раз. Так приложение связывает мировой
MSH-запрос с движением свободной preview-камеры. Выбор первого корня и полный
состав мирового списка ещё не сверены с оригиналом; игровые источники поз и
событий, а также поведение стратегической и следящей камеры остаются
открытыми.
## Сторона грани в цветовом проходе
В цветном проходе `Terrain.dll`, функция `0x100296F0`, сравнивает камеру с
каждой гранью по знаку тройного произведения:
```c
d = dot(eye - v0, cross(v1 - v0, v2 - v0));
```
Проход оставляет грань только при `d > 0`. Raw `Batch20.flags` задаёт два
исключения: `0x0002` пропускает программную проверку стороны, поэтому обратная
грань не отбрасывается этим условием. С `0x4000` проверяется только первая
грань записи: если `d <= 0`, цикл её граней прекращается; если `d > 0`,
оставшиеся грани этой записи уже не проходят повторную программную проверку.
`Terrain.dll!0x100296F0` также настраивает пакет этого цветного пути: flags по
адресу `packet+0x128` равны `0x404` или `0x414`. Путь отправки в
`Terrain.dll!0x1001D660` перед вызовом renderer vtable `+0x48` очищает только
маску `0x1`; `Ngi32.dll!0x10007470` проверяет маску `0x4` этого аргумента. При
установленной маске render state 22 получает `D3DCULL_NONE`, иначе —
`D3DCULL_CW`. Оба значения CShade сохраняют маску `0x4`, поэтому в этом
цветном пути аппаратное отсечение выключено, а набор обычных граней определяет
программный тест `d > 0`. Raw-флаги `0x0002` и `0x4000` меняют этот тест, как
описано выше.
Статический Vulkan preview сохраняет каждую исходную запись `Batch20` одним
диапазоном рисования:
`append_static_preview_component_with_lightmaps` добавляет исходные индексы без
перестановки, а в диапазон переносит `index_count` и исходный `first_index` с
общим смещением компонента. Поэтому `0x4000` выбирает ту же первую грань, даже
если диапазон получил другой материал для lightmap.
Поскольку вершины preview переведены в мировые координаты, знак `d` для MSH
диапазона корректируется знаком детерминанта полной матрицы размещения. Повороты
узлов и mounts — единичные кватернионы с determinant `+1`; отрицательный object
scale меняет знак. Renderer Vulkan задаёт `front_face=CLOCKWISE` и viewport с
положительной высотой. Для используемых матриц legacy и free-flight preview
при положительном determinant native-visible `d > 0` относится к Vulkan
back-face; поэтому preview отсекает `FrontFace`. При отрицательном determinant
режим меняется на `BackFace`.
Для `0x0002` и `0x4000` аппаратное отсечение выключено, чтобы сохранить их
программную политику. Отклонённый `0x4000` диапазон получает число индексов 0;
каждый кадр это число заново вычисляется из исходного значения, поэтому после
изменения положения камеры или opacity материала диапазон может вернуться в
draw queue. Проверка первой грани
применяется только к MSH-диапазонам цветовой мировой очереди; terrain и
shadow/sun запросы её не используют.
|