Merge feat/callscript-resolution: call-script resolution + C# VM execution

- 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>
This commit is contained in:
gamer147
2026-07-07 13:22:44 -04:00
24 changed files with 1348 additions and 85 deletions

View File

@@ -94,11 +94,54 @@ handler through the dispatch table (`ctx[0x26c93 + op]`). The raw VA is off by w
--- ---
### op `0x03` (`call-script`) is a raw index into the SYS4INI file table — SOLVED (2026-07-07)
The long-deferred `call-script <id>` registry (`name-resolution.md §1`) is cracked. Resolved through
the dispatch table (op `0x03` → `ctx[0x26c93+3]` = **`FUN_0041bc90`**), then the loader/resolver chain:
- **`FUN_0041bc90`** (handler): fetches operand 1 (the id), bounds-checks call depth (≤ 0x26), pushes
a script frame, and calls the loader.
- **`FUN_0040e980`** (loader): opens the resource by id, reads the **0x20-byte SYS4 header**, checks
magic, allocates per-frame code/local buffers from the header var-counts, reads the bytecode body,
and pushes a script frame (**stride 0x1e = 30 dwords**, indexed by `ctx[0x14f45]`). Returns to the
caller when the callee ends.
- **`FUN_0044f390`** (resolver — the key): `record = [ctx+0x414] + id*0x50`. The record is exactly the
**SYS4INI 80-byte layout** `{name[64], arc_id@0x40, file_number@0x44, offset@0x48, size@0x4c}`
(count = `[ctx+0x40c]`, archive-name table = `[ctx+0x410]`). It tries a **loose override first**
(`CreateFileA` on `record.name` → the mod/patch hook point), else opens archive
`[record.arc_id*0x100 + ctx+0x410]`, `SetFilePointer` to `record.offset`, size = `record.size`.
High-byte-tagged ids (`id & 0xff000000`) select an alternate pack via `[ctx+0x3028]` — **unused by
the corpus** (0/297 ids carry a high byte).
**So `call-script <id>` = a direct RAW index into the SYS4INI global file table** — the same table
`parse_sys4ini.py` reads, but indexed *without* skipping `@` placeholders (13208 records, 2
placeholders). There is **no separate on-disk id→code registry**; SYS4INI *is* the registry, and we
already had it. **Statically confirmed:** all **297/297** distinct corpus `call-script` ids resolve to
a `.BIN` script with a semantically-exact name (`0x1ab→ADDITEM`, `0x2ae7→MES`, `0x143→BUNKI`,
`0x329d→CALCREVISE`, `0x2add→CALCBTPARAM`), 0 out-of-range, 0 pack-branch. Tooling:
`parse_sys4ini.py` emits `build/callscript-names.json` (id→name); `sys4load` annotates
`call-script 0x1ab =ADDITEM.BIN`; the whole `build/disasm/*.asm` call graph now reads by name. See
`name-resolution.md §1`.
**Companion — op `0x8f` (`call`) is INTRA-script, not cross-script.** Its handler **`FUN_0041fba0`**
sets `[frame PC @+0x53d2c] = [frame codebase @+0x53d28] + operand*4` and pushes a return address on
the per-frame return stack (`[ctx+0x552e8]`/`[ctx+0x55248]`). The operand is a **code offset within
the current script** (matches header table **T3, tag 0x8F** = local call targets). So `0x8f` is a
local JSR; only `0x03` loads another script.
**Follow-up (functional):** the C# VM still *stubs* `call-script`. With the id→resource mapping now
known, it can be implemented for real (load the target `.BIN` from the archive via the SYS4INI record,
push a frame, run, return) — the unlock for subroutine-using scripts and, via the same path,
decision→scene (scenes are just `SCxxxx.BIN` records loaded by their SYS4INI index).
---
## Native walls backlog (targets for this loop) ## Native walls backlog (targets for this loop)
- **decision→scene** — how `0x62ccf`/the decision actually selects the next `SCxxxx` (re-aimed away - ~~**call-script dispatch**~~ — **SOLVED** (above): `call-script <id>` = raw SYS4INI file index.
from `u00428010`; likely call-script-adjacent). - **decision→scene** — how `0x62ccf`/the decision selects the next `SCxxxx`. Now narrower: scenes load
- **call-script dispatch** — `call-script <id>` → engine entry point (`name-resolution.md §1`). via `call-script`/the same SYS4INI-index loader, so the open question is only where the decision
value is turned into a scene *id* (a caller of SCJUMP; re-aimed away from `u00428010`).
- **op `0x60`** (`u0041A270`) — the rand-like value gating 1732/1755 SCJUMP decisions. - **op `0x60`** (`u0041A270`) — the rand-like value gating 1732/1755 SCJUMP decisions.
- **gfx command-buffer** — the `0x2120x21a` positioned-object subsystem (`scjump`-unrelated; the - **gfx command-buffer** — the `0x2120x21a` positioned-object subsystem (`scjump`-unrelated; the
rendering drift). rendering drift).

View File

@@ -12,10 +12,25 @@ would make it read like source.
--- ---
## #1 — `call-script` target resolution (naming the call graph) ## #1 — `call-script` target resolution (naming the call graph) — ✅ SOLVED (2026-07-07)
**What it is.** `call-script N` (Kelebek opcode 0x03) carries a bare number — `0x329d`, **RESOLVED via native-RE.** `call-script <id>` is a **direct RAW index into the SYS4INI file table**
`0x2ade` — the id of an engine entry point. To render `call RECOVER` instead of the very asset index we already parsed. No hidden engine registry: SYS4INI *is* the registry. Cracked
by decompiling the handler chain in Ghidra (op 0x03 → `FUN_0041bc90` → loader `FUN_0040e980`
resolver `FUN_0044f390`, which does `record = table_base + id*0x50` over the 80-byte SYS4INI records).
**Statically confirmed:** all 297/297 distinct corpus `call-script` ids resolve to a `.BIN` script with
a semantically-exact name (`0x1ab→ADDITEM`, `0x2ae7→MES`, `0x143→BUNKI`), 0 out-of-range. Full
mechanism in `engine-re.md` (“op 0x03 (call-script)…”). Tooling: `parse_sys4ini.py`
`build/callscript-names.json` (id→name); `sys4load` renders `call-script 0x1ab =ADDITEM.BIN`; the
`build/disasm/*.asm` call graph now reads by name. The one caveat: index the RAW SYS4INI records
(*including* the 2 `@` placeholders) — `asset-index.json` carries each entry's `raw_index` (= the id)
for exactly this. **Remaining (functional, not naming):** the C# VM still stubs `call-script`
execution; implementing it (load `.BIN` by id, push frame, run, return) is the follow-up. The original
analysis (kept below for provenance) had concluded this was engine-level and deferred — it was, and
the Ghidra loop is what resolved it.
**What it is (original framing).** `call-script N` (Kelebek opcode 0x03) carries a bare number —
`0x329d`, `0x2ade` — the id of an engine entry point. To render `call RECOVER` instead of
`call-script 0x329d` you need a table `id → (script, entry)`. `call-script 0x329d` you need a table `id → (script, entry)`.
**Findings (inspected 2026-07-06):** **Findings (inspected 2026-07-06):**
@@ -40,16 +55,18 @@ on disk to read. Resolving it needs one of:
- **Find the registration path** — if a boot script assigns ids to entry points, extract it - **Find the registration path** — if a boot script assigns ids to entry points, extract it
statically (SYSTEM4.BIN is far too small to hold ~13k, so it's cumulative or lives in AGE.EXE). statically (SYSTEM4.BIN is far too small to hold ~13k, so it's cumulative or lives in AGE.EXE).
**Status: deferred.** Not the quick win first assumed. Belongs with the engine/dispatch work **Status: ✅ SOLVED** (see the banner at the top of this section). It did belong with the
(Phase 3), or a dedicated `SCJUMP.BIN` reverse. Until then `call-script` stays numeric. engine/dispatch work — the Ghidra + MCP loop resolved it via the opcode-dispatch table.
**Update (2026-07-07):** SCJUMP's *decision logic* is now decoded — `(chapter_mode, guards) → **Update (2026-07-07):** SCJUMP's *decision logic* is now decoded — `(chapter_mode, guards) →
decision value` — see `docs/scjump-progression.md` and `tools/scjump_decode.py`. That confirmed decision value` — see `docs/scjump-progression.md` and `tools/scjump_decode.py`. That confirmed
SCJUMP is not the `call-script` registry (it produces a decision value, not a script id). The SCJUMP is not the `call-script` registry (it produces a decision value, not a script id). Then the
decision→scene hop is **native and still unidentified** — an earlier guess that op `u00428010` Ghidra + MCP loop **cracked `call-script` itself** (the SOLVED banner above): via the opcode-dispatch
resolved it was **disproven via Ghidra** (that op is a graphics command-buffer op; see table it walked the handler → loader → resolver and found the id is a raw SYS4INI file index. What
`docs/engine-re.md`). It's the same engine-level bucket as `call-script`; the Ghidra + MCP loop (and remains of the earlier `decision→scene` question is now narrow: scenes are `SCxxxx.BIN` records loaded
its recovered opcode-dispatch table) is now the tool to crack the `call-script`/script-load handler. through the *same* id-indexed loader, so the only open piece is where the SCJUMP decision *value*
becomes a scene *id* (a caller of SCJUMP). The `u00428010` guess for that hop was disproven via Ghidra
(it's a graphics command-buffer op; see `docs/engine-re.md`).
--- ---
@@ -172,7 +189,7 @@ automatically (it reads `build/global-var-map.json` at load).
## How the two relate ## How the two relate
#1 names **functions** (the call graph); #2 names **data** (game state). In `RECOVER`, #1 turns #1 names **functions** (the call graph); #2 names **data** (game state). In `RECOVER`, #1 turns
`call-script 0x329d` into a name; #2 turns `C[unit][s] = E[unit][s]` into `unit.hp[s] = `call-script 0x329d` into `CALCREVISE.BIN`; #2 turns `C[unit][s] = E[unit][s]` into `unit.hp[s] =
unit.maxHp[s]`. Priority reversal from the first guess: **#2 is the tractable readability lever unit.maxHp[s]`. Both are now largely in hand: **#1 is SOLVED** (the SYS4INI-index dispatch reverse —
now** (static handholds already half-built via the `*INIT` extraction); **#1 needs the engine** turned out to need the engine, and the Ghidra loop delivered it), and **#2 has a partial static map**
(dispatch reverse or Frida) and is deferred. (the `*INIT` handholds) that grows on demand.

View File

