30 KiB
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, thenpy -3.11 -X utf8 tools/opcodes_build.py --buildregeneratesbuild/opcodes.json+tools/age_opcodes_himegari.py+docs/opcode-reference.md. - VM dispatches on the opcode's top-level
labelinopcodes.toml(the0x208/0x215precedent). Setting alabelto a dispatch name is how an op becomes handled. - Headless parity is sacred: the VM must only record gfx state; non-Godot hosts (
CaptureHost, testRecordingHost/CountHost) read none of it. Existing--selftest,sweep, and engine tests must stay byte-identical. GfxStatestays 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 isrange_00400000.bin(Raw x86:LE:32:default @0x400000); handler for op N = the value stored toparam_1[0x26c93 + N]inFUN_00413860. - Test commands: all engine tests
dotnet test engine/AgeEngine.sln --nologo; one classdotnet 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_clockoperand contract + confirmed opening op subset (Task 1).engine/Age.Engine/Model/GfxState.cs— animation-channel fields +SetAnimTransform/StartAnim/SetAnimClock;RenderObjectgains anAnimState(Tasks 2, 5).vm-map/opcodes.toml— labels for0x21e/0x220/0x234/0x238(Task 3).engine/Age.Engine/Vm/VirtualMachine.cs—casearms 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 inRecomposite/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(op0x234) andset-anim-clock(op0x238) — for each:argc, and the role of each operand (handle? duration/ticks? channel index?). Also: the confirmed list of cluster ops SC0000's--bootopening actually executes, and which quantity the fade animates (the0x21e/0x220transform vec3 atobj+0xac, vs the packed alpha from0x202/0x203). Tasks 3–6 consume this. -
Step 1: Resolve the two handler addresses. In Ghidra, open
FUN_00413860(decompile_functionat0x00413860). The dispatch slot for op N isparam_1[0x26c93 + N]. Read the value at index0x26ec7(=0x234,gfx_op_0x234_anim_start) and0x26ecb(=0x238,gfx_op_0x238_set_anim_clock). They sit betweenLAB_00423cf0(op0x233) andFUN_00423e40(op0x235), and betweenFUN_004240a0(op0x237) andFUN_00424120(op0x239) respectively. -
Step 2: Decompile both handlers.
decompile_functionat 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 decoded0x220/0x21e(both write cmd-type0xd, call a transform worker). Determine: doesanim_starttake a handle + start the channel (obj+0x68/a "playing" flag)? Doesset_anim_clockset a duration/tick count (and on which object/scope)? -
Step 3: Annotate in Ghidra.
rename_function_by_addresseach togfx_op_0x234_anim_start/gfx_op_0x238_set_anim_clock(if not already),set_plate_commentwith the decode (argc, operand roles, cmd-type, worker), thensave_program. -
Step 4: Confirm the opening's executed op subset + animated quantity. Statically inspect the opening path in
build/disasm/SC0000.asmaround theAE*CG loads (search theset-texture/draw-texturesites 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 is0x202/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
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. Methodsvoid 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:
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. InGfxState.cs, insidepublic sealed class GfxObject, after the existingpublic bool Visible;line add:
// ---- 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 theBindDrawmethod, add:
/// <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
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(ops0x21e,0x220,0x234,0x238) - Modify:
engine/Age.Engine/Vm/VirtualMachine.cs(addcasearms) - 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 rebuiltbuild/opcodes.json; VMcasearms 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-levellabeland the[opcode.semantics]name/category/summary/source/confidence. Op0x220block:
op = 0x220
label = "set-anim-transform-abs"
argc = 6
and its [opcode.semantics]:
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 existingusing Age.Engine.Model; using Xunit;), add the usings this and later tasks need:
using System.Collections.Generic;
using Age.Engine.Sys4;
using Age.Engine.Vm;
Then add these helpers + test inside the GfxAnimationTests class:
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
casearms. InVirtualMachine.Step, alongside the other gfx ops (beforedefault:), add:
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
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.mdaddendum before coding. The code below assumesanim-start=(handle)andset-anim-clock=(handle)(durationTicks). If Task 1 foundset-anim-clockis argc-1 (duration only, applied to the current object), useGfx.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)):
[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
casearms (matching Task 1's contract):
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
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
GfxObjectanim fields (Task 2). -
Produces:
public readonly record struct AnimState(bool Enabled, bool Normalized, long TX, long TY, long TZ, long DurationTicks, long Generation);andRenderObjectgains a trailingAnimState Animmember. Consumed by Task 6. -
Step 1: Write the failing test — add to
GfxAnimationTests.cs:
[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+ extendRenderObject. InGfxState.cs, replace theRenderObjectrecord declaration with:
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 thelist.Add(...)to include the anim state:
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
SetThenDrawTextureMakesAVisibleObjectFromTheSurfacetest inGfxCommandBufferTestsconstructsRenderObjectpositionally only via the snapshot (notnew), so it stays valid.
Run: dotnet test engine/AgeEngine.sln --nologo
Expected: PASS, all green.
- Step 6: Commit
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 fromRenderObject.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:
// 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 theforeach (var v in _vm.Gfx.SnapshotVisibleObjects())loop so each object computes an alpha (1.0 when not animating) and passes it to the blit:
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
BlitLayeralpha-aware. Change its signature toBlitLayer(string bmpPath, int srcX, int srcY, int w, int h, int dstX, int dstY, float alpha = 1f). Keep the fast opaque path whenalpha >= 0.999f(existingBlitRect). Otherwise blend over the raw RGBA byte buffer:
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
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 the0x21c–0x243cluster + scene-coroutine timing) and refresh its one-lineMEMORY.mdindex entry. Convert relative dates to absolute. -
Step 5: Commit
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- or0x202/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 --nologomust stay fully green — the anim ops only writeGfxState, which non-Godot hosts never read. - YAGNI: only the four opening ops are wired; the rest of
0x21c–0x243stays on thedefaultstub and keeps showing as GAP in the tracker (intentional, measurable remaining work).