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 <SCENE.BIN> [--boot] --trace-json <out>'. Observe-only; sweep path and trace parity untouched. +2 xUnit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -220,6 +220,40 @@ if (args[0] == "sweep")
|
||||
|
||||
if (args[0] == "trace")
|
||||
{
|
||||
// --trace-json <path>: 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 <SCENE.BIN> [--boot] --trace-json <out.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<string, object>(StringComparer.Ordinal);
|
||||
|
||||
55
engine/Age.Engine.Tests/JsonOffsetTraceSinkTests.cs
Normal file
55
engine/Age.Engine.Tests/JsonOffsetTraceSinkTests.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<Operand>()),
|
||||
};
|
||||
var s = ScriptAssembler.Assemble(t, "S.BIN", body, Array.Empty<string>());
|
||||
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<Operand>()),
|
||||
}, Array.Empty<string>());
|
||||
var caller = ScriptAssembler.Assemble(t, "CALLER.BIN",
|
||||
new List<(int, Operand[])> { (0x3, new[] { new Operand(0, 5) }), (0x2, Array.Empty<Operand>()) },
|
||||
Array.Empty<string>());
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
30
engine/Age.Engine/Diagnostics/JsonOffsetTraceSink.cs
Normal file
30
engine/Age.Engine/Diagnostics/JsonOffsetTraceSink.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using System.Collections.Generic;
|
||||
namespace Age.Engine.Diagnostics;
|
||||
|
||||
/// <summary>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 <see cref="Model.Instruction.Offset"/> (the bytecode WORD index, same unit the engine
|
||||
/// tracer emits as <c>(pc-codebase)/4</c>), 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.</summary>
|
||||
public sealed class JsonOffsetTraceSink : TraceSinkBase
|
||||
{
|
||||
private readonly string _target;
|
||||
private readonly List<int> _offsets = new();
|
||||
|
||||
public JsonOffsetTraceSink(string targetScript) { _target = targetScript; }
|
||||
|
||||
/// <summary>Executed offsets of the target script, in order.</summary>
|
||||
public IReadOnlyList<int> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user