Skip to content

Debug Overlays

Every debug visualization the engine draws is a client preference under client.debug., off by default, toggled live from the console (client.debug.hud 2) with no relaunch. They are client-local and cosmetic: nothing under this namespace is networked, predicted, or visible to anyone else on the server.

PreferenceTypeDefaultWhat it draws
client.debug.entityGizmosboolfalseA dot at every in-view logic entity’s live pivot, a name-and-value label above it, and the authored wiring lines between them — during ordinary play. The level editor draws them whatever this says, in its own inks and filtered by the selection.
client.debug.viewDotboolfalseThe two points the frame is built from, marked in world space: a filled dot at the aim eye, a wider ring at the view origin, and a line between them measuring how far the neck has swung the camera off the eye. In first person they only appear where they genuinely land in frame; a detached editor camera finds the pair standing on the pawn’s head. The same numbers are printed to the client channel when an avatar is worn.
client.debug.hudint0The corner debug HUD level: 0 off, 1 numbers, 2 + orientation and velocity gizmos, 3 + the rush-signal figures.
client.debug.axisGizmoPlatebooltrueThe gizmo’s dark backing plate. Off leaves only the outlines. Only meaningful while the gizmo is on.
client.debug.effectsboolfalseThe dynamic visual-effects readout at the top center: the shared speed signal, and under it every effect that reads it — including which limiter is holding the motion blur down.
client.debug.effectsDistancefloat (m)5The depth the effects readout quotes a motion-blur streak against, and therefore the depth it decides whether the screen cap is binding at.
client.debug.colliderGizmoboolfalseWireframes of every solid body the collision world reports near the camera, tinted for the ones the local mover is resting against, with a name label close up. Reads live from the physics world’s own census, so it cannot miss or drift from a live scene edit. A voxel volume’s chunks show as mesh wireframes labeled with the volume’s name. client.debug.colliderGizmoDepthTest draws it depth-tested (occluded by geometry) instead of through walls; client.debug.colliderGizmoTouchBudget/client.debug.colliderGizmoIdleBudget cap how many line segments a frame spends on touched vs. untouched bodies (0 is unlimited), touched bodies spent first so what the player stands on is the last thing dropped.
client.debug.contactListboolfalseA stacking text list near the position readout naming the bodies the local mover is touching this frame — the support/ground body first, then the rest of the contact manifold — each line fading out over client.debug.contactListFadeSeconds after contact ends.
client.debug.lightsboolfalseEach lit light’s influence volume — a range sphere for a point light, the outer cone for a spot — tinted with that light’s own color.
client.debug.cascadesboolfalseTints shaded surfaces by which sun-shadow cascade shadowed them: red (near), green (mid), blue (far), gray past client.render.shadowDistance.
client.debug.contactsboolfalseLogs rather than draws: the local player’s mover collision contacts (collider identity, bounds, contact point and normal, feet position) to the predict channel, on contact-enter only and de-duplicated. A voxel chunk is named by its volume, grid and chunk, and the cell, block and surface the player touched.
client.debug.mapSubMeshModeint1How a map GLB’s draw ranges are partitioned on the next map load: 0 per-primitive, 1 merged by material, 2 split per node. Output is pixel-identical; only the draw-call count moves.
client.debug.frameStatsint (seconds)0When positive, logs mean/p50/p99/min/max frame time and derived FPS to the client channel every N seconds. 0 disables.
client.debug.frameBreakdownint (seconds)0The same timer one level down: logs where the frame’s milliseconds went, scope by scope, every N seconds. 0 disables.
client.debug.profileFramesint (frames)300How many frames a bare profile records when it is typed with no count.
client.profiler.*variousThe Profiler window’s history length, metrics and colors.
client.diagnostics.sessionboolfalseWrites a Halcyon Diagnostics session: counters, marks, a census and, on a viewer’s request, the scope profiler’s frames, in a file the viewer follows live. client.diagnostics.sessionSeconds (1) is the gap between counter readings. An offscreen render takes it with --set client.diagnostics.session=true.

Because these are an ordinary preference branch, the namespace verbs work on the whole family: get client.debug lists every one with its current value, and reset client.debug restores all of them at once.

Every overlay on this page paints into Halcyon’s overlay draw list — the flat command list underneath the widget system, not the widget system itself. A corner-pinned readout and a world-projected marker both name their own coordinates, and Halcyon’s flex layout has no absolute placement to name them with, so they skip it and emit primitives directly.

The list is spliced in first, beneath the widget tree, so the console and the pause screen cover the tooling rather than the other way round. The only thing above it and still below the screens is the crosshair, which is where a reticle belongs: no gizmo or readout may cover the mark a shot is lined up on, and no reticle may land on a menu. Both halves are photographed offscreen — crosshairInPlay puts the mark over the scene-pivot gizmo, and crosshairUnderMenu holds the request open with a pause screen up so the covering itself is on film. Everything is in the post-tonemap LDR pass, which is why the Panini warp cannot reach it.

Every surface on this page — the frame-stats card’s deeper levels included — records into the Tooling list, spliced in under every screen: they paint under the pause menu, the main menu and their scrim, and under the console and the options window. A marker pinned to a world position has nothing to say once a screen has covered the world, and the frame-stats card’s netcode is the same argument one step out — while a menu or the developer overlay’s bar is up, the frame it is counting is the menu. The player’s speedometer and the compact frame-stats card live in the Hud band, spliced to land exactly where Tooling does — under the menus, dimmed by the same scrim — so a speed is covered the same way the world it reports on is.

