Files
OpenMaidEngine/docs/superpowers/specs/2026-07-08-sc0000-anim-transform-cluster-design.md
gamer147 4589b98be5 docs(spec): SC0000 anim/transform/spritesheet cluster design
One-unit scope for the gfx animation cluster (0x1fd, 0x21c-0x243): channel
model + faithful gfx_object_anim_interpolate port on FrameClock (fixes 'mach 5')
+ spritesheet cell selection. Grounded in the recovered dispatch table
(handler(op)=ctx[0x26c93+op]; Kelebek labels are drift) + the reversed
interpolator. Bounded RE Task-0 with known handler addresses.

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

9.3 KiB
Raw Blame History

SC0000 gfx animation / transform / spritesheet cluster (design)

Date: 2026-07-08 Status: design, pending implementation plan Scope: the gfx animation/transform/spritesheet opcode cluster (0x1fd, 0x21c0x243) as one unit, to drive SC0000 from "half the graphics broken" to a coherent, validatable scene. Follows graphics slice A (blend/transparency, merged). draw-string (on-screen text) and play-sound-effect are separate adjacent efforts, NOT in this cluster.

Problem

SC0000 has 62 GAP ops (effectful but stubbed). Three of the four visible defects are one subsystem — the retained sprite animation/transform cluster — because these ops all read/write the same per-object record and clock:

Symptom (user, eyes-on) Stubbed ops
Sprites don't move 0x22f×34, 0x228×33, 0x229×7, 0x21f×8, 0x1fd×12, 0x21d, 0x223, 0x224
Animated sprites show the whole spritesheet 0x239×7, 0x231×9, 0x232, 0x236 (src-rect / cell-grid)
Glow/explosion runs too fast ("mach 5") the anim timing — periods + clock coupling; 0x242, 0x243, 0x23f, 0x23d

"Mach 5" is not the frame-stepped throttle failing (that governs op execution). The visible animation is paced by per-channel periods on the engine's frame clock; we model neither, so effects snap/run unbounded.

Native model (reversed — source of truth)

Canonical: docs/engine-re.md §"The full gfx render model" + §"Blend & transparency". Confirmed this session (Ghidra, annotated + saved):

Dispatch-table unlock (general, foundational). The op→handler table is registered statically by FUN_00413860: handler(op) = ctx[0x26c93 + op] (dword index; default FUN_004162b0). The u004xxxxx labels in opcodes.toml are Kelebek VAs with drift and point at the wrong functions — always resolve a handler via this table, not the label. Recovered cluster handler addresses (all real):

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

Setter shape. Each cluster op is a thin wrapper that fetches operands (FUN_0041b940(n)) and calls a worker (FUN_0047xxxx) that writes the object's channel fields. E.g. 0x22f = (handle, op2, x, y, z)FUN_00472e90; 0x239 = 6 operands → FUN_0047ed90.

The interpolator — gfx_object_anim_interpolate (0x473ed0), the CONSUMER = ground truth. Called by gfx_object_composite when the object's anim bit is set. Timebase = the global frame clock ctx+0xb550 (advances per present), NOT the op-0x238 clock. It interpolates 5 independent channels, each with its own period + start timestamp (start inits to the clock on first frame), and each ping-pongs (triangle wave: progress = (now-start) % period, folded at period/2) — these OSCILLATE toward a target and back (pulsing/glow), they are not one-shot:

Channel period start target/params
Color/alpha obj+0x220 obj+0x20c obj+0x240 (packed, LERP vs current)
Matrix A obj+0x224 obj+0x210 obj+0x250..0x28c (4×4)
Rotation obj+0x228 obj+0x214 obj+0x244 (range 0x168=360°)
Matrix B obj+0x22c obj+0x218 obj+0x290..0x2cc (4×4)
Src-rect scroll obj+0x230 obj+0x21c grid obj+0x238/0x23c, crops obj+8..0x14

Architecture (hybrid: engine resolves, host blits)

Same seam as slice A. The engine owns the channel model + a faithful port of the interpolator; the host blits the resolved result. Keeping the interpolator engine-side makes it unit-testable (channels + nowMs → expected transform/cell/color, no pixels) and headless-consistent.

The pacing fix. Drive the interpolator off the FrameClock built for frame-stepping (Age.Engine.Hosting.FrameClock.NowMs), using the RE'd per-channel periods. Anim time then == the throttled virtual time, so effects play at native cadence — this is the direct fix for "mach 5". (The native's frame clock advances per present; our FrameClock advances per rendered _Process — the same wall-clock basis.)

