Files
OpenMaidEngine/docs/superpowers/plans/2026-07-08-sc0000-anim-transform-cluster.md
gamer147 a6377c6a60 docs(plan): SC0000 anim/transform/spritesheet cluster implementation plan
TDD plan: RE workers (known dispatch addresses) -> channel model -> ported
ping-pong interpolator on FrameClock -> wire setter ops -> host apply. Fixes
motion + spritesheet-cell + 'mach 5' pacing. Rotation/scale gated on Task 1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 21:25:07 -04:00

27 KiB
Raw Permalink Blame History

SC0000 gfx animation / transform / spritesheet cluster — 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.

Goal: Implement the gfx animation/transform/spritesheet opcode cluster (0x1fd, 0x21c0x243) so SC0000 renders coherently — sprites move, animated sprites show one cycling spritesheet cell (not the full sheet), and effects play at native cadence (fixing "mach 5") — turning SC0000 into a scene the user can finally validate as a whole.

Architecture: Hybrid (same seam as slice A). The engine model holds a per-object animation-channel record; the cluster setter ops populate it; an engine-side interpolator (a faithful port of native gfx_object_anim_interpolate) computes each visible object's current transform + spritesheet cell + color as a function of a clock time; the Godot host blits the result. The interpolator is driven by the FrameClock built for frame-stepping, so animation time == throttled virtual time (the "mach 5" fix).

Tech Stack: C# / .NET 8 (engine/AgeEngine.sln), xUnit; Godot 4.7 mono (godot/Himegari.csproj); Ghidra MCP on build/engine-dump/range_00400000.bin.

Spec: docs/superpowers/specs/2026-07-08-sc0000-anim-transform-cluster-design.md.

Global Constraints

  • Parity is sacred. Gates that MUST stay green unchanged: dotnet test engine/AgeEngine.sln; Age.Cli sweepexit=284, STEP-LIMIT=13; Godot --selftestSELFTEST OK. New cluster ops are effectful only through the gfx model; the headless dialogue path is unaffected.
  • Seam rule: Age.Engine/Vm references only Model, Hosting, Diagnostics. The interpolator + channel model live in Age.Engine/Model; it may read Age.Engine.Hosting.FrameClock (already allowed).
  • Dispatch table (verbatim, from RE): handler(op) = ctx[0x26c93 + op] (dword index; registrar FUN_00413860). The u004xxxxx labels in opcodes.toml are Kelebek VA drift — never decompile them; resolve via the table. Recovered cluster handler addresses are in Task 1.
  • Interpolator timebase: the native uses the frame clock ctx+0xb550 with per-channel periods, ping-pong (triangle wave, folded at period/2). We port this onto FrameClock.NowMs.
  • Build/run: dotnet build engine/AgeEngine.sln -c Debug; dotnet test engine/AgeEngine.sln. Godot: dotnet build godot/Himegari.csproj -c Debug then S:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe. Coverage gauge: py -3.11 -X utf8 tools/scene_opcode_coverage.py SC0000.
  • TDD, one deliverable per task, commit at the end of each task. End every commit message with: Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Task 0: Branch

  • Step 1: Create the feature branch (repo root = age-reimpl/, default main)
cd "S:/Game Hacking/Eushully/Himegari/age-reimpl"
git checkout -b feat/anim-transform-cluster

File Structure

  • docs/engine-re.mdmodify (Task 1). The op→channel-field table + used-channel set.
  • engine/Age.Engine/Model/GfxState.csmodify (Tasks 2, 3, 4). Channel fields on GfxObject; setter mutators; SnapshotVisibleObjects(long nowMs) interpolator.
  • engine/Age.Engine/Vm/VirtualMachine.csmodify (Task 4). case handlers for the cluster ops.
  • engine/Age.Engine.Tests/AnimChannelTests.csnew (Tasks 2, 4). Setter-op → field assertions.
  • engine/Age.Engine.Tests/AnimInterpolatorTests.csnew (Task 3). Ping-pong phase assertions.
  • godot/Main.csmodify (Task 5). Pass FrameClock.NowMs; blit at interpolated pos + src-cell.

Task 1: RE — reverse the cluster workers → op→field table + used-channel set

