Files
OpenMaidEngine/docs/superpowers/plans/2026-07-07-animated-compositor-phase1.md
gamer147 011d0900c5 docs(gfx): animated-compositor design spec + Phase 1 plan + animation RE
engine-re.md: the gfx animation/effects subsystem (gfx_anim_start/0x234, 0x1fd,
0x238 non-blocking anim clock, render model — the fades are host-loop-drivable, no
VM/host lockstep). Design spec: retained per-frame animated compositor, 4-phase.
Phase 1 plan (TDD): retained DrawLayer model + per-frame clear/recomposite. Ghidra
handlers/workers annotated (0x234/0x1fd/0x238/gfx_anim_start).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 19:41:13 -04:00

14 KiB
Raw Blame History

Animated Compositor — Phase 1: Retained Compositor Implementation Plan (TDD)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax.

Goal: Replace the immediate-mode permanent canvas with a retained-object compositordraw-texture records/updates a persistent layer keyed by its object handle; the Godot host clears + re-composites all active layers each frame; 0x1f7 erase removes them. This is the foundation for alpha (Phase 2) and time-animation (Phase 3).

Architecture: The retained layer set lives in GfxState (VM-owned, version-neutral, thread-safe): the VM's draw-texture updates it (additive — engine parity preserved), EraseRange removes from it. The Godot host stops immediate-blitting; Main._Process snapshots the layers each frame and composites them (in execution order = current correct paint order) using the host's slot→image map. Design: docs/superpowers/specs/2026-07-07-animated-compositor-design.md.

Tech Stack: C# / .NET 8 (engine/, xUnit); Godot 4.7 .NET (godot/).

Global Constraints

  • Composite order = execution order, handle-keyed update-in-place (matches the current, correct immediate-mode render — the guardrail is "booted SC0000 CGs must not regress"). Erase removes by handle. This ordering policy is the Phase-1 design choice; validate by screenshot no-regression.
  • Engine parity: draw-texture gains a GfxState update but stays one step / pc+1; non-Godot hosts are unaffected (they read nothing new). dotnet test engine/AgeEngine.sln must stay green; Godot --selftest (synthetic, no gfx) must stay green.
  • Thread-safety: GfxState layers are mutated on the VM thread and read on the Godot main thread → all layer access is lock-guarded; the host reads via an immutable SnapshotLayers().
  • No auto-screenshot: per the user, do NOT screenshot-and-quit for validation; the user drives the live window. Provide the numeric Age.Cli gfx oracle for headless checks.
  • Seam: GfxState is Age.Engine/Model; the VM references Model. DrawLayer lives with GfxState.

File structure

File Responsibility Task
engine/Age.Engine/Model/GfxState.cs DrawLayer + retained layer list (add/update, remove, snapshot; lock-guarded); EraseRange also drops layers 1.1
engine/Age.Engine.Tests/GfxStateTests.cs Layer add/update-in-place/order/erase unit tests 1.1
engine/Age.Engine/Vm/VirtualMachine.cs draw-texture records a DrawLayer in GfxState 1.2
engine/Age.Engine.Tests/GfxCommandBufferTests.cs Synthetic scene: draws → layer list; erase → removed 1.2
engine/Age.Cli/Program.cs (gfx cmd) Dump the retained layer list (headless oracle) 1.3
godot/GodotAdvHost.cs DrawTexture no longer blits (retained loop composites); keep slot→BMP map 1.4
godot/Main.cs _Process snapshots GfxState layers → clear + composite each frame 1.4

Task 1.1 — DrawLayer + retained layer list in GfxState (pure data)

Files: Modify engine/Age.Engine/Model/GfxState.cs; Test engine/Age.Engine.Tests/GfxStateTests.cs.

Interfaces produced: readonly record struct DrawLayer(long Handle, int Slot, int SrcX, int SrcY, int W, int H, int DstX, int DstY); on GfxState: void AddOrUpdateLayer(DrawLayer l) (update-in-place by Handle, else append — preserving order), void RemoveLayers(long handle), IReadOnlyList<DrawLayer> SnapshotLayers(). EraseRange also calls RemoveLayers per erased handle.

  • Step 1: Write the failing tests
    [Fact]
    public void LayersAppendInOrderAndUpdateInPlace()
    {
        var g = new GfxState();
        g.AddOrUpdateLayer(new DrawLayer(0xA, 4, 0, 0, 800, 600, 0, 0));
        g.AddOrUpdateLayer(new DrawLayer(0xB, 5, 0, 0, 200, 200, 100, 100));
        g.AddOrUpdateLayer(new DrawLayer(0xA, 4, 0, 0, 800, 600, 0, 50));   // re-draw A -> update in place
        var s = g.SnapshotLayers();
        Assert.Equal(2, s.Count);
        Assert.Equal(0xA, s[0].Handle);           // order preserved (A still first)
        Assert.Equal(50, s[0].DstY);              // updated
        Assert.Equal(0xB, s[1].Handle);
    }

    [Fact]
    public void EraseRangeAlsoDropsLayers()
    {
        var g = new GfxState();
        g.AddOrUpdateLayer(new DrawLayer(0x10, 4, 0, 0, 10, 10, 0, 0));
        g.AddOrUpdateLayer(new DrawLayer(0x20, 5, 0, 0, 10, 10, 0, 0));
        g.EraseRange(0x10, 1);
        var s = g.SnapshotLayers();
        Assert.Single(s);
        Assert.Equal(0x20, s[0].Handle);
    }
  • Step 2: Run to verify failure

