From 011d0900c5c3b0cb32a6e87893d8aa2b4950421b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 7 Jul 2026 19:41:13 -0400 Subject: [PATCH] docs(gfx): animated-compositor design spec + Phase 1 plan + animation RE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/engine-re.md | 23 ++ .../2026-07-07-animated-compositor-phase1.md | 275 ++++++++++++++++++ .../2026-07-07-animated-compositor-design.md | 119 ++++++++ 3 files changed, 417 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-animated-compositor-phase1.md create mode 100644 docs/superpowers/specs/2026-07-07-animated-compositor-design.md diff --git a/docs/engine-re.md b/docs/engine-re.md index dd45736..5385f99 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -250,6 +250,29 @@ deferred — Phase 2 scope), and some object-slot CGs start with a zero anchor ( game's warm ones; default object geometry is confirmed `(0,0)` in `gfx_object_init_default`, so it is not a missing-default bug). See `docs/phase-a-slice-plan.md` A2b-Geometry. +### The gfx animation/effects subsystem — the `AE*` fades (2026-07-07) + +The `AE*` flash/glow effects (and sprite motion) are a **native time-animated retained render loop**, not +per-frame bytecode. Reversed + annotated in Ghidra: +- **Retained objects carry animation state:** an *active* flag (obj`+0` bit 4), a **progress** counter + (obj`+0x214`, starts 0), a **duration** (obj`+0x228`), and a **target vector** (obj`+0x244/248/24c`). +- **`gfx_anim_start`** (`0x47f060`, worker for op **`0x234`**) configures a per-object animation: sets the + flag, resets progress, stores duration + target. Op **`0x1fd`** (`gfx_op_0x1fd_set_vec_scaled`) sets a + scaled 3-vector. +- **Op `0x238`** (`gfx_op_0x238_set_anim_clock`) sets a **global animation clock**, **non-blocking**: + `ctx+0x51b78 = 0` (elapsed), `ctx+0x51b7c = duration` (the max per-object duration; SC0000 `label_1235a` + maxes a table to compute it). It does **not** loop/wait. +- **Frame model:** the bytecode does `configure anims (0x234/0x1fd) → set clock (0x238) → show-text → + wait-for-input` and **continues**; the native render loop advances the clock + per-object progress each + frame, interpolates, composites, presents. Render/present family nearby: `0x243/0x20c/0x21c/0x224` + (`u004162xx`, not yet fully RE'd). **⇒ the port can drive animation in the HOST per-frame loop while the + VM is parked at wait-for-input — no blocking present op, no VM/host frame-lockstep** (the answer to the + "frame loop" question). + +Consequence: reproducing the fades needs a **retained per-frame animated compositor** — the current +immediate-mode permanent canvas can neither fade nor clear. Design: `docs/superpowers/specs/2026-07-07- +animated-compositor-design.md`. + --- ## Native walls backlog (targets for this loop) diff --git a/docs/superpowers/plans/2026-07-07-animated-compositor-phase1.md b/docs/superpowers/plans/2026-07-07-animated-compositor-phase1.md new file mode 100644 index 0000000..9b25dba --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-animated-compositor-phase1.md @@ -0,0 +1,275 @@ +# 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 compositor** — `draw-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 SnapshotLayers()`. `EraseRange` also calls `RemoveLayers` per erased handle. + +- [ ] **Step 1: Write the failing tests** +```csharp + [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: +```csharp +public readonly record struct DrawLayer(long Handle, int Slot, int SrcX, int SrcY, int W, int H, int DstX, int DstY); +``` +Inside `GfxState`: +```csharp + private readonly List _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 SnapshotLayers() + { + lock (_lock) { return _layers.ToArray(); } + } +``` +Extend `EraseRange` to also drop layers — change its body to: +```csharp + 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** +```bash +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 " +``` + +--- + +### 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`) +```csharp + 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()); + 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: +```csharp + 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** +```bash +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 " +``` + +--- + +### 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: +```csharp + 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** +```bash +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 " +``` + +--- + +### 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: +```csharp + // 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 `Image`s by path to avoid re-reading every frame (a `Dictionary`), 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** +```bash +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 " +``` + +--- + +## 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.1–1.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. diff --git a/docs/superpowers/specs/2026-07-07-animated-compositor-design.md b/docs/superpowers/specs/2026-07-07-animated-compositor-design.md new file mode 100644 index 0000000..0d3a27e --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-animated-compositor-design.md @@ -0,0 +1,119 @@ +# Design: Retained Animated Compositor (the AE* fades / gfx effects) + +Status: **approved (design)** · Date: 2026-07-07 +RE source: `docs/engine-re.md` (op `0x215` + "The gfx animation/effects subsystem"). Builds on the gfx +command-buffer work (`docs/phase-a-slice-plan.md` A2b; `docs/superpowers/specs/2026-07-07-gfx-command-buffer-design.md`). +Touches: `engine/Age.Engine/Model/GfxState.cs`, `engine/Age.Engine/Vm/VirtualMachine.cs`, +`engine/Age.Cli/Program.cs` (`GfxTraceHost`), `godot/{Main,GodotAdvHost}.cs`. + +## Problem / goal + +SC0000's opening event CGs now render (gfx command-buffer + system boot), but the `AE*` flash/glow effects +draw **opaque and never clear** — a white "explosion" glow stays at the bottom of the screen. Root cause +(RE, `engine-re.md`): those effects are a **native time-animated retained render loop** — objects carry +animation state (progress/duration/target), op `0x238` sets a non-blocking global animation clock, and the +engine's per-frame loop interpolates + composites. Our **immediate-mode permanent canvas** can neither +animate (fade) nor clear (remove the overlay). **Goal:** replace it with a **retained, host-driven, +per-frame animated compositor** so effects fade and clear correctly — completing SC0000's rendering, which +proves the ADV effectful-op layer for all ADV scenes. + +## Key architectural shift: immediate → retained + +Today `draw-texture` (and `BlitSlot`) blit **immediately and permanently** onto one canvas. The real engine +is **retained**: the VM configures object/layer state; a per-frame render loop clears, advances animations, +and re-composites the active set. We adopt that: + +- **The VM's gfx ops update retained `GfxState`** (they already do for geometry; extend to alpha + animation + + a draw/layer list). They do **not** paint pixels. +- **The host owns a per-frame render loop** (`godot/_Process`): advance time, interpolate, clear, composite. +- **No VM/host frame-lockstep** — the VM parks at `wait-for-input` and the host animates during the park; + the bytecode's `configure → wait` structure gives the timing (RE-confirmed: `0x238` is non-blocking). + +## The model — `GfxState` extensions + +`GfxState` (VM-owned execution state) gains, per object, the reversed animation fields and a compositor- +facing draw list: + +``` +GfxObject (additions) + int Alpha // 0..255, from 0x202/0x203 (default 255) + bool Animating // native flag bit 4 + long AnimDuration // obj+0x228 (from gfx_anim_start / 0x234) + long AnimProgress // obj+0x214 (advanced by the host clock) + (long X,Y,Z) AnimTarget // obj+0x244/248/24c + // (existing: Slot, V18/V24/V16c, Field64/68/6c, Color) + +GfxState (additions) + long AnimClockElapsed, AnimClockDuration // op 0x238 global clock (ctx+0x51b78/7c) + IReadOnlyList Layers // retained composite list (order = paint order) +``` + +`DrawLayer` = a resolved draw command (slot/source-rect/dst/alpha) captured when the VM executes +`draw-texture`/`0x202`/`0x203`, **replacing** the immediate blit. The host composites `Layers` each frame. + +- **Layer lifecycle:** a full-screen opaque draw resets the layer set (it covers everything, matching the + engine's frame); `0x1f7` erase / animation-complete removes layers; a new frame's draws append. Exact + reset policy is a Phase-1 decision, validated by the oracle/screenshot (see Phase 1 plan). +- **Native workers still NOT modelled** — only the retained data the render loop needs. + +## Data flow (per frame, host) + +``` +VM thread: gfx ops → mutate GfxState (layers, alpha, anim target/duration/clock) → park at wait-for-input +Host _Process(delta): + 1. advance AnimClockElapsed += delta-scaled; per-object AnimProgress += ... (Phase 3) + 2. interpolate each Animating object's current props toward AnimTarget by progress/dur (Phase 3) + 3. clear screen; composite Layers in order, each blended by its Alpha (Phase 1–2) + 4. present (update the TextureRect) +``` + +## Phasing (each a runnable, screenshot-checkable checkpoint) + +- **Phase 1 — Retained compositor + clear.** `draw-texture` registers a `DrawLayer` instead of blitting; + Godot `_Process` clears + composites the layer set each frame. No alpha/anim yet (layers opaque). Fixes + the *never-clears* half — stuck overlays disappear when the frame resets. **Check:** opening CGs still + render; the stuck bottom glow no longer persists across pages. +- **Phase 2 — Alpha/blend.** `0x202/0x203` set per-object/layer `Alpha`; the composite blends by alpha. + **Check:** the glow renders translucent, not solid white. +- **Phase 3 — Time-animation.** Host clock + per-object interpolation (`0x234`/`0x1fd`/`0x238`) advance in + `_Process`. **Check:** the explosion/glow **fades** over its duration. +- **Phase 4 — Remaining effect ops + polish.** RE + fold in the render/present family + (`0x243/0x20c/0x21c/0x224`), green chromakey for sprites, residual effect ops. **Check:** opening correct + end-to-end by-eye. + +## Parity & testing + +- **Engine tests stay host-agnostic.** `GfxState` additions are pure data → unit-testable (layer list built + by the ops; alpha/anim fields set; clock advance is a pure function). No Godot needed. +- **`Age.Cli gfx` oracle** dumps the resolved layer list (slot/dst/alpha) + anim state — the headless + numeric check that the VM builds the right retained state. +- **Godot `--selftest`** stays green (synthetic scene, no gfx) — the compositor rewrite must not perturb it. +- **Screenshot / by-eye** on `SC0000 --boot` is the human oracle per phase (the user drives it live — do + **not** auto-screenshot-and-quit). +- **Guardrail:** the existing booted CG render (event CGs at `(0,0)`) must not regress at any phase. + +## Deferrals (explicit) + +- **SRPG rendering ops** (map/sprite `RENDERMAP`/`DRAWOBJ` family) — a separate surface SC0000 doesn't + exercise; out of scope. +- **Exact easing curve** of the interpolation (linear vs the engine's curve) — start linear; refine if + by-eye shows it matters. +- **The render/present family internals** (`0x243/0x20c/0x21c/0x224`) — RE in Phase 4 as needed; Phases 1–3 + don't require them (the host loop is our present). + +## Risks / open questions + +1. **Layer-reset policy** (when does the frame clear / which layers persist): the main Phase-1 design risk. + Fallback: reset on each full-screen opaque draw + on `0x1f7` erase; validated by the oracle + screenshot, + iterate if wrong. +2. **VM-runs-ahead** vs animation timing: mitigated by the bytecode's `setup → wait-for-input` structure + (the VM parks after each animation setup). If a sequence sets up an animation without a following wait, + the retained state still animates from its configured values — acceptable. +3. **Time base** for the clock (`delta` seconds vs the engine's unit): calibrate the elapsed→progress scale + against a known-duration effect (e.g. the `9000` explosion) by-eye in Phase 3. + +## Success criteria (design done) + +- This spec approved; the retained/host-loop architecture + 4-phase split committed. +- Phase 1 expanded into a TDD plan (`docs/superpowers/plans/2026-07-07-animated-compositor-phase1.md`). +- RE findings recorded in `engine-re.md` (done).