feat(vm): IHost.Sleep seam + dispatch 0xc8 (headless hosts no-op; parity held)

- IHost.Sleep(long duration); VM case "sleep" forwards the raw operand.
- 8 non-Godot hosts no-op Sleep; RecordingHost records it.
- SleepDispatchTests (51 green); sweep unchanged 284 exit / 13 STEP-LIMIT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-08 08:59:52 -04:00
parent 6044a0d412
commit 5f245d4cf4
16 changed files with 407 additions and 0 deletions

View File

@@ -250,6 +250,7 @@ sealed class AudioTraceHost : IHost
}
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 srcX, int srcY, int width, int height, int dstX, int dstY) { }
@@ -298,6 +299,7 @@ sealed class GfxTraceHost : IHost
}
public void ShowText(int offset, string text) { }
public void WaitForInput() { }
public void Sleep(long duration) { }
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}

View File

@@ -9,6 +9,7 @@ public class CallScriptIntegrationTests
{
public void ShowText(int o, string t) { }
public void WaitForInput() { }
public void Sleep(long duration) { }
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) { }

View File

@@ -0,0 +1,49 @@
using Age.Engine.Hosting;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class CallScriptIntegrationTests
{
private sealed class NullHost : IHost
{
public void ShowText(int o, string t) { }
public void WaitForInput() { }
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) { }
public (int Width, int Height) GetTextureSize(int s) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}
[Fact]
public void RealScriptExecutesRealSubroutinesAndReturns()
{
// ADDILL.BIN unconditionally call-scripts ADDILLSUB then CALCREVISE at entry, then exits.
// With execution on, both subroutines load, run, and return, so ADDILL reaches its own exit.
var t = OpcodeTableJson.Load(Paths.OpcodesJson);
var provider = Sys4ScriptProvider.Load(t);
var script = Sys4Loader.Load(Paths.Scripts()["ADDILL.BIN"], t);
var host = new NullHost();
var vm = new VirtualMachine(script, t, host, null, provider);
vm.Run();
Assert.Equal(2, vm.CallScriptDispatches); // ADDILLSUB + CALCREVISE both dispatched
Assert.Equal("exit", vm.HaltReason); // subroutines returned; ADDILL reached its own exit
}
[Fact]
public void BunkiTopLevelRetReturnsCleanlyAsSubroutine()
{
// BUNKI.BIN ends with a top-level `ret` (empty intra-call stack). Called as a subroutine it
// must return to the caller, not underflow-halt. Drive it directly.
var t = OpcodeTableJson.Load(Paths.OpcodesJson);
var provider = Sys4ScriptProvider.Load(t);
var bunki = provider.GetById(0x143); // BUNKI.BIN
Assert.NotNull(bunki);
var vm = new VirtualMachine(bunki!, t, new NullHost(), null, provider);
vm.Run();
// Reaching a frame-return at the top = clean "exit"; never "ret-underflow".
Assert.NotEqual("ret-underflow", vm.HaltReason);
}
}

View File

@@ -15,6 +15,7 @@ public class CallScriptTests
{
public void ShowText(int o, string t) { }
public void WaitForInput() { }
public void Sleep(long duration) { }
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) { }

View File

