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.
The family
Section titled “The family”| Preference | Type | Default | What it draws |
|---|---|---|---|
client.debug.entityGizmos | bool | false | A dot at every in-view logic entity’s live pivot, a name-and-value label above it, and the authored wiring lines between them. |
client.debug.nodeGizmos | bool | false | A pivot marker and #id name label at every in-view map node; a node hidden by a scene edit draws dimmed and struck through. |
client.debug.hud | int | 0 | The corner debug HUD level: 0 off, 1 numbers, 2 + orientation and velocity gizmos, 3 + the rush-signal figures. |
client.debug.axisGizmoPlate | bool | true | The gizmo’s dark backing plate. Off leaves only the outlines. Only meaningful while the gizmo is on. |
client.debug.effects | bool | false | The 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.effectsDistance | float (m) | 5 | The 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.colliders | bool | false | Depth-tested wireframes of the colliders the client predicts against — map static boxes in pink, character capsules in teal. |
client.debug.lights | bool | false | Each 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.cascades | bool | false | Tints shaded surfaces by which sun-shadow cascade shadowed them: red (near), green (mid), blue (far), gray past client.render.shadowDistance. |
client.debug.contacts | bool | false | Logs 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. |
client.debug.mapSubMeshMode | int | 1 | How 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.frameStats | int (seconds) | 0 | When positive, logs mean/p50/p99/min/max frame time and derived FPS to the client channel every N seconds. 0 disables. |
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.
How they are painted
Section titled “How they are painted”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 as of the crosshair run — 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. Before that run existed the band had no capture hand-off at all and was empty in every frame, which made a clean render diff for it worth nothing. 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 block 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 block 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 block did sit in the Hud band over the menus for a while; that is what made it paint straight through the overlay’s own menu bar, and being covered while paused was chosen over that. The player’s speedometer is the one readout still in Hud, over the menus, because a speed is information about a world that is still there.
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 block is not gated: the client is still rendering frames at a menu, and that is the number it reports.
Three consequences worth knowing:
- The text is the engine’s bundled JetBrains Mono, at the atlas’s baked small size, so every readout on this page and every player screen are the same typeface at the same rasterization quality.
- The layout is pure and unit-tested.
AxisGizmoLayout,PositionReadoutLayout,EffectsReadoutLayoutandScenePivotGizmoLayouttake 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 --renderUicomposites them into an offscreen capture, one image per HUD level, on a machine that never opens a window.
Material debug views
Section titled “Material debug views”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:
| Value | Shows |
|---|---|
off | The normal lit image. Default. |
albedo | The material’s base color, before any lighting. |
normal | The shaded world-space normal, remapped from [-1, 1] to [0, 1] so it is visible as color. |
roughness | The roughness scalar (after the metallic-roughness map is applied), as gray. |
metallic | The metallic scalar (after the metallic-roughness map is applied), as gray. |
emissive | The material’s emissive term alone. |
occlusion | The baked ambient-occlusion sample (1.0 — fully unoccluded — on a material with no occlusion map bound). |
depth | Linear view depth, normalized against client.render.shadowDistance so it reads as gray-white near to far. |
lightingOnly | Direct and ambient shading with the material’s albedo forced to white, isolating the lighting itself from the material’s own color. |
lightmap | The 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. |
uv2Grid | A DCC-style checker grid rendered through each mesh’s UV2 (lightmap) coordinates: an 8x8 checker per 0..1 chart, hue-shifted per tile so neighboring tiles are distinguishable and seams or stretching are obvious, with a thin line every tile and a heavier line at each chart’s 0/1 edges. A mesh with no lightmap UVs (UV2 all zero) renders solid magenta. |
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.
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 stays one program with a switch rather than a family of debug-only pipeline permutations.
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.
The corner debug HUD
Section titled “The corner debug HUD”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 frame-stats block (top-left)
Section titled “The frame-stats block (top-left)”The same level also raises a performance readout in the opposite corner, because it answers a different question — “is the client keeping up” rather than “where am I” — and reading it next to the coordinates would mean reading one to get to the other.
| Level | Lines |
|---|---|
1 | The smoothed frame rate alone. Under one frame per second it prints a decimal, because 0 fps and 0.4 fps are different situations and the first reads as a hang. |
2+ | Four lines: rate and frame time, then the tick rate and interpolation window, then the server tick, the un-acked pending input count, the server’s replicated input-queue depth against the clock’s effective target (- until the first reading lands) and the clock rate scale, then the visual correction distance in meters. |
Line building is pure (PerformanceOverlay.BuildLines), so what each level reports is asserted with no font, no device and no frame. The block has no backing plate — it floats over arbitrary world geometry with a one-pixel dark drop shadow, which is the whole of what keeps it legible over a bright sky.
Note this is not client.debug.frameStats, which logs percentiles to the console on a timer and draws nothing. The two are complementary: the block is what you glance at, the log is what you page back through.
The axis and velocity gizmos
Section titled “The axis and velocity gizmos”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.
The velocity stick
Section titled “The velocity stick”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.
The numbers
Section titled “The numbers”From level 1, a four-line numeric block 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.
| Line | Level | Shows | Units |
|---|---|---|---|
pos | 1 | The pawn’s feet origin — the point under the player’s feet, not the eye. | meters |
eye | 1 | The camera position: the feet position plus the current stance’s eye height. | meters |
ang | 1 | View angles as y yaw, p pitch, r roll. 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 |
vel | 1 | Horizontal 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 |
rush | 3 | The 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 y-131.1 filling its field edge to edge (no gap at all between label and value) while p +56.0 had one, 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.
The columns are tinted by axis
Section titled “The columns are tinted by axis”The block’s rows are quantities and its columns are axes, and the columns say so in color: on pos and eye 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 y, p 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.
The rush line (level 3)
Section titled “The rush line (level 3)”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.0s— 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— the0..1ramp 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.startSpeedandclient.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 conventions, and why they round-trip
Section titled “The conventions, and why they round-trip”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.
posis the same point aspawnPointentity’spositionnames. - Yaw is degrees around
+Y, and zero faces-Z. That is the same convention as aspawnPoint’syawand acameraentity’syaw. - Pitch is degrees, positive looking up — the same as a
cameraentity’spitch. - Angles are normalized to
(-180, 180]. There is one canonical form for every angle, so-180never appears where180is meant.
That is the whole design. Stand 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 y +100.0 p -14.0 r +0.0{ "$type": "camera", "name": "skyline", "position": [-30, 26, 180], "yaw": 100, "pitch": -14 }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.
The effects readout (top center)
Section titled “The effects readout (top center)”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, the menus and the options window cover it. That is deliberate and it is not the Hud band the speedometer takes: a speed survives a raised menu because the world is still there, while a readout whose whole subject is what the frame under it is doing has nothing to say once a screen covers that frame.
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 rampshutter 0.15 frames taps 8 rot 0.15fov ramp 0.08 75.0 + 0.9 = 75.9 deg vwind ramp 0.00 gain 0.00scrape ramp 0.00 gain 0.00roll +0.00 deg punch +0.00 rad/sfade session 1.00 dissolve 0.00and during a saturated fall:
speed 24.00 m/s airborne (3-D)blur ramp 1.00 scale 0.075 streak 4/ 64 px limit shuttershutter 0.15 frames taps 8 rot 0.15fov ramp 1.00 75.0 + 12.0 = 87.0 deg vwind ramp 1.00 gain 0.85scrape ramp 0.00 gain 0.00roll +0.00 deg punch +0.00 rad/sfade session 1.00 dissolve 0.00The 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 limiter is the point
Section titled “The limiter is the point”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.
limit | What is holding the streak down |
|---|---|
none | Nothing. Every multiplier is one. |
off | The 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. |
hitch | The frame was longer than client.render.motionBlurHitchSeconds (Source’s 1/15 s). Checked first, exactly as the math checks it. |
teleport | The camera jumped rather than traveled — a snapshot correction, a respawn, a map switch — and the whole effect was zeroed for that frame. |
dampen | Valve’s frame-rate dampening: full at 50 fps, nothing at 30. |
rotation | The sustained rotational hold after a large jump is still ramping back. |
ramp | The speed ramp: you are not going fast enough. |
shutter | The 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. |
strength | client.render.motionBlurAmount. |
screen | The 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.
Where the numbers come from
Section titled “Where the numbers come from”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.