1434 lines
128 KiB
Markdown
1434 lines
128 KiB
Markdown
# Сценарная VM, формулы и игровые свойства
|
||
|
||
## Подтверждённый surface
|
||
|
||
Миссионный сценарный слой задаёт стартовые события, completion/failure,
|
||
messages, teleports, задачи, research и campaign transitions. Точки входа и
|
||
файлы: `ai.dll: CreateSuperAI/GetSuperAI`, `MisLoad.dll: LoadResearch`,
|
||
`ArealMap.dll: CalcFullResearchCost`, `MISSIONS/SCRIPTS/*.scr`, `*.fml`,
|
||
`*.trf`, `varset.var`, `MISSIONS/dispatcher.ini`, `mission.cfg`, `messages.cfg`
|
||
и `briefing.cfg`.
|
||
|
||
`.scr` — binary package с version checks, symbol/event sections и offsets;
|
||
полная opcode grammar не доказана. Его внешний framing читает
|
||
`fparkan-script`: первый little-endian `u32` является числом required opcode
|
||
handlers, второй — числом event records. Каждый event хранит `name_len`,
|
||
`name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных
|
||
records. Вложенный record сохраняет семь `u32` header words (в disk order),
|
||
список `u32` references после шестого header word и trailing seventh word.
|
||
Никакой из этих words ещё не получает semantic name. `.fml` — текстовый
|
||
symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaults.
|
||
`fparkan-script::parse_varset` уже читает подтверждённые numeric
|
||
`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются
|
||
opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и
|
||
`FUNCTION(...)` пока сохранены за границей этого numeric contract;
|
||
GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration:
|
||
31 `float` и 200 `DWORD` (от `f0` до `fY`);
|
||
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
|
||
`not found` откатывается к этому shared file. Runtime повторяет данный порядок
|
||
транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но
|
||
ещё не исполняет declarations как VM state;
|
||
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
|
||
consumer-inferred.
|
||
|
||
## Безопасная модель исполнения
|
||
|
||
Целевая VM должна разделять immutable package (bytecode, symbols, events,
|
||
constants), per-mission variables/timers/frames, bindings
|
||
logical-name/ObjectId/clan/research key и typed commands к
|
||
World3D/Behavior/UI/campaign. После varset defaults и bindings она будет
|
||
запускать Init/start, обновлять timers на каждом tick, ставить готовые events
|
||
в стабильную очередь и исполнять bounded instruction budget. Опасное удаление
|
||
должно идти через World3D queue и общий deferred lifecycle.
|
||
|
||
До восстановления opcode table package mode читает header/strings/symbols/
|
||
event offsets/raw bytecode losslessly. Статический анализ уже выделил отдельный
|
||
evaluator записей условий (`ai.dll` RVA `0x5180`). Его подтверждённые ветви и
|
||
точные границы описаны ниже; он не является dispatcher-ом инструкций или jump
|
||
table `.scr`. Bytecode opcode table всё ещё требует отдельного доказательства.
|
||
Unknown opcode нельзя пропустить как один byte: это ломает синхронизацию. Для
|
||
каждого доказанного opcode фиксируются number, size, operands, control flow,
|
||
effects, errors и минимальный test.
|
||
|
||
GOG `ai.dll` доказывает этот framing двумя consumer-ами: loader по
|
||
`0x10001000` открывает `<bundle>.scr`, `varset.var`, `<bundle>.fml`, затем
|
||
собирает ровно 73 pointers handlers; `0x10011b20` читает описанную count-driven
|
||
структуру. В GOG `c1m2p.scr` reader находит 73 handler slots,
|
||
9 events, 17 nested records и 20 references, заканчивая на EOF.
|
||
Это подтверждает разбор пакета, но не смысл всех opcodes.
|
||
|
||
Selector устроен следующим образом: loader `0x10001000` создаёт 73 pointers в
|
||
фиксированном порядке, а `0x10011e70` копирует их без перестановки в runtime
|
||
array. Во всех 58 GOG `.scr` первый header word каждого nested record равен
|
||
`0..72` либо `0xffff_ffff`: соответственно 2095 handler selectors и 3992
|
||
sentinel records. Поэтому `ScriptInstruction::dispatch_selector()` возвращает
|
||
`Handler(0..72)`, `Sentinel` или сохраняемый `Unknown(u32)`. Первый handler
|
||
(`Handler(0)`, VA `0x10008034`) только устанавливает current context и flag
|
||
`+0x50 = 1`; это не даёт ему игрового имени и не заменяет runtime trace.
|
||
|
||
### Evaluator записей условий: `ai.dll` RVA `0x5180`
|
||
|
||
Ниже описан отдельный слой готовых условий, а не декодирование instructions.
|
||
Результат привязан к GOG `ai.dll` версии `1.00`, SHA-256
|
||
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`;
|
||
PE image base — `0x10000000`, адрес функции — VA `0x10005180` / RVA `0x5180`.
|
||
Функция занимает 910 байт и имеет единственного прямого caller-а
|
||
`FUN_10004ee0` (RVA `0x4ee0`). Это статический вывод по машинному коду; сам
|
||
evaluator и его внешний объектный интерфейс здесь не запускались.
|
||
|
||
Функция принимает signed index в массив записей по `this + 0x34`. Проверенный
|
||
доступ `FUN_100070c0` возвращает 16-byte запись:
|
||
|
||
| Смещение | Поле | Роль в evaluator-е |
|
||
| ---: | --- | --- |
|
||
| `+0x00` | `u32 tag` | Выбор одной из пяти ветвей |
|
||
| `+0x04` | `u32 subtype` | Дополнительная проверка формата ветви |
|
||
| `+0x08` | payload | Ключ объекта или индекс списка, в зависимости от tag |
|
||
| `+0x0c` | byte completion | Записывается в `1` только после успешной проверки |
|
||
|
||
Общие проверки допускают только пары `(tag 1..3, subtype 0)` и
|
||
`(tag 4..5, subtype 1)`. Неизвестный tag и неподходящий subtype не завершают
|
||
условие. При провале evaluator не очищает и не меняет байт завершения; при
|
||
успехе он устанавливает его в `1`. Вызывающая функция `0x4ee0` отдельно
|
||
пропускает уже завершённые записи, поэтому этот пропуск нельзя приписывать
|
||
самому evaluator-у. Прямая запись evaluator-а в запись — только этот байт, но
|
||
вызываемые виртуальные методы могут иметь внешние эффекты.
|
||
|
||
Пары tag/subtype проверяются так:
|
||
|
||
| Запись | Условие установки флага завершения |
|
||
| --- | --- |
|
||
| `(1, 0)` | Менеджер объектов, полученный из контекста `this + 0x60` по полю `+0x35c`, ищет payload через виртуальный слот `+0x1c`; поиск должен вернуть null. Найденный объект оставляет условие незавершённым. |
|
||
| `(2, 0)` | Поиск payload через тот же слот `+0x1c` должен найти объект. Затем его слот `+0x10` должен вернуть `0`, либо слот `+0x18` должен заполнить переданную структуру результата так, чтобы её первый `u32` был `1`, `0x12` или `0x13`. |
|
||
| `(3, 0)` | Поиск должен найти объект; его слот `+0x44` должен вернуть тот же `u32`, что и первый word контекста, на который указывает поле `this + 0x60`. |
|
||
| `(4, 1)` | Payload выбирает 32-byte запись в списке `this + 0x4c`; вложенный в неё список дочерних ссылок находится по смещению `+0x0c`. Ни один проверяемый дочерний ключ не должен разрешиться в объект. |
|
||
| `(5, 1)` | Используется тот же список. Отсутствующий объект пропускается; найденный объект проходит проверку, только если его слот `+0x10` вернул `0`. Первый найденный объект с ненулевым результатом завершает обход неуспехом. |
|
||
|
||
Для tag 2 структура размером `0x138` байт инициализируется функцией
|
||
`FUN_10004330`, затем передаётся в slot `+0x18`. x87-значение, которое
|
||
возвращает этот вызов, инструкция `FSTP ST0` по RVA `0x5256` сразу удаляет со
|
||
стека FPU. Следовательно, ветвь принимает решение только по первому word
|
||
выходной структуры, а не по возвращённому числовому значению.
|
||
|
||
У tag 4 и 5 есть важная граница индекса. Сравнение payload с количеством
|
||
элементов — unsigned `CMP` с условием `JA`: только `payload > count` сразу
|
||
оставляет запись незавершённой. Значение `payload == count` проходит эту
|
||
проверку и доходит до проверенного доступа `FUN_10002e20`; тот отвергает
|
||
`index >= count` и выбрасывает C++ exception. Это не эквивалентно обычной проверке
|
||
`payload < count` с безопасным отказом для всех неверных индексов.
|
||
|
||
Сами дочерние ссылки списка читаются как signed `i32`. Ровно `-1` — sentinel,
|
||
который пропускается. Остальные значения проверяются как индексы в контекстном
|
||
массиве `context + 0x10` с 32-byte элементами; в элементе по `+0x04` берётся
|
||
ключ следующего поиска объекта. Для tag 4 первый найденный объект немедленно
|
||
прерывает обход с незавершённым результатом; для tag 5 обход прекращается на
|
||
первом найденном объекте с ненулевым результатом slot `+0x10`. Если обход
|
||
достигает конца без такого запрета, соответствующая ветвь устанавливает byte
|
||
completion.
|
||
|
||
Из этих ветвей нельзя выводить языковые имена вроде «объект уничтожен» или
|
||
«объект принадлежит игроку»: в коде подтверждены только поиски, виртуальные
|
||
вызовы, сравнения и коды результата. Неизвестно и то, какая функция создаёт
|
||
массив 16-byte записей. Считыватель `FUN_10011b20` (RVA `0x11b20`) отдельно
|
||
разбирает `.scr` с переменной длиной: событие содержит длину и байты имени,
|
||
счётчик вложенных записей, а вложенная запись — семь заголовочных `u32` и список
|
||
ссылок. При разборе reader хранит описатели событий с шагом `0x14` и описатели
|
||
вложенных записей с шагом `0x20`; это шаги элементов внутренних контейнеров, а не
|
||
фиксированные размеры записей на диске. Связь этих контейнеров с массивом
|
||
`this + 0x34` не найдена, поэтому приравнивать их нельзя. Не установлены и
|
||
объект-владелец массива, соглашение его runtime-вызова и предметные эффекты
|
||
внешних виртуальных вызовов; границы перечислены в
|
||
[`knowledge-boundaries.md`](knowledge-boundaries.md).
|
||
|
||
`Handler(1)` — второй table entry, VA `0x10007fd0`, — не создаёт игровую
|
||
команду. Он сохраняет active VM context, берёт один instruction-derived index
|
||
через current event/instruction offsets `+0x48/+0x4c`, а затем разрешает его
|
||
в varset object по `this + 0x18`. Resolver `0x10002d30` проверяет
|
||
`0 <= index < count` и возвращает record `base + index * 0x30`; invalid index
|
||
вызывает C++ exception, а не становится нулём. Полученный 48-byte record
|
||
передаётся в `0x10013190`, который возвращает x87 floating result: kinds `0`
|
||
и `4` идут через отдельный opaque conversion path, kind `1` — signed integer,
|
||
kind `2` выбирает одну из двух static scalar constants по нулевости payload,
|
||
kind `3` — float, kind `5` — unsigned integer; остальные и пустые cases дают
|
||
один fixed fallback scalar. Это доказанный numeric
|
||
bridge для VM, но пока не Rust handler: неизвестны точный disk operand slot,
|
||
ownership значения на FPU stack и следующий consumer, поэтому нельзя назвать
|
||
его арифметическим opcode или silently заменить portable `f32` execution.
|
||
Отдельный проход по всем 58 GOG `.scr` (6 087 instruction records) не нашёл
|
||
ни одного selector `1`: из них 2 095 записей выбирают один из handlers, а
|
||
3 992 являются sentinel. Значит, это установленная, но не corpus-reachable
|
||
ветка данного издания; её нельзя делать приоритетным execution path без
|
||
отдельного dynamic/evidence route.
|
||
|
||
`Handler(2)` (третья entry table, VA `0x10009610`) уже имеет статический
|
||
contract, но ещё не Rust execution: он выбирает active event/instruction через
|
||
runtime offsets `+0x48/+0x4c` и разрешает семь 32-bit slots через varset object
|
||
`+0x18`. Их доказанный dataflow: slot 0 даёт один `u32` и base string, slot 1
|
||
даёт numeric scalar, slots 2 и 3 — по `u32`, slots 4, 5 и 6 принимают только
|
||
kind `5`/`3` и иначе дают `0.0`. Затем он вызывает `0x100059f0` объекта по
|
||
`this + 0x7c` и очищает flag `+0x50`.
|
||
|
||
Этот callee больше не opaque. Он строит key из семи значений, ищет matching
|
||
record в своей collection по `this + 0x24` и при совпадении обновляет только
|
||
record fields `+0x0c` и `+0x14`, затем вызывает его refresh path `0x10005070`.
|
||
При отсутствии record он лениво ищет в event table имена `<base>_Start` и
|
||
`<base>_Continue`, сохраняет их IDs в indexed state и materializes новый
|
||
internal record. В этой ветке не видно прямого World3D/Behavior call, поэтому
|
||
это доказанная scheduler/event-record boundary, а не команда движения, атаки
|
||
или строительства. Semantic names семи slots и consumer нового record остаются
|
||
открытыми; до dynamic capture Rust возвращает явный
|
||
unsupported result, а не «примерный» game command.
|
||
|
||
У этой границы также нет скрытого immediate dispatch: после добавления новой
|
||
записи `0x100059f0` вызывает `0x1000f920`, а Ghidra 12.1.2 декомпилирует эту
|
||
функцию как пустой `return`. Следовательно, найденные `<base>_Start` и
|
||
`<base>_Continue` только кэшируются в scheduler state; их фактический consumer
|
||
находится в отдельном позднем update path.
|
||
|
||
Во всех 58 GOG `.scr` имеются 6 087 instruction records, включая 3 992
|
||
sentinel. Самый частый non-sentinel selector — `Handler(30)`, 246 records.
|
||
Его VA `0x1000c266` читает первые два слова-ссылки активной инструкции,
|
||
разрешает каждое через varset (`0x10002d30` и `0x10013570`) и вызывает внешний
|
||
обратный вызов с тремя `u32`: `(0, first, second)`. Он не принадлежит `ai.dll`:
|
||
указатель из десятого аргумента сохраняет экспорт `CreateSuperAI`. В
|
||
проверенном GOG AutoDemo он указывает на `iron3d.dll` RVA `0x611d0` (SHA-256
|
||
`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`).
|
||
Вызываемая функция использует ABI `__cdecl(mode, command, payload)`. При
|
||
`mode == 0`, `command == 0` аргумент `payload` со значением `0` выбирает ветвь
|
||
отказа, а `1` — ветвь завершения миссии; обе срабатывают только при условиях,
|
||
описанных ниже. Среда выполнения Rust может
|
||
записать соответствующее целевое состояние в собственный
|
||
`MissionScriptOutcome`, но не проверяет эти условия и пока не исполняет
|
||
команду из диспетчера `.scr`.
|
||
`Handler(57)` передаёт тому же обратному вызову режим `2`, для которого в
|
||
вызываемой функции есть отдельная ветвь IGame; отдельный путь жизненного цикла
|
||
передаёт режим `1`, но ветви для него в `FUN_100611d0` не найдено. По одному
|
||
вызову нельзя присвоить общее игровое имя остальным командам или называть весь
|
||
`Handler(30)` сообщением, приказом либо кодом интерфейса. Преобразователь
|
||
текстовых ссылок в varset находится за обёрткой `0x10011ea0`, в
|
||
`0x100174a0`.
|
||
|
||
`0x100174a0` добавляет каждый
|
||
recognized source declaration в encounter order как 48-byte record; GOG shared
|
||
`varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries
|
||
образуют точно это index space. `0x10013570` возвращает `DWORD` record kind
|
||
raw `u32`; float kind проходит `__ftol`, чей x87 rounding profile ещё требует
|
||
capture. Полный GOG scan всех 246 Handler(30) instructions показывает 492
|
||
operand references: все 492 in-range и указывают на `DWORD`. Поэтому
|
||
`VarSet::resolve_handler30` уже materializes точный opaque callback command
|
||
`(mode=0, first, second)` для данного corpus path, но явно отклоняет float,
|
||
out-of-range и incomplete instructions вместо silent coercion.
|
||
|
||
Identity ровно равна
|
||
`(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и
|
||
`+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`,
|
||
slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При
|
||
совпавшем key refresh случается только когда slot1 сравнивается unequal
|
||
(включая NaN); он заменяет `+0x14` и `+0x0c`, затем прибавляет сохранённый
|
||
`+0x28` к `+0x24` с x86 wrapping arithmetic. `fparkan-script` отражает эту
|
||
изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode,
|
||
не назначает игровых имён и не делает event lookup за original VM.
|
||
|
||
Шаг прибавления проверен прямым вызовом оригинального helper-а
|
||
`FUN_10005070` (RVA `0x5070`) из GOG `ai.dll`, SHA-256
|
||
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`.
|
||
Исходник x86 test harness-а хранится в
|
||
[`handler2_native_oracle.cs`](../../crates/fparkan-script/tests/handler2_native_oracle.cs):
|
||
он сначала сверяет hash DLL, затем вызывает helper с 100-byte record и
|
||
проверяет оба поля. Пример воспроизведения в Windows PowerShell:
|
||
|
||
```powershell
|
||
$env:FPARKAN_ORIGINAL_AI_DLL = 'C:\GOG Games\Parkan - Iron Strategy\ai.dll'
|
||
& "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" /nologo /platform:x86 /out:target\Handler2NativeOracle.exe crates\fparkan-script\tests\handler2_native_oracle.cs
|
||
.\target\Handler2NativeOracle.exe
|
||
```
|
||
|
||
Полученные значения:
|
||
|
||
| `+0x24` до вызова | сохранённый `+0x28` | `+0x24` после вызова | `+0x28` после вызова |
|
||
| ---: | ---: | ---: | ---: |
|
||
| `0x00000004` | `0x00000004` | `0x00000008` | `0x00000004` |
|
||
| `0x00000008` | `0x00000004` | `0x0000000C` | `0x00000004` |
|
||
| `0xFFFFFFFF` | `0xFFFFFFFF` | `0xFFFFFFFE` | `0xFFFFFFFF` |
|
||
| `0xFFFFFFFE` | `0xFFFFFFFF` | `0xFFFFFFFD` | `0xFFFFFFFF` |
|
||
|
||
Rust-тест повторяет эти начальные значения. Для первой пары обновлений входы
|
||
несут slot 2, равный `99` и `7`, однако сохранённый при создании шаг равен `4`;
|
||
для второй пары оба счётчика и шаг начинаются с `0xFFFFFFFF`, а новые входы
|
||
несут `2` и `17`. Правило refresh читается прямо из модели:
|
||
|
||
```rust
|
||
record.counter = record.counter.wrapping_add(record.increment);
|
||
```
|
||
|
||
Native helper получает только указатель на record, поэтому напрямую проверяет
|
||
сложение текущего `+0x24` с сохранённым `+0x28`; свежий slot 2 принадлежит
|
||
сценарию Rust-теста, а не аргументам helper-а. Проверка не охватывает полный
|
||
scheduler path внутри запущенной игры.
|
||
|
||
На границе mission runtime выбранный TMA clan `first_resource`
|
||
материализуется как отдельный `MissionScriptBundle`: loader нормализует
|
||
`<base>.scr`, декодирует его тем же bounded reader-ом и сохраняет clan
|
||
provenance. Полная загрузка получает от вызывающей стороны значение
|
||
32-битного времени очереди, затем для каждого bundle вычисляет начальное
|
||
состояние Behavior и пробует выполнить только восстановленный поднабор первого
|
||
`Init` с `Handler(19)`. Остальные события пока остаются прочитанными данными.
|
||
Статический просмотр не запускает warm-start Behavior, но сохраняет результат
|
||
поддержанных `Handler(19)` writes и создаёт ячейки varset; при неподдержанной
|
||
инструкции оставляет default-значения и статус `Unsupported`.
|
||
|
||
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
|
||
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
|
||
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
|
||
`ClanID`, ore, speed и free-time properties.
|
||
|
||
Research/economy работают в simulation: `LoadResearch` и
|
||
`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный
|
||
layout prerequisites/modifiers/unlocks. Formula evaluator требует strict
|
||
grammar/version, typed operands, deterministic numeric policy, bounded stack и
|
||
явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
|
||
|
||
### Первый фрагмент dispatcher-а: `Init` и Handler(19)
|
||
|
||
Этот контракт относится к проверенной сборке GOG: `ai.dll`, SHA-256
|
||
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`, PE32 с
|
||
image base `0x10000000`. В ней `FUN_10011f40` (RVA `0x11f40`) ищет имя
|
||
события как C-строку: сравнивает байты до первого NUL, с учётом регистра, и
|
||
возвращает индекс первого совпадения. Поле `name_len` при поиске не используется.
|
||
Если совпадения нет, поиск возвращает `-1`; `FUN_10011f10` (RVA `0x11f10`) не
|
||
вызывает обработчик для такого индекса. Поэтому повторные записи `Init` не
|
||
исполняются все подряд: выбирается первая.
|
||
|
||
`FUN_10011f10` передаёт выбранный индекс внутреннему циклу `FUN_10011fb0`
|
||
(RVA `0x11fb0`). Цикл начинает с первой вложенной инструкции, сбрасывает индекс
|
||
инструкции и глубину стека условий, затем идёт по записям в порядке файла.
|
||
В исходном `default.scr` событие `Init` содержит одну инструкцию selector `19`,
|
||
control word `0xffffffff` (действие) и result reference `0xffffffff` (результат
|
||
не сохраняется). Это наблюдаемая форма пакета; поведение оригинала для остальных
|
||
форм здесь не утверждается.
|
||
|
||
В GOG `default.scr` события `Init` в двух AutoDemo bundles имеют по одной такой
|
||
инструкции. Её ссылки `224`, `225`, `226` ведут к объявлениям `DWORD`
|
||
`ClanBaseX`, `ClanBaseY` и `ClanID`. `Handler(19)` (двадцатая запись таблицы,
|
||
VA `0x1000aa38`, RVA `0xaa38`) пишет их по порядку: поля SuperAI `+0x80` и
|
||
`+0x84` переводит через x87 helper `__ftol`, а поле `+0x7c` копирует как
|
||
неизменённое слово. После вызова он очищает поле SuperAI `+0x50`; соответствующее
|
||
смещение в VM, начинающейся с SuperAI `+8`, равно `+0x48`. Поскольку цели имеют
|
||
тип `DWORD`, общий setter сохраняет полученные слова без дополнительного
|
||
преобразования. Helper `__ftol` в `ai.dll` (VA `0x1001df70`, RVA `0x1df70`)
|
||
сохраняет x87 control word, временно задаёт округление к нулю, выполняет
|
||
`FISTP qword` и восстанавливает control word.
|
||
|
||
Входные координаты идут через два DLL. В проверенной сборке `iron3d.dll`
|
||
(SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`)
|
||
функция `FUN_100392e0` преобразует две координаты TMA из `float` в signed
|
||
64-bit через x87 `FISTP qword` (RVA `0x39307` и `0x3932f`), а пятый и шестой
|
||
аргументы `CreateSuperAI` получает как младшие DWORD этих результатов. Десятый
|
||
аргумент — указатель callback. В прямом вызове оригинала пара
|
||
`(500.2857, 751.8217)` дала `(500, 752)`, а tie-векторы `(500.5, 501.5)` при
|
||
ближайшем режиме дали `(500, 502)`. Например, `-0.6` сначала округляется до
|
||
`-1`, а его младший DWORD равен `0xffffffff`.
|
||
|
||
`CreateSuperAI` в `ai.dll` (VA `0x1000f710`, RVA `0xf710`) выделяет `0x8b0`
|
||
байт и вызывает конструктор `FUN_10001000`. Тот передаёт `clan_id`, `base_x` и
|
||
`base_y` в `FUN_10006340`, которая заполняет поля позднее читаемые
|
||
`Handler(19)`. Значения больше `10000` вызывают исходное предупреждение через
|
||
`MessageBoxA`, после которого конструктор продолжает работу; это не отказ и не
|
||
ограничение координат. `FUN_10005c70` (RVA `0x5c70`) затем расширяет DWORD до
|
||
qword, загружает его через x87 и записывает `float` в поля структуры `+4` и
|
||
`+8` — в этой цепочке это SuperAI `+0x80` и `+0x84`. Поэтому `0xffffffff`
|
||
превращается в `2^32` при округлении до single precision.
|
||
Сам `Handler(19)` применяет `__ftol` с усечением к этим полям и берёт младший
|
||
DWORD: для `2^32` получается `0`, а для `10001.0` — `10001`.
|
||
|
||
Rust повторяет весь числовой путь до DWORD, записываемого в `VarSet`:
|
||
nearest-even `FISTP qword`, младший DWORD, преобразование DWORD в `f32`, затем
|
||
усечение результата до qword и его младший DWORD. В частности, это сохраняет
|
||
наблюдаемый переход `-0.6 -> 0xffffffff -> 2^32 -> 0` и не отвергает `10001`.
|
||
Координата, не конечная или не представимая в signed qword на первом шаге,
|
||
остаётся явной ошибкой: поведение такой области здесь не моделируется. Rust не
|
||
показывает исходное предупреждение `MessageBoxA`; оно находится за пределами
|
||
этого числового resolver-а.
|
||
|
||
Ограниченный Rust-исполнитель `VarSet::resolve_handler19_init` ищет только
|
||
первое событие `Init`, проходит его инструкции по порядку и возвращает
|
||
упорядоченные записи. Runtime применяет их к отдельной копии varset выбранного
|
||
клана лишь после успешного прохода всего события. Отсутствующий `Init` даёт
|
||
статус `Missing` и не создаёт записей инициализации; пустой первый `Init` даёт
|
||
`Executed` без записей. Если форма инструкции пока не поддержана, package
|
||
остаётся доступен в preview режиме со статусом `Unsupported`, а default-значения
|
||
ячеек сохраняются; при полной загрузке миссии такая же форма возвращает ошибку с
|
||
индексами события и инструкции. Предшествующие частичные записи не применяются.
|
||
Повреждённая ссылка или цель неправильного типа остаётся ошибкой в обоих режимах.
|
||
Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение
|
||
оригинального dispatcher-а.
|
||
|
||
### Первый вызов Behavior и сценарного `Init`
|
||
|
||
В той же GOG-сборке `Behavior.dll` (SHA-256
|
||
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) функция
|
||
`FUN_1001adb0` (RVA `0x1adb0`) обновляет один Behavior distributor. Она получает
|
||
текущее время через `GetQueue` и queue vslot `+0x38`. Если distributor `+0x10`
|
||
равен нулю, функция записывает туда текущее время очереди, считает начальный
|
||
deadline `+0x14` по формуле `время + 3*64 + jitter`, где `jitter` лежит в
|
||
`0..63` и получен одним шагом глобального MSVCRT LCG. В этой ветви она также
|
||
обнуляет пять слов `+0x30..+0x40`. При повторном вызове до deadline ненулевой
|
||
`+0x10` оставляет срок прежним, не расходует новое случайное число и не
|
||
очищает эти слова. Если само время очереди равно нулю, `+0x10` остаётся
|
||
нулевым, и повторный вызов снова проходит ветвь начальной подготовки. После
|
||
срока scheduler продолжает при нулевом deadline или при беззнаковом
|
||
`now > deadline`; равенство ненулевому deadline ещё ждёт. Он считает
|
||
`u32(now - first_time)` с переполнением по модулю `2^32`, переводит миллисекунды в секунды через x87
|
||
`FILD`/`FMUL`/`FSTP` и планирует следующий срок от текущего времени. Шаг
|
||
интервала равен `3 * 64 + jitter`: один шаг LCG `state = state * 214013 +
|
||
2531011` выдаёт значение `random = (state >> 16) & 0x7fff`, а
|
||
`jitter = ((random & 0xff) * 64) >> 8`. Native oracle проверяет
|
||
равенство и проход срока, обёртку и большие интервалы с округлением времени.
|
||
|
||
Путь host callback в проверенной паре DLL теперь статически известен. В
|
||
`iron3d.dll` функция `FUN_1005ff40` (RVA `0x5ff40`) сначала вызывает
|
||
`FUN_100392e0` для TMA clan records, а затем для записей вызывает
|
||
`FUN_10039440` (RVA `0x39440`). Последняя передаёт AI callback-у аргументы через
|
||
vtable slot `+0x14`. Метод `ai.dll!FUN_10001ae0` (RVA `0x1ae0`) сохраняет это
|
||
слово аргумента в AI `+0x3fc`, сначала обновляет Behavior через объект `AI+0x438`
|
||
и его vslot `+0x2c`, а затем, если latch `AI+0x8a8` ещё сброшен, находит первый
|
||
`Init`. При отсутствии события latch остаётся нулевым. Если событие найдено,
|
||
метод запускает dispatcher только при нулевом recursion guard `AI+0x78`,
|
||
временно ставит guard и снимает его после dispatch; затем ставит latch, даже
|
||
если занятый guard пропустил dispatch. Это определяет одну штатную
|
||
callback-точку входа и порядок внутри неё, но не раскрывает всех смыслов
|
||
входного слова аргумента и не подменяет динамическую трассу полного запуска игры.
|
||
|
||
`fparkan-runtime::load_mission` получает значение времени очереди явным
|
||
аргументом `queue_time_ms`; Rust не читает оригинальную очередь. Для полной
|
||
загрузки Rust после регистрации объектов запускает последовательность
|
||
подготовки Behavior → ограниченный `Init` по каждому bundle. Путь использует
|
||
локальный RNG engine, начиная с состояния `1`; его шаги LCG общие между
|
||
bundle, но остальные потребители исходного глобального RNG не моделируются.
|
||
В Rust также нет значения аргумента в `AI+0x3fc`, recursion guard `AI+0x78`
|
||
или одноразового latch `AI+0x8a8`. Поэтому это связанный начальный поднабор, а
|
||
не полная активация SuperAI. После полной загрузки узкая Rust-реализация
|
||
вызывается из `step_headless(engine, input, queue_time_ms)` и
|
||
`frame(engine, queue_time_ms)`. Отдельный
|
||
`advance_loaded_mission_resources(engine, queue_time_ms)` остаётся доступен
|
||
для прямого ресурсного шага. Runtime не читает системную очередь, а оконное
|
||
приложение пока использует static preview. Шаг использует разрешённые handles и представленные snapshots
|
||
профилей и Control, поэтому не воспроизводит отсутствующие в них callbacks. При
|
||
ошибке ограниченного `Init` Rust отклоняет полную загрузку и сохраняет прежний
|
||
мир и состояние генератора; такая политика отката принадлежит Rust loader-у.
|
||
|
||
В проверенной GOG-сборке `Behavior.dll` (SHA-256
|
||
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`)
|
||
`FUN_10019e80` (RVA `0x19e80`) — отдельный шаг Resource Distributor. При
|
||
нулевом `dt` он сразу возвращается. Иначе он проходит список
|
||
строк по семь DWORD в порядке регистрации дважды: первый проход собирает
|
||
значения, второй заново ищет объекты и применяет результаты. Поэтому найденный
|
||
при втором проходе объект или getter может вернуть уже другое значение. Для каждой
|
||
строки вызывается ArealMap lookup; отсутствующий объект пропускается. Если
|
||
младшие 16 бит результата vslot `+0x44` равны `0xfffe`, строка пропускается до
|
||
запроса интерфейсов. Иначе исходный код запрашивает QI `0x16` и `0x204`, читает
|
||
класс через vslot `+0x38`, а для классов мощности вызывает vslot `+0x68` с
|
||
селектором `0x1005`, для двух классов руды — с `0x1002`. Остальные ветви
|
||
используют интерфейс `0x204` и свойство `0x02000100` через vslots `+0x4c` и
|
||
`+0x48`. В реализации этого свойства `FUN_10026990` (RVA `0x26990`) возвращает
|
||
DWORD по смещению `+0x14` как `f32`; если выходной указатель не null, функция
|
||
копирует в него DWORD по смещению `+0x08`. Парный setter `FUN_100269E0` (RVA
|
||
`0x269e0`) записывает переданный `f32` в `+0x14`; только если третий аргумент
|
||
не null, setter копирует указанный им DWORD в `+0x08`. При null старое значение
|
||
`+0x08` сохраняется. Это нулевые индексы 5 и 2 вектора DWORD; значение `+0x08`
|
||
пока не получило доменного имени. Идентификаторы и границы вызовов не
|
||
задают полную семантику свойств.
|
||
|
||
Раздача считает доступный за этот шаг объём `A`, полный спрос `D` и взвешенный
|
||
спрос `W = Σ(demand × share)`. Из них выводятся коэффициенты приоритетной доли
|
||
`P`, остатка `R` и расхода `C`. В таблице ниже показан исходный порядок
|
||
проверок: он важен, когда нестандартные доли дают `W > D`.
|
||
|
||
| Первое совпавшее условие (при `A > 0`, `D > 0`) | `P` | `R` | `C` |
|
||
| --- | ---: | ---: | ---: |
|
||
| `A ≤ W` | `A / W` | `0` | `1` |
|
||
| `W < A < D` | `1` | `(A − W) / (D − W)` | `1` |
|
||
| `A ≥ D` | `1` | `1` | `D / A` |
|
||
|
||
При нулевом доступном количестве или спросе native path не выделяет
|
||
положительное значение. Строка с долей `share` получает коэффициент
|
||
`(1 − share) × R + share × P`, который сочетается с её спросом или доступной
|
||
вместимостью. `C` задаёт долю отбора руды у источников; для источников энергии
|
||
этот коэффициент вычисляется, но native-код не применяет его к производству.
|
||
У объектов в ветви приёма руды (не источников руды) native-код ограничивает `CurrentOre`:
|
||
если `MaximumOre − CurrentOre < 0.01`, значение становится `MaximumOre`.
|
||
Сравнение строгое: при разнице ровно `0.01` значение не меняется.
|
||
В x86-коде это константа `0x3c23d70a`; сравнение и округление проверены при
|
||
игровом x87 control word `0x007f`.
|
||
|
||
Изолированный x86 oracle вызывает исходный scheduler RVA `0x1adb0`, который
|
||
сам вызывает исходный tick RVA `0x19e80`. Для синтетического времени он
|
||
подменяет IAT-слот `GetQueue`; tick подменяет `GetSystemArealMap`, а синтетические
|
||
vtables записывают последовательность вызовов и ответы. Для смешанного примера
|
||
два прохода нашли объекты в порядке
|
||
`41,42,41,42`. Производитель руды с начальным значением `1` и скоростью `4`
|
||
вернул при следующем чтении скорость `1`; получатель с начальным значением `6`
|
||
получил `7`, а агрегаты distributor равны `[0,1,1,0.375,6]`. При запасе
|
||
производителя `0.25` получатель стал `6.25`; в трёхстрочном примере вес
|
||
получателя `0` не изменил значение `6`, а вес `0.25` дал `8 -> 9`. В примере
|
||
мощности доступное значение `4` покрывает спрос `0.375`; при значении `0.125`
|
||
selector `1` на интерфейсе получателя становится `2/3` (битовый образ
|
||
`0x3f2aaaab`). Эти векторы проверяют арифметику исходной функции, но не
|
||
распределение реальной карты.
|
||
|
||
Полная загрузка Rust собирает этот список из runtime `ObjectHandle` и сохраняет
|
||
веса и поля строк, а также начальные ore-свойства объектов. Членство строится
|
||
только для полного режима; preview остаётся статическим. Явный API
|
||
`advance_loaded_mission_resources` использует очередь времени, расписание,
|
||
профили и представленные Control-таблицы, чтобы исполнить этот ограниченный
|
||
шаг над загруженным состоянием. `step_headless` и `frame` требуют явный
|
||
`u32` clock и используют ту же подготовку: ресурсные изменения, расписания и
|
||
RNG остаются временными до успешного `World::step`. Ошибка ресурсов не
|
||
продвигает мир; ошибка мировой очереди не публикует ресурсные изменения.
|
||
Игнорируемый тест `headless_step_runs_resource_schedule_and_rolls_back_both_error_paths`
|
||
на AutoDemo проверяет равенство срока, исполнение следующего шага и обе ветви
|
||
отказа, включая отсутствие Control-таблицы у позднего клана и устаревший handle
|
||
в очереди. Это не запускает отдельный clock Control или полную миссионную VM.
|
||
|
||
### Добавление и нормализация долей
|
||
|
||
В этой же GOG `Behavior.dll` (SHA-256
|
||
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`)
|
||
`FUN_1001aac0` (RVA `0x1aac0`) добавляет запись размером `0x1c`. Её слово по
|
||
`+0x04` — доля `f32`, а слова `+0x08..+0x18` копируются как сырой хвост.
|
||
Новая запись начинается с доли `1 / count` и нулевого хвоста. При повторном
|
||
ключе запись не вставляется снова: меняется только её доля, остальные пять
|
||
DWORD сохраняются. После каждого вызова выполняется `FUN_1001ac90` (RVA
|
||
`0x1ac90`). Она считает долю целевого ключа и записей с ненулевым первым словом
|
||
хвоста фиксированной. Если сумма остальных долей положительна, остаток от
|
||
единицы распределяется пропорционально; при нулевой или отрицательной сумме он
|
||
делится поровну между изменяемыми записями. Проверенный ветвящийся путь также
|
||
направляет unordered-сумму в пропорциональную ветвь, но стандартный набор этих
|
||
тестов содержит конечные значения.
|
||
|
||
Инструкции Add и normalize выполнялись при x87 control word `0x007f`, то есть
|
||
с 24-битной точностью мантиссы; такой же CW измерен у оригинальной игры на
|
||
входах обоих методов. Поэтому Rust повторяет их в порядке списка отдельными
|
||
операциями `f32`, без раннего перехода в `f64`. Native x86 oracle проверяет
|
||
три последовательных Add, повторный ключ с sentinel-хвостом, фиксированную
|
||
запись, нулевую сумму и пограничный вектор округления. В последнем доли имеют
|
||
биты `[0x3f000000, 0x3f000000, 0x33000000]`. Эти сценарии сверяют локальную
|
||
арифметику на стандартных конечных данных, а не весь игровый запуск.
|
||
|
||
Для проверки использованы три x86 helper-а на синтетической памяти. Caller
|
||
oracle вызывает оригинальные `iron3d.dll` entry points и проверяет преобразование
|
||
TMA-координат в аргументы `CreateSuperAI`. AI oracle проходит оригинальный
|
||
`iron3d.dll!FUN_10039440` → `ai.dll!FUN_10001ae0` → реальный `Init` dispatcher;
|
||
также проверяет оба AutoDemo-набора, повторный/missing/busy callback и связку
|
||
`FUN_10005c70` → Handler(19). Для входных DWORD `(0xffffffff, 10001)` поля стали
|
||
`(2^32, 10001.0)`, а varset получил `(0, 10001, 2)`. Behavior oracle вызывает
|
||
`FUN_1001adb0` с искусственно заданной очередью и проверяет шаг генератора,
|
||
срок, очистку и повторный вызов. Эти проверки исполняют оригинальные DLL-функции на заданных
|
||
структурах, но не являются трассой штатного старта или последующего игрового
|
||
цикла.
|
||
|
||
Проверки можно повторить из корня репозитория в Windows PowerShell:
|
||
|
||
```powershell
|
||
$env:FPARKAN_ORIGINAL_IRON3D_DLL = 'C:\GOG Games\Parkan - Iron Strategy\iron3d.dll'
|
||
$env:FPARKAN_ORIGINAL_AI_DLL = 'C:\GOG Games\Parkan - Iron Strategy\ai.dll'
|
||
$env:FPARKAN_ORIGINAL_BEHAVIOR_DLL = 'C:\GOG Games\Parkan - Iron Strategy\Behavior.dll'
|
||
$env:FPARKAN_ORIGINAL_DEFAULT_SCR = 'C:\GOG Games\Parkan - Iron Strategy\MISSIONS\SCRIPTS\default.scr'
|
||
New-Item -ItemType Directory -Force -Path target\handler19-native-oracles | Out-Null
|
||
& "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" /nologo /platform:x86 /out:target\handler19-native-oracles\AnchorOracle.exe crates\fparkan-runtime\tests\handler19_anchor_native_oracle.cs
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
& "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" /nologo /platform:x86 /out:target\handler19-native-oracles\InitOracle.exe crates\fparkan-runtime\tests\handler19_init_native_oracle.cs
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
& "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" /nologo /platform:x86 /out:target\handler19-native-oracles\BehaviorWarmStartOracle.exe crates\fparkan-runtime\tests\behavior_warm_start_native_oracle.cs
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
& .\target\handler19-native-oracles\AnchorOracle.exe
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
& .\target\handler19-native-oracles\InitOracle.exe
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
& .\target\handler19-native-oracles\BehaviorWarmStartOracle.exe
|
||
exit $LASTEXITCODE
|
||
```
|
||
|
||
Изолированный x86 oracle оригинальных Behavior, ArealMap и Control
|
||
компилируется и запускается так:
|
||
|
||
```powershell
|
||
$env:FPARKAN_ORIGINAL_BEHAVIOR_DLL = 'C:\GOG Games\Parkan - Iron Strategy\Behavior.dll'
|
||
$env:FPARKAN_ORIGINAL_CONTROL_DLL = 'C:\GOG Games\Parkan - Iron Strategy\Control.dll'
|
||
$env:FPARKAN_ORIGINAL_AREALMAP_DLL = 'C:\GOG Games\Parkan - Iron Strategy\ArealMap.dll'
|
||
$env:FPARKAN_ORIGINAL_BEHAVIOR_PROFILE_RES = 'C:\GOG Games\Parkan - Iron Strategy\behpsp.res'
|
||
New-Item -ItemType Directory -Force -Path target\behavior-distributor-native-oracle | Out-Null
|
||
& "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" /nologo /platform:x86 /out:target\behavior-distributor-native-oracle\BehaviorDistributorTickOracle.exe crates\fparkan-runtime\tests\behavior_distributor_tick_native_oracle.cs
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
& .\target\behavior-distributor-native-oracle\BehaviorDistributorTickOracle.exe
|
||
exit $LASTEXITCODE
|
||
```
|
||
|
||
Oracle проверяет SHA-256 оригинального `Behavior.dll`:
|
||
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`. Harness
|
||
явно устанавливает x87 CW `0x007f` для Add (`0x1aac0`), normalize (`0x1ac90`)
|
||
и scheduler/tick (`0x1adb0`/`0x19e80`), проверяя слово перед набором вызовов
|
||
и восстанавливая прежнее значение после каждого набора. Для Add и normalize
|
||
подменяется только bounded вставка синтетического vector; их машинный код не
|
||
патчится. Scheduler и tick тоже исполняются оригинальными: подменяются лишь
|
||
IAT-слоты `GetQueue` и `GetSystemArealMap`, а synthetic vtables задают часы и
|
||
ответы объектов. Проверяются равенство и проход срока, переполнение времени
|
||
и точные `f32`-биты elapsed seconds (`1 → 0x3a83126f`, `1001 → 0x3f8020c5`,
|
||
`16777217 → 0x46831270`, `16777219 → 0x46831271`, `u32::MAX → 0x4a83126f`).
|
||
Tick-векторы проверяют нулевой шаг, обе ветви селекторов скорости, пропуск
|
||
состояния `0xfffe`, отсутствующий объект, повторное чтение `0x1002`, ограниченный
|
||
запас руды, порог `0.01`, ветви дефицита/частичного/избыточного предложения и
|
||
равенство спросу, разные веса и достаточную/недостаточную мощность. Тот же
|
||
harness проверяет в оригинальном Control пропорциональное и последовательное
|
||
распределение provider amount, коэффициент класса `4` с интервалом `2`, а также
|
||
неактивную временную ветвь с входами `0.5`, `1` и `2`. Отдельные векторы
|
||
проверяют активацию класса `4` и бюджетную часть активной ветви класса `10`.
|
||
Вызовы Control используют
|
||
синтетические строки и хостовые callbacks; ArealMap тоже получает синтетические
|
||
объекты. Harness сверяет SHA-256 всех трёх DLL; ArealMap загружается из
|
||
изолированной временной рабочей папки, которую процесс затем удаляет. Эти тесты
|
||
проверяют отдельные native-пути, но не полный Control update и не паритет Rust с
|
||
игрой.
|
||
|
||
Caller oracle проверяет AutoDemo пару `(500.2857, 751.8217)`, четыре режима
|
||
x87 на tie-векторах, границу `10000` и младший DWORD. AI oracle отдельно
|
||
проверяет, что `FUN_10005c70(0xffffffff, 10001)` даёт поля `(2^32, 10001.0)`,
|
||
а обработчик записывает `(0, 10001, 2)`.
|
||
|
||
`GetSuperAI` возвращает элемент `n` из таблицы на 64 указателя по RVA `0x55398`
|
||
в `ai.dll`, если `n <= 63`. В режиме только чтения у запущенной GOG AutoDemo
|
||
одновременно сняты исходные координаты кланов в `iron3d.dll` и поля созданных
|
||
SuperAI в этой таблице. Для первого клана raw TMA-слова `0x43fa2492` и
|
||
`0x443bf497` декодируются как `(500.2857, 751.8217)`, а соответствующие поля
|
||
SuperAI `+0x80`, `+0x84`, `+0x7c` содержали `(500.0, 752.0, 0)`. Для второго
|
||
клана сырые координаты и поля совпали: `(728.0, 449.0, 1)`. Связанный снимок
|
||
исходных и конечных значений исключает усечение, направление вверх и направление
|
||
вниз в реально запущенной игре. Отдельный caller oracle вызывает оригинальный
|
||
`FUN_100392e0` при явно заданных x87 RC-режимах, включая nearest; сравнение
|
||
tie-векторов подтверждает реакцию `FISTP`, но не измеряет control word процесса.
|
||
Профиль живой игры выводится из связанной пары raw X/Y → SuperAI.
|
||
|
||
Нижнеуровневый `VarSet::resolve_handler19` принимает уже преобразованные первые
|
||
два слова и третье исходное слово, выдаёт три типизированные записи и отклоняет
|
||
отсутствующие, выходящие за диапазон или не-`DWORD` цели. Метод
|
||
`resolve_handler19_init` добавляет выбор события и остановку на неподдержанной
|
||
форме инструкции. Всё остальное содержимое `.scr` сохраняется reader-ом, но
|
||
остаётся вне этого исполнения.
|
||
|
||
### Изменение provider-состояния Control
|
||
|
||
Для `Control.dll` (SHA-256
|
||
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) native
|
||
oracle вызывает оригинальные `FUN_10010ba0` (RVA `0x10ba0`), `FUN_10010f30`
|
||
(RVA `0x10f30`) и selector-3 provider getter через vtable `0x3d254`, RVA
|
||
`0xdc40`. Пропорциональный режим проверен на максимумах `[30, 60, 50]`, текущих
|
||
значениях `[10, 20, 0]` и добавке `22`, результат `[14, 28, 10]`; отдельный
|
||
вектор подтверждает последовательное заполнение вперёд и вычитание с конца.
|
||
Проверены ограничение строк, пропуск истощённых записей, их пополнение с флагом
|
||
revive и порядок обратных вызовов. Все распределительные вызовы используют
|
||
четвёртый аргумент `1` в `0x10f30`: фактическое изменение строки прибавляется к
|
||
её полю `+0x10`. Для исходных значений `[2^24, 2]` и запроса `-1` получены
|
||
`[16777214, 1.99999976]`, общий итог `16777216` и накопленные разницы
|
||
`[-2, -2.3841858e-7]`. Проверка также подтверждает, что последовательный режим
|
||
несёт исходный запрос между строками: сумма свободных мест `[16777216, 1]`
|
||
округляется до `16777216`, однако запрос `33554432` заполняет обе строки.
|
||
Oracle задаёт внешнюю публикацию результата обратным вызовом; provider-строки
|
||
не имеют родительских узлов и не проходят отдельный жизненный цикл истощения
|
||
корневого объекта.
|
||
|
||
Агрегаты тоже округляются независимо. Начальная ёмкость `+0x58c = 33554436`
|
||
для максимумов `[33554432, 3]` не меняется при обновлениях одной строки
|
||
`-1, -1, -1, +3`, но текущий итог `+0x590` округляется до `33554440`.
|
||
Следующая добавка `1` с revive использует signed capacity `-4`, выдаёт ratio `1`
|
||
и сохраняет уже полные строки. Native oracle и Rust повторяют эту достижимую
|
||
последовательность; отрицательный результат разности сам по себе не является
|
||
ошибкой состояния.
|
||
|
||
Для `FUN_10012a40` (RVA `0x12a40`) нативный вектор задаёт класс `4`, интервал
|
||
`2`, коэффициент `+0x1a4` равный `1` и текущее значение `10.000001`; результат
|
||
равен `8.000001`, вспомогательное поле — `-2`, нормализованное поле имеет биты
|
||
`0x3da3d70c`. Происхождение коэффициента подтверждено только статическим
|
||
трассированием: `FUN_1001a450` (RVA `0x1a450`) запрашивает интерфейс IID `0x0d`,
|
||
вызывает его slot `+0x24`, читает DWORD по смещению `+0x0c` в полученной записи
|
||
и сохраняет его в поле объекта `+0x1a4`. Поэтому нативный вектор проверяет
|
||
арифметику `FUN_10012a40` с заданным коэффициентом, а не исполнение контактного
|
||
пути или его игровой источник.
|
||
|
||
`FUN_1000bcf0` (RVA `0xbcf0`) получает абсолютное значение времени. Oracle
|
||
исполняет только безопасную начальную ветвь при `+0xe0 = 0`: вход `0.5`
|
||
обновляет `+0xe8`, а входы `1` и `2` синхронизируют `+0xe4`, `+0xec`, `+0xd8` и
|
||
`+0xdc` без callbacks. Активную ветвь этот тест не запускал. Статический код
|
||
показывает gate по `+0xe0`; при условии `randomized_deadline < +0xe4` или
|
||
`+0xd4 != 0` вычисляет `dt = (+0xe4 - +0xec) * 0.001`. x87 выполняет `FSUB`,
|
||
затем `FMUL` с binary32-константой `0.001` (`0x3a83126f`), после чего результат
|
||
сохраняется в `f32`. Код вызывает `FUN_10012a40`, затем `FUN_1002d340` и
|
||
присваивает `+0xec = +0xe4`. В соседней ветви update packet queue обрабатывается
|
||
через `FUN_10012ce0`. Бюджетная часть активной ветви проверена на ограниченном
|
||
хосте класса `10`, как описано ниже. Источник входного времени и порядок его
|
||
доставки до обработчика установлены статически;
|
||
Rust ещё не вызывает этот бюджетный цикл; отдельно перенесён прямой путь
|
||
изменения одной строки `0x10f30` и явный шаг распределения `0x10ba0`, описанные
|
||
ниже. Ни один из них пока не подключён к игровому Control clock.
|
||
|
||
#### Прямое изменение одной строки в Rust
|
||
|
||
`apply_loaded_mission_control_node_delta(engine, handle, key, delta)` моделирует
|
||
`0x10f30` с нулевым четвёртым аргументом: накопление анимационного изменения
|
||
`+0x10` не выполняется, а текущее значение поля сохраняется; detached-ветвь
|
||
объекта тоже не моделируется. Максимум читается из исходной NDP строки при
|
||
подтверждённых единичных множителях Unit DAT. Сначала складываются
|
||
`f32` amount и delta, результат ограничивается `0..maximum`; при изменении
|
||
native `+0x590` получает `max(0, total + (new - old))`, `+0x14` — долю
|
||
`new / maximum`, а `+0x598` становится dirty. Флаг `+0x28 & 0x10` отмечает
|
||
истощение и снимается при пополнении. Количество `+0x0c` этим методом не
|
||
обновляется: native записывает его позже в `0x11220`. Конструктор владельца
|
||
`0x6bf0` начинает с `+0x598 = 1`, а `0xf940` оставляет этот dirty-флаг:
|
||
первый агрегатный проход нужен даже до новой добавки. Rust loader сохраняет
|
||
это начальное значение.
|
||
|
||
Rust сохраняет начальные и текущие поля отдельно. Возвращаемая
|
||
`ControlNodeProviderPublication` содержит ключ и значение уже применённого
|
||
callback selector `0x200` в CPU-проекции узла. У `AniMesh.dll` с SHA-256
|
||
`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`
|
||
primary vtable RVA `0x2057c`, slot `+0x34`, указывает на `0x5750`.
|
||
Этот stdcall-обработчик принимает четыре DWORD: owner, ordinal, selector,
|
||
raw float value. Только selector `0x200` записывает float в
|
||
`owner[+0x1a8] + ordinal * 0x130 + 0x124`. Видимая стадия `+0x1c` при этом не
|
||
меняется. Конструктор строки RVA `0x123e0` задаёт начальные `+0x124 = 1.0`
|
||
и `+0x1c = 0`.
|
||
|
||
Native live-provider oracle использует оригинальный конструктор этих строк и
|
||
передаёт callback `Control+0x10f30` в исходный `AniMesh+0x5750` через
|
||
прозрачную записывающую трассу обёртку. Проверены значения `0.6` и `0.1`,
|
||
отсутствие записи у соседнего узла и прежняя стадия `77` у целевого узла;
|
||
selector `0x201` ничего не меняет. При signed-zero no-publication случае
|
||
provider остаётся с заданным тестом значением `0.25`, а модель сохраняет
|
||
последнее опубликованное `0.1`. Ещё один native вектор показывает, что
|
||
стадийное наследование доли родителя тоже не переписывает `+0x124` модели.
|
||
Поэтому Rust хранит `mesh_state.type1_value_bits` отдельно от
|
||
`current_type1_value_bits` provider, а `mesh_state.visible_stage` — отдельно от
|
||
текущего Control stage/sentinel. Неизвестный узел даёт ошибку без частичной
|
||
записи; callback отражён в CPU-проекции, но GPU/window path ещё не подключён.
|
||
Delta API не вызывает стадийный проход. Отдельный single-node API для
|
||
`0x11220` и отдельный API обхода потомков `0x11130` описаны в разделе о
|
||
стадиях; видимость и FX callbacks остаются вне этого исполнения.
|
||
Истощение корня или критического узла возвращает `UnsupportedNodeLifecycle`
|
||
до записи: состояние владельца `+0x550`, отложенная гибель и сопутствующие
|
||
callbacks ещё не реализованы. Отсутствие MSH flags, provider aggregate,
|
||
неподдержанный численный диапазон или неверный handle тоже дают явную ошибку
|
||
без частичных изменений.
|
||
|
||
Rust-тест повторяет native последовательность количества
|
||
`40 → 60 → 50 → 100 → 0 → 10` при максимуме `100`, проверяет публикации,
|
||
неизменное обработанное количество и новые результаты `0xe00`/`0x1002`.
|
||
Native oracle отдельно подтверждает сохранение `+0x0c = 40` и снятие флага
|
||
истощения при последнем пополнении. Для amount `-0.0` с отрицательной добавкой
|
||
native записывает биты `+0.0`, но сравнение количеств считает их равными:
|
||
доля, флаги, итог и callback остаются прежними. Этот случай сохранён и в Rust.
|
||
|
||
#### Распределение provider-таблицы в Rust
|
||
|
||
`distribute_loaded_mission_control_provider_amount` переносит общий шаг
|
||
`Control.dll+0x10ba0`. Режим proportional распределяет добавку по свободной
|
||
ёмкости, а снятие — по текущему количеству строк; sequential заполняет их с
|
||
первой и снимает ресурс с последней. Для sequential native сохраняет исходную
|
||
величину запроса, даже когда сумма свободных мест округляется вниз.
|
||
`revive_exhausted` включает строки с флагом истощения `+0x28 & 0x10` при добавке.
|
||
Rust хранит максимум общей таблицы `+0x58c` отдельно от текущей суммы `+0x590`;
|
||
для загруженных Unit DAT эти значения рассчитаны при подтверждённых единичных
|
||
масштабах. Их разность может стать отрицательной из-за независимого округления
|
||
изменений текущего агрегата; распределитель сохраняет такой signed capacity по
|
||
формуле оригинала. Обновления масштабов `0x9ee0` и `0x9f90` в Rust не реализованы.
|
||
|
||
Native row updater вызывается здесь с четвёртым аргументом `1`, поэтому
|
||
изменение `(new amount - old amount)` накапливается в provider `+0x10`.
|
||
`apply_loaded_mission_control_node_delta` сохраняет прежний четвёртый аргумент
|
||
`0` и оставляет этот накопитель нетронутым. После всех строк `0x10ba0`
|
||
обязательно запускает `0x11130(root, update_effects=true)`. Rust переносит этот
|
||
проход на копии таблицы: если ему нужны неизвестное состояние, depletion
|
||
lifecycle, FX или host callback, весь шаг завершается ошибкой без частичных
|
||
amount, accumulator, aggregate или stage изменений.
|
||
|
||
Поле `+0x618` изначально выключено; обработчик события `7` задаёт gate по
|
||
правилу `gate = (arg == 2)` и вызывает пересчёт `0x2dc20`, только если gate
|
||
изменился. Проекция сохраняет начальное состояние и уважает установленную
|
||
suspension-метку. События Control пока не исполняются, а ресурсный budget API
|
||
вызывается явно и пока не подключён к игровому clock.
|
||
Тесты Rust повторяют пропорциональный вектор, накопитель `+0x10`, округление
|
||
`2^24`, sequential capacity boundary, достижимый negative capacity и откат при
|
||
unsupported FX/lifecycle.
|
||
|
||
#### Бюджет и обновление устройства tag 15
|
||
|
||
Для той же сборки `Control.dll` oracle исполняет оригинальный диспетчер
|
||
RVA `0x2d340`, распределение по категориям RVA `0x2dca0`, конструктор tag 15
|
||
RVA `0x22ae0`, его rate RVA `0x22b20` и update RVA `0x22bb0`.
|
||
Категория читается из текущего descriptor через RVA `0x2c3a0`.
|
||
Порядок масок категорий — `8`, `1`, `0x24`, `0x10`, `2`.
|
||
Поэтому порядок обслуживания может отличаться от порядка устройств в массиве.
|
||
Положительные вклады образуют начальный бюджет; потребность выбранных
|
||
категорий определяет коэффициент выделения.
|
||
|
||
Вектор задаёт provider с максимумом `100`, количеством `40` и долей `0.4`,
|
||
интервал `0.5` секунды, коэффициенты tag 15 равные `1` и нулевое поле
|
||
descriptor `+0x20`. Исходная rate-функция возвращает `-0.2`.
|
||
Генератор с descriptor tag `14` подставлен явно: mock возвращает положительный
|
||
вклад `0.1` или `0.5` и записывает аргументы своего update. Функции tag 15,
|
||
provider getter и изменение количества не подменены.
|
||
|
||
| Бюджет | Коэффициент tag 15 | Amount / processed / total, биты | Доля, биты | `+0x5dc` / `+0x5e0`, биты |
|
||
|---|---|---|---|---|
|
||
| `0.1` | `0.5` | `0x42206666` (`40.1`) | `0x3ecd4fdf` | `0x3e4ccccd` / `0x3e4ccccd` (`0.2`) |
|
||
| `0.5` | `1` | `0x4220cccd` (`40.2`) | `0x3ecdd2f2` | `0x3ecccccd` / `0x3ecccccd` (`0.4`) |
|
||
|
||
Сравнение идёт по битам при x87 control word `0x007f`. Реальный update
|
||
вызывает RVA `0x10ba0` и публикует новую долю одним callback selector `0x200`.
|
||
Хотя генератор стоит первым в массиве, его update уже видит изменённое
|
||
количество: категория tag 15 обслуживается раньше. Проверены также интервал
|
||
и коэффициент, переданные mock-генератору. Его собственная генерация, другие
|
||
категории и выполнение миссии этим не проверены. Исходные выходные ставки
|
||
`+0x5dc` и `+0x5e0` также записываются на каждом вызове; при пустой таблице
|
||
оба поля получают битовый отрицательный ноль.
|
||
|
||
Rust предоставляет явный CPU-вызов
|
||
`advance_loaded_mission_control_budget(engine, handle, dt)`. Он переносит
|
||
исходные callbacks factory tags `9`, `15`, `19` и общий default callback, а
|
||
категории выбирает по текущему descriptor tag. Native map подтверждён только
|
||
для префикса тегов `0..=34`; неизвестная категория, специализированный
|
||
callback, provider или численный диапазон возвращает ошибку без публикации
|
||
частичных изменений. Ставки `+0x5dc/+0x5e0` сохраняются отдельно, а сам вызов
|
||
ещё не включён в Control clock.
|
||
|
||
Нативный oracle проверяет пустую таблицу с отрицательным нулём в обеих выходных
|
||
ставках, невырожденную пару исходных устройств tag 19/tag 15 при `dt = 0.375`
|
||
и tag 9 с шестью значениями и шестью накопителями изменений. Rust-тесты
|
||
повторяют их по битам, включая положительное и отрицательное обновления tag 9.
|
||
В лицензированном `min_l_n1.dat` предварительная проверка распознаёт категории,
|
||
но находит неподдержанный factory tag `26` и отклоняет бюджет до расчёта ставок
|
||
или вызова устройств; транзакционный тест подтверждает, что исходная таблица
|
||
не меняется. Полный бюджетный проход на реальной миссии пока не выполнен. Rust также отклоняет нулевой знаменатель
|
||
обновления tag 19; тест вызывает эту ошибку после изменения provider
|
||
потребителем и подтверждает откат всей таблицы.
|
||
|
||
#### Генератор tag 19 в том же бюджетном проходе
|
||
|
||
Отдельный вектор заменяет mock-генератор исходным устройством tag `19`:
|
||
конструктор `Control.dll` RVA `0x22980`, rate `0x229a0`, update `0x22a60`,
|
||
vtable `0x3c608`. Конструктор выставляет слово включения `0x20`; descriptor
|
||
с `+0x18 = -1` сохраняет его. Оба устройства проходят через исходные
|
||
`0x2d340` и `0x2dca0`, их provider и изменение количества тоже исходные.
|
||
|
||
При положительных параметрах проверенного вектора выработка зависит от поля
|
||
descriptor `+0x20`, текущего множителя `+0x4c`, доли provider и `dt`. Если
|
||
коэффициент selector `0` положителен, rate ограничивается значением selector
|
||
`0x200`, умноженным на долю provider. Поэтому один и тот же источник может
|
||
вернуть полную выработку или её ограниченный запас.
|
||
|
||
| Коэффициент | Поле `+0x20` | Исходная rate | Amount после прохода | Доля provider | Множитель генератора после прохода |
|
||
|---|---|---|---|---|---|
|
||
| `1` | `0.5` | `0.1` | `0x42206666` | `0x3ecd4fdf` | `0x3f400000` (`0.75`) |
|
||
| `0.125` | `0.5` | `0.05` | `0x42203333` | `0x3ecd0e56` | `0` |
|
||
|
||
В обоих случаях исходные amount, максимум, доля и интервал равны `40`, `100`,
|
||
`0.4` и `0.5`, а исходный множитель генератора — `1`. Его update читает
|
||
provider уже после потребителя tag `15`, повторно вычисляет rate и уменьшает
|
||
собственный множитель. Это не запись коэффициента выделения в `+0x4c`, как у
|
||
tag `15`: смысл изменения этого поля зависит от фабричного класса.
|
||
Векторы проверяют конечные положительные значения при x87 CW `0x007f`;
|
||
нативный oracle дополнительно фиксирует невырожденный случай, в котором
|
||
коэффициенты различаются и поле `+0x20` ненулевое. Нулевой знаменатель,
|
||
отрицательные параметры и другие классы устройств остаются непокрытыми.
|
||
В этом дополнительном native-векторе `dt = 0.375`, provider `40/100`, tag 19
|
||
имеет `B = 1`, `C0 = 0.25`, а tag 15 — `B = 0.125`, `C0 = 0.75`, `C1 = 1.25`
|
||
и начальный множитель `0.8`. Получены rate генератора `0x3dcccccd`, rate
|
||
потребителя `0xbe400000`, amount `0x42202b85`, ratio `0x3ecd0481`, накопитель
|
||
`+0x10 = 0x3d2e1400`, множитель tag 15 `0x3f088889`, множитель tag 19 `0`, а
|
||
обе выходные ставки равны `0x3e88888a`. Это проверяет раздельное чтение
|
||
коэффициента `C1` и множителя `+0x4c`, а также ненулевую базовую стоимость.
|
||
Поддержанные Rust-векторы не означают, что любой Control descriptor или
|
||
полный миссионный цикл уже работает.
|
||
|
||
#### Класс 9: ставка и шесть изменяемых значений
|
||
|
||
У `Control.dll` SHA-256
|
||
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`
|
||
класс `9` использует constructor RVA `0x25460`, vtable `0x3c938`, rate
|
||
`0x25700`, update `0x257b0` и adjustment `0x25a90`. Constructor начинает шесть
|
||
значений в `+0x98..+0xac` с `1.0`, а шесть накопителей в `+0xb0..+0xc4` — с
|
||
нуля. Текущий `INTO` заменяет descriptor и общие коэффициенты, сохраняя эти
|
||
runtime-массивы.
|
||
|
||
Для rate `q` — текущее значение provider, `B` — descriptor `+0x20`, `C0..C2` —
|
||
коэффициенты, а `scale` — class-9 поле `+0x11c`. Сначала отдельно округляются
|
||
`G0 = C0 * scale`, сумма шести значений, и свободная величина `6 - sum`; затем
|
||
вычисляются доступная часть `(6 - sum) * G0` и запрос `(C1 * q) * dt`. Rate
|
||
равна `-(dt * B) - C2 * min(доступная часть, запрос)`. В update множитель
|
||
`+0x4c` сначала получает allocation, затем вычисляется
|
||
`S = -(rate(dt) * allocation) - (dt * B)`. При `C2 > 0` изменение равно
|
||
`S / C2`; иначе его знак выбирает `±((C1 * q) * dt)`. Если `+0x618` не
|
||
приостанавливает обновление, а `G0 > 0`, изменение делится на `G0 * 6` и
|
||
передаётся `0x25a90`.
|
||
|
||
Adjustment умножает упорядоченную сумму шести значений на единственную
|
||
binary32-константу `0x3e2aaaab` — приблизительное `1/6`. Дополнительного деления
|
||
на шесть нет. При положительном изменении native ограничивает фактор сверху
|
||
единицей и приближает каждое значение к `1`; при отрицательном — уменьшает
|
||
каждое пропорционально ему. Каждый фактический шаг также прибавляется к своему
|
||
накопителю. Oracle запускает исходные entry points на оригинальном Control.dll
|
||
при x87 control word `0x007f`:
|
||
|
||
| Вектор | Вход | Rate / multiplier | Шесть выходных значений | Шесть выходных дельт |
|
||
|---|---|---|---|---|
|
||
| Уменьшение | `q=.4, dt=.5, B=.2, C0=10, scale=1, C1=.6, C2=2`, allocation `.5`; все значения `1`, дельты `0` | `0xbdcccccd`, `0x3f000000` | `0x3f7fe4b1` во всех строках | `0xb9da740e` во всех строках |
|
||
| Смешанный набор | `q=.4, dt=.5, B=.2, C0=.75, scale=1.25, C1=.6, C2=2`, allocation `1`; значения `[0,.25,.5,.75,1,.9]`, дельты `[.1,-.2,0,.3,-.4,.5]` | `0xbeae147b`, `0x3f800000` | `[3d49a637,3e92e795,3f064d32,3f432699,3f800000,3f67a90a]` | `[3e18cff4,be26fda3,3cc9a637,3e9fe6cc,becccccd,3f0142a4]` |
|
||
|
||
Отдельный прямой вызов `0x25a90` для значения `[0,1,1,1,1,1]`, нулевых
|
||
накопителей и добавки `.1` записывает `0x3f19999c` в первую пару value/delta;
|
||
остальные пять строк остаются неизменными. Этот vector фиксирует умножение на
|
||
сохранённую константу, а не вычисление `sum / 6`. Rust хранит оба массива
|
||
отдельно и сохраняет их при descriptor override; ошибки численного диапазона
|
||
не публикуют частичный результат.
|
||
|
||
#### Общие default budget callbacks и категории
|
||
|
||
Ветка фабрики Control RVA `0x2d4b0` направляет обычные классы в constructor
|
||
`0x20800`; он устанавливает vtable `0x3c448` и word `+0x50 = 5`. Rate и update
|
||
этого семейства — исходные `0x21860` и `0x21890`: первая возвращает
|
||
`-(dt * descriptor[+0x20])`, второй записывает allocation в `+0x4c` после
|
||
общей проверки активности provider. Фабричные теги `12`, `13` и `25` идут по
|
||
ветке default; tag `21` явно выбирает ту же пару. У tags `4`, `8` и `27` свои
|
||
vtable/constructor, но их rate/update entries указывают на ту же пару. Native
|
||
oracle проверяет factory routing тегов `12/13`, vtable pointers и прямой
|
||
вызов rate/update общей пары. Rust повторяет формулу и сохраняет исходные
|
||
word-50 defaults, в частности `0x200` у тега `4` и ноль у тега `27`.
|
||
|
||
Descriptor category lookup `0x2c3a0` возвращает следующие значения для
|
||
проверенного префикса `0..=34`:
|
||
|
||
```text
|
||
[0,4,4,0,2,3,0,0,2,5,5,0,0,0,1,0,3,2,0,1,3,5,4,0,4,0,3,5,0,0,4,0,1,2,4]
|
||
```
|
||
|
||
Именно текущий descriptor выбирает категорию; callback остаётся у исходного
|
||
factory-класса. В Rust неизвестный текущий тег за концом префикса и
|
||
специализированный factory callback не подменяются общей формулой. На
|
||
лицензированном Unit DAT полный проход доходит до factory `26` и возвращает
|
||
ошибку без частичного изменения таблицы. Ни этот CPU-вызов, ни локальные
|
||
векторы не означают включения бюджетного шага в Control clock.
|
||
|
||
#### Активация и начальные часы
|
||
|
||
Для Control класса `4` oracle вызывает исходный конструктор RVA `0x31490`
|
||
и обработчик интерфейса RVA `0x31ae0` → `0x314f0` с событием `4` и ненулевым
|
||
DWORD времени. Он выставляет `+0xe0` по условию `class != 0`, копирует матрицу
|
||
хоста в три transform-поля и численно преобразует беззнаковое время в `f32`.
|
||
Поля `+0xe4`, `+0xe8` и `+0xec` получают `time - 100`, а командные часы
|
||
`+0xd8` и `+0xdc` — `time`. Это разные базовые значения:
|
||
|
||
| Вход DWORD | Абсолютное `f32` | `+0xe4`, `+0xe8`, `+0xec` |
|
||
|---|---|---|
|
||
| `1000` | `1000` | `900` |
|
||
| `33554433` | `33554432` | `33554332` |
|
||
|
||
Первый вызов при старом `+0xe4 == 0` добавляет начальную команду в очередь;
|
||
повторный вызов в этом векторе сохраняет одну запись. Хост явно подставляет
|
||
identity transform, единичный масштаб и нулевую геометрию. Oracle проверяет
|
||
аргументы и порядок этих callbacks, а функции Control и изменения его часов
|
||
остаются исходными. Таблицы устройств, shapes и дорожек пусты: их активация,
|
||
полный object loader и ветвь события `4` с нулевым payload здесь не исполнены.
|
||
Нулевой payload статически выбирает отдельную фазу связывания интерфейсов,
|
||
поэтому его нельзя считать обычным ненулевым временем активации.
|
||
|
||
Период ресурсного обновления — поле `+8` глобального объекта настроек
|
||
RVA `0x42478`. Инициализатор RVA `0x6350` и reset-метод RVA `0x6420` задают
|
||
ему `250` миллисекунд (`0x437a0000`); oracle проверяет значение после загрузки
|
||
DLL. Экспорт `InitializeSettings` (RVA `0x32260`) регистрирует объект с ключом
|
||
`0x15` в `World3D::CreateGameSettings`. Это default, а не доказательство
|
||
неизменности настройки во всех игровых режимах.
|
||
|
||
#### Активный ресурсный clock
|
||
|
||
Тот же native budget-вектор проходит через `Control.dll` RVA `0xbcf0` при
|
||
явно заданных классе `10` и признаке активности `+0xe0 = 1`. Хост подставляет
|
||
два getter-а: наличие игрового хоста и отсутствие объекта движения. Класс `10`
|
||
пропускает цикл команд и движения; очереди пакетов и detached-объектов пусты.
|
||
Поле dirty перед первым ресурсным циклом равно нулю, поэтому `0x12a40`
|
||
возвращается без пересчёта геометрии. Это исполнение бюджетной части активной
|
||
ветви, а не полная загрузка или физика объекта.
|
||
|
||
Период по RVA `0x42480` явно равен `500`, состояние RNG по RVA `0x43228` —
|
||
нулю. Эти данные принадлежат изолированному процессу oracle и восстанавливаются
|
||
после вектора. Нулевое состояние остаётся нулевым и даёт deadline
|
||
`500 - 500 / 8 = 437.5` миллисекунды при исходном `+0xec = 0`.
|
||
|
||
| Абсолютное время | Результат |
|
||
|---|---|
|
||
| `0.5` | меньше миллисекунды: `+0xe4` ещё равен `0` |
|
||
| `436` | время принято, ресурсный deadline ещё не пройден |
|
||
| `437.5` | равенство deadline: ресурсный цикл пропущен |
|
||
| `500` | `dt = (500 - 0) * 0.001 = 0.5`, бюджет исполнен, `+0xec = 500` |
|
||
|
||
Последний межкадровый интервал `+0xe8` равен `62.5`, но ресурсный интервал
|
||
берётся от отдельного `+0xec`. Исходные `0x2d340` и tag 15 изменяют amount
|
||
до `0x42206666`, долю до `0x3ecd4fdf`; mock-генератор получает `dt = 0.5`
|
||
ровно один раз. После вызова dirty равен `1`, а агрегат `+0x588` ещё не
|
||
пересчитан: бюджетное изменение происходит после предшествующего `0x12a40`.
|
||
Событие активации проверено отдельно выше.
|
||
|
||
Условие срока не использует сохранённый deadline. Машинные инструкции
|
||
`0xc74a..0xc7c1` на каждом дошедшем до этой точки вызове изменяют RNG
|
||
`Control.dll+0x43228` и заново вычисляют порог. Два 16-битных слова состояния
|
||
обновляются по правилу `low' = u16(low << 1) XOR high`,
|
||
`high' = (high >> 1) XOR low'`. При периоде `P` четверть периода равна
|
||
`q = P * 0.25`, коэффициент случайного вклада — `q / 65536`, а порог имеет вид
|
||
`(high' * (q / 65536) - q * 0.5) + P + last_resource_time`.
|
||
Порядок этих x87 операций существенен; отдельное сохранение коэффициента в
|
||
`f32` видно по RVA `0xc78e`. Это RNG данного модуля, его нельзя подменять
|
||
LCG планировщика Behavior.
|
||
|
||
При `P = 500`, исходных `+0xe4 = +0xec = 0` и seed `0x12345678` проверены
|
||
следующие последовательные вызовы. Таблица устройств здесь пуста: вектор
|
||
изолирует cadence после отдельной проверки настоящего бюджетного изменения.
|
||
|
||
| Время | RNG после проверки срока | Последний ресурсный clock `+0xec` |
|
||
| --- | --- | --- |
|
||
| `0.5` | `0x12345678`, без изменения | `0` |
|
||
| `500` | `0xb7debec4` | `0` |
|
||
| `501` | `0x91b9ca56` | `0` |
|
||
| `502` | `0x4dc90515` | `502` |
|
||
| `503` | `0x610747e3` | `502` |
|
||
|
||
Оба первых принятых вызова пропускают ресурсный шаг, но всё равно расходуют
|
||
RNG. На третьем порог меняется и шаг выполняется раньше прежних порогов.
|
||
Поэтому предварительно выбрать один случайный deadline до следующего
|
||
ресурсного шага означало бы другую модель времени.
|
||
|
||
Отдельный seed `0xffff0000` после одного перехода даёт `0x8000ffff` и порог
|
||
ровно `500`: равенство снова пропускается. Вызов `501` получает
|
||
`0x3ffe7ffe` и обновляет ресурсный clock. Затем `+0xd4 = 2` принуждает вызов
|
||
`502` выполнить ещё один цикл, несмотря на новый порог; RNG становится
|
||
`0xdffdc002`, а счётчик уменьшается до `1`. Если после этого снять `+0xe0`,
|
||
вызов `503` синхронизирует `+0xec`, но сохраняет RNG и принудительный счётчик.
|
||
Движение, непустые очереди и исполнение всего этого пути в миссии остаются
|
||
отдельными границами.
|
||
|
||
#### Доставка игровых часов в Control
|
||
|
||
Статическая цепочка относится к GOG-модулям `World3D.dll` (SHA-256
|
||
`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`),
|
||
`AniMesh.dll` (`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`)
|
||
и `Control.dll` (`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`).
|
||
`World3D!FUN_10006bf0` (RVA `0x6bf0`) получает `timeGetTime()`, прибавляет
|
||
игровое смещение и вычитает смещение паузы. Младший DWORD результата сохраняется
|
||
по RVA `0x32a38`. Затем первый обход объектов вызывает их vslot `+0x34` с
|
||
аргументами `(self, 6, 1, clock)`. После него вызывается world physics vslot
|
||
`+0x0c(clock)`, а второй обход рассылает `(self, 6, 0x1c, clock)`. Во всех трёх
|
||
этапах передаётся одно сохранённое значение, а не заново измеренное время.
|
||
|
||
В `Comp.ini` CID `3` и `4` назначены `AniMesh.dll!LoadAgent` (RVA `0x16a00`).
|
||
Его конструктор RVA `0x1000` устанавливает primary vtable RVA `0x201d4`, где
|
||
слот `+0x34` указывает на обработчик RVA `0x1370`. Для события `6/1` этот
|
||
обработчик вызывает ненулевые интерфейсы в следующем порядке:
|
||
|
||
| Поле AniMesh | Получатель | Подсобытие |
|
||
|---|---|---|
|
||
| `+0x17c` | Behavior | `1` |
|
||
| `+0x178` | Wizard | `1` |
|
||
| `+0x170` | CollObject | `1` |
|
||
| `+0x158` | Control | `1` |
|
||
| `+0x15c` | PhysicalModel | `1` |
|
||
| `+0x170` | CollObject повторно | `0x1c` |
|
||
|
||
Вызовы идут через slot `+0x08`: `ECX` содержит указатель интерфейса, `EDX` —
|
||
подсобытие, а исходный DWORD часов передаётся на стеке. Связи Control, Behavior,
|
||
Wizard и CollObject с этими полями видны в загрузчике AniMesh RVA `0x2ff0`.
|
||
|
||
Фабрика Control RVA `0x32280` выбирает конструктор RVA `0x7330` для класса `9`
|
||
и RVA `0x31490` для остальных классов. В первом случае интерфейс объекта `+4`
|
||
имеет vtable RVA `0x3b888`: его slot `+0x08` ведёт в переходник RVA `0x7fe0`,
|
||
который вычитает `4` из `ECX` и передаёт вызов RVA `0x7830`. Во втором случае
|
||
vtable RVA `0x3d254` ведёт через переходник RVA `0x31ae0` в RVA `0x314f0`;
|
||
для подсобытия `1` этот обработчик также вызывает RVA `0x7830`.
|
||
Общий обработчик дополняет входной DWORD нулевым старшим словом до qword,
|
||
выполняет `FILD`, затем `FSTP f32` и вызывает primary slot `+0x58`.
|
||
Это числовое преобразование беззнаковых миллисекунд в `f32`, а не чтение битов
|
||
DWORD как float и не передача длительности кадра. Для класса `9` primary
|
||
vtable RVA `0x3b8cc` направляет этот слот в RVA `0xcbb0`; у остальных классов
|
||
vtable RVA `0x3d298` направляет его в RVA `0xbcf0`.
|
||
|
||
Эта цепочка доказана инструкциями и таблицами исходных модулей. Она не является
|
||
проверкой полного исполнения активной ветви `0xbcf0` или изменения
|
||
provider-состояния в реальной миссии. Ограниченный активный вектор описан выше.
|
||
|
||
#### Родители, стадии и видимость provider-узлов
|
||
|
||
`Control.dll` с SHA-256
|
||
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` проверен
|
||
прямыми вызовами исходных `FUN_10011220` (RVA `0x11220`) и
|
||
`FUN_10011130` (RVA `0x11130`). При расчёте стадии `FUN_10011220` может взять
|
||
долю родителя по флагу `0x04` или стадию родителя по `0x08`. Для стадии с
|
||
числом вариантов `count` при проверенных долях `0..1` расчёт имеет вид
|
||
`stage = min(count, count - ceil(f32(count * ratio)))`. Нативное ограничение
|
||
применяется к результату вычитания как к беззнаковому 32-битному значению;
|
||
здесь проверены конечные доли `0..1`. Для отдельного ряда из трёх стадий коэффициенты
|
||
`[0.1, 0.4, 0.5, 1, 0]` дали внутренние значения `[2, 1, 1, 0, 3]`; последнее
|
||
значение равно служебному sentinel `count` и означает истощение. В callback
|
||
`AniMesh.dll!FUN_10005810` передаётся только видимое значение
|
||
`min(stage, count - 1)`.
|
||
|
||
Rust API `update_loaded_mission_control_node_stage(engine, handle, key,
|
||
update_effects)` переносит отдельный вызов `0x11220` в пределах подтверждённой
|
||
арифметики и CPU-модели. Он читает текущие flags, count и parent, копирует долю
|
||
родителя по `4` и его Control stage по `8`, записывает изменённую видимую стадию
|
||
по правилу исходного `AniMesh+0x5810`, ставит flag `0x200` при переходе и
|
||
сохраняет текущее количество в `processed_amount_bits`. Начальные снимки не
|
||
меняются. Возвращаемый bool означает только смену Control stage: при неизменной
|
||
стадии обработанное количество всё равно обновляется. Модельный `+0x124` при
|
||
наследовании доли не переписывается.
|
||
|
||
Flags `0x1c0` вместе с `0x10` и `0xc0` дают нативный ранний выход до чтения
|
||
mesh metadata и обновления `+0x0c`. Когда эти же flags требуют lifecycle
|
||
callback, API возвращает `UnsupportedNodeLifecycle`. Переход при
|
||
`update_effects = true` требует не только создания FX, но и host notifications
|
||
через `0x11af0`; пока он возвращает `UnsupportedNodeEffects`. Если стадия не
|
||
меняется, нативный код пропускает эту ветвь и такой вызов поддержан.
|
||
Переход через sentinel при снятом flag `2` требует `0x11920` и возвращает
|
||
`UnsupportedNodeVisibility`. Неверные parent/count, неизвестное состояние
|
||
модели и неподдержанные доли также отклоняются. Все эти ошибки сохраняют
|
||
прежнее состояние, включая ещё не скопированную долю родителя.
|
||
|
||
Здесь поддержаны конечные нормальные доли `0..1` и ноль. У инструкции `FILD`
|
||
целый count остаётся точным до `FMUL`; предварительный cast count в `f32`
|
||
может изменить ответ. Native вектор `count = 16777217`, `ratio = 0.75` получает
|
||
Control/visible stage `4194304`; ранний cast давал бы `4194305`. Rust умножает
|
||
целый count на 24-битную мантиссу доли в `u64`, округляет произведение один раз
|
||
и точно масштабирует его степенью двойки. Это проверка арифметики с
|
||
искусственным большим count, а не число стадий в игровых ресурсах.
|
||
|
||
Native oracle и Rust-тест также проверяют двухстадийный случай `ratio = 0.5`
|
||
со стадией `1`, обновление только обработанного количества при неизменной
|
||
стадии и отсутствие такого обновления при защищённом раннем выходе. Licensed
|
||
тест повторяет отдельный переход `0 → 1` на двухстадийном узле настоящего
|
||
`min_l_n1.dat`, с `update_effects = false`. Полный игровой вызов не подменяется
|
||
этим режимом: FX, lifecycle и видимость всё ещё требуют реализации; обход
|
||
потомков доступен отдельным CPU API и пока не подключён к игровому циклу.
|
||
|
||
`FUN_10011130` сохраняет старую стадию, вызывает `0x11220`, затем сохраняет
|
||
обновлённые flags родителя для всего прохода. В исследованной сборке
|
||
`0x11130` вызывает `0x11220` по RVA `0x11156`; caller-ы `0x11130` находятся в
|
||
`0x10ba0`, `0x12a40`, `0x12ce0`, `0x13180` и в самой рекурсивной функции.
|
||
`0x10f30` вызывает `0x11220` напрямую по RVA `0x1110f`.
|
||
|
||
Затем `0x11130` сканирует provider-строки с большим индексом и выбирает строки,
|
||
у которых parent равен текущему ключу. Обход идёт в глубину: сначала ребёнок и
|
||
всё его поддерево, затем следующий ребёнок. Для parents `[-1, 0, 0, 1, 3]`
|
||
порядок callbacks равен `1, 3, 4, 2`. Если стадия родителя выросла или у него
|
||
стоит флаг истощения `0x10`, Control снимает количество с ребёнка. При флагах
|
||
родителя `0xc0` дочернему ряду ставится `0x80`; в обычной ветви ставится `0x100`
|
||
при ухудшении и очищается `0x80`. Дочерний mask `0x140` запрещает только
|
||
перезапись его flags: количество всё равно снимается, и обход продолжается.
|
||
Стадии детей пересчитываются, даже если родитель не ухудшился.
|
||
|
||
Rust API `propagate_loaded_mission_control_node_stage` выполняет этот обход
|
||
отдельно от single-node `update_loaded_mission_control_node_stage`. Он работает
|
||
с копией provider-таблицы и публикует её только после успешной обработки всех
|
||
потомков. Если в проверяемом суффиксе отсутствуют mesh metadata и поэтому нельзя
|
||
установить parent-связи, либо потомку требуются lifecycle, FX или visibility
|
||
callbacks, API возвращает ошибку и оставляет исходную таблицу без изменений.
|
||
Native oracle использует синтетические NDP/Node38-строки и исходные
|
||
Control/AniMesh callbacks; это не полное исполнение или сравнение миссии.
|
||
|
||
Переход между обычной стадией и sentinel `stage == count` вызывает
|
||
`Control.dll!FUN_10011920`, только если у строки не установлен флаг `0x02`.
|
||
Функция читает геометрию уже выбранной видимой стадии через
|
||
`AniMesh.dll!FUN_100051f0`.
|
||
Selectors `F` и `10` возвращают одну общую ячейку; второй запрос перезаписывает
|
||
значение первого. Это влияет на агрегаты: один синтетический узел при состоянии `0`
|
||
давал пару `[42, 330]`, а другой слот для состояния `2` — `[102, 570]`. В одном
|
||
тестовом цикле hide/show использовали один и тот же слот, поэтому агрегаты
|
||
вернулись к исходным; во втором цикл скрывал состояние `2`, а показывал состояние
|
||
`0`, и полученные значения уже не были взаимными противоположностями. Отрицательные
|
||
агрегаты во втором векторе — специально выбранные синтетические числа, а не
|
||
утверждение о допустимом состоянии игры.
|
||
|
||
Эти вызовы исполнены в x86 oracle с Control SHA выше и AniMesh SHA-256
|
||
`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`. Таблицы
|
||
NDP, Node38 и объект хоста синтетические; слот lifecycle проходит через исходный
|
||
`FUN_10005500`, а переход видимой стадии — через исходный `FUN_10005810`. Вызовы
|
||
`FUN_10011220` сделаны с `updateEffects = false`, поэтому ветвь Control FX,
|
||
включая `FUN_10011af0`, ими исключена. В отдельном visibility-сценарии класс `9`
|
||
заставляет `FUN_1000fca0` вернуться сразу после смены видимости, так что связанное
|
||
с ним движение также не проверено. Одна строка Control имеет flags `0x102`, но
|
||
слово флагов ANI `+0x14` и производные байты `+0x111..+0x113` изначально равны
|
||
нулю; поэтому вызовы
|
||
`AniMesh.dll!FUN_100093c0` и `FUN_1000a2a0` (пересчёт производной геометрии и
|
||
видимости) не покрыты. Эти результаты подтверждают локальные переходы и порядок
|
||
callbacks, но не полный Control update реальной миссии. Источник часов и порядок
|
||
их доставки установлены статически выше; бюджетная часть активной clock-ветви
|
||
проверена на синтетическом хосте класса `10`, её исполнение в реальной миссии
|
||
остаётся непроверенным.
|
||
|
||
### Начальная MSH-часть строки provider
|
||
|
||
`AniMesh.dll` (SHA-256
|
||
`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`)
|
||
строит глобальную MSH-таблицу в `FUN_1000a460` (RVA `0xa460`). Для каждого
|
||
узла корневой модели добавляется его исходная запись; mounted `EXTO` начинает
|
||
с узла `1`; `INTO` в эту таблицу не добавляет. При чтении Node38 поле
|
||
`parent_or_link` — это беззнаковое `u16` из байтов `+2`, а не готовый индекс
|
||
глобальной таблицы.
|
||
|
||
Статические инструкции `0xa474..0xa49f` дают основу родительского индекса.
|
||
Для корня база равна нулю; для `EXTO` она равна старому размеру глобальной
|
||
таблицы минус один. Само значение трактуется так:
|
||
|
||
```text
|
||
raw == 0 -> mount_node
|
||
raw == 0xffff -> parent_base - 1
|
||
иначе -> parent_base + u32(raw)
|
||
```
|
||
|
||
Только `0xffff` служит sentinel-ом: остальные слова, включая `0xfffe`,
|
||
прибавляются как беззнаковые `u16`. Для примонтированного `EXTO` с базой `21`
|
||
слово `1` даёт parent `22`, а `0xffff` даёт `20`. `mount_node` выбирается из
|
||
уже построенной иерархии компонентов. Эта формула восстановлена по машинным
|
||
инструкциям загрузчика: `ADD EDI,EAX` в RVA `0xa7e3` прибавляет нормализованное
|
||
значение к базе перед записью parent по `0xa7e7`. Это не исполнение полного
|
||
загрузочного пути оригинальной игры.
|
||
|
||
`Control.dll` (SHA-256
|
||
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`)
|
||
`FUN_1000f940` (RVA `0xf940`) получает три поля из AniMesh для того же
|
||
глобального ordinal. В x86 ABI oracle оригинального `AniMesh.dll`
|
||
`FUN_100051f0` (RVA `0x51f0`) с selector `0x0d` копирует runtime parent из
|
||
строки `+0x18` в общий scratch `DAT_10025e88` и возвращает адрес этой копии;
|
||
selector `0x0e` так же возвращает копию hdr0 Node38, а не адрес исходной записи.
|
||
Тест устанавливает raw parent words `[3, 0, 1]`, но runtime parents `[-1, 22, 7]`,
|
||
и проверяет, что selector D читает именно последние. hdr0 `[0x8201, 0x0180,
|
||
0x0400]` читается через полный 32-битный результат, поэтому старшие биты не
|
||
теряются.
|
||
|
||
Control переводит проверенные маски hdr0 в поле строки `+0x28`:
|
||
|
||
| Node38 hdr0 | Control bit |
|
||
| --- | --- |
|
||
| `hdr0 & 0x0200` | `0x01` |
|
||
| `hdr0 & 0x0100` | `0x02` |
|
||
| `hdr0 & 0x0400` | `0x04` |
|
||
| `(hdr0 & 1) != 0 && (hdr0 & 0x0080) == 0` | `0x08` |
|
||
|
||
`FUN_10005840` (RVA `0x5840`) возвращает число подряд присутствующих slots
|
||
состояний `0..2`: `[present, absent, present] -> 1`,
|
||
`[absent, present, present] -> 0`, `[present, present, present] -> 3`.
|
||
Пустой префикс в начальной строке Control становится одной доступной стадией,
|
||
поэтому Rust хранит `max(1, prefix_length)`. Slot проверяется через штатный
|
||
`selected_slot` с `Group(0)` (native LOD0), а не подсчётом всех ненулевых words.
|
||
|
||
Runtime временно собирает mesh-состояния в порядке AniMesh, затем сопоставляет
|
||
их с уже упорядоченными NDP provider-строками по общему ordinal. Избыточная NDP
|
||
строка или отсутствующий MSH asset получают `initial_mesh_state = None`;
|
||
совпадение по `(component_index, source_row_index)` не выполняется. Состояние
|
||
не меняет правило `0xe00` query: он читает текущее type-1 поле `+0x14`
|
||
provider-строки, которое после нативного конструктора равно `1.0`.
|
||
|
||
### Начальное количество строки provider
|
||
|
||
В `Control.dll` SHA-256
|
||
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`
|
||
`FUN_1000f940` (RVA `0xf940`) получает для каждой строки selector `3` через
|
||
provider getter `FUN_1000dc40` (RVA `0xdc40`). Getter берёт `max_volume` из
|
||
NDP descriptor и последовательно умножает его на поля владельца `+0x660` и
|
||
`+0x548`. Конструктор `FUN_10006bf0` (RVA `0x6bf0`) задаёт оба множителя как
|
||
`1.0`. Инициализатор записывает результат в поля строки `+0x08` и `+0x0c`, а
|
||
type-1 поле `+0x14` остаётся `1.0`. Второе поле не является постоянной
|
||
вместимостью: `FUN_10011220` (RVA `0x11220`) копирует туда текущее `+0x08` при
|
||
завершении обновления.
|
||
|
||
Native oracle вызывает оригинальные `f940`, `dc40`, `b240` и AniMesh getters
|
||
`FUN_100051f0`/`FUN_10005840` на тестовой памяти; подменён только callback
|
||
владельца. При unity множителях строки с `max_volume` `[30, 60, 50, 0]`
|
||
получили начальные количества с теми же битами, включая нулевую строку, а
|
||
callback-и пришли в порядке `[0, 1, 2, 3]`. Отдельный прогон с синтетическими
|
||
множителями `1.5` и `0.5` подтвердил формулу для этих входов, но не порядок
|
||
операций или загрузку игрового объекта; порядок `max_volume * +0x660 * +0x548`
|
||
виден в машинных инструкциях getter-а. На начальном пути Unit DAT множители
|
||
единичные: `ArealMap.dll` (SHA-256
|
||
`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`) в RVA
|
||
`0x15290` передаёт в `CreateObject` три значения `1.0`. Вызов World3D
|
||
`AddObjectToGame` (`World3D.dll`, SHA-256
|
||
`17e4a3089b2583a8cf2356c9db0390b1aba138356a09130d79b4e7e4791da61e`, RVA
|
||
`0x7ea0`) не применяет transform scale; отдельный `AddNewObjectToGame` в RVA
|
||
`0x8310` имеет ветку установки scale. AniMesh constructor (`AniMesh.dll`,
|
||
SHA-256 `65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`,
|
||
RVA `0x68a0`) читает три DLL scale globals, которые после LoadLibrary в oracle
|
||
равны `1.0`. В Rust `initial_amount_bits` сохраняет это общее начальное
|
||
количество отдельно от текущего и последнего обработанного. Прямой delta API
|
||
меняет только текущее количество; отдельный стадийный API обновляет
|
||
обработанное и текущую стадию в подтверждённой области.
|
||
|
||
Licensed full-load regression `full_load_builds_effective_control_device_table_for_mine_unit_dat`
|
||
проверяет значения настоящего `min_l_n1.dat`: корневой provider row `0` даёт
|
||
`1.0`, а row `1` примонтированного `EXTO` (общий ordinal `22`) даёт `6000.0`.
|
||
Проверка использует исходные CTL/NDP/MSH, затем явно уменьшает количество
|
||
строки tag-26 устройства вдвое. Rust `0xe00` и `0x1002` дают `0.5`, и обычный
|
||
шаг runtime сохраняет эту долю. Это вызов прямого API, а не полный живой
|
||
Control tick. Проекция поля модели `+0x124` также получает `0.5`, а её видимая
|
||
стадия этого ресурсного узла остаётся `0`. Затем другой, двухстадийный узел
|
||
получает долю `0.5` и отдельный stage API переводит его из `0` в `1`.
|
||
GPU/window path этим тестом не исполняется.
|
||
|
||
Native-часть этих ABI проверок воспроизводит
|
||
`behavior_distributor_tick_native_oracle.cs` на синтетически выделенной памяти
|
||
и оригинальном DLL с указанным SHA. Она подтверждает getters и layout, но не
|
||
создание реальных AniMesh rows при полной загрузке; licensed runtime test
|
||
отдельно проверяет реальные Node38 literals root/`EXTO` на `min_l_n1.dat`.
|
||
|
||
### Аргументы и обратный вызов Handler(30)
|
||
|
||
`resolve_handler30_with_values` сохраняет восстановленное ABI типов объявлений,
|
||
но читает аргументы из экземпляра ячеек varset конкретного клана, а не из
|
||
текстовых значений по умолчанию. `resolve_loaded_handler30` возвращает точную
|
||
команду обратного вызова `(mode=0, first, second)`, не приписывая ей
|
||
дополнительный смысл. Отдельная функция `apply_loaded_script_host_callback`
|
||
моделирует в Rust состояние исхода для `(0, 0, payload=0|1)`; она не выбирает
|
||
событие `.scr` и не вызывает машинный код оригинала.
|
||
|
||
Снимок работающей GOG AutoDemo определяет обратный вызов: прочитанный без записи
|
||
указатель по `ai.dll + 0x555e4` равен `0x100611d0`, то есть `iron3d.dll +
|
||
0x611d0` при наблюдавшейся базе загрузки. Восстановленное ABI вызываемой
|
||
функции — `__cdecl(mode, command, payload)`; `Handler(30)` передаёт те же поля как
|
||
`(0, first, second)`. Обратный вызов принимает интересующую нас команду только
|
||
при `mode == 0`, `command == 0` и нулевом байте `IGame + 0xe5`. Ветвь отказа
|
||
(`payload == 0`) запускает `VOICE_MISSION_FAIL`, записывает `1` в слово `IGame + 8`
|
||
и обнуляет байт `*(IGame + 4) + 0x157`, только если прежнее слово не равно `1`.
|
||
Ветвь завершения (`payload == 1`) запускает `VOICE_MISSION_COMPLETE`, записывает
|
||
`0` в слово и устанавливает байт в `1`, только если прежнее слово не равно `0`.
|
||
Оба звуковых вызова предшествуют этим записям. Команды 3, 4 и 5 ведут к другим
|
||
ветвям игры; режим `2` выполняет отдельный вызов IGame, а ветви для режима `1`
|
||
в этой функции нет.
|
||
|
||
Статический анализ восстановил также `command == 1`, но не дал основания
|
||
присвоить этой ветви предметное имя. Её аргумент `payload` — знаковый ключ
|
||
бинарного дерева поиска. Поле `+0x04` у получателя указывает на корневой
|
||
сторожевой узел; в каждом узле ссылки на дочерние узлы лежат по `+0x08` и
|
||
`+0x0c`, ключ — по `+0x10`, а передаваемый дальше аргумент — по `+0x14`. При
|
||
совпадении ключа вызывается `FUN_10095600(node + 0x14)`. У этого объекта есть
|
||
одноразовый флаг `+0x19` и второй флаг `+0x18`. При первом вызове Iron3D
|
||
устанавливает `+0x19`, получает строку из менеджера ресурсов через виртуальный
|
||
слот `+0x1c` и передаёт значение `+0x0c` объекта-аргумента в `FUN_10061fb0`.
|
||
При повторных вызовах используются идентификаторы менеджера ресурсов `0x181a`
|
||
и `0x184f`, селектор IGame `0x2ed` и непрозрачный запрос с категорией `3` или
|
||
`4`, выбираемой по `+0x18`. Это подтверждает поиск и различие первого и
|
||
повторных вызовов, но не назначение сообщения интерфейса или смысл этих
|
||
идентификаторов; в Rust команда `1` поэтому остаётся `Unhandled`.
|
||
|
||
### Состояние обратного вызова исхода миссии
|
||
|
||
Слово `IGame + 8` создаётся не загрузчиком миссии. В исследованной GOG-сборке
|
||
`iron3d.dll` (SHA-256
|
||
`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`, image base
|
||
`0x10000000`) конструктор `FUN_1005c820` записывает туда `5` (RVA `0x5c84f`), а
|
||
байт `IGame + 0xe5` обнуляет (RVA `0x5c9f6`). При настройке игры функция
|
||
`FUN_1005cbb0` задаёт `+0xe5` выражением `(*AppState == 3)` (RVA `0x5cc4a`);
|
||
обработчик таймера главного меню выставляет режим `3` для AutoDemo (RVA `0x70f3`).
|
||
Отдельный обработчик выбора миссии записывает режим `1` (RVA `0x29142`) перед
|
||
переходом к пути `missions/single.*`. После успешной стандартной инициализации
|
||
игровой цикл записывает в `IGame + 8` значение `4` (`FUN_1005eb70`, RVA `0x5eb70`).
|
||
Этот переход запуска не выполняется при статическом просмотре миссии и здесь не
|
||
моделируется.
|
||
|
||
`MissionRequest` в Rust несёт явный `MissionMode::{SingleMission, AutoDemo}`;
|
||
режим задаёт вызывающая сторона и его нельзя выводить из имени пути. Во время
|
||
загрузки Rust инициализирует своё слово состояния значением `5`, как у нового
|
||
IGame, и сохраняет запрошенный режим; сама загрузка не вызывает нативный
|
||
конструктор.
|
||
Публичный `apply_loaded_script_host_callback` применяет те же интересующие нас
|
||
проверки: режим `AutoDemo` закрывает ветвь, а в `SingleMission` обратный вызов
|
||
меняет только слово состояния и исход Rust, если выполняется исходное условие
|
||
по этому слову. Поэтому при состоянии `4` ветвь завершения разрешена, как и
|
||
ветвь отказа, пока слово не равно `1`. Повторный отказ при состоянии `1` либо
|
||
завершение при состоянии `0` ничего не меняют; если тест намеренно
|
||
рассинхронизирует исход Rust и слово состояния, повтор оставляет это
|
||
расхождение. Так работает исходная проверка слова, а не выравнивание всех полей.
|
||
|
||
Изолированное сравнение с оригинальной DLL выполнено небольшой тестовой
|
||
x86-программой `crates/fparkan-runtime/tests/original_host_callback_oracle.cs`:
|
||
программа проверяет SHA-256, загружает `iron3d.dll` и вызывает RVA `0x611d0` с
|
||
синтетическими IGame и AppState. Для изоляции проверки она временно подставляет
|
||
синтетический IGame через глобальную переменную `DAT_1010a5f8` (RVA `0x10a5f8`)
|
||
и заменяет слот таблицы импортов звукового интерфейса (RVA `0xe42cc`)
|
||
регистратором; машинный код обратного вызова остаётся исходным. Проверка сверяет
|
||
записи полей, повторные/обратные переходы, блокирующий `+0xe5` и порядок вызова
|
||
звука. Это
|
||
подтверждает локальную семантику обратного вызова, но не запуск исходной миссии,
|
||
диспетчеризацию Handler(30) или весь игровой цикл. Rust пока не запускает
|
||
диспетчеризацию сценария автоматически. Команды 1, 3, 4, 5 и режим 2 остаются
|
||
`Unhandled` в этом обработчике исхода.
|
||
|
||
Повторить из корня репозитория в PowerShell на Windows с установленными GOG
|
||
ресурсами можно так:
|
||
|
||
```powershell
|
||
$env:FPARKAN_ORIGINAL_IRON3D_DLL = 'C:\GOG Games\Parkan - Iron Strategy\iron3d.dll'
|
||
New-Item -ItemType Directory -Force -Path target\original-host-callback-probe | Out-Null
|
||
& 'C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe' /nologo /platform:x86 /out:target\original-host-callback-probe\HostCallbackOracle.exe crates\fparkan-runtime\tests\original_host_callback_oracle.cs
|
||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||
& .\target\original-host-callback-probe\HostCallbackOracle.exe
|
||
exit $LASTEXITCODE
|
||
```
|
||
|
||
### Handler(8): problem-record state write
|
||
|
||
`Handler(8)` is the ninth VM-table entry at GOG `ai.dll` VA `0x10009b0d`.
|
||
All 179 corpus records have exactly one in-range `DWORD` reference; the two
|
||
observed entries are `ST_SOLVING=1` (122 records) and `ST_SOLVED=2` (57).
|
||
The handler resolves loader-bound `dCurrentProblem` through varset index
|
||
`this+0x868`, uses that live DWORD as a bounds-checked index into a table at
|
||
`this+0xa0` with 100-byte records, then resolves the instruction's one DWORD
|
||
and writes it to the selected record at `+0x18`.
|
||
|
||
The write has two statically proven exceptional branches. State `2` calls a
|
||
reset helper that zeroes record words `0..=3` and `6` before invoking two
|
||
opaque callback slots; state `3` does the same except word `3` is preserved.
|
||
Both then write `+0x18`. Every other state simply writes the state word.
|
||
`VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned
|
||
live record index, resolved state, and explicit reset kind. It does not invent
|
||
the table owner, the pre-reset helper, or callback semantics.
|
||
|
||
### Handler(15): typed target-call boundary
|
||
|
||
`Handler(15)` is the sixteenth VM-table entry at GOG `ai.dll` VA `0x10008054`
|
||
and the second most frequent non-sentinel selector: 236 records in 28 of 58
|
||
GOG packages. It is not a one-word opcode. Across the complete corpus,
|
||
references `0..3` are `DWORD`, `4..7` are `float`, and `8` is the DWORD mode.
|
||
The original resolves the first four through `0x10013570`, the next four
|
||
through x87 scalar helper `0x10013190`, then reads the mode through
|
||
`0x10013570`.
|
||
|
||
The shipped `varset.var` names the only observed mode values: `NONE=0` uses
|
||
9 total references; `TARGET_BY_LOGIC_ID=0x0201`, `TARGET_BY_TYPE=0x0203`,
|
||
`TARGET_NOT_DEFINED=0x0204`, and `TARGET_BY_NAME=0x0205` use 10; and
|
||
`TARGET_BY_PLACE=0x0202` uses 11. The corpus contains 34/122/80 records in
|
||
these three arities. The trailing references at positions 9 and, only for
|
||
`TARGET_BY_PLACE`, 10 are all in-range DWORD declarations.
|
||
|
||
After resolving those inputs, the handler looks up an opaque target through
|
||
virtual slot `+0x1c` on `this+0x3d8` using the first word. A missing target
|
||
sets VM flag `+0x50=5`; otherwise the handler builds a temporary call record,
|
||
applies the mode-specific tail, and invokes the target's `+0x0c` virtual slot
|
||
with that record and the third word. Its zero/non-zero result becomes
|
||
`+0x50=0/1`. The target type, the virtual method's semantic action, and its
|
||
return value remain unproven. Accordingly `VarSet::resolve_handler15` only
|
||
materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`;
|
||
it never executes the opaque target call. Missing, out-of-range, wrong-type,
|
||
and unobserved-mode inputs are explicit errors.
|
||
|
||
## Готовность
|
||
|
||
Все demo packages должны проходить package/version checks, offsets оставаться
|
||
в bytecode, а confirmed disassembler — не терять синхронизацию. VM считается
|
||
готовой после deterministic Init/basic mission events, stable object bindings,
|
||
typed research/property tests и save/load script state. Для закрытия остаются
|
||
dispatcher/jump table, minimal differential packages и traces world/variable
|
||
effects; до них unknown opcode — явная unsupported branch, не no-op.
|