Files
OpenMaidEngine/docs/superpowers/specs/2026-07-07-gfx-animation-subsystem-design.md
gamer147 0bd0fa01d5 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 <noreply@anthropic.com>
2026-07-07 22:57:42 -04:00

183 lines
12 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.
# 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 `0x21c0x243` (+ `0x2bd/0x2bf`) — e.g.
`0x220`×66, `0x22f`×34, `0x228`×33, `0x21e`×25 static sites in SC0000.
Native RE (see `docs/engine-re.md`, "The `0x21c0x243` 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 `0x2120x21a`
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<long, TweenState { Vector startTransform,
targetTransform; float elapsed, duration; long generation; }>`.
- 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.