- CLI run: report call-script dispatch count + distinct source scripts per run.
- Godot --scene <NAME>: play any scene (not just SC0000).
- Godot reports the call-scripts executed as nested frames at scene end (collected
thread-safely; Godot drops GD.Print from the VM background thread).
Demonstrated live: SC0240 in Godot executes 29 call-scripts (RESETLAND, SETEN,
ADDEN, RENDERMAP, SETOBJ, DRAWOBJ, CALCREVISE, LOOK) as nested subroutine frames.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Play path: VM now gets Sys4ScriptProvider, so call-script executes live on screen
(subroutines run; input-wait loops break on real player input).
- Selftest: was 'run SC0000 stubbed, match vm0-trace (186)'. Now runs a SYNTHESIZED
scene (show-text + wait-for-input + real nested call-script) through the Godot
thread/semaphore/CallDeferred plumbing and asserts it matches a headless run of the
same scene — full handling, expected computed live, no frozen golden, no vm0 dep.
- Verified: godot --headless -- --selftest => 'threaded host matches headless (3 lines)'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- ScriptAssembler (Age.Engine/Sys4): assemble code+strings -> Script (inverse of
Sys4Loader; also Phase-D modding-assembler groundwork).
- SyntheticSceneTests: deterministic show-text/wait/nested-call-script/shared-global
scene run with call-script handling ON.
- WaitForInputTests: reworked onto a synthesized two-page scene (was: SC0000 stub=186).
- RecoverTests: full call-script handling via a no-op subroutine double (isolates
RECOVER's ISA semantics from real subroutines' game-state deps).
- Retire TraceDiffTests: it matched the C# VM to vm0.py's stubbed-call-script trace;
vm0.py is retired from oracle duty, and we no longer gate handling to keep it matching.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Native-RE: call-script id = raw SYS4INI file index (name-resolution #1 solved)
- C# VM: call-script now executes (IScriptProvider + ExecFrame + nested run)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Integration: ADDILL executes ADDILLSUB + CALCREVISE and returns to its own exit.
Sweep (execution on): 284/297 exit clean, 13 STEP-LIMIT (input/state-gated ADV
scenes that now spin headless once subroutine global-writes drive their loops —
state divergence, not a call-script bug; 0 depth-cap, 0 unresolved, 0 crashes).
Removed the dead _halted field (halt propagates via FrameOutcome).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Design for making call-script actually execute (load target .BIN by id,
run nested sharing globals, return to caller). Scope: subroutine
execution only. Drops vm0.py from oracle duty; C# owns golden traces.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Native-RE (Ghidra) cracked call-script <id> (opcode 0x03): its handler
FUN_0041bc90 -> loader FUN_0040e980 -> resolver FUN_0044f390 indexes an
80-byte record table at base + id*0x50 == the SYS4INI record layout. So
`call-script <id>` is a direct RAW index into the SYS4INI global file
table (the asset index we already parse) -- there is no separate on-disk
id->code registry. This resolves name-resolution.md #1, statically, no
Frida.
Confirmed: all 297 distinct corpus call-script ids resolve to a .BIN
script with a semantically-exact name (0x1ab->ADDITEM, 0x2ae7->MES,
0x143->BUNKI, 0x329d->CALCREVISE), 0 out-of-range, 0 alternate-pack.
Companion op 0x8f `call` is an intra-script JSR (FUN_0041fba0), not
cross-script.
- parse_sys4ini.py: preserve `raw_index` per entry (= the engine file id;
index the RAW records incl. '@' placeholders) + emit
build/callscript-names.json (id->name).
- sys4load.py: annotate `call-script 0x1ab =ADDITEM.BIN`.
- opcodes.toml 0x03/0x8f refined (source=investigation, confidence high,
handler VAs) + rebuilt opcode-reference.md.
- docs: engine-re.md (op 0x03 section + backlog re-aimed),
name-resolution.md #1 (SOLVED), script-inventory.md (call graph +
living-reference decision), tools-reference.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
op 0x1a2 resolve-handle? -> gfx-cmd-register (verified handler FUN_0042d360, gfx
command-buffer op). name-resolution: decision->scene hop is native+unidentified, not
u00428010 (disproven). Regenerated opcode-reference + shim.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Anchored the opcode dispatch table: handler(op)=ctx[0x26c93+op] (default FUN_004162b0,
registered by FUN_00413860). Corrects the prior note: raw Kelebek VA 0x428010 is op
0x1ac (a save op, 0x427fb0); op 0x1a2's REAL handler is FUN_0042d360 = a graphics
command-buffer op (cmd-type 3, '%c%8.8x' key). So u00428010 is NOT decision->scene and
NOT save; the FIELD 0x5f0ed/0x62ccf snippet is gfx/UI. decision->scene premise discredited;
real mechanism = call-script/script-load (still unidentified). Lesson: resolve handlers
via the table, never the raw Kelebek VA.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Ghidra+MCP workflow validated. Found the opcode->handler dispatch table
(*(ctx+0x9b8f4+op*4), registered by FUN_00413860) — the general fix for Kelebek VA
drift. Corrected: u00428010 (op 0x1a2) is a save/resource op, NOT decision->scene;
the SCJUMP consumer persists the visited-decision flag. New doc docs/engine-re.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stand up bethington/ghidra-mcp loop; prove it by reversing SCJUMP's native
decision->scene resolver. Raw-dump-first, pe-sieve fallback; findings -> docs/engine-re.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guarded-DFS decode of SCJUMP's acyclic DAG into (chapter_mode, guards)->decision
table (1755 sites/847 decisions), VM-verified (execution-driven, 0 failures).
Finding: 1732/1755 decisions gated by native op 0x60. Decision->scene (u00428010)
documented as the deferred native boundary.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reworked from static-witness-only to execution-driven: ~99% of SCJUMP decisions
are gated by a native computed value (op 0x60), so witness-synthesis alone can't
cover them. Value-local tracking resolves load-then-compare guards to real globals;
native terms honestly marked opaque. Verify: static 3/3 exact + 279/279 execution-
driven consistent over 2000 seeds, 0 failures.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Guarded-DFS decode of SCJUMP's DAG into (chapter, guards)->decision table;
VM witness cross-check; native decision->scene boundary documented/deferred.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Curated single-source global registry (opcodes.toml analogue) with static
story-flag discovery miner; sys4load labels from merged build/globals.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
build/globals.json is generated (build/ is gitignored, regenerable via
globals_build.py --build); docs/global-reference.md is the tracked human view.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Curated single-source global registry modeled on opcodes.toml; static
story-flag discovery miner; sys4load labels from merged build/globals.json.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
godot -- --seed 0xa57=1 seeds initial global state in the ADV frontend (e.g.
Lily's form-gated voiced dialogue plays live; forms A/B/C = 0xa57/0xa58/0xa59).
Additive: no seed = unchanged, selftest OK. Also documents the engine CLI
(run/trace/audio/gfx/play/sweep) + Godot frontend flags in tools-reference.md
(they lived only in memory/commits).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sweep [--boot] [0xADDR=VAL...] runs each scene with and without the seeds from
the same baseline and reports which scenes' dialogue changes. Maps a story flag's
reach across the corpus. Finding: the Lily form-A flag 0xa57=1 changes dialogue
in 34/297 scenes (SC0000 186->229, SC0040 111->156, ...) — a pervasive ADV lever,
validating the state-divergence finding at scale. Engine 18/18.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Frida-free foundation for cross-scene playthrough and state seeding:
- GameSession: persistent global bank carried across scenes (VM untouched, parity held).
- play [--boot]: run a scene sequence carrying state; --boot runs the 9 *INIT
data scripts into the bank (23646 globals) so scenes see real skill/item/unit data.
- Snapshot save/load (--state/--save-state): capture an expensive booted state once,
reuse it; foundation for save-files.
- sweep [--boot]: corpus-scale validation (matches vm0.py: 294 exit + 3 LOOP).
Findings operationalized: seeding the Lily form flag 0xa57=1 takes SC0000 186->229
lines (state divergence, headless). Data-boot is regression-free but doesn't change
ADV flow — story-state flags drive that. Engine 18/18; Godot selftest OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runs every SC/SP scene through GameSession (fresh or booted-from-snapshot) and
reports halt distribution + line counts. Unbooted matches the vm0.py A0 baseline
(294 exit + the same 3 LOOP scenes) — cross-validates the C# VM at scale.
FINDING: booting the *INIT data tables gives byte-identical results (regression-
free) but does NOT change ADV dialogue flow — *INIT feeds gameplay; ADV branches
key on story-state flags (chapter/form/choices) from the progression layer, not
*INIT. So story-state seeding (e.g. the Lily form flag) is the ADV-unlock lever.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ToJson/FromJson round-trip the persistent global bank; play --save-state <file>
persists it, --state <file> restores it — so an expensive booted state (23646
globals) is captured once and reused without re-running *INIT, and it's the
foundation for real save-file work. Tests: unit round-trip + booted-state
survives snapshot (skill data intact). Engine 18/18.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All 9 *INIT scripts run clean and populate the game's data tables into the
global bank. play --boot prepends them before scenes so scenes see real
skill/item/unit/map/stage state (23646 globals for SC0000). Test cross-checks
the VM's SKINIT population against the static extraction (build/data/SKINIT.json:
skill 0 '飛行'@0x23a3, G[0xa6e5b]=30) — validates both the VM and extract_init.
Engine 16/16.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persistent global store carried across scenes (the engine's flat global bank),
the substrate for cross-scene flow and state seeding. GameSession seeds a fresh
VM from session state, runs, merges back; VM untouched so trace/selftest parity
holds. Age.Cli play <SCENE...> [0xADDR=VAL] runs a sequence carrying state.
Tests (engine 15/15): state persists A->B; seed visible to scene; SC0000 via
session byte-identical to single run; seeding form flag 0xa57=1 changes behavior.
Demonstrated: play SC0000 0xa57=1 -> 186->229 lines (Lily's form-gated dialogue
executes); SC0000->SC0030 carries 76 globals. Operationalizes the state-
divergence finding. Godot selftest OK.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reverse-engineered the post-opening bg/sprite drift end-to-end:
- Root cause: stubbed 0x215 (native gfx-object query) collapses draws to slot 0.
- Unlocked static analysis of the unpacked engine (dump_engine.py + capstone;
AGE.EXE unpacks in-place at 0x400000; SYS4AB = XOR-0xFF(AGE.EXE) dead end).
- Live capture verdict: the real opening uses zero CG object-records => the drift
is a STATE-DIVERGENCE artifact of the unseeded headless VM, not a missing op.
Fix = Phase B state/choices flow. Native gfx-op modeling deferred (dump in hand).
No engine-code changes (RE tooling + docs only); engine 11/11 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
tools-reference: dump_engine/probe_handlers/capture_gfx_objects + SYS4AB note.
phase-a-slice-plan: post-opening drift RESOLVED as a state-divergence artifact
(fix = Phase B state flow, not a native-op subsystem). PROJECT-STRUCTURE:
build/{textures,engine-dump}, engine/, tools/frida/.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Live capture (esi=engine ctx via operand-fetch, poll object-record array
[esi+0x53d64] stride 120). KEY FINDING: through the full real opening, the record
array holds only 3 persistent UI objects — NO CG objects. The real game does NOT
draw opening CGs via the 0x212-0x21a positioned-object path our headless VM uses;
with state it takes a different (direct) branch. So the bg/sprite drift is a
STATE-DIVERGENCE artifact of the unseeded headless VM, not a missing native op —
the fix is the Phase B state/choices flow (makes label_12649 take the if-branch).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
operand-fetch (call 0x41b940) fires ~8500/sec => the VM interpreter executes
from the in-place unpacked module at 0x400000 (NOT the heap copy) => handlers are
hookable by dump address. gfx-family(0x212-0x215)=0 at the title (no CG commands
until a scene runs).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>