From 3d263a289a4715816cb47d7bc799d6a532fe6fde Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 8 Jul 2026 08:44:27 -0400 Subject: [PATCH] =?UTF-8?q?docs(gfx):=20spec=20=E2=80=94=20frame-paced=20s?= =?UTF-8?q?leep=20so=20the=20SC0000=20opening=20animates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .../2026-07-08-frame-paced-sleep-design.md | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-08-frame-paced-sleep-design.md diff --git a/docs/superpowers/specs/2026-07-08-frame-paced-sleep-design.md b/docs/superpowers/specs/2026-07-08-frame-paced-sleep-design.md new file mode 100644 index 0000000..0298b0c --- /dev/null +++ b/docs/superpowers/specs/2026-07-08-frame-paced-sleep-design.md @@ -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 ` 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 [--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.