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>
8.7 KiB
call-script execution in the C# VM — design (2026-07-07)
Goal
Make call-script <id> (opcode 0x03) actually execute in the C# engine (Age.Engine): load the
target .BIN by id, run it as a nested subroutine that shares the global bank, and return to the
caller. This unlocks the ~297 subroutine scripts the corpus calls (MES, ADDITEM, the CALC* family,
SHOWGROW, …) which are currently stubbed — the first functional step past "single script runs" toward
a driven playthrough.
Enabled by the native-RE finding that call-script <id> is a direct raw index into the SYS4INI file
table (see docs/engine-re.md, "op 0x03 (call-script)"; docs/name-resolution.md §1).
Scope
In scope: subroutine execution — nested load + run + return, sharing globals, with per-call local frames. Validated headless + in the existing hosts.
Out of scope (this slice):
- Scene chaining / decision→scene (the SCJUMP decision-value → scene-id native hop is still open).
- Input model (
0x90) and any new interactive behavior. - The graphics geometry/blend drift (a separate, state-divergence problem).
- Reimplementing call-script in
vm0.py(see Validation — vm0.py is retired from oracle duty).
Background — current architecture
VirtualMachine (engine/Age.Engine/Vm/VirtualMachine.cs) runs one Script with one Frame
and a single intra-script _callstack (for op 0x8f call). call-script today is a stub:
_host.CallScript(id); return pc + 1; — the target never runs.
Scripts are loaded by name from Paths.Scripts() (the override-aware corpus map: root loose-file
overrides shadow extracted/DATA1/*.BIN) via Sys4Loader.Load. GameSession carries the flat global
bank across scenes; local frames correctly do not persist. Seam rule: Vm references only Model +
Hosting, never Sys4.
Corpus facts grounding the design: called scripts terminate with exit (ADDITEM, MES, SHOWGROW) or
ret (BUNKI); native call depth is bounded (≤ 0x26 = 38); all 297 distinct call-script ids resolve to
a DATA1 .BIN (0 out-of-range, 0 alternate-pack).
Design
1. Script-provider seam
A small interface in Hosting (which may reference Model), injected into the VM so Vm never
references Sys4:
public interface IScriptProvider { Script? GetById(long id); }
Sys4implementsSys4ScriptProvider:id → build/callscript-names.json → name → Paths.Scripts()[name] → Sys4Loader.Load, cached by id (MES is called 59×; parse once). ReusingPaths.Scripts()gives the native "loose override first" behavior for free (it already prefers root overrides over the archive copy).- The VM takes the provider as an optional constructor dependency. Product paths (CLI
play, Godot) always supply one, so execution is effectively always-on. The provider is a genuine dependency, not a feature flag — you cannot run a script you cannot load. A VM constructed without a provider that then hitscall-scripthalts with a clear reason (it never happens on product paths).
2. Recursive frame execution
Per-script state moves out of instance fields into an ExecFrame:
Scriptbeing run, the localFrame(I/F/S/P slots), the intra-scriptcall/retstack, thepc, and the emit-seen loop-guard map.
The run loop becomes RunFrame(ExecFrame). Run(entry) builds the top frame and calls it.
call-script id → provider.GetById(Read(a[0])) → build a child ExecFrame (fresh locals, entry
pc 0) → RunFrame(child) → on return, the caller continues at pc + 1. Recursion (not an explicit
stack list) is chosen because depth is bounded (≤ 38), so there is no overflow risk and
exit-returns-to-caller falls out as a plain return from RunFrame.
Shared VM-level state stays on the instance: Globals, GlobalStrings, Emitted, Steps, _host,
_provider.
3. Semantics
- Shared globals = the return channel. A callee returns results by writing globals the caller reads
(the native shared-global model; no explicit return value). Local frames are per-call and discarded
on return (matches
GameSession). exit/exit-script: pop the current frame. Empty stack (top-level) → HALT; otherwise return to the caller at itscall-script+ 1. Only the outermostexithalts.ret(op 0x5): returns from an intra-scriptcall(0x8f) via the frame'scall-stack. When a script's top-level flow reachesretwith an emptycall-stack (e.g. BUNKI), it returns from the script frame (same asexit). This empty-stackretbehavior is confirmed against the native op 0x5 handler (ctx[0x26c93+5]=LAB_00417ad0) during implementation, not guessed.- Emit-seen is per-frame. The loop guard keys on string offset, and offsets are script-local
(MES
0x100≠ SC00000x100); a shared map would collide and falsely trip the cap on hot subroutines. It lives inExecFrame, reset per call. - Depth cap: cap recursion at ~38 frames; exceeding halts with a distinct reason (the native throws there). Guards runaway / mutual recursion.
- Steps: one monotonic counter across all frames (subroutine steps count toward it).
Emitted: aggregates all frames' text in execution order; each entry carries the source script's identity so mixed output is disambiguable in golden traces.- Dynamic ids: the operand is usually immediate but may be g-int/l-ptr;
Read(a[0])handles all types, so a computed target resolves through the same provider. IHost.CallScript(id)is kept as a fire-on-entry notification (diagnostics/logging); control flow is now VM-owned. Existing host implementations are unaffected.
Validation
vm0.py is retired from oracle duty. It served its purpose (prototyping the execution model and proving the C# port byte-identical across 297 scenes); it stays in the repo as a frozen historical reference and Python-side experiment tool but is no longer maintained in lockstep. Reimplementing call-script in it would be double work for a shrinking payoff (the C# engine now also has real-game ground truth vm0.py never had).
The C# engine owns its correctness fixtures:
- Golden traces regenerated from the C# engine and checked into the repo as the source of truth (replacing "match whatever vm0.py emits"). Scenes that call no subroutines are unchanged (and, for a cheap belt-and-suspenders check, still happen to match vm0.py — an optional narrowed parity test).
- New on-path checks:
- the corpus sweep (
Age.Cli sweep) still terminates cleanly with execution on (no new hangs / depth-cap trips across the corpus); - known subroutines execute and return — e.g. a scene calling MES/ADDITEM shows the subroutine's effects (emitted text / global writes) and control resumes after the call;
- the empty-stack
retandexit-returns-to-caller branches have unit tests with hand-built frames; - the new SC0000 opening trace (subroutines now executed) is validated by inspection against the real-game ground truth we hold (Frida load order, screenshots, by-ear audio).
- the corpus sweep (
Testing
- Unit (xUnit,
Age.Engine.Tests):- a fake in-memory
IScriptProviderreturning hand-builtScripts: caller → callee → return to caller atpc+1; calleeexitreturns (does not halt caller); nested depth; shared-global write-visible-to-caller; per-frame locals don't leak; depth-cap halt; missing-id halt. - empty-stack
retreturns from the frame (after the native-handler confirmation).
- a fake in-memory
- Integration:
Sys4ScriptProviderresolves real ids (0x1ab→ADDITEM, 0x2ae7→MES) and caches; a real scene that calls MES executes it. - Corpus:
sweepterminates with execution on; golden-trace fixtures regenerated + committed. - Regression: existing non-call-script scenes produce unchanged golden traces.
Risks / open items
- Empty-stack
retsemantics — resolved by decompilingLAB_00417ad0before relying on it. - Headless zero-state subroutines — with no seeded state some callees may take odd branches; the sweep-terminates check is the guard. Not a correctness bug in call-script itself (state divergence).
- Golden-trace churn — one-time regeneration; the diff is reviewed, not blindly accepted.
Files touched (anticipated)
Age.Engine/Hosting/IScriptProvider.cs(new),IHost.cs(unchanged;CallScriptkept as notify).Age.Engine/Vm/VirtualMachine.cs(ExecFrame refactor + call-script execution),Vm/Frame.cs(possiblyExecFrame),Vm/VmOptions.cs(depth cap constant).Age.Engine/Sys4/Sys4ScriptProvider.cs(new),Sys4/Paths.cs(callscript-names.json path if needed).Age.Cli/Program.cs(wire the provider intoplay/run/sweep), GodotGodotAdvHost/Main(supply the provider).Age.Engine.Tests/*(new call-script tests), regenerated golden fixtures.