diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index 18da9da..c8de47b 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -317,6 +317,7 @@ sealed class AudioTraceHost : IHost public void ShowText(int offset, string text) { } public void WaitForInput() { } public void Sleep(long duration) { } + public void FrameYield() { } public void CreateTexture(int slot, int width, int height) { } public void SetTexture(long resourceId, int slot) { } public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { } @@ -366,6 +367,7 @@ sealed class GfxTraceHost : IHost public void ShowText(int offset, string text) { } public void WaitForInput() { } public void Sleep(long duration) { } + public void FrameYield() { } public void PlayBgm(long id) { } public void PlayVoice(long id) { } } diff --git a/engine/Age.Engine.Tests/CallScriptIntegrationTests.cs b/engine/Age.Engine.Tests/CallScriptIntegrationTests.cs index 9e9ebe8..bd37d21 100644 --- a/engine/Age.Engine.Tests/CallScriptIntegrationTests.cs +++ b/engine/Age.Engine.Tests/CallScriptIntegrationTests.cs @@ -10,6 +10,7 @@ public class CallScriptIntegrationTests public void ShowText(int o, string t) { } public void WaitForInput() { } public void Sleep(long duration) { } + public void FrameYield() { } public void CreateTexture(int s, int w, int h) { } public void SetTexture(long r, int s) { } public void DrawTexture(int s, int sx, int sy, int w, int h, int dx, int dy) { } diff --git a/engine/Age.Engine.Tests/CallScriptTests.cs b/engine/Age.Engine.Tests/CallScriptTests.cs index 07ef8bb..7ef898a 100644 --- a/engine/Age.Engine.Tests/CallScriptTests.cs +++ b/engine/Age.Engine.Tests/CallScriptTests.cs @@ -16,6 +16,7 @@ public class CallScriptTests public void ShowText(int o, string t) { } public void WaitForInput() { } public void Sleep(long duration) { } + public void FrameYield() { } public void CreateTexture(int s, int w, int h) { } public void SetTexture(long r, int s) { } public void DrawTexture(int s, int sx, int sy, int w, int h, int dx, int dy) { } diff --git a/engine/Age.Engine.Tests/FrameClockTests.cs b/engine/Age.Engine.Tests/FrameClockTests.cs new file mode 100644 index 0000000..6a30797 --- /dev/null +++ b/engine/Age.Engine.Tests/FrameClockTests.cs @@ -0,0 +1,29 @@ +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); + } +} diff --git a/engine/Age.Engine.Tests/FrameYieldTests.cs b/engine/Age.Engine.Tests/FrameYieldTests.cs new file mode 100644 index 0000000..4622a15 --- /dev/null +++ b/engine/Age.Engine.Tests/FrameYieldTests.cs @@ -0,0 +1,35 @@ +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 + } +} diff --git a/engine/Age.Engine.Tests/GameSessionTests.cs b/engine/Age.Engine.Tests/GameSessionTests.cs index a7b9d12..5cffe71 100644 --- a/engine/Age.Engine.Tests/GameSessionTests.cs +++ b/engine/Age.Engine.Tests/GameSessionTests.cs @@ -30,6 +30,7 @@ public class GameSessionTests public void ShowText(int o, string t) => Emitted.Add(o); public void WaitForInput() { } public void Sleep(long duration) { } + public void FrameYield() { } public void CreateTexture(int s, int w, int h) { } public void SetTexture(long r, int s) { } public void DrawTexture(int s, int sx, int sy, int w, int h, int dx, int dy) { } diff --git a/engine/Age.Engine.Tests/TestSupport.cs b/engine/Age.Engine.Tests/TestSupport.cs index 5eb70e4..f0be7ec 100644 --- a/engine/Age.Engine.Tests/TestSupport.cs +++ b/engine/Age.Engine.Tests/TestSupport.cs @@ -14,6 +14,7 @@ internal sealed class RecordingHost : IHost public void ShowText(int offset, string text) => Lines.Add((offset, text)); public void WaitForInput() => Waits++; public void Sleep(long duration) => SleptDurations.Add(duration); + public void FrameYield() { } public void CreateTexture(int slot, int w, int h) { } public void SetTexture(long resId, int slot) { } public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { } diff --git a/engine/Age.Engine.Tests/TextureGeometryTests.cs b/engine/Age.Engine.Tests/TextureGeometryTests.cs index 19dc63a..5d1773b 100644 --- a/engine/Age.Engine.Tests/TextureGeometryTests.cs +++ b/engine/Age.Engine.Tests/TextureGeometryTests.cs @@ -12,6 +12,7 @@ public class TextureGeometryTests public void ShowText(int o, string t) { } public void WaitForInput() { } public void Sleep(long duration) { } + public void FrameYield() { } public void CreateTexture(int slot, int w, int h) { } public void SetTexture(long resId, int slot) { } public void DrawTexture(int slot, int sx, int sy, int w, int h, int dx, int dy) { } diff --git a/engine/Age.Engine.Tests/TextureOpsTests.cs b/engine/Age.Engine.Tests/TextureOpsTests.cs index ed61920..96e16e6 100644 --- a/engine/Age.Engine.Tests/TextureOpsTests.cs +++ b/engine/Age.Engine.Tests/TextureOpsTests.cs @@ -14,6 +14,7 @@ public class TextureOpsTests public void ShowText(int o, string t) { } public void WaitForInput() { } public void Sleep(long duration) { } + public void FrameYield() { } public void CreateTexture(int slot, int w, int h) => Creates++; public void SetTexture(long resId, int slot) => Sets.Add((resId, slot)); public void DrawTexture(int slot, int srcX, int srcY, int w, int h, int dstX, int dstY) => Draws.Add((slot, w, h)); diff --git a/engine/Age.Engine/Hosting/CaptureHost.cs b/engine/Age.Engine/Hosting/CaptureHost.cs index 42a5c48..da651fa 100644 --- a/engine/Age.Engine/Hosting/CaptureHost.cs +++ b/engine/Age.Engine/Hosting/CaptureHost.cs @@ -5,6 +5,7 @@ public sealed class CaptureHost : IHost public void ShowText(int offset, string text) => Emitted.Add((offset, text)); public void WaitForInput() { } public void Sleep(long duration) { } + public void FrameYield() { } public void CreateTexture(int slot, int width, int height) { } public void SetTexture(long resourceId, int slot) { } public void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY) { } diff --git a/engine/Age.Engine/Hosting/FrameClock.cs b/engine/Age.Engine/Hosting/FrameClock.cs new file mode 100644 index 0000000..3ff0433 --- /dev/null +++ b/engine/Age.Engine/Hosting/FrameClock.cs @@ -0,0 +1,23 @@ +namespace Age.Engine.Hosting; + +/// 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 +/// 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. +public sealed class FrameClock +{ + /// Monotonic virtual time in milliseconds (scaled by Speed). + public long NowMs { get; private set; } + + /// Speed multiplier. 1.0 = normal. The future Ctrl hook (ADV-scoped); leave at 1.0 for now. + public double Speed = 1.0; + + /// Base per-frame interpreter op budget (tunable by eye; ~30 ≈ 1,800 ops/sec at 60fps). + public int OpsPerFrame = 30; + + /// Advance the clock by one rendered frame's real delta (seconds), scaled by Speed. + public void Advance(double realDeltaSeconds) => NowMs += (long)(realDeltaSeconds * 1000.0 * Speed); + + /// Ops the VM may run before yielding a frame, scaled by Speed (min 1). + public int EffectiveBudget => System.Math.Max(1, (int)System.Math.Round(OpsPerFrame * Speed)); +} diff --git a/engine/Age.Engine/Hosting/IHost.cs b/engine/Age.Engine/Hosting/IHost.cs index 55022b1..d50e128 100644 --- a/engine/Age.Engine/Hosting/IHost.cs +++ b/engine/Age.Engine/Hosting/IHost.cs @@ -4,6 +4,7 @@ public interface IHost void ShowText(int offset, string text); void WaitForInput(); void Sleep(long duration); + void FrameYield(); void CreateTexture(int slot, int width, int height); void SetTexture(long resourceId, int slot); void DrawTexture(int slot, int srcX, int srcY, int width, int height, int dstX, int dstY); diff --git a/engine/Age.Engine/Vm/VirtualMachine.cs b/engine/Age.Engine/Vm/VirtualMachine.cs index fd213a3..cf85f3d 100644 --- a/engine/Age.Engine/Vm/VirtualMachine.cs +++ b/engine/Age.Engine/Vm/VirtualMachine.cs @@ -122,6 +122,7 @@ public sealed class VirtualMachine Steps++; if (_sink.TracingSteps) _sink.Emit(TraceEvent.Step(pc, frame.Script.Instructions[pc], _depth)); int next = Step(frame.Script.Instructions[pc], pc); + _host.FrameYield(); if (next == FRAME_RETURN) { outcome = FrameOutcome.Returned; break; } if (next == HALT) { outcome = FrameOutcome.Halted; break; } pc = next; diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index ca6b621..4c5ac8d 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -13,12 +13,15 @@ public sealed class GodotAdvHost : IHost // the single-scene harness skips; seed it so the first CG's anchor math stays correct (not 0x0). private readonly Dictionary _slotDims = new() { { 0, (800, 600) } }; private readonly SemaphoreSlim _gate = new(0, 1); + private readonly Age.Engine.Hosting.FrameClock _clock; + private readonly System.Threading.AutoResetEvent _frameSignal = new(false); + private int _opsSinceYield; public volatile bool IsWaiting; public readonly List<(int Offset, string Text)> Captured = new(); - public GodotAdvHost(Main main, ResourceMap res, string scene) + public GodotAdvHost(Main main, ResourceMap res, string scene, Age.Engine.Hosting.FrameClock clock) { - _main = main; _res = res; _scene = scene; + _main = main; _res = res; _scene = scene; _clock = clock; } public void ShowText(int offset, string text) @@ -42,16 +45,35 @@ public sealed class GodotAdvHost : IHost // called from the main thread (click) or the selftest auto-clicker public void SignalInput() { if (_gate.CurrentCount == 0) _gate.Release(); } + // 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 + } + // op 0xc8: block the VM background thread so the main-thread compositor (Main.Recomposite in _Process) // presents the current retained GfxState — this is what makes the sleep-paced opening burst animate. // Time-based sibling of WaitForInput's suspend. The native op arms a non-blocking main-loop-polled timer; // blocking this throwaway task thread is behaviorally equivalent given our threading model. Operand is // MILLISECONDS (docs/engine-re.md sleep section + opcodes.toml 0xc8). Headless CLI hosts no-op it (parity). public double SleepScale = 1.0; // --sleep-scale : debug multiplier to slow/speed the paced opening for inspection + // Wait on the unified FrameClock timebase (not Thread.Sleep) so a future Speed multiplier scales + // sleeps together with the throttle and the tween. Main._Process advances the clock + pulses each frame. public void Sleep(long duration) { - int ms = (int)System.Math.Clamp(duration * SleepScale, 0, 60_000); // cap so a pathological script can't hang the window - if (ms > 0) Thread.Sleep(ms); + long ms = (long)System.Math.Clamp(duration * SleepScale, 0, 60_000); // cap so a pathological script can't hang the window + long deadline = _clock.NowMs + ms; + while (_clock.NowMs < deadline) + if (!_frameSignal.WaitOne(2000)) break; // safety cap } // ---- texture ops (run on the VM thread; marshal Godot node work to the main thread) ---- diff --git a/godot/Main.cs b/godot/Main.cs index 7846a39..df495dc 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -19,6 +19,7 @@ public partial class Main : Godot.Control private AudioStreamPlayer _voice = null!; // interrupt-on-new voice private VirtualMachine _vm = null!; private GodotAdvHost _host = null!; + private readonly Age.Engine.Hosting.FrameClock _clock = new(); private GodotTraceSink _trace = null!; private Age.Engine.Diagnostics.HistogramTraceSink? _hist; // --trace-histogram: profile the real run private string? _histFile; @@ -119,7 +120,7 @@ public partial class Main : Godot.Control IScriptProvider provider; if (_selftest) (script, provider) = BuildSelfTestScene(table); else { script = Sys4Loader.Load(Paths.Scripts()[scene.ToUpperInvariant() + ".BIN"], table); provider = Sys4ScriptProvider.Load(table); } - _host = new GodotAdvHost(this, ResourceMap.Load(), scene) { SleepScale = sleepScale }; + _host = new GodotAdvHost(this, ResourceMap.Load(), scene, _clock) { SleepScale = sleepScale }; _trace = new GodotTraceSink(); // --trace-histogram: aggregate op/call-site execution counts of the REAL Godot run (headless flow // diverges — wait-for-input is a no-op there — so this is the only way to profile the live path). @@ -156,6 +157,8 @@ public partial class Main : Godot.Control public override void _Process(double delta) { _lastDelta = delta; + _clock.Advance(delta); + _host?.PulseFrame(); if (!_selftest && _vm != null) Recomposite(); // retained per-frame compositor (surface+object model) // --shot-sequence: dump one PNG per frame across the opening so a time-based (paced) effect can be // verified as distinct frames, not just the final state. Captures after Recomposite; quits when full. @@ -278,7 +281,7 @@ public partial class Main : Godot.Control tw.TargetA = targetA; tw.Initialized = true; } - tw.Elapsed += _lastDelta; + tw.Elapsed += _lastDelta * _clock.Speed; // Speed==1 now => identical; future Ctrl scales the tween double p = tw.Duration > 0 ? System.Math.Clamp(tw.Elapsed / tw.Duration, 0, 1) : 1; tw.CurrentA = tw.StartA + (tw.TargetA - tw.StartA) * p; return (float)tw.CurrentA;