Files
OpenMaidEngine/docs/superpowers/specs/2026-07-07-callscript-vm-execution-design.md
gamer147 3d88c13c72 Add design spec: call-script execution in the C# VM
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>
2026-07-07 12:44:51 -04:00

8.7 KiB
Raw Blame History

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); }
  • Sys4 implements Sys4ScriptProvider: id → build/callscript-names.json → name → Paths.Scripts()[name] → Sys4Loader.Load, cached by id (MES is called 59×; parse once). Reusing Paths.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 hits call-script halts 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:

  • Script being run, the local Frame (I/F/S/P slots), the intra-script call/ret stack, the pc, and the emit-seen loop-guard map.

The run loop becomes RunFrame(ExecFrame). Run(entry) builds the top frame and calls it. call-script idprovider.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 its call-script + 1. Only the outermost exit halts.
  • ret (op 0x5): returns from an intra-script call (0x8f) via the frame's call-stack. When a script's top-level flow reaches ret with an empty call-stack (e.g. BUNKI), it returns from the script frame (same as exit). This empty-stack ret behavior 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 ≠ SC0000 0x100); a shared map would collide and falsely trip the cap on hot subroutines. It lives in ExecFrame, 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 ret and exit-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).

Testing

  • Unit (xUnit, Age.Engine.Tests):
    • a fake in-memory IScriptProvider returning hand-built Scripts: caller → callee → return to caller at pc+1; callee exit returns (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 ret returns from the frame (after the native-handler confirmation).
  • Integration: Sys4ScriptProvider resolves real ids (0x1ab→ADDITEM, 0x2ae7→MES) and caches; a real scene that calls MES executes it.
  • Corpus: sweep terminates with execution on; golden-trace fixtures regenerated + committed.
  • Regression: existing non-call-script scenes produce unchanged golden traces.

Risks / open items

  • Empty-stack ret semantics — resolved by decompiling LAB_00417ad0 before 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; CallScript kept as notify).
  • Age.Engine/Vm/VirtualMachine.cs (ExecFrame refactor + call-script execution), Vm/Frame.cs (possibly ExecFrame), 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 into play/run/sweep), Godot GodotAdvHost/Main (supply the provider).
  • Age.Engine.Tests/* (new call-script tests), regenerated golden fixtures.