In the level editor the band moves up a little, and the reason is that the world stops being the frame: it is a picture drawn into a docked pane by a screen, so a band under the screens is a band under the world. Whenever the game view is docked the whole Tooling list is spliced just past that picture instead — and inside the picture’s own scissor, because the mark that names the point is taken within the clip the pane cuts its image to. Every surface on this page then paints over the pane, is anchored into its corners rather than the display’s, and cannot reach a pixel the pane does not own: not the pane’s tab strip, not a divider, not a panel docked beside the view, and not the toolbar. That last part is the scissor’s doing rather than the ordering’s — a pane the dock solver placed first is painted first, and no index could put the band beneath it. Anything the editor draws over the game view itself, a floating window included, still covers the band the ordinary way.

World-anchored surfaces stop at the main menu. A session ending unloads the map, so there is no world for a gizmo to be anchored to — the axis gizmo is gated on being in a session rather than on its preference, and the position readout, the entity gizmos and the scene-pivot gizmo already fall out on their own because there is no eye to project from. client.debug.axisGizmo stays exactly as you left it and comes back with the next session. The frame-stats card is not gated: the client is still rendering frames at a menu, and that is the number it reports — only its map name leaves with the world.

Three consequences worth knowing:

  • The text is the engine’s bundled JetBrains Mono, at the size client.ui.overlayTextSize names, so every readout on this page and every player screen are the same typeface at the same rasterization quality. See the readouts’ text size.
  • The layout is pure and unit-tested. AxisGizmoLayout, PositionReadoutLayout, EffectsReadoutLayout and ScenePivotGizmoLayout take numbers in and give shapes out with no device anywhere, so what a gizmo claims is asserted rather than eyeballed.
  • They can be photographed. dh render --renderUi composites them into an offscreen capture, one image per HUD level, on a machine that never opens a window.

The corners are the picture’s, not the display’s

Section titled “The corners are the picture’s, not the display’s”

Every readout on this page is corner-anchored, and each one anchors to the corners of the rectangle the world was rendered into rather than to the display. In play those are the same rectangle. With the level editor open they are not: the world renders into the game view’s docked pane, and the readouts — which are about the world — anchor inside that pane and are clipped to it. Drag the divider beside the game view and the frame-stats card follows it; close the pane’s neighbors and it goes back out to the window’s own corners.

The readouts never learn that an editor exists. The rectangle arrives on the draw list they were already handed, as the one rectangle the projection, the pick ray and the renderer’s own image are all sized from — so a readout cannot end up describing a different picture than the one it is drawn over.

client.ui.overlayTextSize sets the size of every block on this page: the frame stats, the position readout, the effects readout and the sound-cue panel. It defaults to 12 and accepts 8–48.

The number lands on the nearest size the font atlas actually baked — 11, 12, 14, 15, 22, 44 — rather than being honored exactly. A size the atlas has no bitmap for is drawn by stretching the nearest one, and these are the surfaces a bug report is a screenshot of, so the reachable answers are the ladder and the range only keeps a typed line inside it. Ask for 13 and you get 12; ask for 30 and you get 22.

It is a separate knob from interface scale: the scale magnifies the whole interface, this picks the rung the readouts sit on, and the two compose. Offscreen captures ignore it entirely and pin the default — a photograph that changed with a local preference would not be a photograph of the same surface twice.

client.render.debugView lives in client.render., not client.debug. — it is a full replacement of the opaque pass’s output rather than an overlay drawn on top of the normal image, so it sits with the rest of the renderer’s tunables instead of the boolean/leveled family above. Setting it switches every opaque surface to one diagnostic channel at a time:

ValueShows
offThe normal lit image. Default.
albedoThe material’s base color, before any lighting.
normalThe shaded world-space normal, remapped from [-1, 1] to [0, 1] so it is visible as color.
roughnessThe roughness scalar (after the metallic-roughness map is applied), as gray.
metallicThe metallic scalar (after the metallic-roughness map is applied), as gray.
emissiveThe material’s emissive term alone.
occlusionThe occlusion the diffuse ambient is multiplied by: the material’s occlusion map combined with the screen-space pass when client.render.ao is on. 1.0 (fully unoccluded) where neither occludes.
depthLinear view depth, normalized against client.render.shadowDistance so it reads as gray-white near to far.
lightingOnlyDirect and ambient shading with the material’s albedo forced to white, isolating the lighting itself from the material’s own color.
lightmapThe baked lightmap contribution alone, before albedo. Black everywhere on a map with no bake, and black on any surface the atlas does not cover — which is exactly how you find geometry the packer skipped.
lightmapTexelsThe lightmap’s texel density, the engine’s answer to Unity’s Baked Lightmap and UV Charts scene views. A checker through each surface’s lightmap coordinate in which every square is one texel of the bound atlas page, tinted by how the triangle’s density compares with the target its page’s bake zone packs at: blue below it, green within client.render.lightmapTexelBand of it (±25% by default), red above it. The density is texels per meter, the triangle’s lightmap-UV area over its world area. The target is the zone’s texelDensity, else the map’s lighting.lightmap.texelDensity, and never includes a node’s lightmapScale: a node scaled to 2 reads red and one scaled to 0.5 reads blue, which is what makes this the view to tune scale in. A surface with no texels — an unlightmapped draw, a node whose lightmapScale is 0, or any surface while no bake is bound — is hatched gray. Squares smaller than a pixel flatten into their tint rather than alias.
skyThe procedural sky’s radiance along the eye ray through each fragment — every surface reads as the atmosphere behind it. What it checks is plumbing rather than a material: the sky’s parameters ride the shared frame uniform, so the opaque pass can reconstruct them with no sky pipeline bound. A world with the sky off reads as the model evaluated against its parameters anyway.
decalMipThe coarsest mip level the topmost material decal actually samples at — its sheet, its mask, or whichever of the two gives up more detail — as a banded color ramp: one hue per whole level, dimmed a half step every six so a level and the level six above it are never the same color. A surface wearing no decal, and a fragment outside a textured decal’s own sheet, read as flat black. This is the one view that photographs a sampling DECISION rather than a material term, which is what a decal that reads soft needs: a coarse level over a small UV island, a texture that never reached memory at full size, and a decal that is simply small on screen are the same picture in the shaded result, and only this one tells them apart.
voxelLightThe voxel light field, read the way the scene will read it — see Voxel light below.
faceOrientationBlender’s face orientation overlay: every front face blue and every back face red, whatever the material’s own color — see Face orientation below.

