# 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 `0xe` → `draw-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 3–5** 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": ""} — 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 test** — `engine/Age.Engine.Tests/TextureOpsTests.cs`: ```csharp 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) ```bash dotnet test engine/Age.Engine.Tests --filter FullyQualifiedName~TextureOpsTests ``` - [ ] **Step 3: Implement** `engine/Age.Engine/Hosting/IHost.cs` — add three methods: ```csharp 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`): ```csharp 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: ```csharp 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** ```bash 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** ```bash 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/.BMP`. **Interfaces — Produces:** `vm-map/resources.json` = `{"0x21": ""}`; `build/textures/.BMP` for the mapped AGF; `tools/convert_agf.py` (CLI: ` [ ...]`). This is the investigation **gate**. Output (the specific AGF) is discovered; the steps are concrete. - [ ] **Step 1: Write the AGF converter** — `tools/convert_agf.py`: ```python #!/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: ```bash 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: ```bash 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 `), 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`: ```json { "0x21": "REPLACE_WITH_RESOLVED.AGF" } ``` (the resolved AGF name from Step 2/3), then: ```bash py -3.11 -X utf8 tools/convert_agf.py ls build/textures/ # the resolved .BMP is present ``` Confirm by eye that `build/textures/.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** ```bash 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 test** — `engine/Age.Engine.Tests/BackgroundResolveTests.cs`: ```csharp 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) ```bash dotnet test engine/Age.Engine.Tests --filter FullyQualifiedName~BackgroundResolveTests ``` - [ ] **Step 3: Implement** — add to `engine/Age.Engine/Sys4/Paths.cs` (after `OpcodesJson`): ```csharp 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`: ```csharp using System.Text.Json; namespace Age.Engine.Sys4; public sealed class ResourceMap { private readonly IReadOnlyDictionary _m; private ResourceMap(IReadOnlyDictionary m) => _m = m; public static ResourceMap Load(string path) { var m = new Dictionary(); 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** ```bash 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** ```bash 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`): ```csharp // texture state (main-thread-facing values captured on the worker, applied via CallDeferred) private readonly Dictionary _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: ```csharp private TextureRect _bg = null!; private readonly System.Collections.Generic.Dictionary _slotTex = new(); ``` In `_Ready`, as the FIRST child added (before `_text`): ```csharp _bg = new TextureRect { StretchMode = TextureRect.StretchModeEnum.KeepAspectCovered }; _bg.SetAnchorsAndOffsetsPreset(LayoutPreset.FullRect); AddChild(_bg); ``` New public methods (invoked via `CallDeferred` on the main thread): ```csharp 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)** ```bash 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** ```bash 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: ```bash "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** ```bash 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 2–4) ✓ · ResourceMap resolver + Godot backend rendering only resolved full-screen slots + compositing behind dialogue (Tasks 3–4) ✓ · 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 3–4. `Paths.ResourcesJson`/`TexturesDir` defined Task 3, used Tasks 3–4. `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.