Skip to content

Halcyon Diagnostics

Apps/HalcyonDiagnostics follows a diagnostics session: a JSON Lines file a running program appends to. It draws the session’s counters, object censuses and profiled frames as they arrive. It is built from the Halcyon libraries alone: Halcyon.Diagnostics for the data and the file format, Halcyon.Diagnostics.Views for the views, and Halcyon.Sdl.Vulkan for the window. It knows nothing about the program it watches. Two programs write sessions today: the BONELAB mod and the engine. The engine’s Profiler window shows the same profiler view.

Terminal window
HalcyonDiagnostics <session.jsonl> # one session
HalcyonDiagnostics <folder> # the folder's newest session, switching when a newer one appears
HalcyonDiagnostics # the last folder it was given

A folder is the usual choice, such as BONELAB’s UserData\DigitalHeaven\diagnostics. A game started again writes a new session there, and the viewer moves to it within two seconds. The window’s size and place, the last folder and the log (latest.log) are kept in the per-user config directory, HalcyonDiagnostics.

  • Counters. Every counter, with its latest reading, its change over the session and a sparkline. Select one to chart it over the whole session, with the session’s marks (level starts, servers joined) drawn across it, so a bend in the line has a cause written beside it. A counter that only grows is drawn in amber, and Growing only lists those alone. The rule is ten readings, eight of whose steps rise, with no fall bigger than 2% of the peak.
  • Censuses. Every object census the program took, by exact type. A and B pick two to compare; they default to the first and the latest. The types that only grew across the last six censuses are listed above the comparison, ranked by growth per hour. Then comes the comparison itself: every type, its count in B and its change from A, the most grown first.
  • Profiler. Profiled frames, in the same view as the engine’s Profiler window: a frame-time graph, a timeline with one band per thread, and the scope tree. Every number takes the profiler’s defaults.

The status line says what was opened, and what was asked for.

There is no socket. The session file is the live channel, and the requests go the other way through a second file. Census now, Mark and Profile 10 s each append one line to <session>.requests beside the session. The three buttons are as large as the tabs under them, about 56 logical pixels tall with a 20 pixel label (ActionHeight and ActionFontSize on DiagnosticsStyle), so they can be hit from inside a headset or with a ray.

LineWhat the program does
census #idTakes a census when it next can. A program may wait, such as while a level loads, and writes a note saying so
mark #id <text>Writes a mark at once
profile #id <seconds>Turns its scope profiler on, streams the frames’ spans, then turns it off again

The #id is a number the viewer chose, which the program echoes back in an acknowledgment. A line without one is still carried out, and nothing answers it. A program reads the request file from where it last stopped. It starts after whatever the file held when the program started, so an old request is never carried out twice.

Each request goes through SessionRequestTracker, which reads the ack records the program writes (received, started, done) against a clock the viewer passes in. Pressing a button while its request is in flight does nothing. The button of a request in flight is filled with the accent.

ButtonWhile askedWhile runningFinishedNo answer
Profile 10 sWaiting for game… until the program says it startedProfiling… 9 s, counting down each secondProfile ready for four seconds, and the viewer opens the Profiler tab on the new capture’s worst frameGame did not answer after 8 s without a received; Profile did not finish after the length plus 8 s
Census nowCensus requested…Census #3 taken for four secondsGame did not answer after 8 s; Census still pending after 60 s once the program read it, since it may be waiting for a level to settle
MarkMarking…Marked at 3m12s, the session time the program stamped; the mark is drawn across the counter chartsGame did not answer after 8 s

A failure stays on its button for eight seconds. The status line under the tabs keeps the last thing that happened.

ack fieldMeaning
at, id, kindWhen, the request’s id, and census, mark or profile
statereceived when the program read the line; started when a profile began streaming; done when the census, the mark or the whole profile is in the file
nFor a finished census, how many censuses the program has written, which is its number in the Censuses tab

A profile’s done is written after its last batch, in the same order on disk, so a viewer that sees it has every span.

While it follows a folder, the viewer writes viewer.lock into it, holding its process id, and deletes it on exit. A program that writes sessions there can read it to tell its folder is being watched: the marker counts only while its process is alive, since a crash leaves the file behind. ViewerMarker in Halcyon.Diagnostics writes, reads and checks it. The BONELAB overlay’s Diagnostics entry shows “connected” for a viewer it did not start only when the marker is live.

One JSON object per line, flushed as it is written, so a reader sees each record as soon as it exists and a crash keeps everything before it. Every record names its kind in "t". Times are seconds since the session started.