@@ -27,6 +27,36 @@
- **grounding:** source=investigation, confidence=high - **grounding:** source=investigation, confidence=high
- **evidence:** By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug. - **evidence:** By-ear confirmed (2026-07-06): SC0000 prologue voices play on their lines via Godot AudioStreamPlayer. Off-by-one disproven structurally: manifest interleaves graphics/voice (files[35]=EV049AA, [36]=MAN999, [37]=EV052CA, [38]=SYL0001), so files[base+id] lands voices on OGGs while files[base+id-1] would land them on .AGF graphics (silent) -- and they play, so the offset is exactly 0. Lily's lines are correctly form-gated (G[0xa57/0xa58/0xa59]) and stay silent when no form flag is seeded -- not a bug.
## control
### 0x3 `call-script` (call-script, argc 1)
- **summary:** load & call another SYS4 script by id; id = RAW index into the SYS4INI file table (asset-index). Pushes a script frame; returns to caller when the callee ends.
- **grounding:** source=investigation, confidence=high
- **evidence:** native-RE (Ghidra): handler FUN_0041bc90 -> loader FUN_0040e980 -> resolver FUN_0044f390 indexes an 80-byte record table (base [ctx+0x414], count [ctx+0x40c]) at base+id*0x50 = the SYS4INI record layout {name[64],arc_id@0x40,file_number@0x44,offset@0x48,size@0x4c}. Confirmed statically: 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 pack-branch. See docs/engine-re.md + name-resolution.md #1.
op 0x03 (call-script, argc 1): `call-script <id>`. RESOLVED — the id is a direct RAW index into
the SYS4INI global file table (the same table parse_sys4ini.py reads, but indexed WITHOUT skipping
'@' placeholders; SYS4INI has 13208 records / 2 placeholders). No separate on-disk id->code registry
exists; SYS4INI *is* the call-script registry.
Native mechanism (dispatch table `handler(op)=ctx[0x26c93+op]`, op 0x03 -> FUN_0041bc90):
1. FUN_0041bc90 fetches operand 1 (id), bounds-checks call depth (<=0x26), pushes a frame.
2. FUN_0040e980 (loader): opens the resource by id, reads the 0x20-byte SYS4 header, checks magic,
allocates per-frame code/local buffers from the header var-counts, reads the bytecode body,
pushes a script frame (stride 0x1e = 30 dwords, indexed by ctx[0x14f45]).
3. FUN_0044f390 (resolver): record = [ctx+0x414] + id*0x50. Tries a LOOSE OVERRIDE first
(CreateFileA on record.name -> mod/patch hook point), else opens archive [record.arc_id*0x100 +
ctx+0x410], SetFilePointer to record.offset, size = record.size.
(High-byte-tagged ids `id & 0xff000000` select an alternate pack via [ctx+0x3028]; UNUSED by the
corpus -- 0/297 ids have a high byte.)
Companion op 0x8f `call` is INTRA-script (a local JSR), not cross-script -- see its entry.
This also names the whole call graph statically (build/callscript-names.json).
### 0x8f `call` (call, argc 1)
- **summary:** intra-script subroutine call (local JSR): PC = frame.codebase + operand*4; pushes a return address on the per-frame return stack. NOT cross-script (that is call-script 0x03).
- **grounding:** source=investigation, confidence=high
- **evidence:** native-RE (Ghidra): handler FUN_0041fba0 (= ctx[0x26c93+0x8f]) sets [frame PC @+0x53d2c] = [frame codebase @+0x53d28] + operand*4 and pushes ((pc-base)>>2)+3 onto the per-frame return stack ([ctx+0x552e8]/[ctx+0x55248]). Target is a code OFFSET within the current script (matches header table T3 tag 0x8F = local call targets), confirming it is a local JSR, not a script load.
## draw ## draw
### 0x1a2 `gfx-cmd-register` (u00428010, argc 1) ### 0x1a2 `gfx-cmd-register` (u00428010, argc 1)
@@ -179,10 +209,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=med - **grounding:** source=kelebek, confidence=med
### 0x3 `call-script` (call-script, argc 1)
- **summary:** —
- **grounding:** source=kelebek, confidence=med
### 0x5 `ret` (ret, argc 0) ### 0x5 `ret` (ret, argc 0)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=med - **grounding:** source=kelebek, confidence=med
@@ -375,10 +401,6 @@ op 0x90 (u0041BEB0, argc 7): `0x90 x y w h tgt_a tgt_b tgt_c`. Kelebek left it "
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=med - **grounding:** source=kelebek, confidence=med
### 0x8f `call` (call, argc 1)
- **summary:** —
- **grounding:** source=kelebek, confidence=med
### 0x93 `u00415040` (u00415040, argc 0) ### 0x93 `u00415040` (u00415040, argc 0)
- **summary:** — - **summary:** —
- **grounding:** source=kelebek, confidence=low - **grounding:** source=kelebek, confidence=low

View File

@@ -307,3 +307,44 @@ is only needed for scenes that genuinely use runtime-positioned sprites — revi
Build `tools/vm0.py` and get the **RECOVER unit test** green (pointer/array/control-flow correctness), Build `tools/vm0.py` and get the **RECOVER unit test** green (pointer/array/control-flow correctness),
then run the first linear ADV scene against the dialogue oracle. That single result tells us whether then run the first linear ADV scene against the dialogue oracle. That single result tells us whether
the whole VM approach executes correctly — the load-bearing question behind option 3. the whole VM approach executes correctly — the load-bearing question behind option 3.
---
## ✅ call-script EXECUTION (2026-07-07) — subroutines now run in the C# VM
Spec `docs/superpowers/specs/2026-07-07-callscript-vm-execution-design.md`; plan
`docs/superpowers/plans/2026-07-07-callscript-vm-execution.md`. Enabled by the native-RE finding that
`call-script <id>` is a raw SYS4INI file index (`docs/engine-re.md`).
**Shipped (engine 25/25 green):**
- **`IScriptProvider`** (Hosting) + **`Sys4ScriptProvider`** (Sys4): `id → build/callscript-names.json →
name → Paths.Scripts() → Sys4Loader.Load`, cached. Injected into the VM so `Vm` never references `Sys4`.
- **`ExecFrame` refactor** of `VirtualMachine`: per-script state (script, pc, locals, intra-call stack,
per-frame emit-guard) moved into `ExecFrame`, run by a recursive `RunFrame`. Globals/Emitted/Steps stay
VM-level (shared). Emitted lines now carry their source script name.
- **`call-script` executes:** loads the child, runs it as a nested frame sharing globals, returns to the
caller at `pc+1`. `exit`/`exit-script` and empty-stack `ret` return from the frame (top frame → HALT).
Depth-capped (`VmOptions.CallDepthCap=64`; native limit 38). Shared globals are the return channel;
per-call locals are discarded on return.
- **Product paths** (`Age.Cli run/play/sweep`, `GameSession.RunScene`) inject the provider. `trace` +
the `audio`/`gfx` diagnostics stay provider-less (base-ISA oracle / built against stub behavior).
**Design decision (refines the spec):** a VM with **no provider** falls back to the prior stub
(`host.CallScript(id); pc+1`), not a halt. This keeps every existing base-ISA test byte-identical
(they construct provider-less VMs) and needs no golden-fixture regeneration; **vm0.py retires from
oracle duty gracefully** — `trace`/`TraceDiffTests`/`WaitForInputTests` stay on the stub path as the
base-ISA guard, with zero lockstep maintenance.
**Validation:** 6 new tests (fake-provider unit tests: return-to-caller, callee `exit` returns not
halts, shared-global visibility, per-frame local isolation, unresolved-id halt, no-provider stub;
integration: ADDILL executes ADDILLSUB+CALCREVISE and reaches its own exit; BUNKI's top-level `ret`
returns cleanly). **Corpus sweep (execution on): 284/297 exit clean, 13 STEP-LIMIT, 0 depth-cap, 0
unresolved, 0 crashes.** The 13 STEP-LIMITs are input/state-gated ADV scenes (SC0000 etc.): executing
subroutines makes their global-writes drive caller loops that headless can't break (no input;
`WaitForInput` is a no-op) — the known state-divergence, not a call-script bug (loops hit STEP-LIMIT,
not the depth cap → recursion is bounded correctly).
**Follow-ups (out of this slice):** wire the provider into the Godot play path (keep `--selftest`
provider-less to preserve the 186-offset gate); optionally give the `audio`/`gfx` diagnostics a
provider once their stub-era baselines are revisited; `decision→scene` (scene chaining) rides this same
loader once the SCJUMP decision→scene-id native hop is reversed.

View File

