13 KiB
Design: Gfx Command-Buffer Host-Side Model (Phase 2)
Status: draft (awaiting approval) · Date: 2026-07-07
Plan: docs/superpowers/plans/2026-07-07-gfx-command-buffer.md · RE source (canonical op contract):
docs/engine-re.md (gfx op-contract table) · opcode semantics: vm-map/opcodes.toml (0x1a2, 0x1f7,
0x1fa, 0x1ff, 0x202, 0x203, 0x212, 0x213, 0x215–0x21a). Touches: engine/Age.Engine/Vm/VirtualMachine.cs,
engine/Age.Engine/Model, engine/Age.Cli/Program.cs (GfxTraceHost), godot/{GodotAdvHost,Main}.cs,
engine/Age.Engine.Tests/.
Problem / goal
The background/sprite render drift (Screenshot 2026-07-06 211353.png) is caused by stubbed native gfx
ops. Phase-1 RE pinned two stubbed drivers: 0x215 (per-object slot select → collapses every draw to
slot 0) and 0x218/0x21a (per-object geometry vectors → the anchor-preserve math reads stale globals).
Both read object state that the SET ops (0x217/0x219/0x1ff/0x212/0x213) wrote — all bytecode-driven.
Goal: model that per-object state host-side and execute the gfx ops instead of stubbing them, so the
QUERY ops return what the SET ops stored and the existing bytecode geometry math produces correct
dst/w/h. Scope decision (approved): model all 13 ops' state now; defer only alpha
blend/compositing rendering, explicitly guarded.
Key insight — this is VM execution state, not a new render backend
The gfx object ops are almost entirely pure data: they build and query per-object records. The one op
that already needs the host — 0x208 get-texture-size — is implemented. And the existing draw-texture
(0x1fb) op already carries the bytecode-computed dst/w/h to the compositor. So:
- The drift is fixed VM-side: once
0x215returns distinct slots and0x218/0x21areturn the stored vectors, the bytecode computes correct geometry and passes it to the unchanged draw path. - The Godot compositor is already per-slot (
_slotBmp/_slotDimskeyed by slot;BlitSlotblits at the givendst). It collapses today only because the VM feeds it slot 0 for everything. Feed it correct slots +dstand it composites correctly — little or no compositor change for geometry.
This is why the fix is tractable and low-churn: implement the ops against a VM-owned GfxState; the
rendering surface barely moves.
Architecture
GfxState — new VM execution state (not an IHost surface)
A concrete class Age.Engine/Model/GfxState.cs (version-neutral engine state, like the global bank),
owned by VirtualMachine alongside Globals and exposed read-only (public GfxState Gfx { get; }) so a
rendering host can read it if needed. The 13 gfx ops are implemented as VM handlers that mutate/query
GfxState directly — no IHost round-trip for pure-data ops. QUERY ops read GfxState and Write the
result into their output operands exactly like get-texture-size does.
Why VM-side, not behind IHost: GfxState is deterministic execution state produced by opcodes,
identical on every host (it is data, not pixels). Putting it behind IHost would force 13 no-op
implementations across 10 hosts and blur the "IHost = effectful rendering backend" seam. Keeping it VM-side
means non-Godot hosts need zero new methods, base-ISA tests stay byte-identical, and GfxState is unit-
testable with no Godot. (Alternative considered — a single IHost.Gfx property returning a host-owned
GfxState — rejected: it needlessly routes pure engine state through the render seam.)
The one host touch: 0x202/0x203 colored draws
These are the only gfx ops that actually render (blit an object with color/alpha). They (1) store color on
the GfxState record and (2) issue a draw. For now the draw reuses the existing IHost.DrawTexture
path with alpha ignored, behind a one-time guard log "[gfx] alpha/blend deferred (0x202/0x203)". No new
IHost method; when blend lands we extend DrawTexture/BlitSlot to honor the stored color. If the drift
scene doesn't use 0x202/0x203 (Phase-1 grep shows label_12649 draws via 0x1fb), this path is exercised
only by effect scenes and stays correct-but-opaque.
Compositor (godot/Main.cs, GodotAdvHost.cs)
Unchanged for geometry (already per-slot + blit-at-dst). Only additions: the guarded alpha log above, and
— if the gfx oracle reveals it — honoring the distinct slots the VM now assigns (the machinery already
exists; verify no slot-0 assumption remains).
The model — GfxState
GfxState
Dictionary<int handle, GfxObject> objects // the registry 0x215 queries / 0x1a2·0x1f7 populate
int currentObject // analogue of ctx[0x53d14] (current record index)
SlotAllocator slots // hands out distinct slots (observed range 4..13)
GfxObject
int handle
int slot // returned by 0x215 → G[0x62452]
int[3] vecA, vecB, vecC // the geometry 3-vectors (set by 0x217/0x219/0x1ff, got by 0x218/0x21a)
int field64, field68, field6c // scalar fields (set by 0x212/0x213)
int color // packed ARGB (set by 0x202/0x203) — stored; blend deferred
int cmdType // last command tag (3/5/7/9/0xb) — stored; see note
Notes:
cmdTypeis written by every op into the current record natively; because we execute ops immediately (no deferred native flush), nothing in our model reads it. We store it for fidelity/diagnostics but do not branch on it. If a reader is found later, it is already captured.- Native workers (
FUN_0047xxxx) are NOT modelled — only the object-record data. That is the whole point of (b)-is-tractable: the QUERY ops return what the SET ops stored; the DirectDraw surface math the workers do is replaced by our compositor.
Op → GfxState mapping
Authoritative per-op contract is the table in docs/engine-re.md; here is the model action (operand indices
1-based, matching the contract):
| op | model action |
|---|---|
0x1a2 register |
objects[key(op1)] ??= new; currentObject = that (populate registry) |
0x1f7 elem-create |
ensure objects[op1]; assign a slot if new (op2>1 → array of op2); currentObject = op1 |
0x1fa elem-release |
free objects[op1] + its slot |
0x212 set-field64 |
objects[op1].field64 = op2 |
0x213 set-xy |
objects[op1].field68 = op2; .field6c = op3 |
0x215 query |
Write(op1, objects.TryGet(op2)?.slot ?? -1) (the slot-select driver) |
0x216 query |
Write(op1, fieldTable[op2]) (per-object stride-0x14 field) |
0x217 set-geom3 |
objects[op1].vec? = (op2,op3,op4) |
0x218 query |
(Write(op2), Write(op3), Write(op4)) = objects[op1].vec? |
0x219 set-geom3-b |
objects[op1].vec? = (op2,op3,op4) |
0x21a query |
(Write(op2), Write(op3), Write(op4)) = objects[op1].vec? |
0x1ff set-geom3-c |
objects[op1].vec? = (op2,op3,op4) |
0x202 blit-color |
store objects[op1].color = pack(op4,op5); draw via existing path (alpha deferred) |
0x203 draw-color |
store objects[op1].color = pack(op3,op4); draw via existing path (alpha deferred) |
The vec? placeholders (which of vecA/B/C each op targets) are pinned in Phase 3 — see Risks.
Data flow — the drift chain, fixed
0x1f7 elem-create(handle) → GfxState assigns a distinct slot to `handle`
0x217 set-geom3(handle, anchor)→ store anchor 3-vector on the object
0x215 query(out=G[0x62452], handle) → G[0x62452] = object.slot (no longer 0)
0x21a/0x218 query(handle) → G[0x62498/9/a], G[0x6249b/c/d] = stored vectors
… bytecode: G[0x62498] = G[0x6249b] − w/2, foot-anchor … (already correct math)
set-texture(resId, slot=G[0x62452]) → host loads BMP into that slot
draw-texture(slot, …, dst=computed) → compositor blits the right image at the right place
Every object lands in its own slot at its own dst; no collapse, no cumulative anchor drift.
Error handling / edge cases
- Query on unknown handle:
0x215→-1(matches native0xffffffff; the sign test then takes the "new object" branch, as the engine intends). Geometry queries on unknown handle → write0(default), logged once. - Release then query: freed handle →
-1; slot returned to the allocator. - Slot exhaustion (>10 live objects): log and reuse the LRU slot; flagged as an assumption to verify (the observed range is 4..13 = 10 slots).
Writeto an immediate operand (shouldn't happen for out params) → the existingWritehelper's behavior; assert in a test.
Parity & testing
- Base-ISA parity holds. The gfx ops were previously
OnStub(1 step,pc+1); the real handlers are also 1 step,pc+1. Existing tests and the Godot--selftestrun synthetic/non-gfx scenes, so they are unaffected and stay byte-identical. (The QUERY ops now write gfx globals that were previously left0; those globals are gfx-only and do not gate dialogue — verified by the sweep diff below.) - New unit tests (synthesized via
ScriptAssembler, per the "synthesize, don't disable" principle):- register two objects → each gets a distinct slot;
0x215returns them; unknown handle →-1. - set a geom vector via
0x217, read it back via0x218→ round-trips (and the set/get pairing is correct per the Phase-3 table). - drift regression: two different-sized backgrounds registered + drawn land at their own correct
dst(no cumulative drift) — the machine-checkable heart of the fix. 0x202/0x203color packing arithmetic (pure, host-free) → exact packed ARGB.
- register two objects → each gets a distinct slot;
Age.Cli gfx <drift-scene>numeric oracle: background resolves to(0,0)full-frame, sprites centered/foot-anchored (not0×0, not marching). Record before/after inphase-a-slice-plan.md.- Sweep diff (
Age.Cli sweep) on a few gfx-heavy scenes with ops-on vs ops-off: confirm dialogue emission is unchanged (gfx globals don't gate ADV flow) — the parity guard for "did new global writes leak into control flow." - Screenshot (
godot -- --scene <drift> --shot): the bottom-right castle snaps into place.
Deferrals (explicit, guarded — no structural debt)
- Alpha/additive blend rendering (
0x202/0x203alpha,AE*fade sheets): the color/alpha is stored on the record now; only the compositor's blend step is deferred, behind a one-time guard log. Localized, one-line future change inBlitSlot; the geometry spec already built the compositor "to accept alpha." - Green chromakey (sprite transparency): needs alpha; deferred with the same guard.
- Native workers
FUN_0047xxxx(DirectDraw surface math): intentionally not modelled — the object-record data model replaces them. Not debt; it is the design. cmdTypebranching: stored, unused; no reader known. If one appears, the value is already captured.
None of these are stubbed ops — every op executes and updates state. Only the pixel-level blend is deferred, and it is visibly logged so it never reads as "done."
Risks / open questions (Phase-3 confirmations, not deferrals)
- Set/get → vector pairing (
0x217/0x219/0x1ffset which ofvecA/B/C;0x218/0x21aget which): pinned by reading the 5 native workers' object-field offsets (FUN_0047e800/e960/e910setters,FUN_0047f360/f2e0getters — 5 quick decompiles) or by the0x217→0x218round-trip oracle. The model already supports N vectors; this is a lookup-table fill, ~30 min at the start of Phase 3. - Slot-allocation policy (distinct 4..13): confirm against
label_125bd/ the0x3239record table (whether slots are positional or free-list). ASlotAllocatorabstraction isolates this; the drift fix only needs distinct, stable slots per live object. - Registry key derivation (
"%c%8.8x"(cmdtype, ·)): modelled ashandle → object; confirm the key space covers the observed handle families (0x62455[idx], immediates0xcf08/0xe678/…). - Global-write leakage into control flow: covered by the sweep-diff parity test above.
Each is a bounded confirmation with a fallback, isolated behind an abstraction — not open-ended risk.
Success criteria (Phase 2 done)
- This spec self-reviewed (no placeholders/contradictions) and user-approved.
- Architecture committed:
GfxStateas VM execution state; pure-data ops VM-internal; only0x202/0x203color touches the (existing) draw path; compositor essentially unchanged for geometry. - The four Phase-3 confirmations are named with fallbacks (not deferrals).
- Then: re-run
writing-plansto expand Phase 3 into bite-sized TDD tasks from this spec + the contract table.