Set it live from the console — set client.render.debugView normal — and the very next frame’s opaque pass renders that channel; set client.render.debugView off (or reset client.render.debugView) restores the normal image. Typing set client.render.debugView and a partial value autocompletes against the enum’s members, the same as any other enum-valued preference. In the level editor the same list is View ▸ Debug View, one checked row per value; headless, --render takes --debugView <value>.

The lightmapTexels view’s colors and band are preferences of their own, applied on the next frame:

PreferenceDefaultWhat it is
client.render.lightmapTexelBand0.25How far from the target, as a fraction of it, still reads as on target.
client.render.lightmapTexelUnderColor[70, 130, 255, 255]The tint below the band.
client.render.lightmapTexelOnColor[90, 210, 90, 255]The tint inside it.
client.render.lightmapTexelOverColor[240, 80, 64, 255]The tint above it.
client.render.lightmapTexelNoneColor[128, 128, 128, 255]The hatched gray of a surface with no texels.

Each page’s target travels in the frame uniform, sixteen pages of them; a page past the sixteenth is measured against the map’s own density.

The active view travels to the shader as a single integer, riding the otherwise-unused w lane of the frame uniform’s shadowParams vector — the same lane-reuse the client.debug.cascades tint above already does on y — so the opaque shader holds every view in one switch rather than a pipeline permutation per view. The whole group is one variant bit: a frame with no view and no cascade tint on records with variants that compiled the switch out, and turning a view on compiles the variants that keep it the first time it is drawn.

Screen-space overdraw is not one of the views: it needs a second accumulation pass this feature does not add, so it is out of scope here.

voxelLight shows a voxel volume’s block and sky light, 0 to 15 each, to compare against Minecraft’s F3 screen (which reads the same two numbers at the block the player stands in). Block light is orange and sky light blue, each as bright as its level, so open daylight reads blue, a torch-lit cave orange, and a torch at the mouth of a cave both. A dark line marks every whole level, so the levels can be counted outward from a torch: the diamond of a block light is fourteen bands across from a torch. A fragment reads its light through Minecraft’s smooth corner rule, so the bands curve across a face as in-game smooth lighting does. Inside a lit volume’s window, a cell whose light is not computed yet (a column still streaming in) is magenta; outside every lit volume a surface reads as its albedo, dimmed. A volume that computes no light (client.voxel.lightBudgetMb 0, or a volume of painted colors) is outside, everywhere.

faceOrientation finds flipped normals and inside-out meshes. A front face reads blue and a back face red, and the material’s color and tint never choose the hue. What the material does contribute is its base color’s luminance (after the texture, detail layer and decals), remapped between a floor and full brightness, so a texture still reads under the tint while a pure-black material is still plainly blue or red. There is no lighting, fog or tint in the view.

Culling is off while the view is on. Every draw in the scene pass (opaque, alpha-tested, blended, skinned, the avatars and the map backdrop) is recorded with both faces drawn, whatever its material’s renderFace says, so a back face shows red instead of vanishing. This is the same per-draw dynamic cull state renderFace already sets, so there is no second pipeline and nothing to rebuild; the cost is the triangles culling would have skipped, rasterized and mostly rejected by the depth test behind the faces in front of them, and only while the view is on. A mirroring model matrix flips the pipeline’s front face with it, so blue still means the authored front.

The view reaches exactly the passes the other debug views reach, which is the one scene shader: opaque, alpha-tested and blended surfaces, decals, skinned avatars and the backdrop. The glass and water passes, the foam, the sky and the occlusion prepass do not read any debug view and stay as they are.

PreferenceDefaultWhat it is
client.render.faceOrientationFrontColor[48, 94, 217, 255]The front-face tint.
client.render.faceOrientationBackColor[217, 36, 36, 255]The back-face tint.
client.render.faceOrientationFloor0.45The brightness a pure-black material reads at, as a fraction of the tint.
client.render.faceOrientationDetail1How much luminance shows: 0 is flat tints, 1 the whole range from the floor up to the tint.

Blender draws the same overlay with pure blue and red theme colors (face_front 0x0000ff, face_back 0xff0000b3 in userdef_default_theme.c) blended over the shaded surface. This view replaces the surface, so its default tints are those hues softened enough for a texture to read.

client.debug.frameStats says how long a frame took. The profiler says where it went: mean and p99 for every stage the frame loop marks — world gizmos, editor pick, editor highlights, editor ui, ui, world view, present and the rest — costliest first, with each stage’s share of the measured milliseconds.

It has two outputs and one accumulator behind them, so a number read off a log line and a number read out of a file cannot disagree:

  • client.debug.frameBreakdown <seconds> logs the summary line to the client channel every N seconds, exactly the way client.debug.frameStats logs a pacing one.
  • profile [frames] records the next N frames one row per scope per frame and writes them beside the session log as frame-profile-<timestamp>.csv, then prints the same summary to the console. With no count it records client.debug.profileFrames.
  • profiler toggles the overlay’s Profiler window exactly as its menu-bar tab does; with the overlay hidden it brings the overlay up with the window showing.

The samples are the frame loop’s own stage list, not stopwatches of the profiler’s own — there is one stage list per frame and everything that wants to divide a frame marks into it. Where the device writes timestamps, the per-pass GPU figures render.gpuTimings prints ride into the same snapshot under a gpu source column, so one file holds both sides of a frame instead of two that have to be lined up by hand. CPU stages and GPU passes are ranked separately and only the CPU stages carry a share: the two sides overlap in time, so a share across both would claim a fraction neither has.

