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>
8.3 KiB
Frame-paced sleep — the SC0000 opening animates
⚠ OUTCOME CORRECTION (2026-07-08): this spec's premise — that bare
sleeppaces the opening burst, so implementing it would make the opening animate — was wrong.sleepis 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, theG[0xaba5c]==1re-entry gate, and thelabel_125bdslot-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 (
0x204draw-string,0x7a) — text already renders viaIHost.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
IHostgainsvoid Sleep(int milliseconds).VirtualMachinegainscase "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 gatedStubtrace event for0xc8is replaced by the op executing. No golden regen. - The 9 non-Godot hosts (
CaptureHost+ test doublesRecHost/CountHost/etc.) implementSleepas a no-op. Headless and everyAge.Clipath stay instant →sweep284 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
GfxStatemutator (GetOrCreate,draw-texturebind,SetSurface, the anim ops,Register/Release,EraseRange,CurrentObjectset) and the readerSnapshotVisibleObjects()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 asleepop, run against a recording host that assertsSleepwas 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);--selftestunchanged (synthetic selftest scene has nosleep). - Visual verify:
--shot-sequenceframe set shows the openingAE*burst animating. - Completeness gauge:
tools/scene_opcode_coverage.py SC0000—0xc8moves GAP→impl;0x20clikely GAP→safe-noop. Record the GAP delta.
Docs to update on landing
docs/engine-re.md—sleep(0xc8) handler decode + time unit;0x20cpresent 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-sequenceflag.- Status memory (
himegari-port-status.md) +MEMORY.mdline — 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
GodotAdvHostblocks; CLI/headless hosts no-op. A headless Godot play of a sleep-heavy scene takes real wall-clock time, which is acceptable and--shotauto-advances;--selftestis unaffected.