Tools: debug overlay, Tracy profiling, headless mode
Cinder's developer tooling is three things: the Dear ImGui debug overlay in cinder/tools, the
Tracy client wrapper in cinder/core/profile.mojo, and the --headless mode of cinder/app that
lets the same executables run without a display for tests, benchmarks and captures.
Sources: cinder/tools/debug_ui.mojo, cinder/tools/cstrings.mojo, cinder/core/profile.mojo,
native/src/profile.c, native/src/imgui_shim.cpp. API reference: docs/api/tools.md,
docs/api/core.md. Design: docs/adr/0013-tools-and-app-runtime.md (decisions 2, 4, 5, 7).
The debug overlay (DebugUi) #
DebugUi draws one ImGui window titled "Cinder" with five tabs. ImGui itself runs in the shim:
cn_imgui_init creates the context and the SDL3 + WebGPU backends, cn_imgui_process_event feeds
raw SDL events, cn_imgui_new_frame, cn_imgui_render and cn_imgui_shutdown wrap the frame and
teardown. Widgets are called through the generated dcimgui bindings (lib[].ImGui_Begin,
ImGui_TextUnformatted, ImGui_InputFloat, ...), so a Game.draw_ui can draw its own windows
with the same functions; the sample's HUD and pause menu do.
Ownership and lifetime #
App creates the overlay when it has a window and AppConfig.debug_ui is true (--no-debug-ui
disables it; headless apps never have one). It is App.debug_ui: Optional[DebugUi], the first
field of App, so it is destroyed first; its __deinit__ calls cn_imgui_shutdown. The
constructor takes the native library pointer, the SDL_Window, the WGPUDevice and the swapchain
format, and raises if the backend fails to initialise. Setting Platform.forward_to_imgui = True
(done by App) makes Platform.poll hand every raw SDL event to ImGui before translating it.
Per frame, App._draw_overlay calls begin_frame(dt) (records the frame time, clears the label
buffers, ImGui::NewFrame), then game.draw_ui(app), then draw(...), then render(pass) into a
render pass that loads the swapchain image the scene was just drawn into. If you drive DebugUi
yourself, keep that order: draw must run between begin_frame and render.
Tabs #
| Tab | Index | Contents |
|---|---|---|
| Frame | 0 | ImGui_PlotLinesEx of the last HISTORY = 240 frame times (ms, 0 to 33.3 scale) with the average overlaid; renderer stats (draw_calls, prepass_draw_calls, shadow_draw_calls, instances_drawn/instances_submitted, lights, shadow_views, passes, transient_textures); live physics objects, live sounds; total live GPU resources (res[].live_counts().total()). |
| Entities | 1 | Entity count and archetype count; a list of entity <index> selectables; the inspector for the selected entity. |
| Assets | 2 | "no asset server mounted", or live_count() / in_flight, then one line per referenced asset record: logical name, refcount, state (ready, loading, failed: <error>), file. |
| Render graph | 3 | The compiled frame graph as Graphviz DOT text, one line per ImGui_TextUnformatted. |
| Log | 4 | The last 200 records of App.logs as [level] category: message. |
select_tab(index) forces a tab on the next draw (through ImGuiTabItemFlags_SetSelected) and
then resets; visible = False skips drawing the window entirely. frames, average_frame_ms()
and entities (the last snapshot) are public, which is how tests/tools/test_debug_ui.mojo checks
the overlay saw its scene:
def draw_ui(mut self, mut app: App) raises:
if app.debug_ui:
app.debug_ui.value().select_tab(1) # Entities
app.debug_ui.value().selected = 0 # inspect the first entityWhat the inspector edits, and through what #
The inspector is data-driven (ADR-0013, decision 5). Each frame _entities_tab calls
registry.to_doc(world), which serialises every component type registered on the SceneRegistry
(app.registry.register[T]() for a SceneComponent) into a SceneDoc, and snapshots
world.entities(). The selected entity's ComponentRecords are drawn as tree nodes, one widget
per field by kind:
KIND_FLOAT:ImGui_InputFloat;KIND_INT:ImGui_InputInt.KIND_FLOATSwith up to 4 values: oneImGui_InputFloatper element (dcimgui passesfloat[3]by value in the generated bindings, soDragFloat3cannot write back). Longer arrays are shown as a count only.- Everything else (
KIND_BOOL,KIND_STRING,KIND_ENTITY) is shown as text and not editable.
When a field changed, _apply builds a FieldReader over the edited record and calls the
registered type's read through the registry, which overwrites the component on that entity;
edits_applied counts successful writes. A component with any KIND_ENTITY field is read-only,
because a single-entity edit cannot resolve entity references. Only registered types appear: as
docs/STATUS.md notes, built-in engine components are not yet SceneComponents, so the inspector
shows game-registered types (the sample registers Player, Crate, Collectable, OrbitLight,
GameState). tests/tools/test_debug_ui.mojo registers a two-field Spin component for this.
Input capture #
wants_mouse() and wants_keyboard() return ImGui's WantCaptureMouse / WantCaptureKeyboard
(false when there is no ImGui context). App._poll reads them before processing the frame's events
and does not forward mouse presses, motion and wheel, or key presses, to App.input while they are
set, so typing into an overlay field does not move the player (ADR-0013, decision 7). Key and mouse
button releases are always forwarded (cinder.app.app.ui_consumes_event), so a key held when the
overlay takes focus does not stay held in the input map. They answer from the previous
frame's ImGui state, which is the usual immediate-mode one-frame lag.
CStrings #
ImGui keeps label pointers until the frame is rendered, and a Mojo String has inline small-string
storage that moves when the list holding it grows (docs/MOJO_NOTES.md, Phase 7). CStrings
copies each label into its own List[UInt8] with a NUL terminator; the outer list may reallocate,
but each buffer's heap data stays put, so the pointer stays valid until clear(). DebugUi owns
one (_labels) and clears it in begin_frame. A game drawing its own ImGui uses the same pattern:
from cinder.tools import CStrings
var ui = CStrings() # a field of the game
ui.clear() # once per frame, at the top of draw_ui
if lib[].ImGui_Begin(ui.c(String("hud")), Optional[Pointer[Bool, MutUntrackedOrigin]](), flags):
lib[].ImGui_TextUnformatted(ui.c(String(t"collected {self.collected}")))
lib[].ImGui_End()Never pass text.as_c_string_span() of a local String to an ImGui call whose pointer outlives the
statement.
Tracy profiling (cinder/core/profile.mojo) #
The shim is built with the Tracy client (CINDER_TRACY, default ON in native/CMakeLists.txt,
Tracy v0.15.0 vendored under third_party/tracy) compiled with TRACY_MANUAL_LIFETIME,
TRACY_DELAYED_INIT and TRACY_ON_DEMAND. Nothing runs, no socket is opened and no thread is
started until cn_profile_start, and every emit function is a no-op while the profiler is stopped
(native/src/profile.c). Tests and headless runs therefore never touch the network.
Profiler and Zone #
Profiler(lib) is an ImplicitlyCopyable handle; copies share the process-wide client. App
holds one as app.profiler.
| Call | Effect |
|---|---|
available() |
Whether the shim was built with Tracy. |
start() |
Starts the client (idempotent). Returns False without Tracy. --profile / AppConfig.profile calls this at app construction. |
stop() |
Shuts the client down (idempotent). |
is_started(), is_connected() |
Client state; is_connected is whether a Tracy server is attached. |
zone(name, color=0) -> Zone |
Opens a zone. COLOR_RENDER, COLOR_PHYSICS, COLOR_ANIM, COLOR_AUDIO, COLOR_ASSETS are the engine's colours. |
frame_mark() |
Ends a frame (App.run_frame calls it last). |
plot(name, value) |
A plot sample. Tracy keeps the name pointer for the program's life, so each distinct name is interned once in a never-freed table shared by all Profiler copies (plot_name_count()); later samples do not allocate. Dropped while stopped. |
message(text) |
A log message (Tracy 0.15 ___tracy_emit_logString). |
Zone begins in its constructor through cn_profile_zone_begin and ends in __deinit__. Its
id is 0 while profiling is off (the C side packs Tracy's zone context into a uint64, so Mojo
never sees the struct); text(note) attaches text to the open zone.
from cinder.core.profile import COLOR_RENDER, Profiler
var profiler = Profiler(lib.ptr())
_ = profiler.start()
for frame in range(3):
var z = profiler.zone(String("frame"), COLOR_RENDER)
z.text(String("frame body"))
var inner = profiler.zone(String("inner"))
_ = inner^
profiler.plot(String("frame"), Float64(frame))
_ = z^ # closes the zone here, not at the last use above
profiler.frame_mark()
profiler.stop()The _ = z^ at the end matters: Mojo destroys a value right after its last use (docs/MOJO_NOTES.md),
so a zone that is never mentioned again closes immediately. App.run_frame ends each of its zones
("fixed step", "animation", "audio", "render") the same way.
Attaching the Tracy server #
Run the executable with --profile (or set AppConfig.profile, or call app.profiler.start()
later, for example from a menu), then connect the Tracy profiler GUI of the same major version
(0.15) to the machine running the game; with TRACY_ON_DEMAND the client waits for a connection
and starts streaming when one arrives. is_connected() becomes true at that point. Data captured:
the app's zones, Renderer.render and AssetServer.update zones, frame marks, and any plots and
messages you emit. tests/core/test_profile.mojo runs the whole lifecycle with no server attached
and asserts that everything is a no-op while stopped. The server is not part of the repository;
install it separately.
--headless as a tooling feature #
parse_app_args gives every Cinder executable --headless, and AppConfig.headless = True makes
App run the full frame without a window: no SDL platform, a Gfx without a surface, an RGBA8
offscreen render target, a device-less miniaudio engine that run_frame pumps with read_frames,
and no overlay. App.read_frame() returns the last frame as an Image. This is what makes the
following work in CI and in containers with only a software Vulkan driver (REPORT.md):
tests/app/test_app_headless.mojoandtests/sample/test_sample_game.mojorun gameplay, read back pixels and assertgpu_error_count() == 0andlive_objects() == 0.tests/perf/test_zero_alloc.mojoruns 90 warm-up and 60 measured frames and asserts zero Mojo allocations, usingApp.count_allocationsandRenderer.alloc_marksto attribute any regression to a phase (docs/guides/app.md).bench/stressandbench/sample_benchrecordframe_ms,physics_msandrender_msto JSON;tools/bench_check.pycompares againstbench/baselines/.pixi run sample-headlessruns the autopilot for 600 frames;--screenshot PATHwrites the last frame as PNG.
if screenshot.byte_length() > 0 and config_headless:
save_png(app.lib.ptr(), screenshot, app.read_frame())Headless frames can be given a fixed frame_seconds (app.run_frame(game, 1.0 / 60.0)) so step
counts, physics and audio progress are deterministic run to run. --profile combines with
--headless; the overlay does not (there is no window to draw into).
Threading #
The overlay and the profiler wrapper are main-thread code. ImGui is called only from the main thread
inside run_frame; Tracy's own worker thread is started by cn_profile_start inside the client
library and never runs Mojo (the client reads the zone and frame data the main thread emits). Jolt,
the job workers and the audio thread are described in docs/THREADING.md.
Pitfalls #
- A
Zonemust be consumed (_ = z^) where you want it to end; see above. Profiler.start()afterstop()works, but zones opened before astop()and ended after it are dropped silently (cn_profile_zone_endchecksg_started).- ImGui enum constants have their own types in the bindings; wrap them (
Int32(ImGuiCond_FirstUseEver)). - Passing
self.entities[i]into amut selfmethod is an aliasing error; copy into a local first (debug_ui.mojodoes this for the inspector). DebugUi.drawtakesrenderer: Rendererimmutably and callsrenderer.graph.to_graphviz()every frame while the tab is open; that allocates the DOT text, so keep the Render graph tab closed when measuring allocations (the zero-allocation test runs headless and has no overlay).- The overlay test needs a display;
tools/run_tests.shruns it under X11 and Wayland in the toolchain container. Headless runs do not exerciseDebugUiat all.
Tests and references #
tests/tools/test_debug_ui.mojo: windowed app, six frames with the overlay, inspector snapshot of two entities,select_tab(1),wants_mouse/wants_keyboard, no GPU errors or error logs.tests/core/test_profile.mojo: no-ops while stopped, idempotent start, zones with text, nested zones, plots, messages, frame marks, clean stop; plot names interned once per name and still valid after the caller'sStringand later table growth.tests/app/test_app_headless.mojo,tests/perf/test_zero_alloc.mojo,bench/.docs/adr/0013-tools-and-app-runtime.md;docs/MOJO_NOTES.md(Phase 7 pitfalls);docs/STATUS.md(Phase 7 row);docs/guides/app.md.