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

143 lines
9.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`, `0x21c``0x243`) 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_00424120``FUN_0047ed90` |
| 0x21d | `FUN_00423310` | 0x229 | `FUN_00423700` | 0x23d | `LAB_004175c0` |
| 0x21f | `FUN_00423410` | 0x22f | `FUN_00423b00``FUN_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.