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>
9.1 KiB
Frame-stepped VM — design spec
- Date: 2026-07-08
- Status: design (awaiting review → implementation plan)
- Area:
Age.EngineVM/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.dllis not loaded; the game renders viad3d9.dll.IDirect3DDevice9::Presentfires ~1,908/sec (no vsync),BeginScene/EndScenenever fire → it's a 2D StretchRect-style compositor. So there is no fixed display-frame cadence to match;Presentrate ≈ 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
- 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.
- Keep the host behind
IHostand the VM engine-agnostic — pacing lives at the seam + in the Godot host. - Preserve byte-identical headless parity (Steps / emitted lines / halt reasons) across all non-Godot hosts
and the existing oracles (
sweep,--selftest, engine tests). - 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/
0x20cgating (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:
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 reachesopsPerFrame(the budget, scaled bySpeed), block the VM thread until the next_Processadvances the clock, then reset. So the VM runs ~opsPerFrameops per real frame ⇒ ~opsPerFrame × displayFpsops/sec. StartopsPerFrame ≈ 30(≈1,800 ops/sec at 60 fps) — tunable, calibrated by eye.Sleep(ms): block until the clock advancesms(replaces the rawThread.Sleep).- anim tween: reads
FrameClockinstead 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 keepSleep()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)
FrameClockunit tests — deterministicAdvancemath + budget/deadline crossing atSpeed=1and 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 afteropsPerFrameops and resumes on the next clock advance, and thatSpeedscales the budget. - Parity trace-diff — synthetic scene through the no-op headless path == pre-change baseline.
- Regression —
sweepcounts unchanged;--selftestgreen. - Pacing acceptance — the user validates the finished live build (tunes
opsPerFrameby 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
opsPerFrametuning: the initial ~30 (≈1,800 ops/sec) comes from live measurement; final value is dialed in visually. Op cost varies (acall-scriptvs amov), 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
GfxStatelock — 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.