diff --git a/docs/superpowers/specs/2026-07-06-a2b-graphics-geometry-design.md b/docs/superpowers/specs/2026-07-06-a2b-graphics-geometry-design.md new file mode 100644 index 0000000..f5b9795 --- /dev/null +++ b/docs/superpowers/specs/2026-07-06-a2b-graphics-geometry-design.md @@ -0,0 +1,198 @@ +# 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`) so the dims `0x208` returns come from + the actually-loaded image. +- `Age.Cli gfx [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): + +```csharp +(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**: + +```csharp +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** — `0x208` 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` + TextureRect-per-slot model with: + +- `GodotAdvHost`: `dict _slotImg` — `SetTexture(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 [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 test** — `0x208` 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.