From 06231b9459e9c351e93a2eff0a71d8af9d0217ef Mon Sep 17 00:00:00 2001 From: gamer147 Date: Thu, 9 Jul 2026 11:20:23 -0400 Subject: [PATCH] feat(engine): --trace-json emitter for the differential oracle JsonOffsetTraceSink records every executed instruction offset (bytecode word index) of one target script, in order, filtered to the scene's own frame (call-script subroutines excluded) to match the Frida engine tracer's per-codebase filter. Wired as 'trace [--boot] --trace-json '. Observe-only; sweep path and trace parity untouched. +2 xUnit tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- engine/Age.Cli/Program.cs | 34 ++++++++++++ .../JsonOffsetTraceSinkTests.cs | 55 +++++++++++++++++++ .../Diagnostics/JsonOffsetTraceSink.cs | 30 ++++++++++ 3 files changed, 119 insertions(+) create mode 100644 engine/Age.Engine.Tests/JsonOffsetTraceSinkTests.cs create mode 100644 engine/Age.Engine/Diagnostics/JsonOffsetTraceSink.cs diff --git a/engine/Age.Cli/Program.cs b/engine/Age.Cli/Program.cs index c8de47b..c2d2146 100644 --- a/engine/Age.Cli/Program.cs +++ b/engine/Age.Cli/Program.cs @@ -220,6 +220,40 @@ if (args[0] == "sweep") if (args[0] == "trace") { + // --trace-json : single-scene per-op offset trace for the differential oracle (docs/engine-re.md). + // Emits EVERY instruction offset the scene executes, in order (not just show-text lines), filtered to the + // scene's own frame so call-script subroutines into other scripts are excluded — matching the Frida engine + // tracer's per-codebase filter. --boot first runs SYSTEM4's state-setup prefix (INITCONFIG/INIT2/INIT) so + // the scene sees real boot state, exactly like `gfx --boot`. Offsets are bytecode WORD indices, the same + // unit the engine emits as (pc-codebase)/4. Observe-only (a step sink) — the sweep path below is untouched. + int tji = Array.IndexOf(args, "--trace-json"); + if (tji >= 0) + { + if (tji + 1 >= args.Length) { Console.WriteLine("usage: trace [--boot] --trace-json "); return 1; } + var outPath = args[tji + 1]; + var sceneName = args.First(a => a.EndsWith(".BIN", StringComparison.OrdinalIgnoreCase)); + bool boot = args.Contains("--boot"); + var jscripts = Paths.Scripts(); + var target = Sys4Loader.Load(jscripts[sceneName.ToUpperInvariant()], table); + var session = new GameSession(); + if (boot) + foreach (var b in new[] { "INITCONFIG.BIN", "INIT2.BIN", "INIT.BIN" }) + session.RunScene(Sys4Loader.Load(jscripts[b], table), table, new CaptureHost(), null, provider); + var sink = new JsonOffsetTraceSink(target.Name); + var vm = new VirtualMachine(target, table, new CaptureHost(), + new VmOptions(HaltAtWaitForInput: true, MaxSteps: 20_000_000), provider, sink); + foreach (var kv in session.Globals) vm.Globals[kv.Key] = kv.Value; + foreach (var kv in session.GlobalStrings) vm.GlobalStrings[kv.Key] = kv.Value; + vm.Run(); + var dir = Path.GetDirectoryName(outPath); + if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); + File.WriteAllText(outPath, JsonSerializer.Serialize( + new { scene = Path.GetFileNameWithoutExtension(sceneName).ToUpperInvariant(), offsets = sink.Offsets }, + new JsonSerializerOptions { Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping })); + Console.WriteLine($"{sceneName}{(boot ? " (booted)" : "")}: {sink.Offsets.Count} offsets -> {outPath} (halt: {vm.HaltReason})"); + return 0; + } + var scene = new Regex(@"^S[CP]\d{4}\.BIN$"); var scripts = Paths.Scripts(); var trace = new SortedDictionary(StringComparer.Ordinal); diff --git a/engine/Age.Engine.Tests/JsonOffsetTraceSinkTests.cs b/engine/Age.Engine.Tests/JsonOffsetTraceSinkTests.cs new file mode 100644 index 0000000..7e955ed --- /dev/null +++ b/engine/Age.Engine.Tests/JsonOffsetTraceSinkTests.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Age.Engine.Diagnostics; +using Age.Engine.Model; +using Age.Engine.Sys4; +using Age.Engine.Vm; +using Xunit; + +/// The VM side of the differential offset-path oracle: JsonOffsetTraceSink records every executed +/// instruction offset (word index) of one target script, in order, excluding call-script subroutines. +public class JsonOffsetTraceSinkTests +{ + private static OpcodeTable Table() => OpcodeTableJson.Load(Paths.OpcodesJson); + + [Fact] + public void RecordsExecutedOffsetsInOrder() + { + var t = Table(); + // mov g[0x10]=7 (op 0x55, argc2 -> 5 words) ; exit (op 0x2, argc0) => offsets [0, 5]. + var body = new List<(int, Operand[])> + { + (0x55, new[] { new Operand(3, 0x10), new Operand(0, 7) }), + (0x2, Array.Empty()), + }; + var s = ScriptAssembler.Assemble(t, "S.BIN", body, Array.Empty()); + var sink = new JsonOffsetTraceSink("S.BIN"); + new VirtualMachine(s, t, new RecordingHost(), null, null, sink).Run(); + Assert.Equal(new[] { 0, 5 }, sink.Offsets); + } + + [Fact] + public void FiltersToTargetFrameExcludingCallScriptSubroutine() + { + var t = Table(); + // callee: mov ; exit. caller: call-script 5 (op 0x3, argc1 -> 3 words) ; exit => caller offsets [0, 3]. + var callee = ScriptAssembler.Assemble(t, "CALLEE.BIN", + new List<(int, Operand[])> + { + (0x55, new[] { new Operand(3, 0x11), new Operand(0, 1) }), + (0x2, Array.Empty()), + }, Array.Empty()); + var caller = ScriptAssembler.Assemble(t, "CALLER.BIN", + new List<(int, Operand[])> { (0x3, new[] { new Operand(0, 5) }), (0x2, Array.Empty()) }, + Array.Empty()); + + var sink = new JsonOffsetTraceSink("CALLER.BIN"); + var vm = new VirtualMachine(caller, t, new RecordingHost(), null, + new MapProvider(new() { [5] = callee }), sink); + vm.Run(); + + Assert.Equal(1, vm.CallScriptDispatches); // the subroutine really ran + Assert.Equal(new[] { 0, 3 }, sink.Offsets); // ...but its offsets are excluded + } +} diff --git a/engine/Age.Engine/Diagnostics/JsonOffsetTraceSink.cs b/engine/Age.Engine/Diagnostics/JsonOffsetTraceSink.cs new file mode 100644 index 0000000..c09e497 --- /dev/null +++ b/engine/Age.Engine/Diagnostics/JsonOffsetTraceSink.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +namespace Age.Engine.Diagnostics; + +/// Records the script-relative offset of every instruction executed in one target script, in +/// execution order — the VM side of the differential offset-path oracle (docs/engine-re.md). Offsets are +/// the instruction's (the bytecode WORD index, same unit the engine +/// tracer emits as (pc-codebase)/4), so the two sequences are directly comparable. +/// +/// Filters to the target frame: only Step events whose innermost running script is the target are recorded, +/// so call-script subroutines into OTHER scripts are excluded — matching the engine trace's per-codebase +/// filter, which isolates the scene from boot/system scripts. Observe-only (emits nothing, touches no VM +/// state) so trace parity is preserved. +public sealed class JsonOffsetTraceSink : TraceSinkBase +{ + private readonly string _target; + private readonly List _offsets = new(); + + public JsonOffsetTraceSink(string targetScript) { _target = targetScript; } + + /// Executed offsets of the target script, in order. + public IReadOnlyList Offsets => _offsets; + + public override bool TracingSteps => true; + + protected override void OnEvent(in TraceEvent e) + { + if (e.Kind == TraceEventKind.Step && CurrentScript == _target && e.Ins is { } ins) + _offsets.Add(ins.Offset); + } +}