Files
OpenMaidEngine/docs/superpowers/specs/2026-07-06-a2b-graphics-geometry-design.md
gamer147 47e4db71e2 docs(a2b): design — graphics geometry (0x208 keystone + blit compositor)
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>
2026-07-06 22:38:11 -04:00

13 KiB
Raw Blame History

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:

  • 0x208 promoted from stub to a real VM op that writes texture dims into its output operands, backed by a new IHost.GetTextureSize(slot) → (w, h).
  • A screen backbuffer blit compositor in the Godot host: one mutable 800×600 Image; draw-texture blits the source slot's image (src rect → dst) into it in execution order; a single TextureRect displays it. Source images kept per slot (dict<slot, Image>) so the dims 0x208 returns come from the actually-loaded image.
  • Age.Cli gfx <SCENE.BIN> [0xADDR=VAL ...] — dumps each executed set-texture/draw-texture/0x208 with resolved file + computed geometry (mirrors the audio diagnostic), for a headless numeric check.
  • Trace/selftest parity preserved (see below).

Out (deferred, explicitly not this pass):

  • Alpha/additive blend + AE* fades (0x202/0x203 and 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 vs 0xcf08 = screen) actually mean. This pass collapses every draw-texture dest onto the one screen canvas (see below); genuine offscreen surfaces are a later, Frida-informed step.
  • The sprite animation chain (0x217/0x218/0x21a write 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 count0x208 was already one step via OnStub; it is still one step).
  • Engine test count is preserved; add a TextureOps/geometry test asserting 0x208 writes 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> _slotImgSetTexture(resId, slot) resolves via ResourceMap (unchanged), loads the pre-converted BMP into an Image, stores it, and records its dims. GetTextureSize(slot) returns the stored image's (GetWidth(), GetHeight()) (or (0,0) if empty).
  • Main: one screen Image _screen (800×600, Format.Rgba8) shown by a single TextureRect. DrawTexture(slot, sx, sy, w, h, dx, dy)CallDeferred a BlitSlot that _screen.BlitRect(_slotImg[slot], srcRect(sx,sy,w,h), new Vector2I(dx,dy)) and refreshes the TextureRect's ImageTexture. 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 computed dst. 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-texture call in the anchor-preserve path): host returns (0,0); the engine does the same when a slot has no texture. Acceptable — the second 0x208 (after load) drives the final draw.
  • BMP missing / load failure: SetTexture leaves the slot empty (logs once); GetTextureSize(0,0); draw-texture on an empty slot is a no-op blit. Fails soft, never throws.
  • dst off-canvas / oversized src rect: clamp the blit to the screen bounds (Godot BlitRect already ignores out-of-bounds regions; verify and clamp srcRect to the source image size).
  • Dims mismatch (AGF native vs converted BMP): the converted BMP is authoritative for what we render; 0x208 returns the BMP dims so geometry matches what is actually blitted.

Testing / verification

  1. Engine unit test0x208 writes host-provided dims into its two output operands; parity assertions that CaptureHost.GetTextureSize returns (0,0) and leaves trace/step-count unchanged.
  2. --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.
  3. gfx diagnostic — run Age.Cli gfx SC0000.BIN; assert (by eye, then a smoke check) the background resolves to (0,0) 800×500 and sprite draws are centered/foot-anchored at sane coordinates (not 0×0, not off-screen).
  4. 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).
  5. Frida cross-check (only if numbers look wrong) — capture the real game's draw-texture geometry and diff against the gfx dump.

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 gfx dump + 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 in label_12649 flows through draw-texture's explicit dst, so we expect these are not needed for a correct still. If a sprite is misplaced despite correct 0x208, 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.py divergence on branchy scenes (known): use the C# VM / gfx diagnostic for non-opening scenes, not vm0.py --settex.

Success criteria (this pass done)

  • 0x208 implemented; IHost.GetTextureSize added; opcode renamed in opcodes.toml and rebuilt.
  • Godot host uses the screen backbuffer blit compositor; source images kept per slot.
  • --selftest and A1 trace-diff byte-identical; engine tests green (+1 geometry test).
  • Age.Cli gfx SC0000.BIN shows the background at (0,0) 800×500 and sprite draws with non-zero, centered/foot-anchored geometry (no 0×0, no off-center background).
  • Eyeball: SC0000 opening renders with correct background placement and plausibly-placed sprites.