DocsGuides

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:

mojo
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 entity

What 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_FLOATS with up to 4 values: one ImGui_InputFloat per element (dcimgui passes float[3] by value in the generated bindings, so DragFloat3 cannot 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:

mojo
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.

mojo
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.mojo and tests/sample/test_sample_game.mojo run gameplay, read back pixels and assert gpu_error_count() == 0 and live_objects() == 0.
  • tests/perf/test_zero_alloc.mojo runs 90 warm-up and 60 measured frames and asserts zero Mojo allocations, using App.count_allocations and Renderer.alloc_marks to attribute any regression to a phase (docs/guides/app.md).
  • bench/stress and bench/sample_bench record frame_ms, physics_ms and render_ms to JSON; tools/bench_check.py compares against bench/baselines/.
  • pixi run sample-headless runs the autopilot for 600 frames; --screenshot PATH writes the last frame as PNG.
mojo
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 Zone must be consumed (_ = z^) where you want it to end; see above.
  • Profiler.start() after stop() works, but zones opened before a stop() and ended after it are dropped silently (cn_profile_zone_end checks g_started).
  • ImGui enum constants have their own types in the bindings; wrap them (Int32(ImGuiCond_FirstUseEver)).
  • Passing self.entities[i] into a mut self method is an aliasing error; copy into a local first (debug_ui.mojo does this for the inspector).
  • DebugUi.draw takes renderer: Renderer immutably and calls renderer.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.sh runs it under X11 and Wayland in the toolchain container. Headless runs do not exercise DebugUi at 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's String and 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.
View as MarkdownSource: docs/guides/tools.md