From 10cf79a41baff5fbe2dd67a48e59de0c6e6e786a Mon Sep 17 00:00:00 2001 From: gamer147 Date: Tue, 7 Jul 2026 15:19:40 -0400 Subject: [PATCH] feat(diagnostics): ITraceSink seam + TraceEvent + Null/Text sinks Co-Authored-By: Claude Opus 4.8 --- engine/Age.Engine.Tests/TraceSinkTests.cs | 44 +++++++++++++++++++ engine/Age.Engine/Diagnostics/ITraceSink.cs | 12 +++++ .../Age.Engine/Diagnostics/NullTraceSink.cs | 11 +++++ .../Age.Engine/Diagnostics/TextTraceSink.cs | 42 ++++++++++++++++++ engine/Age.Engine/Diagnostics/TraceEvent.cs | 35 +++++++++++++++ 5 files changed, 144 insertions(+) create mode 100644 engine/Age.Engine.Tests/TraceSinkTests.cs create mode 100644 engine/Age.Engine/Diagnostics/ITraceSink.cs create mode 100644 engine/Age.Engine/Diagnostics/NullTraceSink.cs create mode 100644 engine/Age.Engine/Diagnostics/TextTraceSink.cs create mode 100644 engine/Age.Engine/Diagnostics/TraceEvent.cs diff --git a/engine/Age.Engine.Tests/TraceSinkTests.cs b/engine/Age.Engine.Tests/TraceSinkTests.cs new file mode 100644 index 0000000..d3cbb68 --- /dev/null +++ b/engine/Age.Engine.Tests/TraceSinkTests.cs @@ -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); + } +} diff --git a/engine/Age.Engine/Diagnostics/ITraceSink.cs b/engine/Age.Engine/Diagnostics/ITraceSink.cs new file mode 100644 index 0000000..4f2abd1 --- /dev/null +++ b/engine/Age.Engine/Diagnostics/ITraceSink.cs @@ -0,0 +1,12 @@ +namespace Age.Engine.Diagnostics; + +/// The engine's diagnostics seam. The VM emits typed 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). +public interface ITraceSink +{ + /// 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. + bool TracingSteps { get; } + void Emit(in TraceEvent e); +} diff --git a/engine/Age.Engine/Diagnostics/NullTraceSink.cs b/engine/Age.Engine/Diagnostics/NullTraceSink.cs new file mode 100644 index 0000000..1d1d2f5 --- /dev/null +++ b/engine/Age.Engine/Diagnostics/NullTraceSink.cs @@ -0,0 +1,11 @@ +namespace Age.Engine.Diagnostics; + +/// The inert default: no step tracing, empty Emit. Supplying this (or null) to the VM +/// guarantees byte-identical behavior. +public sealed class NullTraceSink : ITraceSink +{ + public static readonly NullTraceSink Instance = new(); + private NullTraceSink() { } + public bool TracingSteps => false; + public void Emit(in TraceEvent e) { } +} diff --git a/engine/Age.Engine/Diagnostics/TextTraceSink.cs b/engine/Age.Engine/Diagnostics/TextTraceSink.cs new file mode 100644 index 0000000..ccad598 --- /dev/null +++ b/engine/Age.Engine/Diagnostics/TextTraceSink.cs @@ -0,0 +1,42 @@ +using Age.Engine.Model; +namespace Age.Engine.Diagnostics; + +/// 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. +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}")); +} diff --git a/engine/Age.Engine/Diagnostics/TraceEvent.cs b/engine/Age.Engine/Diagnostics/TraceEvent.cs new file mode 100644 index 0000000..2aed111 --- /dev/null +++ b/engine/Age.Engine/Diagnostics/TraceEvent.cs @@ -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 } + +/// An engine diagnostic fact. A readonly struct 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. +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 }; +}