Files
OpenMaidEngine/docs/superpowers/plans/2026-07-06-a2b-background.md
2026-07-06 15:44:07 -04:00

21 KiB
Raw Permalink Blame History

A2b-Background — Engine-Driven Background Layer 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: Render SC0000's locally-loaded full-screen background (set-texture 0x21 → slot 0xedraw-texture 800×600) behind the dialogue, driven by the bytecode executing the real texture ops and resolving the resource id through a data map.

Architecture: Promote create/set/draw-texture from VM stubs to typed IHost methods; an investigation resolves resource id 0x21 → an AGF filename into a tracked vm-map/resources.json; AGF2BMP2AGF.exe converts it to a BMP; the Godot backend loads the mapped image into a slot and composites the full-screen slot into a background node behind the dialogue.

Tech Stack: C# / .NET 8, Godot 4.7 mono, xUnit, Python 3.11, AGF2BMP2AGF.exe, (optionally Frida).

Global Constraints

  • Work on a feature branch (e.g. feat/a2b-background); do not commit to main.
  • Concrete target = resource id 0x21 — SC0000's self-loaded full-screen image (set-texture 0x21 0xe, drawn 800×600 at instr 0x5e8). The machinery is resource-agnostic; only Task 2's lookup is 0x21-specific.
  • Confirmed opcode ABI (from build/opcodes.json): create-texture 0x1f8 argc 4 = (slot, w, h, ?); set-texture 0x1f9 argc 3 = (resId, slot, ?); draw-texture 0x1fb argc 8 = (fade, slot, x, y, w, h, ?, ?). VM dispatches on the Kelebek labels.
  • Trace parity is sacred: new IHost methods must not change Steps/Emitted. CaptureHost no-ops them → A1 trace-diff + WaitForInputTests + A2a --selftest stay green. Verified in Task 1.
  • Engine-driven, not pinned: the background renders only because set-texture/draw-texture execute and the resource id resolves through vm-map/resources.json. No image is drawn without the bytecode driving it.
  • vm-map/resources.json (the id→AGF map) is tracked; build/textures/*.BMP (converted images) are gitignored/regenerable via tools/convert_agf.py.
  • Godot toolchain (verified A2a): GODOT="S:/Godot/Godot_v4.7-stable_mono_win64/Godot_v4.7-stable_mono_win64_console.exe"; dotnet build godot/Himegari.csproj; "$GODOT" --headless --path godot [-- --selftest].
  • Contingency (Task 2 is a gate): this plan assumes SC0000's presentable full-screen background is the locally-loaded res 0x21. If Task 2 finds res 0x21 is not a sensible background (e.g. it's an overlay and the real bg is the cross-context slot-3 image), pause and revise Tasks 35 to target the correct resource — the machinery is unchanged, only the mapped id/AGF differs.

File Structure

engine/Age.Engine/Hosting/IHost.cs        (modify: +CreateTexture/SetTexture/DrawTexture)
engine/Age.Engine/Hosting/CaptureHost.cs  (modify: +3 no-ops)
engine/Age.Engine/Vm/VirtualMachine.cs    (modify: 3 texture-op handlers)
engine/Age.Engine/Sys4/Paths.cs           (modify: +ResourcesJson, +TexturesDir, +VmMap)
engine/Age.Engine/Sys4/ResourceMap.cs     (create: id→AGF resolver)
engine/Age.Engine.Tests/TextureOpsTests.cs        (create: Task 1 recording test)
engine/Age.Engine.Tests/BackgroundResolveTests.cs (create: Task 3 resolve-oracle)
tools/convert_agf.py                       (create: AGF→BMP via AGF2BMP2AGF.exe)
vm-map/resources.json                      (create: {"0x21": "<AGF>"} — Task 2)
godot/GodotAdvHost.cs                       (modify: texture ops + slot dict)
godot/Main.cs                               (modify: background TextureRect)
docs/phase-a-slice-plan.md                  (modify: mark A2b-bg done)

Task 1: Engine texture-op wiring

Files: Modify IHost.cs, CaptureHost.cs, VirtualMachine.cs; Create Age.Engine.Tests/TextureOpsTests.cs.

Interfaces — Produces: IHost.CreateTexture(int slot,int width,int height), IHost.SetTexture(long resourceId,int slot), IHost.DrawTexture(int slot,int x,int y,int width,int height); CaptureHost no-ops them.

  • Step 1: Write the failing testengine/Age.Engine.Tests/TextureOpsTests.cs:
using System.Collections.Generic;
using Age.Engine.Hosting;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;

public class TextureOpsTests
{
    private sealed class RecHost : IHost
    {
        public List<(long resId, int slot)> Sets = new();
        public List<(int slot, int w, int h)> Draws = new();
        public int Creates;
        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) => Creates++;
        public void SetTexture(long resId, int slot) => Sets.Add((resId, slot));
        public void DrawTexture(int slot, int x, int y, int w, int h) => Draws.Add((slot, w, h));
    }

    [Fact]
    public void SC0000FiresTextureOpsIncludingRes0x21FullScreen()
    {
        var table = OpcodeTableJson.Load(Paths.OpcodesJson);
        var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table);
        var host = new RecHost();
        new VirtualMachine(script, table, host).Run();
        Assert.True(host.Creates > 0, "create-texture should fire");
        Assert.Contains(host.Sets, s => s.resId == 0x21);                 // the local bg load
        Assert.Contains(host.Draws, d => d.w == 0x320 && d.h == 0x258);   // a full-screen (800x600) draw
    }
}
  • Step 2: Run — expect FAIL (IHost lacks the methods → RecHost won't compile against it)
dotnet test engine/Age.Engine.Tests --filter FullyQualifiedName~TextureOpsTests
  • Step 3: Implement

engine/Age.Engine/Hosting/IHost.cs — add three methods:

    void CreateTexture(int slot, int width, int height);
    void SetTexture(long resourceId, int slot);
    void DrawTexture(int slot, int x, int y, int width, int height);

engine/Age.Engine/Hosting/CaptureHost.cs — add no-ops (after WaitForInput):

    public void CreateTexture(int slot, int width, int height) { }
    public void SetTexture(long resourceId, int slot) { }
    public void DrawTexture(int slot, int x, int y, int width, int height) { }

engine/Age.Engine/Vm/VirtualMachine.cs — add handlers before the default: in Step's switch:

            case "create-texture":
                _host.CreateTexture((int)Read(a[0]), (int)Read(a[1]), (int)Read(a[2])); return pc + 1;
            case "set-texture":
                _host.SetTexture(Read(a[0]), (int)Read(a[1])); return pc + 1;
            case "draw-texture":
                _host.DrawTexture((int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]), (int)Read(a[5])); return pc + 1;
  • Step 4: Run — expect PASS + full regression
dotnet test engine/AgeEngine.sln

Expected: 8 passed (new test + the 7 existing; RECOVER/trace-diff/WaitForInput unchanged — CaptureHost no-ops keep Steps/Emitted identical).

  • Step 5: Commit
git add engine/Age.Engine/Hosting engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine.Tests/TextureOpsTests.cs
git commit -m "feat(a2b): promote create/set/draw-texture to IHost methods (trace parity kept)"

Task 2: Investigation — resolve res 0x21 → AGF + convert + seed map

Files: Create tools/convert_agf.py, vm-map/resources.json; produce build/textures/<AGF>.BMP.

Interfaces — Produces: vm-map/resources.json = {"0x21": "<AGF filename>"}; build/textures/<stem>.BMP for the mapped AGF; tools/convert_agf.py (CLI: <AGF-name> [<AGF-name> ...]).

This is the investigation gate. Output (the specific AGF) is discovered; the steps are concrete.

  • Step 1: Write the AGF convertertools/convert_agf.py:
#!/usr/bin/env python3
"""Convert named AGF files (in extracted/DATA2 or DATA5) to BMP via AGF2BMP2AGF.exe,
into build/textures/. Run: py -3.11 -X utf8 tools/convert_agf.py EV001AA.AGF ..."""
import os, sys, shutil, subprocess
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import paths

EXE = paths.EXTRACTED / "DATA1" / "AGF2BMP2AGF.exe"  # tool lives in extracted/DATA1
SRC_DIRS = [paths.EXTRACTED / "DATA2", paths.EXTRACTED / "DATA5"]
OUT = paths.BUILD / "textures"

def find(name):
    for d in SRC_DIRS:
        p = d / name
        if p.exists():
            return p
    return None

def main(argv):
    OUT.mkdir(parents=True, exist_ok=True)
    for name in argv:
        src = find(name)
        if not src:
            print(f"NOT FOUND: {name}"); continue
        tmp = OUT / name
        shutil.copy(src, tmp)
        subprocess.run([str(EXE), name], cwd=str(OUT), check=True)
        tmp.unlink(missing_ok=True)  # drop the copied .AGF, keep the .BMP
        print(f"converted {name} -> {OUT / (os.path.splitext(name)[0] + '.BMP')}")
    return 0

if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))

Sanity-check it converts a known file:

py -3.11 -X utf8 tools/convert_agf.py EV001CG.AGF
ls -la build/textures/EV001CG.BMP   # ~1.44 MB (800x600x24)

Expected: build/textures/EV001CG.BMP exists.

  • Step 2: Resolve res 0x21 → AGF (static first). Probe the id→name path — SYS4INI.BIN is the ALF filename index; check whether id 0x21/33 (or the value in a nearby resource table) maps to an AGF name. Inspect:
py -3.11 -X utf8 tools/sys4load.py "S:/Game Hacking/Eushully/Himegari/extracted/DATA1/SYS4INI.BIN" --strings 2>&1 | grep -iE '\.AGF|EV[0-9]|BG[0-9]' | head -30

and cross-reference SC0000's texture context (what create-texture/set-texture values surround 0x21) via build/disasm/SC0000.asm around instr 0x5e1. Goal: a candidate AGF name for res 0x21.

  • Step 3: If static is inconclusive — Frida capture (ground truth). Check availability: py -3.11 -c "import frida; print(frida.__version__)" (or frida --version). If present, attach to the running translated game, hook the graphic-load call, reach SC0000, and record the id → AGF for the res-0x21 load. If Frida is unavailable/impractical, fallback: convert the handful of plausible full-screen intro AGFs from DATA2 (py -3.11 -X utf8 tools/convert_agf.py <candidates>), open the BMPs, and pick the one that is SC0000's opening background by eye. Record which AGF corresponds to res 0x21.

  • Step 4: Seed the map + convert the winner. Write vm-map/resources.json:

{ "0x21": "REPLACE_WITH_RESOLVED.AGF" }

(the resolved AGF name from Step 2/3), then:

py -3.11 -X utf8 tools/convert_agf.py <resolved AGF name>
ls build/textures/   # the resolved .BMP is present

Confirm by eye that build/textures/<resolved>.BMP is a plausible SC0000 background. If it clearly isn't a background, invoke the Global-Constraints contingency (retarget to the correct resource) before proceeding.

  • Step 5: Commit
git add tools/convert_agf.py vm-map/resources.json
git commit -m "feat(a2b): resolve SC0000 bg res 0x21 -> AGF; convert_agf tool + resources.json"

Task 3: Resolve-oracle (headless) + ResourceMap + Paths

Files: Modify Age.Engine/Sys4/Paths.cs; Create Age.Engine/Sys4/ResourceMap.cs, Age.Engine.Tests/BackgroundResolveTests.cs.

Interfaces — Produces: Paths.ResourcesJson, Paths.TexturesDir; ResourceMap { static ResourceMap Load(string path); string? Resolve(long resourceId); }.

  • Step 1: Write the failing testengine/Age.Engine.Tests/BackgroundResolveTests.cs:
using System.IO;
using System.Linq;
using Age.Engine.Hosting;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;

public class BackgroundResolveTests
{
    private sealed class RecHost : IHost
    {
        public System.Collections.Generic.List<(long resId, int slot)> Sets = new();
        public System.Collections.Generic.List<(int slot, int w, int h)> Draws = new();
        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) => Sets.Add((resId, slot));
        public void DrawTexture(int slot, int x, int y, int w, int h) => Draws.Add((slot, w, h));
    }

    [Fact]
    public void SC0000BackgroundResolvesToAConvertedImage()
    {
        var table = OpcodeTableJson.Load(Paths.OpcodesJson);
        var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table);
        var host = new RecHost();
        new VirtualMachine(script, table, host).Run();

        // the bg: res 0x21 loaded into a slot that is then drawn full-screen
        var set = host.Sets.First(s => s.resId == 0x21);
        Assert.Contains(host.Draws, d => d.slot == set.slot && d.w == 0x320 && d.h == 0x258);

        var map = ResourceMap.Load(Paths.ResourcesJson);
        string? agf = map.Resolve(0x21);
        Assert.False(string.IsNullOrEmpty(agf), "res 0x21 must resolve in vm-map/resources.json");
        string bmp = Path.Combine(Paths.TexturesDir, Path.GetFileNameWithoutExtension(agf) + ".BMP");
        Assert.True(File.Exists(bmp), $"converted background missing: {bmp}");
    }
}
  • Step 2: Run — expect FAIL (Paths.ResourcesJson/ResourceMap missing)
dotnet test engine/Age.Engine.Tests --filter FullyQualifiedName~BackgroundResolveTests
  • Step 3: Implement — add to engine/Age.Engine/Sys4/Paths.cs (after OpcodesJson):
    public static string VmMap => Path.Combine(Repo, "vm-map");
    public static string ResourcesJson => Path.Combine(VmMap, "resources.json");
    public static string TexturesDir => Path.Combine(Build, "textures");

Create engine/Age.Engine/Sys4/ResourceMap.cs:

using System.Text.Json;
namespace Age.Engine.Sys4;
public sealed class ResourceMap
{
    private readonly IReadOnlyDictionary<long, string> _m;
    private ResourceMap(IReadOnlyDictionary<long, string> m) => _m = m;

    public static ResourceMap Load(string path)
    {
        var m = new Dictionary<long, string>();
        if (File.Exists(path))
        {
            using var doc = JsonDocument.Parse(File.ReadAllText(path));
            foreach (var p in doc.RootElement.EnumerateObject())
                m[System.Convert.ToInt64(p.Name, 16)] = p.Value.GetString() ?? "";
        }
        return new ResourceMap(m);
    }

    public string? Resolve(long resourceId) => _m.TryGetValue(resourceId, out var v) ? v : null;
}
  • Step 4: Run — expect PASS + full regression
dotnet test engine/AgeEngine.sln

Expected: 9 passed. (This is the engine-driven-resolution oracle: SC0000's execution routes res 0x21 to a full-screen draw, and the map+conversion yield a real file — no pixels needed.)

  • Step 5: Commit
git add engine/Age.Engine/Sys4/Paths.cs engine/Age.Engine/Sys4/ResourceMap.cs engine/Age.Engine.Tests/BackgroundResolveTests.cs
git commit -m "feat(a2b): ResourceMap + Paths + headless bg resolve-oracle (res 0x21 -> converted image)"

Task 4: Godot backend — texture ops + background compositing

Files: Modify godot/GodotAdvHost.cs, godot/Main.cs.

Interfaces — Consumes: ResourceMap, Paths.ResourcesJson, Paths.TexturesDir. Produces: the background rendered behind the dialogue when the bytecode draws the resolved full-screen slot.

  • Step 1: Extend godot/GodotAdvHost.cs — add the texture-op state + methods (add fields near the top, methods after SignalInput):
    // texture state (main-thread-facing values captured on the worker, applied via CallDeferred)
    private readonly Dictionary<int, (int W, int H)> _slotDims = new();
    private readonly ResourceMap _resources = ResourceMap.Load(Paths.ResourcesJson);

    public void CreateTexture(int slot, int width, int height) { _slotDims[slot] = (width, height); }

    public void SetTexture(long resourceId, int slot)
    {
        string? agf = _resources.Resolve(resourceId);
        if (string.IsNullOrEmpty(agf)) return;                        // unmapped -> slot stays empty
        string bmp = System.IO.Path.Combine(Paths.TexturesDir,
                        System.IO.Path.GetFileNameWithoutExtension(agf) + ".BMP");
        if (System.IO.File.Exists(bmp)) _main.CallDeferred("LoadSlotTexture", slot, bmp);
    }

    public void DrawTexture(int slot, int x, int y, int width, int height)
    {
        if (width == 0x320 && height == 0x258)                        // full-screen -> background layer
            _main.CallDeferred("ShowBackground", slot);
    }

Add using Age.Engine.Sys4; at the top of the file.

  • Step 2: Extend godot/Main.cs — add the background node + the two deferred UI methods, and load ResourceMap's images. Add a field and create the node in _Ready before the _text label (so it's z-behind); add the methods near the other UI methods:
    private TextureRect _bg = null!;
    private readonly System.Collections.Generic.Dictionary<int, Texture2D> _slotTex = new();

In _Ready, as the FIRST child added (before _text):

        _bg = new TextureRect { StretchMode = TextureRect.StretchModeEnum.KeepAspectCovered };
        _bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect);
        AddChild(_bg);

New public methods (invoked via CallDeferred on the main thread):

    public void LoadSlotTexture(int slot, string bmpPath)
    {
        var img = Image.LoadFromFile(bmpPath);
        if (img != null) _slotTex[slot] = ImageTexture.CreateFromImage(img);
    }

    public void ShowBackground(int slot)
    {
        if (_slotTex.TryGetValue(slot, out var tex)) _bg.Texture = tex;
    }
  • Step 3: Build + regression (headless selftest still green)
GODOT="S:/Godot/Godot_v4.7-stable_mono_win64/Godot_v4.7-stable_mono_win64_console.exe"
dotnet build godot/Himegari.csproj 2>&1 | grep -iE 'Build succeeded|error CS'
"$GODOT" --headless --path godot -- --selftest 2>&1 | grep SELFTEST

Expected: Build succeeded; SELFTEST OK: 186 lines match vm0 trace (dialogue path unaffected by the new texture handling).

  • Step 4: Commit
git add godot/GodotAdvHost.cs godot/Main.cs
git commit -m "feat(a2b): Godot backend renders the resolved full-screen background behind dialogue"

Task 5: Manual visual + docs

Files: Modify docs/phase-a-slice-plan.md.

  • Step 1: Manual visual check (human) — run windowed:
"S:/Godot/Godot_v4.7-stable_mono_win64/Godot_v4.7-stable_mono_win64.exe" --path godot

Confirm by eye: SC0000's dialogue now appears over a background image (the converted AGF), and the dialogue still advances on click/Enter. (If the bg is wrong/absent, revisit Task 2's resolution.)

  • Step 2: Update docs/phase-a-slice-plan.md — under the A2a subsection add:
### A2b-Background — engine-driven background layer ✅ DONE (2026-07-06)
Promoted create/set/draw-texture (0x1f8/0x1f9/0x1fb) to IHost methods (CaptureHost no-ops → trace parity
kept). SC0000's local full-screen res 0x21 resolves via vm-map/resources.json to an AGF, converted by
tools/convert_agf.py (AGF2BMP2AGF → build/textures/*.BMP), rendered behind the dialogue by GodotAdvHost +
a background TextureRect. Headless resolve-oracle asserts res 0x21 -> full-screen draw -> converted image;
engine tests 9/9; A2a selftest still OK. Only the bg LAYER (other ~239 draws still stub); resolution map
grows over time. Next A2b: voice/BGM, choices, general resolution.
Spec/plan: docs/superpowers/{specs,plans}/2026-07-06-a2b-background*.md.
  • Step 3: Commit
git add docs/phase-a-slice-plan.md
git commit -m "docs(a2b): mark engine-driven background layer done"

Self-Review

Spec coverage: promote create/set/draw-texture to typed IHost + CaptureHost no-op + trace parity (Task 1) ✓ · resolution map as tracked profile data + AGF pipeline (Task 2) ✓ · investigation static-then-Frida with eyeball fallback + gate (Task 2 Steps 24) ✓ · ResourceMap resolver + Godot backend rendering only resolved full-screen slots + compositing behind dialogue (Tasks 34) ✓ · headless resolve-oracle (no pixels), A1/A2a regression, manual visual (Tasks 3,4,5) ✓ · engine-driven-not-pinned (map lookup gated by executed ops) ✓ · out-of-scope (239 draws, general resolution, voice/choices) absent ✓.

Placeholder scan: the only discovered value is Task 2's AGF name (REPLACE_WITH_RESOLVED.AGF) — that is the output of an investigation task, with concrete steps to find it (static probe → Frida → eyeball fallback), not an unfilled plan gap. All code steps are complete.

Type consistency: IHost.CreateTexture(int,int,int)/SetTexture(long,int)/DrawTexture(int,int,int,int,int) defined in Task 1 and implemented by CaptureHost (Task 1), RecHost (Tasks 1,3), and GodotAdvHost (Task 4). ResourceMap.Load(string)/Resolve(long)→string? defined Task 3, used Tasks 34. Paths.ResourcesJson/TexturesDir defined Task 3, used Tasks 34. Main.LoadSlotTexture(int,string)/ShowBackground(int) defined Task 4 and called via CallDeferred from GodotAdvHost (Task 4). The full-screen sentinel 0x320×0x258 (800×600) is used identically in Tasks 1, 3, 4.