Debug Panels
The debug panel is an immediate-mode (ImGui-style) overlay split into separate windows — CLIENT, SERVER, NET, HOOKS, and optionally SIM CONTROL — so each machine’s stats read separately and the wire gets its own view. The defining decision: it is Studio-only by design. It auto-attaches in Studio and refuses to exist in production unless an explicit attribute and a per-user allowlist say otherwise, because the NET window and log mirror carry every player’s decoded traffic and the full server log.
Opening the panel
Section titled “Opening the panel”F8 toggles every window at once (F9 is Roblox’s own dev console). Every panel — CLIENT/SERVER and SIM CONTROL alike — boots with its ScreenGui hidden; the first F8 press is what actually shows them, so a Studio play-test doesn’t open with an overlay already covering the screen. In Studio there is nothing else to set up — Main.client.luau attaches the panel after boot:
local Panel = require(ReplicatedStorage.ChloeKernel.Debug.Panel)Panel.attach(Booted) -- Studio-only unless ChloeKernelDebug is true; starts hidden, F8 shows itThe SERVER window needs the server to publish diagnostics; Main.server.luau does this in Studio:
Booted:enableDiagnostics(1) -- feeds the F8 debug panel, 1s intervalWithout it, the SERVER window says so and tells you the call to make. Title bars drag each window independently; a clean click on the title bar collapses it (a drag past 4 pixels never counts as a collapse click). While the overlay is closed, the entire cost is one boolean check per frame.
Mental model
Section titled “Mental model”Four modules cooperate:
Debug/ImGui.luauis the widget layer — immediate mode over retained Instances. Each render pass rebuilds the window declaratively; Instances are reused keyed by call position, so a 10Hz re-render costs property writes with no Instance churn.Debug/Panel.luaurenders the CLIENT and SERVER windows on aScheduler:every(0.1, ...)task atPriority.Low— the panel’s own render task shows up in its own hot-tasks list, deliberately ungraded. It also owns the Scheduler’s hot-task profiler read/reset and attachesDebug/HookScope.luaualongside NetTap/NetPanel.Debug/NetTap.luauis a passive recorder both VMs share: a 64-entry ring of every kernel channel message, plus running counters. The server’s ring drains to allowlisted clients over a debug-onlyCKDBG_NetTapmirror packet;Debug/NetPanel.luaurenders the merged view at 5Hz.Debug/HookScope.luauis the HOOKS window: it decorateskernel.Hooks.fireto time and count every hook chain.
Server stats reach the client two ways: numeric health data as short ChloeKernelDiag* attributes on per-role Configuration children of ReplicatedStorage (written by enableDiagnostics; readable by anyone, harmless — the panel translates legacy long-form attribute names where it still finds them), and the sensitive streams — log tail, wire capture — as per-recipient packets sent only to allowlisted debuggers, never broadcast.
The CLIENT window
Section titled “The CLIENT window”Reads this machine directly. Sections top to bottom:
- Frame & network — kernel version, uptime, FPS with frame time, ping, replication kbps in/out, physics solver rate.
- Memory — total memory (Studio only — see the aside below), Lua heap, Instance count, script memory, GC alloc (Lua allocation rate, the lever that drives GC cost), and GC cycles (sweep cadence and reclaim sizes). Heap climbing with reclaims near zero is a leak; heap stable with busy cycles is churn.
- Frame — decomposes measured frame compute against the frame target: engine + other scripts (heartbeat phase minus the kernel), physics, render CPU, and the kernel share. When a frame is slow, the red row names whose problem it is before you reach for the MicroProfiler.
- Scheduler — the frame budget bar (consumed vs permitted; at 100% remaining tasks defer to the next frame), a step-time sparkline, tasks/sec, Deferred (green only at 0), live queue depths per priority (
K/H/N/L/B), recurring-task count, and live Process count. - Hot tasks — the heaviest scheduled origins this second in ms/s, attributed to the exact defining
script:lineviaScheduler:topTasks(5). “Something is lagging” becomes “Systems.Combat:131is eating 4.2 ms/s, open it.” - Kernel internals — booted services, distinct bus topics (+wildcards), hook points and handler counts. Each is graded against its own leak pattern.
- Log tail — the last 6 Logger lines via a sink; hover a row for the full untruncated line. A Dump button prints a stats snapshot to the output console where text can actually be copied.
Every graded stat colors green/orange/red with thresholds tuned to its meaning, and every row tooltips a diagnosis — not just what the number means, but where to look when it is wrong.
The SERVER window
Section titled “The SERVER window”Mirrors the server through the ChloeKernelDiag* attributes, so it works in a real multi-machine session:
- Status — sessions, services, uptime, and a VERSION SKEW warning that only renders when server and client kernels report different versions (a mid-rolling-deploy server or a stale client).
- Memory — server Lua heap, GC alloc/cycles, Instances, and a Total bar against Roblox’s 6400 MB hard cap (the server crashes at the cap, taking every player with it).
- Engine — the counters that survive production’s disabled memory tracking: replication and physics repl bandwidth (all players summed), primitives (total and awake), contacts. Replication CPU has no public timing stat — when frame compute reads green and the server still lags, the replication row is the suspect.
- Frame and Scheduler — the same decomposition as the client, plus step max (worst single step in the window) and Deferred summed since the last diagnostics tick, because deferral is bursty and a point sample reads zero during overload. Sustained deferral also fires
Kernel.Overloadon the bus. - Hot tasks — the heaviest server origins, mirrored as an attribute string.
- Log tail — server log lines, mirrored per-recipient over
CKDBG_LogTailonly whileenableDiagnosticsis on. - Net — intents accepted/rejected, rate-limited sends, and transport floods (payloads dropped before channel decoding), with counts and bytes.
The NET window
Section titled “The NET window”A live wire inspector for kernel channel traffic from both machines — server entries arrive via the debug-only mirror, which does not exist in production. It surfaces rejected, rate-limited, and dropped messages as first-class rows, not just delivered ones.
Totals up top, computed over 1-second windows from counter deltas:
| Counter | Meaning |
|---|---|
| In / Out | Kernel channel messages per second, both machines combined. |
| Wire | Payload KB/s computed from each channel’s schema widths — exact for fixed-width wire types and buffers, measured for Any. An Instance argument sizes at 0 bytes in this accounting — it rides the RemoteEvent’s own engine-side argument slot, not the kernel’s packed buffer, so refusing to size it here (rather than guessing a byte cost) keeps the Wire row honest for any Instance-bearing channel. |
| Game total | This machine’s whole network traffic (in/out KB/s) straight from the Stats service — engine replication, physics, and every remote combined, not just kernel channels. Sits next to Wire so a spike is easy to attribute: kernel Wire moving with it means your channels; Game total moving alone means something else (physics replication, a non-kernel remote) is the real cost. |
| Rejected | Failed validation (hook chain returned false), arrived without a session, or hit an Unguarded channel — a handler-bearing channel with no validator rejects every payload and records the Unguarded verdict. |
| Rate limited | Dropped by per-channel token buckets before validation ran. Honest players hitting this means the channel’s RateLimit is below real gameplay — see Rate limiting. |
| Dropped | Transport-level losses: payloads past Packet’s 8 KB/heartbeat flood cap, or unreliable sends over the ~900-byte limit. |
The tail lists newest first. Each row reads: direction arrow (↑ client→server, ↓ server→client — logical flow, the same on either machine), channel, snt/rcv (which half of the wire this row is), decoded arguments, payload bytes, processing time, and the verdict. One delivered message appears twice — the sender’s snt row and the receiver’s rcv row; a snt with no matching rcv never arrived (or, for simulation replicas, was never on the real wire — the load harness uses fake packet factories on purpose).
Pause capture freezes the tail for inspection (messages still flow — only recording stops), Clear resets ring and counters (useful for discarding boot-time spec traffic), and a checkbox hides replica noise.
Click any row to pin it in the Inspector, which shows the message both ways:
- the decoded argument values (pre-serialization),
- the per-argument wire types, with
Anyexpanded to the best-fit encoding the type engine actually picked (Any:U8,Any:F64,Any:Str+U8len…), - the serialized payload as hex — the exact bytes written for those values (first 32 bytes), plus the note that one channel-id byte rides along and Packet batches messages per frame.
Server-side times are gate+validators+handler; client-side request times are round trips. The whole capture layer is spec-verified — Tests/NetTap.spec.luau pins the byte estimates, hex previews, and Any best-fit descriptions down to exact strings (encodePreview({ "NumberU16" }, 258) is "02 01"; the wire is little-endian).
The HOOKS window
Section titled “The HOOKS window”Debug/HookScope.luau puts every hook point live in the F8 panel: fire and rejection counts, per-point chain cost, a fires-per-second sparkline, and the fail stance and handler count read from Hooks:listPoints().
It works by decorating the live registry: on attach it saves kernel.Hooks.fire as OriginalFire and replaces Hooks.fire with a wrapper that calls OriginalFire, times the call, and records the verdict — detach() restores the saved original. This is the same technique Chronicle uses to record hook decisions (save the live function, wrap it, restore on detach), but the two are independent implementations, not a shared mechanism — each keeps its own saved reference and wraps whatever Hooks.fire currently is, so attaching both layers one wrapper over the other rather than sharing state.
Per-point chain cost (avg/max ms) is measured directly around that wrapped call: os.clock() before OriginalFire(...), os.clock() after, accumulated into TotalMs/MaxMs and shown as TotalMs / Fires average plus the running max — the wall time of the entire validator chain for that point, per fire, exactly as felt by the caller.
Points the window shows are the union of two sources: every point that has actually fired this session, and every point Hooks:listPoints() returns ({ Name, FailOpen, Handlers }) whether it has fired or not. A point that’s defined but never fired — a gate registered at boot that no code path has exercised yet — shows up dimmed in the list instead of being omitted. That’s a real signal: it can mean dead validation code nothing calls anymore, or code that’s simply never been exercised in this session and deserves a manual pass before shipping.
Clicking a row pins it in the inspector: fail stance (fail-open/fail-closed) and handler count from listPoints, fires/rejected counts, chain cost, time since the last fire and the last rejection, and the fires/s sparkline. Pause capture stops the decoration from recording (the real Hooks.fire chain still runs underneath — pausing only stops the wrapper’s bookkeeping) and Reset counters clears all per-point stats and the pin.
The SIM CONTROL window
Section titled “The SIM CONTROL window”Debug/SimPanel.luau (attached from this place’s client Bootstrap) is a remote control for load testing, sharing the same gate and F8 toggle:
- Server load — buttons for
HighIntensity,Overload,SmallGame, andShowcase, fired as a realSimControlkernel intent so the command itself shows up in the NET window and runs the full validation pipeline. The current mode reads back from theChloeKernelSimulationattribute; Stop clears it. - Client load (this machine) — runs
Debug/SimLoad.luaurigs locally:HighIntensity(150 entity processes, 400 bus msgs/s, 240 hook fires/s, Serde round trips, pooled part churn, a deliberate 2 ms/tick burn, and 60 realSimNoiseintents/s — deliberately above that channel’s 50/s rate limit so RateLimited verdicts visibly fire in the NET window) orSmallGame(~1/10 intensity, no burn). Client and server loads start and stop independently.
Start a load, then watch the CLIENT/SERVER windows react: hot tasks name the simulation’s own origins, the budget bar climbs, Deferred grades. Modes, intensities, and what each rig exercises are covered on Simulation.
Building your own panels with ImGui
Section titled “Building your own panels with ImGui”Debug/ImGui.luau is a small immediate-mode layer you can use for any debug window. Rendering is declarative — call render as often as you like and describe the whole window each time:
-- Client Bootstrap: a self-contained custom debug windowlocal Players = game:GetService("Players")local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ImGui = require(ReplicatedStorage.ChloeKernel.Debug.ImGui)
return function(kernel) local Gui = Instance.new("ScreenGui") Gui.Name = "CombatDebug" Gui.ResetOnSpawn = false Gui.Parent = Players.LocalPlayer:WaitForChild("PlayerGui")
local Window = ImGui.window("Combat", { Size = UDim2.fromOffset(320, 240), Position = UDim2.fromOffset(12, 560), Parent = Gui, })
local PhysicsHistory = {} local Shots = 0 local DrawAimLines = false
kernel.Scheduler:every(0.1, function() table.insert(PhysicsHistory, workspace:GetRealPhysicsFPS()) if #PhysicsHistory > 40 then table.remove(PhysicsHistory, 1) end
Window:render(function(ui) ui:header("Weapons") ui:value("Shots fired", Shots) ui:bar("heat", (Shots % 20) / 20, `{Shots % 20}/20`, nil, "Fires 20 shots to fill the bar.") ui:sparkline("physics Hz", PhysicsHistory, 240) ui:separator() if ui:button("Fire") then Shots += 1 end DrawAimLines = ui:checkbox("Draw aim lines", DrawAimLines) end) end, kernel.Priority.Low)endHow it stays cheap: each widget call claims a slot keyed by call position. Same call order + same widget type = same Instance, updated only when a value actually changed (every widget diffs against cached state before touching a property). Slots past the last call of a pass are destroyed, so conditional trailing rows just disappear. Buttons and checkboxes return their event on the next render pass — the classic immediate-mode handshake — and a relabeled slot is treated as a different logical widget, so its pending click drops instead of firing on the wrong button.
ImGui API
Section titled “ImGui API”| Member | Description |
|---|---|
ImGui.window(title: string, options?: { Size: UDim2?, Position: UDim2?, Parent: Instance? }) |
Creates a draggable, collapsible window. Defaults: 380×440 at (16, 16). |
Window:render(build: (ui: Ui) -> ()) |
Runs one immediate-mode pass. Skipped entirely while collapsed. |
Window:destroy() |
Destroys the window and disconnects its drag listener (which outlives the window otherwise). |
ImGui.Theme |
The shared palette — Good/Warn/Bad/Accent/Text/TextDim/… — for consistent health-grade colors. |
The ui object inside render:
| Widget | Description |
|---|---|
ui:header(text) |
Section header row. |
ui:label(text, color?, hint?) |
One line of text; truncates, hover shows hint. |
ui:value(label, value, color?, hint?) |
Key/value row — the workhorse. |
ui:separator() |
Horizontal rule. |
ui:button(text): boolean |
Returns true on the pass after a click. |
ui:checkbox(text, checked): boolean |
Returns the new value; assign it back to your state. |
ui:selectable(text, selected, color?, hint?): boolean |
List row with selection highlight; returns true when clicked. |
ui:bar(label, fraction, text?, color?, hint?) |
Progress/budget bar with overlay text; fraction clamps to 0–1. |
ui:sparkline(label, values, max?, hint?) |
40-bar history graph; auto-scales to the peak when max is nil; bars grade orange past 60% and red past 85%. |
Every hint renders as a tooltip to the window’s right side — the built-in panels use them for full diagnostic text, and yours can too.
Production access control
Section titled “Production access control”Who can open any of this is decided in one place, NetTap.debugAllowed(player), and it fails closed:
- Studio — always allowed.
- Live,
ChloeKernelDebugattribute nottrue— denied. The panel refuses to attach, the mirrors never fire,NetTap.Activeis false and recording costs one boolean check. - Live, flag on — hardcoded owner UserIds (the
OwnerUserIdstable at the top ofNetTap.luau— edit it for your deployment) are always allowed. Everyone else is checked against theChloeKernelDebugUserIdsattribute: a comma-separated UserId list, or"*"/"all"/"ALL"to open a test place to every joiner. - Flag on but allowlist unset or empty — denied for everyone but owners and Studio, with a one-time warning explaining exactly that. Failing open here would leak every player’s decoded traffic and the full server log to anyone.
The sensitive mirrors respect the same gate per recipient: CKDBG_NetTap and CKDBG_LogTail are fired client-by-client to allowlisted debuggers only, never broadcast. The SimControl intent validates the sender with the same function, so flagging a live server does not hand load controls to every player.
| Attribute (ReplicatedStorage) | Meaning |
|---|---|
ChloeKernelDebug: boolean |
Enables the debug surface outside Studio. Without it, nothing attaches. |
ChloeKernelDebugUserIds: string |
Comma-separated UserId allowlist, or "*"/"all"/"ALL" wildcard. Missing/empty = owners and Studio only. |
ChloeKernelSimulation: string |
Current server simulation mode; empty stops. Set directly or via SIM CONTROL. |
ChloeKernelDiag* |
Numeric server diagnostics written by enableDiagnostics — step times, budgets, heap, GC, replication, net counters. Read by the SERVER window. |