15 KiB
GFX Command-Buffer Subsystem Implementation Plan
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: Fix the background/sprite render drift (Screenshot 2026-07-06 211353.png) by modelling the AGE engine's native graphics command-buffer host-side and executing the gfx ops instead of stubbing them, so per-object slot selection and geometry are correct.
Architecture: The drift is settled as (b) a native command-buffer op (docs/engine-re.md, op 0x215): the gfx ops maintain a native object-record array (ctx+0x53d64, stride 0x78) plus a handle→object registry (a hash map) that 0x215 queries to pick each object's slot; stubbing collapses every draw onto slot 0. Because the VM is ours, we reproduce this as a clean host-side model behind IHost (the same pattern as GetTextureSize): the gfx ops call into a shared GfxState, and label_12649's bytecode geometry math — already correct — then produces correct dst/w/h. The registry inserts are bytecode-driven, so the state rebuilds itself from the same scripts; no Frida, no state-seeding.
Tech Stack: C# / .NET 8 (engine/, solution AgeEngine.sln, xUnit); Godot 4.7 .NET (godot/Himegari.csproj); Python 3.11 for the opcode build (tools/opcodes_build.py); Ghidra 12.1.2 + ghidra-mcp for the RE phase (engine dump build/engine-dump/range_00400000.bin, base 0x400000).
Global Constraints
- This is a discovery-gated plan. Phase 1 (RE) is fully specified and executable now; it produces the op-contract table that is the spec for Phases 3–4. Phase 2 is a re-plan checkpoint: after Phase 1, expand Phases 3–4 into bite-sized TDD tasks with exact code (a second
writing-planspass) — do not write handler code before its contract is reversed. - Run Python as
py -3.11 -X utf8 tools/<name>.py …(utf8 mode mandatory on Windows for cp932). - Never hand-edit generated files. After editing
vm-map/opcodes.toml, runpy -3.11 -X utf8 tools/opcodes_build.py --build(regeneratestools/age_opcodes_himegari.py,build/opcodes.json,docs/opcode-reference.md,build/opcode-coverage.md);--lintmust report0 errors. - Ghidra VA-drift rule: never analyse a native op by its Kelebek
u00XXXXXXVA. Resolve the real handler through the dispatch tablectx[0x26c93 + op]= the store[ESI + (0x26c93+op)*4] = <handler>inFUN_00413860(docs/engine-re.mdmaster key). - Engine seam rule:
Age.Engine/Vmreferences onlyModel+Hosting+Diagnostics(neverSys4). Gfx state lives host-side, reached throughIHost.IHostlives inAge.Engine/Hosting. - Parity guardrail: non-Godot hosts (
CaptureHost, test/CLI recording hosts) must keep any newIHostmethod a pure value-returning no-op that leaves trace/step-count unchanged, so existing base-ISA tests stay byte-identical. Verify withdotnet test engine/AgeEngine.sln. - Testing principle (user-directed): synthesize test data; never disable a feature to keep a real scene matching a frozen number. Use
Age.Engine/Sys4/ScriptAssemblerto build synthetic gfx scenes for unit tests. - Opcode
categoryvocabulary is fixed:marker/structural/control/adv/draw/audio/input/compute/unknown. Gfx ops =draw. Confidence:highonly once the handler is Ghidra-read;medfor inference. - Canonical doc homes (do not duplicate): native-op decode →
docs/engine-re.md; opcode semantics →vm-map/opcodes.toml; slice status/results →docs/phase-a-slice-plan.md; host-side design → this plan's Phase-2 spec.
File structure
| File | Responsibility | Phase |
|---|---|---|
docs/engine-re.md |
Canonical op-contract table for the gfx command-buffer family (per-op: handler VA, cmd-type, record/registry effects, operand roles) | 1 |
vm-map/opcodes.toml |
Per-op semantics for each reversed gfx op (summary/evidence/source/confidence) |
1 |
docs/superpowers/specs/2026-07-07-gfx-command-buffer-design.md |
Host-side model design (record set, registry, IHost surface, compositor) — written from the Phase-1 contract |
2 |
engine/Age.Engine/Hosting/GfxState.cs |
New. Host-side command-buffer model: object-record set + handle→object registry; the ops mutate/query it | 3 |
engine/Age.Engine/Hosting/IHost.cs |
New gfx-op methods (register/query/geometry), mirroring the GetTextureSize pattern |
3 |
engine/Age.Engine/Vm/VirtualMachine.cs |
Dispatch the reversed gfx ops to the new IHost methods (replace OnStub fall-through) |
3 |
engine/Age.Engine/Hosting/CaptureHost.cs + test/CLI hosts |
Parity no-op impls of the new IHost methods |
3 |
engine/Age.Cli/Program.cs (GfxTraceHost) |
Drive the real GfxState so Age.Cli gfx reports true per-object geometry |
3 |
godot/GodotAdvHost.cs, godot/Main.cs |
Composite from the GfxState object set (per-object slots) instead of collapsing to slot 0 |
3 |
engine/Age.Engine.Tests/GfxCommandBufferTests.cs |
New. Synthetic-scene tests: register→query returns the object; two different-sized bgs don't drift | 3 |
Phase 0 — Branch
- Step 1: Create the working branch
Run: cd age-reimpl && git checkout -b feat/gfx-command-buffer
Expected: Switched to a new branch 'feat/gfx-command-buffer'
Phase 1 — Reverse the gfx command-buffer op contract (executable now)
Deliverable: a complete op-contract table in docs/engine-re.md covering every still-stubbed gfx-family op, plus updated vm-map/opcodes.toml semantics and Ghidra annotations. This is the spec for Phases 3–4. No C# is written in this phase.
Target ops (the draw-category + adjacent handlers that build/query the command buffer; 0x215 is already done as the worked example):
0x1a2 (gfx-cmd-register, cmd-type 3), 0x1f7 (ui-elem?), 0x1fa (ui-clear?), 0x1ff (draw?), 0x202 (draw-blit?), 0x203 (draw?), 0x212 (u00421090), 0x213 (u004210D0), 0x216 (u004211A0), 0x217/0x218/0x21a (gfx-geom?), 0x219 (u004212E0).
Already implemented (read only if a contract dependency surfaces): 0x1f8 create-texture, 0x1f9 set-texture, 0x1fb draw-texture, 0x208 get-texture-size.
Per-op procedure (repeat for each target op; 0x215 in engine-re.md is the template):
- Resolve the real handler. Compute the table byte-offset
off = (0x26c93 + op) * 4. In Ghidra (mcp) runsearch_instructionswithoperand_pattern= that offset in hex (e.g. op0x217→(0x26c93+0x217)*4 = 0x9baac) — the single match inFUN_00413860isMOV dword ptr [ESI + 0x…], <handler_va>.<handler_va>is the real handler. - Decompile the handler (
decompile_function <handler_va>) and, if it shares code, the operand helpers (FUN_0041b940operand-fetch,FUN_00425fb0operand-write,FUN_0047f280registry-find,FUN_0042cf70registry-insert are already identified). - Extract the contract: argc + operand roles; which record-array fields it writes (
ctx+0x53d64 + curidx*0x78; cmd-type at+0x24; note any geometry offsets); whether it inserts/queries the registry; and any output written back to a VM global. - Annotate Ghidra:
rename_function_by_address <va> gfx_op_0x<op>_<role>+set_plate_commentwith the decode (as done for0x42a0b0/0x47f280/0x42cf70).save_programat the end of the phase. - Record the contract: add a row to the
engine-re.mdgfx-op-contract table and set the op'svm-map/opcodes.tomlsemantics(source = "investigation",confidence = "high", evidence = handler VA + effects).
- Step 1: Add the contract-table skeleton to
engine-re.md
Under the op-0x215 section in docs/engine-re.md, add a new subsection #### gfx command-buffer — op contract table with a Markdown table header: | op | handler | argc | cmd-type | record fields | registry | operand roles | summary |. Seed the 0x215, 0x1a2 rows from what's already known.
- Step 2: Reverse each target op (one checkbox per op)
Work the per-op procedure above. Check off as each contract row lands:
-
0x1a2gfx-cmd-register (confirm cmd-type 3 + registry insert key format) -
0x212u00421090 -
0x213u004210D0 -
0x216u004211A0 -
0x217gfx-geom? -
0x218gfx-geom? -
0x219u004212E0 -
0x21agfx-geom? -
0x1f7ui-elem? -
0x1faui-clear? -
0x1ffdraw? -
0x202draw-blit? -
0x203draw? -
Step 3: Cross-check against
label_12649
Re-read build/disasm/SC0000.asm around label_12649 (and label_123ef, label_125bd @0x0050f) and confirm the reversed contracts explain the slot-select branch and the per-object slot assignment (slots 4..13 via record table 0x3239). Note in the contract table exactly which op(s) the branch keys on and what a correct return is (found ≥0 → existing slot; -1 → new).
- Step 4: Rebuild generated opcode files + lint
Run: py -3.11 -X utf8 tools/opcodes_build.py --build
Then: py -3.11 -X utf8 tools/opcodes_build.py --lint
Expected: build writes 4 files; lint = 0 errors, 0 warnings.
- Step 5: Save Ghidra + commit the RE
save_program (mcp). Then:
git add docs/engine-re.md vm-map/opcodes.toml tools/age_opcodes_himegari.py docs/opcode-reference.md
git commit -m "docs(gfx): reverse the gfx command-buffer op contract (0x1a2/0x212-0x21a/…)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
Phase 1 exit criteria: every target op has (a) a real handler VA, (b) a Ghidra rename + plate comment, (c) a contract-table row, (d) opcodes.toml semantics at confidence high; and the label_12649 slot-select branch is explained end-to-end. ← Re-plan checkpoint: Phase 3/4 tasks are authored now, from this table.
Phase 2 — Host-side model design (spec) — gated on Phase 1
Deliverable: docs/superpowers/specs/2026-07-07-gfx-command-buffer-design.md, approved before implementation. Written from the Phase-1 contract table. Must specify, concretely:
- The
GfxStatemodel — the object-record set (fields the reversed ops actually touch: handle, slot, cmd-type, geometry) and the handle→object registry (aDictionary), with the insert/query semantics the contract table dictates. Only the fields the ops use — YAGNI. - The
IHostsurface — one method per op-group (register / query / geometry-write), each mirroring theGetTextureSizepattern: the VM handler calls it and writes any result into the op's output operand(s). Exact C# signatures. - State ownership —
GfxStatelives in the host layer and is shared byGodotAdvHost(real compositing) and the CLIGfxTraceHost(numeric oracle); the VM stays seam-clean (Vm→Model+Hosting+Diagnostics). - Compositor change — how
godot/Main.cscomposites the per-object slot set (replacing the collapse-to-slot-0 blit), and how it stays compatible with the existingBlitSlot/0x208geometry path. - Parity + test strategy — non-Godot no-op contracts; synthetic-scene unit tests (register→query→correct slot; two different-sized bgs → no drift); the
Age.Cli gfxand screenshot oracles. - Explicit deferrals — alpha/blend (
AE*,0x202/0x203blend semantics), green chromakey, true multi-surface. In scope: static positioned geometry only (kills the drift).
Phase 2 exit criteria: design doc self-reviewed (no placeholders/contradictions) and user-approved. Then run writing-plans again to expand Phase 3 into TDD tasks.
Phase 3 — Implement (task-level; expanded to TDD steps after Phase 2)
Each task is test-first (synthetic scenes via ScriptAssembler), ends with dotnet test engine/AgeEngine.sln green + a commit, and preserves parity on non-Godot hosts.
- Task 3a —
GfxStatemodel + parityIHostsurface. CreateGfxState.cs; add the Phase-2IHostmethods; give every non-Godot host a value-returning no-op. Unit test: register an object then query its handle → returns the object's slot; unknown handle → -1. Gate: all existing engine tests byte-identical. - Task 3b — Dispatch the reversed ops in the VM. Replace the
OnStubfall-through for each contract'd op inVirtualMachine.Step, calling the newIHostmethods and writing outputs to operands per the contract. Unit test: a synthetic scene that registers two different-sized backgrounds and draws them lands each at its own correctdst(no cumulative drift). Gate:--selftest+ engine suite green. - Task 3c — CLI oracle uses the real model. Wire
GfxTraceHostto a realGfxStatesoAge.Cli gfx <SCENE>reports true per-object slots/geometry. Verify on the drift scene: background resolves to(0,0)full-frame, not(300,500). - Task 3d — Godot compositor.
GodotAdvHost/Main.cscomposite the per-object slot set. Build (godot --import→dotnet build godot/Himegari.csproj), selftest parity.
Phase 4 — Validate against the drift scene
- Task 4a — Numeric oracle.
Age.Cli gfx <drift-scene>shows everyBG*/sprite at plausible on-screen geometry (no0×0, no marchingdst). Record the before/after numbers indocs/phase-a-slice-plan.md(A2b section). - Task 4b — Screenshot acceptance.
godot --path godot -- --scene <drift-scene> --shot <png> [--shot-page N]; the background fills the frame correctly (the bottom-right castle fromScreenshot 2026-07-06 211353.pngis snapped into place). This is the human oracle — no machine oracle for pixels. - Task 4c — Regression + docs. Full
dotnet test engine/AgeEngine.slngreen; Godot--selftestOK; updatedocs/phase-a-slice-plan.md(A2b: drift RESOLVED, implemented) and the status memory. Thenfinishing-a-development-branchfor merge.
Phase 4 exit criteria: the drift scene renders correctly (screenshot-verified), engine + selftest green, docs/memory updated to "drift resolved (implemented)".
Self-review
- Spec coverage: the (b) verdict → Phase 1 reverses the whole family; the fix ("model + execute the ops") → Phases 2–3; acceptance (the drift screenshot) → Phase 4. ✓
- Discovery gate honesty: Phase 3/4 are deliberately task-level, not fake TDD code, because the handler contracts are Phase 1's output; the plan states the re-plan checkpoint explicitly. ✓
- Placeholder scan: Phase 1 & 2 are fully actionable now; Phase 3/4 carry concrete files, gates, and acceptance criteria, with the expansion gate named. No "TBD/handle edge cases" hand-waving. ✓
- Seam/parity consistency: every phase re-states the
Vm→Model+Hosting+Diagnosticsseam and the non-Godot no-op parity rule;IHost-method pattern matches the establishedGetTextureSize. ✓