@@ -0,0 +1,106 @@
using System.Collections.Generic;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class CallScriptTests
{
// Opcodes (from build/opcodes.json): exit=0x2, call-script=0x3(argc1), mov=0x55(argc2).
// Operand types: imm=0, global-int=3, local-int=9.
private const uint OP_EXIT = 0x2, OP_CALLSCRIPT = 0x3, OP_MOV = 0x55;
private sealed class NullHost : IHost
{
public void ShowText(int o, string t) { }
public void WaitForInput() { }
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) { }
public (int Width, int Height) GetTextureSize(int s) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}
private sealed class MapProvider : IScriptProvider
{
private readonly Dictionary<long, Script> _m;
public MapProvider(Dictionary<long, Script> m) => _m = m;
public Script? GetById(long id) => _m.TryGetValue(id, out var s) ? s : null;
}
// Build a Script from raw dwords via the real loader (guarantees identical decode).
private static Script Asm(OpcodeTable t, string name, params uint[] body)
{
var bytes = new byte[0x3C + body.Length * 4];
System.Text.Encoding.ASCII.GetBytes("SYS4422 ").CopyTo(bytes, 0);
// fields[8] (F8 = code end) at header offset 8 + 8*4 = 0x28; set to body length (all code).
System.BitConverter.GetBytes(body.Length).CopyTo(bytes, 8 + 8 * 4);
for (int i = 0; i < body.Length; i++) System.BitConverter.GetBytes(body[i]).CopyTo(bytes, 0x3C + i * 4);
return Sys4Loader.Parse(bytes, t, name);
}
private static OpcodeTable Table() => OpcodeTableJson.Load(Paths.OpcodesJson);
[Fact]
public void CalleeRunsAndControlResumesAfterTheCall()
{
var t = Table();
// Callee (id 5): mov g[0x10] = 7, then exit.
var callee = Asm(t, "CALLEE", OP_MOV, 3, 0x10, 0, 7, OP_EXIT);
// Caller: call-script 5 ; mov g[0x11] = g[0x10] ; exit.
var caller = Asm(t, "CALLER",
OP_CALLSCRIPT, 0, 5,
OP_MOV, 3, 0x11, 3, 0x10,
OP_EXIT);
var host = new NullHost();
var sink = new RecordingTraceSink();
var vm = new VirtualMachine(caller, t, host, null, new MapProvider(new() { [5] = callee }), sink);
vm.Run();
Assert.Equal(7, vm.Globals[0x10]); // callee wrote a shared global
Assert.Equal(7, vm.Globals[0x11]); // caller read it AFTER the call returned
Assert.Equal("exit", vm.HaltReason); // top-level exit
Assert.Contains(5L, sink.CallScriptIds); // dispatch observed via the trace sink
}
[Fact]
public void MissingProviderFallsBackToStub()
{
var t = Table();
var caller = Asm(t, "CALLER", OP_CALLSCRIPT, 0, 5, OP_EXIT);
var host = new NullHost();
var sink = new RecordingTraceSink();
var vm = new VirtualMachine(caller, t, host, null, null, sink); // no provider
vm.Run();
Assert.Equal("exit", vm.HaltReason); // did not halt on the call; stub + continue
Assert.Contains(5L, sink.CallScriptIds);
}
[Fact]
public void UnresolvedIdHalts()
{
var t = Table();
var caller = Asm(t, "CALLER", OP_CALLSCRIPT, 0, 99, OP_EXIT);
var vm = new VirtualMachine(caller, t, new NullHost(), null, new MapProvider(new()));
vm.Run();
Assert.StartsWith("callscript-unresolved", vm.HaltReason);
}
[Fact]
public void LocalsDoNotLeakBetweenCallerAndCallee()
{
var t = Table();
// Callee writes LOCAL-int 0 = 42 (mov to local-int, type 9), then exit.
var callee = Asm(t, "CALLEE", OP_MOV, 9, 0, 0, 42, OP_EXIT);
// Caller sets local-int 0 = 1, calls, then copies its own local-int 0 to global 0x20.
var caller = Asm(t, "CALLER",
OP_MOV, 9, 0, 0, 1, // l[0] = 1
OP_CALLSCRIPT, 0, 5, // call-script 5 (callee sets ITS local 0 = 42)
OP_MOV, 3, 0x20, 9, 0, // g[0x20] = l[0]
OP_EXIT);
var vm = new VirtualMachine(caller, t, new NullHost(), null, new MapProvider(new() { [5] = callee }));
vm.Run();
Assert.Equal(1, vm.Globals[0x20]); // caller's local 0 unchanged by callee's local 0
}
}

View File

@@ -29,6 +29,7 @@ public class GameSessionTests
public List<int> Emitted = new();
public void ShowText(int o, string t) => Emitted.Add(o);
public void WaitForInput() { }
public void Sleep(long duration) { }
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) { }

View File

