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:
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
@@ -12,7 +13,13 @@ public class RecoverTests
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
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;
|
||||
vm.Globals[0x152616] = unit;
|
||||
|
||||
51
engine/Age.Engine.Tests/SyntheticSceneTests.cs
Normal file
51
engine/Age.Engine.Tests/SyntheticSceneTests.cs
Normal 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
|
||||
}
|
||||
}
|
||||
38
engine/Age.Engine.Tests/TestSupport.cs
Normal file
38
engine/Age.Engine.Tests/TestSupport.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
using Age.Engine.Hosting;
|
||||
using Age.Engine.Model;
|
||||
using Age.Engine.Sys4;
|
||||
using Age.Engine.Vm;
|
||||
using Xunit;
|
||||
|
||||
public class WaitForInputTests
|
||||
{
|
||||
private sealed class CountHost : IHost
|
||||
{
|
||||
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) { }
|
||||
}
|
||||
|
||||
// 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.
|
||||
[Fact]
|
||||
public void WaitForInputFiresAndEmittedIsStable()
|
||||
public void WaitForInputFiresOncePerPage()
|
||||
{
|
||||
var table = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
var script = Sys4Loader.Load(Paths.Scripts()["SC0000.BIN"], table);
|
||||
var host = new CountHost();
|
||||
var vm = new VirtualMachine(script, table, host);
|
||||
var t = OpcodeTableJson.Load(Paths.OpcodesJson);
|
||||
(int, Operand[]) ShowText(int s) => (0x6e, new[] { new Operand(2, s), new Operand(0, 0) });
|
||||
(int, Operand[]) Wait() => (0x72, new[] { new Operand(0, 0) });
|
||||
(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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
74
engine/Age.Engine/Sys4/ScriptAssembler.cs
Normal file
74
engine/Age.Engine/Sys4/ScriptAssembler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user