Files
OpenMaidEngine/docs/superpowers/specs/2026-07-08-frame-stepped-vm-design.md
gamer147 cdc50b571f docs: frame-stepped-vm spec+plan + frame-cadence Frida probes
Design artifacts for the merged frame-stepped VM work (throttle the Godot VM
to a per-frame op budget). Probes measured the native ~1788 ops/sec cadence
and uncapped D3D9 Present that motivated the wall-clock-op-rate approach.

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

150 lines
9.1 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-stepped VM — design spec
- **Date:** 2026-07-08
- **Status:** design (awaiting review → implementation plan)
- **Area:** `Age.Engine` VM/host seam + Godot frontend
- **Related RE:** `docs/engine-re.md` — "Frame cadence — the interpreter tick" + "Frame cadence — live measurement"; `docs/phase-a-slice-plan.md` — "opening speed-through / engine-cadence"
## Problem
The SC0000 opening **visibly speeds through** in our Godot port. Root cause (confirmed): our VM runs on a
**free-running background thread** (`Task.Run(() => vm.Run())` in `godot/Main.cs`) with no binding to real
time. Only `WaitForInput` and `Sleep` pause it; the main-thread compositor samples `GfxState` at ~60 fps. So
the VM executes a page's opcodes — and any auto-playing stretch — in microseconds, and the compositor only
catches the final state. Distinct visible states that aren't separated by a `sleep`/`wait` collapse.
The native engine is cooperative and **rate-limited**: the interpreter advances a bounded number of opcodes
per main-loop iteration; visible state therefore changes over real wall-clock time. Our fix must give the VM
the same bounded wall-clock execution rate.
## Live measurement (Frida, 2026-07-08 — the basis for the mechanism)
Read-only / import-only instrumentation of the running game (`tools/frida/probe_frame_cadence.py`,
`probe_present.py`; see `docs/engine-re.md` "Frame cadence — live measurement"):
- **Execution is rate-limited, not free-running.** Active interpreter rate ≈ **1,788 operand-fetches/sec**
(peak ~5,796) in normal play — orders of magnitude below an unthrottled interpreter, so the engine is
pacing itself.
- **Fast-forward (Ctrl) scales the rate ~4×** (≈7,738/sec avg, peak ~15,572), gated by the engine's own skip
bit `flags & 0x8000000`. It runs *more ops per unit time*, it does not skip content.
- **Rendering is Direct3D 9, uncapped.** `ddraw.dll` is not loaded; the game renders via `d3d9.dll`.
`IDirect3DDevice9::Present` fires ~**1,908/sec** (no vsync), `BeginScene`/`EndScene` never fire → it's a
**2D StretchRect-style compositor**. So there is **no fixed display-frame cadence** to match; `Present`
rate ≈ op rate (~1 op per present). The meaningful pacing quantity is the **wall-clock op rate**, not a
per-display-frame budget.
**Correction this supersedes:** an earlier draft of this spec used `present` (op `0x20c`) as the frame
boundary. Measurement killed that — `present` occurs only 7× statically / ~2× per opening run in *our*
bytecode path, and the native present is uncapped anyway. The mechanism is now a wall-clock op-rate throttle.
## Goals
1. The VM advances at a bounded **wall-clock execution rate** (~1,800 ops/sec target, tunable) so visible
state changes over real time instead of instantly.
2. Keep the host behind `IHost` and the VM engine-agnostic — pacing lives at the seam + in the Godot host.
3. Preserve byte-identical headless parity (Steps / emitted lines / halt reasons) across all non-Godot hosts
and the existing oracles (`sweep`, `--selftest`, engine tests).
4. Route all timing (the throttle, `sleep`, the anim tween) through **one host-owned clock** so a future
speed multiplier scales everything coherently.
## Non-goals (YAGNI)
- **The Ctrl key / speed multiplier is NOT wired.** Build the single-clock seam so the ~4× multiplier is a
trivial later addition; no key handler, no multiplier value now. When added it must be **ADV-mode-scoped**
(the native governor does not speed up gameplay/menus), so no global-speed assumption.
- **No present/`0x20c` gating** (rare + native present is uncapped), **no transitions/tweens for CG pacing**
(CGs hard-swap; back-to-back loads are layers that correctly composite into one image), **no pull-based VM
rewrite** (background thread stays), **no automated pacing assertion** (validated by the user on the build).
## Design
### Driving model (approach A: background thread + host-timed throttle)
The VM keeps its background thread and its recursive `Run`/`RunFrame`/`Step` unchanged. The only VM change is
**one host call per executed opcode**; all pacing policy lives in the Godot host, mirroring the existing
`WaitForInput`/`Sleep` suspend pattern (VM thread blocks on a host primitive; main-thread `_Process` releases
it).
### Interface change (`IHost`)
Add **one** method:
```csharp
void FrameYield(); // called by the VM after each executed opcode; host paces / no-ops
```
`Sleep(long)` keeps its signature (only its Godot body changes to use the host clock). `present-frame`
(`0x20c`) stays a no-op — it is NOT a gate. No other `IHost` changes.
### VM change
In `RunFrame`, after each `Step`, call `_host.FrameYield()`. That is the whole VM change — the VM does not
know the budget, the rate, or the multiplier; it just offers a yield point per opcode. (If per-op virtual
dispatch shows up in headless profiling, batch to every K ops — parity is unaffected either way.)
### Host-owned scalable clock + op-budget throttle (Godot)
The Godot host owns a single **`FrameClock`** (a pure, Godot-independent class in `Age.Engine/Hosting`, so it
is unit-testable and off the `Vm` seam): monotonic virtual ms + a `Speed` factor (default `1.0`; the future
Ctrl hook). `Advance(realDeltaSeconds)` is called once per `_Process`. All host timing reads only this clock:
- **`FrameYield()` (the throttle):** increment an op counter; when it reaches `opsPerFrame` (the budget,
scaled by `Speed`), block the VM thread until the next `_Process` advances the clock, then reset. So the VM
runs ~`opsPerFrame` ops per real frame ⇒ ~`opsPerFrame × displayFps` ops/sec. Start `opsPerFrame ≈ 30`
(≈1,800 ops/sec at 60 fps) — **tunable**, calibrated by eye.
- **`Sleep(ms)`:** block until the clock advances `ms` (replaces the raw `Thread.Sleep`).
- **anim tween:** reads `FrameClock` instead of Godot's raw wall clock.
Because the throttle, sleeps, and tweens share one clock, a future `Speed = 4` scales all three together — the
~4× fast-forward — with no desync, and the multiplier stays entirely inside the host.
### Concurrency
Same shape as `WaitForInput`: the VM background thread blocks on a host primitive; `_Process` advances the
clock and releases waiters. `GfxState` access is already serialized on its re-entrant lock (sleep-race fix);
the VM now runs concurrently with the compositor for real durations, so that lock discipline is relied upon
(covered by `GfxStateConcurrencyTests`).
## Parity (hard constraint)
- All non-Godot hosts implement `FrameYield()` as an **empty method** and keep `Sleep()` a no-op. The VM never
blocks headless → `Steps`, emitted lines, halt reasons are byte-identical.
- Regression gates unchanged: `Age.Cli sweep` (284 exit / 13 STEP-LIMIT), Godot `--selftest`, engine tests.
- A trace-diff on a synthetic scene proves the added per-op `FrameYield()` (no-op headless) changes nothing
the oracle observes.
## Testing (synthesize, don't disable)
- **`FrameClock` unit tests** — deterministic `Advance` math + budget/deadline crossing at `Speed=1` and other
values; no real sleeping.
- **Throttle gate test with a fake-clock host** — a synthesized scene (`ScriptAssembler`) run through a test
host whose clock is advanced by hand; assert the VM blocks after `opsPerFrame` ops and resumes on the next
clock advance, and that `Speed` scales the budget.
- **Parity trace-diff** — synthetic scene through the no-op headless path == pre-change baseline.
- **Regression** — `sweep` counts unchanged; `--selftest` green.
- **Pacing acceptance** — the user validates the finished live build (tunes `opsPerFrame` by eye).
## Affected components
| File | Change |
|---|---|
| `engine/Age.Engine/Hosting/IHost.cs` | add `void FrameYield()` |
| `engine/Age.Engine/Hosting/FrameClock.cs` | **new** — pure virtual clock (`NowMs`, `Speed`, `Advance`); the op counter + thread-blocking live in the Godot host, not here |
| `engine/Age.Engine/Vm/VirtualMachine.cs` | call `_host.FrameYield()` after each `Step` in `RunFrame` |
| `engine/Age.Engine/Hosting/CaptureHost.cs` + all other non-Godot hosts | add empty `FrameYield()` |
| `godot/GodotAdvHost.cs` | `FrameYield()` = op-budget throttle on `FrameClock`; `Sleep()` blocks on `FrameClock` |
| `godot/Main.cs` | own `FrameClock`; `Advance(delta)` + release waiters each `_Process`; anim tween reads `FrameClock` |
| `engine/Age.Engine.Tests/` | new `FrameClock`, throttle-gate, parity trace-diff tests |
## Risks / open items
- **`opsPerFrame` tuning:** the initial ~30 (≈1,800 ops/sec) comes from live measurement; final value is
dialed in visually. Op *cost* varies (a `call-script` vs a `mov`), so a pure op count is an approximation —
acceptable for pacing, tune by eye.
- **Per-op host call overhead** in headless (empty `FrameYield`): negligible expected; batch to every K ops if
profiling disagrees. Parity holds regardless.
- **Concurrency:** the VM now runs concurrently with the compositor for real durations; relies on the existing
`GfxState` lock — watch for unlocked shared state during validation.
- **Stretch (only if needed):** confirm the 2D-composite model by hooking `StretchRect`/`Clear` — not required
for the fix.