diff --git a/docs/superpowers/plans/2026-07-06-a2b-graphics-geometry.md b/docs/superpowers/plans/2026-07-06-a2b-graphics-geometry.md new file mode 100644 index 0000000..2dadd0c --- /dev/null +++ b/docs/superpowers/plans/2026-07-06-a2b-graphics-geometry.md @@ -0,0 +1,579 @@ +# A2b Graphics Geometry 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:** Implement opcode `0x208` (`get-texture-size`) so the bytecode's own geometry math produces correct sprite/background positions, and replace the Godot host's TextureRect-per-slot approximation with a faithful 800×600 immediate-mode blit compositor. + +**Architecture:** The CG-load subroutine (`SC0000.asm` `label_12649`) computes all geometry in bytecode from anchor globals + the texture's width/height; the only missing native primitive is `0x208 = get-texture-size(slot) → (out_w, out_h)`. We add `IHost.GetTextureSize`, make `0x208` a real VM op that writes the two output globals, and give the Godot host a screen-backbuffer blit compositor whose `draw-texture` blits a source slot's image (src rect → dst) into one shared canvas in execution order. Texture dims are read from the pre-converted BMP header on the VM thread (synchronous); pixel blits happen deferred on the Godot main thread. + +**Tech Stack:** C# / .NET 8 (`engine/`, solution `AgeEngine.sln`), Godot 4.7 .NET (`godot/Himegari.csproj`), Python 3.11 for the opcode build (`tools/opcodes_build.py`), xUnit for engine tests. + +## Global Constraints + +- **Run Python as** `py -3.11 -X utf8 tools/.py …` (utf8 mode mandatory on Windows). +- **Never hand-edit generated files.** `tools/age_opcodes_himegari.py`, `build/opcodes.json`, `docs/opcode-reference.md`, `build/opcode-coverage.md` are generated from `vm-map/opcodes.toml` via `py -3.11 -X utf8 tools/opcodes_build.py --build`. +- **Trace/selftest parity is a guardrail.** After any VM change, the C# trace must stay byte-identical to `build/vm0-trace.json` (offsets + halt + **step count**). Non-Godot hosts (`CaptureHost`, test/CLI `RecHost`s) must no-op the new op (return `(0,0)`). +- **Seam rule:** `Age.Engine/Vm` references only `Model` + `Hosting` (never `Sys4`). `IHost` lives in `Age.Engine/Hosting`. +- **Opcode `category` vocabulary** is fixed: `marker/structural/control/adv/draw/audio/input/compute/unknown`. Use `draw` for `0x208`. +- **Valid opcode `confidence`:** use `med` for `0x208` (inference from disassembly, not Frida-confirmed). +- Build engine + tests: `dotnet test engine/AgeEngine.sln`. Build Godot: `godot --headless --path godot --import` then `dotnet build godot/Himegari.csproj`. Godot selftest: `godot --headless --path godot -- --selftest`. + +--- + +## File structure + +| File | Responsibility | Task | +|---|---|---| +| `vm-map/opcodes.toml` | Rename `0x208` → `get-texture-size` (source edit) | 1 | +| `engine/Age.Engine/Hosting/IHost.cs` | Add `GetTextureSize` to the host contract | 1 | +| `engine/Age.Engine/Hosting/CaptureHost.cs` | No-op impl → `(0,0)` (parity) | 1 | +| `engine/Age.Engine/Vm/VirtualMachine.cs` | `case "get-texture-size"` handler (writes 2 globals) | 1 | +| `engine/Age.Cli/Program.cs` | Add impl to `AudioTraceHost`; add `gfx` command + `GfxTraceHost` | 1, 2 | +| `engine/Age.Engine.Tests/TextureOpsTests.cs` | Add `GetTextureSize` to test `RecHost` | 1 | +| `engine/Age.Engine.Tests/TextureGeometryTests.cs` | New: `0x208` writes host dims into output globals | 1 | +| `engine/Age.Engine/Sys4/BmpHeader.cs` | New: `ReadDims(path) → (w,h)` from BMP header | 2 | +| `engine/Age.Engine.Tests/BmpHeaderTests.cs` | New: `ReadDims` on a known BMP | 2 | +| `godot/GodotAdvHost.cs` | Per-slot BMP path + dims; real `GetTextureSize`; `DrawTexture` → blit | 1 (stub), 3 | +| `godot/Main.cs` | Screen `Image` backbuffer + one `TextureRect`; `BlitSlot` | 3 | + +--- + +### Task 1: `0x208 get-texture-size` — real VM op + host contract + +Make `0x208` a VM op that writes the loaded texture's width/height into its two output globals, backed by `IHost.GetTextureSize`. All non-Godot hosts return `(0,0)` so trace/selftest parity holds. The Godot host gets a temporary `(0,0)` stub here (real impl in Task 3) so `Himegari.csproj` keeps compiling. + +**Files:** +- Modify: `vm-map/opcodes.toml` (op `0x208` block, ~line 4980) +- Modify: `engine/Age.Engine/Hosting/IHost.cs:10` (add method) +- Modify: `engine/Age.Engine/Hosting/CaptureHost.cs` +- Modify: `engine/Age.Engine/Vm/VirtualMachine.cs` (add case near the other texture ops, ~line 175) +- Modify: `engine/Age.Cli/Program.cs` (`AudioTraceHost`) +- Modify: `engine/Age.Engine.Tests/TextureOpsTests.cs` (`RecHost`) +- Modify: `godot/GodotAdvHost.cs` (temporary stub) +- Test: `engine/Age.Engine.Tests/TextureGeometryTests.cs` (new) + +**Interfaces:** +- Produces: `IHost.GetTextureSize(int slot) → (int Width, int Height)`. VM handler for label `"get-texture-size"`: reads operand 0 as the slot, writes `Width` to operand 1 and `Height` to operand 2 (both via `Write`). +- Consumes: existing `Read`/`Write` operand helpers; `OpcodeTable.Label(op)` dispatch (dispatches on the `label` field, confirmed: `set-texture`/`draw-texture` carry friendly labels in `opcodes.toml`). + +- [ ] **Step 1: Rename the opcode in the source-of-truth and rebuild** + +Edit `vm-map/opcodes.toml`, the `op = 0x208` block. Change the `label` line and the `[opcode.semantics]` fields (leave `argc = 3` and the three `[[opcode.semantics.args]]` blocks; optionally set their `role`s): + +```toml +[[opcode]] +op = 0x208 +label = "get-texture-size" +argc = 3 +abi_source = "kelebek+decode-validated" + +[opcode.semantics] +name = "get-texture-size" +category = "draw" +summary = "0x208 (slot)(out_w)(out_h) — writes the loaded texture's width/height into two output globals; keystone for bytecode-computed sprite/bg geometry (SC0000 label_12649)" +noop_headless = false +source = "inference" +confidence = "med" +depends_on = [] +evidence = "SC0000 label_12649: set-texture(resId,slot) then 0x208(slot)->w,h feeds w/2 horizontal-center + foot-anchor subtraction into draw-texture dst; stubbing yields 0x0 sizes / off-center draws" + +[[opcode.semantics.args]] +i = 1 +role = "slot" +observed_types = ["imm", "g-int", "l-int"] + +[[opcode.semantics.args]] +i = 2 +role = "out_width" +observed_types = ["g-int", "l-int"] + +[[opcode.semantics.args]] +i = 3 +role = "out_height" +observed_types = ["g-int", "l-int"] +``` + +Then rebuild the generated files: + +Run: `py -3.11 -X utf8 tools/opcodes_build.py --build` +Expected: succeeds, writes the 4 generated files. + +Verify the label propagated: + +Run: `grep -n '"label": "get-texture-size"' build/opcodes.json` +Expected: one match. + +- [ ] **Step 2: Add `GetTextureSize` to `IHost` and all existing implementors (compile green)** + +In `engine/Age.Engine/Hosting/IHost.cs`, add after `DrawTexture` (line 10): + +```csharp + (int Width, int Height) GetTextureSize(int slot); +``` + +In `engine/Age.Engine/Hosting/CaptureHost.cs`, add: + +```csharp + public (int Width, int Height) GetTextureSize(int slot) => (0, 0); +``` + +In `engine/Age.Cli/Program.cs`, add to `AudioTraceHost` (with the other no-op IHost members): + +```csharp + public (int Width, int Height) GetTextureSize(int slot) => (0, 0); +``` + +In `engine/Age.Engine.Tests/TextureOpsTests.cs`, add to `RecHost`: + +```csharp + public (int Width, int Height) GetTextureSize(int slot) => (0, 0); +``` + +In `godot/GodotAdvHost.cs`, add a temporary stub (real impl in Task 3), next to the texture ops: + +```csharp + public (int Width, int Height) GetTextureSize(int slot) => (0, 0); +``` + +Run: `dotnet build engine/AgeEngine.sln` +Expected: build succeeds (no `IHost` member missing). + +- [ ] **Step 3: Write the failing test** + +Create `engine/Age.Engine.Tests/TextureGeometryTests.cs`: + +```csharp +using System.Collections.Generic; +using Age.Engine.Hosting; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +public class TextureGeometryTests +{ + private sealed class FakeSizeHost : IHost + { + public void ShowText(int o, string t) { } + public void CallScript(long id) { } + public void OnStub(int op) { } + public void WaitForInput() { } + public void CreateTexture(int slot, int w, int h) { } + public void SetTexture(long resId, int slot) { } + public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { } + public void PlayBgm(long id) { } + public void PlayVoice(long id) { } + public (int Width, int Height) GetTextureSize(int slot) => (0x140, 0xC8); + } + + [Fact] + public void GetTextureSizeWritesHostDimsIntoOutputGlobals() + { + var table = OpcodeTableJson.Load(Paths.OpcodesJson); + // 0x208 (global-int 50)(global-int 60)(global-int 61): slot=50, out_w=G[60], out_h=G[61] + const int T_GINT = 3; + var ins = new Instruction(0, 0x208, new[] + { + new Operand(T_GINT, 50), new Operand(T_GINT, 60), new Operand(T_GINT, 61), + }); + var script = new Script + { + Header = new ScriptHeader(0, 0, 0, 0, 0, 0), + Instructions = new[] { ins }, + IndexByOffset = new Dictionary { { 0, 0 } }, + Strings = new Dictionary(), + }; + var vm = new VirtualMachine(script, table, new FakeSizeHost()); + vm.Run(); + Assert.Equal(0x140, vm.Globals[60]); + Assert.Equal(0xC8, vm.Globals[61]); + } +} +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~TextureGeometryTests` +Expected: FAIL — `0x208` currently dispatches to `OnStub`, so `G[60]`/`G[61]` are never written and `vm.Globals[60]` throws `KeyNotFoundException` (or the asserts fail). + +- [ ] **Step 5: Add the VM handler** + +In `engine/Age.Engine/Vm/VirtualMachine.cs`, add a case alongside the other texture ops (after the `draw-texture` case, ~line 177): + +```csharp + case "get-texture-size": // 0x208 (slot) (out_w) (out_h) + { + var (gw, gh) = _host.GetTextureSize((int)Read(a[0])); + Write(a[1], gw); Write(a[2], gh); + return pc + 1; + } +``` + +- [ ] **Step 6: Run the new test to verify it passes** + +Run: `dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~TextureGeometryTests` +Expected: PASS. + +- [ ] **Step 7: Run the full engine suite (parity guardrail)** + +Run: `dotnet test engine/AgeEngine.sln` +Expected: all tests PASS — including `TraceDiffTests` (C# trace byte-identical to `build/vm0-trace.json`). Rationale: with `CaptureHost.GetTextureSize` returning `(0,0)`, the handler writes `0`/`0`, which equals the prior default; the op is still one step, so offsets/halt/steps are unchanged. + +- [ ] **Step 8: Confirm the Godot project still compiles** + +Run: `dotnet build godot/Himegari.csproj` +Expected: build succeeds (the temporary `GetTextureSize` stub satisfies `IHost`). + +- [ ] **Step 9: Commit** + +```bash +git add vm-map/opcodes.toml tools/age_opcodes_himegari.py build/opcodes.json docs/opcode-reference.md build/opcode-coverage.md \ + engine/Age.Engine/Hosting/IHost.cs engine/Age.Engine/Hosting/CaptureHost.cs \ + engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Cli/Program.cs \ + engine/Age.Engine.Tests/TextureOpsTests.cs engine/Age.Engine.Tests/TextureGeometryTests.cs \ + godot/GodotAdvHost.cs +git commit -m "feat(a2b): implement 0x208 get-texture-size (geometry keystone) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 2: `BmpHeader.ReadDims` + `gfx` diagnostic (headless numeric oracle) + +Add a reusable BMP-dimension reader and an `Age.Cli gfx ` command that runs a scene and dumps each `set-texture` / `get-texture-size` / `draw-texture` with resolved file + computed geometry — so we can validate geometry numerically without Godot (mirrors the `audio` command). + +**Files:** +- Create: `engine/Age.Engine/Sys4/BmpHeader.cs` +- Test: `engine/Age.Engine.Tests/BmpHeaderTests.cs` (new) +- Modify: `engine/Age.Cli/Program.cs` (add `gfx` command + `GfxTraceHost`) + +**Interfaces:** +- Produces: `BmpHeader.ReadDims(string path) → (int Width, int Height)` (0,0 on failure/missing); `Age.Cli gfx [0xADDR=VAL ...]`. +- Consumes: `ResourceMap.Resolve` / `ResourceMap.TexturePath` (Task-independent, existing); the `0x208` handler from Task 1. + +- [ ] **Step 1: Write the failing test for `ReadDims`** + +Create `engine/Age.Engine.Tests/BmpHeaderTests.cs`: + +```csharp +using System.IO; +using Age.Engine.Sys4; +using Xunit; + +public class BmpHeaderTests +{ + [Fact] + public void ReadDimsReadsWidthAndHeightFromBmpHeader() + { + // Minimal 54-byte BMP header (BITMAPFILEHEADER 14 + BITMAPINFOHEADER 40); width=4, height=3. + var b = new byte[54]; + b[0] = (byte)'B'; b[1] = (byte)'M'; + System.BitConverter.GetBytes(40).CopyTo(b, 14); // header size + System.BitConverter.GetBytes(4).CopyTo(b, 18); // width + System.BitConverter.GetBytes(3).CopyTo(b, 22); // height + var tmp = Path.Combine(Path.GetTempPath(), "agehdr_test.bmp"); + File.WriteAllBytes(tmp, b); + try + { + var (w, h) = BmpHeader.ReadDims(tmp); + Assert.Equal(4, w); + Assert.Equal(3, h); + } + finally { File.Delete(tmp); } + } + + [Fact] + public void ReadDimsReturnsZeroForMissingFile() + { + var (w, h) = BmpHeader.ReadDims(Path.Combine(Path.GetTempPath(), "does_not_exist_agehdr.bmp")); + Assert.Equal((0, 0), (w, h)); + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~BmpHeaderTests` +Expected: FAIL — `BmpHeader` does not exist (compile error). + +- [ ] **Step 3: Implement `BmpHeader`** + +Create `engine/Age.Engine/Sys4/BmpHeader.cs`: + +```csharp +namespace Age.Engine.Sys4; + +/// +/// Reads pixel dimensions from a BMP file header (BITMAPINFOHEADER: width at byte 18, height at byte 22, +/// both little-endian int32; height may be negative for top-down bitmaps). Used to give the VM the +/// texture size that opcode 0x208 (get-texture-size) needs, without decoding pixels. Our textures are +/// pre-converted BMPs (tools/convert_agf.py). +/// +public static class BmpHeader +{ + public static (int Width, int Height) ReadDims(string? path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) return (0, 0); + try + { + var b = new byte[26]; + using var fs = File.OpenRead(path); + if (fs.Read(b, 0, 26) < 26 || b[0] != (byte)'B' || b[1] != (byte)'M') return (0, 0); + int w = System.BitConverter.ToInt32(b, 18); + int h = System.BitConverter.ToInt32(b, 22); + return (System.Math.Abs(w), System.Math.Abs(h)); + } + catch { return (0, 0); } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~BmpHeaderTests` +Expected: PASS (both facts). + +- [ ] **Step 5: Add the `gfx` command and `GfxTraceHost` to the CLI** + +In `engine/Age.Cli/Program.cs`, add a new command block after the `audio` block (before the `trace` block): + +```csharp +if (args[0] == "gfx") +{ + // gfx [0xADDR=VAL ...] — run the scene and dump executed texture ops in order with + // resolved file + computed geometry (set-texture / get-texture-size / draw-texture). Diagnostic only. + var sceneName = args[1]; + var sceneKey = Path.GetFileNameWithoutExtension(sceneName).ToUpperInvariant(); + var res = ResourceMap.Load(); + var host = new GfxTraceHost(res, sceneKey); + var vm = new VirtualMachine(Sys4Loader.Load(Paths.Scripts()[sceneName.ToUpperInvariant()], table), table, host); + foreach (var s in args.Skip(2)) + { + var kv = s.Split('='); + int k = kv[0].StartsWith("0x") ? Convert.ToInt32(kv[0], 16) : int.Parse(kv[0]); + long v = kv[1].StartsWith("0x") ? Convert.ToInt64(kv[1], 16) : long.Parse(kv[1]); + vm.Globals[k] = v; + } + vm.Run(); + Console.WriteLine($"{sceneName}: {host.Events.Count} texture ops (halt: {vm.HaltReason})"); + foreach (var line in host.Events) Console.WriteLine(" " + line); + return 0; +} +``` + +Add the `GfxTraceHost` class next to `AudioTraceHost` at the bottom of `Program.cs`: + +```csharp +sealed class GfxTraceHost : IHost +{ + private readonly ResourceMap _res; + private readonly string _scene; + private readonly Dictionary _slotBmp = new(); // slot -> resolved BMP path (or null) + public List Events { get; } = new(); + public GfxTraceHost(ResourceMap res, string scene) { _res = res; _scene = scene; } + + public (int Width, int Height) GetTextureSize(int slot) + { + _slotBmp.TryGetValue(slot, out var bmp); + var (w, h) = BmpHeader.ReadDims(bmp); + Events.Add($"get-tex-size slot={slot} -> {w}x{h}"); + return (w, h); + } + + public void SetTexture(long resId, int slot) + { + var e = _res.Resolve(_scene, resId); + var bmp = e != null ? ResourceMap.TexturePath(e) : null; + _slotBmp[slot] = bmp; + Events.Add($"set-texture slot={slot} res=0x{resId:x} -> {(e?.Name ?? "")}" + + (bmp == null ? " [NO BMP]" : "")); + } + + public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) + { + _slotBmp.TryGetValue(slot, out var bmp); + Events.Add($"draw-texture slot={slot} src=({sx},{sy} {w}x{h}) dst=({dx},{dy}) " + + $"file={(bmp != null ? System.IO.Path.GetFileName(bmp) : "")}"); + } + + public void CreateTexture(int slot, int width, int height) => Events.Add($"create-texture slot={slot} {width}x{height}"); + public void ShowText(int offset, string text) { } + public void CallScript(long id) { } + public void OnStub(int opcode) { } + public void WaitForInput() { } + public void PlayBgm(long id) { } + public void PlayVoice(long id) { } +} +``` + +- [ ] **Step 6: Run the diagnostic and inspect the geometry** + +Run: `dotnet run --project engine/Age.Cli -- gfx SC0000.BIN` +Expected: prints an ordered list of texture ops. Sanity checks: +- The full-screen background/event-CG draws show `src=(0,0 800x600)` (`0x320`×`0x258`) `dst=(0,0)` — i.e. `800x600` / `800x500` (bg is natively 800×500) at the origin. +- Any sprite `draw-texture` shows **non-zero** `w`×`h` and a plausible on-screen `dst` (not `0x0`, not wildly off-canvas). Before Task 1 these were `0×0`; the `get-tex-size` lines now report real dims from the BMP header. + +(No unit test for the command itself — it's a diagnostic, like `audio`. The `ReadDims` unit test covers the load-bearing logic.) + +- [ ] **Step 7: Commit** + +```bash +git add engine/Age.Engine/Sys4/BmpHeader.cs engine/Age.Engine.Tests/BmpHeaderTests.cs engine/Age.Cli/Program.cs +git commit -m "feat(a2b): BmpHeader.ReadDims + gfx diagnostic (headless geometry oracle) + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +### Task 3: Godot screen-backbuffer blit compositor + +Replace the TextureRect-per-slot approximation with one 800×600 screen `Image` that `draw-texture` blits into (src rect → dst) in execution order, displayed by a single `TextureRect`. The Godot host reports real texture dims (from the BMP header, on the VM thread) via `GetTextureSize`, replacing the Task-1 stub. This is the pass that makes sprites and backgrounds actually appear at correct positions/sizes on screen. + +**Files:** +- Modify: `godot/GodotAdvHost.cs` (per-slot path + dims; real `GetTextureSize`; `DrawTexture` → deferred blit) +- Modify: `godot/Main.cs` (screen `Image` backbuffer + one `TextureRect`; `BlitSlot`; remove per-slot `TextureRect`s) + +**Interfaces:** +- Consumes: `BmpHeader.ReadDims` (Task 2); `ResourceMap.Resolve` / `TexturePath` (existing); `IHost.GetTextureSize` (Task 1). +- Produces: `Main.BlitSlot(string bmpPath, int srcX, int srcY, int w, int h, int dstX, int dstY)` (main-thread, `CallDeferred`-invoked); a single displayed screen texture. + +- [ ] **Step 1: Rework `GodotAdvHost` — cache path + dims per slot, real `GetTextureSize`, blit on draw** + +In `godot/GodotAdvHost.cs`, replace the texture-ops region (the `_slotBmp` field stays; add a dims cache) so it reads: + +```csharp + private readonly Dictionary _slotBmp = new(); // slot -> pre-converted BMP path + private readonly Dictionary _slotDims = new(); // slot -> texture dims (BMP header) +``` + +```csharp + // ---- texture ops (run on the VM thread; marshal Godot node work to the main thread) ---- + public void CreateTexture(int slot, int width, int height) { _slotBmp[slot] = null; _slotDims[slot] = (0, 0); } + + public void SetTexture(long resourceId, int slot) + { + var asset = _res.Resolve(_scene, resourceId); + var bmp = asset != null ? ResourceMap.TexturePath(asset) : null; + _slotBmp[slot] = bmp; + _slotDims[slot] = BmpHeader.ReadDims(bmp); // synchronous: dims from the header, no Godot Image + } + + // Dims are read from the BMP header on the VM thread so the bytecode's geometry math (which calls this + // synchronously right after set-texture) sees the real size. Pixels are blitted later on the main thread. + public (int Width, int Height) GetTextureSize(int slot) + => _slotDims.TryGetValue(slot, out var d) ? (d.W, d.H) : (0, 0); + + public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) + { + if (_slotBmp.TryGetValue(slot, out var bmp) && bmp != null) + _main.CallDeferred("BlitSlot", bmp, srcX, srcY, width, height, dstX, dstY); + } +``` + +Add the `BmpHeader` namespace use — `GodotAdvHost.cs` already has `using Age.Engine.Sys4;`, so `BmpHeader.ReadDims` resolves. Remove the temporary `GetTextureSize` stub added in Task 1 (it is replaced by the real one above). + +- [ ] **Step 2: Rework `Main` — one screen backbuffer, blit into it, drop per-slot TextureRects** + +In `godot/Main.cs`, replace the slot fields and `_Ready` stage setup so the stage holds **one** `TextureRect` showing a shared screen `Image`. Replace: + +```csharp + private Control _stage = null!; // texture layer (behind the text) + private readonly Dictionary _slots = new(); +``` + +with: + +```csharp + private TextureRect _screenView = null!; // shows the composited screen backbuffer + private Image _screen = null!; // 800x600 immediate-mode canvas + private ImageTexture _screenTex = null!; +``` + +In `_Ready`, replace the `_stage` block with: + +```csharp + // Screen backbuffer: one 800x600 canvas that draw-texture blits into, shown behind the dialogue. + _screen = Image.CreateEmpty(800, 600, false, Image.Format.Rgba8); + _screenTex = ImageTexture.CreateFromImage(_screen); + _screenView = new TextureRect + { + Texture = _screenTex, + ExpandMode = TextureRect.ExpandModeEnum.IgnoreSize, + StretchMode = TextureRect.StretchModeEnum.Scale, + MouseFilter = MouseFilterEnum.Ignore, + }; + _screenView.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); + AddChild(_screenView); // added first -> draws behind the text/status labels +``` + +Replace the `DrawSlot` method with `BlitSlot`: + +```csharp + // Blit a source BMP (src rect) onto the screen backbuffer at (dstX,dstY), then refresh the display + // texture. Execution order == paint order, so later draws (sprites) land over earlier ones (bg). + public void BlitSlot(string bmpPath, int srcX, int srcY, int w, int h, int dstX, int dstY) + { + var src = new Image(); + if (src.LoadBmpFromBuffer(System.IO.File.ReadAllBytes(bmpPath)) != Error.Ok) + { GD.Print($"BMP load failed {bmpPath}"); return; } + if (src.GetFormat() != Image.Format.Rgba8) src.Convert(Image.Format.Rgba8); + + // Clamp the source rect to the image; a zero/negative size falls back to the full image. + int sw = w > 0 ? w : src.GetWidth(); + int sh = h > 0 ? h : src.GetHeight(); + sw = System.Math.Min(sw, src.GetWidth() - srcX); + sh = System.Math.Min(sh, src.GetHeight() - srcY); + if (sw <= 0 || sh <= 0) return; + + _screen.BlitRect(src, new Rect2I(srcX, srcY, sw, sh), new Vector2I(dstX, dstY)); + _screenTex.Update(_screen); + } +``` + +- [ ] **Step 3: Build the Godot project** + +Run: `godot --headless --path godot --import` +Then: `dotnet build godot/Himegari.csproj` +Expected: both succeed (no references to the removed `_stage`/`_slots`/`DrawSlot`). + +- [ ] **Step 4: Run the headless selftest (VM-behavior guardrail)** + +Run: `godot --headless --path godot -- --selftest` +Expected: `SELFTEST OK: 186 lines match vm0 trace`, exit 0. Dialogue flow does not branch on pixel geometry, so returning real dims from `GetTextureSize` must not change the emitted show-text offsets. **If it FAILS,** do not disable the check — investigate which geometry global leaked into control flow (a genuine finding); use `Age.Cli gfx SC0000.BIN` to see the computed values and trace the divergence. + +- [ ] **Step 5: Eyeball the opening (human oracle)** + +Run: `godot --path godot` +Expected: SC0000 opening renders with the background placed correctly (filling the frame / at the origin, not off-center) and any sprites at plausible positions and non-zero sizes. Known-and-expected caveats (out of scope this pass, do not treat as regressions): sprites may show an opaque background box (no chromakey yet) and `AE*` fades draw opaque/instant (no alpha yet). + +- [ ] **Step 6: Commit** + +```bash +git add godot/GodotAdvHost.cs godot/Main.cs +git commit -m "feat(a2b): screen-backbuffer blit compositor + real GetTextureSize + +Correct sprite/background geometry: get-texture-size feeds the bytecode's own +centering/anchor math; draw-texture blits src rect -> dst into one 800x600 +canvas in execution order. Selftest byte-parity preserved. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Self-review + +**Spec coverage:** +- `0x208` real op + `IHost.GetTextureSize` → Task 1. ✓ +- Screen backbuffer blit compositor, source images per slot, dest-handle collapse → Task 3. ✓ +- `Age.Cli gfx` diagnostic → Task 2 (Step 5–6). ✓ +- Trace/selftest parity preserved → Task 1 Step 7 (engine `TraceDiffTests`) + Task 3 Step 4 (Godot selftest). ✓ +- Opcode rename in `opcodes.toml` + rebuild → Task 1 Step 1. ✓ +- Empty-slot / missing-BMP / off-canvas / dims-mismatch edge cases → `BmpHeader.ReadDims` returns `(0,0)`; `BlitSlot` clamps src rect and returns on empty; `GetTextureSize` returns `(0,0)` for empty slots. ✓ +- Deferred (out of scope): fades/alpha, chromakey, true multi-surface, animation ops → not implemented; noted in Task 3 Step 5 caveats. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows complete code; commands have expected output. ✓ + +**Type consistency:** `GetTextureSize(int slot) → (int Width, int Height)` identical across `IHost`, `CaptureHost`, `AudioTraceHost`, test `RecHost`, `FakeSizeHost`, `GfxTraceHost`, `GodotAdvHost`. `BmpHeader.ReadDims(string?) → (int Width, int Height)` consistent in Tasks 2 and 3. `Main.BlitSlot(string,int,int,int,int,int,int)` matches the `CallDeferred("BlitSlot", bmp, srcX, srcY, width, height, dstX, dstY)` argument order in `GodotAdvHost.DrawTexture`. VM dispatch label `"get-texture-size"` matches the `label` set in `opcodes.toml`. ✓