Recon of SC0000 label_12649 shows sprite/background geometry is computed in bytecode; the only missing native primitive is 0x208 get-texture-size. Spec: implement 0x208 (host returns real image dims) + a faithful 800x600 blit compositor; defer fades/chromakey/multi-surface. Headless `gfx` diagnostic as numeric oracle; --selftest/trace parity preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
13 KiB
Design: A2b — Graphics Geometry (the 0x208 keystone + blit compositor)
Status: approved (design) · Date: 2026-07-06
Related: docs/phase-a-slice-plan.md (A2b), docs/superpowers/specs/2026-07-06-a2b-background-design.md
(the first-pass render this builds on), vm-map/opcodes.toml (op 0x208), engine/Age.Engine
(Vm/VirtualMachine.cs, Hosting/IHost.cs), godot/{GodotAdvHost,Main}.cs,
engine/Age.Cli/Program.cs, build/disasm/SC0000.asm (subroutine label_12649).
Problem / goal
A2b's first-pass render composites the full-screen event-CG layer correctly, but sprites and BG*
backgrounds routed through the CG-load subroutine have garbage geometry — they land off-center and
their sizes come out 0×0. The status doc framed the fix as implementing "the sprite
position/registration/animation chain," implying a large native subsystem.
Recon overturns that. In SC0000.asm, the CG-load subroutine label_12649 computes all geometry
in bytecode using ordinary add/sub/div/lookup-array the VM already executes correctly. The
only missing native primitive is 0x208 = get-texture-size(slot) → (out_w, out_h). Because it is
stubbed, the width/height output globals stay 0, so every downstream centering/anchoring computation
operates on zeros → garbage dst, 0×0 sizes.
The load-bearing sequence (from label_12649):
0x126e1: set-texture (G[0x62424]=resId) (slot=G[0x62452]) -1 ; load the AGF into the slot
0x126e8: op 0x208 (slot G[0x62452]) → w=G[0x62453], h=G[0x62454] ; GET TEXTURE SIZE ← stubbed = 0,0
0x126ef: div local = w / 2
0x126f6: sub G[0x62498] = G[0x6249b] - w/2 ; top-left x = anchorX - w/2 (horizontal center)
0x126fd: sub G[0x62499] = G[0x6249c] - h ; top-left y = anchorY - h (foot anchor)
0x1270b: draw-texture (handle) (slot) 0 0 w h dstX=G[0x62498] dstY=G[0x62499]
Goal: implement 0x208 (host returns the real dimensions of the image loaded in a slot) and replace
the TextureRect-per-slot approximation with a faithful immediate-mode blit compositor, so that
sprites and backgrounds position and size correctly — driven entirely by the executed bytecode. Add a
headless gfx diagnostic to verify computed geometry without launching Godot.
Scope
In:
0x208promoted from stub to a real VM op that writes texture dims into its output operands, backed by a newIHost.GetTextureSize(slot) → (w, h).- A screen backbuffer blit compositor in the Godot host: one mutable 800×600
Image;draw-textureblits the source slot's image (src rect → dst) into it in execution order; a singleTextureRectdisplays it. Source images kept per slot (dict<slot, Image>) so the dims0x208returns come from the actually-loaded image. Age.Cli gfx <SCENE.BIN> [0xADDR=VAL ...]— dumps each executedset-texture/draw-texture/0x208with resolved file + computed geometry (mirrors theaudiodiagnostic), for a headless numeric check.- Trace/selftest parity preserved (see below).
Out (deferred, explicitly not this pass):
- Alpha/additive blend +
AE*fades (0x202/0x203and the fade/flash effects). The compositor is built to accept alpha later, but blend semantics are not implemented here — they need Frida to confirm op roles and would risk stalling this pass. - Green chromakey for sprites (depends on whether AGF→BMP conversion already carries alpha; a separate question).
- True multi-surface compositing — resolving what the non-screen dest handles (the per-sprite
lookup-array'd handles vs0xcf08= screen) actually mean. This pass collapses everydraw-texturedest onto the one screen canvas (see below); genuine offscreen surfaces are a later, Frida-informed step. - The sprite animation chain (
0x217/0x218/0x21awrite geometry globals for stored/animated records) beyond what static positioning needs. We treat these as they currently are unless a wrong picture forces otherwise.
Why 0x208 is the keystone (and why the math is already correct)
The VM already runs add/sub/div/lookup-array/lookup-array-2d with validated pointer/lvalue
semantics (A0 RECOVER test). Every coordinate in label_12649 is derived by those ops from two inputs:
the anchor globals and the texture width/height. The width/height are the only values the bytecode
cannot compute itself — they come from the loaded image via 0x208. Supply them and the existing,
already-correct arithmetic yields correct dst/w/h. No new positioning logic is written in C#; we
provide one measurement primitive and let the bytecode do what it already does.
This also explains the two 0x208 calls around a set-texture: the engine preserves a
center-x / bottom-y anchor across a texture swap — measure old size to derive the anchor, load the new
texture, measure new size to re-derive the top-left. Both calls need real dims; a stub breaks both.
Architecture
Engine — 0x208 becomes a real op (Age.Engine)
IHost gains one method (mirrors the WaitForInput/texture-op additions):
(int Width, int Height) GetTextureSize(int slot);
VirtualMachine.Step replaces the default → OnStub fall-through for opcode 0x208 with a handler that
reads the slot operand, queries the host, and writes both dims into the output operands:
case "get-texture-size": // 0x208 (slot) (out_w) (out_h)
var (w, h) = _host.GetTextureSize((int)Read(a[0]));
Write(a[1], w); Write(a[2], h);
return pc + 1;
(The opcode is renamed from u00420BF0 to get-texture-size in opcodes.toml, source = inference,
with the label_12649 evidence; then opcodes_build.py --build regenerates the derived files.)
Non-Godot hosts (CaptureHost, test RecHost/CountHost) return (0, 0) → the handler writes
0/0, which equals the pre-change behavior (globals defaulted to 0 and were never written). So:
--selftest(SC0000 show-text offset sequence) stays byte-identical.- A1 trace-diff stays byte-identical (offsets + halt + step count —
0x208was already one step viaOnStub; it is still one step). - Engine test count is preserved; add a
TextureOps/geometry test asserting0x208writes host dims into its two output globals (with a fake host returning known dims).
Godot host — screen backbuffer blit compositor (godot/)
Replace the dict<slot, string bmpPath> + TextureRect-per-slot model with:
GodotAdvHost:dict<int, Image> _slotImg—SetTexture(resId, slot)resolves viaResourceMap(unchanged), loads the pre-converted BMP into anImage, stores it, and records its dims.GetTextureSize(slot)returns the stored image's(GetWidth(), GetHeight())(or(0,0)if empty).Main: one screenImage _screen(800×600,Format.Rgba8) shown by a singleTextureRect.DrawTexture(slot, sx, sy, w, h, dx, dy)→CallDeferredaBlitSlotthat_screen.BlitRect(_slotImg[slot], srcRect(sx,sy,w,h), new Vector2I(dx,dy))and refreshes theTextureRect'sImageTexture. Execution order = paint order → correct z (full-frame bg first, sprites over it) with no separate layering logic.- Dest-handle collapse:
draw-texture's dest handle arg is ignored for now; every draw blits onto the one screen canvas. A sprite the real engine routes through an offscreen buffer still lands on screen at its computeddst. If this collapse visibly double-draws or misplaces, that is the signal to implement true multi-surface compositing (a later, Frida-informed pass).
IHost.DrawTexture already carries (slot, srcX, srcY, w, h, dstX, dstY); no signature change. The VM's
draw-texture dispatch is unchanged — it already passes those operands.
CLI — the gfx diagnostic (Age.Cli)
Age.Cli gfx <SCENE.BIN> [0xADDR=VAL ...] runs the scene under a small recording host that implements
GetTextureSize from a lightweight in-memory image-dims lookup (reads the pre-converted BMP headers for
each resolved slot, no Godot) and logs, in execution order: each set-texture (slot ← resId → file), each
0x208 (slot → returned w×h), and each draw-texture (slot, src rect, computed dst, w×h, resolved
file). Optional 0xADDR=VAL seeds globals (same as audio). This gives a headless numeric oracle:
the background should resolve to (0,0) 800×500; sprites should be horizontally centered and foot-anchored
at plausible on-screen coordinates. It reuses the same section resolver — no new resolution logic.
Data flow
bytecode (label_12649) host screen
────────────────────── ──── ──────
set-texture(resId, slot) ───────────► SetTexture: resolve → load BMP →
_slotImg[slot] = Image (w×h known)
0x208(slot) → w,h globals ───────────► GetTextureSize(slot) → (w,h)
(VM Writes w,h into globals)
add/sub/div (VM) ── compute dst, size from anchor + w,h ──►
draw-texture(_, slot, sx,sy,w,h, dx,dy) ─► BlitSlot: _screen.BlitRect( ──► TextureRect shows _screen
_slotImg[slot], (sx,sy,w,h), (dx,dy))
Error handling / edge cases
- Empty slot on
0x208(the pre-set-texturecall in the anchor-preserve path): host returns(0,0); the engine does the same when a slot has no texture. Acceptable — the second0x208(after load) drives the final draw. - BMP missing / load failure:
SetTextureleaves the slot empty (logs once);GetTextureSize→(0,0);draw-textureon an empty slot is a no-op blit. Fails soft, never throws. dstoff-canvas / oversized src rect: clamp the blit to the screen bounds (GodotBlitRectalready ignores out-of-bounds regions; verify and clampsrcRectto the source image size).- Dims mismatch (AGF native vs converted BMP): the converted BMP is authoritative for what we render;
0x208returns the BMP dims so geometry matches what is actually blitted.
Testing / verification
- Engine unit test —
0x208writes host-provided dims into its two output operands; parity assertions thatCaptureHost.GetTextureSizereturns(0,0)and leaves trace/step-count unchanged. --selftest+ A1 trace-diff — must stay byte-identical (SC0000 186-line offset sequence; all SC/SP step counts). This is the guardrail that the new op didn't perturb control flow.gfxdiagnostic — runAge.Cli gfx SC0000.BIN; assert (by eye, then a smoke check) the background resolves to(0,0) 800×500and sprite draws are centered/foot-anchored at sane coordinates (not0×0, not off-screen).- Eyeball in Godot — the opening renders with the background placed correctly and any sprites at plausible positions/sizes. This is the final human oracle (no machine oracle for pixels).
- Frida cross-check (only if numbers look wrong) — capture the real game's
draw-texturegeometry and diff against thegfxdump.
Risks / open questions
- Dest-handle collapse is a simplification. If the opening relies on offscreen surfaces (sprite buffers
composited to screen with transforms), collapsing to one canvas could double-draw or misplace. Mitigation:
the
gfxdump + eyeball surface it immediately; true multi-surface work is the designated follow-up. 0x217/0x218/0x21a(gfx-geom?) write geometry globals for stored/animated sprite records. Static positioning inlabel_12649flows throughdraw-texture's explicitdst, so we expect these are not needed for a correct still. If a sprite is misplaced despite correct0x208, these are the next suspects.- Chromakey deferred: sprites may render with a visible background box until chromakey/alpha lands. This is expected and out of scope; note it when eyeballing so it isn't mistaken for a geometry bug.
vm0.pydivergence on branchy scenes (known): use the C# VM /gfxdiagnostic for non-opening scenes, notvm0.py --settex.
Success criteria (this pass done)
0x208implemented;IHost.GetTextureSizeadded; opcode renamed inopcodes.tomland rebuilt.- Godot host uses the screen backbuffer blit compositor; source images kept per slot.
--selftestand A1 trace-diff byte-identical; engine tests green (+1 geometry test).Age.Cli gfx SC0000.BINshows the background at(0,0) 800×500and sprite draws with non-zero, centered/foot-anchored geometry (no0×0, no off-center background).- Eyeball: SC0000 opening renders with correct background placement and plausibly-placed sprites.