@@ -0,0 +1,132 @@
using System.Collections.Generic;
using System.Linq;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class GameSessionTests
{
private const int T_IMM = 0, T_GINT = 3;
private static readonly OpcodeTable Table = OpcodeTableJson.Load(Paths.OpcodesJson);
// A minimal one-instruction script: `mov (global-int dst) src`. Halts pc-out-of-range after.
private static Script MovScript(int dst, int srcType, long srcVal) => new()
{
Header = new ScriptHeader(0, 0, 0, 0, 0, 0),
Instructions = new[]
{
new Instruction(0, 0x55, new[] { new Operand(T_GINT, dst), new Operand(srcType, srcVal) }),
},
IndexByOffset = new Dictionary<int, int> { { 0, 0 } },
Strings = new Dictionary<int, string>(),
};
private sealed class VoiceCountHost : IHost
{
public int Voices;
public List<int> Emitted = new();
public void ShowText(int o, string t) => Emitted.Add(o);
public void WaitForInput() { }
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) { }
public (int Width, int Height) GetTextureSize(int slot) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) => Voices++;
}
[Fact]
public void StatePersistsAcrossScenes()
{
var session = new GameSession();
// Scene A writes G[0x5000] = 42.
session.RunScene(MovScript(0x5000, T_IMM, 0x2a), Table, new CaptureHost());
Assert.Equal(0x2a, session.Globals[0x5000]);
// Scene B copies G[0x5000] -> G[0x5001]; it must SEE A's write.
session.RunScene(MovScript(0x5001, T_GINT, 0x5000), Table, new CaptureHost());
Assert.Equal(0x2a, session.Globals[0x5001]);
}
[Fact]
public void SeedIsVisibleToTheScene()
{
var session = new GameSession();
session.Seed(0x5000, 99);
session.RunScene(MovScript(0x5001, T_GINT, 0x5000), Table, new CaptureHost());
Assert.Equal(99, session.Globals[0x5001]);
}
[Fact]
public void SC0000ViaSessionMatchesSingleRun()
{
var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], Table);
// single run
var host1 = new CaptureHost();
new VirtualMachine(script, Table, host1).Run();
var single = host1.Emitted.Select(e => e.Offset).ToList();
// via session
var host2 = new CaptureHost();
var r = new GameSession().RunScene(Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], Table), Table, host2);
var viaSession = r.Emitted.Select(e => e.Offset).ToList();
Assert.Equal(single, viaSession);
Assert.Equal("exit", r.Halt);
}
[Fact]
public void BootingSkinitPopulatesDataTableGlobals()
{
// Running the SKINIT data script through the session populates the real skill table into the
// global bank (the game's boot behavior). Cross-check vs the static extraction (build/data/SKINIT.json):
// skill 0 = "飛行" at global-string 0x23a3, field G[0xa6e5b] = 30.
var session = new GameSession();
var r = session.RunScene(Sys4Loader.Load(Paths.Scripts()["SKINIT.BIN"], Table), Table, new CaptureHost());
Assert.Equal("exit", r.Halt);
Assert.Equal("飛行", session.GlobalStrings[0x23a3]);
Assert.Equal(30, session.Globals[0xa6e5b]);
Assert.True(session.Globals.Count > 1000, $"SKINIT should populate the skill table (got {session.Globals.Count})");
}
[Fact]
public void SnapshotRoundTripsState()
{
var s = new GameSession();
s.Seed(0x10, 42); s.Seed(0x20, -7); s.SeedString(0x30, "リリィ");
var t = GameSession.FromJson(s.ToJson());
Assert.Equal(42, t.Globals[0x10]);
Assert.Equal(-7, t.Globals[0x20]);
Assert.Equal("リリィ", t.GlobalStrings[0x30]);
Assert.Equal(s.Globals.Count, t.Globals.Count);
}
[Fact]
public void BootedStateSurvivesSnapshot()
{
var s = new GameSession();
s.RunScene(Sys4Loader.Load(Paths.Scripts()["SKINIT.BIN"], Table), Table, new CaptureHost());
var t = GameSession.FromJson(s.ToJson()); // snapshot the expensive booted state, rebuild
Assert.Equal("飛行", t.GlobalStrings[0x23a3]);
Assert.Equal(30, t.Globals[0xa6e5b]);
Assert.Equal(s.Globals.Count, t.Globals.Count);
Assert.Equal(s.GlobalStrings.Count, t.GlobalStrings.Count);
}
[Fact]
public void SeedingFormFlagChangesBehavior()
{
// Lily's lines are gated on form flags G[0xa57/8/9]; unseeded => all skipped (0 voices on her lines).
// Seeding form A (0xa57=1) makes her lines execute -> more play-voice calls. Proven finding (audio).
var scriptPath = Paths.Scripts()["SC0000.BIN"];
var unseeded = new VoiceCountHost();
new GameSession().RunScene(Sys4Loader.Load(scriptPath, Table), Table, unseeded);
var s = new GameSession();
s.Seed(0xa57, 1);
var seeded = new VoiceCountHost();
s.RunScene(Sys4Loader.Load(scriptPath, Table), Table, seeded);
Assert.True(seeded.Voices > unseeded.Voices,
$"seeding form flag should fire more voices: unseeded={unseeded.Voices} seeded={seeded.Voices}");
}
}

View File

