> Cinder 0.1.0 documentation: Tools: debug overlay, Tracy profiling, headless mode
> Canonical page: https://cinder.ash10.dev/docs/guides/tools/ · Source: `docs/guides/tools.md` in the engine repository (engine commit 47c9352, imported 2026-11-09)
> Index of every page for agents: https://cinder.ash10.dev/llms.txt

# 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 `ComponentRecord`s 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 `SceneComponent`s, 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`.
