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>
19 KiB
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(reportsexit=284, STEP-LIMIT=13), and the Godot--selftest(SELFTEST OK).FrameYield/Sleepare no-ops in every non-Godot host, which is what guarantees this. - Seam rule:
Age.Engine/Vmmay reference onlyModel,Hosting,Diagnostics— neverSys4.FrameClocklives inAge.Engine/Hosting(used by hosts, not byVm). - No Ctrl wiring.
FrameClock.Speedstays1.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 Debugthen the console exe atS:\Godot\Godot_v4.7-stable_mono_win64\Godot_v4.7-stable_mono_win64_console.exe(orpwsh 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 ismain)
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. Addvoid 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-GodotIHostimplementer — modify. Add emptyFrameYield(). Task 2.godot/GodotAdvHost.cs— modify. RealFrameYield()throttle +Sleep()on the clock. Task 3.godot/Main.cs— modify. Own aFrameClock, 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.FrameClockwithlong 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:
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:
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
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(theRunFrameloop, ~lines 119-128) - Modify:
engine/Age.Engine/Hosting/CaptureHost.csand every other non-GodotIHostimplementer - Test:
engine/Age.Engine.Tests/FrameYieldTests.cs
Interfaces:
-
Consumes: nothing from Task 1 yet (the Godot host uses
FrameClockin 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
IHostimplementer (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.
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
FrameYieldto the interface
In engine/Age.Engine/Hosting/IHost.cs, add the method (place it next to WaitForInput/Sleep):
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):
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:
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:
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
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:
GodotAdvHostthrottled toFrameClock.EffectiveBudgetops per rendered frame;GodotAdvHost.PulseFrame()(called byMain._Processeach 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):
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:
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:
// 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:
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):
private readonly Age.Engine.Hosting.FrameClock _clock = new();
(b) Pass it when constructing the host. The current line (~122) reads:
_host = new GodotAdvHost(this, ResourceMap.Load(), scene) { SleepScale = sleepScale };
Change to:
_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:
_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:
tw.Elapsed += _lastDelta;
to:
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
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 slowdotnet 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(extendtools/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;Sleepon clock + tween on clock → Task 3; singleSpeedhook (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 touches0x20c, 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/EffectiveBudgetused 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.