diff options
| author | Valentin Popov <valentin@popov.link> | 2026-02-12 01:06:56 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-02-12 01:06:56 +0300 |
| commit | 481ff1c06da8102e50f54f3adb893336ae53b4f7 (patch) | |
| tree | 96fbe6396218b6e73eda53b309b281d0b4d79b7e /docs | |
| parent | 7702d800a0a18c0212cfbf4434d4477eaa76d639 (diff) | |
| download | fparkan-481ff1c06da8102e50f54f3adb893336ae53b4f7.tar.xz fparkan-481ff1c06da8102e50f54f3adb893336ae53b4f7.zip | |
Implement feature X to enhance user experience and fix bug Y in module Z
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/specs/fxid.md | 936 |
1 files changed, 654 insertions, 282 deletions
diff --git a/docs/specs/fxid.md b/docs/specs/fxid.md index 65bf7f1..957f95b 100644 --- a/docs/specs/fxid.md +++ b/docs/specs/fxid.md @@ -1,382 +1,730 @@ # FXID -Документ описывает формат ресурса эффекта `FXID`, контракт runtime в `Effect.dll` и практические правила для инструментов чтения/конвертации/редактирования. +Документ фиксирует спецификацию ресурса эффекта `FXID` на уровне, достаточном для: -Цель: дать достаточную high-level спецификацию для: - -- 1:1 загрузчика/рантайма эффекта; -- валидатора payload; -- бинарно-совместимого редактора; -- конвертера в промежуточный формат и обратно. +- 1:1 загрузки и исполнения в совместимом runtime; +- построения валидатора payload; +- создания lossless-конвертера (`binary -> IR -> binary`); +- создания редактора с безопасным редактированием полей. Связанный контейнер: [NRes / RsLi](nres.md). --- -## 1. Источники восстановления +## 1. Источники и статус восстановления -Спецификация собрана по: +Спецификация восстановлена по: - `tmp/disassembler1/Effect.dll.c`; - `tmp/disassembler2/Effect.dll.asm`; - интеграционным вызовам из `tmp/disassembler1/Terrain.dll.c`; - проверке реальных архивов `testdata/nres`. -Ключевые точки: +Ключевые функции: - parser FXID: `Effect.dll!sub_10007650`; -- core update: `Effect.dll!sub_10008120`, `sub_10006170`, `sub_10007D10`; -- export API: `CreateFxManager`, `InitializeSettings`. +- 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. Место формата в движке +## 2. Контейнер и runtime API -### 2.1. Контейнер NRes +## 2.1. NRes entry -Эффект хранится как запись NRes с типом: +FXID хранится как NRes-entry: - `type_id = 0x44495846` (`"FXID"`). -Для всех 923 FXID-entries в `testdata/nres` подтверждено: +Наблюдение по датасету (923 эффекта): -- `attr1 = 0`; -- `attr2 = 0`; -- `attr3 = 1`. +- `attr1 = 0`, `attr2 = 0`, `attr3 = 1`. -### 2.2. Runtime-модуль +## 2.2. Export API `Effect.dll` -`Effect.dll` экспортирует 2 функции: +Экспортируются: - `CreateFxManager(int a1, int a2, int owner)`; - `InitializeSettings()`. -`CreateFxManager` выделяет объект (`0xB8` байт), инициализирует его через `sub_10003AE0`, возвращает **интерфейсный указатель** (смещение `+4` от базового объекта). +`CreateFxManager` создаёт manager-объект (`0xB8` байт), инициализирует через `sub_10003AE0`, возвращает интерфейсный указатель (`base + 4`). -### 2.3. COM-подобный интерфейс +## 2.3. Интерфейс менеджера -Внешний код (например, `Terrain.dll`) получает рабочий интерфейс через `QueryInterface(id=19)` и далее вызывает методы vtable `off_1001E478`. +Рабочая vtable (`off_1001E478`): -Ключевые методы интерфейса менеджера (по vtable): - -| Vtable offset | Функция | Назначение (high-level) | +| Смещение | Функция | Назначение | |---|---|---| -| +0x10 | `sub_10004320` | Открыть/закэшировать ресурс эффекта (`archive + name`) | -| +0x14 | `sub_10004590` | Создать runtime-инстанс эффекта по шаблону | -| +0x18 | `sub_10004780` | Удалить инстанс по id | -| +0x1C | `sub_100047B0` | Установить режим интерполяции/времени | +| +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` | Установить матрицу transform | -| +0x2C | `sub_10004B00` | Перезапуск с mode | -| +0x38 | `sub_10004BA0` | Модификатор длительности | +| +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` | Привязать emitter/context | -| +0x48 | `sub_10004D50` | Сброс frame-флагов | -| +0x08 | `sub_10003D30` | Системные event-коды (tick/reset/remove-range) | +| +0x44 | `sub_10004C50` | Bind emitter/context | +| +0x48 | `sub_10004D50` | Сброс frame flags | -Этого контракта достаточно, чтобы корректно встроить FXID-рантайм в движок. +`Terrain.dll` использует `QueryInterface(id=19)` для получения рабочего интерфейса. --- -## 3. Бинарный формат payload FXID +## 3. Бинарный формат FXID payload -Все числа little-endian. +Все значения little-endian. ## 3.1. Header (60 байт, `0x3C`) ```c struct FxHeader60 { - uint32_t cmd_count; // 0x00: число команд - uint32_t time_mode; // 0x04: базовый режим вычисления alpha/time - float duration_sec; // 0x08: длительность эффекта в секундах - float phase_jitter; // 0x0C: амплитуда рандом-сдвига alpha (если flags bit0) - uint32_t flags; // 0x10: флаги runtime (см. таблицу ниже) - uint32_t settings_id; // 0x14: id категории/настройки (используется low8) - float rand_shift_x; // 0x18: рандомный сдвиг (если flags bit3) - float rand_shift_y; // 0x1C - float rand_shift_z; // 0x20 - float pivot_x; // 0x24: опорная точка/anchor - float pivot_y; // 0x28 - float pivot_z; // 0x2C - float scale_x; // 0x30: базовый scale - float scale_y; // 0x34 - float scale_z; // 0x38 + 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 итерируется ровно `cmd_count` раз; - - дополнительных ограничений в оригинале нет. -- `time_mode`: - - начальный runtime-mode (`effect+0x14`), участвует в `sub_10005C60`. -- `duration_sec`: - - переводится в миллисекунды как `duration_ms = duration_sec * 1000.0`. -- `phase_jitter`: - - при `flags & 0x1` к вычисленному alpha добавляется рандом в диапазоне `[-phase_jitter/2, +phase_jitter/2]`. -- `settings_id`: - - `sub_1000EC40` использует только `settings_id & 0xFF` как индекс таблицы настроек. -- `rand_shift_*`: - - при `flags & 0x8` добавляется рандомный сдвиг к позиции эффекта. -- `pivot_*`: - - используется как опорная точка в ветках проверки видимости/окклюзии (`sub_10007D10`). -- `scale_*`: - - копируется в runtime (`this+56..64`) и участвует в построении матрицы в `sub_10007C90`. - -## 3.3. `flags` (`header+0x10`) — подтвержденные биты - -| Бит | Маска | Поведение | +## 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` | Участвует в ветках видимости/окклюзии в `sub_10006170`/`sub_10007D10` | -| 5 | `0x0020` | Треугольная ремап-функция alpha в `sub_10005C60` | -| 6 | `0x0040` | Инвертирует начальное активное состояние (`this+324 = !(flags&0x40)`) | -| 7 | `0x0080` | Условная фильтрация по manager-флагу day/night | -| 8 | `0x0100` | Инверсная day/night фильтрация | -| 9 | `0x0200` | Домножение alpha на нормализованное время жизни | -| 10 | `0x0400` | Включает manager-глобальный флаг (`manager+0xA0` bit1) | -| 11 | `0x0800` | Меняет поведение ветки `sub_10007D10` (gating для checks) | -| 12 | `0x1000` | Проставляет manager-state bit0x10 в `sub_10006170` | - -Остальные биты в движке напрямую не расшифрованы на уровне high-level, но должны сохраняться 1:1. - -## 3.4. `time_mode` (`header+0x04`) — режимы `sub_10005C60` - -Поддерживаются коды `0..17`. - -| mode | Логика | -|---:|---| -| 0 | Константа (значение из runtime-поля) | -| 1 | Линейно: `(t - t0) / (t1 - t0)` | -| 2 | Цикл `frac((t - t0)/(t1 - t0))` | -| 3 | Обратная линейная: `1 - (t - t0)/(t1 - t0)` | -| 4 | Значение из внешнего queue/world-запроса | -| 5..8 | Нормированные отношения компонент вектора (camera/world path) | -| 9..12 | Альтернативный набор нормированных отношений | -| 13 | `1 - value` из queue-запроса по объекту | -| 14 | `1 - value` из параметра queue id=49 | -| 15 | max из двух нормированных длин | -| 16 | Кламп "не убывать" относительно предыдущего значения | -| 17 | Кламп "не возрастать" относительно предыдущего значения | - -После базового mode-преобразования применяются post-флаги `0x200` и `0x20`. +| 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. Формат записи команды +## 4.1. Общий формат команды + +Каждая команда: -Каждая команда начинается с `uint32 cmd_word`. +- `uint32 cmd_word`; +- далее body фиксированного размера по opcode. -Биты: +`cmd_word`: - `opcode = cmd_word & 0xFF`; - `enabled = (cmd_word >> 8) & 1`; -- в реальных данных `bits 9..31 == 0` (но редактор должен сохранять весь word как есть). - -Никакого межкомандного выравнивания нет: следующая команда начинается сразу после `size(opcode)`. +- `bits 9..31` в датасете нулевые, но их надо сохранять 1:1. -## 4.2. Размеры записей по opcode +Выравнивания между командами нет. -| Opcode | Размер записи (байт) | Размер тела после `cmd_word` | -|---:|---:|---:| -| 1 | 224 | 220 | -| 2 | 148 | 144 | -| 3 | 200 | 196 | -| 4 | 204 | 200 | -| 5 | 112 | 108 | -| 6 | 4 | 0 | -| 7 | 208 | 204 | -| 8 | 248 | 244 | -| 9 | 208 | 204 | -| 10 | 208 | 204 | +## 4.2. Размеры -## 4.3. Opcode -> runtime-класс +| Opcode | Размер записи | +|---:|---:| +| 1 | 224 | +| 2 | 148 | +| 3 | 200 | +| 4 | 204 | +| 5 | 112 | +| 6 | 4 | +| 7 | 208 | +| 8 | 248 | +| 9 | 208 | +| 10 | 208 | -В `sub_10007650` для opcode создаются объекты: +## 4.3. Opcode -> runtime-класс (vtable) -| Opcode | `operator new` | 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` | +| 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` | -Важно: payload команды хранится как сырой указатель и разбирается runtime-методами класса. +## 4.4. Общий вызовной контракт команды -## 4.4. Внутренний вызовной контракт команд +После создания команды (`sub_10007650`): -После создания каждой команды менеджер: +1. `cmd->enabled = cmd_word.bit8`. +2. `cmd->Init(fx_queue, fx_instance)` (`vfunc +4`). +3. команда добавляется в список инстанса. -1. Проставляет `enabled` из `cmd_word.bit8` в поле `obj+4`. -2. Вызывает инициализацию команды (`vfunc +4`) с аргументами `(queue, manager)`. -3. Добавляет команду в массив команд эффекта. +В runtime cycle: -В update-cycle менеджер вызывает: - -- `vfunc +8`: вычисление/обновление команды (bool); -- `vfunc +12`: callback при render/emission; -- `vfunc +20`: toggle активности; -- `vfunc +24`: обновление transform-context (для части opcode no-op). +- `vfunc +8`: update/compute (bool); +- `vfunc +12`: emission/render callback; +- `vfunc +20`: toggle active; +- `vfunc +16`/`+24`: служебные функции (зависят от opcode). --- -## 5. Алгоритм загрузки FXID (engine-accurate) +## 5. Загрузка FXID (engine-accurate) -Псевдокод `sub_10007650`: +`sub_10007650`: ```c void FxLoad(FxInstance* fx, uint8_t* payload) { FxHeader60* h = (FxHeader60*)payload; - fx->raw_header_ptr = h; + fx->raw_header = h; fx->mode = h->time_mode; - fx->end_ms = h->duration_sec * 1000.0f + fx->start_ms; - fx->scale = { h->scale_x, h->scale_y, h->scale_z }; + 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++) { + 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 = CreateCommandByOpcode(op, ptr); // может вернуть null - - if (cmd != null) { + Command* cmd = CreateByOpcode(op, ptr); // может вернуть null + if (cmd) { cmd->enabled = (w >> 8) & 1; - if (h->flags & 0x400) - fx->manager_flags |= 0x0100; // внутренний bit - - if ((h->flags & 0x400) || cmd->enabled) - fx->manager_flags |= 0x0010; + if (h->flags & 0x400) fx->manager_flags |= 0x0100; + if ((h->flags & 0x400) || cmd->enabled) fx->manager_flags |= 0x0010; - cmd->Attach(fx->queue, fx); + cmd->Init(fx->queue, fx); fx->commands.push_back(cmd); } - ptr += size_by_opcode(op); // в оригинале без checks + ptr += size_by_opcode(op); // без bounds checks в оригинале } } ``` -Поведение оригинала, важное для 1:1: +Критичные edge-case оригинала: + +- bounds checks отсутствуют; +- при unknown opcode `ptr` не двигается (`advance = 0`); +- при `new == null` команда пропускается, но `ptr` двигается. -- проверок границ буфера нет; -- при `unknown opcode` указатель `ptr` не двигается (счётчик цикла движется); -- при `new == null` команда пропускается, но `ptr` двигается на размер opcode. +Фактический `advance` в `sub_10007650` задан hardcoded в DWORD: -Для toolchain рекомендуется **строгий** и **безопасный** парсер (см. раздел 7). +- `op1:+56`, `op2:+37`, `op3:+50`, `op4:+51`, `op5:+28`, +- `op6:+1`, `op7:+52`, `op8:+62`, `op9:+52`, `op10:+52`, +- `default:+0`. --- -## 6. Runtime-жизненный цикл эффекта +## 6. Runtime lifecycle -## 6.1. Инициализация +- `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`. -- `sub_10007470`: конструктор instance; -- инициализируются матрицы/scale/флаги; -- начальный `mode` берётся из header. +Event-codes `sub_10003D30`: -## 6.2. Tick и обновление +- `4`: bootstrap/time init; +- `20`: range-removal + index repair; +- `23`: set manager bit0; +- `24`: clear manager bit0; +- `28`: main tick. + +--- -Основной тик идёт через `sub_10003D30(case 28)`: +## 7. Общий тип `ResourceRef64` -1. обновление времени manager; -2. обход активных FX instances; -3. для каждого инстанса `sub_10006170`: - - gating по `flags`/queue-state; - - вычисление alpha через `sub_10005C60`; - - вызов `sub_10008120` (update/bounds/command-pass); - - при необходимости `sub_10007D10` (эмиссия/рендерный callback). +Для opcode `2/3/4/5/7/8/9/10` присутствует ссылка вида: -## 6.3. Start/Stop/Restart API +```c +struct ResourceRef64 { + char archive[32]; // null-terminated ASCII, case-insensitive compare + char name[32]; // null-terminated ASCII +}; +``` -- Start: `sub_10004BD0` -> `sub_10007A30(..., 1, now)`; -- Stop: `sub_10004C10` -> `sub_10007A30(..., 0, now)`; -- Restart/retime: `sub_10004B00`, `sub_10004BA0`. +Поведение loader'а: -## 6.4. Manager event-codes (`sub_10003D30`) +- оба имени обязаны быть непустыми; +- кэширование по `(_strcmpi archive, _strcmpi name)`; +- загрузка/резолв через manager resource API. -Обработанные коды: +Наблюдение по данным: -- `4`: bootstrap + установка текущего времени; -- `20`: удаление диапазона объектов в queue и корректировка индексов; -- `23`: выставить manager-flag bit0; -- `24`: сбросить manager-flag bit0; -- `28`: основной per-frame update. +- для `opcode 2`: обычно `sounds.lib` + `*.wav`; +- для остальных: обычно `material.lib` + material name. --- -## 7. Спецификация для инструментов +## 8. Полная карта body по opcode (field-level) -## 7.1. Reader (strict) +Смещения указаны от начала команды (включая `cmd_word`). -Рекомендуемый строгий парсер: +## 8.1. Opcode 1 (`off_1001E78C`, size=224) -1. проверить `len(payload) >= 60`; -2. прочитать `cmd_count`; -3. `ptr = 0x3C`; -4. для каждой команды: - - требовать `ptr + 4 <= len`; - - прочитать `opcode`; - - `opcode` должен быть в `1..10`; - - `ptr + size(opcode) <= len`; - - `ptr += size(opcode)`; -5. в strict-режиме требовать `ptr == len(payload)`. +Основные методы: -Такой алгоритм совпадает с валидатором `tools/msh_doc_validator.py`. +- init: `sub_1000F4B0`; +- update: `sub_1000F6E0`; +- emit: `nullsub_2`; +- toggle: `sub_1000F490`. -## 7.2. Reader (engine-compatible) +```c +struct FxCmd01 { + uint32_t word; // +0 + uint32_t mode; // +4 (enum, см. ниже) + float t_start; // +8 + float t_end; // +12 -Для byte-level совместимости с оригиналом можно поддержать legacy-режим: + float p0_min[3]; // +16..24 + float p0_max[3]; // +28..36 -- без bounds-check (как `Effect.dll`); -- с toleration на `unknown opcode` (но это потенциально unsafe). + float p1_min[3]; // +40..48 + float p1_max[3]; // +52..60 -## 7.3. Editor (без потери совместимости) + float q0_min[4]; // +64..76 + float q0_max[4]; // +80..92 -Безопасные операции: + float q0_rand_span[4]; // +96..108 (все 4 читаются в sub_1000F6E0) -- менять `header`-поля (mode, duration, flags, scale, pivot); -- менять `enabled` через `cmd_word.bit8`; -- удалять/вставлять команды с корректным пересчётом `cmd_count` и сдвигом stream; -- сохранять command-body как opaque bytes, если нет полного field-level декодера. + 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`). -- всегда little-endian; -- не менять размеры записей opcode; -- не вставлять padding между командами; -- для неизвестных битов `cmd_word` и `header.flags` использовать copy-through. +## 8.7. Opcode 7 (`off_1001E228`, size=208) -## 7.4. Writer (canonical) +Методы: -Каноническая сборка payload: +- 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 = len(commands)`; -3. для каждой команды записать `cmd_word` + body фиксированного размера для opcode; -4. итоговый размер должен быть `0x3C + sum(size(opcode_i))`; -5. без хвоста. +2. `cmd_count = commands.len()`; +3. команды сериализуются как `cmd_word + fixed-body`; +4. размер payload: `0x3C + sum(size(op_i))`; +5. без хвостовых байт. + +## 10.4. Editor (lossless) + +Правила: -## 7.5. Конвертация в промежуточный JSON +- все поля little-endian; +- не менять fixed size команды; +- не добавлять padding; +- сохранять неизвестные биты (`cmd_word`, `header.flags`) copy-through; +- для частично-известных полей поддерживать режим `opaque`. -Рекомендуемая структура для round-trip: +## 10.5. IR/JSON (рекомендуемая форма) ```json { @@ -392,71 +740,95 @@ void FxLoad(FxInstance* fx, uint8_t* payload) { }, "commands": [ { - "opcode": 3, + "opcode": 8, + "word_raw": 264, "enabled": 1, - "word_raw": 259, - "body_hex": "..." + "fields": { + "mode": 1065353216, + "eval_t0": 0.0, + "eval_t1": 1.0, + "resource": {"archive": "material.lib", "name": "fire_smoke"} + }, + "opaque_extra_hex": "..." } ] } ``` -`body_hex` хранит opaque payload без потери данных. +--- + +## 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`. --- -## 8. Проверка на реальных данных +## 12. Практический чек-лист 1:1 -`testdata/nres` (через `tools/msh_doc_validator.py`) : +Для runtime-порта: -- FXID effects: `923/923 valid`. +- реализовать `FxHeader60` и parser `sub_10007650`; +- реализовать opcode-классы с методами как в vtable; +- учитывать start/stop/restart контракт manager API; +- воспроизвести `sub_10005C60` + post-flags (`0x20`, `0x200`); +- воспроизвести event loop `sub_10003D30(case 28)`. -Дополнительно по этим 923 payload: +Для toolchain: -- `cmd_count`: min `0`, max `81`, avg `5.13`; -- `duration_sec`: min `0.0`, max `60.0`, avg `2.46`; -- `opcode` распределение: - - `1: 618` - - `2: 517` - - `3: 1545` - - `4: 202` - - `5: 31` - - `7: 1161` - - `8: 237` - - `9: 266` - - `10: 160` - - `6`: не встречен, но поддержан parser. -- `cmd_word`: - - `bits 9..31` не использованы в датасете; - - `bit8` встречается для части opcode (особенно `3`, `7`, `9`). +- strict validator по разделу 10.1; +- canonical writer по разделу 10.3; +- field-aware editor + opaque fallback для неизвестных зон. --- -## 9. Известные пробелы (не блокируют 1:1 container/runtime) +## 13. Что считать «полной» совместимостью -1. Полная человеко-читаемая семантика **внутренних полей command body** для каждого opcode не завершена. -2. Для части битов `header.flags` есть только functional-наблюдение без финального gameplay-имени. -3. Высокие биты `settings_id` используются как есть (runtime читает low8); их предметное имя не зафиксировано. +Практический критерий завершения: -Это не мешает: +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`) совпадают с оригиналом. -- корректно читать/валидировать/пересобирать FXID; -- делать lossless редактирование; -- воспроизводить lifecycle менеджера и update-loop 1:1 на уровне контракта. +Эта страница покрывает весь наблюдаемый контракт формата/рантайма и полную карту body-полей по всем opcode. --- -## 10. Минимальный чек-лист реализации +## 14. Что осталось до «абсолютных 100%» -Для 1:1-порта движка: +Для практического 1:1 (парсер/writer/runtime на известном контенте) покрытие уже достаточно. +Для «абсолютных 100%» на любых входах и во всех краевых режимах остаются 3 пункта: -- реализовать `FxHeader60` и stream parser по размерам opcode; -- реализовать менеджер API (раздел 2.3); -- реализовать tick-path `03D30(case 28)` -> `06170` -> `08120`/`07D10`; -- учитывать флаги `0x40`, `0x400`, `0x800`, `0x1000`, `0x80/0x100`, `0x20`, `0x200`. +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-образцы. -Для инструментов: +Что нужно собрать, чтобы закрыть это полностью: -- strict validator по разделу 7.1; -- canonical writer по разделу 7.4; -- opaque-представление command-body для безопасного round-trip. +- frame-by-frame dump из оригинального runtime (alpha, manager flags, per-command state); +- контрольные прогоны при фиксированном `dt` и seed; +- минимум по одному ресурсу на каждую редкую ветку (`op6`, op1-tail с ненулевыми `+136..223`). |
