2026-07-18 20:10:26 +04:00
# Сценарная 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;
2026-10-11 13:12:50 +04:00
полная opcode grammar не доказана. Его внешний framing читает
2026-07-18 20:40:17 +04:00
`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` — текстовый
2026-07-18 21:17:57 +04:00
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` );
2026-07-18 21:23:28 +04:00
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
`not found` откатывается к этому shared file. Runtime повторяет данный порядок
транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но
ещё не исполняет declarations как VM state;
2026-07-18 20:40:17 +04:00
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
consumer-inferred.
2026-07-18 20:10:26 +04:00
## Безопасная модель исполнения
Новая 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 она dispatches Init/start, на каждом tick обновляет timers,
ставит готовые events в стабильную очередь и исполняет bounded instruction
budget. Опасное удаление идёт через World3D queue и общий deferred lifecycle.
До восстановления opcode table package mode читает header/strings/symbols/
2026-07-18 20:32:56 +04:00
event offsets/raw bytecode losslessly. Статический анализ уже выделил отдельный
2026-10-20 01:45:58 +04:00
evaluator записей условий (`ai.dll` RVA `0x5180` ). Его подтверждённые ветви и
точные границы описаны ниже; он не является dispatcher-ом инструкций или jump
table `.scr` . Bytecode opcode table всё ещё требует отдельного доказательства.
Unknown opcode нельзя пропустить как один byte: это ломает синхронизацию. Для
каждого доказанного opcode фиксируются number, size, operands, control flow,
effects, errors и минимальный test.
2026-07-18 20:10:26 +04:00
2026-07-18 20:40:17 +04:00
GOG `ai.dll` доказывает этот framing двумя consumer-ами: loader по
`0x10001000` открывает `<bundle>.scr` , `varset.var` , `<bundle>.fml` , затем
собирает ровно 73 pointers handlers; `0x10011b20` читает описанную count-driven
2026-09-06 05:22:12 +04:00
структуру. В GOG `c1m2p.scr` reader находит 73 handler slots,
9 events, 17 nested records и 20 references, заканчивая на EOF.
Это подтверждает разбор пакета, но не смысл всех opcodes.
2026-07-18 20:40:17 +04:00
2026-09-06 05:22:12 +04:00
Selector устроен следующим образом: loader `0x10001000` создаёт 73 pointers в
2026-07-18 20:44:10 +04:00
фиксированном порядке, а `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.
2026-10-20 01:45:58 +04:00
### 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 ).
2026-07-18 21:01:46 +04:00
`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.
2026-07-18 21:03:36 +04:00
Отдельный проход по всем 58 GOG `.scr` (6 087 instruction records) не нашёл
ни одного selector `1` : из них 2 095 записей выбирают один из handlers, а
3 992 являются sentinel. Значит, это установленная, но не corpus-reachable
ветка данного издания; её нельзя делать приоритетным execution path без
отдельного dynamic/evidence route.
2026-07-18 21:01:46 +04:00
2026-07-18 20:46:37 +04:00
`Handler(2)` (третья entry table, VA `0x10009610` ) уже имеет статический
contract, но ещё не Rust execution: он выбирает active event/instruction через
2026-07-18 21:05:55 +04:00
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, а не команда движения, атаки
2026-07-18 21:11:10 +04:00
или строительства. Semantic names семи slots и consumer нового record остаются
открытыми; до dynamic capture Rust возвращает явный
2026-07-18 21:05:55 +04:00
unsupported result, а не «примерный» game command.
2026-07-18 20:46:37 +04:00
2026-07-18 21:13:40 +04:00
У этой границы также нет скрытого immediate dispatch: после добавления новой
записи `0x100059f0` вызывает `0x1000f920` , а Ghidra 12.1.2 декомпилирует эту
функцию как пустой `return` . Следовательно, найденные `<base>_Start` и
`<base>_Continue` только кэшируются в scheduler state; их фактический consumer
2026-10-11 13:12:50 +04:00
находится в отдельном позднем update path.
Во всех 58 GOG `.scr` имеются 6 087 instruction records, включая 3 992
sentinel. Самый частый non-sentinel selector — `Handler(30)` , 246 records.
Его VA `0x1000c266` читает первые два reference words активной instruction,
разрешает каждый через varset (`0x10002d30` и `0x10013570` ) и вызывает внешний
callback с тремя `u32` : `(0, first, second)` . Callback не принадлежит `ai.dll` : его
2026-07-18 21:27:39 +04:00
кладёт десятый argument экспортного `CreateSuperAI` . Тот же callback встречен
у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word
`1` ; предметная семантика этих modes ещё не доказана. В частности, это пока
не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный
2026-07-18 21:33:14 +04:00
text-to-varset resolver расположен за wrapper `0x10011ea0` в
2026-10-11 13:12:50 +04:00
`0x100174a0` .
2026-07-18 21:27:39 +04:00
2026-10-11 13:12:50 +04:00
`0x100174a0` добавляет каждый
2026-07-18 21:33:14 +04:00
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,
2026-10-11 13:12:50 +04:00
out-of-range и incomplete instructions вместо silent coercion.
2026-07-18 21:33:14 +04:00
2026-10-11 13:12:50 +04:00
Identity ровно равна
2026-07-18 21:11:10 +04:00
`(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.
2026-10-20 01:31:26 +04:00
Шаг прибавления проверен прямым вызовом оригинального 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 внутри запущенной игры.
2026-10-11 13:12:50 +04:00
На границе mission runtime выбранный TMA clan `first_resource`
2026-07-18 20:56:34 +04:00
материализуется как отдельный `MissionScriptBundle` : loader нормализует
`<base>.scr` , декодирует его тем же bounded reader-ом и публикует immutable
2026-09-06 05:22:12 +04:00
package вместе с clan provenance. Это именно wiring входных данных, не VM
execution: Init и остальные events пока не dispatch-ятся, а ошибка чтения сохраняет
2026-07-18 20:56:34 +04:00
transactional rollback mission loader-а .
2026-07-18 20:10:26 +04:00
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 нужен там, где оно выбирает ветку.
2026-07-18 22:04:10 +04:00
### Handler(19): AutoDemo Init varset initialization
`Handler(19)` is the twentieth VM-table entry at GOG `ai.dll` VA `0x1000aa38` .
It is the only instruction in the `Init` event of the two `default.scr` bundles
referenced by `MISSIONS\\Autodemo.00\\data.tma` ; together those bundles account for
the observed 18 named script events. Each instruction references varset records
`224` , `225` , and `226` : `ClanBaseX` , `ClanBaseY` , and `ClanID` respectively.
The original writes three raw DWORD values in order. It converts the VM fields
at `+0x80` and `+0x84` through the x87 `__ftol` helper and stores the resulting
words into references 0 and 1. It copies the raw word from `+0x7c` into reference
2, then clears VM field `+0x50` . The default targets are `DWORD` records; the
shared setter preserves the incoming word for that type. Therefore this is not
a license to replace the first two conversions with Rust float casts: their
rounding behavior remains an x87 compatibility boundary until it has captured
test vectors.
2026-07-18 22:15:55 +04:00
The missing source-field provenance is now constrained by the public creation
boundary. `CreateSuperAI` at `ai.dll` VA `0x1000f710` allocates `0x8b0` bytes
and calls constructor `0x10001000` with its first eight arguments. That
constructor calls `0x10006340(this + 0x7c, clan_id, base_x, base_y)` : these are
the fields later read by `Handler(19)` . `base_x` and `base_y` are unsigned and
the constructor rejects values greater than `10000` . The actual `__ftol` helper
at `0x1001df70` saves the x87 control word, sets its rounding-control bits to
truncate, executes `fistp qword` , then restores the control word.
Consequently the runtime resolves and retains this one proved vertical slice
during mission loading: each selected clan's TMA anchor is accepted only in the recovered
`0..=10000` base range, truncated through the recovered x87 rule, and paired
with its zero-based clan index. For every `Init` instruction whose selector is
`Handler(19)` , `VarSet::resolve_handler19` produces the three per-clan DWORD
2026-07-18 22:18:49 +04:00
writes. The runtime then materializes an independent declaration-ordered value
array for each selected clan and applies those writes, so later recovered
handlers can consume `ClanBaseX` , `ClanBaseY` , and `ClanID` as runtime cells
rather than loader defaults. Other Init selectors and all other events remain
decoded but unexecuted. AutoDemo validates the path end-to-end: its non-integral first
2026-07-18 22:15:55 +04:00
anchor (`500.2857` ) yields captured `ClanBaseX=500` , and the live GOG process
contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)` ;
2026-07-18 22:18:49 +04:00
the Rust loader reports `script_init_states=2` and `script_varset_states=2` .
2026-07-18 22:15:55 +04:00
2026-07-18 22:04:10 +04:00
`GetSuperAI` returns element `n` of the 64-pointer global table at preferred
2026-10-11 13:12:50 +04:00
`ai.dll + 0x55398` for `n <= 63` . A read-only capture of the running GOG
AutoDemo process observed `(500, 752, 0)` for entry 0 and `(728, 449, 1)` for
entry 1 at fields
2026-07-18 22:04:10 +04:00
`(+0x80, +0x84, +0x7c)` . These values are integral samples, not a rounding
profile.
The Rust reader exposes `VarSet::resolve_handler19` . It accepts the already
converted first two words and the third raw word, produces three typed writes,
2026-07-18 22:15:55 +04:00
and rejects missing, out-of-range, or non-`DWORD` targets. The runtime only
binds it to the proven creation/anchor path above; it does not guess the
2026-10-11 13:12:50 +04:00
remaining script event semantics.
2026-07-18 22:04:10 +04:00
2026-07-18 22:25:28 +04:00
### Runtime Handler(30) operand binding
`resolve_handler30_with_values` preserves the recovered declaration-kind ABI
but reads operands from instantiated per-clan cells rather than textual
defaults. Runtime exposes `resolve_loaded_handler30` for the exact opaque
`(mode=0, first, second)` callback command. It intentionally returns that
2026-07-18 22:55:31 +04:00
command without automatically invoking a game-side consumer: only one
consumer branch has a safe Rust meaning so far.
2026-07-18 22:25:28 +04:00
2026-07-18 22:30:49 +04:00
The live GOG AutoDemo closes that consumer boundary: the read-only callback
pointer at `ai.dll + 0x555e4` is `0x100611d0` , or `iron3d.dll + 0x611d0` at
the observed load base. Its recovered `__cdecl` ABI is `(mode, command,
payload)` , matching `Handler(30)` as `(0, first, second)` . In `mode == 0` ,
`command == 0` and `payload == 0` selects `VOICE_MISSION_FAIL` , records the
failed status, and clears an IGame byte; `payload == 1` selects
`VOICE_MISSION_COMPLETE` , records completion, and sets that byte. Commands
2026-07-18 22:55:31 +04:00
3, 4, and 5 have additional game-side paths; mode 2 is a separate IGame
call.
`command == 1` is now statically traced, but still has no justified domain
name. Its payload is a signed integer key for a binary-search tree: the
receiver's `+0x04` is the sentinel/root, each node has child links at `+0x08`
and `+0x0c` , a key at `+0x10` , and a dispatched payload at `+0x14` . A matching
node reaches `FUN_10095600(node + 0x14)` . That payload has one-shot flag
`+0x19` and a second flag `+0x18` . On its first dispatch, Iron3D sets `+0x19` ,
obtains a resource-manager string through virtual slot `+0x1c` , and passes the
payload's `+0x0c` through `FUN_10061fb0` . On later dispatches it uses
resource-manager IDs `0x181a` and `0x184f` , an IGame selector `0x2ed` , and
emits an opaque request with category `3` or `4` selected by `+0x18` . This
proves the lookup and one-shot/repeat split, not the UI/message subject or the
semantics of either resource ID; Rust therefore retains command `1` as
`Unhandled` .
2026-10-11 13:12:50 +04:00
Runtime applies only this branch as
2026-07-18 22:36:38 +04:00
`apply_loaded_script_host_callback` : `(0, 0, 0)` transitions a loaded mission
to `Failed` , `(0, 0, 1)` transitions it to `Completed` , and a repeated target
state is a no-op just as the Iron3D guards require. The effect is deliberately
separate from audio/UI playback; commands 1, 3, 4, 5 and mode 2 return
`Unhandled` until their consumers are recovered.
2026-07-18 21:53:09 +04:00
### 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
2026-10-11 13:12:50 +04:00
the table owner, the pre-reset helper, or callback semantics.
2026-07-18 21:53:09 +04:00
2026-07-18 21:47:33 +04:00
### 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,
2026-10-11 13:12:50 +04:00
and unobserved-mode inputs are explicit errors.
2026-07-18 21:47:33 +04:00
2026-07-18 20:10:26 +04:00
## Готовность
Все 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.