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>
This commit is contained in:
gamer147
2026-07-07 12:44:51 -04:00
parent 14ad23b8c7
commit 3d88c13c72

View File

@@ -0,0 +1,149 @@
# 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`:
```csharp
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 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 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 `Script`s: 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.