The file starts with #-commented header lines naming the map, the resolution, whether the editor was open and how much was selected, and the build — a snapshot outlives the session that took it. Idle it costs one comparison per frame and allocates nothing.

An offscreen render takes the same snapshot with --render <dir> --profile. Every frame the render draws is recorded, and when the run ends frame-profile-<timestamp>.csv and its .json trace land in the render’s output folder, written by the very same FrameProfiler and trace writer. The frame’s one stage is render; the scopes inside it (passes, culling, cascades) are the spans. A lane can profile without opening a window. A render draws one frame per camera, so add --gpuTimings for a warmed run of about 150 frames.

The stage list divides the frame loop’s own thread into a flat run of stages. Under it sits the scope profiler: nested scopes, recorded per thread, that show what a stage spent its time on and what every worker thread was doing meanwhile.

using DigitalHeaven.Core.Profiling;
// Registered once, carried as an integer from then on.
private static readonly ProfileScopeId MeshScope = ProfileScopes.Register("voxel mesh region");
JobResult Prepare(Job job)
{
using var meshing = Profile.Scope(MeshScope);
// ...the work being measured...
}
  • Off, a scope is one branch. Profile.Scope returns an empty struct while Profile.Enabled is false, and disposing it does nothing. Nothing is allocated, and a test holds that to zero bytes.
  • On, it is two timestamps, two allocation readings and one write. The span is written when the scope closes, carrying its own start and the managed bytes its thread allocated while it was open (GC.GetAllocatedBytesForCurrentThread, bound once by reflection because netstandard2.0 lacks it, and zero where a runtime has none), into the calling thread’s own ring buffer. Nothing else writes to that ring, so there is no lock. The frame loop drains every ring once a frame. A ring that overflows between two drains loses its oldest spans and nothing else. A span the writer could have lapped mid-copy is dropped, never read torn.
  • The allocation tally is the cheap half. Profile.TallyAllocations adds each scope’s own bytes (its nested scopes’ taken out) to a per-scope total without timestamps or rings; Profile.TakeAllocationTally drains it, largest first. It costs two counter reads per scope, allocates nothing, and keeps Profile.Scope at one branch while both are off. The BONELAB counter log keeps it on, to name the scopes that allocate most each minute.
  • The depth is not recorded. It is recovered from the extents, because a span’s parent is the innermost span that wholly contains it. That is what lets the stage list join the same hierarchy: while the profiler is on, every StageTimer.Mark anywhere in the engine is written as a span on the thread that marked it. The frame loop’s stages, a server tick’s stages and an object load’s stages all appear with no scope of their own.
  • A scope opens and closes on one thread. A scope opened before an await and closed after it would write into another thread’s ring.

Where it lives. The recording side (Profile, ProfileScopes, each thread’s ProfileThread ring and the ProfileCollector that drains them) is in DigitalHeaven.Core, under DigitalHeaven.Core.Profiling, so a game mod records into the same rings the engine does. What a ring drains into (ProfileHistory, ProfileSpan, the nesting and the ProfileTree aggregation) is the unbranded Halcyon.Diagnostics, and the Profiler window’s body (graph, timeline, tree) is ProfilerView in Halcyon.Diagnostics.Views. The engine’s window is that view in an overlay window, dressed in the overlay’s theme and buttons. The same view runs in the Halcyon Diagnostics app over a session file. A span carries its scope as a plain integer, and a history names it through its own ScopeName, so a history read from a file names its scopes from the file.

To add a scope, register its name in a static readonly ProfileScopeId field and wrap the work in using (Profile.Scope(id)). Put it where the time actually goes, and name it what a person would search for. A name built from data (a count, a path) grows the table. Past ProfileScopes.MaxScopes, every new name folds into other.

What is scoped today:

WhereScopes
Frame loop (main)frame, every stage of the stage list, frame limiter (the pacing sleep, outside the frame)
Net and predictionnet poll, reconcile, predicted tick › send input, predict (mover), after tick (props)
Voxels, frame sidevoxel plan, voxel submit, voxel reclaim, voxel upload
Voxels, workersvoxel mesher job › voxel mesh region or voxel open
Every ObjectLoadWorker<thread name> job around each request, so the object loader, the voxel meshers and the voxel collision cookers all show up
Renderingvisibility prepare, record <pass> for each pass of the main view (the CPU writing that pass’s commands), shadow cascade
Any StageTimerits stages, on whatever thread marks them: the local server’s tick, object loads, device rebuilds

Where the device writes timestamps, each frame’s passes go on a track of their own, named GPU, with the names render.gpuTimings prints. The device’s clock is not calibrated against the CPU’s, so the frame is placed so it ends where the frame loop read its timestamps back, the one moment known to be after it finished. The durations and the order are the device’s own. The position along the CPU’s timeline is an approximation.

Profiler on the developer overlay’s menu bar opens it. The scopes record only while the window is showing or a profile snapshot is armed, and closing it turns them back off.

  • The frame-time graph at the top has one bar per kept frame: green under the first guide, amber under the second, red above it. The frames the timeline is looking at are shaded. Click a frame to pause on it. The timeline then frames that frame alone, and the tree aggregates it alone. Worst frame does the same for the longest frame kept.
  • The timeline has one band per thread and one for the GPU, with bars nested by depth. Each scope’s color is a hue hashed from its name at one OKLCh lightness, so a scope keeps its color across frames, sessions and machines. Frame boundaries are white lines. The two guides (client.profiler.guideMs and secondGuideMs, a 60 Hz and a 30 Hz frame) are drawn after each frame’s start. Hover a bar for its name, its milliseconds and its thread. Wheel zooms about the pointer, and drag pans.
  • Pause freezes the history so a spike can be read. The rings are still drained underneath, so nothing overflows and a snapshot still records.
  • The tree lists every scope by every path it was reached by, per thread. Its columns are calls per frame, then self and total milliseconds, each as a mean per frame and as the most any one frame spent, then alloc KB, the managed kilobytes allocated per frame with the children’s included. Self is a span less the spans nested directly inside it. A worker’s span is charged to the frame it started in. Click a column header to sort siblings by it, a chevron to fold, and a row to light that scope’s bars across every thread and fade the rest. The live tree is re-aggregated every client.profiler.treeRefreshSeconds, so it can be read while it runs.

