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 };
+}