feat(diagnostics): ITraceSink seam + TraceEvent + Null/Text sinks

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gamer147
2026-07-07 15:19:40 -04:00
parent 6981bd0e41
commit 10cf79a41b
5 changed files with 144 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
using System.IO;
using Age.Engine.Diagnostics;
using Age.Engine.Model;
using Xunit;
public class TraceSinkTests
{
[Fact]
public void FactoriesSetKindAndFields()
{
var ins = new Instruction(0x40, 0x55, new[] { new Operand(3, 0x10), new Operand(0, 7) });
var step = TraceEvent.Step(0x40, ins, 2);
Assert.Equal(TraceEventKind.Step, step.Kind);
Assert.Equal(0x55, step.Opcode);
Assert.Same(ins, step.Ins);
Assert.Equal(2, step.Depth);
var cs = TraceEvent.CallScript(0x1ab, "ADDITEM");
Assert.Equal(TraceEventKind.CallScript, cs.Kind);
Assert.Equal(0x1abL, cs.Id);
Assert.Equal("ADDITEM", cs.Name);
}
[Fact]
public void NullSinkIsInertAndNotTracingSteps()
{
Assert.False(NullTraceSink.Instance.TracingSteps);
NullTraceSink.Instance.Emit(TraceEvent.Halt("x", 1)); // must not throw
}
[Fact]
public void TextSinkFormatsEachKind()
{
var sw = new StringWriter();
var sink = new TextTraceSink(sw, table: null, includeSteps: true);
sink.Emit(TraceEvent.FrameEnter("SC0000", 1, FrameCause.TopScene));
sink.Emit(TraceEvent.CallScript(0x1ab, "ADDITEM"));
sink.Emit(TraceEvent.Halt("exit", 27994));
var outp = sw.ToString();
Assert.Contains("» SC0000 (enter, TopScene)", outp);
Assert.Contains("call-script 0x1ab =ADDITEM (resolved)", outp);
Assert.Contains("halt: exit @ 27994 steps", outp);
}
}

View File

@@ -0,0 +1,12 @@
namespace Age.Engine.Diagnostics;
/// <summary>The engine's diagnostics seam. The VM emits typed <see cref="TraceEvent"/>s here; any
/// consumer (CLI, Godot, tests) supplies a sink instead of reimplementing IHost. Observe-only:
/// a sink never reads/writes VM state or influences control flow (that guarantees trace parity).</summary>
public interface ITraceSink
{
/// <summary>Cheap gate: when false the VM skips constructing per-instruction Step events, keeping
/// the hot path (a corpus sweep is ~1.46M instructions) free. Rare events emit regardless.</summary>
bool TracingSteps { get; }
void Emit(in TraceEvent e);
}

View File

@@ -0,0 +1,11 @@
namespace Age.Engine.Diagnostics;
/// <summary>The inert default: no step tracing, empty Emit. Supplying this (or null) to the VM
/// guarantees byte-identical behavior.</summary>
public sealed class NullTraceSink : ITraceSink
{
public static readonly NullTraceSink Instance = new();
private NullTraceSink() { }
public bool TracingSteps => false;
public void Emit(in TraceEvent e) { }
}

View File

@@ -0,0 +1,42 @@
using Age.Engine.Model;
namespace Age.Engine.Diagnostics;
/// <summary>The one built-in formatter: writes each event as a deterministic text line to a
/// TextWriter (Console.Out or a file). Indents by frame depth. If an OpcodeTable is supplied, Step
/// lines show the mnemonic; otherwise the raw opcode. Step lines only appear when includeSteps is set.</summary>
public sealed class TextTraceSink : ITraceSink
{
private readonly TextWriter _w;
private readonly OpcodeTable? _table;
private readonly bool _steps;
public TextTraceSink(TextWriter writer, OpcodeTable? table = null, bool includeSteps = false)
{ _w = writer; _table = table; _steps = includeSteps; }
public bool TracingSteps => _steps;
public void Emit(in TraceEvent e)
{
string indent = new string(' ', Math.Max(0, e.Depth - 1) * 2);
switch (e.Kind)
{
case TraceEventKind.FrameEnter:
_w.WriteLine($"{indent}» {e.Name} (enter, {e.Cause})"); break;
case TraceEventKind.FrameExit:
_w.WriteLine($"{indent}« {e.Name} ({e.Text})"); break;
case TraceEventKind.Step:
_w.WriteLine($"{indent} {e.Pc:x4} {Mnemonic(e.Opcode)} {Args(e.Ins)}"); break;
case TraceEventKind.CallScript:
_w.WriteLine($"{indent} call-script 0x{e.Id:x} ={e.Name ?? "?"} " +
$"({(e.Name != null ? "resolved" : "stub/unresolved")})"); break;
case TraceEventKind.Stub:
_w.WriteLine($"{indent} {e.Pc:x4} STUB op=0x{e.Opcode:x}"); break;
case TraceEventKind.Halt:
_w.WriteLine($"halt: {e.Text} @ {e.Steps} steps"); break;
}
}
private string Mnemonic(int op) => _table?.Label(op) is { Length: > 0 } l ? l : $"0x{op:x}";
private static string Args(Instruction? ins) =>
ins == null ? "" : string.Join(" ", ins.Args.Select(o => $"{o.Type}:{o.Value}"));
}

View File

@@ -0,0 +1,35 @@
using Age.Engine.Model;
namespace Age.Engine.Diagnostics;
public enum TraceEventKind { Step, FrameEnter, FrameExit, CallScript, Stub, Halt }
public enum FrameCause { TopScene, CallScript }
/// <summary>An engine diagnostic fact. A <c>readonly struct</c> with a Kind discriminator and a shared
/// field set — no per-event heap allocation. Only the fields relevant to a Kind are populated; the
/// static factories are the only intended constructors.</summary>
public readonly struct TraceEvent
{
public TraceEventKind Kind { get; private init; }
public int Pc { get; private init; }
public int Opcode { get; private init; }
public int Depth { get; private init; }
public long Id { get; private init; } // call-script id
public long Steps { get; private init; } // total steps at Halt
public FrameCause Cause { get; private init; }
public string? Name { get; private init; } // script/scene name; resolved call-script name (null => unresolved/stub)
public string? Text { get; private init; } // halt reason; frame outcome
public Instruction? Ins { get; private init; } // Step: the instruction (args) by ref, never copied
public static TraceEvent Step(int pc, Instruction ins, int depth) => new()
{ Kind = TraceEventKind.Step, Pc = pc, Opcode = ins.Opcode, Ins = ins, Depth = depth };
public static TraceEvent FrameEnter(string name, int depth, FrameCause cause, long id = 0) => new()
{ Kind = TraceEventKind.FrameEnter, Name = name, Depth = depth, Cause = cause, Id = id };
public static TraceEvent FrameExit(string name, int depth, string outcome) => new()
{ Kind = TraceEventKind.FrameExit, Name = name, Depth = depth, Text = outcome };
public static TraceEvent CallScript(long id, string? name) => new()
{ Kind = TraceEventKind.CallScript, Id = id, Name = name };
public static TraceEvent Stub(int opcode, int pc) => new()
{ Kind = TraceEventKind.Stub, Opcode = opcode, Pc = pc };
public static TraceEvent Halt(string reason, long steps) => new()
{ Kind = TraceEventKind.Halt, Text = reason, Steps = steps };
}