# 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).