Test the Tracy GUI headlessly
Two routes run the profiler GUI on a display-less machine. Pick one per test goal:
| Route A — Web (emscripten) | Route B — Native (X11) | |
|---|---|---|
| Display | Browser; no X11/Wayland/DISPLAY — the browser is the display server | Xvfb (virtual X server) + openbox (window manager) |
| Input | Puppeteer mouse/keyboard | xdotool mouse/keyboard |
| Ground truth | Instrumented harness (debug_snapshot/debug_clear) — app-side mouse/ID state |
Screenshot diffs (PIL ImageChops) — no harness |
| Can test | Loaded-trace GUI: toolbar, panels, targeting, DPI scaling | Live TCP connection to a client, native file dialogs (save/open), X11/GLFW input |
| Cannot test | Live client (browser cannot open a TCP server), native file dialogs | No known GUI-level gap; verification precision is lower (no hit-test oracle) |
| Prereqs | emsdk ($EMSDK_DIR, default ~/emsdk), CPM cache |
Xvfb, openbox, xdotool, ImageMagick (import), mesa swrast |
Route A — Web GUI (emscripten, headless browser)
Build the emscripten profiler GUI into an instrumented temporary workspace, serve it, and drive it with the browser tool. The harness exports debug_snapshot/debug_clear, exposing the app-side mouse/ID state — the ground truth for targeting and verification, not screenshots.
Constants marked M are workstation-dependent — re-verify them. Everything else is stable; do not re-derive it.
Setup
sh <this-skill-dir>/setup.sh # workspace=/tmp/tracy-protocol
# or: TRACY_WEB_WS=/elsewhere TRACY_WEB_REPO=<repo> sh setup.sh
<this-skill-dir> is the project-scoped .omp/skills/tracy-gui-verify/ under the active project root — not a fixed home directory. If multiple copies exist (one per project that has used this skill), use the copy inside the repo being tested; TRACY_WEB_REPO controls which repo is copied into the workspace.
The script does: fresh source copy (excludes .git and build dirs) → applies harness.patch (adds debug_snapshot/debug_clear exports + event/frame recording to the emscripten backend, in the COPY only) → configures → builds Release with the emsdk at $EMSDK_DIR (default ~/emsdk) and the CPM cache at ~/.cache/cpm.
Pitfall: if the workspace is deleted while a tracy-web server is running, stop the hub process first — a stale server keeps the deleted cwd and silently returns empty responses while the port still accepts connections.
Pitfall: a tracy-web that died in an earlier session leaves the daemon in failed state: stop only reports the old failure, and start keeps returning "Daemon tracy-web has unacknowledged completion notifications" while the port stays free. Drain the daemon with hub op=logs name=tracy-web (the log also shows the cause), then relaunch with hub op=restart name=tracy-web (retained spec). If the port is NOT free, a stale process owns it — often one serving an earlier (now-stale) build directory: the new httpd.py dies on the bind and your requests silently hit the old directory. Check ss -tlnp | grep 8000 and kill the owner. After any start/restart, verify the server is actually serving: curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8000/index.html must return 200 — the daemon message is not proof of a server.
After editing the GUI in the repo: copy the changed files into $WS/src/ (same relative path), re-run cmake --build $WS/build -j$(nproc), reload the page. If a change touches profiler/src/BackendEmscripten.cpp or profiler/CMakeLists.txt, the harness patch may no longer apply — re-apply it manually (keep the Dbg* harness section, the DbgPushEvent/DbgPushFrame calls in the mouse callbacks + NewFrame, and the two debug_* exports in -sEXPORTED_FUNCTIONS).
Serve + open + ready
Server (long-running → hub):
hub op=start name=tracy-web application=python3 args=["$WS/build/httpd.py"]
cwd=$WS/build ready={port:8000} persist=true
httpd.py sends the COOP/COEP headers required for pthreads/SharedArrayBuffer — use it, not a bare http.server.
Browser:
browser open url=http://127.0.0.1:8000/index.html viewport={width:1600,height:900}
Ready signal (in a run cell; run code executes in Node scope — page JS goes through tab.evaluate):
let title = null;
for (let i = 0; i < 360; i++) {
title = await tab.evaluate(() => document.title);
if (/ - Tracy Profiler/.test(title)) break;
await new Promise(r => setTimeout(r, 250));
}
Title is Tracy Profiler X.Y.Z until the preloaded embed.tracy (DarkRL capture) finishes loading, then <trace> (embed.tracy) - Tracy Profiler X.Y.Z (profiler/src/main.cpp SetWindowTitleCallback). Wait ~1.5 s after, then confirm the app loop lives: snap().frames.at(-1).tick > 0.
Harness API (ground truth)
const snap = () => tab.evaluate(() => JSON.parse(Module.ccall('debug_snapshot','string',[],[])));
const clear = () => tab.evaluate(() => Module.ccall('debug_clear','void',[],[]));
snap() → { dpr, innerW, innerH, bufW, bufH, rectL, rectT, rectW, rectH, title, cursor, frames:[…64], events:[…64] } (newest last).
frames[]: one sample per rAF tick, including idle ticks.tick= main-loop count (always advances),f=ImGui::GetFrameCount()(advances only when a frame actually renders — the idle gate, §Idle),mx/my= app mouse position,md[3]= mouse button states,q= pending input events,wa=tracy::s_wasActiveat tick start,hi/ai= ImGuiHoveredId/ActiveId.events[]: raw callback data as the app received it.type0=move 1=down 2=up 3=enter 4=leave 5=wheel;tx/ty= Emscripten inttargetX/Y;ax/ay= position passed to ImGui (-1for non-move);t= DOMHighResTimeStamp.
Coordinate mapping
appX = floor( trunc(cssX - rectL) * dpr ) // device px, what ImGui hit-tests
cssX = appX / dpr // inverse for targeting
screenshot_px = cssX * dpr
Chain: DOM clientX → Emscripten targetX = int(clientX - (rect.left|0)) (src/lib/libhtml5.js, EmscriptenMouseEvent.targetX is an int) → Tracy AddMousePosEvent(targetX * dpr, …) (BackendEmscripten.cpp) → ImGui ImFloor (imgui.cpp AddMousePosEvent). Canvas fills the viewport, so rectL/rectT are 0 (re-read anyway).
M: dpr on this workstation's headless browser is 1.25 (canvas buffer 2000×1125 for a 1600×900 viewport; screenshots are captured at device px). Never assume 1. App positions come in multiples of dpr; verify targeting within ±1 px via frames[].mx/my.
Click protocol
Click = move, press, refresh through the hold, release, verify — all against rendered frames. The idle gate (§Idle) renders only 3 frames per wake, and a hold with no mouse events at all refreshes nothing, so a fixed-wait down/up from a cold idle can be swallowed (both events received, zero frames rendered) or released after the wake budget expires. Issuing mouse moves while the button is down keeps the budget alive: a same-coord re-move is delivered (verified: it reaches the canvas handler, wakes the app, and a same-coord hold click straddles cleanly) and is the safest refresh — zero hover drift; a ±dx px wiggle works too, but must stay inside the hit area:
async function click(cssX, cssY, holdMs = 400) {
await page.mouse.move(cssX, cssY); // 1. ALWAYS move first
await new Promise(r => setTimeout(r, 150)); // 2. >= 1 rendered frame
const pre = await snap();
const pf = pre.frames.at(-1);
await clear();
await page.mouse.down(); // 3. press
let df = null, hiDuring = null;
const t0 = Date.now();
while (Date.now() - t0 < holdMs) {
await new Promise(r => setTimeout(r, 70));
await page.mouse.move(cssX, cssY); // 4. same-coord re-move refreshes the wake budget
const f = (await snap()).frames.at(-1);
if (f.md[0] === 1) { df ??= f.f; hiDuring = f.hi; }
}
await page.mouse.up(); // 5. release
let uf = null;
for (let i = 0; i < 40; i++) {
await new Promise(r => setTimeout(r, 40));
const f = (await snap()).frames.at(-1);
if (f.md[0] === 0) { uf = f.f; break; }
}
await new Promise(r => setTimeout(r, 400));
const s = await snap();
const d = s.dpr, ex = Math.floor(Math.trunc(cssX - s.rectL) * d), ey = Math.floor(Math.trunc(cssY - s.rectT) * d);
return { hoverID: pf.hi, hiDuring, appPos: [pf.mx, pf.my], expected: [ex, ey],
straddle: { df, uf },
ok: df !== null && uf !== null && uf > df && hiDuring === pf.hi &&
Math.abs(pf.mx - ex) <= 1 && Math.abs(pf.my - ey) <= 1 };
}
Rules:
- Move before every click. The emscripten mousemove handler is the only thing that sets the app mouse position; mousedown/mouseup only queue button state. Fresh page position is invalid (
-FLT_MAX) — a click without a prior move activates nothing. - Press and release must each render in separate frames with the item hovered (ImGui's
PressedOnClickRelease— doc table atimgui_widgets.cpp:489-497; input trickling,ConfigInputTrickleEventQueue, defers a same-frame release). The same-coord refresh is what guarantees rendered frames across the hold from a cold idle. - Verify every click:
ok: true(position landed, press and release each rendered, hover held —hiDuring === hoverID), plus a screenshot diff as supplementary visual confirmation. Non-ImGui targets (timeline zone bars) havehi = 0by design — for those verify by screenshot, not hover. - Double-click trap: two same-position clicks <300 ms apart = double-click (
MouseDoubleClickTime = 0.3 s) → zoom/thread-info actions fire. Wait ≥350 ms between repeated clicks at one spot. - Drag: move → down → (move + wait 70 ms)×n → up. Wheel:
page.mouse.wheel({ deltaY: ±500 })after positioning. Keys:page.keyboard.type(..., { delay: 50 })after focusing the input by click;page.keyboard.press('Enter'). - Danger: toolbar device x 10–32 (css 8–26) = red power button (leftmost toolbar icon, hover ID 3313137574), closes the trace view (back to "Get started"). Never click it unless intended.
Idle and power saving
main.cpp DrawContents keeps an activeFrames = 3 budget refreshed on any input event (tracy::s_wasActive), a connected client, view animation, or a queued input; exhausted → sleep 16 ms with no ImGui::NewFrame and no GL draw (canvas keeps the last frame). Expect ~97 % of ticks to skip rendering while idle.
- Clicks from a cold idle land only via the click protocol's hold refresh (§Click protocol) — a fixed-wait down/up can be swallowed by the idle gate (both events received, zero frames rendered). Before interacting, assert
tick/fis advancing; a stalled main loop (initial trace parse, hidden tab) renders nothing and drops everything. During the initial load the cursor style iswait. focusLostLimit(reduce render rate on focus loss) is desktop-only; it does not exist in the emscripten path.canvas.style.cursoris not a hover oracle: this ImGui build sets the Hand cursor only inTextLink, not on buttons.
Targeting
Screenshots never resolve targeting. Full-page shots arrive downscaled JPEG (1024 px wide for a 2000-px device buffer; the factor varies), and page.screenshot({ clip }) returns a PNG at device scale — a W×H CSS clip returns W·dpr × H·dpr px (CSS = clip offset + px/dpr). Icon buttons are 15–25 device px wide, so visual estimates carry ±20 px error, enough to hit the neighboring button or a gap. Target from the app's own hit-testing — sweep the row and click the run's center:
async function sweepRow(dpr, cssY, devFrom, devTo, step = 4) {
const ranges = []; let cur = null;
for (let dev = devFrom; dev <= devTo; dev += step) {
await page.mouse.move(dev / dpr, cssY);
await new Promise(r => setTimeout(r, 50));
const s = await snap();
const hi = s.frames[s.frames.length - 1].hi;
if (cur && hi === cur.id) cur.end = dev;
else { if (cur) ranges.push(cur); cur = { id: hi, start: dev, end: dev }; }
}
ranges.push(cur);
return ranges.filter(r => r.id !== 0 && r.end - r.start >= 4);
}
- Same non-zero
hiacross consecutive steps = one widget;hi = 0= gap. Click the run's center:click((r.start + r.end) / 2 / dpr, cssY). - Layout is state-dependent: docked panels (Statistics, Flame, …) resize the main window and shift the toolbar — re-sweep after any state change.
- Close popups before sweeping: while a popup is open, hovers outside the popup can read
hi = 0even over live widgets (verified with ZoomPopup over the toolbar). Clicking a % option in ZoomPopup does not close the popup — click an empty area first. - Hover IDs are stable across sessions (label hashes) — the map below holds for the default state (fresh load, no panels, 1600×900, dpr 1.25).
- Zoomed region shots (for correlating IDs with icons):
page.screenshot({clip})returns a Buffer — write it with Nodefs, thenreadthe file:
const fs = require('fs');
const buf = await page.screenshot({ clip: { x: 0, y: 0, width: 720, height: 28 } });
fs.writeFileSync('/tmp/toolbar-zoom.png', buf);
Toolbar map — default state (device px; css = device/1.25; row y css 15)
| device x | hover ID | function |
|---|---|---|
| 10–32 | 3313137574 | ⏻ power (red, leftmost) — closes the trace view (DANGER) |
| 44–68 | 608241489 | ⚙ Options gear — opens the Options window (clickable; do not confuse with the power button) |
| 81–185 | 2246346107 | 💬 Messages panel toggle |
| 199–259 | 3177774389 | 🔍 Find zone toggle (search input hover ID 3637961452, css ~x 100–440, y ~90) |
| 271–367 | 1419322276 | 📊 Statistics toggle |
| 383–451 | 322727105 | 🔥 Flame graph toggle |
| 463–551 | 37993378 | 💾 Memory graph toggle |
| 567–667 | 2133895361 | ⚖ Compare toggle |
| 679–735 | 1808024465 | ⌗ Info (trace information) toggle |
| 751–775 | 2535445525 | 🛠 wrench → ToolsPopup (Playback, CPU data, Annotations, Limits, Wait stacks, Frame statistics) |
| 791–811 | 3101356487 | 📖 book → User manual toggle |
| 827–851 | 2335673581 | 🔍+ → ZoomPopup (50–300 % user scale) |
| 864–888 | 1263603982 | ◀ previous frame — focuses the timeline on the previous frame (view range shrinks to the frame's timespan) |
| ~890–1010 | 0 | frame set name + count text ("Frames: N") — LMB jumps to a specified frame (custom hit-test, like timeline zones) |
| 1012–1036 | 2142681495 | ▶ next frame — focuses the timeline on the next frame (verified: view range 25.82 s → 203.38 ms) |
| 1052–1072 | 2961929287 | ▼ frame set selection (switch the active frame set) |
Button names and functions are documented in the user manual's Control menu section (manual/tracy.md, generated into the profiler build directory by the build, e.g. build/profiler/manual/tracy.md). The web build omits Connection (live capture only) and Tracy Assist (desktop only).
User scale (DPI zoom)
The 🔍+ button (hover ID 2335673581) — Display scale in the user manual — opens a ZoomPopup with 50–300 % steps.
In the web build this scales the UI layout only: fonts, Style::ScaleAllSizes, and window sizes (main.cpp SetupDPIScale, scale = devicePixelRatio × userScale). The canvas buffer and the mouse→app mapping stay at devicePixelRatio (BackendEmscripten.cpp) — hit regions become physically larger in the same coordinate space, targeting precision is unchanged, and the standard click protocol works as-is.
Use it when small buttons are hard to hit or details are hard to read in screenshots. At 200 % every toolbar hit region is roughly 2× (verified: power 16→40 px, gear 25→50 px, Messages 105→190 px wide; row height 24→46 device px). The layout reflows completely at any scale — re-sweep after changing it and after setting it back. userScale is session-only unless Options → "Save UI scale" is on; a fresh page load starts at 100 %.
Limitations
- A connected live-streaming client cannot be tested in the web build (the browser GUI cannot open a TCP server). The embedded-trace GUI is the live-app proxy: real trace data, full GUI — for live-client behavior use Route B.
embed.tracy(10 MB) is downloaded from share.nereid.pl at configure time — network needed once per fresh workspace; the file then lives in$WS/build.
References
Sources for the behavior the rules above encode — consult them when re-verifying a constant or when a rule fails:
profiler/src/BackendEmscripten.cpp— emscripten input handlers, canvas/dpr sizing, cursor mapping.profiler/src/main.cpp:540-574— idle gate (activeFramesbudget).profiler/src/profiler/TracyMouse.cpp— per-frame click state cache.profiler/CMakeLists.txt:334-353— emscripten link options.- ImGui 1.92.9b (vendored copy in the CPM cache,
~/.cache/cpm/imgui/):imgui_widgets.cpp:483-545(click semantics table),imgui.cpp:11278-11403(event trickling),imgui.cpp:1736(trickle default on),imgui.cpp:1992-2016(AddMousePosEvent floor/dedup). - Emscripten 5.0.7:
src/lib/libhtml5.js(fillMouseEventData,targetX = e.clientX - (rect.left|0)into HEAP32),system/include/emscripten/html5.h(EmscriptenMouseEvent.targetXisint;EmscriptenWheelEventembeds amousesub-struct). - Upstream: imgui issue #4921 (new input event API — events spread over multiple frames), PR #2525 (same-frame click/release), issue #1992 (rapid down/up "soft clicks" dropped).
Route B — Native GUI (X11, headless)
Run the native (GLFW) GUI on a virtual X server for what Route A cannot test: a live TCP connection to a client, the native file dialogs (save/open flows), and X11 input semantics. Stack: Xvfb + openbox + xdotool + ImageMagick import. Constants marked M were verified on this workstation (1280x800 screen, openbox, profiler window client area at (1,20)); re-verify after any screen/WM change, and always confirm against a screenshot before clicking.
Build
cmake -S profiler -B /tmp/prof-x11 -DCMAKE_BUILD_TYPE=Release -DLEGACY=ON -DGTK_FILESELECTOR=ON
cmake --build /tmp/prof-x11 --target tracy-profiler -j$(nproc)
LEGACY=ONselects the X11 (GLFW) backend; the Linux default is Wayland.GTK_FILESELECTOR=ONroutes NFD through GTK. Without it the default Linux NFD uses the XDG desktop portal and fails headless with the notification "File selector is not available".- Verify after building:
ldd /tmp/prof-x11/tracy-profiler | grep -c gtk≥ 1.
Services
hub op=start name=xvfb application=Xvfb args=[":99","-screen","0","1280x800x24"]
Ready when /tmp/.X11-unix/X99 exists — check the socket. (If you add -nolisten tcp to the Xvfb args, a TCP port readiness check cannot work; the UNIX socket is then the only listener.) Then:
DISPLAY=:99 nohup openbox >/dev/null 2>&1 &
openbox is required. Without a window manager the window lands at an arbitrary position, xdotool windowactivate fails ("WM claims not to support _NET_ACTIVE_WINDOW"), and keyboard focus has no owner. Under openbox the profiler window is placed deterministically (M: (1,20), 1278x775 on a 1280x800 screen).
Run
DISPLAY=:99 LIBGL_ALWAYS_SOFTWARE=1 nohup /tmp/prof-x11/tracy-profiler >gui.log 2>&1 &
LIBGL_ALWAYS_SOFTWARE=1— no GPU; mesa swrast renders.- Window:
DISPLAY=:99 xdotool search --name "Tracy Profiler"→getwindowgeometryfor the origin;windowactivate --syncto focus. - Live test client:
tracy-monitor -p $(pgrep -n sleep)style — attach to a long-lived process; the in-process client listens on 8086 and advertises via UDP (discovery works headless; the client line appears under "Discovered clients").
Interaction rules
- Verify every click with a screenshot diff —
import -window rootbefore and after, then PILImageChops.difference(a, b).getbbox(): no (or irrelevant) bbox = the click missed. Expect at least one swallowed click; re-issue it and re-verify. - Move before click; sleep ≥0.3 s between two clicks at the same spot (ImGui double-click threshold is 0.3 s).
- Keyboard (
xdotool key) is reliable once openbox owns focus. - First launch only: an achievements prompt appears bottom-right — click "No" (M: 725,585). The choice persists in the Tracy config; later launches skip it.
- A stale "Client not ready / another server" notification means the client was replaced underneath the GUI — click Reconnect (M: 738,574); there is no automatic retry.
Coordinates (M: screen px, 1280x800, window at (1,20))
| Target | Coords |
|---|---|
| Get started → Connect | 101,227 |
| Get started → wrench (About panel) | 317,120 |
| Connection popup → Save trace… | 64,202 |
| Connection popup → Reconnect | 738,574 |
| GTK save dialog → Save button | 1195,773 |
| GTK overwrite confirmation → Replace | 790,482 |
| Tracy save-confirmation → Save trace | 757,540 |
Directly clicked and verified: Connect, Save trace…, GTK Save, Replace, and the save-confirmation button. Derived as the verified window-relative offset translated to the openbox window origin (confirm on first use): wrench, Reconnect, achievements "No".
The Get-started panel, connection popup, and the two confirmation panels keep fixed offsets inside the profiler window; the GTK dialogs are separate top-level windows sized to the screen. Derive any missing coordinate from a screenshot rather than guessing.
Save flow (end-to-end)
- Start the client, click Connect (the GUI never auto-connects).
- The connection popup opens automatically on connect; click Save trace….
- GTK dialog: the Name entry is focused and the last-used directory is preselected;
xdotool type --delay 30 <name>; click Save (1195,773). - Existing target file → a separate overwrite-confirmation window appears; click Replace (790,482).
- Tracy's own confirmation panel appears (shows the final
Path:); click its Save trace (757,540). - Verify the file (below).
File and connection checks
- Live connection:
ss -tnp | grep 8086showsESTABbetweentracy-profilerand the client process; the window title becomes<program> @ <date> - Tracy Profiler X.Y.Z. - Saved
.tracy: first 4 bytes =74 72 FD 50;tail -c +11 file | zstd -t→premature endmeans the frame decodes fully (expected — the cut falls past the final frame into trailing metadata);unsupported formatmeans a bad offset or a non-trace file. - No
file.tmpmay remain after a completed save; a save killed mid-write leaves the previous file intact (the GUI writes throughSafeFileWrite, tmp + rename-on-commit). - Video evidence while interacting:
ffmpeg -f x11grab -video_size 1280x800 -i :99 -t N -c:v libx264 out.mp4(background it; it records the whole interaction).
Limitations
- No debug harness — the screenshot diff is the only oracle; targeting is coordinate-from-screenshot, slower and coarser than Route A's hit-test sweeps.
- The verified coordinates are tied to the M constants (1280x800 screen, openbox default theme, 100 % UI scale); re-derive from a screenshot if any of them changes.
- The GTK dialogs are separate top-level windows whose layout follows the system GTK theme.
Teardown
pkill -f prof-x11; pkill -f tracy-monitor
pkill -f "sleep 600" # the test target from §Run — adjust to what you started
pkill openbox
# hub op=stop name=xvfb
References
profiler/src/BackendGlfw.cpp— X11/GLFW input handlers (mouse/keyboard → ImGui).profiler/CMakeLists.txt:5-7—NO_FILESELECTOR,GTK_FILESELECTOR,LEGACY(X11 vs default Wayland) options.cmake/vendor.cmake:222-242— NFD packaging:NFD_PORTALon by default on Linux, theGTK_FILESELECTORswitch, thenfd-xdg-foreign-v2.patch.server/TracySafeFileWrite.hpp— the GUI save's tmp + rename-on-commit (the invariants in §File and connection checks).- NFD (btzy/nativefiledialog-extended, pinned in
cmake/vendor.cmake) — the GTK dialog behavior (Name entry focus, last-directory memory, overwrite confirmation).