Every number the window is drawn with is a client.profiler.* preference: frames kept (frames), frames the timeline spans unzoomed (timelineFrames), ring size per thread (ringEvents), row height and depth (rowHeight, maxDepth), label column (labelWidth), colors (chroma, lightness, dimOthers), graph (graphHeight, graphCeilingMs), split (timelineShare), zoom step (zoomStep), tree (treeRowHeight, treeIndent, treeColumnWidth, treeOpenDepth), and the window’s opening size (windowWidth, windowHeight). get client.profiler lists them.

Snapshots carry the nested data, and a Chrome trace

Section titled “Snapshots carry the nested data, and a Chrome trace”

A profile snapshot records the scopes along with the stages. The CSV gains thread, depth and startMs columns. Every span on every thread is a span row, in the snapshot’s own frame numbering, with its depth and its start within its frame. The stage and pass rows leave those three columns empty.

Beside the CSV, the same capture is written as frame-profile-<timestamp>.json in the Chrome trace event format. Open it in Perfetto or chrome://tracing. Each track is a thread of one DigitalHeaven process, named and ordered the way the window shows them, with one complete event per span and times in microseconds from the capture’s first frame. One path produces both files, so the CSV and the trace cannot disagree.

The HUD is one CUMULATIVE level, client.debug.hud, rather than a preference per widget: 1 numbers, 2 adds the gizmos, 3 adds the rush-signal figures. The pieces answer the same “where am I, which way, how fast” question at increasing resolution and share a corner, a margin and a stacking order, so a level makes that ordering explicit and turning the HUD up never means remembering which four things to enable.

The opposite corner holds the frame-stats card, client.showFps. It answers a different question (“is the client keeping up, and where”) from the coordinates, and reading it next to them would mean reading one to get to the other. It is its own preference rather than a rung of client.debug.hud, and it is on by default at its compact level, so every screenshot says which map it was taken on.

client.showFpsWhat the card shows
0Nothing.
1 (default)One line: the frame rate at the card’s left edge, its number bold and its unit faint, then the map, spelled the way every surface spells a map (the pallet prefix faint, the name bold, no extension). A frame rate under one prints a decimal, because 0 fps reads as a hang. A host with a heat or power reading (the iPad) adds it, faint, beside the rate.
2Adds a summary line under the rate, starting in the same column: the smoothed frame time, a graph of the recent frames, and the round trip judged against its thresholds. The round trip is left off a link with none to report, which is every local one; the netcode level’s rtt row still prints it.
3Adds a hairline and the labeled rows, in the position readout’s grammar: build (the build’s sha, which is on this level and not the summary one, and the world time a --worldSeconds render needs to match the water), clock (tick rate, clock rate, server tick), input (pending inputs, the server’s buffer against its target, starved ticks), corr (the last correction in centimeters, the eased-out remainder, the correction rate), rtt (round trip and jitter) and remote (the interpolation delay, the ticks behind and the margin earned, held frames, snaps and the deepest burst). A headset session adds a vr row.

The frame rate is averaged over wall time, not over frames: each frame weighs in by its own length against client.hud.frameRateSmoothing (seconds, 0.16 by default, which reads a steady 60 fps as the old fixed ten-frame average did). A drop to one frame a second therefore reads as one within a couple of seconds, and the recovery is as quick, where a per-frame share would have taken a minute to walk down. The shown frame time is its reciprocal. The rate counts frames drawn: an unfocused window paced at the tick rate but drawing at client.display.unfocusedFrameCap reads 30, not the 60 loop iterations that carried it.

The card is as wide as its widest line and no wider: the rate sits at the left edge and the map follows it at client.hud.readoutHeaderGap, so a short map name does not leave the line stretched across an empty gap. The map only appears while there is a world: at the main menu and across a join that has not landed the rate stands alone. The compact card is the player’s and records into the Hud band beside the speedometer, painted by the shared layer on every host; the deeper levels are a developer’s and record into Tooling. client.debug.gpuTimings adds the per-pass GPU block under the card, after another hairline, at any level.

Net readings past their thresholds take the warning and alarm inks (see net health). A Debug build (JIT optimizer disabled, read from the assembly at runtime, never a compile symbol) ends the sha with DEBUG; Release shows nothing. The startup line [host] build <sha> <branch> <time> and the version verb end with Debug or Release, so a slow run says what it was.

The lines are built by the pure FrameStatsLayout and painted by the same painter as the position readout, so what each level reports is asserted with no font, no device and no frame.

Note this is not client.debug.frameStats, which logs percentiles to the console on a timer and draws nothing. The two are complementary: the card is what you glance at, the log is what you page back through.

The summary line’s graph is the last 120 frames, or the last client.hud.frameGraphSeconds of them if that is fewer, cut into columns, oldest on the left. The seconds bound is what keeps a slow client from showing minutes of history: at one frame a second the graph holds two frames, not a hundred and twenty. Each column is as tall as the slowest frame in its slice, against a fixed scale, so a hitch survives the cut that a mean would hide: at the defaults it is 60 columns of two frames each, 20 ms tall. A column whose worst frame took at least client.hud.frameGraphWarnMs is drawn in the warning ink and from client.hud.frameGraphAlarmMs in the alarm ink, the same two inks the net readings use; a frame slower than the scale is cut off at the top rather than rescaling the rest. A card that has seen fewer frames than columns fills from the right. There is no second history: the graph is cut from the ring the smoothed frame rate already keeps.

