docs(a1): spec for the C# VM core (headless, trace-diff vs vm0.py)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-06 13:32:42 -04:00
parent 0ffb9620a3
commit a6cd43ce74

View File

@@ -0,0 +1,198 @@
# Design: A1 — C# VM Core (headless, differential-tested against vm0.py)
Status: **approved (design)** · Date: 2026-07-06
Related: `docs/phase-a-slice-plan.md` (A1), `docs/remake-architecture-and-roadmap.md`, `tools/vm0.py`,
`tools/sys4load.py`, `vm-map/opcodes.toml``build/opcodes.json`.
## Problem / goal
A0 validated the AGE/SYS4 execution model in Python (`vm0.py`): RECOVER unit test passes, and 282/294
ADV scenes emit a clean in-order subsequence of `dialogue.jsonl` with zero garbage. A1 ports that
*validated* model to **C#** — the runtime language chosen for the hot 1.5M-instruction fetch/execute
loop (GDScript is too slow; see roadmap). A1 is **headless**: it reproduces A0's behavior exactly,
proven by a per-scene differential trace against `vm0.py`. No rendering, input, audio, or Godot — those
are A2.
## Goals
1. A C# VM that reproduces `vm0.py` **exactly** on every SC/SP scene (per-scene trace-identical) and
passes the RECOVER pointer/array/control-flow unit test.
2. Establish the roadmap's seams — **VM core** (version/game-agnostic) / **SYS4 front-end**
(parser + codec + opcode table) / **backend (`IHost`)** — with a **version-neutral `Script`
contract** as an explicit constraint, so retooling to a plugin architecture later is additive.
3. A standalone `.NET 8` solution that builds and tests without Godot.
## Non-goals (explicitly deferred to A2 / Phase B)
Rendering, message window, input, audio, real `call-script` resolution, Godot integration, per-scene
state seeding / unlocking the 12 EMPTY scenes, a version-front-end plugin system or manifest loader,
and embedding `opcodes.json` for a shipped build. Also not porting the non-VM Python tools
(`extract_*`, `global_map`, `opcodes_build`) — those stay in Python (toolchain vs runtime).
## Architecture
New `.NET 8` solution under `engine/` (`engine/AgeEngine.sln`). Three projects:
```
engine/
AgeEngine.sln
Age.Engine/ class library — the reusable engine
Model/ version-NEUTRAL contract (the load-bearing seam)
Vm/ execution core (depends ONLY on Model + Hosting)
Sys4/ SYS4 front-end (the ONLY SYS4/encoding-specific code)
Hosting/ IHost seam + CaptureHost (headless)
Age.Cli/ console runner: recover | run <f> | sweep | trace <out.json>
Age.Engine.Tests/ xUnit: RECOVER, parser, trace-diff
```
**Seam rule (enforced, reviewed):** `Age.Engine.Vm` references only `Age.Engine.Model` and
`Age.Engine.Hosting`. It must not reference `Age.Engine.Sys4` or do file I/O. This keeps the core
version-neutral; extracting `IVersionFrontend` later = naming the interface `Sys4Loader` already
satisfies + adding a manifest loader (pure addition, no core edits).
### `Age.Engine.Model` — the neutral contract
```csharp
public readonly record struct Operand(int Type, long Value); // type tags match vm0 (0=imm,2=str,3=g-int,9=l-int,…)
public sealed record Instruction(int Offset, int Opcode, IReadOnlyList<Operand> Args);
public sealed record ScriptHeader(int LocalInt1, int LocalFloats, int LocalStrings1,
int LocalInt2, int Unknown, int LocalStrings2); // F0F5
public sealed class Script {
public required ScriptHeader Header { get; init; }
public required IReadOnlyList<Instruction> Instructions { get; init; }
public required IReadOnlyDictionary<int,int> IndexByOffset { get; init; } // dword offset -> instruction index
public required IReadOnlyDictionary<int,string> Strings { get; init; } // resolved type-2 strings (offset -> decoded)
public string GetString(int offset) => Strings.TryGetValue(offset, out var s) ? s : "";
}
public sealed class OpcodeTable { // from build/opcodes.json
public bool TryGet(int opcode, out string label, out int argc);
public string Label(int opcode); // dispatch key (Kelebek label)
public int Argc(int opcode); // decode width
}
```
The VM consumes `Script` + `OpcodeTable` + `IHost`. It never sees a byte, a file path, or the codec.
### `Age.Engine.Vm` — faithful port of `vm0.py`
Stateful `VirtualMachine` mirroring vm0's `VM` (so RECOVER can pre-seed and inspect state):
```csharp
public sealed class VirtualMachine {
public Dictionary<int,long> Globals { get; } = new(); // flat global-int bank
public Dictionary<int,string> GlobalStrings { get; } = new();
public List<(int Offset, string Text)> Emitted { get; } = new(); // captured show-text
public string? HaltReason { get; private set; }
public long Steps { get; private set; }
public VirtualMachine(Script script, OpcodeTable table, IHost host, VmOptions? options = null);
public void Run(int entryOffset = 0);
}
public sealed record VmOptions(int EmitCap = 2, long MaxSteps = 2_000_000);
```
Ported element-for-element from `vm0.py` (same names, same order):
- **Operand resolution** with pointer/lvalue semantics: `Read`, `Write`, `BaseAddr`, `LookupStore`
(ptr dst takes a *reference* = the global address; reading a ptr dereferences; write-through-ptr).
- **Handlers:** ALU (`add sub mul div mod and or sar shl`), compares (`eq ne lt lte gr gre`), `mov`,
`set-string`, `lookup-array`, `lookup-array-2d`, `bit-set`, `bit-reset`, `check-bit`,
`copy-to-global`; control `jmp`, `call`, `ret`, `jcc` (`0xFFFFFFFF` = fallthrough),
`exit`/`exit-script`; `call-script``host.CallScript(id)` (stub); `show-text` → capture via host
with the **emit-cap loop-guard** (halt `LOOP:line@…` on the `(EmitCap+1)`-th emit of an offset);
`end-text-line`/`wait-for-input`/`set-font`/`comment`/`display-furigana`/`dev_ukn` → no-op; anything
else → `host.OnStub(opcode)` + fall through. Halt reasons: `exit`, `LOOP:…`, `STEP-LIMIT`,
`ret-underflow`, `pc-out-of-range` (identical strings to vm0).
- **Dispatch** on `table.Label(opcode)` (the Kelebek label) — the same switch `vm0.step()` does.
**Integer-semantics parity (critical):** vm0 uses Python ints. Two ops differ from C# defaults and MUST
be implemented to match Python, or the trace will diverge on negative operands:
- `div`**floor division** (C# `/` truncates toward zero; Python `//` floors). Pure-integer helper
(no `double` — precision-safe for full `long` range):
`PyDiv(a,b){ if(b==0) return 0; long q=a/b, r=a%b; if(r!=0 && (r<0)!=(b<0)) q--; return q; }`
- `mod` → result takes the **divisor's sign** (Python `%`; C# `%` takes the dividend's sign):
`PyMod(a,b){ if(b==0) return 0; long r=a%b; if(r!=0 && (r<0)!=(b<0)) r+=b; return r; }`
- Use `long` for all values (vm0's ints are unbounded; `long` matches for the magnitudes these scenes
reach). `sar`/`shl` use `a >> (b & 31)` / `a << (b & 31)` (arithmetic shift on signed `long`, matching
Python on the tested range). The trace-diff is the backstop that proves these choices.
### `Age.Engine.Sys4` — the front-end (only SYS4-specific code)
- `Sys4Loader.Load(string path) / Parse(byte[])` → neutral `Script`. Ports `sys4load`'s container parse:
header (magic `SYS44xx`, 13×u32, F0F12 = var-bank counts), sections (code `[0..F8)`, T1/T2/T3), the
`len = 1 + 2*argc` code walk with the **`data_array_end` shrink** (stop code at the lowest referenced
string/array offset), `IndexByOffset`, and resolve every type-2 operand into `Strings` via the codec.
- `Sys4StringCodec.Decode(body, dwordOffset)` → XOR-`0xFF` then cp932. Register
`System.Text.Encoding.CodePages` (`CodePagesEncodingProvider.Instance`) for code page 932.
- Opcode table via `OpcodeTableJson.Load(Paths.OpcodesJson)` reading `build/opcodes.json` (opcode →
`label`, `argc`). Argc drives the decode walk; label drives VM dispatch.
- `Paths` — a small resolver mirroring `tools/paths.py`: from the assembly location walk up to the
`age-reimpl/` root, expose `Data1`, `Extracted`, `Build`, `OpcodesJson`, and the game dir.
### `Age.Engine.Hosting` — the backend seam
```csharp
public interface IHost {
void ShowText(int offset, string text); // A1: capture. A2: message window.
void CallScript(long id); // A1: stub (log). Later: dispatch.
void OnStub(int opcode); // effectful draw/audio/input/unknown — A1: log/no-op.
}
public sealed class CaptureHost : IHost { /* records ShowText into a list; counts stubs; no-ops rest */ }
```
A2 = implement `IHost` for Godot. The VM is unchanged.
## Data flow
`Sys4Loader.Load(.BIN)``Script` ; `OpcodeTableJson.Load``OpcodeTable` ;
`new VirtualMachine(script, table, captureHost).Run()``Emitted` + `HaltReason` + `Globals`. The CLI
sweeps every SC/SP scene and, for `trace`, writes `{scene: {offsets, halt, steps}}`.
## Validation — per-scene differential trace
Shared trace format (both sides emit exactly this):
```json
{ "SC0000.BIN": { "offsets": [2457, 2464, ], "halt": "exit", "steps": 12345 }, }
```
- **Python side:** add `tools/vm0.py --trace <out.json>` — iterate the same SC/SP set as `--sweep`,
writing each scene's emitted string-offset list + halt reason + steps. (Reuses `run_scene`;
`offsets = [off for off,_ in vm.text]`.)
- **C# side:** `Age.Cli trace <out.json>` produces the identical structure over the same scenes.
- **Diff:** load both; assert per-scene equality of `offsets` (ordered) + `halt` + `steps`; on mismatch
report the scene and the first differing index. Trace-identical across all 294 = behavioral parity.
xUnit tests:
- `RecoverTests` — port `vm0.run_test` (seed the RECOVER globals, run, assert the 7 checks).
- `Sys4LoaderTests` — parse `MENU.BIN` + `SC0030.BIN`: assert header fields, section bounds, instruction
count, and that decoded `Strings` for a sample match `sys4load`'s output (**codec parity** — the
offset-only trace can't catch a decode bug, so this test guards it).
- `TraceDiffTests` — read `build/vm0-trace.json` (skip with a clear message if absent), run the C# sweep,
assert per-scene equality. This is the headline A1 gate.
**A1 done when:** RECOVER passes in C#; the C# trace is byte-identical to `vm0.py --trace` on all 294
scenes (so 282/294 dialogue-valid falls out); parser + codec-parity tests green.
## Opcode data
The engine reads `build/opcodes.json` (generated by `opcodes_build.py --build` from the canonical
`vm-map/opcodes.toml`) via `Paths.OpcodesJson`. It is a runtime data input, like the `.BIN` scripts —
the solution *builds* without Python; *running* requires `--build` to have produced the JSON.
Embedding it as an assembly resource for a shipped engine is deferred (A2/C).
## Risks
- **Integer semantics** (div/mod/shift sign behavior) — addressed above with `PyDiv`/`PyMod`; the
trace-diff is the definitive check.
- **Codec parity not covered by the offset-only trace** — covered by `Sys4LoaderTests` string-equality
against `sys4load`.
- **`data_array_end` shrink subtlety** — the trickiest parser detail; port carefully and validate via
`Sys4LoaderTests` instruction counts against `sys4load --summary`.
- **Toolchain/runtime drift** — the C# parser and `sys4load` must agree; the parser tests + trace-diff
pin them together. If they diverge later, the format spec in `docs/sys4-format-notes.md` is the
arbiter.
- **`build/opcodes.json` prerequisite** — CLI/tests fail fast with a clear message if it's missing
(run `opcodes_build.py --build`).
## Out of scope (future)
Godot backend + real effectful ops (A2), input + state seeding to unlock EMPTY scenes (A2/B),
`call-script` registry (deferred), version-front-end plugin + manifest (Phase C), shipped-engine
resource embedding.