This is a real RE task (~18 workers). All handler addresses are known (below); each op is a thin wrapper (FUN_0041b940(n) fetches operand n) → a worker (FUN_0047xxxx) that writes object fields. The consumer gfx_object_anim_interpolate (0x473ed0, already annotated) is ground truth for what each field means.

Files: Modify docs/engine-re.md (append the table). No code.

Known handler addresses (resolve worker by decompiling the handler, which tail-calls the worker):

op handler op handler op handler
0x1fd gfx_op_0x1fd_set_vec_scaled 0x223 FUN_00423620 0x232 FUN_00423c30
0x20a FUN_00422ce0 0x224 LAB_00417550 0x236 FUN_00423ee0
0x20e LAB_004174f0 0x228 FUN_0042a3a0 0x239 FUN_00424120FUN_0047ed90
0x21c LAB_00417520 0x229 FUN_00423700 0x23d LAB_004175c0
0x21d FUN_00423310 0x22f FUN_00423b00FUN_00472e90 0x23f FUN_0042a520
0x21f FUN_00423410 0x231 FUN_00423be0 0x242 FUN_004249d0
0x243 LAB_004182d0

Worked examples (done — record these + reverse the rest the same way):

  • 0x22f (FUN_00472e90): (handle, op2, x, y, z) → sets anim bit obj|2, resets obj+0x45c, writes obj+0x46c=op2, obj+0x480, and a translation 3-vector at obj+0x5d4 (via FUN_0048afb1). ⇒ position/translation channel.
  • 0x239 (FUN_0047ed90): (handle, p3, p4, grid_w, grid_h, cell) → resets progress obj+0x34=0, writes src-rect grid obj+0x238/obj+0x23c + cell obj+0x234, params obj+0x48/obj+0x5c. ⇒ src-rect / spritesheet-cell channel (matches the interpolator's obj+0x238/0x23c grid + obj+8..0x14 crop).

Interpolator channel map (from gfx_object_anim_interpolate, ground truth):

Channel period start target/params
Color/alpha obj+0x220 obj+0x20c obj+0x240
Matrix A obj+0x224 obj+0x210 obj+0x250..0x28c
Rotation obj+0x228 obj+0x214 obj+0x244 (360°=0x168)
Matrix B obj+0x22c obj+0x218 obj+0x290..0x2cc
Src-rect scroll obj+0x230 obj+0x21c grid obj+0x238/0x23c, crop obj+8..0x14
  • Step 1: Reverse each remaining cluster worker (decompile the handler at its known address; note the operand→field writes). Rename FUN_...gfx_op_0xNNN_worker_* and set_plate_comment the decode as you go (CLAUDE.md rule); save_program when done.

  • Step 2: Determine the SC0000 used-channel set. From build/disasm/SC0000.asm, list which cluster ops the opening actually calls and thus which channels (position / rotation / scale / src-rect / matrices) are exercised. grep the cluster op labels in the disasm.

  • Step 3: Record the op→field table + used-channel set in docs/engine-re.md (new subsection under §"The full gfx render model"). This table is the reference Tasks 2 and 4 implement against.

  • Step 4: Commit

git add docs/engine-re.md
git commit -m "docs(re): SC0000 anim cluster op->field table + used-channel set

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Gate for the user / reviewer: the used-channel set decides Task 5's scope (rotation/scale rendering in or out). Surface it before proceeding if it materially changes the plan size.


Task 2: Engine — anim-channel fields on GfxObject

Files: Modify engine/Age.Engine/Model/GfxState.cs; Test engine/Age.Engine.Tests/AnimChannelTests.cs.

Interfaces:

  • Produces: GfxState.GfxObject gains, per channel, { long PeriodX, long StartX, ... target fields } and mutators SetPositionAnim, SetSrcRectAnim (+ others per Task 1). Start fields default -1 = "uninitialized" (the interpolator seeds them to nowMs on first sight).
  • Src-rect channel fields: long SrcGridW, SrcGridH, SrcCell, SrcPeriod, SrcStart.
  • Position channel fields: long PosTX, PosTY, PosTZ, PosPeriod, PosStart (+ anim-active flag).

This task adds the position and src-rect channels (the two worked in Task 1, covering motion + spritesheet). Additional channels found used in Task 1 (rotation/scale) are added in Task 4 the same way.

  • Step 1: Write the failing test

Create engine/Age.Engine.Tests/AnimChannelTests.cs:

using System.Linq;
using Age.Engine.Model;
using Xunit;

public class AnimChannelTests
{
    private static GfxState VisibleObj(long handle)
    {
        var g = new GfxState();
        g.SetSurface(1, resId: 5, colorKey: -1);
        g.BindDraw(handle, 1, 0, 0, 64, 64, 100, 100);   // 64x64 cell surface at (100,100)
        return g;
    }

    [Fact]
    public void SetSrcRectAnim_StoresGridAndCell()
    {
        var g = VisibleObj(0x100);
        g.SetSrcRectAnim(0x100, gridW: 4, gridH: 1, cell: 2, period: 800);
        var o = g.TryGet(0x100)!;
        Assert.Equal(4, o.SrcGridW);
        Assert.Equal(2, o.SrcCell);
        Assert.Equal(800, o.SrcPeriod);
        Assert.Equal(-1, o.SrcStart);   // uninitialized until first interpolated frame
    }

    [Fact]
    public void SetPositionAnim_StoresTargetAndPeriod()
    {
        var g = VisibleObj(0x100);
        g.SetPositionAnim(0x100, tx: 300, ty: 100, tz: 0, period: 1000);
        var o = g.TryGet(0x100)!;
        Assert.Equal(300, o.PosTX);
        Assert.Equal(1000, o.PosPeriod);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test engine/AgeEngine.sln --filter AnimChannelTests Expected: FAIL — fields/mutators don't exist (compile error).

  • Step 3: Add the channel fields + mutators

In GfxState.GfxObject (near the color fields), add:

        // ---- position/translation anim channel (op 0x22f family; interpolator target obj+0x5d4 vec) ----
        public long PosTX, PosTY, PosTZ, PosPeriod, PosStart = -1;
        public bool PosAnim;
        // ---- src-rect / spritesheet-cell channel (op 0x239 family; interpolator grid obj+0x238/0x23c) ----
        public long SrcGridW = 1, SrcGridH = 1, SrcCell, SrcPeriod, SrcStart = -1;
        public bool SrcAnim;

In GfxState, add the mutators (near SetObjectColor):

    /// <summary>Op 0x22f family: animate the object toward (tx,ty,tz) over <paramref name="period"/> ms
    /// (ping-pong). Resets Start so the interpolator re-seeds it on the next frame.</summary>
    public void SetPositionAnim(long handle, long tx, long ty, long tz, long period)
    {
        lock (_lock)
        {
            var o = GetOrCreate(handle);
            o.PosTX = tx; o.PosTY = ty; o.PosTZ = tz; o.PosPeriod = period; o.PosStart = -1; o.PosAnim = true;
        }
    }

    /// <summary>Op 0x239 family: set the spritesheet grid + animate the visible cell over
    /// <paramref name="period"/> ms (ping-pong across the row).</summary>
    public void SetSrcRectAnim(long handle, long gridW, long gridH, long cell, long period)
    {
        lock (_lock)
        {
            var o = GetOrCreate(handle);
            o.SrcGridW = gridW < 1 ? 1 : gridW; o.SrcGridH = gridH < 1 ? 1 : gridH;
            o.SrcCell = cell; o.SrcPeriod = period; o.SrcStart = -1; o.SrcAnim = true;
        }
    }
  • Step 4: Run test to verify it passes

Run: dotnet test engine/AgeEngine.sln --filter AnimChannelTests Expected: PASS (2 tests).

  • Step 5: Commit
git add engine/Age.Engine/Model/GfxState.cs engine/Age.Engine.Tests/AnimChannelTests.cs
git commit -m "feat: add position + src-rect anim-channel fields/mutators to GfxObject

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 3: Engine — the interpolator (SnapshotVisibleObjects(long nowMs))

Files: Modify engine/Age.Engine/Model/GfxState.cs (RenderObject + SnapshotVisibleObjects); Test engine/Age.Engine.Tests/AnimInterpolatorTests.cs.

Interfaces:

  • Produces: RenderObject gains int CellW, CellH (the resolved spritesheet sub-rect is expressed via the existing SrcX/SrcY/W/H). SnapshotVisibleObjects(long nowMs) overload interpolates active channels; the existing no-arg overload calls it with nowMs = 0 (deterministic, back-compat for headless callers).
  • Ping-pong helper: static long BlendMath.PingPong(long now, long start, long period) → the folded progress u ∈ [0, period/2] (add to BlendMath, unit-tested here).

Ping-pong math (verbatim from gfx_object_anim_interpolate): with period P>0, start S (seed to now if uninitialized), u = (now - S) mod P; if u >= P/2 then u = P - u; the interpolation weight toward the target is t = (2u)/P ∈ [0,1] (and 1-t toward the base).

  • Step 1: Write the failing test

Create engine/Age.Engine.Tests/AnimInterpolatorTests.cs:

using System.Linq;
using Age.Engine.Model;
using Xunit;

public class AnimInterpolatorTests
{
    private static GfxState VisibleObj(long handle)
    {
        var g = new GfxState();
        g.SetSurface(1, resId: 5, colorKey: -1);
        g.BindDraw(handle, 1, 0, 0, 256, 64, 100, 100);   // 256x64 sheet, base pos (100,100)
        return g;
    }

    [Fact]
    public void PingPong_FoldsAtHalfPeriod()
    {
        Assert.Equal(0,   BlendMath.PingPong(now: 0,   start: 0, period: 1000));
        Assert.Equal(500, BlendMath.PingPong(now: 500, start: 0, period: 1000));   // peak at half
        Assert.Equal(0,   BlendMath.PingPong(now: 1000, start: 0, period: 1000));  // back to base
    }

    [Fact]
    public void Position_InterpolatesTowardTargetAtHalfPeriod()
    {
        var g = VisibleObj(0x100);
        g.SetPositionAnim(0x100, tx: 300, ty: 100, tz: 0, period: 1000);   // base x=100 -> target x=300
        // first snapshot seeds Start=0; at now=500 (half period) it's fully at the target
        g.SnapshotVisibleObjects(0);
        var ro = g.SnapshotVisibleObjects(500).Single();
        Assert.Equal(300, ro.DstX);
        var back = g.SnapshotVisibleObjects(1000).Single();
        Assert.Equal(100, back.DstX);   // ping-ponged back to base
    }

    [Fact]
    public void SrcRect_SelectsCellFromGrid()
    {
        var g = VisibleObj(0x100);
        // 4-cell row across a 256px sheet => each cell 64px wide; cell index 2 => srcX=128
        g.SetSrcRectAnim(0x100, gridW: 4, gridH: 1, cell: 2, period: 0);   // static cell 2
        var ro = g.SnapshotVisibleObjects(0).Single();
        Assert.Equal(128, ro.SrcX);
        Assert.Equal(64, ro.W);
    }
}
  • Step 2: Run test to verify it fails

Run: dotnet test engine/AgeEngine.sln --filter AnimInterpolatorTests Expected: FAIL — PingPong, SnapshotVisibleObjects(long) don't exist.

  • Step 3: Add PingPong to BlendMath

In engine/Age.Engine/Model/BlendMath.cs:

    /// <summary>Ping-pong (triangle-wave) progress of gfx_object_anim_interpolate: fold (now-start) mod
    /// period at period/2 so the value ramps to the peak at half-period then back. Returns u in
    /// [0, period/2]; the interpolation weight toward the target is 2u/period.</summary>
    public static long PingPong(long now, long start, long period)
    {
        if (period <= 0) return 0;
        long u = ((now - start) % period + period) % period;
        if (u >= period / 2) u = period - u;
        return u;
    }
  • Step 4: Add the interpolating overload

In GfxState.cs, add int CellW, int CellH to RenderObject is NOT needed — reuse SrcX/SrcY/W/H. Change the existing no-arg SnapshotVisibleObjects() to delegate, and add the interpolating overload:

    public IReadOnlyList<RenderObject> SnapshotVisibleObjects() => SnapshotVisibleObjects(0);

    /// <summary>Visible objects (ascending-handle z-order) with each active anim channel interpolated at
    /// <paramref name="nowMs"/> (ping-pong over the channel period) — the port of gfx_object_anim_interpolate.
    /// Start fields seed to nowMs on first sight. Position animates the blit dst; src-rect selects the
    /// spritesheet cell into SrcX/W. Color/alpha/tint come from slice A.</summary>
    public IReadOnlyList<RenderObject> SnapshotVisibleObjects(long nowMs)
    {
        lock (_lock)
        {
            var list = new List<RenderObject>();
            foreach (var kv in _objects.OrderBy(k => k.Key))
            {
                var o = kv.Value;
                if (!o.Visible) continue;
                var (resId, ck) = _surfaces.TryGetValue(o.SourceSlot, out var s) ? s : (0L, 0L);

                int alpha = 255; long tint = 0xFFFFFF; var blend = BlendKind.Opaque;
                if (o.HasColor)
                {
                    var (a, r, g, b) = BlendMath.UnpackArgb(o.Color);
                    alpha = a; tint = ((long)r << 16) | ((long)g << 8) | (long)b; blend = BlendKind.Alpha;
                }

                // ---- position channel: base V24 + ping-pong toward (PosTX,PosTY) ----
                int dstX = (int)o.V24.X, dstY = (int)o.V24.Y;
                if (o.PosAnim)
                {
                    if (o.PosStart < 0) o.PosStart = nowMs;
                    long u = BlendMath.PingPong(nowMs, o.PosStart, o.PosPeriod);
                    long half = o.PosPeriod > 0 ? o.PosPeriod / 2 : 1;
                    double t = half > 0 ? (double)u / half : 0;                // 0..1 base->target->base
                    dstX = (int)(o.V24.X + (o.PosTX - o.V24.X) * t);
                    dstY = (int)(o.V24.Y + (o.PosTY - o.V24.Y) * t);
                }

                // ---- src-rect channel: pick the spritesheet cell ----
                int srcX = o.SrcRect.X, srcY = o.SrcRect.Y, w = o.SrcRect.W, h = o.SrcRect.H;
                if (o.SrcAnim && o.SrcGridW >= 1)
                {
                    int cellW = (int)(o.SrcRect.W / o.SrcGridW);
                    int cellH = o.SrcGridH >= 1 ? (int)(o.SrcRect.H / o.SrcGridH) : o.SrcRect.H;
                    long cell = o.SrcCell;
                    if (o.SrcPeriod > 0)                                        // animate the cell across the row
                    {
                        if (o.SrcStart < 0) o.SrcStart = nowMs;
                        long half = o.SrcPeriod / 2; half = half > 0 ? half : 1;
                        long u = BlendMath.PingPong(nowMs, o.SrcStart, o.SrcPeriod);
                        cell = (long)((double)u / half * (o.SrcGridW - 1));
                    }
                    srcX = o.SrcRect.X + (int)(cell % o.SrcGridW) * cellW;
                    srcY = o.SrcRect.Y + (int)(cell / o.SrcGridW) * cellH;
                    w = cellW; h = cellH;
                }

                list.Add(new RenderObject(kv.Key, resId, ck, srcX, srcY, w, h, dstX, dstY,
                                          new AnimState(o.AnimEnabled, o.AnimNormalized,
                                                        o.AnimTarget.X, o.AnimTarget.Y, o.AnimTarget.Z,
                                                        o.AnimDurationTicks, o.AnimGeneration),
                                          alpha, tint, blend));
            }
            return list;
        }
    }

Remove the old SnapshotVisibleObjects() body (now replaced by the delegating one + the overload).

  • Step 5: Run tests — verify pass

Run: dotnet test engine/AgeEngine.sln --filter "AnimInterpolatorTests|BlendMathTests" Expected: PASS.

  • Step 6: Full suite + sweep parity

Run: dotnet test engine/AgeEngine.sln — Expected: all green (the no-arg overload keeps existing callers identical). Run: dotnet run --project engine/Age.Cli -c Debug -- sweep — Expected: exit=284, STEP-LIMIT=13.

  • Step 7: Commit
git add engine/Age.Engine/Model/GfxState.cs engine/Age.Engine/Model/BlendMath.cs engine/Age.Engine.Tests/AnimInterpolatorTests.cs
git commit -m "feat: port gfx_object_anim_interpolate (ping-pong position + spritesheet cell)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 4: Engine — wire the cluster setter ops

Files: Modify engine/Age.Engine/Vm/VirtualMachine.cs; extend engine/Age.Engine.Tests/AnimChannelTests.cs.

Interfaces:

  • Consumes: SetPositionAnim, SetSrcRectAnim (Task 2) + any additional channel mutators added here per the Task 1 table.
  • Produces: VirtualMachine.Step cases for the cluster ops, routing operands to the mutators.

The op labels are in opcodes.toml (e.g. 0x22fu00421DD0); dispatch keys on the label string (as the existing gfx cases do). Operand order is verified against the Task 1 worked examples: 0x22f (handle)(op2)(x)(y)(z), 0x239 (handle)(p3)(p4)(gridW)(gridH)(cell).

  • Step 1: Write the failing test (append to AnimChannelTests.cs)
    [Fact]
    public void Op0x22f_DrivesPositionAnim()
    {
        var t = OpcodeTableJson.Load(Paths.OpcodesJson);
        // assemble: bind a surface+object, then op 0x22f to animate toward (300,100)
        // (use the existing ScriptAssembler harness as in GfxCommandBufferTests)
        // ... assert vm.Gfx.TryGet(handle).PosAnim == true and PosTX == 300
    }

Follow the exact ScriptAssembler/MovGI pattern from engine/Age.Engine.Tests/GfxCommandBufferTests.cs (which already assembles gfx-op scripts). Assert PosAnim and the target field.

  • Step 2: Run to verify it failsdotnet test engine/AgeEngine.sln --filter Op0x22f → FAIL (no case; op is a stub).

  • Step 3: Add the cases in VirtualMachine.Step (near the existing set-anim-transform-abs):

            case "u00421DD0":   // 0x22f — position/translation anim (handle)(op2)(x)(y)(z)
                Gfx.SetPositionAnim(Read(a[0]), Read(a[2]), Read(a[3]), Read(a[4]), Read(a[1])); return pc + 1;
            case "u004223C0":   // 0x239 — spritesheet cell/grid (handle)(p3)(p4)(gridW)(gridH)(cell)
                Gfx.SetSrcRectAnim(Read(a[0]), Read(a[3]), Read(a[4]), Read(a[5]), Read(a[1])); return pc + 1;

NOTE: the case string is the opcode label from opcodes.toml (dispatch keys on label, per the existing gfx cases). Confirm each label with grep "op = 0x22f" -A1 vm-map/opcodes.toml. The exact operand→argument slot mapping (which operand is period vs target) comes from the Task 1 worker decode — the two above match the worked examples; fill the remaining ops from the Task 1 table.

  • Step 4: Wire the remaining used cluster ops (per Task 1's used-channel set). For each, add a case routing operands to the appropriate mutator (position/src-rect, or a rotation/scale mutator added the same way as Task 2 if Task 1 found those channels used). Ops that Task 1 shows write channels not exercised by the SC0000 opening may be routed to a safe no-op case (documented) rather than left as effectful GAP stubs.

  • Step 5: Run the new tests + full suite + sweep

Run: dotnet test engine/AgeEngine.sln — Expected: green. Run: dotnet run --project engine/Age.Cli -c Debug -- sweep — Expected: exit=284, STEP-LIMIT=13.

  • Step 6: Coverage check

Run: py -3.11 -X utf8 tools/scene_opcode_coverage.py SC0000 Expected: the wired cluster ops move from GAP → handled (GAP count drops by the number wired).

  • Step 7: Commit
git add engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine.Tests/AnimChannelTests.cs
git commit -m "feat: wire SC0000 anim/transform/spritesheet cluster ops to channel mutators

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 5: Host — drive the interpolator off FrameClock + blit cell/position

Files: Modify godot/Main.cs (Recomposite ~line 251).

Interfaces:

  • Consumes: SnapshotVisibleObjects(long nowMs) (Task 3); _clock.NowMs (Age.Engine.Hosting.FrameClock, already owned by Main).

  • Produces: the compositor renders each object at its interpolated position and spritesheet cell, paced by the frame clock (the "mach 5" fix).

  • Step 1: Pass the frame clock's time into the snapshot

In Recomposite, change the enumeration to the interpolating overload:

        foreach (var v in _vm.Gfx.SnapshotVisibleObjects(_clock.NowMs))   // interpolate at the throttled clock

(The existing loop already blits v.SrcX/SrcY/W/H at v.DstX/DstY, so the selected cell + interpolated position flow through with no further change; BlitLayer is unchanged.)

  • Step 2: Build the Godot project

Run: dotnet build godot/Himegari.csproj -c Debug --nologo -v q Expected: Build succeeded. 0 Error(s).

  • Step 3: Verify parity via the headless self-test

Run: & "S:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe" --headless --path godot -- --selftest Expected: SELFTEST OK.

  • Step 4: Scene acceptance — the user's whole-scene validation

Run: & "S:\Godot\...console.exe" --path godot -- --boot --shot-sequence "..\scratch\anim_seq" --frames 240 Then Read a spread of frames. Expected: sprites translate across frames; animated sprites show a single cycling cell (not the full sheet); the glow/explosion plays at a readable cadence (not mach-5). This is the scene-level confirmation. If rotation/scale were gated out in Task 1 and a sprite visibly needs them, note it as the follow-up (don't expand here).

  • Step 5: Commit
git add godot/Main.cs
git commit -m "feat(godot): drive the anim interpolator off FrameClock (motion+spritesheet paced)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Task 6: Docs + memory

  • Step 1: Update vm-map/opcodes.toml summaries for the wired cluster ops (now handled; cite engine-re.md §anim cluster), then py -3.11 -X utf8 tools/opcodes_build.py --build.
  • Step 2: Append a "cluster DONE" section to docs/phase-a-slice-plan.md (what landed, coverage delta, any gated-out channels).
  • Step 3: Update memory himegari-port-status.md + MEMORY.md (absolute date 2026-07-08).
  • Step 4: Commit
git add vm-map/opcodes.toml tools/age_opcodes_himegari.py docs/opcode-reference.md docs/phase-a-slice-plan.md
git commit -m "docs: record SC0000 anim/transform/spritesheet cluster coverage

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"

Notes for the executor

  • Task 1 is the load-bearing RE. Do not guess operand→field mappings — decompile each worker at its known address. The two worked examples (0x22f, 0x239) are the pattern.
  • Ping-pong, not one-shot. These channels oscillate (glow pulses); do not "reach target and stop." The PingPong helper enforces the fold.
  • Rotation/scale rendering stays out unless Task 1 proves the opening uses it — then add a rotation/scale mutator (Task 2 pattern) + an affine blit in Main.cs (a larger change; scope it as its own task if it lands).
  • nowMs back-compat: every existing headless caller uses the no-arg SnapshotVisibleObjects() (→ nowMs=0), so their output is deterministic and unchanged — parity holds.

Self-review (done while writing)

  • Spec coverage: channel model → Task 2; setter ops → Tasks 1(RE)+4(wire); interpolator port → Task 3; FrameClock pacing → Task 5 Step 1; spritesheet cell → Task 3 Step 4; RE Task-0 → Task 1; testing (setter+interpolator+scene) → Tasks 2/3/5; rotation-scale gating → Task 1 Step 2 + Task 5 note. Covered.
  • Placeholder scan: the interpolator (Task 3), channel model (Task 2), and the two worked setter ops (Task 4) are complete code. The remaining setter ops (Task 4 Step 4) are explicitly RE-dependent (Task 1) — this is a genuine sequencing dependency, not a hidden placeholder; the pattern + addresses are given.
  • Type consistency: SetPositionAnim(long,long,long,long,long), SetSrcRectAnim(long,long,long,long,long), PosTX/PosPeriod/PosStart/PosAnim, SrcGridW/SrcCell/SrcPeriod/SrcStart/SrcAnim, BlendMath.PingPong(long,long,long), SnapshotVisibleObjects(long) — used identically across Tasks 2/3/4/5.