@@ -95,6 +95,25 @@ outside scenes lives.
--- ---
## Call graph — scripts are addressable by `call-script <id>` (2026-07-07)
`call-script <id>` (opcode 0x03) loads another script by a **raw index into the SYS4INI file table**
(id = the entry's `raw_index` = its global position in SYS4INI). This is the resolved call-graph
registry — there is no separate id→code table; SYS4INI is it. Mechanism: `engine-re.md` (op 0x03
section); id→name single source: `build/callscript-names.json` (from `parse_sys4ini.py`); `sys4load`
and the regenerated `build/disasm/*.asm` corpus now render targets by name
(`call-script 0x1ab =ADDITEM.BIN`). **297 distinct scripts are called** across the corpus (3002 sites);
the hottest are `HISTORY` (backlog), `MENU`, `HIDEWIN`, `BUNKI` (branch), `MES` (message), `ADDITEM`,
`ADDEN`, `LOOK`, `RENDERMAP`. Scenes (`SCxxxx.BIN`) load through the *same* id-indexed loader.
**Living-reference decision:** no separate generated markdown call-script reference is kept. Unlike
`opcode-reference.md` / `global-reference.md` (rendered from *curated* knowledge bases), the id→name
mapping is purely mechanical (SYS4INI index → filename) with no semantics to curate — it already lives
in the build artifact and in the named disasm corpus. Full call-graph edges (caller→callee counts) are
derivable on demand from the corpus; materialize a doc only if a consumer needs it.
---
## Implications for the port ## Implications for the port
1. **Much more game logic lives in bytecode than expected.** Damage formulas 1. **Much more game logic lives in bytecode than expected.** Damage formulas

View File

@@ -0,0 +1,644 @@
# call-script Execution in the C# VM — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make `call-script <id>` actually load and execute the target `.BIN` as a nested subroutine that shares the global bank and returns to its caller.
**Architecture:** Introduce an `IScriptProvider` seam (implemented in `Sys4`, injected into the VM so `Vm` never references `Sys4`) that maps a call-script id (a raw SYS4INI file index) to a loaded `Script`. Refactor the VM's per-script state into an `ExecFrame` and run scripts through a recursive `RunFrame`; `call-script` recurses into a child frame. Recursion is safe because call depth is bounded (native ≤ 38).
**Tech Stack:** C# / .NET 8, xUnit. Solution `engine/AgeEngine.sln` (projects `Age.Engine`, `Age.Cli`, `Age.Engine.Tests`).
## Global Constraints
- Run tests with: `dotnet test engine/AgeEngine.sln` (from `age-reimpl/`).
- Seam rule: `Vm` code references only `Model` + `Hosting` namespaces, never `Sys4`. (One assembly; enforced by convention.)
- `call-script <id>`: `id` is a **raw index into the SYS4INI file table** (`build/callscript-names.json` maps id→name; `Paths.Scripts()` maps name→path). Confirmed: all 297 corpus ids resolve to a DATA1 `.BIN`. See `docs/engine-re.md`.
- **Deviation from spec (deliberate):** a VM with **no** `IScriptProvider` that hits `call-script` falls back to the **prior stub** (`_host.CallScript(id); pc+1`), NOT a halt. This keeps every existing base-ISA test byte-identical (they construct provider-less VMs) and avoids golden-fixture churn. Product paths always inject a provider, so execution is the real behavior where it matters.
- Frequent commits: one per task minimum.
---
### Task 1: Script-provider seam + resolver
**Files:**
- Create: `engine/Age.Engine/Hosting/IScriptProvider.cs`
- Create: `engine/Age.Engine/Sys4/Sys4ScriptProvider.cs`
- Modify: `engine/Age.Engine/Sys4/Paths.cs` (add `CallscriptNamesJson`)
- Test: `engine/Age.Engine.Tests/Sys4ScriptProviderTests.cs`
**Interfaces:**
- Produces: `interface IScriptProvider { Script? GetById(long id); }` (namespace `Age.Engine.Hosting`).
- Produces: `Sys4ScriptProvider.Load(OpcodeTable table) -> Sys4ScriptProvider`; instance `GetById(long id) -> Script?` (cached; unknown id → null).
- Produces: `Paths.CallscriptNamesJson -> string`.
- Consumes: existing `OpcodeTable`, `Sys4Loader.Load(string, OpcodeTable)`, `Paths.Scripts()`, `Paths.Build`.
- [ ] **Step 1: Add the path constant**
In `engine/Age.Engine/Sys4/Paths.cs`, after the `AssetIndexJson` line (line 12), add:
```csharp
public static string CallscriptNamesJson => Path.Combine(Build, "callscript-names.json");
```
- [ ] **Step 2: Create the provider interface**
Create `engine/Age.Engine/Hosting/IScriptProvider.cs`:
```csharp
using Age.Engine.Model;
namespace Age.Engine.Hosting;
/// <summary>Resolves a call-script id (a raw SYS4INI file index) to a loaded <see cref="Script"/>.
/// Implemented in Sys4; injected into the VM so the Vm layer never references Sys4.</summary>
public interface IScriptProvider
{
/// <summary>The script for this id, or null if the id maps to no known script.</summary>
Script? GetById(long id);
}
```
- [ ] **Step 3: Write the failing test**
Create `engine/Age.Engine.Tests/Sys4ScriptProviderTests.cs`:
```csharp
using Age.Engine.Sys4;
using Xunit;
public class Sys4ScriptProviderTests
{
[Fact]
public void ResolvesKnownIdsToTheirScripts()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var provider = Sys4ScriptProvider.Load(table);
var additem = provider.GetById(0x1ab); // ADDITEM.BIN
var mes = provider.GetById(0x2ae7); // MES.BIN
Assert.NotNull(additem);
Assert.NotNull(mes);
Assert.True(additem!.Instructions.Count > 0);
Assert.Same(additem, provider.GetById(0x1ab)); // cached: same instance
Assert.Null(provider.GetById(long.MaxValue)); // unknown id
}
}
```
- [ ] **Step 4: Run the test to verify it fails**
Run: `dotnet test engine/AgeEngine.sln --filter Sys4ScriptProviderTests`
Expected: FAIL (compile error — `Sys4ScriptProvider` does not exist).
- [ ] **Step 5: Implement the provider**
Create `engine/Age.Engine/Sys4/Sys4ScriptProvider.cs`:
```csharp
using System.Text.Json;
using Age.Engine.Hosting;
using Age.Engine.Model;
namespace Age.Engine.Sys4;
/// <summary>Resolves call-script ids (raw SYS4INI file indices) to loaded scripts, using
/// build/callscript-names.json (id→name) + Paths.Scripts() (name→path). Cached per id.
/// The native resolver prefers a loose override before the archive; Paths.Scripts() already
/// shadows extracted/DATA1 with root overrides, so that behavior is preserved.</summary>
public sealed class Sys4ScriptProvider : IScriptProvider
{
private readonly OpcodeTable _table;
private readonly IReadOnlyDictionary<long, string> _idToName;
private readonly Dictionary<string, string> _byName; // NAME(UPPER) -> path
private readonly Dictionary<long, Script?> _cache = new();
public Sys4ScriptProvider(OpcodeTable table, IReadOnlyDictionary<long, string> idToName,
Dictionary<string, string> byName)
{ _table = table; _idToName = idToName; _byName = byName; }
public static Sys4ScriptProvider Load(OpcodeTable table)
{
var raw = JsonSerializer.Deserialize<Dictionary<string, string>>(
File.ReadAllText(Paths.CallscriptNamesJson)) ?? new();
var idToName = raw.ToDictionary(kv => long.Parse(kv.Key), kv => kv.Value);
return new Sys4ScriptProvider(table, idToName, Paths.Scripts());
}
public Script? GetById(long id)
{
if (_cache.TryGetValue(id, out var cached)) return cached;
Script? s = null;
if (_idToName.TryGetValue(id, out var name) &&
_byName.TryGetValue(name.ToUpperInvariant(), out var path))
s = Sys4Loader.Load(path, _table);
_cache[id] = s;
return s;
}
}
```
- [ ] **Step 6: Run the test to verify it passes**
Run: `dotnet test engine/AgeEngine.sln --filter Sys4ScriptProviderTests`
Expected: PASS. (Prerequisite: `build/callscript-names.json` exists — regenerate with `py -3.11 -X utf8 tools/parse_sys4ini.py` if missing.)
- [ ] **Step 7: Commit**
```bash
git add engine/Age.Engine/Hosting/IScriptProvider.cs engine/Age.Engine/Sys4/Sys4ScriptProvider.cs engine/Age.Engine/Sys4/Paths.cs engine/Age.Engine.Tests/Sys4ScriptProviderTests.cs
git commit -m "Add IScriptProvider + Sys4ScriptProvider (call-script id -> Script)"
```
---
### Task 2: ExecFrame refactor (no behavior change) + emitted script identity
Refactor the VM's per-script state into an `ExecFrame` run by a recursive `RunFrame`, and tag each emitted line with its source script. call-script stays a stub in this task. All existing tests must stay green (emitted offsets/counts/halt unchanged).
**Files:**
- Create: `engine/Age.Engine/Vm/ExecFrame.cs`
- Modify: `engine/Age.Engine/Model/Script.cs` (add `Name`)
- Modify: `engine/Age.Engine/Sys4/Sys4Loader.cs` (set `Name`)
- Modify: `engine/Age.Engine/Vm/VirtualMachine.cs` (the refactor + emitted tuple)
- Modify: `engine/Age.Engine/Vm/GameSession.cs` (SceneResult tuple)
- Modify: `engine/Age.Cli/Program.cs:17` (destructure fix)
**Interfaces:**
- Produces: `Script.Name` (string, defaults `""`).
- Produces: `VirtualMachine.Emitted` becomes `List<(int Offset, string Text, string Script)>`.
- Produces (internal): `ExecFrame { Model.Script Script; int Pc; Frame Locals; List<int> CallStack; Dictionary<int,int> EmitSeen; }`.
- Consumes: `IScriptProvider` (Task 1) — added to the constructor but unused until Task 3.
- [ ] **Step 1: Add `Name` to the Script model**
In `engine/Age.Engine/Model/Script.cs`, add a property (keep existing members):
```csharp
public string Name { get; init; } = "";
```
- [ ] **Step 2: Set `Name` in the loader**
In `engine/Age.Engine/Sys4/Sys4Loader.cs`, in `Parse`, change the return (line ~25) to include the name:
```csharp
return new Script { Name = name, Header = header, Instructions = instrs, IndexByOffset = idxByOff, Strings = strings };
```
- [ ] **Step 3: Create ExecFrame**
Create `engine/Age.Engine/Vm/ExecFrame.cs`:
```csharp
using Age.Engine.Model;
namespace Age.Engine.Vm;
/// <summary>One script activation: the running script, its instruction cursor, its local slots,
/// its intra-script call/ret stack, and its per-script loop-guard map. Globals live on the VM and
/// are shared across frames; everything here is per-call and discarded on return.</summary>
internal sealed class ExecFrame
{
public readonly Script Script;
public int Pc; // entry instruction index
public readonly Frame Locals = new();
public readonly List<int> CallStack = new(); // intra-script `call` (op 0x8f) returns
public readonly Dictionary<int, int> EmitSeen = new();
public ExecFrame(Script script, int pc) { Script = script; Pc = pc; }
}
```
- [ ] **Step 4: Refactor VirtualMachine to frames**
In `engine/Age.Engine/Vm/VirtualMachine.cs`:
(a) Add the constructor provider parameter and sentinels/fields. Replace the fields block + constructor (lines 727) so it reads:
```csharp
private const long NoJump = 0xFFFFFFFF;
private const int HALT = int.MinValue;
private const int FRAME_RETURN = int.MinValue + 1;
private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6,
T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12;
private readonly Script _s;
private readonly OpcodeTable _t;
private readonly IHost _host;
private readonly VmOptions _o;
private readonly IScriptProvider? _provider;
private ExecFrame _cur = null!;
private int _depth;
private bool _halted;
public Dictionary<int, long> Globals { get; } = new();
public Dictionary<int, string> GlobalStrings { get; } = new();
public List<(int Offset, string Text, string Script)> Emitted { get; } = new();
public string? HaltReason { get; private set; }
public long Steps { get; private set; }
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, IScriptProvider? provider = null)
{ _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider; }
```
(b) Replace `Run` (lines 94106) with:
```csharp
private enum FrameOutcome { Returned, Halted, RanOff }
public void Run(int entryOffset = 0)
{
var top = new ExecFrame(_s, _s.IndexByOffset.TryGetValue(entryOffset, out var idx) ? idx : 0);
var outcome = RunFrame(top);
if (outcome == FrameOutcome.RanOff) HaltReason ??= "pc-out-of-range";
else if (outcome == FrameOutcome.Returned) HaltReason ??= "exit";
// Halted: HaltReason already set by the halting op.
}
private FrameOutcome RunFrame(ExecFrame frame)
{
var prev = _cur; _cur = frame; _depth++;
var outcome = FrameOutcome.RanOff;
int pc = frame.Pc;
while (pc >= 0 && pc < frame.Script.Instructions.Count)
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; _halted = true; outcome = FrameOutcome.Halted; break; }
Steps++;
int next = Step(frame.Script.Instructions[pc], pc);
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
if (next == HALT) { _halted = true; outcome = FrameOutcome.Halted; break; }
pc = next;
}
_cur = prev; _depth--;
return outcome;
}
```
(c) Repoint the helpers from `_fr`/`_s` to the current frame. Change every `_fr` to `_cur.Locals` in `Read`, `Write`, `ReadStr`, `WriteStr`, `BaseAddr`, `LookupStore` (the fields `_fr.I/F/S/P` become `_cur.Locals.I/F/S/P`).
(d) In `Step`, repoint frame-scoped state: `_callstack``_cur.CallStack`; `_emitSeen``_cur.EmitSeen`; `_s.IndexByOffset``_cur.Script.IndexByOffset`; `_s.GetString``_cur.Script.GetString`. Specifically:
- `jmp`: `return _cur.Script.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1);`
- `call`: `_cur.CallStack.Add(pc + 1); return _cur.Script.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1);`
- `ret`: `if (_cur.CallStack.Count > 0) { int r = _cur.CallStack[^1]; _cur.CallStack.RemoveAt(_cur.CallStack.Count - 1); return r; } return FRAME_RETURN;`
- `jcc`: `return tgt == NoJump ? pc + 1 : _cur.Script.IndexByOffset.GetValueOrDefault((int)tgt, pc + 1);`
- `exit` / `exit-script`: `return FRAME_RETURN;`
- `show-text` body: `_cur.EmitSeen.TryGetValue(off, out var c); c++; _cur.EmitSeen[off] = c;` then on cap `HaltReason = $"LOOP:line@0x{off:x}×{c}"; return HALT;`, and `string text = _cur.Script.GetString(off); Emitted.Add((off, text, _cur.Script.Name)); _host.ShowText(off, text);`
- `call-script`: leave the stub for now — `case "call-script": _host.CallScript(a.Count > 0 ? Read(a[0]) : 0); return pc + 1;`
- [ ] **Step 5: Fix the SceneResult tuple**
In `engine/Age.Engine/Vm/GameSession.cs` line 67, change the record to:
```csharp
public sealed record SceneResult(IReadOnlyList<(int Offset, string Text, string Script)> Emitted, string? Halt, long Steps);
```
- [ ] **Step 6: Fix the one positional destructure**
In `engine/Age.Cli/Program.cs` line 17, change:
```csharp
foreach (var (off, text, _) in vm.Emitted.Take(20)) Console.WriteLine($" [{off:x}] {text}");
```
- [ ] **Step 7: Run the full suite to verify no behavior change**
Run: `dotnet test engine/AgeEngine.sln`
Expected: PASS — all existing tests green. `WaitForInputTests` still asserts SC0000 = 186 (provider-less = stub), `RecoverTests`, `TextureOpsTests`, `GameSessionTests` unchanged. (`TraceDiffTests` passes if `build/vm0-trace.json` is present; it is skipped otherwise.)
- [ ] **Step 8: Commit**
```bash
git add engine/Age.Engine/Vm/ExecFrame.cs engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine/Vm/GameSession.cs engine/Age.Engine/Model/Script.cs engine/Age.Engine/Sys4/Sys4Loader.cs engine/Age.Cli/Program.cs
git commit -m "Refactor VM to ExecFrame + tag emitted lines with source script (no behavior change)"
```
---
### Task 3: call-script execution
Wire the provider into `call-script`: load the child script, run it as a nested frame sharing globals, return to the caller. `exit`/empty-stack `ret` return from the frame; only the top frame's return ends the VM. Depth-capped.
**Files:**
- Modify: `engine/Age.Engine/Vm/VirtualMachine.cs` (the `call-script` case)
- Modify: `engine/Age.Engine/Vm/VmOptions.cs` (depth cap)
- Test: `engine/Age.Engine.Tests/CallScriptTests.cs`
**Interfaces:**
- Consumes: `IScriptProvider.GetById` (Task 1), `ExecFrame`, `RunFrame`, `FrameOutcome` (Task 2).
- Produces: nested execution semantics (below) exercised by later tasks.
- [ ] **Step 1: Add the depth-cap option**
In `engine/Age.Engine/Vm/VmOptions.cs`:
```csharp
namespace Age.Engine.Vm;
public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64);
```
(64 is a generous runaway guard; the native limit is 38.)
- [ ] **Step 2: Write the failing tests**
Create `engine/Age.Engine.Tests/CallScriptTests.cs`. It uses a fake in-memory provider and hand-built scripts.
```csharp
using System.Collections.Generic;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class CallScriptTests
{
private sealed class NullHost : IHost
{
public List<long> Calls = new();
public void ShowText(int o, string t) { }
public void CallScript(long id) => Calls.Add(id);
public void OnStub(int op) { }
public void WaitForInput() { }
public void CreateTexture(int s, int w, int h) { }
public void SetTexture(long r, int s) { }
public void DrawTexture(int s, int sx, int sy, int w, int h, int dx, int dy) { }
public (int Width, int Height) GetTextureSize(int s) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}
private sealed class MapProvider : IScriptProvider
{
private readonly Dictionary<long, Script> _m;
public MapProvider(Dictionary<long, Script> m) => _m = m;
public Script? GetById(long id) => _m.TryGetValue(id, out var s) ? s : null;
}
// Build a Script from raw dwords via the real loader (guarantees identical decode).
private static Script Asm(OpcodeTable t, string name, params uint[] body)
{
var bytes = new byte[0x3C + body.Length * 4];
System.Text.Encoding.ASCII.GetBytes("SYS4422 ").CopyTo(bytes, 0);
// fields[8] (F8 = code end) at header offset 8 + 8*4 = 0x28; set to body length (all code).
System.BitConverter.GetBytes(body.Length).CopyTo(bytes, 8 + 8 * 4);
for (int i = 0; i < body.Length; i++) System.BitConverter.GetBytes(body[i]).CopyTo(bytes, 0x3C + i * 4);
return Sys4Loader.Parse(bytes, t, name);
}
private static OpcodeTable Table() => OpcodeTableJson.Load(Paths.OpcodesJson);
[Fact]
public void CalleeRunsAndControlResumesAfterTheCall()
{
var t = Table();
// Callee (id 5): set global 0x10 = 7, then exit (op 0x0).
var callee = Asm(t, "CALLEE", 0x55, 3, 0x10, 0, 7, 0x0); // mov g[0x10]=7 ; exit
// Caller: call-script 5 ; set g[0x11]=g[0x10]+1 ; exit.
var caller = Asm(t, "CALLER",
0x03, 0, 5, // call-script 5
0x55, 3, 0x11, 3, 0x10, // mov g[0x11] = g[0x10] (see note below)
0x0); // exit
var host = new NullHost();
var vm = new VirtualMachine(caller, t, host, null, new MapProvider(new() { [5] = callee }));
vm.Run();
Assert.Equal(7, vm.Globals[0x10]); // callee wrote a shared global
Assert.Equal(7, vm.Globals[0x11]); // caller read it AFTER the call returned
Assert.Equal("exit", vm.HaltReason); // top-level exit
Assert.Contains(5L, host.Calls); // host notified
}
[Fact]
public void MissingProviderFallsBackToStub()
{
var t = Table();
var caller = Asm(t, "CALLER", 0x03, 0, 5, 0x0); // call-script 5 ; exit
var host = new NullHost();
var vm = new VirtualMachine(caller, t, host, null, null); // no provider
vm.Run();
Assert.Equal("exit", vm.HaltReason); // did not halt on the call; stub + continue
Assert.Contains(5L, host.Calls);
}
[Fact]
public void UnresolvedIdHalts()
{
var t = Table();
var caller = Asm(t, "CALLER", 0x03, 0, 99, 0x0);
var vm = new VirtualMachine(caller, t, new NullHost(), null, new MapProvider(new()));
vm.Run();
Assert.StartsWith("callscript-unresolved", vm.HaltReason);
}
[Fact]
public void LocalsDoNotLeakBetweenCallerAndCallee()
{
var t = Table();
// Callee writes LOCAL-int 0 = 42 (op 0x55 to local-int, type 9), then exit.
var callee = Asm(t, "CALLEE", 0x55, 9, 0, 0, 42, 0x0);
// Caller sets local-int 0 = 1, calls, then copies its own local-int 0 to global 0x20.
var caller = Asm(t, "CALLER",
0x55, 9, 0, 0, 1, // l[0] = 1
0x03, 0, 5, // call-script 5 (callee sets ITS local 0 = 42)
0x55, 3, 0x20, 9, 0, // g[0x20] = l[0]
0x0);
var vm = new VirtualMachine(caller, t, new NullHost(), null, new MapProvider(new() { [5] = callee }));
vm.Run();
Assert.Equal(1, vm.Globals[0x20]); // caller's local 0 unchanged by callee's local 0
}
}
```
Note on `mov g[0x11] = g[0x10]`: the `mov` opcode 0x55 with a 2-operand form copies src→dst; the test encodes dst=`(type 3 = global-int, 0x11)`, src=`(type 3, 0x10)`. If the corpus `mov` is strictly 2-arg, keep argc=2 as encoded (`0x55, 3,0x11, 3,0x10`). Confirm the opcode's argc via `build/opcodes.json` when implementing; adjust the dword stream to match the real argc so `Sys4Loader.Parse` decodes it as one instruction.
- [ ] **Step 3: Run the tests to verify they fail**
Run: `dotnet test engine/AgeEngine.sln --filter CallScriptTests`
Expected: FAIL — `CalleeRunsAndControlResumesAfterTheCall`, `UnresolvedIdHalts`, `LocalsDoNotLeakBetweenCallerAndCallee` fail (call-script is still the stub, so the callee never runs / never halts on unresolved). `MissingProviderFallsBackToStub` may already pass.
- [ ] **Step 4: Implement call-script execution**
In `engine/Age.Engine/Vm/VirtualMachine.cs`, replace the `call-script` stub case with:
```csharp
case "call-script":
{
long id = a.Count > 0 ? Read(a[0]) : 0;
_host.CallScript(id); // notify (diagnostics)
if (_provider == null) return pc + 1; // no script source: prior stub behavior
if (_depth >= _o.CallDepthCap) { HaltReason ??= "call-depth-exceeded"; return HALT; }
var child = _provider.GetById(id);
if (child == null) { HaltReason ??= $"callscript-unresolved:0x{id:x}"; return HALT; }
var entry = child.IndexByOffset.TryGetValue(0, out var ci) ? ci : 0;
var outcome = RunFrame(new ExecFrame(child, entry));
if (outcome == FrameOutcome.Halted) return HALT; // propagate whole-VM halt up
return pc + 1; // Returned / RanOff: resume caller
}
```
- [ ] **Step 5: Run the tests to verify they pass**
Run: `dotnet test engine/AgeEngine.sln --filter CallScriptTests`
Expected: PASS (all four). If the `mov` encoding decodes wrong, fix the dword stream per the Step-2 note.
- [ ] **Step 6: Run the full suite (no regressions)**
Run: `dotnet test engine/AgeEngine.sln`
Expected: PASS — existing provider-less tests unchanged (SC0000 still 186 in `WaitForInputTests`).
- [ ] **Step 7: Commit**
```bash
git add engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine/Vm/VmOptions.cs engine/Age.Engine.Tests/CallScriptTests.cs
git commit -m "Execute call-script: nested frame, shared globals, return to caller"
```
---
### Task 4: Wire the provider into product paths + real-scene validation
Thread a real `Sys4ScriptProvider` into the CLI run/play/sweep paths and `GameSession.RunScene`, prove a real scene executes a real subroutine, and confirm the corpus still terminates with execution on. Confirm the empty-stack `ret` path with a real script (BUNKI).
**Files:**
- Modify: `engine/Age.Engine/Vm/GameSession.cs` (`RunScene` gains an optional provider)
- Modify: `engine/Age.Cli/Program.cs` (construct + pass the provider in `run`, `play`, `sweep`)
- Test: `engine/Age.Engine.Tests/CallScriptIntegrationTests.cs`
**Interfaces:**
- Consumes: `Sys4ScriptProvider.Load` (Task 1), `VirtualMachine(..., provider)` (Task 2/3).
- Produces: `GameSession.RunScene(Script, OpcodeTable, IHost, VmOptions?, IScriptProvider?)`.
- [ ] **Step 1: Add the provider to GameSession.RunScene**
In `engine/Age.Engine/Vm/GameSession.cs`, change the `RunScene` signature and VM construction:
```csharp
public SceneResult RunScene(Script script, OpcodeTable table, IHost host,
VmOptions? options = null, IScriptProvider? provider = null)
{
var vm = new VirtualMachine(script, table, host, options, provider);
```
(The rest of the method is unchanged.)
- [ ] **Step 2: Write the failing integration tests**
Create `engine/Age.Engine.Tests/CallScriptIntegrationTests.cs`:
```csharp
using Age.Engine.Hosting;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class CallScriptIntegrationTests
{
private sealed class NullHost : IHost
{
public int CallScripts;
public void ShowText(int o, string t) { }
public void CallScript(long id) => CallScripts++;
public void OnStub(int op) { }
public void WaitForInput() { }
public void CreateTexture(int s, int w, int h) { }
public void SetTexture(long r, int s) { }
public void DrawTexture(int s, int sx, int sy, int w, int h, int dx, int dy) { }
public (int Width, int Height) GetTextureSize(int s) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}
[Fact]
public void RealSceneExecutesRealSubroutinesAndReturns()
{
var t = OpcodeTableJson.Load(Paths.OpcodesJson);
var provider = Sys4ScriptProvider.Load(t);
var script = Sys4Loader.Load(Paths.Scripts()["ALCHEMY.BIN"], t); // calls MES/BUNKI/ADDITEM
var host = new NullHost();
var vm = new VirtualMachine(script, t, host, null, provider);
vm.Run();
Assert.True(host.CallScripts > 0, "the scene should issue call-scripts");
// Termination is the key property: execution-on must still reach a clean end, not hang or
// trip the depth cap. (No unresolved / depth halts.)
Assert.DoesNotContain("unresolved", vm.HaltReason ?? "");
Assert.NotEqual("call-depth-exceeded", vm.HaltReason);
Assert.NotEqual("STEP-LIMIT", vm.HaltReason);
}
[Fact]
public void BunkiTopLevelRetReturnsCleanlyAsSubroutine()
{
// BUNKI.BIN ends with a top-level `ret` (empty intra-call stack). Called as a subroutine it
// must return to the caller, not underflow-halt. Drive it directly as a child of a 1-op caller.
var t = OpcodeTableJson.Load(Paths.OpcodesJson);
var provider = Sys4ScriptProvider.Load(t);
var bunki = provider.GetById(0x143); // BUNKI.BIN
Assert.NotNull(bunki);
var vm = new VirtualMachine(bunki!, t, new NullHost(), null, provider);
vm.Run();
// Reaching a frame-return at the top = clean "exit"; never "ret-underflow".
Assert.NotEqual("ret-underflow", vm.HaltReason);
}
}
```
- [ ] **Step 3: Run to verify (expected: mostly pass already)**
Run: `dotnet test engine/AgeEngine.sln --filter CallScriptIntegrationTests`
Expected: PASS. If `BunkiTopLevelRetReturnsCleanlyAsSubroutine` reveals a wrong `ret` semantics (e.g. it halts early with a bad reason), that is the empty-stack-`ret` branch — verify against the native op 0x5 handler `ctx[0x26c93+5]` (LAB_00417ad0) before adjusting; the plan's choice (empty-stack `ret` = frame return) should hold.
- [ ] **Step 4: Wire the provider into the CLI**
In `engine/Age.Cli/Program.cs`, build one provider after `table` is loaded and pass it into the product paths:
- In the `run` command (line ~13), construct `var provider = Sys4ScriptProvider.Load(table);` and change the VM to `new VirtualMachine(script, table, host, null, provider)`.
- In `play` (line ~96), construct the provider once before the loop and pass it: `session.RunScene(script, table, new CaptureHost(), null, provider)`.
- In `sweep` (lines ~121, 140, 162): construct the provider once and pass it to each `RunScene(...)` call (`, null, provider`). The `--boot` INIT scripts may be run with or without the provider; pass it for consistency.
- Leave `trace` (line ~181) provider-less on purpose — it is the base-ISA offset oracle (stub behavior, comparable to vm0.py).
Exact edit for `run` (lines 1217):
```csharp
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var provider = Sys4ScriptProvider.Load(table);
var script = Sys4Loader.Load(args[1], table);
var host = new ConsoleHost();
var vm = new VirtualMachine(script, table, host, null, provider);
vm.Run();
```
(Use whatever host `run` already constructs; only the trailing `provider` arg is added.)
- [ ] **Step 5: Confirm the corpus still terminates with execution on**
Build and run the sweep:
```bash
dotnet run --project engine/Age.Cli -- sweep
```
Expected: it completes; halt distribution is dominated by `exit`. Note any new non-`exit` halts (e.g. `call-depth-exceeded`, `callscript-unresolved`, `STEP-LIMIT`) — none should appear for well-formed scenes. Compare the anomaly list to the pre-change baseline (unbooted sweep previously matched vm0.py: 294 `exit` + 3 `LOOP`). New anomalies are the review signal.
- [ ] **Step 6: Run the full suite**
Run: `dotnet test engine/AgeEngine.sln`
Expected: PASS.
- [ ] **Step 7: Commit**
```bash
git add engine/Age.Engine/Vm/GameSession.cs engine/Age.Cli/Program.cs engine/Age.Engine.Tests/CallScriptIntegrationTests.cs
git commit -m "Wire Sys4ScriptProvider into CLI run/play/sweep; validate real subroutine execution"
```
---
## Post-implementation notes (not tasks)
- **Godot host:** the playable Godot path (`GodotAdvHost`/`Main`) should construct a `Sys4ScriptProvider` and pass it to its `VirtualMachine`, so subroutines execute live; keep the `--selftest` path **provider-less** (preserves the 186-offset SC0000 gate). This is a small follow-up wiring, mirroring Task 4's CLI changes.
- **vm0.py** is not modified. `trace`/`TraceDiffTests`/`WaitForInputTests` remain on the provider-less (stub) path, so vm0.py stays a valid base-ISA reference without lockstep maintenance.
- **decision→scene** (scene chaining) is out of scope; it rides this same loader once the SCJUMP decision→scene-id hop is reversed.
- After landing, update the status memory (`himegari-port-status.md`) and `docs/phase-a-slice-plan.md` with the result.
## Self-review
- **Spec coverage:** provider seam (Task 1) ✓; recursive frame execution (Task 2) ✓; shared globals / per-frame locals / exit+ret semantics / depth cap / dynamic ids / IHost.CallScript notify (Task 3) ✓; emitted script identity (Task 2) ✓; validation via C#-owned tests + sweep-terminates + real subroutine + empty-stack-ret (Tasks 34) ✓; vm0.py retired without deletion, base-ISA guard preserved (Global Constraints + notes) ✓. Deviation (no-provider → stub, not halt) is documented in Global Constraints.
- **Placeholder scan:** none — every code step shows full code; the one encoding caveat (`mov` argc) has an explicit resolution instruction.
- **Type consistency:** `IScriptProvider.GetById` (Task 1) is consumed unchanged in Tasks 24; `Emitted`/`SceneResult` tuple `(int Offset, string Text, string Script)` is introduced in Task 2 and consumers updated in the same task; `RunFrame`/`FrameOutcome`/`ExecFrame`/`_provider`/`_depth` defined in Task 2 and used in Task 3; `VmOptions.CallDepthCap` defined in Task 3 before first use.

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.

View File

@@ -26,7 +26,7 @@ whenever a tool's inputs/outputs change.**
| Tool | Purpose | Run | Reads → Writes | | Tool | Purpose | Run | Reads → Writes |
|---|---|---|---| |---|---|---|---|
| `sys4load.py` | Loader + opcode-decoding disassembler for SYS4 `.BIN` scripts (the container-format core every other tool builds on). | `sys4load.py <file.BIN>` · `--summary` · `--strings` · `--json` · `sys4load.py <dir> --validate` (corpus check) | `.BIN` + `age_opcodes*.py` + `build/global-var-map.json` → stdout listing, or `build/scripts-json/` with `--json` | | `sys4load.py` | Loader + opcode-decoding disassembler for SYS4 `.BIN` scripts (the container-format core every other tool builds on). Annotates global operands (`build/globals.json`) and **`call-script` targets by name** (`build/callscript-names.json`, e.g. `call-script 0x1ab =ADDITEM.BIN`). | `sys4load.py <file.BIN>` · `--summary` · `--strings` · `--json` · `sys4load.py <dir> --validate` (corpus check) | `.BIN` + `age_opcodes*.py` + `build/globals.json` + `build/callscript-names.json` → stdout listing, or `build/scripts-json/` with `--json` |
| `age_opcodes.py` | 548-entry Kelebek AGE opcode/arg-type table. **PRISTINE upstream data — never edit.** | *Imported.* | — | | `age_opcodes.py` | 548-entry Kelebek AGE opcode/arg-type table. **PRISTINE upstream data — never edit.** | *Imported.* | — |
## Opcode reference toolchain — single source of truth = `vm-map/opcodes.toml` ## Opcode reference toolchain — single source of truth = `vm-map/opcodes.toml`
@@ -90,7 +90,7 @@ plays `SC0000` from the real bytecode. User args (after `--`):
| Tool | Purpose | Run | Reads → Writes | | Tool | Purpose | Run | Reads → Writes |
|---|---|---|---| |---|---|---|---|
| `parse_sys4ini.py` | Parse `SYS4INI.BIN` (S4IC422, LZSS-compressed) into the authoritative asset index — name ↔ archive ↔ offset ↔ size for all DATA*.ALF (the `resId→file` answer key). | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN``build/asset-index.json` | | `parse_sys4ini.py` | Parse `SYS4INI.BIN` (S4IC422, LZSS-compressed) into the authoritative asset index — name ↔ archive ↔ offset ↔ size for all DATA*.ALF (the `resId→file` answer key). Each entry carries `raw_index` (its 0-based position in the SYS4INI record table incl. `@` placeholders) = the engine's universal file id. Also emits the **`call-script <id> → name`** map (id = `raw_index`; see `engine-re.md`). | `parse_sys4ini.py [--check]` (`--check` validates vs `extracted/` + `.ALF` sizes) | `姫狩り…/SYS4INI.BIN``build/asset-index.json` + `build/callscript-names.json` |
| `resolve_asset.py` | ★ **The static asset resolver.** SYS4INI is sectioned (one per scene: `SCxxxx.BIN` + its cross-archive manifest; `file_number` = index within section). Resolves `resId → files[section_base(scene) + resId]` for graphics AND audio, no capture. | `resolve_asset.py --build` · `resolve_asset.py <SCENE> [resId]` | `build/asset-index.json``build/asset-sections.json`; resolves any (scene, resId) | | `resolve_asset.py` | ★ **The static asset resolver.** SYS4INI is sectioned (one per scene: `SCxxxx.BIN` + its cross-archive manifest; `file_number` = index within section). Resolves `resId → files[section_base(scene) + resId]` for graphics AND audio, no capture. | `resolve_asset.py --build` · `resolve_asset.py <SCENE> [resId]` | `build/asset-index.json``build/asset-sections.json`; resolves any (scene, resId) |
| `resolve_frida_reads.py` | Rescue noisy Frida archive-read offsets → asset names via the index (per-archive range search; drops 0x20000 paging reads); recovers the per-scene asset load order. | `resolve_frida_reads.py [reads.log] [-o out.json]` | `build/frida-reads.log` + `build/asset-index.json``build/frida-asset-loads.json` | | `resolve_frida_reads.py` | Rescue noisy Frida archive-read offsets → asset names via the index (per-archive range search; drops 0x20000 paging reads); recovers the per-scene asset load order. | `resolve_frida_reads.py [reads.log] [-o out.json]` | `build/frida-reads.log` + `build/asset-index.json``build/frida-asset-loads.json` |
| `convert_agf.py` | Convert AGF stills to BMP via `AGF2BMP2AGF.exe` (searches all `extracted/DATA*`). `--scene` batch-converts a scene's whole SYS4INI manifest — feeds the Godot render. | `convert_agf.py EV052CA.AGF …` · `convert_agf.py --scene SC0000` | `extracted/DATA*/*.AGF``build/textures/*.BMP` | | `convert_agf.py` | Convert AGF stills to BMP via `AGF2BMP2AGF.exe` (searches all `extracted/DATA*`). `--scene` batch-converts a scene's whole SYS4INI manifest — feeds the Godot render. | `convert_agf.py EV052CA.AGF …` · `convert_agf.py --scene SC0000` | `extracted/DATA*/*.AGF``build/textures/*.BMP` |

View File

@@ -5,16 +5,19 @@ using Age.Engine.Sys4;
using Age.Engine.Vm; using Age.Engine.Vm;
var table = OpcodeTableJson.Load(Paths.OpcodesJson); var table = OpcodeTableJson.Load(Paths.OpcodesJson);
// call-script execution: resolves ids -> scripts. Product paths pass this so subroutines run;
// `trace` stays provider-less on purpose (the base-ISA offset oracle).
var provider = Sys4ScriptProvider.Load(table);
if (args.Length == 0) { Console.WriteLine("usage: run <file> | trace <out.json>"); return 1; } if (args.Length == 0) { Console.WriteLine("usage: run <file> | trace <out.json>"); return 1; }
if (args[0] == "run") if (args[0] == "run")
{ {
var script = Sys4Loader.Load(args[1], table); var script = Sys4Loader.Load(args[1], table);
var vm = new VirtualMachine(script, table, new CaptureHost()); var vm = new VirtualMachine(script, table, new CaptureHost(), null, provider);
vm.Run(); vm.Run();
Console.WriteLine($"{Path.GetFileName(args[1])}: {vm.Steps} steps, {vm.Emitted.Count} show-text (halt: {vm.HaltReason})"); Console.WriteLine($"{Path.GetFileName(args[1])}: {vm.Steps} steps, {vm.Emitted.Count} show-text (halt: {vm.HaltReason})");
foreach (var (off, text) in vm.Emitted.Take(20)) Console.WriteLine($" [{off:x}] {text}"); foreach (var (off, text, _) in vm.Emitted.Take(20)) Console.WriteLine($" [{off:x}] {text}");
return 0; return 0;
} }
@@ -94,7 +97,7 @@ if (args[0] == "play")
foreach (var name in scenes) foreach (var name in scenes)
{ {
var script = Sys4Loader.Load(scripts[name.ToUpperInvariant()], table); var script = Sys4Loader.Load(scripts[name.ToUpperInvariant()], table);
var r = session.RunScene(script, table, new CaptureHost()); var r = session.RunScene(script, table, new CaptureHost(), null, provider);
totalLines += r.Emitted.Count; totalLines += r.Emitted.Count;
Console.WriteLine($" {name,-14} {r.Emitted.Count,4} lines, {r.Steps,7} steps (halt: {r.Halt})"); Console.WriteLine($" {name,-14} {r.Emitted.Count,4} lines, {r.Steps,7} steps (halt: {r.Halt})");
} }
@@ -118,7 +121,7 @@ if (args[0] == "sweep")
var bootSession = new GameSession(); var bootSession = new GameSession();
foreach (var s in new[] { "SKINIT.BIN", "ITINIT.BIN", "EBINIT.BIN", "CGINIT.BIN", "MPINIT.BIN", foreach (var s in new[] { "SKINIT.BIN", "ITINIT.BIN", "EBINIT.BIN", "CGINIT.BIN", "MPINIT.BIN",
"AFINIT.BIN", "CCINIT.BIN", "STINIT.BIN", "STINIT2.BIN" }) "AFINIT.BIN", "CCINIT.BIN", "STINIT.BIN", "STINIT2.BIN" })
bootSession.RunScene(Sys4Loader.Load(scripts[s], table), table, new CaptureHost()); bootSession.RunScene(Sys4Loader.Load(scripts[s], table), table, new CaptureHost(), null, provider);
baseline = bootSession.ToJson(); baseline = bootSession.ToJson();
Console.WriteLine($"[boot] baseline = {bootSession.Globals.Count} globals; running {names.Count} scenes from it."); Console.WriteLine($"[boot] baseline = {bootSession.Globals.Count} globals; running {names.Count} scenes from it.");
} }
@@ -137,7 +140,7 @@ if (args[0] == "sweep")
{ {
var session = Fresh(); var session = Fresh();
if (seeded) foreach (var (k, v) in seeds) session.Seed(k, v); if (seeded) foreach (var (k, v) in seeds) session.Seed(k, v);
return session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost()).Emitted.Count; return session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost(), null, provider).Emitted.Count;
} }
if (seeds.Count > 0) if (seeds.Count > 0)
@@ -159,7 +162,7 @@ if (args[0] == "sweep")
foreach (var name in names) foreach (var name in names)
{ {
var session = Fresh(); var session = Fresh();
var r = session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost()); var r = session.RunScene(Sys4Loader.Load(scripts[name], table), table, new CaptureHost(), null, provider);
var halt = r.Halt ?? "null"; var halt = r.Halt ?? "null";
haltDist[halt] = haltDist.GetValueOrDefault(halt) + 1; haltDist[halt] = haltDist.GetValueOrDefault(halt) + 1;
totalLines += r.Emitted.Count; totalLines += r.Emitted.Count;

View File

@@ -0,0 +1,52 @@
using Age.Engine.Hosting;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class CallScriptIntegrationTests
{
private sealed class NullHost : IHost
{
public int CallScripts;
public void ShowText(int o, string t) { }
public void CallScript(long id) => CallScripts++;
public void OnStub(int op) { }
public void WaitForInput() { }
public void CreateTexture(int s, int w, int h) { }
public void SetTexture(long r, int s) { }
public void DrawTexture(int s, int sx, int sy, int w, int h, int dx, int dy) { }
public (int Width, int Height) GetTextureSize(int s) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}
[Fact]
public void RealScriptExecutesRealSubroutinesAndReturns()
{
// ADDILL.BIN unconditionally call-scripts ADDILLSUB then CALCREVISE at entry, then exits.
// With execution on, both subroutines load, run, and return, so ADDILL reaches its own exit.
var t = OpcodeTableJson.Load(Paths.OpcodesJson);
var provider = Sys4ScriptProvider.Load(t);
var script = Sys4Loader.Load(Paths.Scripts()["ADDILL.BIN"], t);
var host = new NullHost();
var vm = new VirtualMachine(script, t, host, null, provider);
vm.Run();
Assert.Equal(2, host.CallScripts); // ADDILLSUB + CALCREVISE both dispatched
Assert.Equal("exit", vm.HaltReason); // subroutines returned; ADDILL reached its own exit
}
[Fact]
public void BunkiTopLevelRetReturnsCleanlyAsSubroutine()
{
// BUNKI.BIN ends with a top-level `ret` (empty intra-call stack). Called as a subroutine it
// must return to the caller, not underflow-halt. Drive it directly.
var t = OpcodeTableJson.Load(Paths.OpcodesJson);
var provider = Sys4ScriptProvider.Load(t);
var bunki = provider.GetById(0x143); // BUNKI.BIN
Assert.NotNull(bunki);
var vm = new VirtualMachine(bunki!, t, new NullHost(), null, provider);
vm.Run();
// Reaching a frame-return at the top = clean "exit"; never "ret-underflow".
Assert.NotEqual("ret-underflow", vm.HaltReason);
}
}

View File

@@ -0,0 +1,107 @@
using System.Collections.Generic;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class CallScriptTests
{
// Opcodes (from build/opcodes.json): exit=0x2, call-script=0x3(argc1), mov=0x55(argc2).
// Operand types: imm=0, global-int=3, local-int=9.
private const uint OP_EXIT = 0x2, OP_CALLSCRIPT = 0x3, OP_MOV = 0x55;
private sealed class NullHost : IHost
{
public List<long> Calls = new();
public void ShowText(int o, string t) { }
public void CallScript(long id) => Calls.Add(id);
public void OnStub(int op) { }
public void WaitForInput() { }
public void CreateTexture(int s, int w, int h) { }
public void SetTexture(long r, int s) { }
public void DrawTexture(int s, int sx, int sy, int w, int h, int dx, int dy) { }
public (int Width, int Height) GetTextureSize(int s) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}
private sealed class MapProvider : IScriptProvider
{
private readonly Dictionary<long, Script> _m;
public MapProvider(Dictionary<long, Script> m) => _m = m;
public Script? GetById(long id) => _m.TryGetValue(id, out var s) ? s : null;
}
// Build a Script from raw dwords via the real loader (guarantees identical decode).
private static Script Asm(OpcodeTable t, string name, params uint[] body)
{
var bytes = new byte[0x3C + body.Length * 4];
System.Text.Encoding.ASCII.GetBytes("SYS4422 ").CopyTo(bytes, 0);
// fields[8] (F8 = code end) at header offset 8 + 8*4 = 0x28; set to body length (all code).
System.BitConverter.GetBytes(body.Length).CopyTo(bytes, 8 + 8 * 4);
for (int i = 0; i < body.Length; i++) System.BitConverter.GetBytes(body[i]).CopyTo(bytes, 0x3C + i * 4);
return Sys4Loader.Parse(bytes, t, name);
}
private static OpcodeTable Table() => OpcodeTableJson.Load(Paths.OpcodesJson);
[Fact]
public void CalleeRunsAndControlResumesAfterTheCall()
{
var t = Table();
// Callee (id 5): mov g[0x10] = 7, then exit.
var callee = Asm(t, "CALLEE", OP_MOV, 3, 0x10, 0, 7, OP_EXIT);
// Caller: call-script 5 ; mov g[0x11] = g[0x10] ; exit.
var caller = Asm(t, "CALLER",
OP_CALLSCRIPT, 0, 5,
OP_MOV, 3, 0x11, 3, 0x10,
OP_EXIT);
var host = new NullHost();
var vm = new VirtualMachine(caller, t, host, null, new MapProvider(new() { [5] = callee }));
vm.Run();
Assert.Equal(7, vm.Globals[0x10]); // callee wrote a shared global
Assert.Equal(7, vm.Globals[0x11]); // caller read it AFTER the call returned
Assert.Equal("exit", vm.HaltReason); // top-level exit
Assert.Contains(5L, host.Calls); // host notified
}
[Fact]
public void MissingProviderFallsBackToStub()
{
var t = Table();
var caller = Asm(t, "CALLER", OP_CALLSCRIPT, 0, 5, OP_EXIT);
var host = new NullHost();
var vm = new VirtualMachine(caller, t, host, null, null); // no provider
vm.Run();
Assert.Equal("exit", vm.HaltReason); // did not halt on the call; stub + continue
Assert.Contains(5L, host.Calls);
}
[Fact]
public void UnresolvedIdHalts()
{
var t = Table();
var caller = Asm(t, "CALLER", OP_CALLSCRIPT, 0, 99, OP_EXIT);
var vm = new VirtualMachine(caller, t, new NullHost(), null, new MapProvider(new()));
vm.Run();
Assert.StartsWith("callscript-unresolved", vm.HaltReason);
}
[Fact]
public void LocalsDoNotLeakBetweenCallerAndCallee()
{
var t = Table();
// Callee writes LOCAL-int 0 = 42 (mov to local-int, type 9), then exit.
var callee = Asm(t, "CALLEE", OP_MOV, 9, 0, 0, 42, OP_EXIT);
// Caller sets local-int 0 = 1, calls, then copies its own local-int 0 to global 0x20.
var caller = Asm(t, "CALLER",
OP_MOV, 9, 0, 0, 1, // l[0] = 1
OP_CALLSCRIPT, 0, 5, // call-script 5 (callee sets ITS local 0 = 42)
OP_MOV, 3, 0x20, 9, 0, // g[0x20] = l[0]
OP_EXIT);
var vm = new VirtualMachine(caller, t, new NullHost(), null, new MapProvider(new() { [5] = callee }));
vm.Run();
Assert.Equal(1, vm.Globals[0x20]); // caller's local 0 unchanged by callee's local 0
}
}

View File

@@ -0,0 +1,20 @@
using Age.Engine.Sys4;
using Xunit;
public class Sys4ScriptProviderTests
{
[Fact]
public void ResolvesKnownIdsToTheirScripts()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var provider = Sys4ScriptProvider.Load(table);
var additem = provider.GetById(0x1ab); // ADDITEM.BIN
var mes = provider.GetById(0x2ae7); // MES.BIN
Assert.NotNull(additem);
Assert.NotNull(mes);
Assert.True(additem!.Instructions.Count > 0);
Assert.Same(additem, provider.GetById(0x1ab)); // cached: same instance
Assert.Null(provider.GetById(long.MaxValue)); // unknown id
}
}

View File

@@ -0,0 +1,10 @@
using Age.Engine.Model;
namespace Age.Engine.Hosting;
/// <summary>Resolves a call-script id (a raw SYS4INI file index) to a loaded <see cref="Script"/>.
/// Implemented in Sys4; injected into the VM so the Vm layer never references Sys4.</summary>
public interface IScriptProvider
{
/// <summary>The script for this id, or null if the id maps to no known script.</summary>
Script? GetById(long id);
}

View File

@@ -1,6 +1,7 @@
namespace Age.Engine.Model; namespace Age.Engine.Model;
public sealed class Script public sealed class Script
{ {
public string Name { get; init; } = "";
public required ScriptHeader Header { get; init; } public required ScriptHeader Header { get; init; }
public required IReadOnlyList<Instruction> Instructions { get; init; } public required IReadOnlyList<Instruction> Instructions { get; init; }
public required IReadOnlyDictionary<int, int> IndexByOffset { get; init; } public required IReadOnlyDictionary<int, int> IndexByOffset { get; init; }

View File

@@ -10,6 +10,7 @@ public static class Paths
public static string OpcodesJson => Path.Combine(Build, "opcodes.json"); public static string OpcodesJson => Path.Combine(Build, "opcodes.json");
public static string AssetSectionsJson => Path.Combine(Build, "asset-sections.json"); public static string AssetSectionsJson => Path.Combine(Build, "asset-sections.json");
public static string AssetIndexJson => Path.Combine(Build, "asset-index.json"); public static string AssetIndexJson => Path.Combine(Build, "asset-index.json");
public static string CallscriptNamesJson => Path.Combine(Build, "callscript-names.json");
public static string Textures => Path.Combine(Build, "textures"); public static string Textures => Path.Combine(Build, "textures");
private static string FindRepo() private static string FindRepo()

View File

@@ -22,7 +22,7 @@ public static class Sys4Loader
var header = new ScriptHeader(fields[0], fields[1], fields[2], fields[3], fields[4], fields[5]); var header = new ScriptHeader(fields[0], fields[1], fields[2], fields[3], fields[4], fields[5]);
var (instrs, idxByOff, strings) = DecodeCode(dw, fields, nbody, table); var (instrs, idxByOff, strings) = DecodeCode(dw, fields, nbody, table);
return new Script { Header = header, Instructions = instrs, IndexByOffset = idxByOff, Strings = strings }; return new Script { Name = name, Header = header, Instructions = instrs, IndexByOffset = idxByOff, Strings = strings };
} }
private static (List<Instruction>, Dictionary<int, int>, Dictionary<int, string>) private static (List<Instruction>, Dictionary<int, int>, Dictionary<int, string>)

View File

@@ -0,0 +1,39 @@
using System.Text.Json;
using Age.Engine.Hosting;
using Age.Engine.Model;
namespace Age.Engine.Sys4;
/// <summary>Resolves call-script ids (raw SYS4INI file indices) to loaded scripts, using
/// build/callscript-names.json (id→name) + Paths.Scripts() (name→path). Cached per id.
/// The native resolver prefers a loose override before the archive; Paths.Scripts() already
/// shadows extracted/DATA1 with root overrides, so that behavior is preserved.</summary>
public sealed class Sys4ScriptProvider : IScriptProvider
{
private readonly OpcodeTable _table;
private readonly IReadOnlyDictionary<long, string> _idToName;
private readonly Dictionary<string, string> _byName; // NAME(UPPER) -> path
private readonly Dictionary<long, Script?> _cache = new();
public Sys4ScriptProvider(OpcodeTable table, IReadOnlyDictionary<long, string> idToName,
Dictionary<string, string> byName)
{ _table = table; _idToName = idToName; _byName = byName; }
public static Sys4ScriptProvider Load(OpcodeTable table)
{
var raw = JsonSerializer.Deserialize<Dictionary<string, string>>(
File.ReadAllText(Paths.CallscriptNamesJson)) ?? new();
var idToName = raw.ToDictionary(kv => long.Parse(kv.Key), kv => kv.Value);
return new Sys4ScriptProvider(table, idToName, Paths.Scripts());
}
public Script? GetById(long id)
{
if (_cache.TryGetValue(id, out var cached)) return cached;
Script? s = null;
if (_idToName.TryGetValue(id, out var name) &&
_byName.TryGetValue(name.ToUpperInvariant(), out var path))
s = Sys4Loader.Load(path, _table);
_cache[id] = s;
return s;
}
}

View File

@@ -0,0 +1,15 @@
using Age.Engine.Model;
namespace Age.Engine.Vm;
/// <summary>One script activation: the running script, its instruction cursor, its local slots,
/// its intra-script call/ret stack, and its per-script loop-guard map. Globals live on the VM and
/// are shared across frames; everything here is per-call and discarded on return.</summary>
internal sealed class ExecFrame
{
public readonly Script Script;
public int Pc; // entry instruction index
public readonly Frame Locals = new();
public readonly List<int> CallStack = new(); // intra-script `call` (op 0x8f) returns
public readonly Dictionary<int, int> EmitSeen = new();
public ExecFrame(Script script, int pc) { Script = script; Pc = pc; }
}

View File

@@ -24,9 +24,10 @@ public sealed class GameSession
public void SeedString(int addr, string value) => GlobalStrings[addr] = value; public void SeedString(int addr, string value) => GlobalStrings[addr] = value;
/// <summary>Run one scene: seed a fresh VM from session state, execute, merge final state back.</summary> /// <summary>Run one scene: seed a fresh VM from session state, execute, merge final state back.</summary>
public SceneResult RunScene(Script script, OpcodeTable table, IHost host, VmOptions? options = null) public SceneResult RunScene(Script script, OpcodeTable table, IHost host,
VmOptions? options = null, IScriptProvider? provider = null)
{ {
var vm = new VirtualMachine(script, table, host, options); var vm = new VirtualMachine(script, table, host, options, provider);
foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value; foreach (var kv in Globals) vm.Globals[kv.Key] = kv.Value;
foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value; foreach (var kv in GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value;
@@ -64,4 +65,4 @@ public sealed class GameSession
} }
/// <summary>The observable result of running one scene into a <see cref="GameSession"/>.</summary> /// <summary>The observable result of running one scene into a <see cref="GameSession"/>.</summary>
public sealed record SceneResult(IReadOnlyList<(int Offset, string Text)> Emitted, string? Halt, long Steps); public sealed record SceneResult(IReadOnlyList<(int Offset, string Text, string Script)> Emitted, string? Halt, long Steps);

View File

@@ -6,6 +6,7 @@ public sealed class VirtualMachine
{ {
private const long NoJump = 0xFFFFFFFF; private const long NoJump = 0xFFFFFFFF;
private const int HALT = int.MinValue; private const int HALT = int.MinValue;
private const int FRAME_RETURN = int.MinValue + 1;
private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6, private const int T_IMM = 0, T_STR = 2, T_GINT = 3, T_GFLOAT = 4, T_GSTR = 5, T_GPTR = 6,
T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12; T_LINT = 9, T_LFLOAT = 10, T_LSTR = 11, T_LPTR = 12;
@@ -13,18 +14,18 @@ public sealed class VirtualMachine
private readonly OpcodeTable _t; private readonly OpcodeTable _t;
private readonly IHost _host; private readonly IHost _host;
private readonly VmOptions _o; private readonly VmOptions _o;
private readonly Frame _fr = new(); private readonly IScriptProvider? _provider;
private readonly List<int> _callstack = new(); private ExecFrame _cur = null!;
private readonly Dictionary<int, int> _emitSeen = new(); private int _depth;
public Dictionary<int, long> Globals { get; } = new(); public Dictionary<int, long> Globals { get; } = new();
public Dictionary<int, string> GlobalStrings { get; } = new(); public Dictionary<int, string> GlobalStrings { get; } = new();
public List<(int Offset, string Text)> Emitted { get; } = new(); public List<(int Offset, string Text, string Script)> Emitted { get; } = new();
public string? HaltReason { get; private set; } public string? HaltReason { get; private set; }
public long Steps { get; private set; } public long Steps { get; private set; }
public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null) public VirtualMachine(Script s, OpcodeTable t, IHost host, VmOptions? o = null, IScriptProvider? provider = null)
{ _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); } { _s = s; _t = t; _host = host; _o = o ?? new VmOptions(); _provider = provider; }
private static long Gi(Dictionary<int, long> d, int k) => d.TryGetValue(k, out var v) ? v : 0; private static long Gi(Dictionary<int, long> d, int k) => d.TryGetValue(k, out var v) ? v : 0;
private static string Gs(Dictionary<int, string> d, int k) => d.TryGetValue(k, out var v) ? v : ""; private static string Gs(Dictionary<int, string> d, int k) => d.TryGetValue(k, out var v) ? v : "";
@@ -38,9 +39,9 @@ public sealed class VirtualMachine
T_IMM => op.Value, T_IMM => op.Value,
T_GINT or T_GFLOAT => Gi(Globals, (int)op.Value), T_GINT or T_GFLOAT => Gi(Globals, (int)op.Value),
T_GPTR => Gi(Globals, (int)Gi(Globals, (int)op.Value)), T_GPTR => Gi(Globals, (int)Gi(Globals, (int)op.Value)),
T_LINT => Gi(_fr.I, (int)op.Value), T_LINT => Gi(_cur.Locals.I, (int)op.Value),
T_LFLOAT => Gi(_fr.F, (int)op.Value), T_LFLOAT => Gi(_cur.Locals.F, (int)op.Value),
T_LPTR => Gi(Globals, (int)Gi(_fr.P, (int)op.Value)), T_LPTR => Gi(Globals, (int)Gi(_cur.Locals.P, (int)op.Value)),
_ => op.Value, _ => op.Value,
}; };
@@ -50,17 +51,17 @@ public sealed class VirtualMachine
{ {
case T_GINT: case T_GFLOAT: Globals[(int)op.Value] = val; break; case T_GINT: case T_GFLOAT: Globals[(int)op.Value] = val; break;
case T_GPTR: Globals[(int)Gi(Globals, (int)op.Value)] = val; break; case T_GPTR: Globals[(int)Gi(Globals, (int)op.Value)] = val; break;
case T_LINT: _fr.I[(int)op.Value] = val; break; case T_LINT: _cur.Locals.I[(int)op.Value] = val; break;
case T_LFLOAT: _fr.F[(int)op.Value] = val; break; case T_LFLOAT: _cur.Locals.F[(int)op.Value] = val; break;
case T_LPTR: Globals[(int)Gi(_fr.P, (int)op.Value)] = val; break; case T_LPTR: Globals[(int)Gi(_cur.Locals.P, (int)op.Value)] = val; break;
} }
} }
private string ReadStr(Operand op) => op.Type switch private string ReadStr(Operand op) => op.Type switch
{ {
T_STR => _s.GetString((int)op.Value), T_STR => _cur.Script.GetString((int)op.Value),
T_GSTR => Gs(GlobalStrings, (int)op.Value), T_GSTR => Gs(GlobalStrings, (int)op.Value),
T_LSTR => Gs(_fr.S, (int)op.Value), T_LSTR => Gs(_cur.Locals.S, (int)op.Value),
_ => "", _ => "",
}; };
@@ -69,15 +70,15 @@ public sealed class VirtualMachine
switch (op.Type) switch (op.Type)
{ {
case T_GSTR: GlobalStrings[(int)op.Value] = val; break; case T_GSTR: GlobalStrings[(int)op.Value] = val; break;
case T_LSTR: _fr.S[(int)op.Value] = val; break; case T_LSTR: _cur.Locals.S[(int)op.Value] = val; break;
} }
} }
private long BaseAddr(Operand op) => op.Type switch private long BaseAddr(Operand op) => op.Type switch
{ {
T_IMM or T_GINT or T_GFLOAT or T_GSTR or T_GPTR => op.Value, T_IMM or T_GINT or T_GFLOAT or T_GSTR or T_GPTR => op.Value,
T_LINT => Gi(_fr.I, (int)op.Value), T_LINT => Gi(_cur.Locals.I, (int)op.Value),
T_LPTR => Gi(_fr.P, (int)op.Value), T_LPTR => Gi(_cur.Locals.P, (int)op.Value),
_ => op.Value, _ => op.Value,
}; };
@@ -85,24 +86,39 @@ public sealed class VirtualMachine
{ {
switch (dst.Type) switch (dst.Type)
{ {
case T_LPTR: _fr.P[(int)dst.Value] = addr; break; case T_LPTR: _cur.Locals.P[(int)dst.Value] = addr; break;
case T_GPTR: Globals[(int)dst.Value] = addr; break; case T_GPTR: Globals[(int)dst.Value] = addr; break;
default: Write(dst, Gi(Globals, (int)addr)); break; default: Write(dst, Gi(Globals, (int)addr)); break;
} }
} }
private enum FrameOutcome { Returned, Halted, RanOff }
public void Run(int entryOffset = 0) public void Run(int entryOffset = 0)
{ {
int pc = _s.IndexByOffset.TryGetValue(entryOffset, out var idx) ? idx : 0; var top = new ExecFrame(_s, _s.IndexByOffset.TryGetValue(entryOffset, out var idx) ? idx : 0);
while (pc >= 0 && pc < _s.Instructions.Count) var outcome = RunFrame(top);
if (outcome == FrameOutcome.RanOff) HaltReason ??= "pc-out-of-range";
else if (outcome == FrameOutcome.Returned) HaltReason ??= "exit";
// Halted: HaltReason already set by the halting op.
}
private FrameOutcome RunFrame(ExecFrame frame)
{ {
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; return; } var prev = _cur; _cur = frame; _depth++;
var outcome = FrameOutcome.RanOff;
int pc = frame.Pc;
while (pc >= 0 && pc < frame.Script.Instructions.Count)
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
Steps++; Steps++;
int next = Step(_s.Instructions[pc], pc); int next = Step(frame.Script.Instructions[pc], pc);
if (next == HALT) return; if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
if (next == HALT) { outcome = FrameOutcome.Halted; break; }
pc = next; pc = next;
} }
HaltReason ??= "pc-out-of-range"; _cur = prev; _depth--;
return outcome;
} }
private int Step(Instruction ins, int pc) private int Step(Instruction ins, int pc)
@@ -139,28 +155,40 @@ public sealed class VirtualMachine
case "bit-reset": Write(a[0], Read(a[0]) & ~Read(a[1])); return pc + 1; case "bit-reset": Write(a[0], Read(a[0]) & ~Read(a[1])); return pc + 1;
case "check-bit": Write(a[0], (Read(a[1]) >> (int)(Read(a[2]) & 31)) & 1); return pc + 1; case "check-bit": Write(a[0], (Read(a[1]) >> (int)(Read(a[2]) & 31)) & 1); return pc + 1;
case "copy-to-global": Write(a[0], Read(a[1])); return pc + 1; case "copy-to-global": Write(a[0], Read(a[1])); return pc + 1;
case "jmp": return _s.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1); case "jmp": return _cur.Script.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1);
case "call": _callstack.Add(pc + 1); return _s.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1); case "call": _cur.CallStack.Add(pc + 1); return _cur.Script.IndexByOffset.GetValueOrDefault((int)a[0].Value, pc + 1);
case "ret": case "ret":
if (_callstack.Count > 0) { int r = _callstack[^1]; _callstack.RemoveAt(_callstack.Count - 1); return r; } if (_cur.CallStack.Count > 0) { int r = _cur.CallStack[^1]; _cur.CallStack.RemoveAt(_cur.CallStack.Count - 1); return r; }
HaltReason = "ret-underflow"; return HALT; return FRAME_RETURN; // empty intra-call stack => return from the script frame
case "jcc": case "jcc":
{ {
long tgt = Read(a[0]) != 0 ? a[1].Value : a[2].Value; long tgt = Read(a[0]) != 0 ? a[1].Value : a[2].Value;
return tgt == NoJump ? pc + 1 : _s.IndexByOffset.GetValueOrDefault((int)tgt, pc + 1); return tgt == NoJump ? pc + 1 : _cur.Script.IndexByOffset.GetValueOrDefault((int)tgt, pc + 1);
} }
case "exit": case "exit":
case "exit-script": HaltReason = "exit"; return HALT; case "exit-script": return FRAME_RETURN;
case "call-script": _host.CallScript(a.Count > 0 ? Read(a[0]) : 0); return pc + 1; case "call-script":
{
long id = a.Count > 0 ? Read(a[0]) : 0;
_host.CallScript(id); // notify (diagnostics)
if (_provider == null) return pc + 1; // no script source: prior stub behavior
if (_depth >= _o.CallDepthCap) { HaltReason ??= "call-depth-exceeded"; return HALT; }
var child = _provider.GetById(id);
if (child == null) { HaltReason ??= $"callscript-unresolved:0x{id:x}"; return HALT; }
var entry = child.IndexByOffset.TryGetValue(0, out var ci) ? ci : 0;
var outcome = RunFrame(new ExecFrame(child, entry));
if (outcome == FrameOutcome.Halted) return HALT; // propagate whole-VM halt up
return pc + 1; // Returned / RanOff: resume caller
}
case "show-text": case "show-text":
foreach (var o in a) foreach (var o in a)
{ {
if (o.Type != T_STR) continue; if (o.Type != T_STR) continue;
int off = (int)o.Value; int off = (int)o.Value;
_emitSeen.TryGetValue(off, out var c); c++; _emitSeen[off] = c; _cur.EmitSeen.TryGetValue(off, out var c); c++; _cur.EmitSeen[off] = c;
if (c > _o.EmitCap) { HaltReason = $"LOOP:line@0x{off:x}×{c}"; return HALT; } if (c > _o.EmitCap) { HaltReason = $"LOOP:line@0x{off:x}×{c}"; return HALT; }
string text = _s.GetString(off); string text = _cur.Script.GetString(off);
Emitted.Add((off, text)); Emitted.Add((off, text, _cur.Script.Name));
_host.ShowText(off, text); _host.ShowText(off, text);
} }
return pc + 1; return pc + 1;

View File

@@ -1,2 +1,2 @@
namespace Age.Engine.Vm; namespace Age.Engine.Vm;
public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000); public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000, int CallDepthCap = 64);

View File

@@ -115,13 +115,18 @@ def parse(path: Path) -> dict:
f"have {len(blob)} decompressed bytes)") f"have {len(blob)} decompressed bytes)")
files = [] files = []
for _ in range(file_count): for i in range(file_count):
name_b, arc_id, file_number, offset, size = struct.unpack_from(FILE_ENTRY_FMT, blob, p) name_b, arc_id, file_number, offset, size = struct.unpack_from(FILE_ENTRY_FMT, blob, p)
p += FILE_ENTRY_LEN p += FILE_ENTRY_LEN
name = _cstr(name_b) name = _cstr(name_b)
if name == "@" or not name: if name == "@" or not name:
continue continue
files.append({ files.append({
# raw_index = the record's 0-based position in the SYS4INI table INCLUDING '@'
# placeholders. This is the engine's universal file id: `call-script <id>` and every
# script/asset load index by it (engine FUN_0044f390: record = base + id*0x50). It is
# NOT the same as this entry's position in `files` (which omits placeholders).
"raw_index": i,
"name": name, "name": name,
"archive": archives[arc_id] if 0 <= arc_id < arc_count else None, "archive": archives[arc_id] if 0 <= arc_id < arc_count else None,
"arc_id": arc_id, "arc_id": arc_id,
@@ -215,6 +220,15 @@ def main() -> int:
out.write_text(json.dumps(index, ensure_ascii=False, indent=1), encoding="utf-8") out.write_text(json.dumps(index, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"-> {out.relative_to(paths.REPO)}") print(f"-> {out.relative_to(paths.REPO)}")
# call-script id -> name map (raw_index keyed). `call-script <id>` (opcode 0x03) is a direct
# raw index into the SYS4INI file table, so this IS the call-graph name registry that
# name-resolution.md #1 needed (confirmed via native-RE, see docs/engine-re.md). sys4load reads
# it to annotate `call-script 0x1ab =ADDITEM`.
cs = {str(f["raw_index"]): f["name"] for f in index["files"]}
cs_out = paths.BUILD / "callscript-names.json"
cs_out.write_text(json.dumps(cs, ensure_ascii=False, indent=0), encoding="utf-8")
print(f"-> {cs_out.relative_to(paths.REPO)} ({len(cs)} ids)")
if do_check: if do_check:
print("--- validation ---") print("--- validation ---")
return 1 if check(index) else 0 return 1 if check(index) else 0

View File

@@ -80,6 +80,21 @@ def _load_global_labels() -> dict:
GLOBAL_LABELS = _load_global_labels() GLOBAL_LABELS = _load_global_labels()
def _load_callscript_names() -> dict:
"""id -> script name. `call-script <id>` (op 0x03) is a raw index into the SYS4INI file
table; build/callscript-names.json maps every id to its script name (see docs/engine-re.md)."""
try:
p = Path(__file__).resolve().parent.parent / "build" / "callscript-names.json"
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
return {}
return {int(k): v for k, v in data.items()}
CALLSCRIPT_NAMES = _load_callscript_names()
CALLSCRIPT_OP = 0x03
def display_label(op: int) -> str: def display_label(op: int) -> str:
"""Rendered mnemonic: Kelebek name if it has one, else the inferred name, else u00….""" """Rendered mnemonic: Kelebek name if it has one, else the inferred name, else u00…."""
lbl = OPCODES.get(op, (f"?{op:x}", 0))[0] lbl = OPCODES.get(op, (f"?{op:x}", 0))[0]
@@ -368,6 +383,8 @@ def _fmt_operand(op: int, arg_index: int, atype: int, aval: int, strings: dict)
tlabel = ARG_TYPES.get(atype) tlabel = ARG_TYPES.get(atype)
if atype == 0 or tlabel is None: # immediate / unknown-tag: raw value if atype == 0 or tlabel is None: # immediate / unknown-tag: raw value
if atype == 0: if atype == 0:
if op == CALLSCRIPT_OP and aval in CALLSCRIPT_NAMES:
return f"{aval:#x} ={CALLSCRIPT_NAMES[aval]}" # call-script target script name
return f"{aval:#x}" return f"{aval:#x}"
return f"<t{atype:#x} {aval:#x}>" return f"<t{atype:#x} {aval:#x}>"
if tlabel == "float": if tlabel == "float":

View File

@@ -77,17 +77,36 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "call-script" name = "call-script"
category = "unknown" category = "control"
summary = "" summary = "load & call another SYS4 script by id; id = RAW index into the SYS4INI file table (asset-index). Pushes a script frame; returns to caller when the callee ends."
noop_headless = false noop_headless = false
source = "kelebek" source = "investigation"
confidence = "med" confidence = "high"
depends_on = [] depends_on = []
evidence = "" evidence = "native-RE (Ghidra): handler FUN_0041bc90 -> loader FUN_0040e980 -> resolver FUN_0044f390 indexes an 80-byte record table (base [ctx+0x414], count [ctx+0x40c]) at base+id*0x50 = the SYS4INI record layout {name[64],arc_id@0x40,file_number@0x44,offset@0x48,size@0x4c}. Confirmed statically: 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 pack-branch. See docs/engine-re.md + name-resolution.md #1."
confirm_by = ""
details = """
op 0x03 (call-script, argc 1): `call-script <id>`. RESOLVED — the id is a direct RAW index into
the SYS4INI global file table (the same table parse_sys4ini.py reads, but indexed WITHOUT skipping
'@' placeholders; SYS4INI has 13208 records / 2 placeholders). No separate on-disk id->code registry
exists; SYS4INI *is* the call-script registry.
Native mechanism (dispatch table `handler(op)=ctx[0x26c93+op]`, op 0x03 -> FUN_0041bc90):
1. FUN_0041bc90 fetches operand 1 (id), bounds-checks call depth (<=0x26), pushes a frame.
2. FUN_0040e980 (loader): opens the resource by id, reads the 0x20-byte SYS4 header, checks magic,
allocates per-frame code/local buffers from the header var-counts, reads the bytecode body,
pushes a script frame (stride 0x1e = 30 dwords, indexed by ctx[0x14f45]).
3. FUN_0044f390 (resolver): record = [ctx+0x414] + id*0x50. Tries a LOOSE OVERRIDE first
(CreateFileA on record.name -> mod/patch hook point), else opens archive [record.arc_id*0x100 +
ctx+0x410], SetFilePointer to record.offset, size = record.size.
(High-byte-tagged ids `id & 0xff000000` select an alternate pack via [ctx+0x3028]; UNUSED by the
corpus -- 0/297 ids have a high byte.)
Companion op 0x8f `call` is INTRA-script (a local JSR), not cross-script -- see its entry.
This also names the whole call graph statically (build/callscript-names.json).
"""
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "script id = raw SYS4INI file index"
observed_types = ["imm", "g-int", "l-ptr"] observed_types = ["imm", "g-int", "l-ptr"]
[[opcode]] [[opcode]]
@@ -1428,17 +1447,18 @@ abi_source = "kelebek+decode-validated"
[opcode.semantics] [opcode.semantics]
name = "call" name = "call"
category = "unknown" category = "control"
summary = "" summary = "intra-script subroutine call (local JSR): PC = frame.codebase + operand*4; pushes a return address on the per-frame return stack. NOT cross-script (that is call-script 0x03)."
noop_headless = false noop_headless = false
source = "kelebek" source = "investigation"
confidence = "med" confidence = "high"
depends_on = [] depends_on = []
evidence = "" evidence = "native-RE (Ghidra): handler FUN_0041fba0 (= ctx[0x26c93+0x8f]) sets [frame PC @+0x53d2c] = [frame codebase @+0x53d28] + operand*4 and pushes ((pc-base)>>2)+3 onto the per-frame return stack ([ctx+0x552e8]/[ctx+0x55248]). Target is a code OFFSET within the current script (matches header table T3 tag 0x8F = local call targets), confirming it is a local JSR, not a script load."
confirm_by = ""
[[opcode.semantics.args]] [[opcode.semantics.args]]
i = 1 i = 1
role = "" role = "local code target (word offset within current script)"
observed_types = ["imm", "g-int"] observed_types = ["imm", "g-int"]
[[opcode]] [[opcode]]