From 70ed6480c2b2b2ecab4956216c1e8e85b0938b4c Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Wed, 11 Feb 2026 21:50:33 +0000 Subject: Refactor materials and Texm documentation for clarity and completeness - Updated the structure and content of the materials and Texm documentation to provide a comprehensive overview of the material subsystem in the engine. - Enhanced sections on identifiers, architecture, material layout, and runtime storage. - Improved explanations of material attributes, animation modes, and parsing behavior. - Added detailed specifications for toolchain interactions, including lossless write rules and validation recommendations. - Included pseudocode examples for parsing MAT0 and Texm formats to aid in understanding. --- docs/specs/fxid.md | 480 +++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 415 insertions(+), 65 deletions(-) (limited to 'docs/specs/fxid.md') diff --git a/docs/specs/fxid.md b/docs/specs/fxid.md index d4ff66d..65bf7f1 100644 --- a/docs/specs/fxid.md +++ b/docs/specs/fxid.md @@ -1,102 +1,421 @@ # FXID -Документ описывает контейнер ресурса эффекта и формат команд эффекта. +Документ описывает формат ресурса эффекта `FXID`, контракт runtime в `Effect.dll` и практические правила для инструментов чтения/конвертации/редактирования. + +Цель: дать достаточную high-level спецификацию для: + +- 1:1 загрузчика/рантайма эффекта; +- валидатора payload; +- бинарно-совместимого редактора; +- конвертера в промежуточный формат и обратно. + +Связанный контейнер: [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`; +- core update: `Effect.dll!sub_10008120`, `sub_10006170`, `sub_10007D10`; +- export API: `CreateFxManager`, `InitializeSettings`. --- -## 3.2. Контейнер ресурса эффекта +## 2. Место формата в движке + +### 2.1. Контейнер NRes + +Эффект хранится как запись NRes с типом: + +- `type_id = 0x44495846` (`"FXID"`). + +Для всех 923 FXID-entries в `testdata/nres` подтверждено: -Эффекты в игровых архивах хранятся как NRes‑entries типа: +- `attr1 = 0`; +- `attr2 = 0`; +- `attr3 = 1`. -- `0x44495846` (`"FXID"`). +### 2.2. Runtime-модуль -Парсер эффекта находится в `Effect.dll!sub_10007650`. +`Effect.dll` экспортирует 2 функции: -## 3.3. Формат payload эффекта +- `CreateFxManager(int a1, int a2, int owner)`; +- `InitializeSettings()`. -### 3.3.1. Header (первые 60 байт) +`CreateFxManager` выделяет объект (`0xB8` байт), инициализирует его через `sub_10003AE0`, возвращает **интерфейсный указатель** (смещение `+4` от базового объекта). + +### 2.3. COM-подобный интерфейс + +Внешний код (например, `Terrain.dll`) получает рабочий интерфейс через `QueryInterface(id=19)` и далее вызывает методы vtable `off_1001E478`. + +Ключевые методы интерфейса менеджера (по vtable): + +| Vtable offset | Функция | Назначение (high-level) | +|---|---|---| +| +0x10 | `sub_10004320` | Открыть/закэшировать ресурс эффекта (`archive + name`) | +| +0x14 | `sub_10004590` | Создать runtime-инстанс эффекта по шаблону | +| +0x18 | `sub_10004780` | Удалить инстанс по id | +| +0x1C | `sub_100047B0` | Установить режим интерполяции/времени | +| +0x20 | `sub_100047D0` | Установить scale | +| +0x24 | `sub_10004830` | Установить позицию | +| +0x28 | `sub_10004930` | Установить матрицу transform | +| +0x2C | `sub_10004B00` | Перезапуск с mode | +| +0x38 | `sub_10004BA0` | Модификатор длительности | +| +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) | + +Этого контракта достаточно, чтобы корректно встроить FXID-рантайм в движок. + +--- + +## 3. Бинарный формат payload FXID + +Все числа little-endian. + +## 3.1. Header (60 байт, `0x3C`) ```c struct FxHeader60 { - uint32_t cmdCount; // +0x00 - uint32_t globalFlags; // +0x04 - float durationSec; // +0x08 (дальше умножается на 1000.0) - uint32_t unk0C; // +0x0C - uint32_t flags10; // +0x10 (используются биты 0x40 и 0x400) - uint8_t reserved[0x2C];// +0x14..+0x3B + 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 }; ``` -Поток команд начинается строго с `offset 0x3C`. +Командный поток начинается строго с `offset = 0x3C`. -### 3.3.2. Командный поток +## 3.2. Поля header: подтверждённая семантика -Каждая команда начинается с `uint32 cmdWord`, где: +- `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`. -- `opcode = cmdWord & 0xFF`; -- `enabled = (cmdWord >> 8) & 1` (копируется в `obj+4`). +## 3.3. `flags` (`header+0x10`) — подтвержденные биты -Размер команды зависит от opcode и прибавляется в **байтах** (`add edi, ...` в ASM): +| Бит | Маска | Поведение | +|---|---:|---| +| 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` | -| Opcode | Размер записи | -|--------|---------------| -| 1 | 224 | -| 2 | 148 | -| 3 | 200 | -| 4 | 204 | -| 5 | 112 | -| 6 | 4 | -| 7 | 208 | -| 8 | 248 | -| 9 | 208 | -| 10 | 208 | +Остальные биты в движке напрямую не расшифрованы на уровне high-level, но должны сохраняться 1:1. -Никакого межкомандного выравнивания нет: следующая команда сразу после `size(opcode)`. +## 3.4. `time_mode` (`header+0x04`) — режимы `sub_10005C60` -## 3.4. Runtime-классы команд (vtable mapping) +Поддерживаются коды `0..17`. -В `sub_10007650` для каждого opcode создаётся объект конкретного типа: +| 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`. + +--- -- `op1` → `off_1001E78C` -- `op2` → `off_1001F048` -- `op3` → `off_1001E770` -- `op4` → `off_1001E754` -- `op5` → `off_1001E360` -- `op6` → `off_1001E738` -- `op7` → `off_1001E228` -- `op8` → `off_1001E71C` -- `op9` → `off_1001E700` -- `op10` → `off_1001E24C` +## 4. Командный поток -`flags10 & 0x400` включает глобальный runtime-флаг менеджера эффекта (`manager+0xA0`). +## 4.1. Формат записи команды -## 3.5. Алгоритм загрузки эффекта (1:1) +Каждая команда начинается с `uint32 cmd_word`. + +Биты: + +- `opcode = cmd_word & 0xFF`; +- `enabled = (cmd_word >> 8) & 1`; +- в реальных данных `bits 9..31 == 0` (но редактор должен сохранять весь word как есть). + +Никакого межкомандного выравнивания нет: следующая команда начинается сразу после `size(opcode)`. + +## 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.3. Opcode -> runtime-класс + +В `sub_10007650` для opcode создаются объекты: + +| Opcode | `operator new` | Runtime 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` | + +Важно: payload команды хранится как сырой указатель и разбирается runtime-методами класса. + +## 4.4. Внутренний вызовной контракт команд + +После создания каждой команды менеджер: + +1. Проставляет `enabled` из `cmd_word.bit8` в поле `obj+4`. +2. Вызывает инициализацию команды (`vfunc +4`) с аргументами `(queue, manager)`. +3. Добавляет команду в массив команд эффекта. + +В update-cycle менеджер вызывает: + +- `vfunc +8`: вычисление/обновление команды (bool); +- `vfunc +12`: callback при render/emission; +- `vfunc +20`: toggle активности; +- `vfunc +24`: обновление transform-context (для части opcode no-op). + +--- + +## 5. Алгоритм загрузки FXID (engine-accurate) + +Псевдокод `sub_10007650`: ```c -read header60 -ptr = data + 0x3C -for i in 0..cmdCount-1: - op = ptr[0] & 0xFF - obj = new CommandClass(op) - obj->enabled = (ptr[0] >> 8) & 1 - obj->raw = ptr - manager.attach(obj) - ptr += sizeByOpcode(op) +void FxLoad(FxInstance* fx, uint8_t* payload) { + FxHeader60* h = (FxHeader60*)payload; + + fx->raw_header_ptr = 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->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 = CreateCommandByOpcode(op, ptr); // может вернуть null + + if (cmd != null) { + cmd->enabled = (w >> 8) & 1; + + if (h->flags & 0x400) + fx->manager_flags |= 0x0100; // внутренний bit + + if ((h->flags & 0x400) || cmd->enabled) + fx->manager_flags |= 0x0010; + + cmd->Attach(fx->queue, fx); + fx->commands.push_back(cmd); + } + + ptr += size_by_opcode(op); // в оригинале без checks + } +} +``` + +Поведение оригинала, важное для 1:1: + +- проверок границ буфера нет; +- при `unknown opcode` указатель `ptr` не двигается (счётчик цикла движется); +- при `new == null` команда пропускается, но `ptr` двигается на размер opcode. + +Для toolchain рекомендуется **строгий** и **безопасный** парсер (см. раздел 7). + +--- + +## 6. Runtime-жизненный цикл эффекта + +## 6.1. Инициализация + +- `sub_10007470`: конструктор instance; +- инициализируются матрицы/scale/флаги; +- начальный `mode` берётся из header. + +## 6.2. Tick и обновление + +Основной тик идёт через `sub_10003D30(case 28)`: + +1. обновление времени manager; +2. обход активных FX instances; +3. для каждого инстанса `sub_10006170`: + - gating по `flags`/queue-state; + - вычисление alpha через `sub_10005C60`; + - вызов `sub_10008120` (update/bounds/command-pass); + - при необходимости `sub_10007D10` (эмиссия/рендерный callback). + +## 6.3. Start/Stop/Restart API + +- Start: `sub_10004BD0` -> `sub_10007A30(..., 1, now)`; +- Stop: `sub_10004C10` -> `sub_10007A30(..., 0, now)`; +- Restart/retime: `sub_10004B00`, `sub_10004BA0`. + +## 6.4. Manager event-codes (`sub_10003D30`) + +Обработанные коды: + +- `4`: bootstrap + установка текущего времени; +- `20`: удаление диапазона объектов в queue и корректировка индексов; +- `23`: выставить manager-flag bit0; +- `24`: сбросить manager-flag bit0; +- `28`: основной per-frame update. + +--- + +## 7. Спецификация для инструментов + +## 7.1. Reader (strict) + +Рекомендуемый строгий парсер: + +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`. + +## 7.2. Reader (engine-compatible) + +Для byte-level совместимости с оригиналом можно поддержать legacy-режим: + +- без bounds-check (как `Effect.dll`); +- с toleration на `unknown opcode` (но это потенциально unsafe). + +## 7.3. Editor (без потери совместимости) + +Безопасные операции: + +- менять `header`-поля (mode, duration, flags, scale, pivot); +- менять `enabled` через `cmd_word.bit8`; +- удалять/вставлять команды с корректным пересчётом `cmd_count` и сдвигом stream; +- сохранять command-body как opaque bytes, если нет полного field-level декодера. + +Правила: + +- всегда little-endian; +- не менять размеры записей opcode; +- не вставлять padding между командами; +- для неизвестных битов `cmd_word` и `header.flags` использовать copy-through. + +## 7.4. Writer (canonical) + +Каноническая сборка payload: + +1. записать `FxHeader60`; +2. `cmd_count = len(commands)`; +3. для каждой команды записать `cmd_word` + body фиксированного размера для opcode; +4. итоговый размер должен быть `0x3C + sum(size(opcode_i))`; +5. без хвоста. + +## 7.5. Конвертация в промежуточный JSON + +Рекомендуемая структура для round-trip: + +```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": 3, + "enabled": 1, + "word_raw": 259, + "body_hex": "..." + } + ] +} ``` -Ошибка формата: +`body_hex` хранит opaque payload без потери данных. -- неизвестный opcode; -- выход за пределы буфера до обработки `cmdCount`; -- непустой «хвост» после `cmdCount` команд (для строгого валидатора). +--- + +## 8. Проверка на реальных данных -## 3.6. Проверка на реальных данных +`testdata/nres` (через `tools/msh_doc_validator.py`) : -Для `testdata/nres/effects.rlb` (923 entries): +- FXID effects: `923/923 valid`. -- `opcode` всегда в диапазоне `1..10`; -- stream полностью покрывает payload без хвоста; -- частоты opcode: +Дополнительно по этим 923 payload: + +- `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` @@ -106,7 +425,38 @@ for i in 0..cmdCount-1: - `8: 237` - `9: 266` - `10: 160` - - `6` в этом наборе не встретился, но поддерживается парсером. + - `6`: не встречен, но поддержан parser. +- `cmd_word`: + - `bits 9..31` не использованы в датасете; + - `bit8` встречается для части opcode (особенно `3`, `7`, `9`). --- +## 9. Известные пробелы (не блокируют 1:1 container/runtime) + +1. Полная человеко-читаемая семантика **внутренних полей command body** для каждого opcode не завершена. +2. Для части битов `header.flags` есть только functional-наблюдение без финального gameplay-имени. +3. Высокие биты `settings_id` используются как есть (runtime читает low8); их предметное имя не зафиксировано. + +Это не мешает: + +- корректно читать/валидировать/пересобирать FXID; +- делать lossless редактирование; +- воспроизводить lifecycle менеджера и update-loop 1:1 на уровне контракта. + +--- + +## 10. Минимальный чек-лист реализации + +Для 1:1-порта движка: + +- реализовать `FxHeader60` и stream parser по размерам opcode; +- реализовать менеджер API (раздел 2.3); +- реализовать tick-path `03D30(case 28)` -> `06170` -> `08120`/`07D10`; +- учитывать флаги `0x40`, `0x400`, `0x800`, `0x1000`, `0x80/0x100`, `0x20`, `0x200`. + +Для инструментов: + +- strict validator по разделу 7.1; +- canonical writer по разделу 7.4; +- opaque-представление command-body для безопасного round-trip. -- cgit v1.2.3