Typed ITraceSink/TraceEvent seam in Age.Engine (zero deps, allocation-free hot path); relocate CallScript/OnStub off IHost; Null + Text sinks; CLI --trace; Godot dispatch hack retired onto a sink. Serilog/EventSource deferred to optional edge sinks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
13 KiB
engine diagnostics / trace facility — design (2026-07-07)
Goal
Give Age.Engine a first-class, extensible diagnostics seam it owns — a typed event stream the VM
emits and any frontend, the CLI, or a test consumes. The engine should surface what it handles
(script/scene execution, call-script dispatch, opcode flow, halts, step counts); a frontend should log
only its realm (graphics, sound, input). Today that boundary is backwards: the VM has no logging seam,
so "a script is being run" — an engine fact — is surfaced by routing through IHost + a thread-safe
queue + a main-thread GD.Print in Godot, and every CLI diagnostic (audio, gfx) is a full bespoke
IHost reimplementation.
The point of this slice is the seam, not any one diagnostic feature. v1 stays small (console/file text; a modest event vocabulary) but is built so the next time we want visibility into something, we add to this facility rather than writing another bespoke host.
Scope
In scope:
- A typed, injectable trace sink in
Age.Engine(ITraceSink+TraceEvent+NullTraceSink+TextTraceSink). - The VM emits a v1 event vocabulary (Step, FrameEnter, FrameExit, CallScript, Stub, Halt).
- Boundary correction: remove the two diagnostic-only methods (
CallScript,OnStub) fromIHost; relocate them to trace events. Migrate the 4 non-test implementers + 3 real consumers. - CLI
--tracewiring onrun/play/sweep; Godot dispatch visibility moved onto aGodotTraceSink. - Parity: default
NullTraceSink⇒ existing behavior byte-identical.
Out of scope (this slice):
- Serilog /
Microsoft.Extensions.Logging/EventSourcein the core (see Alternatives). These may later be added as edge sink adapters, never as the seam. - A rich event vocabulary beyond the six kinds (GlobalWrite, BranchTaken, etc. — add lazily when a consumer needs them).
- Any Godot debug-overlay UI. Godot's change is limited to retiring the dispatch-queue hack via a sink.
- Composite/fan-out sink, JSON-artifact sink, divergence differ (all future consumers the seam enables, none built now).
- Migrating the
gfx/audioresolving hosts to the sink — they do genuine host-side resId→file work, not pure diagnostics; they only shed the two deadIHostmethods.
Background — current architecture
VirtualMachine (engine/Age.Engine/Vm/VirtualMachine.cs) runs scripts as nested ExecFrames
(RunFrame), sharing a flat global bank; summary facts already live as VM properties (Steps,
HaltReason, Emitted). Diagnostic notifications are smuggled through IHost:
IHost.CallScript(long id)— fired at thecall-scriptop site (VirtualMachine.cs:173), purely to notify observers. Real consumers:CaptureHost.CallScriptCount(the CLIrunsummary line),GodotAdvHost.Dispatched(the thread-queue hackMainprints at scene end), and 3 tests. Everyone else no-ops it.IHost.OnStub(int opcode)— fired at thedefault:arm (VirtualMachine.cs:215). Its only consumer,CaptureHost.Stubs, is written but never read — dead output.
Seam rule (unchanged): Vm references only Model + Hosting, never Sys4. This design adds
Diagnostics as a third allowed Vm dependency — it is version-neutral and references no Sys4.
IHost implementers today (10): CaptureHost (engine); GodotAdvHost (godot); AudioTraceHost,
GfxTraceHost (CLI); and 6 test hosts (VoiceCountHost, RecHost, FakeSizeHost, RecordingHost,
and two NullHosts). Each drops exactly two lines in the migration.
Design
1. The seam — ITraceSink
New namespace Age.Engine.Diagnostics. One hot method plus one cheap gate:
public interface ITraceSink
{
bool TracingSteps { get; } // cheap gate: the VM skips constructing per-instruction Step
// events when no consumer wants them (keeps the hot path free)
void Emit(in TraceEvent e); // `in` = readonly-struct by ref, no copy
}
- Injected into the VM as an optional constructor dependency (exactly like
IHost);null→NullTraceSink.Instance. - Invariant — observe-only: a sink never reads or writes VM state and never influences control flow. This is what protects the byte-identical selftest and lets us guarantee parity.
public VirtualMachine(Script s, OpcodeTable t, IHost host,
VmOptions? o = null, IScriptProvider? provider = null,
ITraceSink? sink = null) // null → NullTraceSink.Instance
GameSession.RunScene threads the sink through so cross-scene runs trace uniformly.
2. The event — TraceEvent
A readonly struct with a Kind discriminator and a small shared field set, so events cost no heap
allocation (a corpus sweep is ~1.46M instructions; a record hierarchy would allocate per event).
Constructed via static factories so call sites read clean:
public enum TraceEventKind { Step, FrameEnter, FrameExit, CallScript, Stub, Halt }
public readonly struct TraceEvent
{
public TraceEventKind Kind { get; }
public int Pc { get; }
public int Opcode { get; }
public int Depth { get; }
public long Id { get; } // call-script id
public long Steps { get; }
public string? Name { get; } // script/scene name; resolved call-script name
public string? Text { get; } // halt reason; frame outcome
public Instruction? Ins { get; } // Step carries the instruction (args) by ref — never copied
public static TraceEvent Step(int pc, Instruction ins, int depth);
public static TraceEvent FrameEnter(string name, int depth, FrameCause cause, long id = 0);
public static TraceEvent FrameExit(string name, int depth, string outcome);
public static TraceEvent CallScript(long id, string? name, bool resolved);
public static TraceEvent Stub(int opcode, int pc);
public static TraceEvent Halt(string reason, long steps);
}
public enum FrameCause { TopScene, CallScript }
Extensibility contract: a new kind = one enum member + one factory + one emit call. Consumers that don't recognize a kind ignore it.
3. Event vocabulary (v1) — where each is emitted
| Kind | Emitted at | Carries | Replaces |
|---|---|---|---|
Step |
RunFrame loop, per instruction — gated by TracingSteps |
pc, opcode, Ins, depth |
(new) |
FrameEnter |
top of RunFrame |
name, depth, cause (TopScene/CallScript), id |
(new) |
FrameExit |
bottom of RunFrame |
name, depth, outcome (Returned/Halted/RanOff) |
(new) |
CallScript |
case "call-script" op site |
id, resolved name, resolved? | IHost.CallScript |
Stub |
default: arm |
opcode, pc | IHost.OnStub |
Halt |
end of Run() |
reason, total steps | reconstructed from HaltReason |
CallScript (op-site) and FrameEnter(cause: CallScript) (child-frame) are complementary, not
redundant: CallScript fires even in provider-less stub mode (no child pushed); FrameEnter/FrameExit
bracket the real nested execution when a provider runs the child. Together they reproduce the
"SC0240 dispatched 29 call-scripts as nested frames" view — now as first-class engine events instead of
the Godot host-queue hack.
4. Boundary correction & migration
IHostlosesCallScript(long)andOnStub(int). All 11 implementers drop those two lines.OnStub's consumer was dead, so nothing migrates — theStubevent replaces it outright.- Call-script count becomes a VM property. Add
VirtualMachine.CallScriptDispatches(incremented at the same site that emits theCallScriptevent), mirroringSteps/HaltReason. Summary stats live on the VM; the detailed stream lives in events. The CLIrunline reads the property — no sink required for the common case. - The 3 real
CallScriptconsumers migrate:- CLI
run→vm.CallScriptDispatches. - Tests (
CallScriptIntegrationTests,CallScriptTests,RecordingHost) → a test-sideRecordingTraceSink(captures the event list, incl. ids forCallScriptTests), or the property for pure counts. - Godot
Dispatchedqueue → aGodotTraceSinkthat recordsCallScriptevents thread-safely;Mainreads them at scene end exactly as it readDispatched. This retires the motivating hack.
- CLI
5. Sinks shipped in v1 (two)
NullTraceSink— singleton;TracingSteps => false; emptyEmit. The default → total parity.TextTraceSink(TextWriter writer, bool includeSteps = false)— the one built-in formatter; writes deterministic text toConsole.Outor a file stream.includeStepsoff by default (Step volume); when on,TracingSteps => true. Formats each kind to a stable line (e.g.» enter SC0000/call-script 0x1ab =ADDITEM (resolved)/halt: exit @ 27994 steps).
Future sinks — noted, not built: CompositeTraceSink (fan-out to console + file), a JSON-artifact
sink (run diffing / regression), a divergence differ, and framework adapters at the edge —
SerilogTraceSink (rolling files / Seq) and EventSourceTraceSink (out-of-proc dotnet-trace /
PerfView profiling). Each is a plain ITraceSink; none binds the core.
6. CLI surface
run / play / sweep gain:
--trace attach a TextTraceSink to Console.Out
--trace-file <path> …to a file instead
--trace-steps include per-instruction Step events (verbose)
Absent ⇒ NullTraceSink. gfx/audio behavior is unchanged (their resolving hosts only shed the two
dead IHost methods).
Alternatives considered
- Serilog /
Microsoft.Extensions.Logging/ NLog / ZLogger (logging frameworks). All are output-oriented and stringly-typed for programmatic consumers, and want a dependency in the core. A future state-divergence differ wantse.Kind == Step && e.Opcode == …, notProperties["Op"]fished from a bag and re-parsed; and per-Steplogging through aLogEventallocates at 1.46M-instruction scale. MEL is the only one worth singling out (standard abstraction, DI-native) but buys little in a classlib + CLI + Godot app while adding a core dependency. Rejected for the core. - BCL built-in tracing (
DiagnosticSource,EventSource). Philosophically identical toITraceSink(in-proc, typed-ish, multi-subscriber,IsEnabledgate) and dependency-free. But payloads areobject/primitive-only — to stay allocation-free and typed at Step granularity you fight the API, and the subscriber ergonomics (IObserver<KeyValuePair<string,object>>) are heavier thanEmit(in e). Kept as a future edge sink (EventSourceTraceSinkgives freedotnet-trace/PerfView consumption), not the seam. - Decision: hand-roll the ~5-line typed
ITraceSink. Our constraints (in-proc, single producer, zero-alloc at Step granularity, typed payload for the differ, zero core deps, clean Godot/Mono build) all cut against what general frameworks optimize for; each would cost a dependency and an impedance mismatch to save five lines.
Testing
- Parity is the invariant. Default
NullTraceSink⇒ existing engine tests (25/25) and the Godot--selfteststay byte-identical. Migrated call-script tests assert the same facts viaCallScriptDispatches/RecordingTraceSink. - Hot-path guard. With
NullTraceSink, the loop adds onlyif (_sink.TracingSteps) …(short-circuit) plus emptyEmitfor the rare events — confirm asweeptiming is unaffected. - New tests (via
ScriptAssembler; synthesize-don't-disable — never disable a feature to keep a golden matching):- A synthetic scene with a nested call-script through a
RecordingTraceSink; assert the event sequence (FrameEnter(TopScene)→Step…→CallScript(resolved)→ childFrameEnter(CallScript)/FrameExit→Halt). - The
TracingStepsgate: off ⇒ zeroStepevents; on ⇒ exactly one per executed instruction. TextTraceSinkdeterministic formatting for each kind.
- A synthetic scene with a nested call-script through a
Files touched
- New:
engine/Age.Engine/Diagnostics/{ITraceSink,TraceEvent,NullTraceSink,TextTraceSink}.cs;engine/Age.Engine.Tests/TraceSinkTests.cs; a testRecordingTraceSinkhelper. - Edited:
Vm/VirtualMachine.cs(sink field + emit calls +CallScriptDispatches);Vm/GameSession.cs(thread the sink);Hosting/IHost.cs+Hosting/CaptureHost.cs(drop 2 methods);Age.Cli/Program.cs(--tracewiring; drop 2 methods fromAudioTraceHost/GfxTraceHost;runcount from property);godot/GodotAdvHost.cs+godot/Main.cs(drop 2 methods;GodotTraceSink); the 6 test hosts (drop 2 methods); migrated call-script tests. - Docs on completion:
docs/tools-reference.md(the--traceflag + sinks); status memory +MEMORY.mdline.