11 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;
.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.
Следующий 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.