Run: dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~GfxStateTests Expected: FAIL — DrawLayer/AddOrUpdateLayer don't exist (compile error).

  • Step 3: Implement

In engine/Age.Engine/Model/GfxState.cs, add the record (top of namespace) and the layer members:

public readonly record struct DrawLayer(long Handle, int Slot, int SrcX, int SrcY, int W, int H, int DstX, int DstY);

Inside GfxState:

    private readonly List<DrawLayer> _layers = new();
    private readonly object _lock = new();

    public void AddOrUpdateLayer(DrawLayer l)
    {
        lock (_lock)
        {
            for (int i = 0; i < _layers.Count; i++)
                if (_layers[i].Handle == l.Handle) { _layers[i] = l; return; }
            _layers.Add(l);
        }
    }

    public void RemoveLayers(long handle)
    {
        lock (_lock) { _layers.RemoveAll(l => l.Handle == handle); }
    }

    public IReadOnlyList<DrawLayer> SnapshotLayers()
    {
        lock (_lock) { return _layers.ToArray(); }
    }

Extend EraseRange to also drop layers — change its body to:

    public void EraseRange(long handle, long count)
    {
        if (count > 1) for (long i = handle; i < handle + count; i++) { Release(i); RemoveLayers(i); }
        else { Release(handle); RemoveLayers(handle); }
    }
  • Step 4: Run to verify pass

Run: dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~GfxStateTests Expected: PASS (all facts, including the pre-existing ones).

  • Step 5: Commit
git add engine/Age.Engine/Model/GfxState.cs engine/Age.Engine.Tests/GfxStateTests.cs
git commit -m "feat(gfx): retained DrawLayer list in GfxState (thread-safe)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 1.2 — draw-texture records a retained layer

Files: Modify engine/Age.Engine/Vm/VirtualMachine.cs; Test engine/Age.Engine.Tests/GfxCommandBufferTests.cs.

Interfaces: Consumes GfxState.AddOrUpdateLayer. The draw-texture case builds a DrawLayer from a[0](handle),a[1](slot),a[2..7] and records it; it keeps the existing _host.DrawTexture(...) call (the CLI oracle logs it; the Godot host will no-op-blit in Task 1.4).

  • Step 1: Write the failing test (in GfxCommandBufferTests.cs)
    private static (int, Operand[]) DrawTex(int handle, int slot, int w, int h, int dx, int dy)
        => (0x1fb, new[] { G(handle), G(slot), I(0), I(0), G(w), G(h), G(dx), G(dy) });

    [Fact]
    public void DrawTextureRecordsARetainedLayer()
    {
        var t = T();
        var scene = ScriptAssembler.Assemble(t, "GFX", new List<(int, Operand[])>
        {
            MovGI(1, 0xA), MovGI(2, 4), MovGI(3, 800), MovGI(4, 600), MovGI(5, 0), MovGI(6, 0),
            DrawTex(1, 2, 3, 4, 5, 6), Exit(),
        }, System.Array.Empty<string>());
        var vm = new VirtualMachine(scene, t, new RecordingHost());
        vm.Run();
        var layers = vm.Gfx.SnapshotLayers();
        Assert.Single(layers);
        Assert.Equal(0xA, layers[0].Handle);
        Assert.Equal((800, 600, 0, 0), (layers[0].W, layers[0].H, layers[0].DstX, layers[0].DstY));
    }

(Note: draw-texture reads slot from a[1], dst from a[6]/a[7], w/h from a[4]/a[5] — mirror the existing case's operand indices exactly.)

  • Step 2: Run to verify failure

Run: dotnet test engine/AgeEngine.sln --filter FullyQualifiedName~DrawTextureRecords Expected: FAIL — no layer recorded (Empty snapshot).

  • Step 3: Implement — in VirtualMachine.Step, extend the draw-texture case:
            case "draw-texture":   // (handle, slot, srcX, srcY, w, h, dstX, dstY)
                Gfx.AddOrUpdateLayer(new DrawLayer(Read(a[0]), (int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]),
                                     (int)Read(a[4]), (int)Read(a[5]), (int)Read(a[6]), (int)Read(a[7])));
                _host.DrawTexture((int)Read(a[1]), (int)Read(a[2]), (int)Read(a[3]), (int)Read(a[4]),
                                  (int)Read(a[5]), (int)Read(a[6]), (int)Read(a[7])); return pc + 1;
  • Step 4: Run to verify pass + full suite (parity)

Run: dotnet test engine/AgeEngine.sln Expected: all green — draw-texture still one step; non-Godot hosts unaffected; new test passes.

  • Step 5: Commit
