From 4589b98be5f3fb8ed711eae6113e6ffd4a4cc7c0 Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 8 Jul 2026 21:06:05 -0400 Subject: [PATCH] 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 --- ...08-sc0000-anim-transform-cluster-design.md | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-08-sc0000-anim-transform-cluster-design.md diff --git a/docs/superpowers/specs/2026-07-08-sc0000-anim-transform-cluster-design.md b/docs/superpowers/specs/2026-07-08-sc0000-anim-transform-cluster-design.md new file mode 100644 index 0000000..37e88ba --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-sc0000-anim-transform-cluster-design.md @@ -0,0 +1,142 @@ +# 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.