Files
OpenMaidEngine/docs/superpowers/plans/2026-07-07-gfx-command-buffer-phase3.md
2026-07-07 17:24:44 -04:00

26 KiB
Raw Blame History

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 cases 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 cases 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 cases 3.3, 3.4, 3.5
engine/Age.Engine.Tests/GfxCommandBufferTests.cs New. Synthetic-scene op tests + drift regression 3.33.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:

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:

namespace Age.Engine.Model;

/// <summary>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.</summary>
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<long, GfxObject> _objects = new();
    private readonly SortedSet<int> _free = new();
    private int _nextSlot = 4;                       // observed native slot range is 4..13
    private readonly Dictionary<long, long> _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); }
    }

    /// <summary>Pack (alpha, rgb) → 0xAARRGGBB, matching op 0x202/0x203's handler bit-manipulation for the
    /// common (non-negative-sentinel) case. The alpha&lt;0 / color&lt;0 native-fetch path is deferred.</summary>
    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
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 <noreply@anthropic.com>"

Task 3.2 — Set opcode labels 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 cases 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 = <op>):

0x1a2gfx-cmd-register, 0x1f7gfx-elem-create, 0x1fagfx-elem-release, 0x1ffset-gfx-geom3-c, 0x202gfx-blit-color, 0x203gfx-draw-color, 0x212set-gfx-field64, 0x213set-gfx-xy, 0x215query-gfx-object?, 0x216query-gfx-field?, 0x217set-gfx-geom3, 0x218get-gfx-geom3?, 0x219set-gfx-geom3-b, 0x21aget-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
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 <noreply@anthropic.com>"

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:

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<Operand>());

    [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<string>());
        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<string>());
        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):

    public GfxState Gfx { get; } = new();
  • Step 4: Add the QUERY cases

In Step, before the default: case (~line 228):

            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 cases (alongside Task 3.3's block)
            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
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 <noreply@anthropic.com>"

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
    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<string>());
        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 cases (state now; blend deferred)
            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):

    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
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 <noreply@anthropic.com>"

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
    [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<string>());
        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
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 <noreply@anthropic.com>"

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:

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
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 <noreply@anthropic.com>"

Self-review

  • Spec coverage: GfxState model → 3.1; dispatch enablement (the label gotcha) → 3.2; all 13 ops (query/set/lifecycle/colored) → 3.33.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 12 (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 labels 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.