@@ -0,0 +1,27 @@
using System.Collections.Generic;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class SleepDispatchTests
{
[Fact]
public void SleepOp_ForwardsRawOperand_ToHost_AndAdvancesLikeNoop()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
(int, Operand[]) Sleep(long ms) => (0xc8, new[] { new Operand(0, ms) });
(int, Operand[]) Show(int s) => (0x6e, new[] { new Operand(2, s), new Operand(0, 0) });
(int, Operand[]) Exit() => (0x2, System.Array.Empty<Operand>());
var script = ScriptAssembler.Assemble(table, "SLEEPTEST",
new List<(int, Operand[])> { Sleep(200), Show(0), Sleep(1000), Exit() }, new[] { "hi" });
var host = new RecordingHost();
var vm = new VirtualMachine(script, table, host);
vm.Run();
Assert.Equal(new List<long> { 200, 1000 }, host.SleptDurations);
Assert.Single(host.Lines); // the show-text between the sleeps still ran
}
}

View File

@@ -10,8 +10,10 @@ internal sealed class RecordingHost : IHost
{
public int Waits;
public readonly List<(int Offset, string Text)> Lines = new();
public readonly List<long> SleptDurations = new();
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 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) { }

View File

@@ -11,6 +11,7 @@ public class TextureGeometryTests
{
public void ShowText(int o, string t) { }
public void WaitForInput() { }
public void Sleep(long duration) { }
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) { }

View File

@@ -0,0 +1,44 @@
using System.Collections.Generic;
using Age.Engine.Hosting;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class TextureGeometryTests
{
private sealed class FakeSizeHost : IHost
{
public void ShowText(int o, string t) { }
public void WaitForInput() { }
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) { }
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
public (int Width, int Height) GetTextureSize(int slot) => (0x140, 0xC8);
}
[Fact]
public void GetTextureSizeWritesHostDimsIntoOutputGlobals()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
// 0x208 (global-int 50)(global-int 60)(global-int 61): slot=50, out_w=G[60], out_h=G[61]
const int T_GINT = 3;
var ins = new Instruction(0, 0x208, new[]
{
new Operand(T_GINT, 50), new Operand(T_GINT, 60), new Operand(T_GINT, 61),
});
var script = new Script
{
Header = new ScriptHeader(0, 0, 0, 0, 0, 0),
Instructions = new[] { ins },
IndexByOffset = new Dictionary<int, int> { { 0, 0 } },
Strings = new Dictionary<int, string>(),
};
var vm = new VirtualMachine(script, table, new FakeSizeHost());
vm.Run();
Assert.Equal(0x140, vm.Globals[60]);
Assert.Equal(0xC8, vm.Globals[61]);
}
}

View File

@@ -13,6 +13,7 @@ public class TextureOpsTests
public int Creates;
public void ShowText(int o, string t) { }
public void WaitForInput() { }
public void Sleep(long duration) { }
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));

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using Age.Engine.Hosting;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class TextureOpsTests
{
private sealed class RecHost : IHost
{
public List<(long resId, int slot)> Sets = new();
public List<(int slot, int w, int h)> Draws = new();
public int Creates;
public void ShowText(int o, string t) { }
public void WaitForInput() { }
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));
public (int Width, int Height) GetTextureSize(int slot) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}
[Fact]
public void SC0000FiresTextureOpsWithSlot0FullScreenSlideshow()
{
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table);
var host = new RecHost();
new VirtualMachine(script, table, host).Run();
Assert.True(host.Creates > 0, "create-texture should fire");
// The intro loads a sequence of full-screen images into slot 0; res 0x23 is the first bg.
Assert.Contains(host.Sets, s => s.resId == 0x23 && s.slot == 0);
Assert.Contains(host.Draws, d => d.slot == 0 && d.w == 0x320 && d.h == 0x258);
}
}

View File

@@ -4,6 +4,7 @@ public sealed class CaptureHost : IHost
public List<(int Offset, string Text)> Emitted { get; } = new();
public void ShowText(int offset, string text) => Emitted.Add((offset, 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 srcX, int srcY, int width, int height, int dstX, int dstY) { }

View File

@@ -3,6 +3,7 @@ public interface IHost
{
void ShowText(int offset, string text);
void WaitForInput();
void Sleep(long duration);
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);

View File

@@ -208,6 +208,8 @@ public sealed class VirtualMachine
}
return pc + 1;
case "wait-for-input": _host.WaitForInput(); return pc + 1;
case "sleep": // 0xc8 (duration) — pause the host duration ms; headless hosts no-op (parity). Frame pacing.
_host.Sleep(Read(a[0])); return pc + 1;
case "end-text-line": case "set-font":
case "comment": case "display-furigana": case "dev_ukn":
return pc + 1;