A column plots what a frame cost, not how long it lasted: the frame’s length less any wait the frame limiter chose. Under a cap the length is the cap by construction, so graphing it said nothing about the engine and, at a 60 Hz loop, put every frame on the 16.7 ms warning line. A wait that ran past its deadline is not subtracted, so a late wake still shows. A shell with no limiter, like the mobile one, waits by choice never, and its columns are its frame lengths as before.

PreferenceDefaultMeaning
client.hud.frameGraphColumns60How many columns the window is cut into, at most one per frame in it.
client.hud.frameGraphSeconds2The most wall time the window reaches back, however few frames that is. At 60 fps it is the whole 120-frame ring.
client.hud.frameGraphWidth60The graph’s width in logical pixels, however many columns it has.
client.hud.frameGraphScaleMs20The frame time that fills a column to the top.
client.hud.frameGraphWarnMs16.7From this frame time a column takes the warning ink.
client.hud.frameGraphAlarmMs33.4From this frame time a column takes the alarm ink.

Both corner readouts, the frame-stats card and the position readout, read one set of client.hud.* preferences and draw through one painter and one plate, so the two corners always match. The plate is a soft neutral gray with no border: the HUD plate’s silhouette and blur, but not its plum fill or its double border, which stay on the speedometer and the other floating surfaces.

PreferenceDefaultMeaning
client.hud.readoutBackgroundTransparentNone floats the text on the world with only its one-pixel shadow; Transparent is the soft gray plate, translucent and blurred; Card is the same plate opaque and unblurred. Also in Interface → Performance.
client.hud.readoutPlateOpacity0.8The ground’s opacity under a Transparent readout, in linear light.
client.hud.readoutPlateColor[44, 44, 48, 255]The plate’s fill as RGBA, each channel 0-255; its alpha multiplies the opacity above.
client.hud.readoutPaddingX10Space to the left and right of a readout’s text inside its plate, in logical pixels.
client.hud.readoutPaddingY6Space above and below it.
client.hud.readoutFaintOpacity0.55How much of the ink a faint field (a pallet prefix, a unit, a build stamp) keeps.
client.hud.readoutHeaderStep1How many font rungs the card’s first line sits above client.ui.overlayTextSize, so it follows the text when the text is resized.
client.hud.readoutHeaderGap16The space between the frame rate and the map’s name.

The plate, not the text, sits on each corner’s margin, and the margin, the padding and the text all grow with the interface scale, so the iPad’s 2x picture is the desktop’s at twice the size.

From level 2, the engine draws a small world-axis orientation gizmo in the bottom-right corner: three colored sticks from a common origin — red +X, green +Y, blue +Z — each labeled with its axis letter, turning as the camera turns.

It reports orientation only. The gizmo is built from the camera’s yaw/pitch/roll and nothing else — no eye position, no field of view, no Panini constant enters the math — so it never translates, never breathes with the speed field of view, and never bends. It is drawn into Halcyon’s overlay draw list after the HDR resolve, in overlay space rather than the scene pass, specifically so the resolve’s Panini warp cannot warp it. (This is the opposite choice from the world-anchored gizmos above, which do project through the scene frustum — they have to, because they are glued to subjects in the world.)

Arms shade with depth so you can tell “toward you” from “away from you”: an arm pointing at the viewer keeps its full color, one pointing away keeps only MinArmShade (0.35) of it and sits the rest of the way toward a near-black, with a linear ramp between.

client.debug.axisGizmoPlate (on by default) adds a soft dark backing plate behind the widget. The halos alone read well over bright and busy scenes, but a receded arm darkens toward the same tone a night exterior already is; the plate gives the gizmo a known ground so both ends of the depth ramp stay legible. Turn it off to keep the corner clean. The plate’s radius is the gizmo’s full extent, so it can never clip a label, and the widget’s margin is measured from that same extent — sticks, labels and plate all sit inside it. It is a style choice rather than a level, which is why it stays its own preference.

A fourth stick, in violet, shares the same origin and the same camera basis as the three axes — so you read which way you are actually moving against world space, rather than against a separate widget. Violet is deliberately none of the three axis colors, so it can never be mistaken for one.

Its length is the speed meter. The stick fills from nothing to a full stick as the rush ramp goes 0 → 1 — the same normalized signal the fall wind and the speed field of view are driven by. So a stick still at the origin means the motion is inside the deadzone and neither effect has fired; a full-length stick means both are saturated. That is the question worth answering while tuning client.audio.fallWind.startSpeed, and it costs one stick rather than a second widget competing for the corner.

Note the length tracks the ramp, not the raw speed: past the full-gain threshold the stick stops growing, because the effects it reports have stopped growing too. A still pawn draws no stick at all — a zero-length one would read as a bug rather than as stillness.

Level 3 prints the same ramp as figures, for when a length is not precise enough to pick a threshold from.

From level 1, a four-line numeric block (five while the third-person camera stands back) is pinned to the same bottom-right margin, sitting directly above the gizmos at level 2 and up (it drops back down to the corner at level 1). Level 3 adds a fifth line. It stands on the same background as the frame-stats card.

