Files
OpenMaidEngine/docs/superpowers/plans/2026-07-06-a2b-graphics-geometry.md
2026-07-06 22:43:35 -04:00

27 KiB
Raw Permalink Blame History

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/<name>.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 RecHosts) 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 0x208get-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 roles):

[[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):

    (int Width, int Height) GetTextureSize(int slot);

In engine/Age.Engine/Hosting/CaptureHost.cs, add:

    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):

    public (int Width, int Height) GetTextureSize(int slot) => (0, 0);

In engine/Age.Engine.Tests/TextureOpsTests.cs, add to RecHost:

    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:

    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:

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<int, int> { { 0, 0 } },
            Strings = new Dictionary<int, string>(),
        };
        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):

            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
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) <noreply@anthropic.com>"

Task 2: BmpHeader.ReadDims + gfx diagnostic (headless numeric oracle)

Add a reusable BMP-dimension reader and an Age.Cli gfx <SCENE> 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 <SCENE.BIN> [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:

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:

namespace Age.Engine.Sys4;

/// <summary>
/// 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).
/// </summary>
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):

if (args[0] == "gfx")
{
    // gfx <SCENE.BIN> [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:

sealed class GfxTraceHost : IHost
{
    private readonly ResourceMap _res;
    private readonly string _scene;
    private readonly Dictionary<int, string?> _slotBmp = new();   // slot -> resolved BMP path (or null)
    public List<string> 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 ?? "<unresolved>")}"
                   + (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) : "<none>")}");
    }

    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
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) <noreply@anthropic.com>"

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 TextureRects)

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:

    private readonly Dictionary<int, string?> _slotBmp = new();          // slot -> pre-converted BMP path
    private readonly Dictionary<int, (int W, int H)> _slotDims = new();  // slot -> texture dims (BMP header)
    // ---- 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:

    private Control _stage = null!;                       // texture layer (behind the text)
    private readonly Dictionary<int, TextureRect> _slots = new();

with:

    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:

        // 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:

    // 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
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) <noreply@anthropic.com>"

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 56). ✓
  • 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. ✓