26 KiB
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
labelfield, notname.OpcodeTableJson.Loadreadsopcodes.jsonlabel;VirtualMachine.Stepswitches on_t.Label(op). My Phase-1 edits setnameonly, so Task 3.2 must set each gfx op'slabelto its dispatch string (the0x208/0x1fbprecedent) and rebuild — otherwise the newcases never match. - Run Python as
py -3.11 -X utf8 tools/opcodes_build.py --build; never hand-edit generated files;--lintmust be0 errors. - Operand indexing:
a[i]is 0-based; contract operand n =a[n-1]. Read inputs withRead(a[i]), write outputs withWrite(a[i], val). - Parity: each gfx op is one step, returns
pc+1(same as the oldOnStub). Base-ISA tests and the Godot--selftestrun synthetic/non-gfx scenes → must stay green and byte-identical. Verify withdotnet test engine/AgeEngine.sln. - Seam:
GfxStatelives inAge.Engine/Model;Vmmay referenceModel. NoSys4/Godot references from the VM. - Testing principle: synthesize scenes with
ScriptAssembler(seeSyntheticSceneTests); 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.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:
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<0 / color<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
labelline for each gfx op
For each op below, change its label = "u00…" line to the dispatch string (the block starts at op = <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
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
Gfxproperty
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
labelgotcha) → 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/0x213idx table,0x216field 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/Mainshapes) — bounded and explicit. ✓ - Type consistency:
GfxState.GetOrCreate/TryGet/QuerySlot/QueryField/Release/PackColorandGfxObject.{Slot,V18,V24,V16c,Field64/68/6c,Color}are used identically in Model, VM cases, and tests; VM dispatch strings match the Task-3.2labels 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.