git add engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine.Tests/GfxCommandBufferTests.cs
git commit -m "feat(gfx): draw-texture records a retained layer in GfxState

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 1.3 — CLI gfx oracle dumps the retained layer list

Files: Modify engine/Age.Cli/Program.cs (the gfx command, after the run).

  • Step 1: Add the layer dump — after the existing gfx objects print:
    var layers = vm.Gfx.SnapshotLayers();
    Console.WriteLine($"  layers ({layers.Count}, composite order):");
    foreach (var l in layers)
        Console.WriteLine($"    h=0x{l.Handle:x} slot={l.Slot} src=({l.SrcX},{l.SrcY} {l.W}x{l.H}) dst=({l.DstX},{l.DstY})");
  • Step 2: Build + eyeball on the drift scene

Run: dotnet run --project engine/Age.Cli -- gfx --boot SC0000.BIN Expected: prints a retained layer list. Sanity: the full-screen CGs appear as layers at (0,0) 800x600; erased handles are absent (fewer layers than raw draw-texture calls). This is the headless proof the retained set is built correctly.

  • Step 3: Commit
git add engine/Age.Cli/Program.cs
git commit -m "feat(gfx): gfx oracle dumps the retained layer list

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 1.4 — Godot: per-frame clear + recomposite from the retained layers

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

Interfaces: Consumes _vm.Gfx.SnapshotLayers() + the host's slot→BMP map. Produces a per-frame composite in Main._Process.

  • Step 1: Godot host — stop immediate-blitting; keep slot→BMP

In godot/GodotAdvHost.cs, make DrawTexture a no-op for compositing (the retained loop handles it) but keep the slot→BMP map current (still populated by SetTexture). Expose the slot→BMP map (e.g. public string? SlotBmp(int slot)), since Main._Process needs it to composite a layer's slot.

  • Step 2: Main — per-frame recomposite in _Process

In godot/Main.cs, give Main access to the VM (_vm already a field) and the host. In _Process, each frame:

    // Retained recomposite: clear, then blit each layer (execution order) from its slot's BMP.
    var layers = _vm.Gfx.SnapshotLayers();
    _screen.Fill(new Color(0, 0, 0, 0));                 // clear (transparent; the frame is rebuilt)
    foreach (var l in layers)
    {
        var bmp = _host.SlotBmp(l.Slot);
        if (bmp == null) continue;
        BlitLayer(bmp, l.SrcX, l.SrcY, l.W, l.H, l.DstX, l.DstY);   // same clamp logic as the old BlitSlot
    }
    _screenTex.Update(_screen);

BlitLayer is the old BlitSlot body (load BMP, clamp src rect, BlitRect) minus the per-call _screenTex.Update (do one update after the loop). Cache loaded Images by path to avoid re-reading every frame (a Dictionary<string, Image>), since _Process runs every frame.

Guardrail: the booted opening CGs must render identically to before (same execution-order composite, now rebuilt each frame). If a CG flickers/disappears, the layer set or order is wrong — check the gfx --boot oracle layer dump against the expected draws.

  • Step 3: Build

Run: godot --headless --path godot --import && dotnet build godot/Himegari.csproj Expected: builds clean.

  • Step 4: Selftest parity

Run: godot --headless --path godot -- --selftest Expected: SELFTEST OK … (synthetic scene, no gfx layers → unaffected).

  • Step 5: Live check (user-driven, NOT auto-screenshot)

Launch for the user in the background: godot --path godot -- --boot. Ask them to confirm: (a) the opening CGs still render correctly (no regression), and (b) the stuck bottom overlay no longer persists indefinitely across pages (it's now rebuilt per frame from the active layer set; it clears when its handle is erased or no longer drawn). A still-opaque, non-fading glow while its handle is active is EXPECTED at Phase 1 — alpha is Phase 2, fading is Phase 3.

  • Step 6: Commit
git add godot/GodotAdvHost.cs godot/Main.cs
git commit -m "feat(gfx): retained per-frame compositor in Godot (clear + recomposite)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Self-review

  • Spec coverage (Phase 1): retained layer model in GfxState → 1.1; draw-texture retained → 1.2; per-frame clear + recomposite → 1.4; headless oracle → 1.3. Alpha/animation explicitly NOT here (Phases 2/3). ✓
  • Parity: draw-texture stays one step + keeps _host.DrawTexture; non-Godot hosts read nothing new; engine suite + selftest re-asserted (1.2 Step 4, 1.4 Step 4). ✓
  • Threading: all _layers access lock-guarded; host reads via immutable SnapshotLayers(); the Image cache prevents per-frame disk reads. ✓
  • Placeholder scan: 1.11.3 have complete code; 1.4 is host glue (concrete approach + key code, validated live) — the honest boundary (no unit oracle for pixels). ✓
  • Guardrail explicit: "booted CGs must not regress" stated at 1.4; the Phase-1 layer-order/reset policy (execution-order, handle-keyed, erase-removes) is called out for screenshot validation. ✓

Next: Phase 2 (alpha/blend) then Phase 3 (time-animation), per the design spec.