LineLevelShowsUnits
pos1The feet origin under the viewpoint — the standable point, not the eye.meters
view1Where the picture is drawn from: the eye (the feet position plus the current stance’s eye height) in first person, the third-person camera when it stands back, the editor’s camera while it is flying.meters
ang1The view’s angles as p pitch, y yaw, r roll — a map object’s rotation array, read straight across. Roll is the cosmetic camera-roll tilt; it always prints a number, because a labeled column with nothing under it reads as a broken readout, and the field’s own rounding already resolves a resting spring to one steady +0.0.degrees
dist1Only while the third-person camera stands back (distance above 0): d the camera’s distance from the player, then near or iso for the zone it is in.meters
vel1Horizontal ground speed and signed vertical velocity, separately. This is raw velocity, deliberately not the ramped SpeedRush feel signal the wind and speed-FOV read.m/s
rush3The rush signal: s the reduced speed, r its 0..1 ramp, then the active start>full window.m/s, ramp 0..1

Every field is fixed-width, right-aligned, explicitly signed and formatted invariantly, and every line is padded to the same length — so nothing shifts sideways frame to frame while you watch a number change. It is one width, shared by meters, degrees and speeds: a narrower degrees field made the ang row a column of its own, with p +56.0 keeping a gap between label and value while y-131.1 in the very next field filled it edge to edge, and neither landing under the coordinates directly above. Rows are quantities and columns are axes, and that only reads as a grid if the nth field of every row starts on the same character. The block’s width is the pos row’s natural length; every shorter row pads out to it rather than widening it, so turning the HUD up to 3 adds a row without moving the block.

Field labels are one character everywhere, including ang’s. The row is already named, so spelling yaw out inside a row called ang says the same thing twice and spends three columns doing it — and those columns are exactly what pushes the angle numbers out of line with the coordinates above them.

In the editor, the block follows the viewport

Section titled “In the editor, the block follows the viewport”

The block reports one viewpoint, and which one follows the frame’s camera. Ordinarily that is the player pawn. In the level editor, the moment the camera detaches and starts flying, the whole block switches to it — position, angles and velocity together — and the view row follows it.

That is the block earning its keep as an authoring tool rather than a telemetry strip: fly the viewport to where a camera or a light belongs, read its coordinates and angles straight off the corner, paste them into the .dh-map. While the camera is out flying, the frozen body’s coordinates are not the ones you asked for.

The switch is all-or-nothing on purpose. A block that took its position from the camera and its angles from the body would describe a place nothing is looking from. Two consequences follow:

  • pos is still the standable origin — the camera’s position minus the pawn’s stance height, the same point the editor’s own teleport-self would fly the body to. So the row keeps meaning what it always meant (the coordinate a spawnPoint takes) instead of becoming a second copy of view.
  • vel is the camera’s fly speed, not the frozen pawn’s. The rush line at level 3 is the exception and stays the player’s: it is the fall-wind signal the ears and the lens are acting on, which is a property of the body, not of a viewpoint. A frozen body reads a flat zero there, which is the truth.

Give the camera back — possess the pawn, or leave the editor — and the pawn answers again. It is one predicate, not two: the only way to be in the editor without a detached camera is to be looking through the body.

The block’s rows are quantities and its columns are axes, and the columns say so in color: on pos and view the x, y and z fields are tinted toward the same red, green and blue the gizmo’s sticks use — read off AxisGizmoLayout, so the two can never disagree about which hue means +X. The whole vel row, and the whole rush row, are tinted violet to match the velocity stick that reports the same motion.

The tints are deliberately faint — a quarter of the way from the readout’s neutral gray toward the axis color, at the same brightness and the same opacity as everything else in the block. These are digits read one character at a time over an arbitrary scene, so legibility comes first and the hue only has to say which column you are in; the gizmo owns the saturated end of the palette. ang stays neutral: its p, y and r are rotations about the axes, not axes, and tinting them the way pos tints its x/y/z would claim a column is a coordinate it is not. Row labels are neutral everywhere, for the same reason — the label names the quantity, never an axis.

Level 3 prints the shared rush signal the velocity stick has been drawing all along:

rush s +14.20 r+0.55 9.0> 24.0
  • s — the rush speed in m/s. Not the raw velocity magnitude: it is the reduced scalar the effects read (horizontal ground speed while grounded, full magnitude while airborne or flying).
  • r — the 0..1 ramp that speed lands on, which is exactly the fraction the velocity stick is filled to.
  • start>full — the live thresholds the ramp is built from (client.camera.speedFov.startSpeed and client.camera.speedFov.fullSpeed, the same pair the fall wind uses), so a threshold you just changed in the console is visible in the same glance as its effect. These are the only unsigned numbers in the block: a configured speed is never negative, and the column that sign would have cost is what keeps the line inside the block’s width.

The stick answers “did that motion clear the deadzone” as a picture; this answers it as digits, which is what you need to pick a value for client.audio.fallWind.startSpeed rather than eyeballing a length. All four figures come from the one evaluation of the signal that already laid out the stick — the picture and the numbers are the same frame’s arithmetic, not two opinions of it.

The readout does not have a display format of its own. It prints the engine’s authoring conventions verbatim:

  • Position is in meters at the feet. pos is the same point the position of an object carrying a dh.spawnPoint names.
  • Yaw is degrees around +Y, and zero faces -Z. That is the same convention as the yaw component of any map object’s rotation.
  • Pitch is degrees, positive looking up — the same as that rotation’s pitch component.
  • Angles are normalized to (-180, 180]. There is one canonical form for every angle, so -180 never appears where 180 is meant.
  • Angles print in the array’s order: p, then y, then r. The ang row is a rotation array, left to right.

That last one is the whole point. Leading with yaw, on the reasoning that yaw is the angle you actually reach for, would buy one glance and charge a mental transposition on every single paste. One order across the ecosystem wins — read the row left to right and type it into the file left to right.

So: stand — or fly the editor camera — where you want a spawn or a fixed shot, read the numbers off the screen, and paste them straight into a .dh-map:

