docs(gfx): spec — frame-paced sleep so the SC0000 opening animates
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
134
docs/superpowers/specs/2026-07-08-frame-paced-sleep-design.md
Normal file
134
docs/superpowers/specs/2026-07-08-frame-paced-sleep-design.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Frame-paced `sleep` — the SC0000 opening animates
|
||||
|
||||
**Date:** 2026-07-08
|
||||
**Branch:** continues `feat/gfx-command-buffer`
|
||||
**Slice goal:** make the SC0000 opening's `sleep`-paced retained-object burst (`AE001D → AE002B →
|
||||
AE003B` surface swaps + the alpha-tween channel) actually animate on screen, by giving `sleep`
|
||||
(`0xc8`) real timing. Activates the already-built per-object alpha-tween subsystem and the per-frame
|
||||
compositor, both currently dormant because the burst runs instantly.
|
||||
|
||||
## Problem (root cause, verified)
|
||||
|
||||
The Godot compositor `Main.Recomposite()` already runs every `_Process` frame (~60 fps) and composites
|
||||
the VM's **live** `GfxState` (`_vm.Gfx.SnapshotVisibleObjects()`, ascending-handle z-order, with the
|
||||
wall-clock alpha tween applied). The infrastructure to *present per-frame* already exists.
|
||||
|
||||
The opening does **not** animate for exactly one reason: `sleep` (`0xc8`) is a GAP op — it has no VM
|
||||
`case`, so it falls through to `pc+1` with **zero delay**. The VM runs the whole opening
|
||||
load/draw/`sleep` burst to completion in microseconds on its background thread, so the compositor only
|
||||
ever catches the **final** retained state. The intermediate `AE*` surface swaps are overwritten before
|
||||
a single frame composites.
|
||||
|
||||
This is confirmed native-side (`docs/engine-re.md`): the engine is retained and does **not** block the
|
||||
VM on present — animation runs in the host's per-frame loop while the VM is parked; `sleep` paces the
|
||||
burst. Raw SC0000 bytecode shows `sleep 0x64 / 0x3e8 / 0x2ee` between paced retained-object draws.
|
||||
|
||||
**Therefore:** give `sleep` real timing (pause the VM thread) → the existing compositor catches each
|
||||
intermediate frame → the opening animates. No new rendering machinery required.
|
||||
|
||||
## Non-goals (explicitly deferred)
|
||||
|
||||
- **Scene-coroutine framework** — `0x7b` / `0x7c` / `0x140`, the `G[0xaba5c]==1` re-entry gate, and the
|
||||
`label_125bd` slot-table setup. This is the interactive multi-object subsystem; it is not on the
|
||||
opening's linear path. Stays the "scene-coroutine backlog" item.
|
||||
- **ADV native text** (`0x204` draw-string, `0x7a`) — text already renders via `IHost.ShowText`.
|
||||
- **Audio/SFX cluster** (`0xb4/b5/b6/c2`) — separate self-contained slice.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. RE task (bounded) — confirm `sleep` semantics
|
||||
|
||||
Decode the `0xc8` handler via the master dispatch table `handler(op) = ctx[0x26c93+op]` (read from
|
||||
`FUN_00413860`, per `docs/engine-re.md`). Determine:
|
||||
|
||||
- **Time unit** of the operand. Operands seen: `0x64`(100), `0xc8`(200), `0x2ee`(750), `0x3e8`(1000),
|
||||
`0x1f4`(500), `0xfa0`(4000). Need to know: milliseconds, frames (÷60), or engine ticks. This gates
|
||||
correct pacing (getting it wrong makes the opening 60× too fast or slow).
|
||||
- Confirm it is a **simple blocking pause** (busy/sleep loop or message-pump wait), not a
|
||||
clock-relative / conditional wait that would need extra state.
|
||||
|
||||
Annotate in Ghidra (rename `FUN_...` → `sleep_op_0xc8` + plate comment with the decode) and record the
|
||||
finding in `docs/engine-re.md` and `vm-map/opcodes.toml` (op `0xc8`, source=investigation).
|
||||
|
||||
**Output of this task:** the unit conversion `operand → milliseconds` used by the Godot host.
|
||||
|
||||
### 2. Engine seam — `IHost.Sleep` + VM dispatch
|
||||
|
||||
- `IHost` gains `void Sleep(int milliseconds)`.
|
||||
- `VirtualMachine` gains `case "sleep": _host.Sleep((int)Read(a[0])); return pc + 1;` (unit conversion
|
||||
applied in the op or the host per the RE — decided in the plan; simplest is the host converts).
|
||||
- **Parity:** still `pc + 1` → step counts and emitted offsets are byte-identical; the only behavioral
|
||||
delta versus the current GAP fall-through is that the gated `Stub` trace event for `0xc8` is replaced
|
||||
by the op executing. No golden regen.
|
||||
- The **9 non-Godot hosts** (`CaptureHost` + test doubles `RecHost`/`CountHost`/etc.) implement `Sleep`
|
||||
as a **no-op**. Headless and every `Age.Cli` path stay instant → `sweep` 284 exit / 13 STEP-LIMIT and
|
||||
all engine tests unchanged.
|
||||
|
||||
### 3. Godot host — real pause on the VM thread
|
||||
|
||||
`GodotAdvHost.Sleep(ms)` blocks the VM **background thread** for the (converted) duration — the same
|
||||
thread `vm.Run()` executes on via `Task.Run`. This mirrors the existing `WaitForInput` semaphore
|
||||
suspend, but time-based. The Godot **main thread is untouched**, so `_Process` → `Recomposite()` keeps
|
||||
compositing and presenting during the pause. No deadlock (the VM thread is a throwaway task thread).
|
||||
|
||||
Safety / debugging:
|
||||
- Optional total-duration cap so a pathological script can't hang the window indefinitely.
|
||||
- Optional `--sleep-scale <f>` CLI knob (default 1.0) to speed up/slow down for inspection. Out of the
|
||||
critical path; include only if cheap.
|
||||
|
||||
### 4. Thread-safety audit (primary correctness risk)
|
||||
|
||||
Today the compositor already races the VM thread, but the mutation window is microseconds so nothing is
|
||||
ever observed mid-write. Once the VM genuinely runs concurrently **for seconds**, `GfxState` reads and
|
||||
writes truly overlap. Requirement:
|
||||
|
||||
- Every `GfxState` **mutator** (`GetOrCreate`, `draw-texture` bind, `SetSurface`, the anim ops,
|
||||
`Register`/`Release`, `EraseRange`, `CurrentObject` set) and the reader `SnapshotVisibleObjects()`
|
||||
must serialize on the existing `_lock`.
|
||||
- `SnapshotVisibleObjects()` returns an **immutable copy** built under the lock (it largely does; audit
|
||||
and close gaps — several mutators currently lock, some may not).
|
||||
|
||||
This is the one place a subtle bug could hide; the audit is explicit work, not incidental.
|
||||
|
||||
### 5. `0x20c` present — minimal handling
|
||||
|
||||
Our compositor presents continuously, so an explicit present op is implicitly satisfied. During the RE
|
||||
(Task 1) confirm whether `0x20c` merely presents the frame (→ mark `noop_headless`/`safe-noop` in
|
||||
`opcodes.toml`, a free GAP-count shrink) or gates double-buffering (→ small handler). Default expectation:
|
||||
`safe-noop`. Kept intentionally minimal.
|
||||
|
||||
### 6. Verification tooling — frame-sequence capture
|
||||
|
||||
A time-based effect cannot be verified by the existing single `--shot`. Add a Godot dev flag
|
||||
`--shot-sequence <dir> [--frames N]` that saves one PNG per `_Process` frame across the opening (up to N
|
||||
frames or scene end). Success criterion: the captured sequence shows `AE001D`, `AE002B`, `AE003B` as
|
||||
**distinct** frames (the explosion stepping), not just the final CG. This is the agent's eyes for the
|
||||
animation.
|
||||
|
||||
## Testing & parity
|
||||
|
||||
- **New engine test:** a synthesized scene (via `ScriptAssembler`) containing a `sleep` op, run against
|
||||
a recording host that asserts `Sleep` was invoked with the expected converted value. Synthesize test
|
||||
data; never disable a feature to keep a real scene matching a frozen number (standing project rule).
|
||||
- **Regression guard:** `sweep` (headless) unchanged 284/13; all engine tests byte-identical (no-op
|
||||
sleep everywhere headless); `--selftest` unchanged (synthetic selftest scene has no `sleep`).
|
||||
- **Visual verify:** `--shot-sequence` frame set shows the opening `AE*` burst animating.
|
||||
- **Completeness gauge:** `tools/scene_opcode_coverage.py SC0000` — `0xc8` moves GAP→impl; `0x20c` likely
|
||||
GAP→safe-noop. Record the GAP delta.
|
||||
|
||||
## Docs to update on landing
|
||||
|
||||
- `docs/engine-re.md` — `sleep` (`0xc8`) handler decode + time unit; `0x20c` present disposition.
|
||||
- `vm-map/opcodes.toml` — `0xc8` (name/semantics/source), `0x20c` (noop_headless if confirmed); rebuild.
|
||||
- `docs/phase-a-slice-plan.md` — A2b frame-pacing slice result; the honest "AE* now animates" update.
|
||||
- `docs/tools-reference.md` — `--shot-sequence` flag.
|
||||
- Status memory (`himegari-port-status.md`) + `MEMORY.md` line — frame-pacing landed; coroutine still
|
||||
deferred.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Time-unit wrong** → mitigated by Task 1 (RE before implementing) + visual verify.
|
||||
- **GfxState race** → mitigated by the Task 4 audit + immutable snapshot.
|
||||
- **Godot-headless real-time cost** → only `GodotAdvHost` blocks; CLI/headless hosts no-op. A headless
|
||||
Godot play of a sleep-heavy scene takes real wall-clock time, which is acceptable and `--shot`
|
||||
auto-advances; `--selftest` is unaffected.
|
||||
Reference in New Issue
Block a user