# Сценарная 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` сначала открывает `.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 она dispatches Init/start, на каждом tick обновляет timers, ставит готовые 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` открывает `.scr`, `varset.var`, `.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 имена `_Start` и `_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`. Следовательно, найденные `_Start` и `_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 нормализует `.scr`, декодирует его тем же bounded reader-ом и публикует immutable package вместе с clan provenance. Это именно wiring входных данных, не VM execution: Init и остальные events пока не dispatch-ятся, а ошибка чтения сохраняет transactional rollback mission loader-а. 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 нужен там, где оно выбирает ветку. ### 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. 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 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 anchor (`500.2857`) yields captured `ClanBaseX=500`, and the live GOG process contains two initialized SuperAI entries `(500, 752, 0)` and `(728, 449, 1)`; the Rust loader reports `script_init_states=2` and `script_varset_states=2`. `GetSuperAI` returns element `n` of the 64-pointer global table at preferred `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 `(+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, 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 remaining script event semantics. ### Аргументы и обратный вызов 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`, срабатывает лишь когда слово по `IGame + 8` не равно `1`: игра запускает `VOICE_MISSION_FAIL`, записывает `1` в это слово и обнуляет байт по `*(IGame + 4) + 0x157`. Ветвь завершения при `payload`, равном `1`, срабатывает лишь когда слово не равно `0`: игра запускает `VOICE_MISSION_COMPLETE`, записывает `0` в слово и устанавливает байт в `1`. Команды 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`. Сейчас `apply_loaded_script_host_callback` отображает `(0, 0, 0)` в локальное состояние `Failed`, `(0, 0, 1)` — в `Completed`, а повтор уже записанного состояния возвращает `NoChange`. Эта дедупликация относится только к модели Rust и не заменяет проверки Iron3D: в Rust нет ни байта блокировки `IGame + 0xe5`, ни слова состояния `IGame + 8`. Значение `Active` назначается при загрузке миссии, но его соответствие исходному состоянию игры пока не подтверждено. Команды 1, 3, 4, 5 и режим 2 остаются `Unhandled` в этом обработчике исхода. ### 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.