// read: pos x -30.00 y 26.00 z 180.00
// ang p -14.0 y +100.0 r +0.0
{ "$type": "object", "id": "b783eabd-a4dd-4a45-87f6-59689fb74dca", "name": "skyline",
"position": [-30, 26, 180], "rotation": [-14, 100, 0], "components": [ { "$type": "dh.camera" } ] }

No reordering, no sign flip, no radian conversion, no “add 90 because the gizmo faces the other way.” If the readout and the map file ever disagree, that is a bug in one of them, not a conversion you are expected to do in your head.

client.debug.effects draws a compact block at the top center — the one band no other readout claims — giving the shared speed signal and, under it, what every effect that reads it resolved to this frame. Off by default, and its own preference rather than a rung of client.debug.hud: the leveled HUD answers “where am I, which way, how fast” at increasing resolution, and this answers “why does the effect look like that”, which is something you turn on while tuning one number and off again immediately.

It is gated on there being a world, like the axis gizmo — at the main menu and across a join that has not landed there is no pawn, no speed and no effect, so the honest block is no block. It records in the ordinary developer band, so the console and the options window cover it and a pause menu’s scrim dims it — the same treatment the Hud band the speedometer takes gets now, since both bands land at the same seam. It stays in Tooling rather than Hud all the same: a readout whose whole subject is what the frame under it is doing is a developer tool, gated on client.debug.effects rather than on there being a world, and merging it into the player-facing band would let a debug preference decide whether the speedometer draws.

At a default sprint on the shipped settings:

speed 10.16 m/s grounded (horizontal)
blur ramp 0.08 scale 0.006 streak 0/ 64 px limit ramp
shutter 0.15 frames taps 8 rot 0.15
fov ramp 0.08 75.0 + 0.9 = 75.9 deg v
wind ramp 0.00 gain 0.00
scrape ramp 0.00 gain 0.00
roll +0.00 deg punch +0.00 rad/s
fade session 1.00 dissolve 0.00

and during a saturated fall:

speed 24.00 m/s airborne (3-D)
blur ramp 1.00 scale 0.075 streak 4/ 64 px limit shutter
shutter 0.15 frames taps 8 rot 0.15
fov ramp 1.00 75.0 + 12.0 = 87.0 deg v
wind ramp 1.00 gain 0.85
scrape ramp 0.00 gain 0.00
roll +0.00 deg punch +0.00 rad/s
fade session 1.00 dissolve 0.00

The shared signal leads and each consumer follows it, because the whole value of the block is showing why consumers disagree. The speed line names the branch that produced its number — grounded (horizontal) drops the vertical component, airborne (3-D) and noclip (3-D) keep every axis — and every ramp column under it is that speed against that consumer’s own thresholds. Reading the blur and fov ramps down one column is how you see that the two ride literally the same pair of numbers at the defaults, and how you see it the instant one of the four preferences is moved. The wind and scrape ramps sit in the same column against their own, deliberately higher thresholds. roll is the cosmetic camera-roll spring’s angle beside the angular-velocity kick the last landing gave it, and fade is the session fade and the world dissolve.

The effect shipped reading “low-mild at most” with strength and shutter both at 100% and the onset at zero. The reason was invisible from inside the game: the ramp put a sprint near the bottom of the curve, and a 100% shutter is exactly one frame of exposure and therefore inherently short. blur 1.00 answers nothing; blur ramp 1.00 … streak 188/ 256 px limit screen answers it in seconds.

Every rung of the motion-blur scale ladder is a multiplier in 0..1 on the streak the frame would otherwise draw, so “which one is binding” has an exact meaning: the smallest of them. The readout computes them all and names the winner.

limitWhat is holding the streak down
noneNothing. Every multiplier is one.
offThe filter is not running: client.render.motionBlur is off, or the strength or shutter is exactly zero, so the pass is not recorded at all.
hitchThe frame was longer than client.render.motionBlurHitchSeconds (Source’s 1/15 s). Checked first, exactly as the math checks it.
teleportThe camera jumped rather than traveled — a snapshot correction, a respawn, a map switch — and the whole effect was zeroed for that frame.
dampenValve’s frame-rate dampening: full at 50 fps, nothing at 30.
rotationThe sustained rotational hold after a large jump is still ramping back.
rampThe speed ramp: you are not going fast enough.
shutterThe exposure is under one frame — Valve’s shipped 15% is a sixth of the frame’s motion. Not a bug, and the most commonly misread number in the effect.
strengthclient.render.motionBlurAmount.
screenThe radial screen-width cap clipped the streak at the reference distance.

Two things are deliberately not in that ladder.

The configured rotation cap — client.render.motionBlurRotation, 15% by default and therefore always below one — applies to the rotation-caused part of each pixel’s velocity and cannot be compared against multipliers on the whole field. If it competed it would be the answer on every frame at the defaults and the readout would never say anything else. It is printed as rot on the shutter line instead, and only the sustained recovery hold competes to be the answer.

And a hitch outranks the dampening it already implies. A 15 fps frame trips both; the gate is what the velocity scale tests first, before it even looks at the gain, so the gate is what you are told about. The design note calls the pair redundant on purpose, and this pins which of the two is reported.

Nothing on the block is derived a second time. The speed and its ramp are the very pair the velocity stick was laid out from; the wind and scrape figures are the loops’ live enveloped gains; and the motion-blur figures are resolved from the sample the renderer recorded where it built the filter’s push constant — the renderer takes its velocity scale, radial cap and tap count straight back out of that same call. So the limiter the block names is the limiter the shader ran, structurally rather than by two call sites agreeing to stay in step. The cost is that the blur line is one frame behind the image, because a frame’s UI is recorded before its passes are; it is a coherent frame rather than a mixed one, which is the property that matters.

dh render --renderUi photographs the block as hudEffects, driven from an authored frame at the shipped defaults — so the image is evidence of what a player sees at Valve’s 15% shutter, which is the setting the readout exists to explain.