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:
gamer147
2026-07-08 08:44:27 -04:00
parent 87d6bf136b
commit 2a54a5bb86

View 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.