Files
OpenMaidEngine/docs/superpowers/plans/2026-07-07-gfx-animation-subsystem.md
2026-07-07 23:05:43 -04:00

571 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/<name>.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<op>_<role>`, 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~<ClassName>"`.
- **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 25).
- `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 "0x21c0x243 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 36 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) = <cmd>`), 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
/// <summary>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.</summary>
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;
}
}
/// <summary>Op 0x234 (anim-start): begin the object's animation. Bumps AnimGeneration — the compositor
/// (re)starts a wall-clock tween whenever this changes.</summary>
public void StartAnim(long handle)
{
lock (_lock) { var o = GetOrCreate(handle); o.AnimEnabled = true; o.AnimGeneration++; }
}
/// <summary>Op 0x238 (set-anim-clock): set the animation duration (in game ticks/frames) for the object.</summary>
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<Operand>());
[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<string>());
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<string>());
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<string>());
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);
/// <summary>A renderable view of one visible gfx object … (existing summary).</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<long, TweenState> _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 `0x21c0x243` 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 `0x21c0x243` stays on the `default` stub and keeps showing as GAP in the tracker (intentional, measurable remaining work).