From f69b295c0ea8a47047bb348765b0e4ec724abf2b Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 7 Jul 2026 22:57:42 -0400 Subject: [PATCH] docs(gfx): RE the 0x21c-0x243 sprite animation cluster + subsystem spec Native RE of the largest remaining SC0000 rendering GAP band: resolved every handler in 0x21c-0x243 (+0x2bd/0x2bf) via the dispatch table (ctx[0x26c93+op] from FUN_00413860). The cluster is ONE subsystem = sprite transform + animation/ tween; two members already named (0x234 anim_start, 0x238 set_anim_clock). Decoded representative ops 0x220/0x21e (argc6, cmd-type 0xd, transform worker (handle,op2,op3,f4,f5,f6); 0x21e normalizes floats /_DAT_00571c28 -> scale%); worker gfx_anim_set_channel@0x47eaa0 arms an anim channel on the same object model GfxState tracks. anim_start+set_anim_clock => a per-frame clock that makes AE* fades animate rather than snap. engine-re.md: full op->handler map + contract. Ghidra annotated (renamed gfx_op_0x220_set_transform3_abs / gfx_op_0x21e_set_transform3_norm / gfx_anim_set_channel + plate comments, saved). Add the approved brainstorming spec for the opening-slice implementation (wall-clock tween, opening-driven subset, passive GfxState + compositor tween + alpha-aware blit). Co-Authored-By: Claude Opus 4.8 --- docs/engine-re.md | 50 +++++ ...26-07-07-gfx-animation-subsystem-design.md | 182 ++++++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-07-gfx-animation-subsystem-design.md diff --git a/docs/engine-re.md b/docs/engine-re.md index 32137fe..67d0fc9 100644 --- a/docs/engine-re.md +++ b/docs/engine-re.md @@ -272,6 +272,56 @@ Worker functions decoded + annotated in the Ghidra project (2026-07-07): `gfx_re `gfx_object_init_default`@`0x472810`), the setters `gfx_set_vec18/24/16c`(`0x47e960/e910/e800`), the getters `gfx_get_vec18/24`(`0x47f360/f2e0`). +#### The `0x21c–0x243` sprite transform / ANIMATION cluster (2026-07-07, recon — implementation pending) + +The scene-completeness tracker (`tools/scene_opcode_coverage.py`) flagged a dense band of GAP ops in +`0x21c–0x243` (+ `0x2bd/0x2bf`) — the **largest remaining rendering unknown** in SC0000 (e.g. `0x220`×66, +`0x22f`×34, `0x228`×33, `0x21e`×25 static sites). Resolving every one through the dispatch table +(`ctx[0x26c93+op]`, read from `FUN_00413860`) shows it is **one coherent subsystem: sprite transform + +animation/tween** — and two members were already named in prior RE (`0x234 gfx_op_0x234_anim_start`, +`0x238 gfx_op_0x238_set_anim_clock`). Kelebek VAs drift here as everywhere (op `0x220` real handler is +`0x4234e0`, not Kelebek's `0x4215D0`). **Op → real handler map:** + +| op | handler | op | handler | op | handler | +|---|---|---|---|---|---| +| `0x21c` | `0x417520` (417xxx trivial) | `0x229` | `0x423700` | `0x236` | `0x423ee0` | +| `0x21d` | `0x423310` | `0x22a` | `0x4237b0` | `0x237` | `0x4240a0` | +| `0x21e` | `0x423350` **✎ set_transform3_norm** | `0x22b` | `0x423850` | `0x238` | **anim_start's clock ✎** | +| `0x21f` | `0x423410` | `0x22c` | `0x423900` | `0x239` | `0x424120` | +| `0x220` | `0x4234e0` **✎ set_transform3_abs** | `0x22d` | `0x423990` | `0x23a` | `0x42a440` | +| `0x221` | `0x423590` | `0x22e` | `0x423a40` | `0x23b` | `0x424190` | +| `0x222` | `0x4235e0` | `0x22f` | `0x423b00` | `0x23c` | `0x417580` (417xxx) | +| `0x223` | `0x423620` | `0x230` | `0x423ba0` | `0x23d` | `0x4175c0` (417xxx) | +| `0x224` | `0x417550` (417xxx) | `0x231` | `0x423be0` | `0x23e` | `0x42a4a0` | +| `0x225` | `0x4236a0` | `0x232` | `0x423c30` | `0x23f` | `0x42a520` | +| `0x226` | `0x42a230` | `0x233` | `0x423cf0` | `0x240` | `0x4245f0` | +| `0x227` | `0x42a2e0` | `0x234` | **anim_start ✎** | `0x241` | `0x4247e0` | +| `0x228` | `0x42a3a0` | `0x235` | `0x423e40` | `0x242` | `0x4249d0` | +| | | | | `0x243` | `0x4182d0` (417xxx) | + +(`0x2bd`→`0x4251c0`, `0x2bf`→`0x425240`. The handful of `0x417xxx` handlers are trivial/marker-shaped — the +default-handler neighbourhood — and are almost certainly no-ops or arg-poppers; triage before modelling.) + +**Contract (decoded, representative ops `0x220`/`0x21e`, both `argc 6`, annotated in Ghidra):** same shape as +the geometry family — write **cmd-type `0xd`** into the current object record, fetch operands 1..6, call a +transform worker with `(int op1=handle, int op2, int op3, float op4, float op5, float op6)`. `0x220` uses raw +floats (worker `0x47ecc0`); **`0x21e` normalizes the 3 floats by `/_DAT_00571c28`** (runtime-init divisor, +static 0) so operand `0x64`=100 → a fraction → **scale/percentage** (worker `gfx_anim_set_channel`@`0x47eaa0`). +The worker calls the SAME `gfx_object_get_or_create` our `GfxState` already models, then arms an animation +channel on the object record: `obj+0x3c = op2`, `obj+0x50 = op3`, `obj+0x68 = 1` (enable), `obj+0xac = +vec3(op4,op5,op6)` (the transform target), and raises global dirty flags `ctx+0xb558/+0xb560`. Corpus idiom: +`0x220 (handle=0xcb20+k) 800 500 0 0 0` (size a CG object), `0x21e (handle) (val) 100 100 100 100` (scale/color +channels). **`0x234 anim_start` + `0x238 set_anim_clock` imply a per-frame clock that interpolates these +targets over time** — i.e. this is what makes `AE*` fades/effects *animate* rather than snap. + +**Model implication (Phase-2 input, mirrors the geometry family):** the DirectDraw workers need NOT be +modelled — extend the host `GfxState` object with the transform/anim fields (a transform `vec3` target + the +two scalar params + enable + an animation clock), have the SET ops (`0x21e/0x220/0x234/0x238/…`) write them and +the compositor apply the transform per-frame, stepping the clock on `anim_start`/`set_anim_clock`. This is a +spec/plan-worthy chunk (~18 effectful handlers + workers `0x47eaa0/0x47ecc0` + the per-frame stepping); the op +map above is the de-risked starting point. `tools/scene_opcode_coverage.py SC0000` measures the GAP shrink as +each lands. + ### The render drift's SECOND half: missing system-boot state (2026-07-07, resolved) Implementing the gfx ops (above) was necessary but not sufficient — a cold single-scene run of SC0000 still diff --git a/docs/superpowers/specs/2026-07-07-gfx-animation-subsystem-design.md b/docs/superpowers/specs/2026-07-07-gfx-animation-subsystem-design.md new file mode 100644 index 0000000..6f4fa23 --- /dev/null +++ b/docs/superpowers/specs/2026-07-07-gfx-animation-subsystem-design.md @@ -0,0 +1,182 @@ +# Design — sprite transform / animation subsystem (opening slice) + +Date: 2026-07-07 +Status: approved (brainstorming), pending implementation plan +Branch: `feat/gfx-command-buffer` (continues the gfx command-buffer work) + +## Problem + +The scene-completeness tracker (`tools/scene_opcode_coverage.py`, added 2026-07-07) makes SC0000's +un-implemented opcodes legible: instruction-weighted the VM handles ~94.8%, but **68 GAP opcodes** are +silently skipped. The largest rendering cluster of these is `0x21c–0x243` (+ `0x2bd/0x2bf`) — e.g. +`0x220`×66, `0x22f`×34, `0x228`×33, `0x21e`×25 static sites in SC0000. + +Native RE (see `docs/engine-re.md`, "The `0x21c–0x243` sprite transform / ANIMATION cluster") resolved +**every** handler in the band through the anchored dispatch table (`ctx[0x26c93+op]`, from `FUN_00413860`) +and showed it is **one coherent subsystem: sprite transform + animation/tween**. Two members were already +named in prior RE — `0x234 gfx_op_0x234_anim_start`, `0x238 gfx_op_0x238_set_anim_clock`. The representative +SET ops `0x220`/`0x21e` (both `argc 6`, now annotated in Ghidra) write **cmd-type `0xd`**, fetch operands +1..6, and call a transform worker `(handle, op2, op3, float op4, float op5, float op6)`; `0x21e` normalizes +the floats by `/_DAT_00571c28` (→ scale/percentage). The worker `gfx_anim_set_channel`@`0x47eaa0` uses the +**same `gfx_object_get_or_create`** our `GfxState` already models and arms an animation channel on the object +record (`obj+0x3c=op2`, `obj+0x50=op3`, `obj+0x68=1` enable, `obj+0xac=vec3(op4,op5,op6)` target). Combined +with `anim_start`/`set_anim_clock`, this is a per-frame clock that interpolates the target over time — i.e. +**what makes `AE*` fades/effects animate instead of snap**. Today the Godot compositor blits every object +**opaque** with no transform and no time, so the opening's fade "explosion glow" stays put. + +## Goal + +Render the SC0000 opening's animated effect(s) — specifically the `AE*` fade — correctly: the effect +**animates over time** rather than snapping or staying opaque. Do it as the smallest verifiable slice, and +leave a clear, measurable trail (tracker GAP shrink) for expanding to the rest of the cluster later. + +## Decisions (from brainstorming) + +1. **Timing fidelity = wall-clock tween.** The VM records the animation channels (targets + durations) into + `GfxState` deterministically; the Godot compositor interpolates them over its own frame delta. Rendering + is frontend-only, so **headless VM trace parity is untouched**. We decode `anim_start`/`set_anim_clock` + enough to get durations/targets but do **not** replicate the game's exact native tick. +2. **Scope = opening-driven subset first.** Trace what SC0000's booted opening actually executes, implement + only those anim ops, verify the fade animates, then expand in follow-ups. Only model what we can confirm + on screen. +3. **Architecture = passive `GfxState` + compositor-owned tween + alpha-aware blit.** The anim ops only + record channel data on the object; the compositor owns all wall-clock/tween logic and the new alpha + compositing. Keeps `GfxState`'s established passive-data-model role and folds in the deferred + `0x202/0x203` alpha as a side effect. + +## Non-goals / deferred + +- Cluster ops that appear only in battle/debug scenes (`BTL`/`DEBUGADV`) and are **not** on the opening + path — they stay on the `default` VM stub and keep showing as GAP in the tracker (honest remaining work). +- Scene-coroutine / script-yield timing ops (`0x7b`/`0x140`/`sleep 0xc8`). If the opening's fade is driven + by a script poll-loop that needs these, we handle the *fire-and-forget* animation-command path and record + the coupling; the coroutine model is a separate backlog item. +- The native DirectDraw workers (`FUN_0047xxxx`) — not modeled, consistent with the existing gfx family. +- Frame-exact reproduction of the game's animation clock. + +## Invariants (must hold) + +- **Headless VM parity:** anim ops are record-only on the VM side (like every existing gfx op); the + non-Godot hosts (`CaptureHost`, test `RecHost`/`CountHost`) read none of the new state → `--selftest`, + `sweep`, and existing engine tests stay byte-identical. +- **`GfxState` stays passive** — no time/`Tick`; it exposes recorded channel data, nothing more. +- **`opcodes.toml` is the source of truth** for op labels; the VM dispatches on `label` and the table is + rebuilt via `opcodes_build.py --build` (never hand-edit generated files). + +## Architecture + +### A. RE step (executed first in the plan — the one real risk) + +The chunk gates on two facts we don't yet have: + +1. **Decode `0x234 anim_start` and `0x238 set_anim_clock`** (handler addresses already resolved in the + `FUN_00413860` dispatch map). Recover their operand contract and which fields they set (duration/clock, + the start trigger, which object/channel). Annotate the handlers in the Ghidra project (`gfx_op_0x234_*`, + `gfx_op_0x238_*`) and record the contract in `docs/engine-re.md`. +2. **Trace the `--boot` opening** (`Age.Cli gfx --boot SC0000.BIN` and/or `play --boot --trace`) to confirm + *which* cluster ops execute on the opening path, in what order relative to the `AE*` CG loads / + `draw-texture` / `0x202`/`0x203`, and therefore **what quantity is being animated** — the transform vec3 + (`obj+0xac`) or the packed alpha from `0x202/0x203`. + +Output: the confirmed op subset + an `engine-re.md` addendum. **If the trace shows the fade's alpha is +`0x202/0x203`-driven**, the chunk narrows to "alpha-blend compositing + those two ops" — even smaller — and +the transform ops become a later slice. + +### B. Data model — `GfxState` / `GfxObject` extensions (`Age.Engine/Model/GfxState.cs`) + +Extend `GfxObject` with the animation channel the worker revealed: + +- `(long X, long Y, long Z) AnimTarget;` — the `obj+0xac` transform target (semantics per RE: scale, color, + or offset; stored faithfully regardless). +- `long AnimParam1, AnimParam2;` — `obj+0x3c` (op2) and `obj+0x50` (op3). +- `bool AnimEnabled;` — `obj+0x68`. +- `long AnimDurationTicks;` — from `set_anim_clock`/`anim_start`. +- `long AnimGeneration;` — bumped every time `anim_start` fires on the object; the compositor's trigger to + (re)start a tween. + +New passive methods (mutate the object, bump generation where appropriate), called from the VM dispatch: + +- `SetAnimTransform(long handle, long p1, long p2, (long,long,long) target, bool normalized)` — for + `0x21e`/`0x220`. (Normalization: `0x21e` divides operands by the runtime divisor; store the fraction as the + design's chosen fixed representation — decide the unit during RE, e.g. permille or a float field.) +- `StartAnim(long handle, …)` — for `0x234`; sets `AnimEnabled` + bumps `AnimGeneration`. +- `SetAnimClock(long handle_or_scope, long duration)` — for `0x238`. + +`SnapshotVisibleObjects()` (and the `RenderObject` record) gain the anim channel + `AnimGeneration` + +`AnimDurationTicks` so the compositor can tween without reaching into `GfxState` internals. + +### C. VM dispatch (`Age.Engine/Vm/VirtualMachine.cs` + `vm-map/opcodes.toml`) + +- Add `label`s in `opcodes.toml` for the confirmed subset (dispatch keys on `label`, the `0x212–0x21a` + precedent): e.g. `0x21e = set-anim-transform-norm`, `0x220 = set-anim-transform-abs`, `0x234 = anim-start`, + `0x238 = set-anim-clock`, plus any opening-path query op. Rebuild `opcodes.json`/reference via + `opcodes_build.py --build`. +- Add `case` arms in `Step` that read operands and call the `GfxState` methods, each returning `pc+1` — + identical shape to the existing gfx ops. Everything else in the cluster stays on `default` (still GAP). + +### D. Godot compositor — the visible payoff (`godot/Main.cs`) + +- **Per-handle tween table** (frontend-only): `Dictionary`. +- Each `_Process`/`Recomposite` frame: for each animated visible object, if its `AnimGeneration` differs from + the tracked one → (re)initialize the tween (start = current on-screen transform, or identity on first + sight); `elapsed += delta`; `t = clamp(elapsed / duration, 0, 1)`; interpolate; apply. +- **Alpha-aware blit:** replace the opaque `BlitRect` in `BlitLayer` with a composite that supports per-object + **alpha** (Godot `Image.BlendRect` for straight alpha; manual per-pixel only if scale/offset is needed). + First pass prioritizes **alpha (fades)** — the user-visible `AE*` defect; scale/position added only if the + trace shows the opening needs them. This is where `0x202/0x203`'s stored packed color is finally applied. + +## Data flow + +``` +VM thread (deterministic): + set-anim-transform (0x21e/0x220) -> GfxState.SetAnimTransform(handle, …, target) + anim-start (0x234) -> GfxState.StartAnim(handle) [bumps AnimGeneration] + set-anim-clock (0x238) -> GfxState.SetAnimClock(…, duration) + (records channel data on the object; no time) + +Godot main thread, every _Process(delta): + Recomposite(): + for each visible object o in SnapshotVisibleObjects() (ascending handle = z-order): + if o.AnimGeneration != tween[o.Handle].generation: start tween (capture start transform) + advance tween.elapsed += delta; t = clamp(elapsed/duration) + transform = lerp(start, target, t) + BlitLayer(surface, srcRect, dst, alpha/scale from transform) [alpha-aware] +``` + +## Testing / verification + +- **Engine unit tests** on *synthesized* scenes (per the "synthesize, don't disable" rule, + `ScriptAssembler`): assert `GfxState` records target/duration/generation from the anim ops; assert the + non-Godot hosts stay byte-identical (parity). No real-scene golden is frozen. +- **Tracker:** `scene_opcode_coverage.py SC0000` — the implemented subset moves impl-ward and the GAP count + drops; this is the chunk's completeness metric. +- **Visual:** extend the `--shot` capture to grab a few time offsets mid-tween (or capture at fixed elapsed + points) and confirm the `AE*` glow **fades** across frames instead of staying opaque. +- **Headless parity:** existing `--selftest` and `sweep` outputs unchanged. + +## Sequencing + +RE (A) → data model (B) → dispatch (C) → compositor (D), running verification (tracker + tests + +screenshot) after each so a wrong assumption surfaces early. **A is the gate**: its trace result can shrink +the whole chunk (alpha-only) or confirm the transform-tween scope. + +## Risks / open questions + +- **What `obj+0xac` controls visually** (scale vs color vs offset) — resolved by the RE step; the data model + stores it faithfully either way, so a wrong guess costs only the compositor's interpretation, not the + recording. +- **Script-driven vs engine-driven animation.** In the real engine the script arms the anim then *yields* + (coroutine/`sleep`) while the native loop ticks. Our VM runs straight through (sleep/coroutine stubbed), + so we capture anim commands as fire-and-forget and tween them in the compositor. If the opening's fade + instead relies on a script poll-loop we can't run headless, that's the scene-coroutine backlog, not this + chunk — the RE trace tells us which. +- **Normalization unit** for `0x21e` (`/_DAT_00571c28`, runtime-init, static 0) — pin the divisor's runtime + value (or infer from corpus `0x64`→1.0 scale) during RE; pick a stable stored representation. + +## Canonical docs touched + +- `docs/engine-re.md` — op-contract addendum for `0x234`/`0x238` + confirmed opening subset. +- `vm-map/opcodes.toml` — labels for the subset (then `opcodes_build.py --build`). +- `docs/phase-a-slice-plan.md` — A2b results for this slice. +- Status memory — milestone entry.