diff --git a/docs/superpowers/plans/2026-07-07-gfx-command-buffer-phase3.md b/docs/superpowers/plans/2026-07-07-gfx-command-buffer-phase3.md new file mode 100644 index 0000000..dcffae1 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-gfx-command-buffer-phase3.md @@ -0,0 +1,534 @@ +# GFX Command-Buffer — Phase 3 Implementation Plan (TDD) + +> **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:** Execute the 13 gfx command-buffer ops against a VM-owned `GfxState` so `0x215` returns distinct per-object slots and `0x218`/`0x21a` return stored geometry vectors — fixing the background/sprite render drift. + +**Architecture:** `GfxState` is version-neutral VM execution state (like the global bank); the ops are new `case`s in `VirtualMachine.Step` that mutate/query it. QUERY ops `Write` results into operands exactly like `get-texture-size`. No new `IHost` methods for pure-data ops. Design: `docs/superpowers/specs/2026-07-07-gfx-command-buffer-design.md`; op contract: `docs/engine-re.md` (gfx op-contract table). + +**Tech Stack:** C# / .NET 8 (`engine/`, `AgeEngine.sln`, xUnit). Opcode build: `py -3.11 -X utf8 tools/opcodes_build.py --build`. Godot 4.7 .NET (`godot/`). + +## Global Constraints + +- **Dispatch keys on the `label` field, not `name`.** `OpcodeTableJson.Load` reads `opcodes.json` `label`; `VirtualMachine.Step` switches on `_t.Label(op)`. My Phase-1 edits set `name` only, so **Task 3.2 must set each gfx op's `label` to its dispatch string** (the `0x208`/`0x1fb` precedent) and rebuild — otherwise the new `case`s never match. +- **Run Python as** `py -3.11 -X utf8 tools/opcodes_build.py --build`; never hand-edit generated files; `--lint` must be `0 errors`. +- **Operand indexing:** `a[i]` is 0-based; contract operand *n* = `a[n-1]`. Read inputs with `Read(a[i])`, write outputs with `Write(a[i], val)`. +- **Parity:** each gfx op is one step, returns `pc+1` (same as the old `OnStub`). Base-ISA tests and the Godot `--selftest` run synthetic/non-gfx scenes → must stay green and byte-identical. Verify with `dotnet test engine/AgeEngine.sln`. +- **Seam:** `GfxState` lives in `Age.Engine/Model`; `Vm` may reference `Model`. No `Sys4`/Godot references from the VM. +- **Testing principle:** synthesize scenes with `ScriptAssembler` (see `SyntheticSceneTests`); never disable a feature to keep a real scene matching a frozen number. + +## Confirmed constants (from Ghidra, Phase 1 + this pass) + +| op | label (dispatch) | argc | GfxState action | +|---|---|---|---| +| `0x1a2` | `gfx-cmd-register` | 1 | `GetOrCreate(a0)` (register) | +| `0x1f7` | `gfx-elem-create` | 2 | `GetOrCreate(a0)` (assign slot if new) | +| `0x1fa` | `gfx-elem-release` | 1 | `Release(a0)` | +| `0x212` | `set-gfx-field64` | 2 | `GetOrCreate(a0).Field64 = a1` | +| `0x213` | `set-gfx-xy` | 3 | `o=GetOrCreate(a0); o.Field68=a1; o.Field6c=a2` | +| `0x215` | `query-gfx-object?` | 2 | `Write(a0, QuerySlot(a1))` | +| `0x216` | `query-gfx-field?` | 2 | `Write(a0, QueryField(a1))` | +| `0x217` | `set-gfx-geom3` | 4 | `GetOrCreate(a0).V18 = (a1,a2,a3)` | +| `0x218` | `get-gfx-geom3?` | 4 | `v=TryGet(a0)?.V18; Write(a1,v.X);Write(a2,v.Y);Write(a3,v.Z)` | +| `0x219` | `set-gfx-geom3-b` | 4 | `GetOrCreate(a0).V24 = (a1,a2,a3)` | +| `0x21a` | `get-gfx-geom3-b?` | 4 | `v=TryGet(a0)?.V24; Write(a1..a3)` | +| `0x1ff` | `set-gfx-geom3-c` | 4 | `GetOrCreate(a0).V16c = (a1,a2,a3)` | +| `0x202` | `gfx-blit-color` | 5 | `GetOrCreate(a0).Color = PackColor(a3,a4)` (blend deferred) | +| `0x203` | `gfx-draw-color` | 4 | `GetOrCreate(a0).Color = PackColor(a2,a3)` (blend deferred) | + +**Vector pairing (native obj fields):** V18 = `+0x18/1c/20` (0x217 set ↔ 0x218 get, anchor); V24 = `+0x24/28/2c` (0x219 set ↔ 0x21a get, position); V16c = `+0x16c` (0x1ff set). All handles resolve in the one `ctx+0x408` registry; getters return `(0,0,0)` on miss. **Simplifications (not drift-critical; validate in Phase 4 if a scene needs them):** `0x212/0x213` index a distinct native table (`ctx+0x14d54`) — modelled on the same object map keyed by their idx; `0x216` reads `ctx+0x46d14`, which no op in the family writes → `QueryField` returns 0. + +## File structure + +| File | Responsibility | Task | +|---|---|---| +| `engine/Age.Engine/Model/GfxState.cs` | **New.** Handle→object registry, slot allocator, PackColor | 3.1 | +| `engine/Age.Engine.Tests/GfxStateTests.cs` | **New.** Pure-data unit tests | 3.1 | +| `vm-map/opcodes.toml` | Set `label` = dispatch string for the 13 ops | 3.2 | +| `engine/Age.Engine/Vm/VirtualMachine.cs` | `Gfx` property + the 13 `case`s | 3.3, 3.4, 3.5 | +| `engine/Age.Engine.Tests/GfxCommandBufferTests.cs` | **New.** Synthetic-scene op tests + drift regression | 3.3–3.6 | +| `engine/Age.Cli/Program.cs` (`GfxTraceHost`) | Oracle reads real `vm.Gfx` slots | 3.7 | +| `godot/Main.cs` | Guarded alpha log; confirm per-slot compositing | 3.7 | + +--- + +### Task 3.1 — `GfxState` model (pure data, host-free) + +**Files:** Create `engine/Age.Engine/Model/GfxState.cs`; Test `engine/Age.Engine.Tests/GfxStateTests.cs`. + +**Interfaces produced:** `GfxState` with `GfxObject GetOrCreate(long handle)`, `GfxObject? TryGet(long)`, `int QuerySlot(long)`, `long QueryField(long)`, `void Release(long)`, `static long PackColor(long alpha, long color)`; `GfxObject { int Slot; (long X,long Y,long Z) V18, V24, V16c; long Field64, Field68, Field6c; long Color; }`. + +- [ ] **Step 1: Write the failing tests** + +Create `engine/Age.Engine.Tests/GfxStateTests.cs`: +```csharp +using Age.Engine.Model; +using Xunit; + +public class GfxStateTests +{ + [Fact] + public void DistinctHandlesGetDistinctSlots() + { + var g = new GfxState(); + int s1 = g.GetOrCreate(0x1000).Slot; + int s2 = g.GetOrCreate(0x2000).Slot; + Assert.NotEqual(s1, s2); + Assert.Equal(s1, g.QuerySlot(0x1000)); // stable + Assert.Equal(-1, g.QuerySlot(0x9999)); // unknown -> -1 (matches native 0xffffffff) + } + + [Fact] + public void VectorsRoundTripPerObject() + { + var g = new GfxState(); + g.GetOrCreate(0x1000).V18 = (10, 20, 30); + g.GetOrCreate(0x1000).V24 = (40, 50, 60); + Assert.Equal((10L, 20L, 30L), g.TryGet(0x1000)!.V18); + Assert.Equal((40L, 50L, 60L), g.TryGet(0x1000)!.V24); + Assert.Null(g.TryGet(0x2000)); // untouched handle absent + } + + [Fact] + public void ReleaseFreesTheSlotForReuse() + { + var g = new GfxState(); + int s1 = g.GetOrCreate(0x1000).Slot; + g.Release(0x1000); + Assert.Equal(-1, g.QuerySlot(0x1000)); + Assert.Equal(s1, g.GetOrCreate(0x2000).Slot); // freed slot reused + } + + [Fact] + public void PackColorPacksArgb() + => Assert.Equal(0x80_112233L, GfxState.PackColor(0x80, 0x112233)); +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~GfxStateTests` +Expected: FAIL — `GfxState` does not exist (compile error). + +- [ ] **Step 3: Implement `GfxState`** + +Create `engine/Age.Engine/Model/GfxState.cs`: +```csharp +namespace Age.Engine.Model; + +/// Host-agnostic model of the AGE native gfx command-buffer (reversed in +/// docs/engine-re.md, gfx op-contract table). One registry maps an object handle to a GfxObject — the +/// native ctx+0x408 map that op 0x215 queries and the geometry get/set ops share. Each object carries a +/// slot (returned by 0x215) and three 3-vectors: V18 (set 0x217 / get 0x218, anchor), V24 (set 0x219 / +/// get 0x21a, position), V16c (set 0x1ff). The native DirectDraw workers are NOT modelled — only the data +/// the query ops read back, which is all the bytecode geometry math needs. +public sealed class GfxState +{ + public sealed class GfxObject + { + public int Slot = -1; + public (long X, long Y, long Z) V18, V24, V16c; + public long Field64, Field68, Field6c; + public long Color; + } + + private readonly Dictionary _objects = new(); + private readonly SortedSet _free = new(); + private int _nextSlot = 4; // observed native slot range is 4..13 + private readonly Dictionary _fieldTable = new(); // ctx+0x46d14 (0x216); no family writer -> default 0 + public long CurrentObject { get; private set; } + + private int AcquireSlot() + { + if (_free.Count > 0) { int s = _free.Min; _free.Remove(s); return s; } + return _nextSlot++; + } + + public GfxObject GetOrCreate(long handle) + { + if (!_objects.TryGetValue(handle, out var o)) + { + o = new GfxObject { Slot = AcquireSlot() }; + _objects[handle] = o; + } + CurrentObject = handle; + return o; + } + + public GfxObject? TryGet(long handle) => _objects.TryGetValue(handle, out var o) ? o : null; + public int QuerySlot(long handle) => _objects.TryGetValue(handle, out var o) ? o.Slot : -1; + public long QueryField(long idx) => _fieldTable.TryGetValue(idx, out var v) ? v : 0; + + public void Release(long handle) + { + if (_objects.TryGetValue(handle, out var o)) { if (o.Slot >= 0) _free.Add(o.Slot); _objects.Remove(handle); } + } + + /// Pack (alpha, rgb) → 0xAARRGGBB, matching op 0x202/0x203's handler bit-manipulation for the + /// common (non-negative-sentinel) case. The alpha<0 / color<0 native-fetch path is deferred. + public static long PackColor(long alpha, long color) + => ((alpha & 0xff) << 24) | (color & 0xffffff); +} +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~GfxStateTests` +Expected: PASS (all 4 facts). + +- [ ] **Step 5: Commit** +```bash +git add engine/Age.Engine/Model/GfxState.cs engine/Age.Engine.Tests/GfxStateTests.cs +git commit -m "feat(gfx): GfxState host-agnostic command-buffer model + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3.2 — Set opcode `label`s to the dispatch strings + rebuild + +The VM switches on `_t.Label(op)` = the `opcodes.toml` **`label`** field. Set each gfx op's `label` to its dispatch string so the Task-3.3/3.4/3.5 `case`s match. + +**Files:** Modify `vm-map/opcodes.toml` (13 ops); then rebuild generated files. + +- [ ] **Step 1: Set the `label` line for each gfx op** + +For each op below, change its `label = "u00…"` line to the dispatch string (the block starts at `op = `): + +`0x1a2`→`gfx-cmd-register`, `0x1f7`→`gfx-elem-create`, `0x1fa`→`gfx-elem-release`, `0x1ff`→`set-gfx-geom3-c`, `0x202`→`gfx-blit-color`, `0x203`→`gfx-draw-color`, `0x212`→`set-gfx-field64`, `0x213`→`set-gfx-xy`, `0x215`→`query-gfx-object?`, `0x216`→`query-gfx-field?`, `0x217`→`set-gfx-geom3`, `0x218`→`get-gfx-geom3?`, `0x219`→`set-gfx-geom3-b`, `0x21a`→`get-gfx-geom3-b?`. + +Example (op `0x215`): change `label = "u00421160"` to `label = "query-gfx-object?"`. + +- [ ] **Step 2: Rebuild + lint + confirm** + +Run: `py -3.11 -X utf8 tools/opcodes_build.py --build` +Run: `py -3.11 -X utf8 tools/opcodes_build.py --lint` +Expected: build writes 4 files; lint `0 errors, 0 warnings`. +Run: `grep -c '"label": "query-gfx-object?"' build/opcodes.json` +Expected: `1`. + +- [ ] **Step 3: Confirm base-ISA parity (labels didn't touch handled ops)** + +Run: `dotnet test engine/AgeEngine.sln` +Expected: all green (these ops are still stubbed at this point; only their label strings changed — disasm/VM unaffected until Task 3.3). + +- [ ] **Step 4: Commit** +```bash +git add vm-map/opcodes.toml tools/age_opcodes_himegari.py build/opcodes.json docs/opcode-reference.md build/opcode-coverage.md +git commit -m "chore(gfx): set dispatch labels for the gfx command-buffer ops + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3.3 — Wire `Gfx` into the VM + implement the QUERY ops (the drift drivers) + +**Files:** Modify `engine/Age.Engine/Vm/VirtualMachine.cs`; Test `engine/Age.Engine.Tests/GfxCommandBufferTests.cs` (new). + +**Interfaces:** Consumes `GfxState` (Task 3.1). Produces `public GfxState Gfx { get; }` on `VirtualMachine`, and dispatch for `query-gfx-object?`/`query-gfx-field?`/`get-gfx-geom3?`/`get-gfx-geom3-b?`. + +- [ ] **Step 1: Write the failing test** + +Create `engine/Age.Engine.Tests/GfxCommandBufferTests.cs`: +```csharp +using System.Collections.Generic; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class GfxCommandBufferTests +{ + private static OpcodeTable T() => OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand G(int addr) => new(3, addr); // global-int + private static Operand I(long v) => new(0, v); // immediate + + // op ctor helpers (label→op: 0x217 set-geom3, 0x218 get-geom3, 0x215 query, 0x2 exit) + private static (int, Operand[]) SetGeom3(int handle, int a, int b, int c) => (0x217, new[] { G(handle), G(a), G(b), G(c) }); + private static (int, Operand[]) GetGeom3(int handle, int a, int b, int c) => (0x218, new[] { G(handle), G(a), G(b), G(c) }); + private static (int, Operand[]) Query(int outAddr, int handle) => (0x215, new[] { G(outAddr), G(handle) }); + private static (int, Operand[]) MovGI(int dst, long v) => (0x55, new[] { G(dst), I(v) }); + private static (int, Operand[]) Exit() => (0x2, System.Array.Empty()); + + [Fact] + public void SetThenGetGeom3RoundTripsThroughTheObject() + { + var t = T(); + // g[1]=handle; set V18 from g[2,3,4]=(10,20,30); read V18 back into g[5,6,7]; then exit. + var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])> + { + MovGI(1, 0x1000), MovGI(2, 10), MovGI(3, 20), MovGI(4, 30), + SetGeom3(1, 2, 3, 4), + GetGeom3(1, 5, 6, 7), + Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, t, new RecordingHost()); + vm.Run(); + Assert.Equal(10, vm.Globals[5]); + Assert.Equal(20, vm.Globals[6]); + Assert.Equal(30, vm.Globals[7]); + } + + [Fact] + public void QueryReturnsDistinctSlotsPerHandle_NotZero() + { + var t = T(); + // create two objects via set-geom, then query each into g[10], g[11]. + var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])> + { + MovGI(1, 0x1000), MovGI(2, 0x2000), MovGI(3, 0), + SetGeom3(1, 3, 3, 3), SetGeom3(2, 3, 3, 3), + Query(10, 1), Query(11, 2), Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, t, new RecordingHost()); + vm.Run(); + Assert.NotEqual(0, vm.Globals[10]); // not collapsed to slot 0 + Assert.NotEqual(vm.Globals[10], vm.Globals[11]); // distinct slots => no collapse + } +} +``` +(The op numbers `0x217/0x218/0x215` here bypass label lookup at assemble time — the VM still dispatches by `_t.Label`, which Task 3.2 made match.) + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~GfxCommandBufferTests` +Expected: FAIL — ops still hit `default`/stub, so `g[5..7]`/`g[10..11]` are never written (assert fails / `KeyNotFoundException`). + +- [ ] **Step 3: Add the `Gfx` property** + +In `engine/Age.Engine/Vm/VirtualMachine.cs`, after the `Globals`/`GlobalStrings` properties (~line 25): +```csharp + public GfxState Gfx { get; } = new(); +``` + +- [ ] **Step 4: Add the QUERY `case`s** + +In `Step`, before the `default:` case (~line 228): +```csharp + case "query-gfx-object?": // 0x215 (out)(handle) -> slot | -1 + Write(a[0], Gfx.QuerySlot(Read(a[1]))); return pc + 1; + case "query-gfx-field?": // 0x216 (out)(idx) + Write(a[0], Gfx.QueryField(Read(a[1]))); return pc + 1; + case "get-gfx-geom3?": // 0x218 (handle)(outA)(outB)(outC) <- V18 + { + var v = Gfx.TryGet(Read(a[0]))?.V18 ?? default; + Write(a[1], v.X); Write(a[2], v.Y); Write(a[3], v.Z); return pc + 1; + } + case "get-gfx-geom3-b?": // 0x21a (handle)(outA)(outB)(outC) <- V24 + { + var v = Gfx.TryGet(Read(a[0]))?.V24 ?? default; + Write(a[1], v.X); Write(a[2], v.Y); Write(a[3], v.Z); return pc + 1; + } +``` +(These reference `set-gfx-geom3` which Task 3.4 adds; the round-trip test needs both, so run the suite after Task 3.4. To keep Task 3.3 self-checking, temporarily add the `set-gfx-geom3` case now too — or run Step 5 after 3.4. Recommended: implement Task 3.4's SET case in the same edit; the split is documentation-only.) + +- [ ] **Step 5: (with Task 3.4) run to verify pass** — see Task 3.4 Step 3. + +--- + +### Task 3.4 — Implement the SET + lifecycle ops + +**Files:** Modify `engine/Age.Engine/Vm/VirtualMachine.cs`. + +- [ ] **Step 1: Add the SET/lifecycle `case`s** (alongside Task 3.3's block) +```csharp + case "set-gfx-geom3": // 0x217 (handle)(a)(b)(c) -> V18 + Gfx.GetOrCreate(Read(a[0])).V18 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; + case "set-gfx-geom3-b": // 0x219 (handle)(a)(b)(c) -> V24 + Gfx.GetOrCreate(Read(a[0])).V24 = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; + case "set-gfx-geom3-c": // 0x1ff (handle)(a)(b)(c) -> V16c + Gfx.GetOrCreate(Read(a[0])).V16c = (Read(a[1]), Read(a[2]), Read(a[3])); return pc + 1; + case "set-gfx-field64": // 0x212 (idx)(val) + Gfx.GetOrCreate(Read(a[0])).Field64 = Read(a[1]); return pc + 1; + case "set-gfx-xy": // 0x213 (idx)(x)(y) + { + var o = Gfx.GetOrCreate(Read(a[0])); o.Field68 = Read(a[1]); o.Field6c = Read(a[2]); return pc + 1; + } + case "gfx-cmd-register": // 0x1a2 (val) — register/select + case "gfx-elem-create": // 0x1f7 (handle)(count) — ensure object + slot + Gfx.GetOrCreate(Read(a[0])); return pc + 1; + case "gfx-elem-release": // 0x1fa (handle) + Gfx.Release(Read(a[0])); return pc + 1; +``` + +- [ ] **Step 2: Add the round-trip + slot facts** (already in Task 3.3's test file) + +- [ ] **Step 3: Run the gfx suite** + +Run: `dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~GfxCommandBufferTests` +Expected: PASS (`SetThenGetGeom3RoundTrips`, `QueryReturnsDistinctSlots`). + +- [ ] **Step 4: Run the full suite (parity)** + +Run: `dotnet test engine/AgeEngine.sln` +Expected: all green — base-ISA/synthetic tests unaffected (gfx ops absent from them; still one step / `pc+1`). + +- [ ] **Step 5: Commit** +```bash +git add engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine.Tests/GfxCommandBufferTests.cs +git commit -m "feat(gfx): execute gfx command-buffer query/set/lifecycle ops in the VM + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3.5 — Colored draws (`0x202`/`0x203`) with alpha deferred + guarded + +**Files:** Modify `engine/Age.Engine/Vm/VirtualMachine.cs`; Test in `GfxCommandBufferTests.cs`. + +- [ ] **Step 1: Write the color-packing test** +```csharp + private static (int, Operand[]) BlitColor(int h, int x, int y, int alpha, int color) + => (0x202, new[] { G(h), G(x), G(y), G(alpha), G(color) }); + + [Fact] + public void BlitColorStoresPackedArgbOnTheObject() + { + var t = T(); + var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])> + { + MovGI(1, 0x1000), MovGI(2, 0), MovGI(3, 0), MovGI(4, 0x80), MovGI(5, 0x112233), + BlitColor(1, 2, 3, 4, 5), Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, t, new RecordingHost()); + vm.Run(); + Assert.Equal(0x80_112233L, vm.Gfx.TryGet(0x1000)!.Color); + } +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~BlitColorStores` +Expected: FAIL (op stubbed → object absent → `TryGet` null-deref). + +- [ ] **Step 3: Add the colored-draw `case`s (state now; blend deferred)** +```csharp + case "gfx-blit-color": // 0x202 (handle)(x)(y)(alpha)(color) — blend deferred + Gfx.GetOrCreate(Read(a[0])).Color = GfxState.PackColor(Read(a[3]), Read(a[4])); + WarnAlphaDeferredOnce(); return pc + 1; + case "gfx-draw-color": // 0x203 (handle)(v)(alpha)(color) — blend deferred + Gfx.GetOrCreate(Read(a[0])).Color = GfxState.PackColor(Read(a[2]), Read(a[3])); + WarnAlphaDeferredOnce(); return pc + 1; +``` +Add a one-time guard near the bottom of the class (routes through the trace sink so it's visible, not silent): +```csharp + private bool _warnedAlpha; + private void WarnAlphaDeferredOnce() + { + if (_warnedAlpha) return; _warnedAlpha = true; + _sink.Emit(TraceEvent.Stub(0x202, -1)); // surfaces "gfx alpha/blend deferred" in --trace + } +``` +(If `TraceEvent.Stub` is unsuitable as a general marker, add a dedicated `TraceEvent.Note(string)`; keep it observe-only so parity holds.) + +- [ ] **Step 4: Run to verify pass + full suite** + +Run: `dotnet test engine/AgeEngine.sln` +Expected: `BlitColorStores…` PASS; all others green (the guard is observe-only → step/trace parity holds). + +- [ ] **Step 5: Commit** +```bash +git add engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine.Tests/GfxCommandBufferTests.cs +git commit -m "feat(gfx): colored-draw ops store packed ARGB; alpha blend deferred + guarded + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3.6 — Drift regression test (two different-sized backgrounds don't drift) + +The machine-checkable heart of the fix: reproduce the anchor-preserve idiom on two different-sized objects and assert each keeps its own geometry (pre-fix they'd share slot 0 and cross-contaminate). + +**Files:** Test in `engine/Age.Engine.Tests/GfxCommandBufferTests.cs`. + +- [ ] **Step 1: Write the regression test** +```csharp + [Fact] + public void TwoObjectsKeepIndependentGeometry_NoDrift() + { + var t = T(); + // obj A(0x1000): V24=(100,500,0). obj B(0x2000): V24=(300,100,0). + // Read each back; A's values must be unaffected by B's write. + var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])> + { + MovGI(1, 0x1000), MovGI(2, 0x2000), + MovGI(3, 100), MovGI(4, 500), MovGI(5, 0), + (0x219, new[]{ G(1), G(3), G(4), G(5) }), // set B-vector on A + MovGI(3, 300), MovGI(4, 100), + (0x219, new[]{ G(2), G(3), G(4), G(5) }), // set B-vector on B + (0x21a, new[]{ G(1), G(10), G(11), G(12) }), // read A back + (0x21a, new[]{ G(2), G(20), G(21), G(22) }), // read B back + Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, t, new RecordingHost()); + vm.Run(); + Assert.Equal((100L, 500L), (vm.Globals[10], vm.Globals[11])); // A intact + Assert.Equal((300L, 100L), (vm.Globals[20], vm.Globals[21])); // B intact, no cross-contamination + } +``` + +- [ ] **Step 2: Run** + +Run: `dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~TwoObjectsKeep` +Expected: PASS (objects are independent by construction of `GfxState`). + +- [ ] **Step 3: Commit** +```bash +git add engine/Age.Engine.Tests/GfxCommandBufferTests.cs +git commit -m "test(gfx): drift regression — independent per-object geometry + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +### Task 3.7 — Host side: CLI oracle reads real slots; Godot guard + +**Files:** Modify `engine/Age.Cli/Program.cs` (`GfxTraceHost`), `godot/Main.cs`. + +- [ ] **Step 1: CLI gfx oracle — report real slots** + +`Age.Cli gfx` builds a VM; after `vm.Run()`, the executed `set-texture`/`draw-texture` already log per the existing `GfxTraceHost`. Add, after the run, a dump of `vm.Gfx` object→slot assignments so the oracle shows distinct slots (not all 0). Concretely, extend the summary print in the `gfx` command block: +```csharp +Console.WriteLine($" gfx objects: {string.Join(", ", /* iterate vm.Gfx slots */ )}"); +``` +(Expose a read-only enumeration on `GfxState`, e.g. `IEnumerable<(long Handle, int Slot)> Objects`, if not already; add a matching `GfxStateTests` fact.) + +- [ ] **Step 2: Godot compositor — confirm per-slot + guard log** + +In `godot/Main.cs`/`GodotAdvHost.cs`, confirm `BlitSlot`/`_slotBmp` already key on the slot the VM supplies (they do). Add a one-time `GD.Print("[gfx] alpha/blend deferred")` when a colored-draw path is hit, mirroring the VM guard, so the deferral is visible in the Godot log too. + +- [ ] **Step 3: Build both** + +Run: `dotnet build engine/AgeEngine.sln` +Run: `godot --headless --path godot --import && dotnet build godot/Himegari.csproj` +Expected: both succeed. + +- [ ] **Step 4: Godot selftest parity** + +Run: `godot --headless --path godot -- --selftest` +Expected: `SELFTEST OK …` (synthetic scene, no gfx ops → unaffected). + +- [ ] **Step 5: Commit** +```bash +git add engine/Age.Cli/Program.cs godot/Main.cs godot/GodotAdvHost.cs +git commit -m "feat(gfx): CLI oracle reports gfx slots; Godot alpha-deferred guard + +Co-Authored-By: Claude Opus 4.8 " +``` + +--- + +## Self-review + +- **Spec coverage:** GfxState model → 3.1; dispatch enablement (the `label` gotcha) → 3.2; all 13 ops (query/set/lifecycle/colored) → 3.3–3.5; drift regression → 3.6; oracle + compositor guard → 3.7. Deferral (alpha blend) implemented as state-now/guard → 3.5/3.7. ✓ +- **Discovery gate:** the one open risk (vector pairing) was resolved before planning (V18/V24/V16c constants baked in); remaining simplifications (`0x212/0x213` idx table, `0x216` field table) are flagged non-drift-critical and validated in Phase 4. ✓ +- **Placeholder scan:** every code step has complete code; the only prose-only step is 3.7 Step 1–2 (host glue that depends on the existing `GfxTraceHost`/`Main` shapes) — bounded and explicit. ✓ +- **Type consistency:** `GfxState.GetOrCreate/TryGet/QuerySlot/QueryField/Release/PackColor` and `GfxObject.{Slot,V18,V24,V16c,Field64/68/6c,Color}` are used identically in Model, VM cases, and tests; VM dispatch strings match the Task-3.2 `label`s and the confirmed-constants table. ✓ +- **Parity:** every op returns `pc+1`, one step; guard is observe-only; base-ISA/synthetic/selftest reaffirmed green in 3.4/3.5/3.7. ✓ + +**Next after Phase 3:** Phase 4 (validate against the real drift scene — `Age.Cli gfx` numbers + screenshot), per `docs/superpowers/plans/2026-07-07-gfx-command-buffer.md`. diff --git a/docs/superpowers/specs/2026-07-07-gfx-command-buffer-design.md b/docs/superpowers/specs/2026-07-07-gfx-command-buffer-design.md index 2479004..4585de1 100644 --- a/docs/superpowers/specs/2026-07-07-gfx-command-buffer-design.md +++ b/docs/superpowers/specs/2026-07-07-gfx-command-buffer-design.md @@ -174,10 +174,12 @@ deferred, and it is visibly logged so it never reads as "done." ## Risks / open questions (Phase-3 confirmations, not deferrals) -1. **Set/get → vector pairing** (`0x217`/`0x219`/`0x1ff` set which of `vecA/B/C`; `0x218`/`0x21a` get which): - pinned by reading the 5 native workers' object-field offsets (`FUN_0047e800/e960/e910` setters, - `FUN_0047f360/f2e0` getters — 5 quick decompiles) **or** by the `0x217→0x218` round-trip oracle. The model - already supports N vectors; this is a lookup-table fill, ~30 min at the start of Phase 3. +1. **Set/get → vector pairing — RESOLVED (2026-07-07).** Read the 5 native workers: **V18** (`+0x18/1c/20`) is + SET by `0x217` (`FUN_0047e960`) and GET by `0x218` (`FUN_0047f360`) = the anchor vector; **V24** + (`+0x24/28/2c`) is SET by `0x219` (`FUN_0047e910`) and GET by `0x21a` (`FUN_0047f2e0`) = the position vector; + **V16c** (`+0x16c`) is SET by `0x1ff` (`FUN_0047e800`), no getter in the family. All resolve the object in the + **one `ctx+0x408` registry** (via `FUN_0047ded0`) that `0x215` also queries → the model is a single + handle→object map, getters return `(0,0,0)` on miss. Constants baked into the Phase-3 plan. 2. **Slot-allocation policy** (distinct 4..13): confirm against `label_125bd` / the `0x3239` record table (whether slots are positional or free-list). A `SlotAllocator` abstraction isolates this; the drift fix only needs *distinct, stable* slots per live object.