7.5 KiB
Design: A2b-Background — Engine-Driven Background Layer (SC0000)
Status: approved (design) · Date: 2026-07-06
Related: docs/phase-a-slice-plan.md (A2), the A2a Godot project (godot/), engine/Age.Engine,
build/data/CGINIT.json, extracted/DATA1/AGF2BMP2AGF.exe, extracted/DATA2|DATA5/*.AGF.
Problem / goal
A2a plays SC0000's dialogue on a black screen. A2b-Background renders the scene's background layer,
driven by the bytecode — the VM's create/set/draw-texture ops execute for real, the Godot backend
renders the slots whose resource it can resolve through a data map, and the background appears
because those ops ran (remove them → nothing draws). This is not a pinned/hardcoded image; it is
the first real graphics op-path promoted from stub to backend, with resolution as growable profile data.
Scope
In: the single background layer for SC0000 — promote create-texture/set-texture/
draw-texture to typed IHost methods; a resourceId → AGF resolution map (seeded for SC0000's bg via
investigation); offline AGF→image conversion; Godot composites the resolved full-screen background behind
the dialogue. Out: the other ~239 draws (full sprite/UI/effects compositor), general resolution for
all resources, per-op fade/blend semantics, voice, choices, non-SC0000 scenes. These follow later; the
resolution map and rendered slots grow over time.
The engine-driven principle (why this isn't a pin)
The bytecode drives every texture op. The backend keeps a slot → texture table; SetTexture(resId,slot)
resolves resId via the map and loads the (pre-converted) image into that slot; DrawTexture(slot,…)
renders a resolved full-screen slot into the background node. Unresolved slots (the other 239 draws) render
nothing yet. The background shows only because set-texture/draw-texture executed and resId
resolved. The map is per-game profile data, not a code shortcut; coverage grows as we map more resources.
Architecture
Engine — promote texture ops (Age.Engine)
Add to IHost (mirrors the WaitForInput addition; CaptureHost no-ops them so A1 trace-diff + A2a
selftest stay green):
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);
In VirtualMachine.Step, replace the OnStub fall-through for these opcodes with handlers that read the
operands and call the host (operand roles are inferred from disassembly — Phase 1 confirms/adjusts):
create-textureargs(slot, w, h, ?)→CreateTexture(Read(a0), (int)Read(a1), (int)Read(a2))set-textureargs(resId, slot, ?)→SetTexture(Read(a0), (int)Read(a1))draw-textureargs(fade, slot, x, y, w, h, ?, ?)→DrawTexture((int)Read(a1), (int)Read(a2), (int)Read(a3), (int)Read(a4), (int)Read(a5))
Opcode numbers (from vm-map/opcodes.toml): create-texture, set-texture, draw-texture are named
Kelebek ops; the VM dispatches on those labels exactly as it does show-text.
Resolution map (profile data)
build/resources.json (or a profile dir): { "<resourceId hex>": "<AGF filename>" }, seeded by Phase 1
for SC0000's background. Format is tentative — Phase 1 may show ids are scene-scoped (then keyed
{scene: {id: agf}}) rather than global. Lives on the backend/profile side, never in the VM core.
AGF → image pipeline
Offline: run AGF2BMP2AGF.exe <name>.AGF (verified: produces an 800×600 24-bit BMP) on the mapped AGFs,
into a Godot-loadable assets dir (e.g. build/textures/). Godot loads with Image.LoadFromFile +
ImageTexture.CreateFromImage. (A tiny tools/convert_agf.py wrapper converts the set named in
resources.json.)
Godot backend + compositing (godot/)
Main adds a background TextureRect behind the dialogue Label (full-rect, StretchMode = keep-aspect-
covered), created before the dialogue label so it's z-behind. GodotAdvHost implements the texture ops:
SetTexture(resId, slot)→ resolveresIdin the map; if found,CallDeferredload the pre-converted image into aslot → ImageTexturedict (main thread).DrawTexture(slot, x, y, w, h)→ if the slot has a texture and the draw is full-screen (≈800×600),CallDeferredset the backgroundTextureRect's texture. Unresolved/non-fullscreen slots: ignore.CreateTexture→ record slot dimensions (used to distinguish full-screen bg from small textures).
Phase 1 — Investigation (gated; the real unknown)
Goal: resolve SC0000's background AGF and document the resourceId → AGF mechanism.
- Static: confirm the operand roles of
create/set/draw-texture; identify which full-screen draw is the visible background (SC0000 draws slot 3 cross-context and slot 0xe from localset-texture 0x21); probe the resolution —SYS4INI(ALF filename index),*INITtables, any id→name path. (CGINITis a per-CG numeric record table, not a direct filename map, so resolution is likely a runtime path.) - Frida (if static is inconclusive): attach to the running translated game on SC0000; hook the
graphic-load call; capture the actual
resourceId → AGF filenamefor the background draw. This yields both the mechanism and a ground-truth oracle. - Confirm: convert the resolved AGF, eyeball that it is SC0000's intro background; seed
resources.json.
Gate: Phase 1's result confirms the operand mapping + resolution shape before the engine handlers and map format are finalized. Expect Frida to be needed (first Frida use in the project).
Validation
- Headless resolve-oracle: a test runs SC0000 with a recording host and asserts the background draw
path fires
SetTexturewith the expected resource id, and the map resolves it to the expected AGF (Phase 1 ground truth) — validates engine-driven resolution without pixels. - Regression:
dotnet test engine/AgeEngine.slnstays green and the A2a--selfteststill printsSELFTEST OK(newIHostmethods no-op inCaptureHost). - Manual visual (human): the converted background renders behind SC0000's dialogue in the Godot window and looks like the intro art; dialogue still advances on click/Enter.
Risks
- Resolution is the crux and uncertain-sized.
CGINITisn't a filename map and SC0000's primary bg slot is loaded cross-context; static RE may quickly stall → Frida. Mitigation: the machinery (op wiring, pipeline, compositing, map-lookup) is built independently of how the map is populated; Phase 1 only has to yield SC0000's one bg entry to unblock the visual. - Draw-op operand roles are inferred — Phase 1 confirms before the handlers are trusted; the headless oracle (which resId the bg path resolves) catches a wrong mapping.
- No pixel oracle — background correctness is human-eyeballed; the resolve-oracle is the automatable proxy. Frida capture is the strongest available ground truth.
- First Frida use — attaching to the packed AGE.EXE at runtime (packer is bypassed once running); budget setup time. If Frida is impractical, static-plus-eyeball is the fallback for this one bg.
- Trace parity — adding
IHosttexture methods must not changeSteps/Emitted; guaranteed byCaptureHostno-ops (verified by the A1 trace-diff regression).
Out of scope (later slices)
Full draw compositor (all 239 draws, layers, blends, fades), general/automated resolution for all
resources, sprite/CG/UI rendering, voice + BGM, choices, call-script/state seeding, non-SC0000 scenes.