Files
OpenMaidEngine/docs/superpowers/specs/2026-07-08-frame-paced-sleep-design.md
gamer147 37fbbd278d docs: correct the overstated 'opening animates' claim across all docs
The frame-paced-sleep slice did NOT make the opening burst animate (only the
one-shot dramatic pauses). Correct the canonical result (phase-a-slice-plan),
the RE doc (engine-re), the opcode source+generated ref (opcodes.toml 0xc8),
and add correction banners to the point-in-time spec/plan. Also folds in the
diagnostics + headless halt-at-wait results into phase-a-slice-plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 10:53:08 -04:00

141 lines
8.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.
# Frame-paced `sleep` — the SC0000 opening animates
> **⚠ OUTCOME CORRECTION (2026-07-08):** this spec's premise — that bare `sleep` paces the opening burst, so
> implementing it would make the opening animate — was **wrong**. `sleep` is correctly implemented and the
> one-shot dramatic pauses work, but the rapid CG/AE\* burst is not sleep-paced (trace shows no sleep between the
> draws) and did not animate. Canonical corrected result: `docs/phase-a-slice-plan.md` (A2b frame-paced sleep).
> Kept as-authored below for provenance.
**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.