From bc9b817301e466c0a24bab389461812986431c4a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 7 Jul 2026 23:05:43 -0400 Subject: [PATCH] docs(gfx): implementation plan for the sprite animation subsystem (opening slice) Co-Authored-By: Claude Opus 4.8 --- .../2026-07-07-gfx-animation-subsystem.md | 570 ++++++++++++++++++ 1 file changed, 570 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-07-gfx-animation-subsystem.md diff --git a/docs/superpowers/plans/2026-07-07-gfx-animation-subsystem.md b/docs/superpowers/plans/2026-07-07-gfx-animation-subsystem.md new file mode 100644 index 0000000..f8e7f3b --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-gfx-animation-subsystem.md @@ -0,0 +1,570 @@ +# Sprite Transform/Animation Subsystem (Opening Slice) 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:** Make SC0000's opening `AE*` fade actually animate — implement the sprite transform/animation ops the booted opening executes, tweened over wall-clock time in the Godot compositor with alpha. + +**Architecture:** The VM records animation-channel data on gfx objects (passive `GfxState`, no time) exactly like the existing gfx command-buffer ops, so headless trace parity is untouched. The Godot compositor owns a per-handle tween table, interpolates the recorded target over its frame delta, and applies alpha in the blit. Native DirectDraw workers are not modeled. + +**Tech Stack:** C# / .NET 8 (`Age.Engine`, xUnit), Godot 4.7 .NET (`godot/`), Python 3.11 tooling (`opcodes_build.py`, `scene_opcode_coverage.py`), Ghidra+MCP for the RE gate. + +## Global Constraints + +- **Run Python as** `py -3.11 -X utf8 tools/.py …` (cp932 needs utf8 mode on Windows). +- **Never hand-edit generated files.** Edit `vm-map/opcodes.toml`, then `py -3.11 -X utf8 tools/opcodes_build.py --build` regenerates `build/opcodes.json` + `tools/age_opcodes_himegari.py` + `docs/opcode-reference.md`. +- **VM dispatches on the opcode's top-level `label`** in `opcodes.toml` (the `0x208`/`0x215` precedent). Setting a `label` to a dispatch name is how an op becomes handled. +- **Headless parity is sacred:** the VM must only *record* gfx state; non-Godot hosts (`CaptureHost`, test `RecordingHost`/`CountHost`) read none of it. Existing `--selftest`, `sweep`, and engine tests must stay byte-identical. +- **`GfxState` stays a passive data model** — no time/`Tick`; it exposes recorded data only. +- **Ghidra: annotate as you RE** — rename `FUN_...` → `gfx_op_0x_`, add a plate comment with the decode, `save_program`. The Ghidra program is `range_00400000.bin` (Raw x86:LE:32:default @0x400000); handler for op N = the value stored to `param_1[0x26c93 + N]` in `FUN_00413860`. +- **Test commands:** all engine tests `dotnet test engine/AgeEngine.sln --nologo`; one class `dotnet test engine/AgeEngine.sln --nologo --filter "FullyQualifiedName~"`. +- **Branch:** continue on `feat/gfx-command-buffer`. Commit after each task. + +--- + +## File Structure + +- `docs/engine-re.md` — RE addendum: `anim_start`/`set_anim_clock` operand contract + confirmed opening op subset (Task 1). +- `engine/Age.Engine/Model/GfxState.cs` — animation-channel fields + `SetAnimTransform`/`StartAnim`/`SetAnimClock`; `RenderObject` gains an `AnimState` (Tasks 2, 5). +- `vm-map/opcodes.toml` — labels for `0x21e`/`0x220`/`0x234`/`0x238` (Task 3). +- `engine/Age.Engine/Vm/VirtualMachine.cs` — `case` arms for the four ops (Tasks 3, 4). +- `engine/Age.Engine.Tests/GfxAnimationTests.cs` — unit tests (Tasks 2–5). +- `godot/Main.cs` — tween table + alpha-aware blit in `Recomposite`/`BlitLayer` (Task 6). +- `docs/phase-a-slice-plan.md`, status memory — results (Task 7). + +--- + +## Task 1: RE gate — decode `anim_start`/`set_anim_clock` + confirm the opening subset + +**Files:** +- Modify: `docs/engine-re.md` (append to the "0x21c–0x243 sprite transform / ANIMATION cluster" section) +- Ghidra project `range_00400000.bin` (annotations only) + +**Interfaces:** +- Produces: **the operand contract** for `anim-start` (op `0x234`) and `set-anim-clock` (op `0x238`) — for each: `argc`, and the role of each operand (handle? duration/ticks? channel index?). Also: **the confirmed list of cluster ops SC0000's `--boot` opening actually executes**, and **which quantity the fade animates** (the `0x21e/0x220` transform vec3 at `obj+0xac`, vs the packed alpha from `0x202/0x203`). Tasks 3–6 consume this. + +- [ ] **Step 1: Resolve the two handler addresses.** In Ghidra, open `FUN_00413860` (`decompile_function` at `0x00413860`). The dispatch slot for op N is `param_1[0x26c93 + N]`. Read the value at index `0x26ec7` (= `0x234`, `gfx_op_0x234_anim_start`) and `0x26ecb` (= `0x238`, `gfx_op_0x238_set_anim_clock`). They sit between `LAB_00423cf0` (op `0x233`) and `FUN_00423e40` (op `0x235`), and between `FUN_004240a0` (op `0x237`) and `FUN_00424120` (op `0x239`) respectively. + +- [ ] **Step 2: Decompile both handlers.** `decompile_function` at each address. Read the cmd-type they write (`*(ctx + 0x53d88 + ctx[0x53d14]*0x78) = `), the operand fetches (`FUN_0041b940(i)`, 1-based), and the worker they call. Compare to the decoded `0x220`/`0x21e` (both write cmd-type `0xd`, call a transform worker). Determine: does `anim_start` take a handle + start the channel (`obj+0x68`/a "playing" flag)? Does `set_anim_clock` set a duration/tick count (and on which object/scope)? + +- [ ] **Step 3: Annotate in Ghidra.** `rename_function_by_address` each to `gfx_op_0x234_anim_start` / `gfx_op_0x238_set_anim_clock` (if not already), `set_plate_comment` with the decode (argc, operand roles, cmd-type, worker), then `save_program`. + +- [ ] **Step 4: Confirm the opening's executed op subset + animated quantity.** Statically inspect the opening path in `build/disasm/SC0000.asm` around the `AE*` CG loads (search the `set-texture`/`draw-texture` sites near the fade), and cross-check dynamically: + +Run: `py -3.11 -X utf8 tools/sys4load.py "$(py -3.11 -X utf8 -c "import sys;sys.path.insert(0,'tools');import paths;print(paths.scripts()['SC0000.BIN'])")" --json > build/sc0000.json` is optional; the reliable read is the disasm. Grep the disasm for the cluster ops near the fade: + +Run: `py -3.11 -X utf8 -c "import re;lines=open('build/disasm/SC0000.asm',encoding='utf8').read().splitlines();[print(l) for l in lines if re.search(r'op 0x(21e|220|234|238|202|203|204)\b', l)][:60]"` +Expected: shows whether the fade region uses `0x21e/0x220` (transform) and/or `0x202/0x203` (color/alpha), and whether `0x234`/`0x238` appear. Record the finding. + +- [ ] **Step 5: Write the addendum + decision.** Append to `docs/engine-re.md`: the two operand contracts, the confirmed opening op subset, and the **scope decision** — if the fade's alpha is `0x202/0x203`-driven (not the transform ops), note that Task 6 must apply that packed alpha and the transform ops become a secondary concern; otherwise confirm the transform-tween scope. This decision is the input to Tasks 3/4/6. + +- [ ] **Step 6: Commit** + +```bash +git add docs/engine-re.md +git commit -m "docs(gfx): decode anim_start/set_anim_clock + confirm SC0000 opening anim subset" +``` + +--- + +## Task 2: `GfxState` animation-channel data model + +**Files:** +- Modify: `engine/Age.Engine/Model/GfxState.cs` +- Test: `engine/Age.Engine.Tests/GfxAnimationTests.cs` (create) + +**Interfaces:** +- Consumes: nothing (pure model). +- Produces: on `GfxState.GfxObject` — fields `(long X,long Y,long Z) AnimTarget`, `long AnimParam1`, `long AnimParam2`, `bool AnimNormalized`, `bool AnimEnabled`, `long AnimDurationTicks`, `long AnimGeneration`. Methods `void SetAnimTransform(long handle, long p1, long p2, (long X,long Y,long Z) target, bool normalized)`, `void StartAnim(long handle)`, `void SetAnimClock(long handle, long durationTicks)`. Consumed by Tasks 3, 4, 5. + +- [ ] **Step 1: Write the failing tests** — create `engine/Age.Engine.Tests/GfxAnimationTests.cs`: + +```csharp +using Age.Engine.Model; +using Xunit; + +public class GfxAnimationTests +{ + [Fact] + public void SetAnimTransform_RecordsTargetParamsAndEnables() + { + var g = new GfxState(); + g.SetAnimTransform(0x1000, p1: 7, p2: 9, target: (100, 100, 100), normalized: true); + var o = g.TryGet(0x1000)!; + Assert.Equal((100L, 100L, 100L), o.AnimTarget); + Assert.Equal(7, o.AnimParam1); + Assert.Equal(9, o.AnimParam2); + Assert.True(o.AnimNormalized); + Assert.True(o.AnimEnabled); + } + + [Fact] + public void StartAnim_BumpsGenerationEachCall() + { + var g = new GfxState(); + g.SetAnimTransform(0x1000, 0, 0, (0, 0, 0), false); + var gen0 = g.TryGet(0x1000)!.AnimGeneration; + g.StartAnim(0x1000); + g.StartAnim(0x1000); + Assert.Equal(gen0 + 2, g.TryGet(0x1000)!.AnimGeneration); + } + + [Fact] + public void SetAnimClock_SetsDurationTicks() + { + var g = new GfxState(); + g.SetAnimClock(0x1000, 30); + Assert.Equal(30, g.TryGet(0x1000)!.AnimDurationTicks); + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `dotnet test engine/AgeEngine.sln --nologo --filter "FullyQualifiedName~GfxAnimationTests"` +Expected: FAIL — `GfxObject` has no `AnimTarget` etc. (compile error). + +- [ ] **Step 3: Add the fields to `GfxObject`.** In `GfxState.cs`, inside `public sealed class GfxObject`, after the existing `public bool Visible;` line add: + +```csharp + // ---- animation channel (0x21e/0x220 transform-set, 0x234 anim-start, 0x238 set-anim-clock; + // native worker gfx_anim_set_channel@0x47eaa0 writes obj+0xac target / +0x3c,+0x50 params / +0x68 enable). + // Passive: recorded here, interpolated by the Godot compositor over wall-clock. See + // docs/superpowers/specs/2026-07-07-gfx-animation-subsystem-design.md. ---- + public (long X, long Y, long Z) AnimTarget; + public long AnimParam1, AnimParam2; + public bool AnimNormalized; // 0x21e (operand/_DAT_00571c28, ~percent) vs 0x220 (absolute) + public bool AnimEnabled; // obj+0x68 + public long AnimDurationTicks; // from set-anim-clock (0x238) + public long AnimGeneration; // bumped by anim-start (0x234); the compositor's re-trigger +``` + +- [ ] **Step 4: Add the methods.** In `GfxState.cs`, after the `BindDraw` method, add: + +```csharp + /// Op 0x21e/0x220 (set-anim-transform): record the transform target + params on the object and + /// enable its animation channel. normalized = 0x21e (operands are ~percent, /_DAT_00571c28); absolute = 0x220. + /// Native: gfx_anim_set_channel@0x47eaa0 sets obj+0x3c=p1, +0x50=p2, +0xac=target, +0x68=1. + public void SetAnimTransform(long handle, long p1, long p2, (long X, long Y, long Z) target, bool normalized) + { + lock (_lock) + { + var o = GetOrCreate(handle); + o.AnimParam1 = p1; o.AnimParam2 = p2; o.AnimTarget = target; + o.AnimNormalized = normalized; o.AnimEnabled = true; + } + } + + /// Op 0x234 (anim-start): begin the object's animation. Bumps AnimGeneration — the compositor + /// (re)starts a wall-clock tween whenever this changes. + public void StartAnim(long handle) + { + lock (_lock) { var o = GetOrCreate(handle); o.AnimEnabled = true; o.AnimGeneration++; } + } + + /// Op 0x238 (set-anim-clock): set the animation duration (in game ticks/frames) for the object. + public void SetAnimClock(long handle, long durationTicks) + { + lock (_lock) { GetOrCreate(handle).AnimDurationTicks = durationTicks; } + } +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `dotnet test engine/AgeEngine.sln --nologo --filter "FullyQualifiedName~GfxAnimationTests"` +Expected: PASS (3 tests). + +- [ ] **Step 6: Run the full engine suite for parity** + +Run: `dotnet test engine/AgeEngine.sln --nologo` +Expected: PASS, all prior tests still green (adding fields/methods touches nothing existing). + +- [ ] **Step 7: Commit** + +```bash +git add engine/Age.Engine/Model/GfxState.cs engine/Age.Engine.Tests/GfxAnimationTests.cs +git commit -m "feat(gfx): GfxState animation-channel model (transform target + clock + generation)" +``` + +--- + +## Task 3: Label + dispatch the transform-set ops `0x21e`/`0x220` + +**Files:** +- Modify: `vm-map/opcodes.toml` (ops `0x21e`, `0x220`, `0x234`, `0x238`) +- Modify: `engine/Age.Engine/Vm/VirtualMachine.cs` (add `case` arms) +- Test: `engine/Age.Engine.Tests/GfxAnimationTests.cs` (add) + +**Interfaces:** +- Consumes: `GfxState.SetAnimTransform` (Task 2). +- Produces: opcode labels `set-anim-transform-norm` (0x21e), `set-anim-transform-abs` (0x220), `anim-start` (0x234), `set-anim-clock` (0x238) in the rebuilt `build/opcodes.json`; VM `case` arms for the two transform ops. Consumed by Tasks 4, 5. + +- [ ] **Step 1: Set the labels in `opcodes.toml`.** For each of the four ops, set the top-level `label` and the `[opcode.semantics]` `name`/`category`/`summary`/`source`/`confidence`. Op `0x220` block: + +```toml +op = 0x220 +label = "set-anim-transform-abs" +argc = 6 +``` +and its `[opcode.semantics]`: +```toml +name = "set-anim-transform-abs" +category = "graphics" +summary = "(handle)(p1)(p2)(x)(y)(z) — set sprite transform/anim channel, absolute; cmd-type 0xd, worker 0x47ecc0. Cluster 0x21c-0x243." +noop_headless = false +source = "investigation" +confidence = "high" +``` +Do the same for `0x21e` (`label = "set-anim-transform-norm"`, summary notes `/_DAT_00571c28` normalization, worker `gfx_anim_set_channel@0x47eaa0`), `0x234` (`label = "anim-start"`, category `graphics`), `0x238` (`label = "set-anim-clock"`, category `graphics`). Keep each op's existing `argc` unless Task 1 found otherwise for `0x234`/`0x238`. + +- [ ] **Step 2: Rebuild the generated opcode files** + +Run: `py -3.11 -X utf8 tools/opcodes_build.py --build` +Expected: `build: wrote build/opcodes.json, …`. Then `py -3.11 -X utf8 tools/opcodes_build.py --lint` → `lint: 0 errors, 0 warnings`. + +- [ ] **Step 3: Write the failing dispatch test.** First, at the **top of `GfxAnimationTests.cs`** (with the existing `using Age.Engine.Model; using Xunit;`), add the usings this and later tasks need: + +```csharp +using System.Collections.Generic; +using Age.Engine.Sys4; +using Age.Engine.Vm; +``` + +Then add these helpers + test **inside the `GfxAnimationTests` class**: + +```csharp + private static OpcodeTable T() => OpcodeTableJson.Load(Paths.OpcodesJson); + private static Operand G(int a) => new(3, a); + private static Operand I(long v) => new(0, v); + private static (int, Operand[]) MovGI(int d, long v) => (0x55, new[] { G(d), I(v) }); + private static (int, Operand[]) Exit() => (0x2, System.Array.Empty()); + + [Fact] + public void SetAnimTransformAbs_DispatchRecordsChannel() + { + var t = T(); + // handle g[1]=0x1000; p1 g[2]=7; p2 g[3]=9; target g[4,5,6]=(800,500,0) + var scene = ScriptAssembler.Assemble(t, "ANIM", new List<(int, Operand[])> + { + MovGI(1, 0x1000), MovGI(2, 7), MovGI(3, 9), MovGI(4, 800), MovGI(5, 500), MovGI(6, 0), + (0x220, new[] { G(1), G(2), G(3), G(4), G(5), G(6) }), + Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, t, new RecordingHost()); + vm.Run(); + var o = vm.Gfx.TryGet(0x1000)!; + Assert.Equal((800L, 500L, 0L), o.AnimTarget); + Assert.False(o.AnimNormalized); + Assert.True(o.AnimEnabled); + } +``` + +- [ ] **Step 4: Run to verify it fails** + +Run: `dotnet test engine/AgeEngine.sln --nologo --filter "FullyQualifiedName~SetAnimTransformAbs_DispatchRecordsChannel"` +Expected: FAIL — op `0x220` hits the `default` stub, so `AnimTarget` stays `(0,0,0)`. + +- [ ] **Step 5: Add the `case` arms.** In `VirtualMachine.Step`, alongside the other gfx ops (before `default:`), add: + +```csharp + case "set-anim-transform-abs": // 0x220 (handle)(p1)(p2)(x)(y)(z) + Gfx.SetAnimTransform(Read(a[0]), Read(a[1]), Read(a[2]), + (Read(a[3]), Read(a[4]), Read(a[5])), normalized: false); return pc + 1; + case "set-anim-transform-norm": // 0x21e — same, operands are ~percent (/_DAT_00571c28) + Gfx.SetAnimTransform(Read(a[0]), Read(a[1]), Read(a[2]), + (Read(a[3]), Read(a[4]), Read(a[5])), normalized: true); return pc + 1; +``` + +- [ ] **Step 6: Run to verify it passes + full suite parity** + +Run: `dotnet test engine/AgeEngine.sln --nologo` +Expected: PASS, all green (audio/gfx CLI + selftest unaffected — new ops only write GfxState, which `RecordingHost` ignores). + +- [ ] **Step 7: Commit** + +```bash +git add vm-map/opcodes.toml build/opcodes.json tools/age_opcodes_himegari.py docs/opcode-reference.md engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine.Tests/GfxAnimationTests.cs +git commit -m "feat(gfx): dispatch set-anim-transform 0x21e/0x220 into GfxState" +``` + +--- + +## Task 4: Dispatch `anim-start` (0x234) + `set-anim-clock` (0x238) + +**Files:** +- Modify: `engine/Age.Engine/Vm/VirtualMachine.cs` +- Test: `engine/Age.Engine.Tests/GfxAnimationTests.cs` (add) + +**Interfaces:** +- Consumes: Task 1's operand contract for `0x234`/`0x238`; `GfxState.StartAnim`/`SetAnimClock` (Task 2); labels from Task 3. +- Produces: `case "anim-start"` / `case "set-anim-clock"` arms. + +> **Operand mapping — reconcile with Task 1's `engine-re.md` addendum before coding.** The code below assumes `anim-start` = `(handle)` and `set-anim-clock` = `(handle)(durationTicks)`. If Task 1 found `set-anim-clock` is argc-1 (duration only, applied to the current object), use `Gfx.SetAnimClock(Gfx.CurrentObject, Read(a[0]))` instead. Adjust the indices to match the documented contract; the test asserts whatever mapping Task 1 confirmed. + +- [ ] **Step 1: Write the failing test** — add to `GfxAnimationTests.cs` (using the mapping Task 1 confirmed; shown here for `(handle)` / `(handle)(ticks)`): + +```csharp + [Fact] + public void AnimStartAndClock_DispatchBumpGenerationAndSetDuration() + { + var t = T(); + var scene = ScriptAssembler.Assemble(t, "ANIM", new List<(int, Operand[])> + { + MovGI(1, 0x1000), MovGI(2, 30), + (0x238, new[] { G(1), G(2) }), // set-anim-clock(handle=0x1000, ticks=30) + (0x234, new[] { G(1) }), // anim-start(handle=0x1000) + Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, t, new RecordingHost()); + vm.Run(); + var o = vm.Gfx.TryGet(0x1000)!; + Assert.Equal(30, o.AnimDurationTicks); + Assert.Equal(1, o.AnimGeneration); + Assert.True(o.AnimEnabled); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test engine/AgeEngine.sln --nologo --filter "FullyQualifiedName~AnimStartAndClock"` +Expected: FAIL — both ops hit `default`; `AnimDurationTicks`/`AnimGeneration` stay 0. + +- [ ] **Step 3: Add the `case` arms** (matching Task 1's contract): + +```csharp + case "anim-start": // 0x234 (handle) — begin the object's animation + Gfx.StartAnim(Read(a[0])); return pc + 1; + case "set-anim-clock": // 0x238 (handle)(durationTicks) + Gfx.SetAnimClock(Read(a[0]), Read(a[1])); return pc + 1; +``` + +- [ ] **Step 4: Run to verify it passes + full suite** + +Run: `dotnet test engine/AgeEngine.sln --nologo` +Expected: PASS, all green. + +- [ ] **Step 5: Commit** + +```bash +git add engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine.Tests/GfxAnimationTests.cs +git commit -m "feat(gfx): dispatch anim-start 0x234 + set-anim-clock 0x238" +``` + +--- + +## Task 5: Surface the anim channel to the compositor (`RenderObject.AnimState`) + +**Files:** +- Modify: `engine/Age.Engine/Model/GfxState.cs` (`RenderObject`, `AnimState`, `SnapshotVisibleObjects`) +- Test: `engine/Age.Engine.Tests/GfxAnimationTests.cs` (add) + +**Interfaces:** +- Consumes: the `GfxObject` anim fields (Task 2). +- Produces: `public readonly record struct AnimState(bool Enabled, bool Normalized, long TX, long TY, long TZ, long DurationTicks, long Generation);` and `RenderObject` gains a trailing `AnimState Anim` member. Consumed by Task 6. + +- [ ] **Step 1: Write the failing test** — add to `GfxAnimationTests.cs`: + +```csharp + [Fact] + public void SnapshotCarriesAnimStateForVisibleObject() + { + var t = T(); + // make object 0xA visible via set/draw-texture, then arm an anim channel + clock + start. + var scene = ScriptAssembler.Assemble(t, "ANIM", new List<(int, Operand[])> + { + MovGI(1, 0xA), MovGI(2, 4), MovGI(7, 0x25), MovGI(3, 800), MovGI(4, 600), MovGI(5, 0), MovGI(6, 0), + (0x1f9, new[] { G(7), G(2), I(0) }), // set-texture resId 0x25 -> slot 4 + (0x1fb, new[] { G(1), G(2), I(0), I(0), G(3), G(4), G(5), G(6) }), // draw-texture: object 0xA visible + MovGI(8, 0), MovGI(9, 30), + (0x220, new[] { G(1), G(5), G(6), G(3), G(4), G(8) }), // anim target (800,600,0) + (0x238, new[] { G(1), G(9) }), // clock 30 + (0x234, new[] { G(1) }), // start + Exit(), + }, System.Array.Empty()); + var vm = new VirtualMachine(scene, t, new RecordingHost()); + vm.Run(); + var vis = vm.Gfx.SnapshotVisibleObjects(); + Assert.Single(vis); + Assert.True(vis[0].Anim.Enabled); + Assert.Equal((800L, 600L, 0L), (vis[0].Anim.TX, vis[0].Anim.TY, vis[0].Anim.TZ)); + Assert.Equal(30, vis[0].Anim.DurationTicks); + Assert.Equal(1, vis[0].Anim.Generation); + } +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `dotnet test engine/AgeEngine.sln --nologo --filter "FullyQualifiedName~SnapshotCarriesAnimState"` +Expected: FAIL — `RenderObject` has no `Anim` member (compile error). + +- [ ] **Step 3: Add `AnimState` + extend `RenderObject`.** In `GfxState.cs`, replace the `RenderObject` record declaration with: + +```csharp +public readonly record struct AnimState(bool Enabled, bool Normalized, long TX, long TY, long TZ, + long DurationTicks, long Generation); + +/// A renderable view of one visible gfx object … (existing summary). +public readonly record struct RenderObject(long Handle, long SurfaceResId, long ColorKey, + int SrcX, int SrcY, int W, int H, int DstX, int DstY, + AnimState Anim); +``` + +- [ ] **Step 4: Populate it in `SnapshotVisibleObjects`.** In that method's loop, change the `list.Add(...)` to include the anim state: + +```csharp + list.Add(new RenderObject(kv.Key, resId, ck, o.SrcRect.X, o.SrcRect.Y, o.SrcRect.W, o.SrcRect.H, + (int)o.V24.X, (int)o.V24.Y, + new AnimState(o.AnimEnabled, o.AnimNormalized, + o.AnimTarget.X, o.AnimTarget.Y, o.AnimTarget.Z, + o.AnimDurationTicks, o.AnimGeneration))); +``` + +- [ ] **Step 5: Run to verify it passes + full suite.** The existing `SetThenDrawTextureMakesAVisibleObjectFromTheSurface` test in `GfxCommandBufferTests` constructs `RenderObject` positionally only via the snapshot (not `new`), so it stays valid. + +Run: `dotnet test engine/AgeEngine.sln --nologo` +Expected: PASS, all green. + +- [ ] **Step 6: Commit** + +```bash +git add engine/Age.Engine/Model/GfxState.cs engine/Age.Engine.Tests/GfxAnimationTests.cs +git commit -m "feat(gfx): surface anim channel to the compositor via RenderObject.AnimState" +``` + +--- + +## Task 6: Godot compositor — wall-clock tween + alpha blit + +**Files:** +- Modify: `godot/Main.cs` (`Recomposite`, `BlitLayer`; add a tween table + `TweenState`) + +**Interfaces:** +- Consumes: `RenderObject.Anim` (Task 5). +- Produces: animated, alpha-composited rendering. Verified visually (Godot; no unit test). + +> **Scope from Task 1:** if Task 1 found the opening fade's alpha comes from `0x202/0x203` (packed color on the object) rather than the transform vec3, drive the blit alpha from `RenderObject.ColorKey`/the object color instead of (or in addition to) the tweened target. The tween mechanics below are unchanged; only the *source of the alpha value* differs. Wire whichever Task 1 confirmed. + +- [ ] **Step 1: Add the tween state + table.** In `Main.cs`, add fields near the other compositor fields: + +```csharp + // Per-handle wall-clock tween of the animation channel. Re-armed whenever an object's AnimGeneration or + // target changes (the VM records those; the compositor owns time). See the gfx-animation-subsystem spec. + private sealed class TweenState + { + public long Generation = long.MinValue; + public double Elapsed, Duration; + public (double X, double Y, double Z) Start, Target; + } + private readonly System.Collections.Generic.Dictionary _tweens = new(); + private const double GameTickSeconds = 1.0 / 60.0; // duration ticks -> seconds (confirm FPS in Task 1) +``` + +- [ ] **Step 2: Advance tweens + compute the current alpha in `Recomposite`.** Replace the body of the `foreach (var v in _vm.Gfx.SnapshotVisibleObjects())` loop so each object computes an alpha (1.0 when not animating) and passes it to the blit: + +```csharp + foreach (var v in _vm.Gfx.SnapshotVisibleObjects()) // ascending handle = z-order + { + if (v.SurfaceResId == 0) continue; // render-target/blank surface + var bmp = _host.ResolveResIdTexture(v.SurfaceResId); + if (bmp == null) continue; + + float alpha = 1f; + if (v.Anim.Enabled) + { + var tw = _tweens.TryGetValue(v.Handle, out var t) ? t : (_tweens[v.Handle] = new TweenState()); + if (tw.Generation != v.Anim.Generation) // (re)arm the tween + { + double norm = v.Anim.Normalized ? 100.0 : 1.0; // 0x21e ~percent; confirm divisor in Task 1 + tw.Generation = v.Anim.Generation; + tw.Elapsed = 0; + tw.Duration = System.Math.Max(1, v.Anim.DurationTicks) * GameTickSeconds; + tw.Target = (v.Anim.TX / norm, v.Anim.TY / norm, v.Anim.TZ / norm); + tw.Start = (1, 1, 1); // fade-from-full default; refine per Task 1 + } + tw.Elapsed += _lastDelta; + double p = System.Math.Clamp(tw.Elapsed / tw.Duration, 0, 1); + // First interpretation: the target's Z (or X) channel is opacity. Task 1 pins which channel. + double a = tw.Start.Z + (tw.Target.Z - tw.Start.Z) * p; + alpha = (float)System.Math.Clamp(a, 0, 1); + } + BlitLayer(bmp, v.SrcX, v.SrcY, v.W, v.H, v.DstX, v.DstY, alpha); + } +``` + +Add a `private double _lastDelta;` field and set it at the top of `_Process`: `_lastDelta = delta;`. + +- [ ] **Step 3: Make `BlitLayer` alpha-aware.** Change its signature to `BlitLayer(string bmpPath, int srcX, int srcY, int w, int h, int dstX, int dstY, float alpha = 1f)`. Keep the fast opaque path when `alpha >= 0.999f` (existing `BlitRect`). Otherwise blend over the raw RGBA byte buffer: + +```csharp + if (alpha >= 0.999f) { _screen.BlitRect(src, new Rect2I(srcX, srcY, sw, sh), new Vector2I(dstX, dstY)); return; } + byte[] dst = _screen.GetData(); byte[] ss = src.GetData(); + int dw = _screen.GetWidth(), sfw = src.GetWidth(); + int ia = (int)(alpha * 255); + for (int y = 0; y < sh; y++) + for (int x = 0; x < sw; x++) + { + int di = ((dstY + y) * dw + (dstX + x)) * 4; + int si = ((srcY + y) * sfw + (srcX + x)) * 4; + if (di < 0 || di + 3 >= dst.Length) continue; + int sa = ss[si + 3] * ia / 255; // source alpha * object alpha + for (int c = 0; c < 3; c++) dst[di + c] = (byte)((ss[si + c] * sa + dst[di + c] * (255 - sa)) / 255); + dst[di + 3] = (byte)System.Math.Min(255, dst[di + 3] + sa); + } + _screen.SetData(dw, _screen.GetHeight(), false, _screen.GetFormat(), dst); +``` + +- [ ] **Step 4: Build + import Godot** + +Run: `dotnet build godot/Himegari.csproj -v q` then `godot --headless --path godot --import` +Expected: build succeeds, import completes. + +- [ ] **Step 5: Capture the opening fade across frames.** Grab the fade page at a couple of time offsets and eyeball that it changes (fades) rather than staying opaque: + +Run: `godot --path godot -- --boot --shot build/shot-anim-p2.png --shot-page 2` +Expected: a PNG is written; opening CG present. Compare against a pre-change capture (`git stash` the Godot change, capture, unstash) — the animated effect should differ across the tween where before it was static/opaque. Record the observation. + +- [ ] **Step 6: Commit** + +```bash +git add godot/Main.cs +git commit -m "feat(gfx): Godot wall-clock tween + alpha-aware blit for the anim channel" +``` + +--- + +## Task 7: Verify the slice + update the living docs + +**Files:** +- Modify: `docs/phase-a-slice-plan.md`, `~/.claude/…/memory/himegari-port-status.md`, `~/.claude/…/memory/MEMORY.md` + +**Interfaces:** none (verification + docs). + +- [ ] **Step 1: Confirm the tracker GAP shrank.** + +Run: `py -3.11 -X utf8 tools/scene_opcode_coverage.py SC0000` +Expected: the implemented ops (`0x21e`,`0x220`,`0x234`,`0x238`) now report **impl**, not GAP; the GAP count dropped by that many (from 68). Record the new number. + +- [ ] **Step 2: Confirm full headless parity.** + +Run: `dotnet test engine/AgeEngine.sln --nologo` → all green. +Run: `godot --headless --path godot -- --selftest` → `SELFTEST OK`. +Run: `dotnet run --project engine/Age.Cli -- sweep` → same 294 exit / 3 LOOP as before (anim ops are record-only). +Expected: no regressions. + +- [ ] **Step 3: Update `docs/phase-a-slice-plan.md`.** In the A2b section, add a short subsection recording: the ops implemented, the tween/alpha approach, the tracker GAP delta, the screenshot result, and any Task-1 corrections (operand contracts, animated channel, divisor/FPS). + +- [ ] **Step 4: Update the status memory.** In `himegari-port-status.md`, append a milestone entry (ops done, GAP shrink, what's still deferred — the rest of the `0x21c–0x243` cluster + scene-coroutine timing) and refresh its one-line `MEMORY.md` index entry. Convert relative dates to absolute. + +- [ ] **Step 5: Commit** + +```bash +git add docs/phase-a-slice-plan.md +git commit -m "docs(gfx): record the anim-subsystem opening slice results" +``` + +--- + +## Self-review notes (for the executor) + +- **Task 1 is the gate.** Do not skip it — Tasks 4 and 6 explicitly reconcile against its findings (operand contract for `0x234`/`0x238`; whether the fade alpha is transform- or `0x202/0x203`-driven; the normalization divisor and game FPS). The plan's default assumptions are the current best hypotheses, not confirmed facts. +- **Parity guard**: after every C# task, `dotnet test engine/AgeEngine.sln --nologo` must stay fully green — the anim ops only write `GfxState`, which non-Godot hosts never read. +- **YAGNI**: only the four opening ops are wired; the rest of `0x21c–0x243` stays on the `default` stub and keeps showing as GAP in the tracker (intentional, measurable remaining work).