Data flow

VM cluster ops (engine thread)          Model (GfxState.GfxObject)              Host (_Process)
0x22f/0x228/... (motion)              → position channel {period,start,target}
0x239/0x231/... (spritesheet)        → src-rect channel {period,start,grid,cells}
0x21f/... (rotation/scale)           → rotation/scale channels
0x202/0x203 (color — slice A)        → color channel
0x234/0x238 (existing)               → one-shot anim + global clock (unchanged)
                                                 │
                    SnapshotVisibleObjects(nowMs)  ── ports gfx_object_anim_interpolate ──►
                    per visible obj (ascending-handle z-order), interpolate each active channel
                    (ping-pong over nowMs w/ its period) → RenderObject {
                        dstPos, Rotation, Scale, SrcRect(selected cell), Alpha, Tint, Blend, ColorKey }
                                                                        ├─► blit surface sub-rect (cell) at pos
                                                                        ├─► (rotation/scale ONLY if used — Task-0)
                                                                        └─► colorkey/alpha/tint (slice A)
    Host passes FrameClock.NowMs as nowMs each frame.

Units

Unit 1 — Engine: anim-channel model (GfxState.GfxObject). Add the per-channel {period, start, target} fields the interpolator reads (position, rotation, scale, src-rect grid/cells; color exists from slice A). Offsets/semantics from the interpolator table above; per-op mapping pinned in Task-0.

Unit 2 — Engine: cluster setter ops (VirtualMachine.Step + GfxState mutators). Implement the GAP ops to populate channels, one mutator per channel family. Each op's worker is decompiled via its known address (Task-0) to get the exact operand→field mapping.

Unit 3 — Engine: the interpolator (GfxState.SnapshotVisibleObjects(long nowMs)). Faithful port of gfx_object_anim_interpolate: for each visible object, for each active channel, ping-pong over nowMs with its period and produce the current transform/cell/color into the RenderObject. Pure → unit-tested.

Unit 4 — Host: apply transform + src-cell (godot/Main.cs). Compositor passes FrameClock.NowMs, blits the selected src sub-rect (spritesheet cell) at the interpolated position. Rotation/scale rendering is included only if Task-0 shows the SC0000 opening animates those channels (a larger affine-blit change); otherwise deferred with a logged note — position + src-cell + slice-A color covers the axis-aligned case.

RE Task-0 (first plan task — bounded, now low-risk)

For each cluster op, decompile its known-address worker and record the operand→channel-field mapping; and enumerate which channels the SC0000 opening actually animates (bounds Unit 4). Verify against the gfx oracle (model state) and the disassembly (which ops fire). Output: the op→field table (into engine-re.md) + the used-channel set. If a channel is unused in the opening (e.g. a raw 4×4 matrix), scope its rendering out of this slice and note it.

Testing & acceptance

  • Engine (xUnit): (a) each setter op writes the expected channel fields; (b) the interpolator — given channels + a sequence of nowMs, assert the ping-pong transform/cell/color at chosen phases (0, quarter, half period) — this is where "mach 5" is pinned to a number (a period at t yields a specific offset).
  • Parity: engine suite, sweep (exit=284, STEP-LIMIT=13), Godot --selftest stay green — the new ops are effectful only through the gfx model (headless dialogue path unaffected); SnapshotVisibleObjects gains a nowMs param but headless callers pass a fixed value → deterministic.
  • Scene (pixels, the user's validation): --shot-sequence on SC0000 --boot — sprites now translate, animated sprites show a single cycling cell (not the full sheet), and the glow/explosion plays at a readable cadence. The coverage gauge (scene_opcode_coverage.py SC0000) shows the cluster GAP ops move to handled. This is the whole-scene confirmation the user has been unable to give — validated once, at the end, not per-op.

Non-goals

  • draw-string 0x204 (on-screen text) and play-sound-effect — separate adjacent efforts.
  • Graphics slices B (geometry/anchors beyond what these channels set) and C (render-targets).
  • Rotation/scale rendering if the opening doesn't use it (Task-0 decides; seam left).
  • Smooth one-shot color-anim interpolation and additive/glow blend — still deferred from slice A (tracked in engine-re.md §Blend); this cluster is the oscillating channels, a distinct mechanism.
  • Reimplementing D3D9 / the raw 4×4 matrix path unless the opening exercises it.