Test on synthesized scenes with full handling, not crippled real scenes

- ScriptAssembler (Age.Engine/Sys4): assemble code+strings -> Script (inverse of
  Sys4Loader; also Phase-D modding-assembler groundwork).
- SyntheticSceneTests: deterministic show-text/wait/nested-call-script/shared-global
  scene run with call-script handling ON.
- WaitForInputTests: reworked onto a synthesized two-page scene (was: SC0000 stub=186).
- RecoverTests: full call-script handling via a no-op subroutine double (isolates
  RECOVER's ISA semantics from real subroutines' game-state deps).
- Retire TraceDiffTests: it matched the C# VM to vm0.py's stubbed-call-script trace;
  vm0.py is retired from oracle duty, and we no longer gate handling to keep it matching.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-07 13:45:42 -04:00
parent 96d12992a6
commit 6604a1fa54
6 changed files with 189 additions and 76 deletions

View File

@@ -1,3 +1,4 @@
using System.Collections.Generic;
using Age.Engine.Hosting; using Age.Engine.Hosting;
using Age.Engine.Sys4; using Age.Engine.Sys4;
using Age.Engine.Vm; using Age.Engine.Vm;
@@ -12,7 +13,13 @@ public class RecoverTests
{ {
var table = OpcodeTableJson.Load(Paths.OpcodesJson); var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = Sys4Loader.Load(Path.Combine(Paths.Data1, "RECOVER.BIN"), table); var script = Sys4Loader.Load(Path.Combine(Paths.Data1, "RECOVER.BIN"), table);
var vm = new VirtualMachine(script, table, new CaptureHost()); // Full call-script handling on, but the subroutines are doubled by a no-op `exit` script:
// this test isolates RECOVER's ISA semantics (pointer/lvalue, 2D-stride, loops) from the real
// subroutines' game-state dependencies. The real subroutines are covered elsewhere.
var noop = ScriptAssembler.Assemble(table, "NOOP",
new List<(int, Age.Engine.Model.Operand[])> { (0x2, System.Array.Empty<Age.Engine.Model.Operand>()) },
System.Array.Empty<string>());
var vm = new VirtualMachine(script, table, new CaptureHost(), null, new AnyProvider(noop));
int unit = 0; int unit = 0;
vm.Globals[0x152616] = unit; vm.Globals[0x152616] = unit;

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using Age.Engine.Model;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
/// <summary>Regression coverage built from a SYNTHESIZED scene rather than a real scene run in a
/// crippled mode. The scene exercises the known-good sequence (show-text, wait-for-input, a real
/// nested call-script, shared-global return) with FULL op handling on, and asserts its exact
/// deterministic output. This is the model for engine regression tests: synthesize the data, don't
/// disable features to keep a real scene matching a frozen number.</summary>
public class SyntheticSceneTests
{
// Opcodes: show-text=0x6e(argc2), end-text-line=0x6f, wait-for-input=0x72(argc1),
// call-script=0x3(argc1), mov=0x55(argc2), exit=0x2. Operand types: imm=0, string=2, global-int=3.
private static (int, Operand[]) ShowText(int strIndex) => (0x6e, new[] { new Operand(2, strIndex), new Operand(0, 0) });
private static (int, Operand[]) Wait() => (0x72, new[] { new Operand(0, 0) });
private static (int, Operand[]) CallScript(long id) => (0x3, new[] { new Operand(0, id) });
private static (int, Operand[]) MovGG(int dst, int src) => (0x55, new[] { new Operand(3, dst), new Operand(3, src) });
private static (int, Operand[]) MovGI(int dst, long imm) => (0x55, new[] { new Operand(3, dst), new Operand(0, imm) });
private static (int, Operand[]) Exit() => (0x2, System.Array.Empty<Operand>());
[Fact]
public void SynthesizedSceneRunsWithFullHandling()
{
var t = OpcodeTableJson.Load(Paths.OpcodesJson);
// Callee (id 5): show "Sub", set g[0x31]=42, exit.
var callee = ScriptAssembler.Assemble(t, "SUBSCENE",
new List<(int, Operand[])> { ShowText(0), MovGI(0x31, 42), Exit() },
new[] { "Sub" });
// Caller: show "Hello", wait, show "World", call-script 5, g[0x30]=g[0x31], exit.
var caller = ScriptAssembler.Assemble(t, "SCENE",
new List<(int, Operand[])> { ShowText(0), Wait(), ShowText(1), CallScript(5), MovGG(0x30, 0x31), Exit() },
new[] { "Hello", "World" });
var host = new RecordingHost();
var vm = new VirtualMachine(caller, t, host, null, new MapProvider(new() { [5] = callee }));
vm.Run();
// Emitted in execution order, with the callee's line interleaved after the call site.
Assert.Equal(new[] { "Hello", "World", "Sub" }, vm.Emitted.Select(e => e.Text).ToArray());
Assert.Equal("SCENE", vm.Emitted[0].Script);
Assert.Equal("SUBSCENE", vm.Emitted[2].Script); // proves the subroutine actually executed
Assert.Equal(1, host.Waits); // wait-for-input fired once
Assert.Equal(42, vm.Globals[0x31]); // callee wrote a shared global
Assert.Equal(42, vm.Globals[0x30]); // caller read it AFTER the call returned
Assert.Equal("exit", vm.HaltReason); // clean top-level exit
}
}

View File

@@ -0,0 +1,38 @@
using System.Collections.Generic;
using Age.Engine.Hosting;
using Age.Engine.Model;
/// <summary>Shared test doubles: a host that records observable effects, and an in-memory script
/// provider for synthetic call-script targets.</summary>
internal sealed class RecordingHost : IHost
{
public int Waits, CallScripts;
public readonly List<(int Offset, string Text)> Lines = new();
public void ShowText(int offset, string text) => Lines.Add((offset, text));
public void CallScript(long id) => CallScripts++;
public void OnStub(int opcode) { }
public void WaitForInput() => Waits++;
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 (int Width, int Height) GetTextureSize(int slot) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}
internal 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;
}
/// <summary>A controlled test double: every call-script id resolves to the same script (typically a
/// no-op that just exits). Lets a test run a real script with call-script handling ON while isolating
/// it from the real subroutines' game-state dependencies.</summary>
internal sealed class AnyProvider : IScriptProvider
{
private readonly Script _s;
public AnyProvider(Script s) => _s = s;
public Script? GetById(long id) => _s;
}

View File

@@ -1,51 +0,0 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using Age.Engine.Hosting;
using Age.Engine.Sys4;
using Age.Engine.Vm;
using Xunit;
public class TraceDiffTests
{
private static readonly Regex Scene = new(@"^S[CP]\d{4}\.BIN$");
[Fact]
public void CsTraceMatchesVm0PerScene()
{
string refPath = Path.Combine(Paths.Build, "vm0-trace.json");
Assert.True(File.Exists(refPath),
"prerequisite: run `py -3.11 -X utf8 tools/vm0.py --trace build/vm0-trace.json`");
using var doc = JsonDocument.Parse(File.ReadAllText(refPath));
var expected = doc.RootElement;
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
var scripts = Paths.Scripts();
var mismatches = new List<string>();
foreach (var name in scripts.Keys.Where(n => Scene.IsMatch(n)).OrderBy(n => n, StringComparer.Ordinal))
{
var script = Sys4Loader.Load(scripts[name], table);
var vm = new VirtualMachine(script, table, new CaptureHost());
vm.Run();
var offsets = vm.Emitted.Select(e => e.Offset).ToArray();
if (!expected.TryGetProperty(name, out var exp)) { mismatches.Add($"{name}: absent in vm0 trace"); continue; }
var expOffsets = exp.GetProperty("offsets").EnumerateArray().Select(x => x.GetInt32()).ToArray();
string expHalt = exp.GetProperty("halt").GetString() ?? "";
long expSteps = exp.GetProperty("steps").GetInt64();
if (!offsets.SequenceEqual(expOffsets))
mismatches.Add($"{name}: offsets differ (cs {offsets.Length} vs vm0 {expOffsets.Length}; first diff at {FirstDiff(offsets, expOffsets)})");
else if (vm.HaltReason != expHalt) mismatches.Add($"{name}: halt cs='{vm.HaltReason}' vs vm0='{expHalt}'");
else if (vm.Steps != expSteps) mismatches.Add($"{name}: steps cs={vm.Steps} vs vm0={expSteps}");
}
Assert.True(mismatches.Count == 0, "scene mismatches:\n" + string.Join("\n", mismatches.Take(20)));
}
private static int FirstDiff(int[] a, int[] b)
{
int n = Math.Min(a.Length, b.Length);
for (int i = 0; i < n; i++) if (a[i] != b[i]) return i;
return n;
}
}

View File

@@ -1,37 +1,31 @@
using System.Collections.Generic; using System.Collections.Generic;
using Age.Engine.Hosting; using Age.Engine.Model;
using Age.Engine.Sys4; using Age.Engine.Sys4;
using Age.Engine.Vm; using Age.Engine.Vm;
using Xunit; using Xunit;
public class WaitForInputTests public class WaitForInputTests
{ {
private sealed class CountHost : IHost // wait-for-input (0x72) fires per page. Synthesize a two-page scene and assert it fires exactly
{ // twice — full handling, no dependency on a real scene's (stubbed) line count.
public int Waits;
public List<int> Emitted = new();
public void ShowText(int offset, string text) => Emitted.Add(offset);
public void CallScript(long id) { }
public void OnStub(int opcode) { }
public void WaitForInput() => Waits++;
public void CreateTexture(int slot, int w, int h) { }
public void SetTexture(long resId, int slot) { }
public void DrawTexture(int slot, int srcX, int srcY, int w, int h, int dstX, int dstY) { }
public (int Width, int Height) GetTextureSize(int slot) => (0, 0);
public void PlayBgm(long id) { }
public void PlayVoice(long id) { }
}
[Fact] [Fact]
public void WaitForInputFiresAndEmittedIsStable() public void WaitForInputFiresOncePerPage()
{ {
var table = OpcodeTableJson.Load(Paths.OpcodesJson); var t = OpcodeTableJson.Load(Paths.OpcodesJson);
var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table); (int, Operand[]) ShowText(int s) => (0x6e, new[] { new Operand(2, s), new Operand(0, 0) });
var host = new CountHost(); (int, Operand[]) Wait() => (0x72, new[] { new Operand(0, 0) });
var vm = new VirtualMachine(script, table, host); (int, Operand[]) Exit() => (0x2, System.Array.Empty<Operand>());
var scene = ScriptAssembler.Assemble(t, "TWOPAGE",
new List<(int, Operand[])> { ShowText(0), Wait(), ShowText(1), Wait(), Exit() },
new[] { "page one", "page two" });
var host = new RecordingHost();
var vm = new VirtualMachine(scene, t, host);
vm.Run(); vm.Run();
Assert.True(host.Waits > 0, "wait-for-input (0x72) should fire at least once");
Assert.Equal(186, host.Emitted.Count); // unchanged vs the A1 SC0000 trace Assert.Equal(2, host.Waits);
Assert.Equal(new[] { "page one", "page two" }, vm.Emitted.Select(e => e.Text).ToArray());
Assert.Equal("exit", vm.HaltReason); Assert.Equal("exit", vm.HaltReason);
} }
} }

