15 KiB
Сценарная 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 она 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. Статический анализ уже выделил отдельный
five-way evaluator condition records (ai.dll VA 0x10005180): tags 1..5,
type guards, object lookup и completion flag. Это не следует выдавать за
instruction 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
структуру. Команда
cargo run -p fparkan-cli -- script inspect `
'C:\GOG Games\Parkan - Iron Strategy\MISSIONS\SCRIPTS\c1m2p.scr' --format json
на исходном пакете возвращает opcode_handler_count=73, 9 events, 17 nested
records, 20 references и 0 trailing bytes. Это corpus evidence для reader-а,
но не разрешение на исполнение неизвестных 73 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.
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. Воспроизводимый read-only extractor:
tools/ghidra/ExportAiVmHandler2Dispatch.java.
Corpus priority теперь измерен, а не предполагается: во всех 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: его
кладёт десятый argument экспортного CreateSuperAI. Тот же callback встречен
у Handler(57) с первым word 2 и у отдельного lifecycle path с первым word
1; предметная семантика этих modes ещё не доказана. В частности, это пока
не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный
text-to-varset resolver расположен за wrapper 0x10011ea0 в
0x100174a0. Воспроизводимые exports: ExportAiVmHandler30.java,
FindAiVmHandler30Callback.java, ExportAiVarSetLoader.java.
Следующий pass восстанавливает эту индексацию. 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. Extractors:
ExportAiVarSetParser.java, ExportAiVarSetU32Resolver.java.
Следующий static pass закрывает equality/update policy. 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.
На границе mission runtime выбранный TMA clan first_resource теперь
материализуется как отдельный MissionScriptBundle: loader нормализует
<base>.scr, декодирует его тем же bounded reader-ом и публикует immutable
package вместе с clan provenance. Headless report выводит число таких packages
и их named events. Это именно 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 нужен там, где оно выбирает ветку.
Готовность
Все 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.