tFields
sessionformat (1), program, host, started, frequency (ticks per second of the profile timestamps). Always the first line
countersat, v: every counter’s name and value, null for one that could not be read
mark, noteat, text. A mark is a moment worth finding again; a note is the program’s remark about the session
censusat, label, walkMs, ids, types: [type, kind, count, minId, maxId] per type
scopesnames: [id, name] for profile scopes, written before the first profile record that uses them
profileframes: [start, end]; tracks: name, order, gpu, spans as [start, end, scope]. A stretch is written as several records of at most 1000 frames or spans of one track each, which a reader files one after the other
ackat, id, kind, state, n: the program’s answer to a request that carried an id

SessionWriter writes this JSON by hand, on netstandard2.0 as well as net10.0, so the program being diagnosed takes no JSON library of its own. SessionTail reads it, and each poll reads only what was appended since the last. A line it does not understand is counted and skipped, so an older viewer reads a newer file.

The engine writes the same kind of session, through the same DiagnosticsRecorder (DigitalHeaven.Core.Profiling) the BONELAB mod uses, so the viewer follows a running client or an offscreen render with nothing engine-specific in it. Turn it on with client.diagnostics.session (off by default); it is read every frame, so a running client starts and ends a session when the preference changes. client.diagnostics.sessionSeconds is the gap between counter readings (1 s by default, at least 0.1 s). The folder is diagnostics under the engine’s user data (%LocalAppData%\DigitalHeaven\Engine\diagnostics, or under DH_ENGINE_DATA_ROOT), and the log names the file.

WhatContents
Countersframe ms mean and frame ms worst over the interval, gpu ms <pass> for every pass and gpu ms total (where the queue writes timestamps), draws, triangles, culled, vram used MiB and vram budget MiB, deferred objects, object loads pending, object upload wait ms, the netcode (net round trip ms, net pending inputs, net corrections/s, net starved ticks/s, net held frames) in a client, gc heap MiB and gc gen2. A counter nothing can read is left out rather than written as zero
Marksmap '<name>' loaded, session joined and session left, third person entered and third person left in a client; rendering map '<barcode>' in a render
ProfilesOn profile, the scope profiler’s frames for the asked length. While one streams, it takes the frames in place of the Profiler window’s own history
CensusThe object cache’s books: ready, stand-in, pending, failed and deferred objects. The engine has no walkable heap of typed objects, so this is its census

The reading itself costs about 100 ns a frame and a short write once an interval; a profile request is the only thing that touches the frame loop more, as above.

Terminal window
DigitalHeaven.Engine.Host --render out --map core:maps/render-eval --gpuTimings \
--set client.diagnostics.session=true --set client.diagnostics.sessionSeconds=0.1

Every frame the render draws counts, and a last reading is written when the run ends, so a short render still has rows. --gpuTimings is what makes a run long enough to chart.

A run on the cube writes its session there. Fetch it and open it here:

Terminal window
bun Engine/scripts/fetch-cube-diagnostics.ts [remote-folder] [local-folder] # copies the newest .jsonl, prints its path
HalcyonDiagnostics <that file>

The remote folder defaults to the engine’s diagnostics folder under ~/.local/share; pass the folder under DH_ENGINE_DATA_ROOT when the run was given one. By hand it is scp deck@cube:<folder>/<file>.jsonl ..

A profile is the one request that touches the program’s frame loop, so it is kept off it. With the scope profiler on, a scope is two timestamps and a write into the calling thread’s own ring, with no allocation (DiagnosticsRecorderTests asserts zero bytes over ten thousand scopes). Each frame the recorder drains the rings into the capture. Once a second it hands the batch to a thread of its own, which formats the JSON from the batch alone and writes it: the frame loop never formats a span, never writes to the file and never waits for it. The writer thread formats numbers by hand into a reused buffer, so a batch allocates no string per span, and it splits a long batch into records of 1000 items so no line becomes a big allocation. A profile’s end is queued behind its last batch.

Measured headlessly, 180 frames at 60 Hz with a profile streaming (the frame loop’s EndFrame call only):

Spans per frameBefore: mean / worstAfter: mean / worst
5000.32 ms / 14.4 ms0.12 ms / 2.1 ms
20000.44 ms / 14.3 ms0.26 ms / 1.5 ms
50001.03 ms / 30.1 ms0.58 ms / 3.3 ms

The worst frame was the second-boundary batch, which formatted and wrote a megabyte or more on the main thread. What is left is the drain and the sort of each frame’s spans, which is proportional to what the program records.

Terminal window
HalcyonDiagnostics <path> --capture out.png [--tab counters|censuses|profiler] [--size 1280x800]
HalcyonDiagnostics <path> --capture out.png --press census|mark|profile --state requested|running|done|timedOut

This renders the window once, headlessly, through UiCapture, and writes the PNG. It opens no window and changes neither the saved window nor the remembered folder. --press with --state shows a request button in that state, by filing the answers it would have waited for; it never writes to a request file.