docs(gfx): implementation plan for the sprite animation subsystem (opening slice)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
570
docs/superpowers/plans/2026-07-07-gfx-animation-subsystem.md
Normal file
570
docs/superpowers/plans/2026-07-07-gfx-animation-subsystem.md
Normal file
@@ -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/<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 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) = <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 `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).
|
||||
Reference in New Issue
Block a user