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>
This commit is contained in:
gamer147
2026-07-08 17:34:52 -04:00
parent 2f4527553b
commit cdc50b571f
4 changed files with 943 additions and 0 deletions

View File

@@ -0,0 +1,426 @@
# Frame-stepped VM Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stop the Godot port's VM from outrunning real time (the SC0000 opening "speeds through") by throttling interpreter execution to a bounded wall-clock op rate, so visible state changes over real time like the native engine.
**Architecture:** Keep the VM on its background thread (unchanged). Add ONE host call per executed opcode — `IHost.FrameYield()`. Headless hosts no-op it (byte-identical parity). The Godot host throttles: it counts ops and, once a per-frame budget is reached, blocks the VM thread until the main-thread `_Process` advances a host-owned `FrameClock`. That same clock drives `Sleep` and the anim tween, so a future single `Speed` multiplier (Ctrl fast-forward, NOT built now) scales everything coherently.
**Tech Stack:** C# / .NET 8 (`engine/AgeEngine.sln`), xUnit tests, Godot 4.7 mono (`godot/Himegari.csproj`).
**Spec:** `docs/superpowers/specs/2026-07-08-frame-stepped-vm-design.md` (read it — it has the RE evidence: the native interpreter runs ~1,788 ops/sec normal, ~4× under Ctrl; rendering is uncapped D3D9, so the pacing quantity is a wall-clock op rate, not a display-frame budget).
## Global Constraints
- **Parity is sacred.** All non-Godot hosts must keep headless output byte-identical. Regression gates that MUST stay green unchanged: the engine test suite (`dotnet test engine/AgeEngine.sln`), `Age.Cli sweep` (reports `exit=284, STEP-LIMIT=13`), and the Godot `--selftest` (`SELFTEST OK`). `FrameYield`/`Sleep` are no-ops in every non-Godot host, which is what guarantees this.
- **Seam rule:** `Age.Engine/Vm` may reference only `Model`, `Hosting`, `Diagnostics` — never `Sys4`. `FrameClock` lives in `Age.Engine/Hosting` (used by hosts, not by `Vm`).
- **No Ctrl wiring.** `FrameClock.Speed` stays `1.0` (a field, the future hook). Do NOT add a key handler or change Speed. When eventually wired it must be ADV-mode-scoped — do not bake in a global-speed assumption.
- **Build/run C#:** `dotnet build engine/AgeEngine.sln -c Debug`; `dotnet test engine/AgeEngine.sln`. Godot: `dotnet build godot/Himegari.csproj -c Debug` then the console exe at `S:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe` (or `pwsh run-godot.ps1`).
- **Python (if needed):** `py -3.11 -X utf8 tools/<name>.py`.
- TDD, one deliverable per task, commit at the end of each task. End every commit message with:
`Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`
---
## Task 0: Branch
- [ ] **Step 1: Create the feature branch** (repo root = `age-reimpl/`, git repo; default branch is `main`)
```bash
cd "S:/Game Hacking/Eushully/Himegari/age-reimpl"
git checkout -b feat/frame-stepped-vm
```
---
## File Structure
- `engine/Age.Engine/Hosting/FrameClock.cs`**new**. Pure virtual clock: `NowMs`, `Speed`, `OpsPerFrame`, `Advance()`, `EffectiveBudget`. No threading. Task 1.
- `engine/Age.Engine/Hosting/IHost.cs`**modify**. Add `void FrameYield()`. Task 2.
- `engine/Age.Engine/Vm/VirtualMachine.cs`**modify**. Call `_host.FrameYield()` once per executed opcode. Task 2.
- `engine/Age.Engine/Hosting/CaptureHost.cs` + every other non-Godot `IHost` implementer — **modify**. Add empty `FrameYield()`. Task 2.
- `godot/GodotAdvHost.cs`**modify**. Real `FrameYield()` throttle + `Sleep()` on the clock. Task 3.
- `godot/Main.cs`**modify**. Own a `FrameClock`, advance + pulse it each `_Process`, tween reads it. Task 3.
- `engine/Age.Engine.Tests/FrameClockTests.cs`**new**. Task 1.
- `engine/Age.Engine.Tests/FrameYieldTests.cs`**new**. Task 2.
---
## Task 1: `FrameClock` — pure virtual clock + op budget
**Files:**
- Create: `engine/Age.Engine/Hosting/FrameClock.cs`
- Test: `engine/Age.Engine.Tests/FrameClockTests.cs`
**Interfaces:**
- Produces: `Age.Engine.Hosting.FrameClock` with `long NowMs { get; }`, `double Speed` (field, default 1.0), `int OpsPerFrame` (field, default 30), `void Advance(double realDeltaSeconds)`, `int EffectiveBudget { get; }`.
- [ ] **Step 1: Write the failing test**
Create `engine/Age.Engine.Tests/FrameClockTests.cs`:
```csharp
using Age.Engine.Hosting;
using Xunit;
public class FrameClockTests
{
[Fact]
public void Advance_AtSpeed1_AddsRealMilliseconds()
{
var c = new FrameClock(); // Speed defaults to 1.0
c.Advance(0.016); // one ~60fps frame
Assert.Equal(16, c.NowMs);
}
[Fact]
public void Advance_ScalesBySpeed()
{
var c = new FrameClock { Speed = 4.0 };
c.Advance(0.016);
Assert.Equal(64, c.NowMs); // 4x virtual time
}
[Fact]
public void EffectiveBudget_ScalesBySpeed_AndFloorsAtOne()
{
Assert.Equal(30, new FrameClock { OpsPerFrame = 30, Speed = 1.0 }.EffectiveBudget);
Assert.Equal(120, new FrameClock { OpsPerFrame = 30, Speed = 4.0 }.EffectiveBudget);
Assert.Equal(1, new FrameClock { OpsPerFrame = 0, Speed = 1.0 }.EffectiveBudget);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test engine/AgeEngine.sln --filter FrameClockTests`
Expected: FAIL — `FrameClock` does not exist (compile error).
- [ ] **Step 3: Write minimal implementation**
Create `engine/Age.Engine/Hosting/FrameClock.cs`:
```csharp
namespace Age.Engine.Hosting;
/// <summary>Host-owned virtual clock + per-frame op budget. Pure (no threading): the Godot host
/// advances it once per rendered frame and consults it to pace the VM. The one <see cref="Speed"/>
/// factor is the future (unwired) Ctrl fast-forward multiplier — scaling it scales the throttle
/// budget, sleeps, and the anim tween together. See docs/superpowers/specs/2026-07-08-frame-stepped-vm-design.md.</summary>
public sealed class FrameClock
{
/// <summary>Monotonic virtual time in milliseconds (scaled by Speed).</summary>
public long NowMs { get; private set; }
/// <summary>Speed multiplier. 1.0 = normal. The future Ctrl hook (ADV-scoped); leave at 1.0 for now.</summary>
public double Speed = 1.0;
/// <summary>Base per-frame interpreter op budget (tunable by eye; ~30 ≈ 1,800 ops/sec at 60fps).</summary>
public int OpsPerFrame = 30;
/// <summary>Advance the clock by one rendered frame's real delta (seconds), scaled by Speed.</summary>
public void Advance(double realDeltaSeconds) => NowMs += (long)(realDeltaSeconds * 1000.0 * Speed);
/// <summary>Ops the VM may run before yielding a frame, scaled by Speed (min 1).</summary>
public int EffectiveBudget => System.Math.Max(1, (int)System.Math.Round(OpsPerFrame * Speed));
}
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test engine/AgeEngine.sln --filter FrameClockTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Commit**
```bash
git add engine/Age.Engine/Hosting/FrameClock.cs engine/Age.Engine.Tests/FrameClockTests.cs
git commit -m "feat: add FrameClock (virtual clock + per-frame op budget)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 2: `IHost.FrameYield()` — per-op hook + headless no-ops + parity
**Files:**
- Modify: `engine/Age.Engine/Hosting/IHost.cs`
- Modify: `engine/Age.Engine/Vm/VirtualMachine.cs` (the `RunFrame` loop, ~lines 119-128)
- Modify: `engine/Age.Engine/Hosting/CaptureHost.cs` and every other non-Godot `IHost` implementer
- Test: `engine/Age.Engine.Tests/FrameYieldTests.cs`
**Interfaces:**
- Consumes: nothing from Task 1 yet (the Godot host uses `FrameClock` in Task 3).
- Produces: `IHost.FrameYield()` (called by the VM exactly once per executed opcode, including the halting/returning op). Headless implementers make it a no-op.
- [ ] **Step 1: Find every `IHost` implementer** (so the build won't break)
Run: `py -3.11 -X utf8 -c "import subprocess"` is not needed — just grep:
Use the Grep tool for `: IHost` and `IHost` across `engine/` — expected implementers to edit in this task:
`engine/Age.Engine/Hosting/CaptureHost.cs`, the test hosts in `engine/Age.Engine.Tests/` (e.g. recording/counting hosts used by existing tests), and the CLI hosts in `engine/Age.Cli/Program.cs` (`AudioTraceHost`, `GfxTraceHost`). **`godot/GodotAdvHost.cs` is handled in Task 3** (separate project, not in `AgeEngine.sln`).
- [ ] **Step 2: Write the failing test**
Create `engine/Age.Engine.Tests/FrameYieldTests.cs`. This asserts the VM calls `FrameYield` exactly once per step (`Steps`), proving the per-op wiring, using a tiny counting host. It loads a real boot script that halts cleanly.
```csharp
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class FrameYieldTests
{
private sealed class CountingHost : IHost
{
public long Yields;
public void FrameYield() => Yields++;
public void ShowText(int offset, string text) { }
public void WaitForInput() { }
public void Sleep(long duration) { }
public void CreateTexture(int slot, int width, int height) { }
public void SetTexture(long resourceId, int slot) { }
public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { }
public (int Width, int Height) GetTextureSize(int slot) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}
[Fact]
public void FrameYield_CalledOncePerStep()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = Sys4Loader.Load(Paths.Scripts()["INITCONFIG.BIN"], table);
var host = new CountingHost();
var vm = new VirtualMachine(script, table, host);
vm.Run();
Assert.True(vm.Steps > 0);
Assert.Equal(vm.Steps, host.Yields); // exactly one FrameYield per executed opcode
}
}
```
- [ ] **Step 3: Run test to verify it fails**
Run: `dotnet test engine/AgeEngine.sln --filter FrameYieldTests`
Expected: FAIL — `IHost` has no `FrameYield` (compile error in the host), and the VM does not call it.
- [ ] **Step 4: Add `FrameYield` to the interface**
In `engine/Age.Engine/Hosting/IHost.cs`, add the method (place it next to `WaitForInput`/`Sleep`):
```csharp
void FrameYield();
```
- [ ] **Step 5: Call it once per opcode in the VM**
In `engine/Age.Engine/Vm/VirtualMachine.cs`, the `RunFrame` loop currently reads (around lines 119-128):
```csharp
while (pc >= 0 && pc < frame.Script.Instructions.Count)
{
if (Steps >= _o.MaxSteps) { HaltReason ??= "STEP-LIMIT"; outcome = FrameOutcome.Halted; break; }
Steps++;
if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth));
int next = Step(frame.Script.Instructions[pc], pc);
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
if (next == HALT) { outcome = FrameOutcome.Halted; break; }
pc = next;
}
```
Add `_host.FrameYield();` immediately after the `Step(...)` call:
```csharp
int next = Step(frame.Script.Instructions[pc], pc);
_host.FrameYield();
if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; }
```
(`_host` is the existing `IHost` field the VM already dispatches `ShowText`/`Sleep`/etc. through.)
- [ ] **Step 6: Add empty `FrameYield()` to every non-Godot host**
In each implementer found in Step 1, add:
```csharp
public void FrameYield() { }
```
Concretely: `engine/Age.Engine/Hosting/CaptureHost.cs`; each test host class in `engine/Age.Engine.Tests/`; and `AudioTraceHost` + `GfxTraceHost` in `engine/Age.Cli/Program.cs`. (The `CountingHost` in Step 2 already has its own.)
- [ ] **Step 7: Run the new test — verify it passes**
Run: `dotnet test engine/AgeEngine.sln --filter FrameYieldTests`
Expected: PASS.
- [ ] **Step 8: Run the FULL suite — verify parity (nothing else changed)**
Run: `dotnet test engine/AgeEngine.sln`
Expected: PASS — all pre-existing tests still green (FrameYield is a no-op everywhere headless, so traces/steps/emitted are byte-identical).
- [ ] **Step 9: Verify the corpus oracle is unchanged**
Run: `dotnet run --project engine/Age.Cli -c Debug -- sweep`
Expected: halt distribution includes `exit=284` and `STEP-LIMIT=13` (unchanged from before this task).
- [ ] **Step 10: Commit**
```bash
git add engine/Age.Engine/Hosting/IHost.cs engine/Age.Engine/Vm/VirtualMachine.cs engine/Age.Engine/Hosting/CaptureHost.cs engine/Age.Cli/Program.cs engine/Age.Engine.Tests/FrameYieldTests.cs
# also add any test-host files you edited under engine/Age.Engine.Tests/
git commit -m "feat: add IHost.FrameYield per-opcode hook (no-op headless, parity held)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Task 3: Godot host throttle + Sleep/tween on the clock
**Files:**
- Modify: `godot/GodotAdvHost.cs`
- Modify: `godot/Main.cs`
**Interfaces:**
- Consumes: `Age.Engine.Hosting.FrameClock` (Task 1); `IHost.FrameYield` (Task 2).
- Produces: `GodotAdvHost` throttled to `FrameClock.EffectiveBudget` ops per rendered frame; `GodotAdvHost.PulseFrame()` (called by `Main._Process` each frame).
This task changes only the Godot project, which builds separately (`godot/Himegari.csproj`) and is verified by the Godot `--selftest` + a manual windowed run (not the xUnit suite).
- [ ] **Step 1: Add the FrameClock + throttle to `GodotAdvHost`**
In `godot/GodotAdvHost.cs`:
(a) Add fields (near the existing `_gate`):
```csharp
private readonly Age.Engine.Hosting.FrameClock _clock;
private readonly System.Threading.AutoResetEvent _frameSignal = new(false);
private int _opsSinceYield;
```
(b) Change the constructor to accept the clock:
```csharp
public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock)
{
_main = main; _res = res; _scene = scene; _clock = clock;
}
```
(c) Add the per-frame pulse (called from `Main._Process`) and the throttle. Add these methods:
```csharp
// Main thread, once per rendered frame: releases a VM thread parked in FrameYield/Sleep.
public void PulseFrame() => _frameSignal.Set();
// Called once per executed opcode (IHost.FrameYield). After a frame's worth of ops (the clock's
// budget), block the VM background thread until Main._Process advances the clock — throttling the
// interpreter to ~budget ops per rendered frame (the native engine's rate-limited cadence).
public void FrameYield()
{
if (++_opsSinceYield < _clock.EffectiveBudget) return;
_opsSinceYield = 0;
long start = _clock.NowMs;
while (_clock.NowMs == start) // wait until a real _Process advanced the clock
if (!_frameSignal.WaitOne(50)) break; // 50ms safety cap: never hang if _Process stalls
}
```
(d) Replace the existing `Sleep` body so it waits on the clock (unified timebase) instead of `Thread.Sleep`:
```csharp
public double SleepScale = 1.0; // --sleep-scale <f>: debug multiplier (kept)
public void Sleep(long duration)
{
long ms = (long)System.Math.Clamp(duration * SleepScale, 0, 60_000);
long deadline = _clock.NowMs + ms;
while (_clock.NowMs < deadline)
if (!_frameSignal.WaitOne(2000)) break; // safety cap
}
```
- [ ] **Step 2: Own + drive the FrameClock in `Main`**
In `godot/Main.cs`:
(a) Add a field (near the other Main fields, ~line 23):
```csharp
private readonly Age.Engine.Hosting.FrameClock _clock = new();
```
(b) Pass it when constructing the host. The current line (~122) reads:
```csharp
_host = new GodotAdvHost(this, ResourceMap.Load(), scene) { SleepScale = sleepScale };
```
Change to:
```csharp
_host = new GodotAdvHost(this, ResourceMap.Load(), scene, _clock) { SleepScale = sleepScale };
```
(c) In `_Process(double delta)`, right after the existing `_lastDelta = delta;` (~line 158), advance the clock and pulse the frame:
```csharp
_lastDelta = delta;
_clock.Advance(delta);
_host?.PulseFrame();
```
(d) Make the anim tween use the clock's (Speed-scaled) time so a future Speed multiplier scales it too. In `AlphaFor` (~line 281), change:
```csharp
tw.Elapsed += _lastDelta;
```
to:
```csharp
tw.Elapsed += _lastDelta * _clock.Speed; // Speed==1 now => identical; future Ctrl scales the tween
```
- [ ] **Step 3: Build the Godot project**
Run: `dotnet build godot/Himegari.csproj -c Debug --nologo -v q`
Expected: `Build succeeded. 0 Error(s)`.
- [ ] **Step 4: Verify parity via the headless self-test**
Run: `& "S:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe" --headless --path godot -- --selftest`
Expected: output ends with `SELFTEST OK` (the synthetic scene still produces identical lines; the throttle changes timing, not output).
- [ ] **Step 5: Manual visual check (acceptance — user-facing)**
Run windowed with boot state:
`& "S:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe" --path godot -- --boot`
Expected: the SC0000 opening now advances at a readable pace (no instant speed-through). If it's too slow or too fast, tune `FrameClock.OpsPerFrame` (default 30; higher = faster). This is the eyeball calibration step — the user validates and picks the final `OpsPerFrame`.
- [ ] **Step 6: Commit**
```bash
git add godot/GodotAdvHost.cs godot/Main.cs
git commit -m "feat: throttle Godot VM to a per-frame op budget on FrameClock (fixes opening speed-through)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
```
---
## Notes for the executor
- **If per-op `FrameYield()` overhead shows up** in a slow `dotnet test`/`sweep`, batch it: keep a counter in the VM and call `_host.FrameYield()` every K ops (e.g. 4). Parity is unaffected (still no-op headless); only the Godot throttle granularity coarsens. Not expected to be necessary.
- **Do NOT** wire Ctrl / change `Speed`. That's a separate future slice (needs the ADV-scope RE); the seam is ready for it.
- **Stretch (only if the opening still looks wrong after tuning):** confirm the 2D-composite assumption by hooking `IDirect3DDevice9::StretchRect`/`Clear` (extend `tools/frida/probe_present.py`). Not required for this plan.
## Self-review (done while writing)
- **Spec coverage:** driving model A → Task 3 (thread kept); `IHost.FrameYield` → Task 2; op-budget throttle on host clock → Tasks 1+3; `Sleep` on clock + tween on clock → Task 3; single `Speed` hook (no Ctrl wiring) → FrameClock (Task 1) + tween (Task 3); parity via headless no-op → Task 2 Steps 8-9; present stays no-op → unchanged (no task touches `0x20c`, correct). All covered.
- **Placeholder scan:** all code blocks are complete; the only "find these files" step (Task 2 Step 1) is a grep with the expected file list given.
- **Type consistency:** `FrameClock.NowMs/Speed/OpsPerFrame/Advance/EffectiveBudget` used identically in Tasks 1 and 3; `FrameYield()` signature identical in IHost (Task 2) and GodotAdvHost (Task 3); `PulseFrame()` defined in Task 3 Step 1c and called in Step 2c.

View File

@@ -0,0 +1,149 @@
# 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.