diff options
Diffstat (limited to 'docs/specs')
| -rw-r--r-- | docs/specs/ai.md | 5 | ||||
| -rw-r--r-- | docs/specs/arealmap.md | 5 | ||||
| -rw-r--r-- | docs/specs/behavior.md | 5 | ||||
| -rw-r--r-- | docs/specs/control.md | 5 | ||||
| -rw-r--r-- | docs/specs/fxid.md | 834 | ||||
| -rw-r--r-- | docs/specs/materials-texm.md | 874 | ||||
| -rw-r--r-- | docs/specs/missions.md | 5 | ||||
| -rw-r--r-- | docs/specs/msh-animation.md | 105 | ||||
| -rw-r--r-- | docs/specs/msh-core.md | 492 | ||||
| -rw-r--r-- | docs/specs/msh-notes.md | 277 | ||||
| -rw-r--r-- | docs/specs/msh.md | 22 | ||||
| -rw-r--r-- | docs/specs/network.md | 5 | ||||
| -rw-r--r-- | docs/specs/nres.md | 718 | ||||
| -rw-r--r-- | docs/specs/runtime-pipeline.md | 123 | ||||
| -rw-r--r-- | docs/specs/sound.md | 5 | ||||
| -rw-r--r-- | docs/specs/terrain-map-loading.md | 32 | ||||
| -rw-r--r-- | docs/specs/ui.md | 5 |
17 files changed, 3517 insertions, 0 deletions
diff --git a/docs/specs/ai.md b/docs/specs/ai.md new file mode 100644 index 0000000..545c07b --- /dev/null +++ b/docs/specs/ai.md @@ -0,0 +1,5 @@ +# AI system + +Документ описывает подсистему искусственного интеллекта: принятие решений, pathfinding и стратегическое поведение противников. + +> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ai.dll`. diff --git a/docs/specs/arealmap.md b/docs/specs/arealmap.md new file mode 100644 index 0000000..cac2743 --- /dev/null +++ b/docs/specs/arealmap.md @@ -0,0 +1,5 @@ +# ArealMap + +Документ описывает формат и структуру карты мира: зоны/сектора, координаты, размещение объектов и связь с terrain и миссиями. + +> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ArealMap.dll`. diff --git a/docs/specs/behavior.md b/docs/specs/behavior.md new file mode 100644 index 0000000..9ffd2dc --- /dev/null +++ b/docs/specs/behavior.md @@ -0,0 +1,5 @@ +# Behavior system + +Документ описывает поведенческую логику юнитов: state machine/behavior-паттерны, взаимодействия и базовые правила боевого поведения. + +> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Behavior.dll`. diff --git a/docs/specs/control.md b/docs/specs/control.md new file mode 100644 index 0000000..a2d3d44 --- /dev/null +++ b/docs/specs/control.md @@ -0,0 +1,5 @@ +# Control system + +Документ описывает подсистему управления: mapping ввода (клавиатура, мышь, геймпад), обработку событий и буферизацию команд. + +> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Control.dll`. diff --git a/docs/specs/fxid.md b/docs/specs/fxid.md new file mode 100644 index 0000000..7dd1d4b --- /dev/null +++ b/docs/specs/fxid.md @@ -0,0 +1,834 @@ +# FXID + +Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для: + +- 1:1 загрузки и исполнения в совместимом runtime; +- построения валидатора payload; +- создания lossless-конвертера (`binary -> IR -> binary`); +- создания редактора с безопасным редактированием полей. + +Связанный контейнер: [NRes / RsLi](nres.md). + +--- + +## 1. Источники и статус восстановления + +Спецификация восстановлена по: + +- `tmp/disassembler1/Effect.dll.c`; +- `tmp/disassembler2/Effect.dll.asm`; +- интеграционным вызовам из `tmp/disassembler1/Terrain.dll.c`; +- проверке реальных архивов `testdata/nres`. + +Ключевые функции: + +- parser FXID: `Effect.dll!sub_10007650`; +- runtime loop: `sub_10003D30(case 28)`, `sub_10006170`, `sub_10008120`, `sub_10007D10`; +- alpha/time: `sub_10005C60`; +- exports: `CreateFxManager`, `InitializeSettings`. + +Проверка по данным: + +- `923/923` FXID payload валидны в `testdata/nres`. + +--- + +## 2. Контейнер и runtime API + +### 2.1. NRes entry + +FXID хранится как NRes-entry: + +- `type_id = 0x44495846` (`"FXID"`). + +Наблюдение по датасету (923 эффекта): + +- `attr1 = 0`, `attr2 = 0`, `attr3 = 1`. + +### 2.2. Export API `Effect.dll` + +Экспортируются: + +- `CreateFxManager(int a1, int a2, int owner)`; +- `InitializeSettings()`. + +`CreateFxManager` создаёт manager-объект (`0xB8` байт), инициализирует через `sub_10003AE0`, возвращает интерфейсный указатель (`base + 4`). + +### 2.3. Интерфейс менеджера + +Рабочая vtable (`off_1001E478`): + +| Смещение | Функция | Назначение | +|---|---|---| +| +0x08 | `sub_10003D30` | Event dispatcher (`4/20/23/24/28`) | +| +0x10 | `sub_10004320` | Открыть/закэшировать FX resource | +| +0x14 | `sub_10004590` | Создать runtime instance | +| +0x18 | `sub_10004780` | Удалить instance | +| +0x1C | `sub_100047B0` | Установить time/interp mode | +| +0x20 | `sub_100047D0` | Установить scale | +| +0x24 | `sub_10004830` | Установить позицию | +| +0x28 | `sub_10004930` | Установить matrix transform | +| +0x2C | `sub_10004B00` | Restart/retime | +| +0x38 | `sub_10004BA0` | Duration modifier | +| +0x3C | `sub_10004BD0` | Start/Enable | +| +0x40 | `sub_10004C10` | Stop/Disable | +| +0x44 | `sub_10004C50` | Bind emitter/context | +| +0x48 | `sub_10004D50` | Сброс frame flags | + +`Terrain.dll` использует `QueryInterface(id=19)` для получения рабочего интерфейса. + +--- + +## 3. Бинарный формат FXID payload + +Все значения little-endian. + +### 3.1. Header (60 байт, `0x3C`) + +```c +struct FxHeader60 { + uint32_t cmd_count; // 0x00 + uint32_t time_mode; // 0x04 + float duration_sec; // 0x08 + float phase_jitter; // 0x0C + uint32_t flags; // 0x10 + uint32_t settings_id; // 0x14 + float rand_shift_x; // 0x18 + float rand_shift_y; // 0x1C + float rand_shift_z; // 0x20 + float pivot_x; // 0x24 + float pivot_y; // 0x28 + float pivot_z; // 0x2C + float scale_x; // 0x30 + float scale_y; // 0x34 + float scale_z; // 0x38 +}; +``` + +Командный поток начинается строго с `offset = 0x3C`. + +### 3.2. Header-поля (подтвержденная семантика) + +- `cmd_count`: число команд (engine итерирует ровно столько шагов). +- `time_mode`: базовый режим вычисления alpha/time (`sub_10005C60`). +- `duration_sec`: в runtime -> `duration_ms = duration_sec * 1000`. +- `phase_jitter`: используется при `flags & 0x1`. +- `flags`: runtime-gating/alpha/visibility (см. ниже). +- `settings_id`: в `sub_1000EC40` используется `settings_id & 0xFF`. +- `rand_shift_*`: используется при `flags & 0x8`. +- `pivot_*`: используется в ветках `sub_10007D10`. +- `scale_*`: копируется в runtime scale и влияет на матрицы. + +### 3.3. `flags` (битовая карта) + +| Бит | Маска | Наблюдаемое поведение | +|---|---:|---| +| 0 | `0x0001` | Random phase jitter (`phase_jitter`) | +| 3 | `0x0008` | Random positional shift (`rand_shift_*`) | +| 4 | `0x0010` | Visibility/occlusion ветки | +| 5 | `0x0020` | Triangular remap в `sub_10005C60` | +| 6 | `0x0040` | Инверсия начального active-state | +| 7 | `0x0080` | Day/night filter (ветка A) | +| 8 | `0x0100` | Day/night filter (ветка B, инверсия) | +| 9 | `0x0200` | Alpha *= normalized lifetime | +| 10 | `0x0400` | Установка manager bit1 (`+0xA0`) | +| 11 | `0x0800` | Изменение gating в `sub_10007D10` | +| 12 | `0x1000` | Установка manager-state bit `0x10` | + +Нерасшифрованные биты должны сохраняться 1:1. + +### 3.4. `time_mode` (`0..17`) + +Обозначения (`sub_10005C60`): + +- `t0 = instance.start_ms`, `t1 = instance.end_ms`; +- `tn = (now_ms - t0) / (t1 - t0)`; +- `prev = instance.cached_alpha` (`v4+52` в дизассембле). + +Режимы: + +- `0`: constant (`instance.alpha_const`, поле `v4+40`); +- `1`: `tn`; +- `2`: `fract(tn)`; +- `3`: `1 - tn`; +- `4`: external value из queue/world API (manager `+36`, id из `this+104[a2]`); +- `5`: `|param33.xyz| / |param17.vecA.xyz|`; +- `6`: `param33.x / param17.vecA.x`; +- `7`: `param33.y / param17.vecA.y`; +- `8`: `param33.z / param17.vecA.z`; +- `9`: `|param36.xyz| / |param17.vecB.xyz|`; +- `10`: `param36.x / param17.vecB.x`; +- `11`: `param36.y / param17.vecB.y`; +- `12`: `param36.z / param17.vecB.z`; +- `13`: `1 - external_resource_value`; +- `14`: `1 - queue_param(49)`; +- `15`: `max(norm(param33/vecA), norm(param36/vecB))`; +- `16`: external (`mode 4`) с нижним clamp к `prev` (`0` не зажимается); +- `17`: external (`mode 4`) с верхним clamp к `prev` (`1` не зажимается). + +Post-обработка после mode: + +- если `flags & 0x200`: `alpha *= tn`; +- если `flags & 0x20`: triangular remap (`alpha = (alpha < 0.5 ? alpha : 1-alpha) * 2`). + +--- + +## 4. Командный поток + +### 4.1. Общий формат команды + +Каждая команда: + +- `uint32 cmd_word`; +- далее body фиксированного размера по opcode. + +`cmd_word`: + +- `opcode = cmd_word & 0xFF`; +- `enabled = (cmd_word >> 8) & 1`; +- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1. + +Выравнивания между командами нет. + +### 4.2. Размеры + +| Opcode | Размер записи | +|---:|---:| +| 1 | 224 | +| 2 | 148 | +| 3 | 200 | +| 4 | 204 | +| 5 | 112 | +| 6 | 4 | +| 7 | 208 | +| 8 | 248 | +| 9 | 208 | +| 10 | 208 | + +### 4.3. Opcode -> runtime-класс (vtable) + +| Opcode | `new(size)` | vtable | +|---:|---:|---| +| 1 | `0xF0` | `off_1001E78C` | +| 2 | `0xA0` | `off_1001F048` | +| 3 | `0xFC` | `off_1001E770` | +| 4 | `0x104` | `off_1001E754` | +| 5 | `0x54` | `off_1001E360` | +| 6 | `0x1C` | `off_1001E738` | +| 7 | `0x48` | `off_1001E228` | +| 8 | `0xAC` | `off_1001E71C` | +| 9 | `0x100` | `off_1001E700` | +| 10 | `0x48` | `off_1001E24C` | + +### 4.4. Общий вызовной контракт команды + +После создания команды (`sub_10007650`): + +1. `cmd->enabled = cmd_word.bit8`. +2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`). +3. команда добавляется в список инстанса. + +В runtime cycle: + +- `vfunc +8`: update/compute (bool); +- `vfunc +12`: emission/render callback; +- `vfunc +20`: toggle active; +- `vfunc +16`/`+24`: служебные функции (зависят от opcode). + +--- + +## 5. Загрузка FXID (engine-accurate) + +`sub_10007650`: + +```c +void FxLoad(FxInstance* fx, uint8_t* payload) { + FxHeader60* h = (FxHeader60*)payload; + + fx->raw_header = h; + fx->mode = h->time_mode; + fx->end_ms = fx->start_ms + h->duration_sec * 1000.0f; + fx->scale = {h->scale_x, h->scale_y, h->scale_z}; + fx->active_default = ((h->flags & 0x40) == 0); + + uint8_t* ptr = payload + 0x3C; + for (uint32_t i = 0; i < h->cmd_count; ++i) { + uint32_t w = *(uint32_t*)ptr; + uint8_t op = (uint8_t)(w & 0xFF); + + Command* cmd = CreateByOpcode(op, ptr); // может вернуть null + if (cmd) { + cmd->enabled = (w >> 8) & 1; + + if (h->flags & 0x400) fx->manager_flags |= 0x0100; + if ((h->flags & 0x400) || cmd->enabled) fx->manager_flags |= 0x0010; + + cmd->Init(fx->queue, fx); + fx->commands.push_back(cmd); + } + + ptr += size_by_opcode(op); // без bounds checks в оригинале + } +} +``` + +Критичные edge-case оригинала: + +- bounds checks отсутствуют; +- при unknown opcode `ptr` не двигается (`advance = 0`); +- при `new == null` команда пропускается, но `ptr` двигается. + +Фактический `advance` в `sub_10007650` задан hardcoded в DWORD: + +- `op1:+56`, `op2:+37`, `op3:+50`, `op4:+51`, `op5:+28`, +- `op6:+1`, `op7:+52`, `op8:+62`, `op9:+52`, `op10:+52`, +- `default:+0`. + +--- + +## 6. Runtime lifecycle + +- `sub_10007470`: ctor instance. +- `sub_10003D30(case 28)`: per-frame update manager. +- `sub_10006170`: gate + alpha/time + command updates. +- `sub_10008120` / `sub_10007D10`: update/render branches. +- Start/Stop: `sub_10004BD0` / `sub_10004C10`. + +Event-codes `sub_10003D30`: + +- `4`: bootstrap/time init; +- `20`: range-removal + index repair; +- `23`: set manager bit0; +- `24`: clear manager bit0; +- `28`: main tick. + +--- + +## 7. Общий тип `ResourceRef64` + +Для opcode `2/3/4/5/7/8/9/10` присутствует ссылка вида: + +```c +struct ResourceRef64 { + char archive[32]; // null-terminated ASCII, case-insensitive compare + char name[32]; // null-terminated ASCII +}; +``` + +Поведение loader'а: + +- оба имени обязаны быть непустыми; +- кэширование по `(_strcmpi archive, _strcmpi name)`; +- загрузка/резолв через manager resource API. + +Наблюдение по данным: + +- для `opcode 2`: обычно `sounds.lib` + `*.wav`; +- для остальных: обычно `material.lib` + material name. + +--- + +## 8. Полная карта body по opcode (field-level) + +Смещения указаны от начала команды (включая `cmd_word`). + +### 8.1. Opcode 1 (`off_1001E78C`, size=224) + +Основные методы: + +- init: `sub_1000F4B0`; +- update: `sub_1000F6E0`; +- emit: `nullsub_2`; +- toggle: `sub_1000F490`. + +```c +struct FxCmd01 { + uint32_t word; // +0 + uint32_t mode; // +4 (enum, см. ниже) + float t_start; // +8 + float t_end; // +12 + + float p0_min[3]; // +16..24 + float p0_max[3]; // +28..36 + + float p1_min[3]; // +40..48 + float p1_max[3]; // +52..60 + + float q0_min[4]; // +64..76 + float q0_max[4]; // +80..92 + + float q0_rand_span[4]; // +96..108 (все 4 читаются в sub_1000F6E0) + + float scalar_min; // +112 + float scalar_max; // +116 + float scalar_rand_amp; // +120 + + float color_rgb[3]; // +124..132 (вызов manager+16) + + float opaque_tail6[6]; // +136..156 (сохранять 1:1; в датасете почти всегда 0) + + char opt_archive[32]; // +160..191 (редко, напр. "material.lib") + char opt_name[32]; // +192..223 (редко, напр. "light_w") +}; +``` + +Замечания по полям op1: + +- `+108` не резерв: участвует в random-выборке как 4-я компонента блока `+96..108`; +- `+136..156` не читается vtable-методами класса `off_1001E78C` в `Effect.dll` (init/update/toggle/accessor), но должно сохраняться 1:1; +- редкий кейс с ненулевыми `+136..156` и строками `+160/+192` зафиксирован в `effects.rlb:r_lightray_w`. + +`mode` (`+4`) -> параметры вызова manager (`sub_1000F4B0`): + +- `1 -> create_kind=1, flags=0x80000000`; +- `2/5 -> create_kind=1, flags=0x00000000`; +- `3 -> create_kind=3, flags=0x00000000`; +- `4 -> create_kind=4, flags=0x00000000`; +- `6 -> create_kind=1, flags=0xA0000000`; +- `7 -> create_kind=1, flags=0x20000000`. + +### 8.2. Opcode 2 (`off_1001F048`, size=148) + +Основные методы: + +- init: `sub_10012D10`; +- update: `sub_10012EB0`; +- emit: `nullsub_2`; +- toggle: `sub_10013170`. + +```c +struct FxCmd02 { + uint32_t word; // +0 + uint32_t mode; // +4 (0..3; влияет на sub_100065A0 mapping) + float t_start; // +8 + float t_end; // +12 + + float a_min[3]; // +16..24 + float a_max[3]; // +28..36 + + float b_min[3]; // +40..48 + float b_max[3]; // +52..60 + + float c0_base; // +64 + float c1_base; // +68 + float c2_base; // +72 + float c2_max; // +76 + + uint32_t param_910; // +80 (передаётся в manager cmd=910) + + ResourceRef64 ref; // +84..147 (обычно sounds.lib + wav) +}; +``` + +`mode` -> внутренний map в `sub_100065A0`: + +- `0 -> 0`, `1 -> 512`, `2 -> 2`, `3 -> 514`. + +### 8.3. Opcode 3 (`off_1001E770`, size=200) + +Методы: + +- init: `sub_100103B0`; +- update: `sub_100105F0`; +- emit: `sub_100106C0`. + +```c +struct FxCmd03 { + uint32_t word; // +0 + uint32_t mode; // +4 + + float alpha_source; // +8 (>=0: norm time, <0: global time) + float alpha_pow_a; // +12 + float alpha_pow_b; // +16 + + float out_min; // +20 + float out_max; // +24 + float out_pow; // +28 + + float active_t0; // +32 + float active_t1; // +36 + + float v0_min[3]; // +40..48 + float v0_max[3]; // +52..60 + + float pow0[3]; // +64..72 + + float v1_min[3]; // +76..84 + float v1_max[3]; // +88..96 + + float v2_min[3]; // +100..108 + float v2_max[3]; // +112..120 + + float pow1[3]; // +124..132 + + ResourceRef64 ref; // +136..199 +}; +``` + +### 8.4. Opcode 4 (`off_1001E754`, size=204) + +Layout как opcode 3 + последний коэффициент: + +```c +struct FxCmd04 { + FxCmd03 base; // +0..199 + float dist_norm_inv_base; // +200 (используется в sub_100108C0/100109B0) +}; +``` + +`sub_100108C0`: `obj->inv = 1.0 / raw[200]`. + +### 8.5. Opcode 5 (`off_1001E360`, size=112) + +Методы: + +- init: `sub_100028A0`; +- update: `sub_10002A20`; +- emit: `sub_10002BE0`; +- context update: `sub_10003070`. + +```c +struct FxCmd05 { + uint32_t word; // +0 + uint32_t mode; // +4 (в данных обычно 1) + uint32_t unused_08; // +8 (в текущем коде opcode5 не читается) + uint32_t unused_0C; // +12 (в текущем коде opcode5 не читается) + + float active_t0; // +16 + uint32_t max_segments; // +20 + float active_t1_min; // +24 + float active_t1_max; // +28 + + float step_norm; // +32 + float segment_len; // +36 + float alpha_source; // +40 (>=0 norm, <0 random) + float alpha_pow; // +44 + + ResourceRef64 ref; // +48..111 +}; +``` + +### 8.6. Opcode 6 (`off_1001E738`, size=4) + +Только `cmd_word`: + +```c +struct FxCmd06 { + uint32_t word; // +0 +}; +``` + +`init/update/emit` фактически no-op (`sub_100030B0` возвращает `0`). + +### 8.7. Opcode 7 (`off_1001E228`, size=208) + +Методы: + +- init: `sub_10001720`; +- update: `sub_10001230`; +- emit: `sub_10001300`; +- element accessor: `sub_10002780`. + +```c +struct FxCmd07 { + uint32_t word; // +0 + uint32_t mode; // +4 + + float eval_min; // +8 + float eval_max; // +12 + float eval_pow; // +16 + + float active_t0; // +20 + float active_t1; // +24 + + float phase_span; // +28 + float phase_rate; // +32 + + uint32_t count_a; // +36 + uint32_t count_b; // +40 + + float set0_min[3]; // +44..52 + float set0_max[3]; // +56..64 + float set0_rand[3]; // +68..76 + float set0_pow[3]; // +80..88 + + float set1_min[3]; // +92..100 + float set1_max[3]; // +104..112 + float set1_rand[3]; // +116..124 + float set1_pow[3]; // +128..136 + + float gravity_or_drag_k; // +140 + + ResourceRef64 ref; // +144..207 +}; +``` + +### 8.8. Opcode 8 (`off_1001E71C`, size=248) + +Методы: + +- init: `sub_10011230`; +- update: `sub_100115C0`; +- emit: `sub_10012030`. + +```c +struct FxCmd08 { + uint32_t word; // +0 + uint32_t mode; // +4 + + float eval_t0; // +8 + float eval_t1; // +12 + + float gate_t0; // +16 + float gate_t1; // +20 + + float period_min; // +24 + float period_max; // +28 + float phase_pow; // +32 + + uint32_t slots; // +36 + + float set0_min[3]; // +40..48 + float set0_max[3]; // +52..60 + float set0_rand[3]; // +64..72 + + float set1_min[3]; // +76..84 + float set1_max[3]; // +88..96 + float set1_rand[3]; // +100..108 + + float set2_rand[3]; // +112..120 + float set2_pow[3]; // +124..132 + + float rmax_set0[3]; // +136..144 (bound/radius calc) + float rmax_set1[3]; // +148..156 (bound/radius calc) + float rmax_set2[3]; // +160..168 (bound/radius calc) + + float render_pow[3]; // +172..180 + + ResourceRef64 ref; // +184..247 +}; +``` + +### 8.9. Opcode 9 (`off_1001E700`, size=208) + +Layout как opcode 3 с двумя final-полями: + +```c +struct FxCmd09 { + FxCmd03 base; // +0..199 + uint32_t render_kind; // +200 (0/1/2 -> 3/5/6 in sub_100138C0) + uint32_t render_flag; // +204 (0 -> добавляет bit 0x08000000) +}; +``` + +Методы: + +- init/update как у opcode 3 (`sub_100103B0`, `sub_100105F0`); +- emit: `sub_100138C0` -> формирует код рендера и вызывает `sub_100106C0`. + +### 8.10. Opcode 10 (`off_1001E24C`, size=208) + +Body-layout совпадает с opcode 7 (`FxCmd07`), но другой runtime класс. + +- init: `sub_10001A40`; +- update: `sub_10001230`; +- emit: `sub_10001300`; +- element accessor: `sub_10002830`. + +Наблюдение по данным: + +- `mode` (`+4`) встречается как `16` или `32`. + +--- + +## 9. Runtime-специфика по opcode (важные отличия) + +### 9.1. Opcode 1 + +- создаёт handle через manager (`vfunc +48`); +- задаёт флаги handle (`vfunc +52`); +- в update пушит: + - позиционный вектор 1 (`vfunc +32`), + - позиционный вектор 2 (`vfunc +36`), + - 4-компонентный параметр (`vfunc +12`), + - scalar+rgb (`vfunc +16`). + +### 9.2. Opcode 2 + +- `ResourceRef64` резолвится через `sub_100065A0` (режим-зависимая загрузка, в данных обычно `sounds.lib`/`wav`); +- использует manager-команду id `910`. + +### 9.3. Opcode 3/4/9 + +- общий core-emitter в `sub_100106C0`; +- opcode 4 добавляет нормализацию по `raw+200`; +- opcode 9 добавляет переключение render-кода (`raw+200/+204`). + +### 9.4. Opcode 5 + +- держит массив внутренних сегментов (`332` байта/элемент, ctor `sub_100099F0`); +- context-matrix приходит через `vfunc +24` (`sub_10003070`). + +### 9.5. Opcode 7/10 + +- общий update/render (`sub_10001230`, `sub_10001300`); +- разные внутренние element-форматы: + - opcode 7: `204` байта/элемент (`sub_100092D0`), + - opcode 10: `492` байта/элемент (`sub_1000BB40`). + +### 9.6. Opcode 8 + +- самый тяжёлый спавнер, хранит ring/slot-структуры; +- emit фаза (`sub_10012030`) использует `mode`, `render_pow`, per-slot transforms. + +--- + +## 10. Спецификация инструментов + +### 10.1. Reader (strict) + +Алгоритм: + +1. `len(payload) >= 60`; +2. читаем `cmd_count`; +3. `ptr = 0x3C`; +4. цикл `cmd_count`: + - `ptr + 4 <= len`; + - `opcode in 1..10`; + - `ptr + size(opcode) <= len`; + - `ptr += size(opcode)`; +5. strict-tail: `ptr == len(payload)`. + +### 10.2. Reader (engine-compatible) + +Legacy-режим (опасный, только при необходимости byte-совместимости): + +- без bounds-check; +- tolerant к unknown opcode как в оригинале. + +### 10.3. Writer (canonical) + +1. записать `FxHeader60`; +2. `cmd_count = commands.len()`; +3. команды сериализуются как `cmd_word + fixed-body`; +4. размер payload: `0x3C + sum(size(op_i))`; +5. без хвостовых байт. + +### 10.4. Editor (lossless) + +Правила: + +- все поля little-endian; +- не менять fixed size команды; +- не добавлять padding; +- сохранять неизвестные биты (`cmd_word`, `header.flags`) copy-through; +- для частично-известных полей поддерживать режим `opaque`. + +### 10.5. IR/JSON (рекомендуемая форма) + +```json +{ + "header": { + "time_mode": 1, + "duration_sec": 2.5, + "phase_jitter": 0.2, + "flags": 22, + "settings_id": 785, + "rand_shift": [0.0, 0.0, 0.0], + "pivot": [0.0, 0.0, 0.0], + "scale": [1.0, 1.0, 1.0] + }, + "commands": [ + { + "opcode": 8, + "word_raw": 264, + "enabled": 1, + "fields": { + "mode": 1065353216, + "eval_t0": 0.0, + "eval_t1": 1.0, + "resource": {"archive": "material.lib", "name": "fire_smoke"} + }, + "opaque_extra_hex": "..." + } + ] +} +``` + +--- + +## 11. Проверка на реальных данных + +`testdata/nres`: + +- FXID payload: `923`; +- валидация parser'а: `923/923 valid`. + +Распределение opcode: + +- `1: 618` +- `2: 517` +- `3: 1545` +- `4: 202` +- `5: 31` +- `6: 0` (в датасете не встречен, но поддержан) +- `7: 1161` +- `8: 237` +- `9: 266` +- `10: 160` + +Подтверждённые `ResourceRef64` оффсеты: + +- op2 `+84`, op3/4/9 `+136`, op5 `+48`, op7/10 `+144`, op8 `+184`. + +Для op1 найден редкий расширенный хвост (`+160/+192`) в `effects.rlb:r_lightray_w`: + +- `material.lib` / `light_w`. + +--- + +## 12. Практический чек-лист 1:1 + +Для runtime-порта: + +- реализовать `FxHeader60` и parser `sub_10007650`; +- реализовать opcode-классы с методами как в vtable; +- учитывать start/stop/restart контракт manager API; +- воспроизвести `sub_10005C60` + post-flags (`0x20`, `0x200`); +- воспроизвести event loop `sub_10003D30(case 28)`. + +Для toolchain: + +- strict validator по разделу 10.1; +- canonical writer по разделу 10.3; +- field-aware editor + opaque fallback для неизвестных зон. + +--- + +## 13. Что считать «полной» совместимостью + +Практический критерий завершения: + +1. Парсер и writer дают byte-identical round-trip для всех 923 FXID. +2. Runtime-порт выдаёт совпадающие state transitions на одинаковом `dt/seed` (по ключевым полям instance + command state). +3. Все opcode `1..10` поддержаны (включая `6`, даже если отсутствует в текущем датасете). +4. `ResourceRef64` и mode-ветки (`op1`, `op2`, `op9`) совпадают с оригиналом. + +Эта страница покрывает весь наблюдаемый контракт формата/рантайма и полную карту body-полей по всем opcode. + +--- + +## 14. Что осталось до «абсолютных 100%» + +Для практического 1:1 (парсер/writer/runtime на известном контенте) покрытие уже достаточно. +Для «абсолютных 100%» на любых входах и во всех краевых режимах остаются 3 пункта: + +1. FP-детерминизм: оригинал опирается на x87-style вычисления; SSE/fast-math могут давать расхождения в alpha/таймингах. +2. RNG parity: используется `sub_10002220` (16-bit генератор) и глобальные seed-состояния; для bit-exact воспроизведения нужны контрольные трассы оригинала. +3. Редкие ветки данных: в текущем датасете нет opcode `6`, и почти не встречаются хвосты op1 (`+136..223`); для исчерпывающей валидации нужны дополнительные FXID-образцы. + +Что нужно собрать, чтобы закрыть это полностью: + +- frame-by-frame dump из оригинального runtime (alpha, manager flags, per-command state); +- контрольные прогоны при фиксированном `dt` и seed; +- минимум по одному ресурсу на каждую редкую ветку (`op6`, op1-tail с ненулевыми `+136..223`). diff --git a/docs/specs/materials-texm.md b/docs/specs/materials-texm.md new file mode 100644 index 0000000..baa80ae --- /dev/null +++ b/docs/specs/materials-texm.md @@ -0,0 +1,874 @@ +# Materials, WEAR, MAT0 и Texm + +Документ описывает материальную подсистему движка (World3D/Ngi32) на уровне, достаточном для: + +- реализации runtime 1:1; +- создания инструментов чтения/валидации; +- создания инструментов конвертации и редактирования с lossless round-trip. + +Источник: дизассемблированные `tmp/disassembler1/*.c` и `tmp/disassembler2/*.asm`, плюс проверка на `tmp/gamedata`. + +--- + +## 1. Идентификаторы и сущности + +| Сущность | ID (LE uint32) | ASCII | Где используется | +|---|---:|---|---| +| Material resource | `0x3054414D` | `MAT0` | `Material.lib` | +| Wear resource | `0x52414557` | `WEAR` | `.wea` записи в world/mission `.rlb` | +| Texture resource | `0x6D786554` | `Texm` | `Textures.lib`, `lightmap.lib`, другие `.lib/.rlb` | +| Atlas tail chunk | `0x65676150` | `Page` | хвост payload `Texm` | + +Дополнительно: палитры загружаются отдельным путём (через `SetPalettesLib` + `sub_10002B40`) и не являются `Texm`. + +--- + +## 2. Архитектура подсистемы + +### 2.1 Экспортируемые точки входа (World3D) + +- `LoadMatManager` +- `SetPalettesLib` +- `SetTexturesLib` +- `SetMaterialLib` +- `SetLightMapLib` +- `SetGameTime` +- `UnloadAllTextures` + +`Set*Lib` просто копируют строки путей в глобальные буферы; валидации пути нет. + +### 2.2 Дефолтные библиотеки (из `iron3d.dll`) + +- `Textures.lib` +- `Material.lib` +- `LightMap.lib` +- `palettes.lib` (строка собирается как `'p' + "alettes.lib"`) + +### 2.3 Ключевые runtime-хранилища + +1. Менеджер материалов (`LoadMatManager`) — объект `0x470` байт. +2. Кэш текстурных объектов. +3. Кэш lightmap-объектов. +4. Банк загруженных палитр. +5. Глобальный пул определений материалов (`MAT0`). + +--- + +## 3. Layout `MatManager` (0x470) + +Объект содержит 70 таблиц wear/lightmaps (не 140). + +```c +// int-индексы относительно this (DWORD*), размер 284 DWORD = 0x470 +// [0] vtable +// [1] callback iface +// [2] callback data +// [3..72] wearTablePtrs[70] // ptr на массив по 8 байт +// [73..142] wearCounts[70] +// [143] tableCount +// [144..213] lightmapTablePtrs[70] // ptr на массив по 4 байта +// [214..283] lightmapCounts[70] +``` + +### 3.1 Vtable методов (`off_100209E4`) + +| Индекс | Функция | Назначение | +|---:|---|---| +| 0 | `loc_10002CE0` | служебный/RTTI-заглушка | +| 1 | `sub_10002D10` | деструктор + освобождение таблиц | +| 2 | `PreLoadAllTextures` | экспорт, но фактически `retn 4` (заглушка) | +| 3 | `sub_100031F0` | получить материал-фазу по `gameTime` | +| 4 | `sub_10003AE0` | сбросить startTime записи wear к `SetGameTime()` | +| 5 | `sub_10003680` | получить материал-фазу по нормализованному `t` | +| 6 | `sub_10003B10` | загрузить wear/lightmaps (файл/ресурс) | +| 7 | `sub_10003F80` | загрузить wear/lightmaps из буфера | +| 8 | `sub_100031A0` | получить указатель на lightmap texture object | +| 9 | `sub_10003AB0` | получить runtime-метаданные материала | +| 10 | `sub_100031D0` | получить `wearCount` для таблицы | + +### 3.2 Кодирование material-handle + +`uint32 handle = (tableIndex << 16) | wearIndex`. + +- `HIWORD(handle)` -> индекс таблицы `0..69` +- `LOWORD(handle)` -> индекс материала в wear-таблице + +--- + +## 4. Глобальные кэши и их ёмкость + +Ёмкости подтверждены границами циклов/адресов в дизассемблере. + +### 4.1 Кэш текстур (`dword_1014E910`...) + +- Размер слота: `5 DWORD` (20 байт) +- Ёмкость: `777` + +```c +struct TextureSlot { + int32_t resIndex; // +0 индекс записи в NRes (не hash), -1 = свободно + void* textureObject; // +4 + int32_t refCount; // +8 + uint32_t lastZeroRefTime;// +12 время, когда refCount стал 0 + uint32_t loadFlags; // +16 флаги загрузки +}; +``` + +`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC. + +### 4.2 Кэш lightmaps (`dword_10029C98`...) + +- Тот же layout `5 DWORD` +- Ёмкость: `100` + +Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается. + +### 4.3 Пул материалов (`dword_100669F0`...) + +- Шаг: `92 DWORD` (`368` байт) +- Ёмкость: `700` + +Фиксированные поля на шаг `i*92`: + +| DWORD offset | Byte offset | Поле | +|---:|---:|---| +| 0 | 0 | `nameResIndex` (`MAT0` entry index), `-1` = free | +| 1 | 4 | `refCount` | +| 2 | 8 | `phaseCount` | +| 3 | 12 | `phaseArrayPtr` (`phaseCount * 76`) | +| 4 | 16 | `animBlockCount` (`< 20`) | +| 5..84 | 20..339 | `animBlocks[20]` по 16 байт | +| 85 | 340 | metaA (`dword_10066B44`) | +| 86 | 344 | metaB (`dword_10066B48`) | +| 87 | 348 | metaC (`dword_10066B4C`) | +| 88 | 352 | metaD (`dword_10066B50`) | +| 89 | 356 | flagA (`dword_10066B54`) | +| 90 | 360 | nibbleMode (`dword_10066B58`) | +| 91 | 364 | flagB (`dword_10066B5C`) | + +### 4.4 Банк палитр + +- `dword_1013DA58[]` +- Загружается до `286` элементов (26 букв * 11 вариантов) + +--- + +## 5. Загрузка палитр (`sub_10002B40`) + +### 5.1 Генерация имён + +Движок перебирает: + +- буквы `'A'..'Z'` +- суффиксы: `""`, `"0"`, `"1"`, ..., `"9"` + +И формирует имя: + +- `<Letter><Suffix>.PAL` +- примеры: `A.PAL`, `A0.PAL`, ..., `Z9.PAL` + +### 5.2 Индекс палитры + +`paletteIndex = letterIndex * 11 + variantIndex` + +- `letterIndex = 0..25` +- `variantIndex = 0..10` (`""`=0, `"0"`=1, ..., `"9"`=10) + +### 5.3 Поведение + +- Если запись не найдена: `paletteSlots[idx] = 0` +- Если найдена: payload отдаётся в рендер (`render->method+60`) + +--- + +## 6. Формат `MAT0` (`Material.lib`) + +### 6.1 Атрибуты NRes entry + +`sub_10004310` использует: + +- `entry.type` = `MAT0` +- `entry.attr1` (bitfield runtime-флагов) +- `entry.attr2` (версия/вариант заголовка payload) +- `entry.attr3` не используется в runtime-парсере + +Маппинг `attr1`: + +- bit0 (`0x01`) -> добавить флаг `0x200000` в загрузку текстур фазы +- bit1 (`0x02`) -> `flagA=1`; при некоторых HW-условиях дополнительно OR `0x80000` +- bits2..5 -> `nibbleMode = (attr1 >> 2) & 0xF` +- bit6 (`0x40`) -> `flagB=1` + +### 6.2 Payload layout + +```c +struct Mat0Payload { + uint16_t phaseCount; + uint16_t animBlockCount; // должно быть < 20, иначе "Too many animations for material." + + // Если attr2 >= 2: + uint8_t metaA8; + uint8_t metaB8; + // Если attr2 >= 3: + uint32_t metaC32; + // Если attr2 >= 4: + uint32_t metaD32; + + PhaseRecordByte34 phases[phaseCount]; + AnimBlockRaw anim[animBlockCount]; +}; +``` + +Если `attr2 < 2`, runtime-значения по умолчанию: + +- `metaA = 255` +- `metaB = 255` +- `metaC = 1.0f` (`0x3F800000`) +- `metaD = 0` + +### 6.3 `PhaseRecordByte34` -> runtime `76 bytes` + +Сырые 34 байта: + +```c +struct PhaseRecordByte34 { + uint8_t p[18]; // параметры + char textureName[16];// если textureName[0]==0, текстуры нет +}; +``` + +Преобразование в runtime-структуру (точный порядок): + +| Из `p[i]` | В offset runtime | Преобразование | +|---:|---:|---| +| `p[0]` | `+16` | `p[0] / 255.0f` | +| `p[1]` | `+20` | `p[1] / 255.0f` | +| `p[2]` | `+24` | `p[2] / 255.0f` | +| `p[3]` | `+28` | `p[3] * 0.01f` | +| `p[4]` | `+0` | `p[4] / 255.0f` | +| `p[5]` | `+4` | `p[5] / 255.0f` | +| `p[6]` | `+8` | `p[6] / 255.0f` | +| `p[7]` | `+12` | `p[7] / 255.0f` | +| `p[8]` | `+32` | `p[8] / 255.0f` | +| `p[9]` | `+36` | `p[9] / 255.0f` | +| `p[10]` | `+40` | `p[10] / 255.0f` | +| `p[11]` | `+44` | `p[11] / 255.0f` | +| `p[12]` | `+48` | `p[12] / 255.0f` | +| `p[13]` | `+52` | `p[13] / 255.0f` | +| `p[14]` | `+56` | `p[14] / 255.0f` | +| `p[15]` | `+60` | `p[15] / 255.0f` | +| `p[16]` | `+64` | `uint32 = p[16]` | +| `p[17]` | `+72` | `int32 = p[17]` | + +Текстура: + +- `textureName[0] == 0` -> `runtime[+68] = -1` и `runtime[+72] = -1` +- иначе `runtime[+68] = LoadTexture(textureName, flags)` + +### 6.4 Runtime-запись фазы (76 байт) + +```c +struct MaterialPhase76 { + float f0; // +0 + float f1; // +4 + float f2; // +8 + float f3; // +12 + float f4; // +16 + float f5; // +20 + float f6; // +24 + float f7; // +28 + float f8; // +32 + float f9; // +36 + float f10; // +40 + float f11; // +44 + float f12; // +48 + float f13; // +52 + float f14; // +56 + float f15; // +60 + uint32_t u16; // +64 + int32_t texSlot; // +68 (индекс в texture cache, либо -1) + int32_t i18; // +72 +}; +``` + +### 6.5 Анимационные блоки (`animBlockCount`, максимум 19) + +Каждый блок в payload: + +```c +struct AnimBlockRaw { + uint32_t headerRaw; // mode = headerRaw & 7; interpMask = headerRaw >> 3 + uint16_t keyCount; + struct KeyRaw { + uint16_t k0; + uint16_t k1; + uint16_t k2; + } keys[keyCount]; +}; +``` + +Runtime-представление блока = 16 байт: + +```c +struct AnimBlockRuntime { + uint32_t mode; // headerRaw & 7 + uint32_t interpMask;// headerRaw >> 3 + int32_t keyCount; + void* keysPtr; // массив keyCount * 8 +}; +``` + +Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`). + +`k2` в `sub_100031F0/sub_10003680` не используется. +Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате. + +### 6.6 Поиск и fallback + +При `LoadMaterial(name)`: + +- сначала точный поиск в `Material.lib`; +- при промахе лог: `"Material %s not found."`; +- fallback на `DEFAULT`; +- если и `DEFAULT` не найден, берётся индекс `0`. + +--- + +## 7. Выбор текущей material-фазы + +### 7.1 Интерполяция (`sub_10003030`) + +Интерполируются только следующие поля (по `interpMask`): + +- bit `0x02`: `+4,+8,+12` +- bit `0x01`: `+20,+24,+28` +- bit `0x04`: `+36,+40,+44` +- bit `0x08`: `+52,+56,+60` +- bit `0x10`: `+32` + +Не интерполируются и копируются из «текущей» фазы: + +- `+0,+16,+48,+64,+68,+72` + +### 7.2 Выбор по времени (`sub_100031F0`) + +Вход: + +- `handle` (`tableIndex|wearIndex`) +- `animBlockIndex` +- глобальное время `SetGameTime()` (`dword_10032A38`) + +Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte`). + +Режимы `mode = headerRaw & 7`: + +- `0`: loop +- `1`: ping-pong +- `2`: one-shot clamp +- `3`: random (`rand() % cycleLength`) + +Важные детали 1:1: + +- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением); +- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную). +- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case). + +После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300`), который возвращается через out-параметр. + +### 7.3 Выбор по нормализованному `t` (`sub_10003680`) + +Аналогично `sub_100031F0`, но time берётся как `t * cycleLength`. + +Перед вычислением времени применяется runtime-нормализация: + +- если `t < 0.0` или `t > 1.0`, используется `t = 0.5`. + +### 7.4 Сброс времени записи + +`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()`. + +--- + +## 8. Формат `WEAR` (текст) + +`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557`), обычно имя `*.wea`. + +### 8.1 Грамматика + +```text +<wearCount:int>\n +<legacyId:int> <materialName>\n // повторить wearCount раз + +[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка +[LIGHTMAPS\n +<lightmapCount:int>\n +<legacyId:int> <lightmapName>\n // повторить lightmapCount раз] +``` + +- `<legacyId>` читается, но как ключ не используется. +- Идентификатором реально является имя (`materialName` / `lightmapName`). + +### 8.2 Парсеры + +1. `sub_10003B10`: файл/ресурсный режим. +2. `sub_10003F80`: парсер из строкового буфера. + +Различие важно для совместимости: + +- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf`. +- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n`; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS`, иначе парсинг может съехать. + +### 8.3 Поведение и ошибки + +- `wearCount <= 0` (в текстовом файловом режиме) -> `"Illegal wear length."` +- при невозможности открыть wear-файл/entry -> `"Wear <%s> doesn't exist."` +- если найден блок `LIGHTMAPS` и `lightmapCount <= 0` -> `"Illegal lightmaps length."` +- отсутствующий материал -> `"Material %s not found."` + fallback `DEFAULT` +- отсутствующая lightmap -> `"LightMap %s not found."` и slot `-1` +- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения. + +### 8.4 Ограничения runtime + +- Таблиц в `MatManager`: максимум 70 (физический layout). +- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет. + +Инструментам нужно явно валидировать `tableCount < 70`. + +--- + +## 9. Загрузка texture/lightmap по имени + +Общие функции: + +- `sub_10004B10` — texture (`Textures.lib`) +- `sub_10004CB0` — lightmap (`LightMap.lib`) + +### 9.1 Валидация имени + +Алгоритм требует наличие `'.'` в позиции `0..16`. + +Иначе: + +- `"Bad texture name."` +- возврат `-1` + +### 9.2 Palette index из суффикса + +После точки разбирается: + +- `L = toupper(name[dot+1])` +- `D = name[dot+2]` (опционально) +- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)` + +Если `idx < 0`, палитра не подставляется (`0`). +Верхняя граница `idx` в runtime не проверяется. + +Практически в стоковых ассетах имена часто вида `NAME.0`; это даёт `idx < 0`, т.е. без палитровой привязки. +Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива. + +### 9.3 Кэширование + +- Дедупликация по `resIndex`. +- При повторном запросе увеличивается `refCount`, `lastZeroRefTime` сбрасывается в `0`. +- При освобождении материала `refCount` texture/lightmap уменьшается. +- texture: при `refCount -> 0` запоминается `lastZeroRefTime`; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд. +- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor). + +--- + +## 10. Формат `Texm` + +### 10.1 Заголовок 32 байта + +```c +struct TexmHeader32 { + uint32_t magic; // 'Texm' = 0x6D786554 + uint32_t width; + uint32_t height; + uint32_t mipCount; + uint32_t flags4; + uint32_t flags5; + uint32_t unk6; + uint32_t format; +}; +``` + +### 10.2 Поддерживаемые `format` + +Подтверждённые в данных: + +- `0` (палитровый 8-bit) +- `565` +- `4444` +- `888` +- `8888` + +Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации): + +- `556` +- `88` + +### 10.3 Layout payload + +1. `TexmHeader32` +2. если `format == 0`: palette table `256 * 4 = 1024` байта +3. mip-chain пикселей +4. опциональный `Page` chunk + +Расчёт: + +```c +bytesPerPixel = + (format == 0) ? 1 : + (format == 565 || format == 556 || format == 4444 || format == 88) ? 2 : + 4; + +pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i)); +sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount; +``` + +### 10.4 `Page` chunk + +```c +struct PageChunk { + uint32_t magic; // 'Page' + uint32_t rectCount; + struct Rect16 { + int16_t x; + int16_t w; + int16_t y; + int16_t h; + } rects[rectCount]; +}; +``` + +Runtime конвертирует `Rect16` в: + +- пиксельные прямоугольники; +- UV-границы с учётом возможного `mipSkip`. + +Формулы (`s = mipSkip`): + +- `x0 = x << s`, `x1 = (x + w) << s` +- `y0 = y << s`, `y1 = (y + h) << s` +- `u0 = x / (width << s)`, `du = w / (width << s)` +- `v0 = y / (height << s)`, `dv = h / (height << s)` + +Также всегда добавляется базовый rect `[0]` на всю текстуру: пиксели `(0,0,width,height)`, UV `(0,0,1,1)`. + +### 10.5 Loader-поведение (`sub_1000FB30`) + +- Читает header в внутренние поля (`+56..+84`) напрямую: + - `+56 magic`, `+60 width`, `+64 height`, `+68 mipCount`, + - `+72 flags4`, `+76 flags5`, `+80 unk6`, `+84 format`. +- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу. +- Считает `sizeCore`, находит tail. +- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page`. +- Может уменьшать стартовый mip (`sub_1000F580`) в зависимости от размеров/формата/флагов. +- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime. + +### 10.6 Политика `mipSkip` (`sub_1000F580`) + +`mipSkip` зависит от `flags5 & 0x72000000`, `width`, `height`, `mipCount`: + +- если `mipCount <= 1` -> `0` +- если `flags5Mask == 0x02000000` -> `2` при `mipCount > 2`, иначе `1` +- если `flags5Mask == 0x10000000` -> `1` +- если `flags5Mask == 0x20000000`: + - `1`, если `width >= 256` или `height >= 256` + - иначе `0` +- если `flags5Mask == 0x40000000`: + - если `width > 128` и `height > 128`: `2` при `mipCount > 2`, иначе `1` + - если `width == 128` или `height == 128`: `1` + - иначе `0` +- иначе `0` + +Применение в loader: + +- `mipCount -= mipSkip` +- `width >>= mipSkip`, `height >>= mipSkip` +- `pixelDataOffset += bytesPerPixel * origWidth * origHeight` для `mipSkip==1` +- `pixelDataOffset += bytesPerPixel * origWidth * origHeight * 1.25` для `mipSkip==2` (первые два уровня) + +--- + +## 11. Флаги профиля/рендера (Ngi32) + +Ключ реестра: `HKCU\Software\Nikita\NgiTool`. + +Подтверждённые значения: + +- `Disable MultiTexturing` +- `DisableMipmap` +- `Force 16-bit textures` +- `UseFirstCard` +- `DisableD3DCalls` +- `DisableDSound` +- `ForceCpu` + +Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки. + +--- + +## 12. Спецификация для toolchain (read/edit/write) + +### 12.1 Каноническая модель данных + +1. `MAT0`: +- хранить исходные `attr1/attr2/attr3`; +- хранить сырой payload + декодированную структуру; +- при записи сохранять порядок/размеры секций точно. + +2. `WEAR`: +- хранить строки wear/lightmaps как текст; +- сохранять порядок строк; +- допускать отсутствие блока `LIGHTMAPS`. +- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`. + +3. `Texm`: +- хранить header поля как есть (`flags4/flags5/unk6` не нормализовать); +- хранить palette (если есть), mip data, `Page`. + +### 12.2 Правила lossless записи + +- Не менять значения `flags4/flags5/unk6` без явной причины. +- Не менять `NRes` entry attrs, если цель — бинарный round-trip. +- Для `MAT0`: + - `animBlockCount < 20`. + - `phaseCount` и фактический размер секции должны совпадать. + - textureName в фазе всегда укладывать в 16 байт и NUL-терминировать. +- Для `Texm`: + - `magic == 'Texm'`. + - `mipCount > 0`, `width>0`, `height>0`. + - tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт. + - при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`. + +### 12.3 Рекомендованные валидации редактора + +- `WEAR`: + - `wearCount > 0`. + - число строк wear соответствует `wearCount`. + - если есть `LIGHTMAPS`, то `lightmapCount > 0` и число строк совпадает. + - для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS`. +- `MAT0`: + - не выходить за payload при распаковке. + - все ссылки фаз/keys проверять на диапазоны. +- `Texm`: + - `sizeCore <= payload_size`. + - проверка `Page` как `8 + rectCount*8`. + - предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime. + +--- + +## 13. Проверка на реальных данных (`tmp/gamedata`) + +### 13.1 `Material.lib` + +- `905` entries, все `type=MAT0` +- `attr2 = 6` у всех +- `attr3 = 0` у всех +- `phaseCount` до `29` +- `animBlockCount` до `8` (ограничение runtime `<20` соблюдается) + +### 13.2 `Textures.lib` + +- `393` entries, все `type=Texm` +- форматы: `8888(237), 888(52), 565(47), 4444(42), 0(15)` +- `flags4`: `32(361), 0(32)` +- `flags5`: `0(312), 0x04000000(81)` +- `Page` chunk присутствует у `65` текстур + +### 13.3 `lightmap.lib` + +- `25` entries, все `Texm` +- формат: `565` +- `mipCount=1` +- `flags5`: в основном `0`, встречается `0x00800000` + +### 13.4 `WEAR` + +- `439` entries `type=WEAR` +- `attr1=0, attr2=0, attr3=1` +- `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`) +- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`. + +--- + +## 14. Opaque-поля и границы знания + +Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`: + +- `MAT0`: + - `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений); + - `metaA/metaB/metaC/metaD` (в `World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено). +- `Texm`: + - `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1). + +Это не блокирует реализацию движка/конвертеров 1:1. + +--- + +## 15. Минимальные псевдокоды для реализации + +### 15.1 `parse_mat0(payload, attr2)` + +```python +def parse_mat0(payload: bytes, attr2: int): + cur = 0 + phase_count = u16(payload, cur); cur += 2 + anim_count = u16(payload, cur); cur += 2 + if anim_count >= 20: + raise ValueError("Too many animations for material") + + if attr2 < 2: + metaA, metaB, metaC, metaD = 255, 255, 0x3F800000, 0 + else: + metaA = u8(payload, cur); cur += 1 + metaB = u8(payload, cur); cur += 1 + metaC = u32(payload, cur) if attr2 >= 3 else 0x3F800000 + cur += 4 if attr2 >= 3 else 0 + metaD = u32(payload, cur) if attr2 >= 4 else 0 + cur += 4 if attr2 >= 4 else 0 + + phases = [payload[cur + i*34 : cur + (i+1)*34] for i in range(phase_count)] + cur += 34 * phase_count + + anim = [] + for _ in range(anim_count): + raw = u32(payload, cur); cur += 4 + key_count = u16(payload, cur); cur += 2 + keys = [payload[cur + k*6 : cur + (k+1)*6] for k in range(key_count)] + cur += 6 * key_count + anim.append((raw, keys)) + + if cur != len(payload): + raise ValueError("MAT0 tail bytes") + + return phase_count, anim_count, metaA, metaB, metaC, metaD, phases, anim +``` + +### 15.2 `parse_texm(payload)` + +```python +def parse_texm(payload: bytes): + magic, w, h, mips, f4, f5, unk6, fmt = unpack_u32x8(payload, 0) + if magic != 0x6D786554: + raise ValueError("not Texm") + + bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444, 88) else 4) + pix = 0 + mw, mh = w, h + for _ in range(mips): + pix += mw * mh + mw = max(1, mw >> 1) + mh = max(1, mh >> 1) + + core = 32 + (1024 if fmt == 0 else 0) + bpp * pix + if core > len(payload): + raise ValueError("truncated") + + page = None + if core < len(payload): + if core + 8 > len(payload) or payload[core:core+4] != b"Page": + raise ValueError("tail without Page") + n = u32(payload, core + 4) + need = 8 + n * 8 + if core + need != len(payload): + raise ValueError("invalid Page size") + page = [unpack_i16x4(payload, core + 8 + i*8) for i in range(n)] + + return (w, h, mips, fmt, f4, f5, unk6, page) +``` + +### 15.3 `mip_skip_policy(flags5, width, height, mip_count)` + +```python +def mip_skip_policy(flags5: int, width: int, height: int, mip_count: int) -> int: + if mip_count <= 1: + return 0 + + m = flags5 & 0x72000000 + if m == 0x02000000: + return 2 if mip_count > 2 else 1 + if m == 0x10000000: + return 1 + if m == 0x20000000: + return 1 if (width >= 256 or height >= 256) else 0 + if m == 0x40000000: + if width > 128 and height > 128: + return 2 if mip_count > 2 else 1 + if width == 128 or height == 128: + return 1 + return 0 +``` + +### 15.4 `parse_wear_buffer_compatible(text)` + +```python +def parse_wear_buffer_compatible(text: str): + lines = text.splitlines() + i = 0 + + wear_count = int(lines[i].strip()); i += 1 + if wear_count <= 0: + raise ValueError("Illegal wear length.") + + wear = [] + for _ in range(wear_count): + legacy, name = lines[i].split(maxsplit=1) + wear.append((int(legacy), name.strip())) + i += 1 + + lightmaps = [] + tail = lines[i:] if i < len(lines) else [] + if tail and tail[0].strip() == "": + # sub_10003F80-совместимый разделитель перед LIGHTMAPS + i += 1 + tail = lines[i:] + + if tail and tail[0].strip().upper() == "LIGHTMAPS": + i += 1 + if i >= len(lines): + raise ValueError("Illegal lightmaps length.") + light_count = int(lines[i].strip()); i += 1 + if light_count <= 0: + raise ValueError("Illegal lightmaps length.") + for _ in range(light_count): + legacy, name = lines[i].split(maxsplit=1) + lightmaps.append((int(legacy), name.strip())) + i += 1 + + return wear, lightmaps +``` + +### 15.5 `select_phase_time_1to1(...)` + +```python +def select_phase_time_1to1(game_time: int, start_time: int, keys, mode: int): + # keys: list[(phase_index, t_start, t_end)], t_end последнего = cycle_len + cycle_len = keys[-1][2] + if cycle_len <= 0: + return 0, 0.0 + + # unsigned div/mod как в runtime + delta = (game_time - start_time) & 0xFFFFFFFF + q = delta // cycle_len + r = delta % cycle_len + + if mode == 1: # ping-pong + if q & 1: + r = cycle_len - r + elif mode == 2: # one-shot + if q > 0: + k = len(keys) - 1 + return k, 0.0 + elif mode == 3: # random + r = rand32() % cycle_len + start_time = r # side effect как в sub_100031F0 + + k = find_segment(keys, r) # t_start <= r < t_end + kn = 0 if (k + 1 == len(keys)) else (k + 1) + t0, t1 = keys[k][1], keys[k][2] + alpha = 0.0 if t1 == t0 else (r - t0) / float(t1 - t0) + return (k, kn), alpha +``` diff --git a/docs/specs/missions.md b/docs/specs/missions.md new file mode 100644 index 0000000..6f351d0 --- /dev/null +++ b/docs/specs/missions.md @@ -0,0 +1,5 @@ +# Missions + +Документ описывает формат миссий и сценариев: начальное состояние, триггеры и связь миссий с картой мира. + +> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `MisLoad.dll`. diff --git a/docs/specs/msh-animation.md b/docs/specs/msh-animation.md new file mode 100644 index 0000000..811fa00 --- /dev/null +++ b/docs/specs/msh-animation.md @@ -0,0 +1,105 @@ +# MSH animation + +Документ описывает анимационные ресурсы MSH: `Res8`, `Res19` и runtime-интерполяцию. + +--- + +## 1.13. Ресурсы анимации: Res8 и Res19 + +- **Res8** — массив анимационных ключей фиксированного размера 24 байта. +- **Res19** — `uint16` mapping‑массив «frame → keyIndex` (с per-node смещением). + +### 1.13.1. Формат Res8 (ключ 24 байта) + +```c +struct AnimKey24 { + float posX; // +0x00 + float posY; // +0x04 + float posZ; // +0x08 + float time; // +0x0C + int16_t qx; // +0x10 + int16_t qy; // +0x12 + int16_t qz; // +0x14 + int16_t qw; // +0x16 +}; +``` + +Декодирование quaternion-компонент: + +```c +q = s16 * (1.0f / 32767.0f) +``` + +### 1.13.2. Формат Res19 + +Res19 читается как непрерывный массив `uint16`: + +```c +uint16_t map[]; // размер = size(Res19)/2 +``` + +Per-node управление mapping'ом берётся из заголовка узла Res1: + +- `node.hdr2` (`Res1 + 0x04`) = `mapStart` (`0xFFFF` => map отсутствует); +- `node.hdr3` (`Res1 + 0x06`) = `fallbackKeyIndex` и одновременно верхняя граница валидного `map`‑значения. + +### 1.13.3. Выбор ключа для времени `t` (`sub_10012880`) + +1) Вычислить frame‑индекс: + +```c +frame = (int64)(t - 0.5f); // x87 FISTP-путь +``` + +Для строгой 1:1 эмуляции используйте именно поведение x87 `FISTP` (а не «упрощённый floor»), т.к. путь в оригинале опирается на FPU rounding mode. + +2) Проверка условий fallback: + +- `frame >= model.animFrameCount` (`model+0x9C`, из `NResEntry(Res19).attr2`); +- `mapStart == 0xFFFF`; +- `map[mapStart + frame] >= fallbackKeyIndex`. + +Если любое условие истинно: + +```c +keyIndex = fallbackKeyIndex; +``` + +Иначе: + +```c +keyIndex = map[mapStart + frame]; +``` + +3) Сэмплирование: + +- `k0 = Res8[keyIndex]` +- `k1 = Res8[keyIndex + 1]` (для интерполяции сегмента) + +Пути: + +- если `t == k0.time` → взять `k0`; +- если `t == k1.time` → взять `k1`; +- иначе `alpha = (t - k0.time) / (k1.time - k0.time)`, `pos = lerp(k0.pos, k1.pos, alpha)`, rotation смешивается через fastproc‑интерполятор quaternion. + +### 1.13.4. Межкадровое смешивание (`sub_10012560`) + +Функция смешивает два сэмпла (например, из двух animation time-позиций) с коэффициентом `blend`: + +1) получить два `(quat, pos)` через `sub_10012880`; +2) выполнить shortest‑path коррекцию знака quaternion: + +```c +if (|q0 + q1|^2 < |q0 - q1|^2) q1 = -q1; +``` + +3) смешать quaternion (fastproc) и построить orientation‑матрицу; +4) translation писать отдельно как `lerp(pos0, pos1, blend)` в ячейки `m[3], m[7], m[11]`. + +### 1.13.5. Что хранится в `Res19.attr2` + +При загрузке `sub_10015FD0` записывает `NResEntry(Res19).attr2` в `model+0x9C`. +Это поле используется как верхняя граница frame‑индекса в п.1.13.3. + +--- + diff --git a/docs/specs/msh-core.md b/docs/specs/msh-core.md new file mode 100644 index 0000000..82aec18 --- /dev/null +++ b/docs/specs/msh-core.md @@ -0,0 +1,492 @@ +# MSH core + +Документ описывает core-часть формата MSH: геометрию, узлы, батчи, LOD и slot-матрицу. + +Связанный формат контейнера: [NRes / RsLi](nres.md). + +--- + +## 1.1. Общая архитектура + +Модель состоит из набора именованных ресурсов внутри одного NRes‑архива. Каждый ресурс идентифицируется **целочисленным типом** (`resource_type`), который передаётся API функции `niReadData` (vtable‑метод `+0x18`) через связку `niFind` (vtable‑метод `+0x0C`, `+0x20`). + +Рендер‑модель использует **rigid‑скининг по узлам** (нет per‑vertex bone weights). Каждый batch геометрии привязан к одному узлу и рисуется с матрицей этого узла. + +## 1.2. Общая структура файла модели + +``` +┌────────────────────────────────────┐ +│ NRes‑заголовок (16 байт) │ +├────────────────────────────────────┤ +│ Ресурсы (произвольный порядок): │ +│ Res1 — Node table │ +│ Res2 — Model header + Slots │ +│ Res3 — Vertex positions │ +│ Res4 — Packed normals │ +│ Res5 — Packed UV0 │ +│ Res6 — Index buffer │ +│ Res7 — Triangle descriptors │ +│ Res8 — Keyframe data │ +│ Res10 — String table │ +│ Res13 — Batch table │ +│ Res19 — Animation mapping │ +│ [Res15] — UV1 / доп. поток │ +│ [Res16] — Tangent/Bitangent │ +│ [Res18] — Vertex color │ +│ [Res20] — Доп. таблица │ +├────────────────────────────────────┤ +│ NRes‑каталог │ +└────────────────────────────────────┘ +``` + +Ресурсы в квадратных скобках — **опциональные**. Загрузчик проверяет их наличие перед чтением (`niFindRes` возвращает `−1` при отсутствии). + +## 1.3. Порядок загрузки ресурсов (из `sub_10015FD0` в AniMesh.dll) + +Функция `sub_10015FD0` выполняет инициализацию внутренней структуры модели размером **0xA4** (164 байта). Ниже приведён точный порядок загрузки и маппинг ресурсов на поля структуры: + +| Шаг | Тип ресурса | Поле структуры | Описание | +|-----|-------------|----------------|-----------------------------------------| +| 1 | 1 | `+0x00` | Node table (Res1) | +| 2 | 2 | `+0x04` | Model header (Res2) | +| 3 | 3 | `+0x0C` | Vertex positions (Res3) | +| 4 | 4 | `+0x10` | Packed normals (Res4) | +| 5 | 5 | `+0x14` | Packed UV0 (Res5) | +| 6 | 10 (0x0A) | `+0x20` | String table (Res10) | +| 7 | 8 | `+0x18` | Keyframe / animation track data (Res8) | +| 8 | 19 (0x13) | `+0x1C` | Animation mapping (Res19) | +| 9 | 7 | `+0x24` | Triangle descriptors (Res7) | +| 10 | 13 (0x0D) | `+0x28` | Batch table (Res13) | +| 11 | 6 | `+0x2C` | Index buffer (Res6) | +| 12 | 15 (0x0F) | `+0x34` | Доп. vertex stream (Res15), опционально | +| 13 | 16 (0x10) | `+0x38` | Доп. vertex stream (Res16), опционально | +| 14 | 18 (0x12) | `+0x64` | Vertex color (Res18), опционально | +| 15 | 20 (0x14) | `+0x30` | Доп. таблица (Res20), опционально | + +### Производные поля (вычисляются после загрузки) + +| Поле | Формула | Описание | +|---------|-------------------------|------------------------------------------------------------------------------------------------| +| `+0x08` | `Res2_ptr + 0x8C` | Указатель на slot table (140 байт от начала Res2) | +| `+0x3C` | `= Res3_ptr` | Копия указателя positions (stream ptr) | +| `+0x40` | `= 0x0C` (12) | Stride позиций: `sizeof(float3)` | +| `+0x44` | `= Res4_ptr` | Копия указателя normals (stream ptr) | +| `+0x48` | `= 4` | Stride нормалей: 4 байта | +| `+0x4C` | `Res16_ptr` или `0` | Stream A Res16 (tangent) | +| `+0x50` | `= 8` если `+0x4C != 0` | Stride stream A (используется только при наличии Res16) | +| `+0x54` | `Res16_ptr + 4` или `0` | Stream B Res16 (bitangent) | +| `+0x58` | `= 8` если `+0x54 != 0` | Stride stream B (используется только при наличии Res16) | +| `+0x5C` | `= Res5_ptr` | Копия указателя UV0 (stream ptr) | +| `+0x60` | `= 4` | Stride UV0: 4 байта | +| `+0x68` | `= 4` или `0` | Stride Res18 (если найден) | +| `+0x8C` | `= Res15_ptr` | Копия указателя Res15 | +| `+0x90` | `= 8` | Stride Res15: 8 байт | +| `+0x94` | `= 0` | Зарезервировано/unk94: инициализируется нулём при загрузке; не является флагом Res18 | +| `+0x9C` | NRes entry Res19 `+8` | Метаданные из каталожной записи Res19 | +| `+0xA0` | NRes entry Res20 `+4` | Метаданные из каталожной записи Res20 (заполняется только если Res20 найден и открыт, иначе 0) | + +**Примечание к метаданным:** поле `+0x9C` читается из каталожной записи NRes для ресурса 19 (смещение `+8` в записи каталога, т.е. `attribute_2`). Поле `+0xA0` — из каталожной записи для ресурса 20 (смещение `+4`, т.е. `attribute_1`) **только если Res20 найден и `niOpenRes` вернул ненулевой указатель**; иначе `+0xA0 = 0`. Индекс записи определяется как `entry_index * 64`, после чего считывается поле. + +--- + +### 1.3.1. Ссылки на функции и паттерны вызовов (для проверки реверса) + +- `AniMesh.dll!sub_10015FD0` — загрузка ресурсов модели через vtable интерфейса NRes: + - `niFindRes(type, ...)` вызывается через `call [vtable+0x20]` + - `niOpenRes(...)` / чтение указателя — через `call [vtable+0x18]` +- `AniMesh.dll!sub_10015FD0` выставляет производные поля (`Res2_ptr+0x8C`, stride'ы), обнуляет `model+0x94`, и при отсутствии Res16 обнуляет только указатели потоков (`+0x4C`, `+0x54`). +- `AniMesh.dll!sub_10004840` / `sub_10004870` / `sub_100048A0` — использование runtime mapping‑таблицы (`+0x18`, индекс `boneId*4`) и таблицы указателей треков (`+0x08`) после построения анимационного объекта. + + +## 1.4. Ресурс Res2 — Model Header (140 байт) + Slot Table + +Ресурс Res2 содержит: + +``` +┌───────────────────────────────────┐ Смещение 0 +│ Model Header (140 байт = 0x8C) │ +├───────────────────────────────────┤ Смещение 140 (0x8C) +│ Slot Table │ +│ (slot_count × 68 байт) │ +└───────────────────────────────────┘ +``` + +### 1.4.1. Model Header (первые 140 байт) + +Поле `Res2[0x00..0x8B]` используется как **35 float** (без внутренних таблиц/индексов). Это подтверждено прямыми копированиями в `AniMesh.dll!sub_1000A460`: + +- `qmemcpy(this+0x54, Res2+0x00, 0x60)` — первые 24 float; +- копирование `Res2+0x60` размером `0x10` — ещё 4 float; +- `qmemcpy(this+0x134, Res2+0x70, 0x1C)` — ещё 7 float. + +Итоговая раскладка: + +| Диапазон | Размер | Тип | Семантика | +|--------------|--------|-------------|----------------------------------------------------------------------| +| `0x00..0x5F` | `0x60` | `float[24]` | 8 вершин глобального bounding‑hull (`vec3[8]`) | +| `0x60..0x6F` | `0x10` | `float[4]` | Глобальная bounding‑sphere: `center.xyz + radius` | +| `0x70..0x8B` | `0x1C` | `float[7]` | Глобальный «капсульный»/сегментный bound: `A.xyz`, `B.xyz`, `radius` | + +Для рендера и broadphase движок использует как слот‑bounds (`Res2 slot`), так и этот глобальный набор bounds (в зависимости от контекста вызова/LOD и наличия слота). + +### 1.4.2. Slot Table (массив записей по 68 байт) + +Slot — ключевая структура, связывающая узел иерархии с конкретной геометрией для конкретного LOD и группы. Каждая запись — **68 байт** (0x44). + +**Важно:** смещения в таблице ниже указаны в **десятичном формате** (байты). В скобках приведён hex‑эквивалент (например, 48 (0x30)). + + +| Смещение | Размер | Тип | Описание | +|-----------|--------|----------|-----------------------------------------------------| +| 0 | 2 | uint16 | `triStart` — индекс первого треугольника в Res7 | +| 2 | 2 | uint16 | `triCount` — длина диапазона треугольников (`Res7`) | +| 4 | 2 | uint16 | `batchStart` — индекс первого batch'а в Res13 | +| 6 | 2 | uint16 | `batchCount` — количество batch'ей | +| 8 | 4 | float | `aabbMin.x` | +| 12 | 4 | float | `aabbMin.y` | +| 16 | 4 | float | `aabbMin.z` | +| 20 | 4 | float | `aabbMax.x` | +| 24 | 4 | float | `aabbMax.y` | +| 28 | 4 | float | `aabbMax.z` | +| 32 | 4 | float | `sphereCenter.x` | +| 36 | 4 | float | `sphereCenter.y` | +| 40 | 4 | float | `sphereCenter.z` | +| 44 (0x2C) | 4 | float | `sphereRadius` | +| 48 (0x30) | 20 | 5×uint32 | Хвостовые поля: `unk30..unk40` (см. §1.4.2.1) | + +**AABB** — axis‑aligned bounding box в локальных координатах узла. +**Bounding Sphere** — описанная сфера в локальных координатах узла. + +#### 1.4.2.1. Точная семантика `triStart/triCount` + +В `AniMesh.dll!sub_1000B2C0` слот считается «владельцем» треугольника `triId`, если: + +```c +triId >= slot.triStart && triId < slot.triStart + slot.triCount +``` + +Это прямое доказательство, что `slot +0x02` — именно **count диапазона**, а не флаги. + +#### 1.4.2.2. Хвост слота (20 байт = 5×uint32) + +Последние 20 байт записи слота трактуем как 5 последовательных 32‑битных значений (little‑endian). Их назначение пока не подтверждено; для инструментов рекомендуется сохранять и восстанавливать их «как есть». + +- `+48 (0x30)`: `unk30` (uint32) +- `+52 (0x34)`: `unk34` (uint32) +- `+56 (0x38)`: `unk38` (uint32) +- `+60 (0x3C)`: `unk3C` (uint32) +- `+64 (0x40)`: `unk40` (uint32) + +Для culling при рендере: AABB/sphere трансформируются матрицей узла и инстанса. При неравномерном scale радиус сферы масштабируется по `max(scaleX, scaleY, scaleZ)` (подтверждено по коду). + +--- + +### 1.4.3. Восстановление счётчиков элементов по размерам ресурсов (практика для инструментов) + +Для toolchain надёжнее считать count'ы по размерам ресурсов (а не по дублирующим полям других таблиц). Это полностью совпадает с тем, как рантайм использует fixed stride'ы в `sub_10015FD0`. + +Берите **unpacked_size** (или фактический размер распакованного блока) соответствующего ресурса и вычисляйте: + +- `node_count` = `size(Res1) / 38` +- `vertex_count` = `size(Res3) / 12` +- `normals_count` = `size(Res4) / 4` +- `uv0_count` = `size(Res5) / 4` +- `index_count` = `size(Res6) / 2` +- `tri_count` = `index_count / 3` (если примитивы — список треугольников) +- `tri_desc_count` = `size(Res7) / 16` +- `batch_count` = `size(Res13) / 20` +- `slot_count` = `(size(Res2) - 0x8C) / 0x44` +- `anim_key_count` = `size(Res8) / 24` +- `anim_map_count` = `size(Res19) / 2` +- `uv1_count` = `size(Res15) / 8` (если Res15 присутствует) +- `tbn_count` = `size(Res16) / 8` (если Res16 присутствует; tangent/bitangent по 4 байта, stride 8) +- `color_count` = `size(Res18) / 4` (если Res18 присутствует) + +**Валидация:** + +- Любое деление должно быть **без остатка**; иначе ресурс повреждён или stride неверно угадан. +- Если присутствуют Res4/Res5/Res15/Res16/Res18, их count'ы по смыслу должны совпадать с `vertex_count` (или быть ≥ него, если формат допускает хвостовые данные — пока не наблюдалось). +- Для `slot_count` дополнительно проверьте, что `size(Res2) >= 0x8C`. + +**Проверка на реальных данных (435 MSH):** + +- `Res2.attr1 == (size-140)/68`, `Res2.attr2 == 0`, `Res2.attr3 == 68`; +- `Res7.attr1 == size/16`, `Res7.attr3 == 16`; +- `Res8.attr1 == size/24`, `Res8.attr3 == 4`; +- `Res19.attr1 == size/2`, `Res19.attr3 == 2`; +- для `Res1` почти всегда `attr3 == 38` (один служебный outlier: `MTCHECK.MSH` с `attr3 == 24`). + +Эти формулы достаточны, чтобы реализовать распаковщик/просмотрщик геометрии и батчей даже без полного понимания полей заголовка Res2. + +## 1.5. Ресурс Res1 — Node Table (38 байт на узел) + +Node table — компактная карта слотов по уровням LOD и группам. Каждый узел занимает **38 байт** (19 × `uint16`). + +### Адресация слота + +Движок вычисляет индекс слова в таблице: + +``` +word_index = nodeIndex × 19 + lod × 5 + group + 4 +slot_index = node_table[word_index] // uint16, 0xFFFF = нет слота +``` + +Параметры: + +- `lod`: 0..2 (три уровня детализации). Значение `−1` → подставляется `current_lod` из инстанса. +- `group`: 0..4 (пять групп). На практике чаще всего используется `group = 0`. + +### Раскладка записи узла (38 байт) + +``` +┌───────────────────────────────────────────────────────┐ +│ Header: 4 × uint16 (8 байт) │ +│ hdr0, hdr1, hdr2, hdr3 │ +├───────────────────────────────────────────────────────┤ +│ SlotIndex matrix: 3 LOD × 5 groups = 15 × uint16 │ +│ LOD 0: group[0..4] │ +│ LOD 1: group[0..4] │ +│ LOD 2: group[0..4] │ +└───────────────────────────────────────────────────────┘ +``` + +| Смещение | Размер | Тип | Описание | +|----------|--------|------------|-----------------------------------------| +| 0 | 8 | uint16[4] | Заголовок узла (`hdr0..hdr3`, см. ниже) | +| 8 | 30 | uint16[15] | Матрица слотов: `slotIndex[lod][group]` | + +`slotIndex = 0xFFFF` означает «слот отсутствует» — узел при данном LOD и группе не рисуется. + +Подтверждённые семантики полей `hdr*`: + +- `hdr1` (`+0x02`) — parent/index-link при построении инстанса (в `sub_1000A460` читается как индекс связанного узла, `0xFFFF` = нет связи). +- `hdr2` (`+0x04`) — `mapStart` для Res19 (`0xFFFF` = нет карты; fallback по `hdr3`). +- `hdr3` (`+0x06`) — `fallbackKeyIndex`/верхняя граница для map‑значений (используется в `sub_10012880`). + +`hdr0` (`+0x00`) по коду участвует в битовых проверках (`&0x40`, `byte+1 & 8`) и несёт флаги узла. + +**Группы (group 0..4):** в рантайме это ортогональный индекс к LOD (матрица 5×3 на узел). Имена групп в оригинальных ресурсах не подписаны; для 1:1 нужно сохранять группы как «сырой» индекс 0..4 без переинтерпретации. + +--- + +## 1.6. Ресурс Res3 — Vertex Positions + +**Формат:** массив `float3` (IEEE 754 single‑precision). +**Stride:** 12 байт. + +```c +struct Position { + float x; // +0 + float y; // +4 + float z; // +8 +}; +``` + +Чтение: `pos = *(float3*)(res3_data + 12 * vertexIndex)`. + +--- + +## 1.7. Ресурс Res4 — Packed Normals + +**Формат:** 4 байта на вершину. +**Stride:** 4 байта. + +```c +struct PackedNormal { + int8_t nx; // +0 + int8_t ny; // +1 + int8_t nz; // +2 + int8_t nw; // +3 (назначение не подтверждено: паддинг / знак / индекс) +}; +``` + +### Алгоритм декодирования (подтверждено по AniMesh.dll) + +> В движке используется делитель **127.0**, а не 128.0 (см. константу `127.0` рядом с `1024.0`/`32767.0`). + +``` +normal.x = clamp((float)nx / 127.0, -1.0, 1.0) +normal.y = clamp((float)ny / 127.0, -1.0, 1.0) +normal.z = clamp((float)nz / 127.0, -1.0, 1.0) +``` + +**Множитель:** `1.0 / 127.0 ≈ 0.0078740157`. +**Диапазон входных значений:** −128..+127 → выход ≈ −1.007874..+1.0 → **после клампа** −1.0..+1.0. +**Почему нужен кламп:** значение `-128` при делении на `127.0` даёт модуль чуть больше 1. +**4‑й байт (nw):** используется ли он как часть нормали, как индекс или просто как выравнивание — не подтверждено. Рекомендация: игнорировать при первичном импорте. + +--- + +## 1.8. Ресурс Res5 — Packed UV0 + +**Формат:** 4 байта на вершину (два `int16`). +**Stride:** 4 байта. + +```c +struct PackedUV { + int16_t u; // +0 + int16_t v; // +2 +}; +``` + +### Алгоритм декодирования + +``` +uv.u = (float)u / 1024.0 +uv.v = (float)v / 1024.0 +``` + +**Множитель:** `1.0 / 1024.0 = 0.0009765625`. +**Диапазон входных значений:** −32768..+32767 → выход ≈ −32.0..+31.999. +Значения >1.0 или <0.0 означают wrapping/repeat текстурных координат. + +### Алгоритм кодирования (для экспортёра) + +``` +packed_u = (int16_t)round(uv.u * 1024.0) +packed_v = (int16_t)round(uv.v * 1024.0) +``` + +Результат обрезается (clamp) до диапазона `int16` (−32768..+32767). + +--- + +## 1.9. Ресурс Res6 — Index Buffer + +**Формат:** массив `uint16` (беззнаковые 16‑битные индексы). +**Stride:** 2 байта. + +Максимальное число вершин в одном batch: 65535. +Индексы используются совместно с `baseVertex` из batch table: + +``` +actual_vertex_index = index_buffer[indexStart + i] + baseVertex +``` + +--- + +## 1.10. Ресурс Res7 — Triangle Descriptors + +**Формат:** массив записей по 16 байт. Одна запись на треугольник. + +| Смещение | Размер | Тип | Описание | +|----------|--------|----------|---------------------------------------------| +| `+0x00` | 2 | `uint16` | `triFlags` — фильтрация/материал tri‑уровня | +| `+0x02` | 2 | `uint16` | `linkTri0` — tri‑ref для связанного обхода | +| `+0x04` | 2 | `uint16` | `linkTri1` — tri‑ref для связанного обхода | +| `+0x06` | 2 | `uint16` | `linkTri2` — tri‑ref для связанного обхода | +| `+0x08` | 2 | `int16` | `nX` (packed, scale `1/32767`) | +| `+0x0A` | 2 | `int16` | `nY` (packed, scale `1/32767`) | +| `+0x0C` | 2 | `int16` | `nZ` (packed, scale `1/32767`) | +| `+0x0E` | 2 | `uint16` | `selPacked` — 3 селектора по 2 бита | + +Расшифровка `selPacked` (`AniMesh.dll!sub_10013680`): + +```c +sel0 = selPacked & 0x3; if (sel0 == 3) sel0 = 0xFFFF; +sel1 = (selPacked >> 2) & 0x3; if (sel1 == 3) sel1 = 0xFFFF; +sel2 = (selPacked >> 4) & 0x3; if (sel2 == 3) sel2 = 0xFFFF; +``` + +`linkTri*` передаются в `sub_1000B2C0` и используются для построения соседнего набора треугольников при коллизии/пикинге. + +**Важно:** дескрипторы не хранят индексы вершин треугольника. Индексы берутся из Res6 (index buffer) через `indexStart`/`indexCount` соответствующего batch'а. + +Дескрипторы используются при обходе треугольников для коллизии и пикинга. `triStart` из slot table указывает, с какого дескриптора начинать обход для данного слота. + +--- + +## 1.11. Ресурс Res13 — Batch Table + +**Формат:** массив записей по 20 байт. Batch — минимальная единица отрисовки. + +| Смещение | Размер | Тип | Описание | +|----------|--------|--------|---------------------------------------------------------| +| 0 | 2 | uint16 | `batchFlags` — битовая маска для фильтрации | +| 2 | 2 | uint16 | `materialIndex` — индекс материала | +| 4 | 2 | uint16 | `unk4` — неподтверждённое поле | +| 6 | 2 | uint16 | `unk6` — вероятный `nodeIndex` (привязка batch к кости) | +| 8 | 2 | uint16 | `indexCount` — число индексов (кратно 3) | +| 10 | 4 | uint32 | `indexStart` — стартовый индекс в Res6 (в элементах) | +| 14 | 2 | uint16 | `unk14` — неподтверждённое поле | +| 16 | 4 | uint32 | `baseVertex` — смещение вершинного индекса | + +### Использование при рендере + +``` +for i in 0 .. indexCount-1: + raw_index = index_buffer[indexStart + i] + vertex_index = raw_index + baseVertex + position = res3[vertex_index] + normal = decode_normal(res4[vertex_index]) + uv = decode_uv(res5[vertex_index]) +``` + +**Примечание:** движок читает `indexStart` как `uint32` и умножает на 2 для получения байтового смещения в массиве `uint16`. + +--- + +## 1.12. Ресурс Res10 — String Table + +Res10 — это **последовательность записей, индексируемых по `nodeIndex`** (см. `AniMesh.dll!sub_10012530`). + +Формат одной записи: + +```c +struct Res10Record { + uint32_t len; // число символов без терминирующего '\0' + char text[]; // если len > 0: хранится len+1 байт (включая '\0') + // если len == 0: payload отсутствует +}; +``` + +Переход к следующей записи: + +```c +next = cur + 4 + (len ? (len + 1) : 0); +``` + +`sub_10012530` возвращает: + +- `NULL`, если `len == 0`; +- `record + 4`, если `len > 0` (указатель на C‑строку). + +Это значение используется в `sub_1000A460` для проверки имени текущего узла (например, поиск подстроки `"central"` при обработке node‑флагов). + +--- + + +--- + +## 1.14. Опциональные vertex streams + +### Res15 — Дополнительный vertex stream (stride 8) + +- **Stride:** 8 байт на вершину. +- **Кандидаты:** `float2 uv1` (lightmap / second UV layer), 4 × `int16` (2 UV‑пары), либо иной формат. +- Загружается условно — если ресурс 15 отсутствует, указатель равен `NULL`. + +### Res16 — Tangent / Bitangent (stride 8, split 2×4) + +- **Stride:** 8 байт на вершину (2 подпотока по 4 байта). +- При загрузке движок создаёт **два перемежающихся (interleaved) подпотока**: + - Stream A: `base + 0`, stride 8 — 4 байта (кандидат: packed tangent, `int8 × 4`) + - Stream B: `base + 4`, stride 8 — 4 байта (кандидат: packed bitangent, `int8 × 4`) +- Если ресурс 16 отсутствует, оба указателя обнуляются. +- **Важно:** в оригинальном `sub_10015FD0` при отсутствии Res16 страйды `+0x50/+0x58` явным образом не обнуляются; это безопасно, потому что оба указателя равны `NULL` и код не должен обращаться к потокам без проверки указателя. +- Декодирование предположительно аналогично нормалям: `component / 127.0` (как Res4), но требует подтверждения; при импорте — кламп в [-1..1]. + +### Res18 — Vertex Color (stride 4) + +- **Stride:** 4 байта на вершину. +- **Кандидаты:** `D3DCOLOR` (BGRA), packed параметры освещения, vertex AO. +- Загружается условно (через проверку `niFindRes` на возврат `−1`). + +### Res20 — Дополнительная таблица + +- Присутствует не всегда. +- Из каталожной записи NRes считывается поле `attribute_1` (смещение `+4`) и сохраняется как метаданные. +- **Кандидаты:** vertex remap, дополнительные данные для эффектов/деформаций. + +--- + diff --git a/docs/specs/msh-notes.md b/docs/specs/msh-notes.md new file mode 100644 index 0000000..1bd4808 --- /dev/null +++ b/docs/specs/msh-notes.md @@ -0,0 +1,277 @@ +# 3D implementation notes + +Контрольные заметки, сводки алгоритмов и остаточные семантические вопросы по 3D-подсистемам. + +--- + +## 5.1. Порядок байт + +Все значения хранятся в **little‑endian** порядке (платформа x86/Win32). + +## 5.2. Выравнивание + +- **NRes‑ресурсы:** данные каждого ресурса внутри NRes‑архива выровнены по границе **8 байт** (0‑padding). +- **Внутренняя структура ресурсов:** таблицы Res1/Res2/Res7/Res13 не имеют межзаписевого выравнивания — записи идут подряд. +- **Vertex streams:** stride'ы фиксированы (12/4/8 байт) — вершинные данные идут подряд без паддинга. + +## 5.3. Размеры записей на диске + +| Ресурс | Запись | Размер (байт) | Stride | +|--------|-----------|---------------|-------------------------| +| Res1 | Node | 38 | 38 (19×u16) | +| Res2 | Slot | 68 | 68 | +| Res3 | Position | 12 | 12 (3×f32) | +| Res4 | Normal | 4 | 4 (4×s8) | +| Res5 | UV0 | 4 | 4 (2×s16) | +| Res6 | Index | 2 | 2 (u16) | +| Res7 | TriDesc | 16 | 16 | +| Res8 | AnimKey | 24 | 24 | +| Res10 | StringRec | переменный | `4 + (len ? len+1 : 0)` | +| Res13 | Batch | 20 | 20 | +| Res19 | AnimMap | 2 | 2 (u16) | +| Res15 | VtxStr | 8 | 8 | +| Res16 | VtxStr | 8 | 8 (2×4) | +| Res18 | VtxStr | 4 | 4 | + +## 5.4. Вычисление количества элементов + +Количество записей вычисляется из размера ресурса: + +``` +count = resource_data_size / record_stride +``` + +Например: + +- `vertex_count = res3_size / 12` +- `index_count = res6_size / 2` +- `batch_count = res13_size / 20` +- `slot_count = (res2_size - 140) / 68` +- `node_count = res1_size / 38` +- `tri_desc_count = res7_size / 16` +- `anim_key_count = res8_size / 24` +- `anim_map_count = res19_size / 2` + +Для Res10 нет фиксированного stride: нужно последовательно проходить записи `u32 len` + `(len ? len+1 : 0)` байт. + +## 5.5. Идентификация ресурсов в NRes + +Ресурсы модели идентифицируются по полю `type` (смещение 0) в каталожной записи NRes. Загрузчик использует `niFindRes(archive, type, subtype)` для поиска, где `type` — число (1, 2, 3, ... 20), а `subtype` (byte) — уточнение (из аргумента загрузчика). + +## 5.6. Минимальный набор для рендера + +Для статической модели без анимации достаточно: + +| Ресурс | Обязательность | +|--------|------------------------------------------------| +| Res1 | Да | +| Res2 | Да | +| Res3 | Да | +| Res4 | Рекомендуется | +| Res5 | Рекомендуется | +| Res6 | Да | +| Res7 | Для коллизии | +| Res13 | Да | +| Res10 | Желательно (узловые имена/поведенческие ветки) | +| Res8 | Нет (анимация) | +| Res19 | Нет (анимация) | +| Res15 | Нет | +| Res16 | Нет | +| Res18 | Нет | +| Res20 | Нет | + +## 5.7. Сводка алгоритмов декодирования + +### Позиции (Res3) + +```python +def decode_position(data, vertex_index): + offset = vertex_index * 12 + x = struct.unpack_from('<f', data, offset)[0] + y = struct.unpack_from('<f', data, offset + 4)[0] + z = struct.unpack_from('<f', data, offset + 8)[0] + return (x, y, z) +``` + +### Нормали (Res4) + +```python +def decode_normal(data, vertex_index): + offset = vertex_index * 4 + nx = struct.unpack_from('<b', data, offset)[0] # int8 + ny = struct.unpack_from('<b', data, offset + 1)[0] + nz = struct.unpack_from('<b', data, offset + 2)[0] + # nw = data[offset + 3] # не используется + return ( + max(-1.0, min(1.0, nx / 127.0)), + max(-1.0, min(1.0, ny / 127.0)), + max(-1.0, min(1.0, nz / 127.0)), + ) +``` + +### UV‑координаты (Res5) + +```python +def decode_uv(data, vertex_index): + offset = vertex_index * 4 + u = struct.unpack_from('<h', data, offset)[0] # int16 + v = struct.unpack_from('<h', data, offset + 2)[0] + return (u / 1024.0, v / 1024.0) +``` + +### Кодирование нормали (для экспортёра) + +```python +def encode_normal(nx, ny, nz): + return ( + max(-128, min(127, int(round(nx * 127.0)))), + max(-128, min(127, int(round(ny * 127.0)))), + max(-128, min(127, int(round(nz * 127.0)))), + 0 # nw = 0 (безопасное значение) + ) +``` + +### Кодирование UV (для экспортёра) + +```python +def encode_uv(u, v): + return ( + max(-32768, min(32767, int(round(u * 1024.0)))), + max(-32768, min(32767, int(round(v * 1024.0)))) + ) +``` + +### Строки узлов (Res10) + +```python +def parse_res10_for_nodes(buf: bytes, node_count: int) -> list[str | None]: + out = [] + off = 0 + for _ in range(node_count): + ln = struct.unpack_from('<I', buf, off)[0] + off += 4 + if ln == 0: + out.append(None) + continue + raw = buf[off:off + ln + 1] # len + '\0' + out.append(raw[:-1].decode('ascii', errors='replace')) + off += ln + 1 + return out +``` + +### Ключ анимации (Res8) и mapping (Res19) + +```python +def decode_anim_key24(buf: bytes, idx: int): + o = idx * 24 + px, py, pz, t = struct.unpack_from('<4f', buf, o) + qx, qy, qz, qw = struct.unpack_from('<4h', buf, o + 16) + s = 1.0 / 32767.0 + return (px, py, pz), t, (qx * s, qy * s, qz * s, qw * s) +``` + +### Эффектный поток (FXID) + +```python +FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208} + +def parse_fx_payload(raw: bytes): + cmd_count = struct.unpack_from('<I', raw, 0)[0] + ptr = 0x3C + cmds = [] + for _ in range(cmd_count): + w = struct.unpack_from('<I', raw, ptr)[0] + op = w & 0xFF + enabled = (w >> 8) & 1 + size = FX_CMD_SIZE[op] + cmds.append((op, enabled, ptr, size)) + ptr += size + if ptr != len(raw): + raise ValueError('tail bytes after command stream') + return cmds +``` + +### Texm (header + mips + Page) + +```python +def parse_texm(raw: bytes): + magic, w, h, mips, f4, f5, unk6, fmt = struct.unpack_from('<8I', raw, 0) + assert magic == 0x6D786554 # 'Texm' + bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4) + pix_sum = 0 + mw, mh = w, h + for _ in range(mips): + pix_sum += mw * mh + mw = max(1, mw >> 1) + mh = max(1, mh >> 1) + off = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum + page = None + if off + 8 <= len(raw) and raw[off:off+4] == b'Page': + n = struct.unpack_from('<I', raw, off + 4)[0] + page = [struct.unpack_from('<4h', raw, off + 8 + i * 8) for i in range(n)] + return (w, h, mips, fmt, f4, f5, unk6, page) +``` + +--- + +# Часть 6. Остаточные семантические вопросы + +Пункты ниже **не блокируют 1:1-парсинг/рендер/интерполяцию** (все бинарные структуры уже определены), но их человеко‑читаемая трактовка может быть уточнена дополнительно. + +## 6.1. Batch table — смысл `unk4/unk6/unk14` + +Физическое расположение полей известно, но доменное имя/назначение не зафиксировано: + +- `unk4` (`+0x04`) +- `unk6` (`+0x06`) +- `unk14` (`+0x0E`) + +## 6.2. Node flags и имена групп + +- Биты в `Res1.hdr0` используются в ряде рантайм‑веток, но их «геймдизайн‑имена» неизвестны. +- Для group‑индекса `0..4` не найдено текстовых label'ов в ресурсах; для совместимости нужно сохранять числовой индекс как есть. + +## 6.3. Slot tail `unk30..unk40` + +Хвост слота (`+0x30..+0x43`, `5×uint32`) стабильно присутствует в формате, но движок не делает явной семантической декомпозиции этих пяти слов в path'ах загрузки/рендера/коллизии. + +## 6.4. Effect command payload semantics + +Container/stream формально полностью восстановлен (header, opcode, размеры, инстанцирование). Остаётся необязательная задача: дать «человеко‑читаемые» имена каждому полю внутри payload конкретных opcode. + +## 6.5. Поля `TexmHeader.flags4/flags5/unk6` + +Бинарный layout и декодер известны, но значения этих трёх полей в контенте используются контекстно; для 1:1 достаточно хранить/восстанавливать их без модификации. + +## 6.6. Что пока не хватает для полноценного обратного экспорта (`OBJ -> MSH/NRes`) + +Ниже перечислено то, что нужно закрыть для **lossless round-trip** и 1:1‑поведения при импорте внешней геометрии обратно в формат игры. + +### A) Неполная «авторская» семантика бинарных таблиц + +1. `Res2` header (`первые 0x8C`): не зафиксированы все поля и правила их вычисления при генерации нового файла (а не copy-through из оригинала). +2. `Res7` tri-descriptor: для 16‑байтной записи декодирован базовый каркас, но остаётся неформализованной часть служебных бит/полей, нужных для стабильной генерации adjacency/служебной топологии. +3. `Res13` поля `unk4/unk6/unk14`: для парсинга достаточно, но для генерации «канонических» значений из голого `OBJ` правила не определены. +4. `Res2` slot tail (`unk30..unk40`): семантика не разложена, поэтому при экспорте новых ассетов нет детерминированной формулы заполнения. + +### B) Анимационный path ещё не закрыт как writer + +1. Нужен полный writer для `Res8/Res19`: + - точная спецификация байтового формата на запись; + - правила генерации mapping (`Res19`) по узлам/кадрам; + - жёсткая фиксация округления как в x87 path (включая edge-case на границах кадра). +2. Правила биндинга узлов/строк (`Res10`) и `slotFlags` к runtime‑сущностям пока описаны частично и требуют формализации именно для импорта новых данных. + +### C) Материалы, текстуры, эффекты для «полного ассета» + +1. Для `Texm` не завершён writer, покрывающий все используемые режимы (включая palette path, mip-chain, `Page`, и правила заполнения служебных полей). +2. Для `FXID` известен контейнер/длины команд, но не завершена field-level семантика payload всех opcode для генерации новых эффектов, эквивалентных оригинальному пайплайну. +3. Экспорт только `OBJ` покрывает геометрию; для игрового ассета нужен sidecar-слой (материалы/текстуры/эффекты/анимация), иначе импорт неизбежно неполный. + +### D) Что это означает на практике + +1. `OBJ -> MSH` сейчас реалистичен как **ограниченный static-экспорт** (позиции/индексы/часть batch/slot структуры). +2. `OBJ -> полноценный игровой ресурс` (без потерь, с поведением 1:1) пока недостижим без закрытия пунктов A/B/C. +3. До закрытия пунктов A/B/C рекомендуется использовать режим: + - геометрия экспортируется из `OBJ`; + - неизвестные/служебные поля берутся copy-through из референсного оригинального ассета той же структуры. diff --git a/docs/specs/msh.md b/docs/specs/msh.md new file mode 100644 index 0000000..e2623f8 --- /dev/null +++ b/docs/specs/msh.md @@ -0,0 +1,22 @@ +# Форматы 3D-ресурсов движка NGI + +Этот документ теперь является обзором и точкой входа в набор отдельных спецификаций. + +## Структура спецификаций + +1. [MSH core](msh-core.md) — геометрия, узлы, батчи, LOD, slot-матрица. +2. [MSH animation](msh-animation.md) — `Res8`, `Res19`, выбор ключей и интерполяция. +3. [Materials + Texm](materials-texm.md) — материалы, текстуры, палитры, `WEAR`, `LIGHTMAPS`, `Texm`. +4. [FXID](fxid.md) — контейнер эффекта и команды runtime-потока. +5. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру. +6. [Runtime pipeline](runtime-pipeline.md) — межмодульное поведение движка в кадре. +7. [3D implementation notes](msh-notes.md) — контрольные заметки, декодирование и открытые вопросы. + +## Связанные спецификации + +- [NRes / RsLi](nres.md) + +## Принцип декомпозиции + +- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо. +- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько DLL и не является форматом на диске. diff --git a/docs/specs/network.md b/docs/specs/network.md new file mode 100644 index 0000000..1950e8a --- /dev/null +++ b/docs/specs/network.md @@ -0,0 +1,5 @@ +# Network system + +Документ описывает сетевую подсистему: протокол обмена, синхронизацию состояния и сетевую архитектуру (client-server/P2P). + +> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Net.dll`. diff --git a/docs/specs/nres.md b/docs/specs/nres.md new file mode 100644 index 0000000..32ccb1b --- /dev/null +++ b/docs/specs/nres.md @@ -0,0 +1,718 @@ +# Форматы игровых ресурсов + +## Обзор + +Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов: + +1. **NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей. + +2. **RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение. + +--- + +## Часть 1. Формат NRes + +### 1.1. Общая структура файла + +``` +┌──────────────────────────┐ Смещение 0 +│ Заголовок (16 байт) │ +├──────────────────────────┤ Смещение 16 +│ │ +│ Данные ресурсов │ +│ (выровнены по 8 байт) │ +│ │ +├──────────────────────────┤ Смещение = total_size - entry_count × 64 +│ Каталог записей │ +│ (entry_count × 64 байт) │ +└──────────────────────────┘ Смещение = total_size +``` + +### 1.2. Заголовок файла (16 байт) + +| Смещение | Размер | Тип | Значение | Описание | +| -------- | ------ | ------- | ------------------- | ------------------------------------ | +| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) | +| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) | +| 8 | 4 | int32 | — | Количество записей в каталоге | +| 12 | 4 | int32 | — | Полный размер файла в байтах | + +**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется. + +### 1.3. Положение каталога в файле + +Каталог располагается в самом конце файла. Его смещение вычисляется по формуле: + +``` +directory_offset = total_size - entry_count × 64 +``` + +Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом. + +### 1.4. Запись каталога (64 байта) + +Каждая запись каталога занимает ровно **64 байта** (0x40): + +| Смещение | Размер | Тип | Описание | +| -------- | ------ | -------- | ------------------------------------------------- | +| 0 | 4 | uint32 | Тип / идентификатор ресурса | +| 4 | 4 | uint32 | Атрибут 1 (например, формат, дата, категория) | +| 8 | 4 | uint32 | Атрибут 2 (например, подтип, метка времени) | +| 12 | 4 | uint32 | Размер данных ресурса в байтах | +| 16 | 4 | uint32 | Атрибут 3 (дополнительный параметр) | +| 20 | 36 | char[36] | Имя файла (null-terminated, макс. 35 символов) | +| 56 | 4 | uint32 | Смещение данных от начала файла | +| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) | + +#### Поле «Имя файла» (смещение 20, 36 байт) + +- Максимальная длина имени: **35 символов** + 1 байт null-терминатор. +- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов). +- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`). + +#### Поле «Индекс сортировки» (смещение 60) + +Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам. + +**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии. + +#### Поле «Смещение данных» (смещение 56) + +Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`. + +### 1.5. Выравнивание данных + +При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**: + +```c +padding = ((data_size + 7) & ~7) - data_size; +// Если padding > 0, записываются нулевые байты +``` + +Таким образом, каждый блок данных начинается с адреса, кратного 8. + +При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога. + +### 1.6. Создание файла (API `niCreateResFile`) + +При создании нового файла: + +1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога. +2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`. + +При закрытии файла (`sub_100122D0`): + +1. Заголовок переписывается в начало файла (16 байт). +2. Вычисляется `total_size = data_end_offset + entry_count × 64`. +3. Индексы сортировки пересчитываются. +4. Каталог записей записывается в конец файла. + +### 1.7. Режимы сортировки каталога + +Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11): + +| Режим | Порядок сортировки | +| ----- | --------------------------------- | +| 0 | Без сортировки (сброс) | +| 1 | По атрибуту 1 (смещение 4) | +| 2 | По атрибуту 2 (смещение 8) | +| 3 | По (атрибут 1, атрибут 2) | +| 4 | По типу ресурса (смещение 0) | +| 5 | По (тип, атрибут 1) | +| 6 | По (тип, атрибут 1) — идентичен 5 | +| 7 | По (тип, атрибут 1, атрибут 2) | +| 8 | По имени (регистронезависимо) | +| 9 | По (тип, имя) | +| 10 | По (атрибут 1, имя) | +| 11 | По (атрибут 2, имя) | + +### 1.8. Операция `niOpenResFileEx` — флаги открытия + +Второй параметр — битовые флаги: + +| Бит | Маска | Описание | +| --- | ----- | ----------------------------------------------------------------------------------- | +| 0 | 0x01 | Sequential scan hint (`FILE_FLAG_SEQUENTIAL_SCAN` вместо `FILE_FLAG_RANDOM_ACCESS`) | +| 1 | 0x02 | Открыть для записи (read-write). Без флага — только чтение | +| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) | +| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс | + +### 1.9. Виртуальное касание страниц + +Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ): + +``` +for (result = 0x10000; result < size; result += 4096); +``` + +Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память. + +--- + +## Часть 2. Формат RsLi + +### 2.1. Общая структура файла + +``` +┌───────────────────────────────┐ Смещение 0 +│ Заголовок файла (32 байта) │ +├───────────────────────────────┤ Смещение 32 +│ Таблица записей (зашифрована)│ +│ (entry_count × 32 байт) │ +├───────────────────────────────┤ Смещение 32 + entry_count × 32 +│ │ +│ Данные ресурсов │ +│ │ +├───────────────────────────────┤ +│ [Опциональный трейлер — 6 б] │ +└───────────────────────────────┘ +``` + +### 2.2. Заголовок файла (32 байта) + +| Смещение | Размер | Тип | Значение | Описание | +| -------- | ------ | ------- | ----------------- | --------------------------------------------- | +| 0 | 2 | char[2] | `NL` (0x4C4E) | Магическая сигнатура | +| 2 | 1 | uint8 | `0x00` | Зарезервировано (должно быть 0) | +| 3 | 1 | uint8 | `0x01` | Версия формата | +| 4 | 2 | int16 | — | Количество записей (sign-extended при чтении) | +| 6 | 8 | — | — | Зарезервировано / не используется | +| 14 | 2 | uint16 | `0xABBA` или иное | Флаг предсортировки (см. ниже) | +| 16 | 4 | — | — | Зарезервировано | +| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) | +| 24 | 8 | — | — | Зарезервировано | + +#### Флаг предсортировки (смещение 14) + +- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XOR‑ключ для данных). +- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XOR‑ключ (младшие 16 бит). + +### 2.3. XOR-шифр таблицы записей + +Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка. + +#### Начальное состояние + +``` +seed = *(uint32*)(header + 20) +lo = seed & 0xFF // Младший байт +hi = (seed >> 8) & 0xFF // Второй байт +``` + +#### Алгоритм дешифровки (побайтовый) + +Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`: + +``` +step 1: lo = hi ^ ((lo << 1) & 0xFF) // Сдвиг lo влево на 1, XOR с hi +step 2: decrypted[i] = lo ^ encrypted[i] // Расшифровка байта +step 3: hi = lo ^ ((hi >> 1) & 0xFF) // Сдвиг hi вправо на 1, XOR с lo +``` + +**Пример реализации:** + +```python +def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes: + lo = seed & 0xFF + hi = (seed >> 8) & 0xFF + result = bytearray(len(encrypted_data)) + for i in range(len(encrypted_data)): + lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF + result[i] = lo ^ encrypted_data[i] + hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF + return bytes(result) +``` + +Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи. + +### 2.4. Запись таблицы (32 байта, на диске, до дешифровки) + +После дешифровки каждая запись имеет следующую структуру: + +| Смещение | Размер | Тип | Описание | +| -------- | ------ | -------- | -------------------------------------------------------------- | +| 0 | 12 | char[12] | Имя ресурса (ASCII, обычно uppercase; строка читается до `\0`) | +| 12 | 4 | — | Зарезервировано (движком игнорируется) | +| 16 | 2 | int16 | **Флаги** (метод сжатия и атрибуты) | +| 18 | 2 | int16 | **`sort_to_original[i]` / XOR‑ключ** (см. ниже) | +| 20 | 4 | uint32 | **Размер распакованных данных** (`unpacked_size`) | +| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) | +| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) | + +#### Имена ресурсов + +- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`. +- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно. +- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён. + +#### Поле `sort_to_original[i]` (смещение 18) + +Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск: + +- Таблица реализована “внутри записей”: значение берётся как `entry[i].sort_to_original` (где `i` — позиция двоичного поиска), а реальная запись для сравнения берётся как `entry[ sort_to_original[i] ]`. +- Тем же значением (младшие 16 бит) инициализируется XOR‑шифр данных для методов, где он используется (0x20/0x60/0xA0). Поэтому при упаковке/шифровании данных ключ должен совпадать с итоговым `sort_to_original[i]` (см. флаг 0xABBA в разделе 2.2). + +Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`). + +### 2.5. Поле флагов (смещение 16 записи) + +Биты поля флагов кодируют метод сжатия и дополнительные атрибуты: + +``` +Биты [8:5] (маска 0x1E0): Метод сжатия/шифрования +Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше) +``` + +#### Методы сжатия (биты 8–5, маска 0x1E0) + +| Значение | Hex | Описание | +| -------- | ----- | --------------------------------------- | +| 0x000 | 0x00 | Без сжатия (копирование) | +| 0x020 | 0x20 | Только XOR-шифр | +| 0x040 | 0x40 | LZSS (простой вариант) | +| 0x060 | 0x60 | XOR-шифр + LZSS (простой вариант) | +| 0x080 | 0x80 | LZSS с адаптивным кодированием Хаффмана | +| 0x0A0 | 0xA0 | XOR-шифр + LZSS с Хаффманом | +| 0x100 | 0x100 | Deflate (аналог zlib/RFC 1951) | + +Примечание: `rsGetPackMethod()` возвращает `flags & 0x1C0` (без бита 0x20). Поэтому: + +- для 0x20 вернётся 0x00, +- для 0x60 вернётся 0x40, +- для 0xA0 вернётся 0x80. + +#### Бит 0x40 (выделение +0x12 и последующее `realloc`) + +Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`. + +Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически. + +### 2.6. Размеры данных + +В каждой записи на диске хранятся оба значения: + +- `unpacked_size` (смещение 20) — размер распакованных данных. +- `packed_size` (смещение 28) — размер упакованных данных (байт во входном потоке для выбранного метода). + +Для метода 0x00 (без сжатия) обычно `packed_size == unpacked_size`. + +`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`). + +Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера. + +### 2.7. Опциональный трейлер медиа (6 байт) + +При открытии с флагом `a2 & 2`: + +| Смещение от конца | Размер | Тип | Описание | +| ----------------- | ------ | ------- | ----------------------- | +| −6 | 2 | char[2] | Сигнатура `AO` (0x4F41) | +| −4 | 4 | uint32 | Смещение медиа-оверлея | + +Если трейлер присутствует, все смещения данных в записях корректируются: `effective_offset = entry_offset + media_overlay_offset`. + +--- + +## Часть 3. Алгоритмы сжатия (формат RsLi) + +### 3.1. XOR-шифр данных (метод 0x20) + +Алгоритм идентичен XOR‑шифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит). + +Важно про размер входа: + +- В ветке **0x20** движок XOR‑ит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`). +- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией. + +#### Инициализация + +``` +key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18 +lo = key16 & 0xFF +hi = (key16 >> 8) & 0xFF +``` + +#### Дешифровка (псевдокод) + +``` +for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0) + lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF + out[i] = in[i] ^ lo + hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF +``` + +### 3.2. LZSS — простой вариант (метод 0x40) + +Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером. + +#### Параметры + +| Параметр | Значение | +| ----------------------------- | ------------------ | +| Размер кольцевого буфера | 4096 байт (0x1000) | +| Начальная позиция записи | 4078 (0xFEE) | +| Начальное заполнение | 0x20 (пробел) | +| Минимальная длина совпадения | 3 | +| Максимальная длина совпадения | 18 (4 бита + 3) | + +#### Алгоритм декомпрессии + +``` +Инициализация: + ring_buffer[0..4095] = 0x20 (заполнить пробелами) + ring_pos = 4078 + flags_byte = 0 + flags_bits_remaining = 0 + +Цикл (пока не заполнен выходной буфер И не исчерпан входной): + + 1. Если flags_bits_remaining == 0: + - Прочитать 1 байт из входного потока → flags_byte + - flags_bits_remaining = 8 + + Декодировать как: + - Старший бит устанавливается в 0x7F (маркер) + - Оставшиеся 7 бит — флаги текущей группы + + Реально в коде: control_word = (flags_byte) | (0x7F << 8) + Каждый бит проверяется сдвигом вправо. + + 2. Проверить младший бит control_word: + + Если бит = 1 (литерал): + - Прочитать 1 байт из входного потока → byte + - ring_buffer[ring_pos] = byte + - ring_pos = (ring_pos + 1) & 0xFFF + - Записать byte в выходной буфер + + Если бит = 0 (ссылка): + - Прочитать 2 байта: low_byte, high_byte + - offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит + - length = (high_byte & 0x0F) + 3 // 4 бита + 3 + - Скопировать length байт из ring_buffer[offset...]: + для j от 0 до length-1: + byte = ring_buffer[(offset + j) & 0xFFF] + ring_buffer[ring_pos] = byte + ring_pos = (ring_pos + 1) & 0xFFF + записать byte в выходной буфер + + 3. Сдвинуть control_word вправо на 1 бит + 4. flags_bits_remaining -= 1 +``` + +#### Подробная раскладка пары ссылки (2 байта) + +``` +Байт 0 (low): OOOOOOOO (биты [7:0] смещения) +Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина − 3 + +offset = low | ((high & 0xF0) << 4) // Диапазон: 0–4095 +length = (high & 0x0F) + 3 // Диапазон: 3–18 +``` + +### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80) + +Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана. + +#### Параметры + +| Параметр | Значение | +| -------------------------------- | ------------------------------ | +| Размер кольцевого буфера | 4096 байт | +| Начальная позиция записи | **4036** (0xFC4) | +| Начальное заполнение | 0x20 (пробел) | +| Количество листовых узлов дерева | 314 | +| Символы литералов | 0–255 (байты) | +| Символы длин | 256–313 (длина = символ − 253) | +| Начальная длина | 3 (при символе 256) | +| Максимальная длина | 60 (при символе 313) | + +#### Дерево Хаффмана + +Дерево строится как **адаптивное** (dynamic, self-adjusting): + +- **627 узлов**: 314 листовых + 313 внутренних. +- Все листья изначально имеют **вес 1**. +- Корень дерева — узел с индексом 0 (в массиве `parent`). +- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства. +- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается. + +#### Кодирование позиции + +Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций): + +- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов. +- Из потока считываются дополнительные биты, которые объединяются с базовым значением. +- Финальная позиция: `pos = (ring_pos − 1 − decoded_position) & 0xFFF` + +**Таблицы инициализации** (d-коды): + +``` +Таблица базовых значений — byte_100371D0[6]: + { 0x01, 0x03, 0x08, 0x0C, 0x18, 0x10 } + +Таблица дополнительных битов — byte_100371D6[6]: + { 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 } +``` + +#### Алгоритм декомпрессии (высокоуровневый) + +``` +Инициализация: + ring_buffer[0..4095] = 0x20 + ring_pos = 4036 + Инициализировать дерево Хаффмана (314 листьев, все веса = 1) + Инициализировать таблицы d-кодов + +Цикл: + 1. Декодировать символ из потока по дереву Хаффмана: + - Начать с корня + - Читать биты, спускаться по дереву (0 = левый, 1 = правый) + - Пока не достигнут лист → символ = лист − 627 + + 2. Обновить дерево Хаффмана для декодированного символа + + 3. Если символ < 256 (литерал): + - ring_buffer[ring_pos] = символ + - ring_pos = (ring_pos + 1) & 0xFFF + - Записать символ в выходной буфер + + 4. Если символ >= 256 (ссылка): + - length = символ − 253 + - Декодировать позицию через d-код: + a) Прочитать 8 бит из потока + b) Найти d-код и дополнительные биты по таблице + c) Прочитать дополнительные биты + d) position = (ring_pos − 1 − full_position) & 0xFFF + - Скопировать length байт из ring_buffer[position...] + + 5. Если выходной буфер заполнен → завершить +``` + +### 3.4. XOR + LZSS (методы 0x60 и 0xA0) + +Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия. + +#### Алгоритм + +1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28). +2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер. +3. Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной. +4. Освободить временный буфер. + +- **0x60** — XOR + простой LZSS (раздел 3.2) +- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3) + +#### Начальное состояние XOR для данных + +При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`. + +### 3.5. Deflate (метод 0x100) + +Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой. + +#### Общая структура + +Данные состоят из последовательности блоков. Каждый блок начинается с: + +- **1 бит** — `is_final`: признак последнего блока +- **2 бита** — `block_type`: тип блока + +#### Типы блоков + +| block_type | Описание | Функция | +| ---------- | --------------------------- | ---------------- | +| 0 | Без сжатия (stored) | `sub_1001A750` | +| 1 | Фиксированные коды Хаффмана | `sub_1001A8C0` | +| 2 | Динамические коды Хаффмана | `sub_1001AA30` | +| 3 | Зарезервировано (ошибка) | Возвращает код 2 | + +#### Блок типа 0 (stored) + +1. Отбросить оставшиеся биты до границы байта (выравнивание). +2. Прочитать 16 бит — `LEN` (длина блока). +3. Прочитать 16 бит — `NLEN` (дополнение длины, `NLEN == ~LEN & 0xFFFF`). +4. Проверить: `LEN == (uint16)(~NLEN)`. При несовпадении — ошибка. +5. Скопировать `LEN` байт из входного потока в выходной. + +Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата. + +#### Блок типа 1 (фиксированные коды) + +Стандартные коды Deflate: + +- Литералы/длины: 288 кодов + - 0–143: 8-битные коды + - 144–255: 9-битные коды + - 256–279: 7-битные коды + - 280–287: 8-битные коды +- Дистанции: 30 кодов, все 5-битные + +Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты). + +#### Блок типа 2 (динамические коды) + +1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257–286. +2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30. +3. Прочитать 4 бита → `HCLEN` (количество кодов длин − 4). Диапазон: 4–19. +4. Прочитать `HCLEN` × 3 бит — длины кодов для алфавита длин. +5. Построить дерево Хаффмана для алфавита длин (19 символов). +6. С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций. +7. Построить два дерева Хаффмана: для литералов/длин и для дистанций. +8. Декодировать данные. + +**Порядок кодов длин** (стандартный Deflate): + +``` +{ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 } +``` + +Хранится в `dword_10037060`. + +#### Валидации + +- `HLIT + 257 <= 286` (max 0x11E) +- `HDIST + 1 <= 30` (max 0x1E) +- При нарушении — возвращается ошибка 1. + +### 3.6. Метод 0x00 (без сжатия) + +Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог). + +--- + +## Часть 4. Внутренние структуры в памяти + +### 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104) + +```c +struct NResArchive { // Размер: 0x68 (104 байта) + void* vtable; // +0: Указатель на таблицу виртуальных методов + int32_t entry_count; // +4: Количество записей + void* mapped_base; // +8: Базовый адрес mapped view + void* directory_ptr; // +12: Указатель на каталог записей в памяти + char* filename; // +16: Путь к файлу (_strdup) + int32_t ref_count; // +20: Счётчик ссылок + uint32_t last_release_time; // +24: timeGetTime() при последнем Release + // +28..+91: Для raw-режима — встроенная запись (единственный File entry) + NResArchive* next; // +92: Следующий архив в связном списке + uint8_t is_writable; // +100: Файл открыт для записи + uint8_t is_cacheable; // +101: Не выгружать при refcount = 0 +}; +``` + +### 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт) + +```c +struct RsLibHeader { // 56 байт (14 DWORD) + uint32_t magic; // +0: 'RsLi' (0x694C7352) + int32_t entry_count; // +4: Количество записей + uint32_t media_offset; // +8: Смещение медиа-оверлея + uint32_t reserved_0C; // +12: 0 + HANDLE file_handle_2; // +16: -1 (дополнительный хэндл) + uint32_t reserved_14; // +20: 0 + uint32_t reserved_18; // +24: — + uint32_t reserved_1C; // +28: 0 + HANDLE mapping_handle_2; // +32: -1 + uint32_t reserved_24; // +36: 0 + uint32_t flag_28; // +40: (flags >> 7) & 1 + HANDLE file_handle; // +44: Хэндл файла + HANDLE mapping_handle; // +48: Хэндл файлового маппинга + void* mapped_view; // +52: Указатель на mapped view +}; +// Далее следуют entry_count записей по 64 байта каждая +``` + +#### Внутренняя запись RsLi (64 байта) + +```c +struct RsLibEntry { // 64 байта (16 DWORD) + char name[16]; // +0: Имя (12 из файла + 4 нуля) + int32_t flags; // +16: Флаги (sign-extended из int16) + int32_t sort_index; // +20: sort_to_original[i] (таблица индексов / XOR‑ключ) + uint32_t uncompressed_size; // +24: Размер несжатых данных (из поля 20 записи) + void* data_ptr; // +28: Указатель на данные в mapped view + uint32_t compressed_size; // +32: Размер сжатых данных (из поля 28 записи) + uint32_t reserved_24; // +36: 0 + uint32_t reserved_28; // +40: 0 + uint32_t reserved_2C; // +44: 0 + void* loaded_data; // +48: Указатель на декомпрессированные данные + // +52..+63: дополнительные поля +}; +``` + +--- + +## Часть 5. Экспортируемые API-функции + +### 5.1. NRes API + +| Функция | Описание | +| ------------------------------ | ------------------------------------------------------------------------- | +| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` | +| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами | +| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти | +| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи | + +### 5.2. RsLi API + +| Функция | Описание | +| ------------------------------- | -------------------------------------------------------- | +| `rsOpenLib(path, flags)` | Открыть RsLi-библиотеку | +| `rsCloseLib(lib)` | Закрыть библиотеку | +| `rsLibNum(lib)` | Получить количество записей | +| `rsFind(lib, name)` | Найти запись по имени (→ индекс или −1) | +| `rsLoad(lib, index)` | Загрузить и декомпрессировать ресурс | +| `rsLoadFast(lib, index, flags)` | Быстрая загрузка (без декомпрессии если возможно) | +| `rsLoadPacked(lib, index)` | Загрузить в «упакованном» виде (отложенная декомпрессия) | +| `rsLoadByName(lib, name)` | `rsFind` + `rsLoad` | +| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса | +| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) | +| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс | +| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) | +| `ngiFree(ptr)` | Освободить память | +| `ngiGetMemSize(ptr)` | Получить размер выделенного блока | + +--- + +## Часть 6. Контрольные заметки для реализации + +### 6.1. Кодировки и регистр + +- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`). +- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк). + +### 6.2. Порядок байт + +Все значения хранятся в **little-endian** порядке (платформа x86/Win32). + +### 6.3. Выравнивание + +- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами). +- **RsLi**: выравнивание данных не описано в коде (данные идут подряд). + +### 6.4. Размер записей на диске + +- **NRes**: каталог — **64 байта** на запись, расположен в конце файла. +- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка). + +### 6.5. Кэширование и memory mapping + +Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`). + +### 6.6. Размер seed XOR + +- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`). +- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи. +- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта. + +### 6.7. Эмпирическая проверка на данных игры + +- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2). +- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно. +- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`. + +Подтверждённые нюансы: + +- Для LZSS (метод `0x040`) рабочая раскладка нибблов в ссылке: `OOOO LLLL`, а не `LLLL OOOO`. +- Для Deflate (метод `0x100`) возможен случай `packed_size == фактический_конец + 1` на последней записи файла. diff --git a/docs/specs/runtime-pipeline.md b/docs/specs/runtime-pipeline.md new file mode 100644 index 0000000..7021c82 --- /dev/null +++ b/docs/specs/runtime-pipeline.md @@ -0,0 +1,123 @@ +# Runtime pipeline + +Документ фиксирует runtime-поведение движка: кто кого вызывает в кадре, как проходят рендер, коллизия и подключение эффектов. + +--- + +## 1.15. Алгоритм рендера модели (реконструкция) + +``` +Вход: model, instanceTransform, cameraFrustum + +1. Определить current_lod ∈ {0, 1, 2} (по дистанции до камеры / настройкам). + +2. Для каждого node (nodeIndex = 0 .. nodeCount−1): + a. Вычислить nodeTransform = instanceTransform × nodeLocalTransform + + b. slotIndex = nodeTable[nodeIndex].slotMatrix[current_lod][group=0] + если slotIndex == 0xFFFF → пропустить узел + + c. slot = slotTable[slotIndex] + + d. // Frustum culling: + transformedAABB = transform(slot.aabb, nodeTransform) + если transformedAABB вне cameraFrustum → пропустить + + // Альтернативно по сфере: + transformedCenter = nodeTransform × slot.sphereCenter + scaledRadius = slot.sphereRadius × max(scaleX, scaleY, scaleZ) + если сфера вне frustum → пропустить + + e. Для i = 0 .. slot.batchCount − 1: + batch = batchTable[slot.batchStart + i] + + // Фильтрация по batchFlags (если нужна) + + // Установить материал: + setMaterial(batch.materialIndex) + + // Установить transform: + setWorldMatrix(nodeTransform) + + // Нарисовать: + DrawIndexedPrimitive( + baseVertex = batch.baseVertex, + indexStart = batch.indexStart, + indexCount = batch.indexCount, + primitiveType = TRIANGLE_LIST + ) +``` + +--- + +## 1.16. Алгоритм обхода треугольников (коллизия / пикинг) + +``` +Вход: model, nodeIndex, lod, group, filterMask, callback + +1. slotIndex = nodeTable[nodeIndex].slotMatrix[lod][group] + если slotIndex == 0xFFFF → выход + +2. slot = slotTable[slotIndex] + triDescIndex = slot.triStart + +3. Для каждого batch в диапазоне [slot.batchStart .. slot.batchStart + slot.batchCount − 1]: + batch = batchTable[batchIndex] + triCount = batch.indexCount / 3 // округление: (indexCount + 2) / 3 + + Для t = 0 .. triCount − 1: + triDesc = triDescTable[triDescIndex] + + // Фильтрация: + если (triDesc.triFlags & filterMask) → пропустить + + // Получить индексы вершин: + idx0 = indexBuffer[batch.indexStart + t*3 + 0] + batch.baseVertex + idx1 = indexBuffer[batch.indexStart + t*3 + 1] + batch.baseVertex + idx2 = indexBuffer[batch.indexStart + t*3 + 2] + batch.baseVertex + + // Получить позиции: + p0 = positions[idx0] + p1 = positions[idx1] + p2 = positions[idx2] + + callback(triDesc, idx0, idx1, idx2, p0, p1, p2) + + triDescIndex += 1 +``` + +--- + + +--- + +## 3.1. Архитектурный обзор + +Подсистема эффектов реализована в `Effect.dll` и интегрирована в рендер через `Terrain.dll`. + +### Экспорты Effect.dll + +| Функция | Описание | +|----------------------|--------------------------------------------------------| +| `CreateFxManager` | Создать менеджер эффектов (3 параметра: int, int, int) | +| `InitializeSettings` | Инициализировать настройки эффектов | + +`CreateFxManager` возвращает объект‑менеджер, который регистрируется в движке и управляет всеми эффектами. + +### Телеметрия из Terrain.dll + +Terrain.dll содержит отладочную статистику рендера: + +``` +"Rendered meshes : %d" +"Rendered primitives : %d" +"Rendered faces : %d" +"Rendered particles/batches : %d/%d" +``` + +Из этого следует: + +- Частицы рендерятся **батчами** (группами). +- Статистика частиц отделена от статистики мешей. +- Частицы интегрированы в общий 3D‑рендер‑пайплайн. + diff --git a/docs/specs/sound.md b/docs/specs/sound.md new file mode 100644 index 0000000..da2a6ee --- /dev/null +++ b/docs/specs/sound.md @@ -0,0 +1,5 @@ +# Sound system + +Документ описывает аудиоподсистему: форматы звуковых ресурсов, воспроизведение эффектов и голосов, а также интеграцию со звуковым API. + +> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга звуковых модулей движка. diff --git a/docs/specs/terrain-map-loading.md b/docs/specs/terrain-map-loading.md new file mode 100644 index 0000000..0fb6e1f --- /dev/null +++ b/docs/specs/terrain-map-loading.md @@ -0,0 +1,32 @@ +# Terrain + map loading + +Документ описывает подсистему ландшафта и привязку terrain-данных к миру. + +--- + +## 4.1. Обзор + +`Terrain.dll` отвечает за рендер ландшафта (terrain), включая: + +- Рендер мешей ландшафта (`"Rendered meshes"`, `"Rendered primitives"`, `"Rendered faces"`). +- Рендер частиц (`"Rendered particles/batches"`). +- Создание текстур (`"CTexture::CTexture()"` — конструктор текстуры). +- Микротекстуры (`"Unable to find microtexture mapping"`). + +## 4.2. Текстуры ландшафта + +В Terrain.dll присутствует конструктор текстуры `CTexture::CTexture()` со следующими проверками: + +- Валидация размера текстуры (`"Unsupported texture size"`). +- Создание D3D‑текстуры (`"Unable to create texture"`). + +Ландшафт использует **микротекстуры** (micro‑texture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности. + +## 4.3. Защита от пустых примитивов + +Terrain.dll содержит проверки: + +- `"Rendering empty primitive!"` — перед первым вызовом отрисовки. +- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки. + +Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта). diff --git a/docs/specs/ui.md b/docs/specs/ui.md new file mode 100644 index 0000000..9d71dfd --- /dev/null +++ b/docs/specs/ui.md @@ -0,0 +1,5 @@ +# UI system + +Документ описывает интерфейсную подсистему: ресурсы UI, шрифты, minimap, layout и обработку пользовательского ввода в интерфейсе. + +> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга UI-компонентов движка. |
