aboutsummaryrefslogtreecommitdiff
path: root/docs/specs/fxid.md
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-02-12 00:50:33 +0300
committerValentin Popov <valentin@popov.link>2026-02-12 00:50:33 +0300
commit70ed6480c2b2b2ecab4956216c1e8e85b0938b4c (patch)
tree701eba2b9d08c50f6e8392f69925bbe9cb7b8644 /docs/specs/fxid.md
parent662b292b5b47d0f7df3b19808db746bbc2ecc48c (diff)
downloadfparkan-70ed6480c2b2b2ecab4956216c1e8e85b0938b4c.tar.xz
fparkan-70ed6480c2b2b2ecab4956216c1e8e85b0938b4c.zip
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.
Diffstat (limited to 'docs/specs/fxid.md')
-rw-r--r--docs/specs/fxid.md480
1 files changed, 415 insertions, 65 deletions
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.