View File

@@ -0,0 +1,74 @@
using System.Text;
using Age.Engine.Model;
namespace Age.Engine.Sys4;
/// <summary>Assembles a SYS4 script from instructions + strings — the inverse of <see cref="Sys4Loader"/>.
/// Used to synthesize deterministic test scenes (so regression tests exercise real op handling instead
/// of running a real scene in a crippled mode) and as groundwork for the Phase-D modding assembler.
///
/// <para>A string operand is written as <c>new Operand(2, stringIndex)</c>; the assembler lays strings
/// out immediately after the code and patches each such operand's value to the string's dword offset,
/// exactly as the compiler does. Strings are cp932, null-terminated, stored XOR-0xFFFFFFFF per dword
/// (matching <see cref="Sys4StringCodec"/>).</para></summary>
public static class ScriptAssembler
{
private const int HeaderSize = 0x3C, NumFields = 13, StringArgType = 2;
private static readonly Encoding Cp932;
static ScriptAssembler()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Cp932 = Encoding.GetEncoding(932);
}
public static Script Assemble(OpcodeTable table, string name,
IReadOnlyList<(int Op, Operand[] Args)> instrs, IReadOnlyList<string> strings)
=> Sys4Loader.Parse(AssembleBytes(instrs, strings), table, name);
public static byte[] AssembleBytes(IReadOnlyList<(int Op, Operand[] Args)> instrs, IReadOnlyList<string> strings)
{
int codeLen = 0;
foreach (var ins in instrs) codeLen += 1 + 2 * ins.Args.Length;
// Encode strings; record each string's starting dword offset (relative to body start).
var strDwords = new List<uint>();
var strOffset = new int[strings.Count];
for (int i = 0; i < strings.Count; i++)
{
strOffset[i] = codeLen + strDwords.Count;
strDwords.AddRange(EncodeString(strings[i]));
}
var body = new List<uint>(codeLen + strDwords.Count);
foreach (var ins in instrs)
{
body.Add((uint)ins.Op);
foreach (var arg in ins.Args)
{
long val = arg.Type == StringArgType ? strOffset[(int)arg.Value] : arg.Value;
body.Add((uint)arg.Type);
body.Add((uint)val);
}
}
body.AddRange(strDwords);
var fields = new int[NumFields];
fields[8] = codeLen; // F8 = code end (strings begin here)
var bytes = new byte[HeaderSize + body.Count * 4];
Encoding.ASCII.GetBytes("SYS4422 ").CopyTo(bytes, 0);
for (int k = 0; k < NumFields; k++) BitConverter.GetBytes(fields[k]).CopyTo(bytes, 8 + k * 4);
for (int i = 0; i < body.Count; i++) BitConverter.GetBytes(body[i]).CopyTo(bytes, HeaderSize + i * 4);
return bytes;
}
private static IEnumerable<uint> EncodeString(string text)
{
var raw = new List<byte>(Cp932.GetBytes(text)) { 0 }; // null terminator
while (raw.Count % 4 != 0) raw.Add(0);
for (int i = 0; i < raw.Count; i += 4)
{
uint le = (uint)(raw[i] | raw[i + 1] << 8 | raw[i + 2] << 16 | raw[i + 3] << 24);
yield return le ^ 0xFFFFFFFFu;
}
}
}