Rewrite --render to compute the spectrogram and write a PNG entirely on the CPU, with no window, no GL context, and no X server. Previously it opened a hidden GL window and grabbed LoadImageFromScreen(), which still required an X server (Xvfb); the output was a UI screenshot rather than the spectrogram data. The new path (RunHeadlessRender) loads the WAV, computes the STFT, colorizes the bitmap at native STFT resolution, bakes the mLnL annotation overlay onto it, and exports — all CPU-only. render.c gains a GL-free colorize (BuildSpectrogramImageCPU), a CPU font loader (LoadFontCPU), and a CPU overlay drawer (DrawAnnotationsToImage). Annotations draw outline + label only: mLnL captures contain many overlapping full-band boxes whose translucent fills alpha-stack to opaque and bury the signal. The outline marks each region while the spectrogram reads through; a dark backing strip keeps labels legible. Note: MeasureTextEx/ImageText* bail when font.texture.id == 0, so the CPU font sets a sentinel non-zero id (the draw path reads glyph images, never the texture). Render options: --annotation-opacity (overlay strength), --annotation-kinds (comma-separated kind filter), --width (resize; default native). Removed the obsolete --pane/--height window options and the screenshot workaround. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Working on a raylib app as an agent: headless run, see, and drive it
This is a portable playbook for an AI agent doing code-health / refactor work on a raylib (or any GLFW/OpenGL) desktop app. The problem it solves: you can edit and compile the code, but you can't see the window or click it — so how do you prove a refactor didn't change behavior, and how do you exercise a UI path?
The answer is a tight loop: run under a virtual display → capture a frame → diff it against a baseline → (optionally) inject keyboard/mouse → look at the PNG. Everything below is the accumulated know-how for doing that efficiently, plus the traps.
This repo's working reference implementation is
shot_input.sh. Read it alongside this doc; the "Porting" checklist at the end says what to change.
0. The loop in one breath
Xvfb :99 -screen 0 1280x800x24 & # 1. a fake screen
DISPLAY=:99 ./bin/Debug/game & # 2. run the app on it
sleep 2 # 3. let it reach a steady frame
DISPLAY=:99 import -window root /tmp/a.png # 4. screenshot the root
compare -metric AE /tmp/base.png /tmp/a.png /dev/null # 5. 0 == pixel-identical
If step 5 prints 0, the render is byte-for-byte what it was before your change. That
single number is the workhorse of refactor verification.
1. Prerequisites (Debian/Ubuntu)
sudo apt-get install xvfb imagemagick xdotool
# If OpenGL fails under Xvfb (black frames / GLX errors), add software rendering:
# sudo apt-get install libgl1-mesa-dri
# and run the app with LIBGL_ALWAYS_SOFTWARE=1
- Xvfb — an X server that renders to memory, no monitor. raylib's GL runs on it via Mesa's software rasterizer; you do not need a GPU.
- ImageMagick —
import(grab) +compare(diff). On ImageMagick v7 the commands aremagick import/magick compare; v6 usesimport/comparedirectly. - xdotool — synthesizes keyboard and mouse events into the X server.
2. Run headless
Xvfb :99 -screen 0 1280x800x24 >/tmp/xvfb.log 2>&1 &
DISPLAY=:99 ./bin/Debug/game [args] >/tmp/app.log 2>&1 &
- Match the Xvfb screen size to the app's window so a root grab == the app's frame.
- There is no window manager, so the window sits at
0,0and fills the screen, and X input focus is unmanaged (see §4). - Capture stdout/stderr to a log — it's your only window into
TraceLog/crashes.
3. Capture a frame — two ways
A. Grab the X root (zero code change):
DISPLAY=:99 import -window root /tmp/shot.png
If your ImageMagick policy blocks import ("not authorized"), use option B.
B. raylib's own framebuffer dump (one line of code, more robust):
TakeScreenshot("/tmp/shot.png"); // writes exactly what GL rendered, no chrome/cursor
Trigger it from a temp keybinding or a --shoot-after N frames flag. This bypasses
ImageMagick entirely and is the exact framebuffer — preferable when you can edit code
(which, in a refactor task, you already are).
C. A true no-display render path (if the app has one). The strongest option when
you only need to verify output (not the live UI): a CLI mode that computes the frame
and writes a PNG entirely on the CPU, never calling InitWindow — so it needs no
Xvfb, no GL, no X server at all. raylib's Image API (GenImageColor, ImageDraw*,
ImageDrawTextEx, ExportImage) is pure CPU; only Texture*/Draw*/BeginDrawing
need a GL context. So a headless path can reuse the real pixel/colorize/annotation code
and skip the window. (One trap: MeasureTextEx/ImageText* short-circuit to zero when
font.texture.id == 0, so a CPU-loaded font — LoadFontData + GenImageFontAtlas, no
upload — must set a sentinel non-zero texture.id to draw text.)
This repo has exactly that:
rspektrum --render OUT.png INPUT.wav(seeRunHeadlessRenderinspectrogram.c). Use it to check spectrogram/annotation output with no display. The Xvfb loop below is still needed to exercise the interactive GUI (clicks, drags, hover tooltips, the live sidebar) — things the static render can't show.
Then look at it: open the PNG with your image-reading tool. Vision catches "the HUD vanished" or "text is now black-on-black" that a pixel count won't explain.
4. Inject input (keyboard + mouse)
WID=$(DISPLAY=:99 xdotool search --name "MyGameWindowTitle" | head -1)
DISPLAY=:99 xdotool windowactivate "$WID" # give it focus first; keys need this
DISPLAY=:99 xdotool key space # keys: by keysym (Return, Escape, F1, a, ...)
DISPLAY=:99 xdotool mousemove 640 360
DISPLAY=:99 xdotool mousedown 1 ; sleep 0.2 ; xdotool mouseup 1
Both keys and mouse buttons reach GLFW under Xvfb. (If a guide tells you synthetic clicks are "dropped," it's wrong — see the next bullet for what actually bites you.)
The one mouse gotcha that wastes an hour
A button's press and release must fall on different frames of the app's loop.
raylib detects a click as current && !previous (a rising edge). A bare
xdotool click 1 presses and releases inside ~1ms — if both land in the same
PollInputEvents() tick, the app sees the button go down and up in one frame and the
rising edge never registers. Always do mousedown 1 → sleep ≥0.15s → mouseup 1.
Same for drags: mousemove A → mousedown → mousemove B → sleep → mouseup.
Lock/modifier keys
Scroll_Lock, Num_Lock, Caps_Lock get eaten by X as LED/lock toggles and do not
arrive as normal key presses. For temp test hooks use ordinary keys (letters, F-keys);
F1–F12 are reliable.
Hit the right rectangle
A click only does something if the pointer is over a live hit-target. Buttons and draggable regions are rectangles computed in code — read the layout code and aim at a rectangle's center, don't eyeball the screenshot. Many "the click didn't work" dead-ends are really "I clicked 10px into the sidebar / a dead margin / below a minimum-drag threshold." (This app, for instance, ignores selection drags under 5px.)
5. Determinism — the part that decides whether pixel-diff even works
compare -metric AE is only a proof if the same inputs produce the same pixels. Two
cases:
Mostly-static apps (viewers, editors, tools)
Once loaded/settled the frame is constant, so two runs of the same binary give AE 0.
Beware async/background work: if the app keeps computing after the window appears
(streaming load, background workers, lazy caches), an early grab is nondeterministic.
Capture after it settles — find the quiet point and add margin (this app needs ~12s
for a background high-res pass; a 6s grab varied run-to-run and produced scary false
diffs). Confirm your settle time by grabbing the same binary twice and checking the
diff is 0.
Games and anything continuously animating
Frames differ every run even with zero code change, so AE 0 is unattainable and the
naive gate is useless. Make the test deterministic instead, roughly in this order:
- Pin the randomness and the clock.
SetRandomSeed(N)(raylib) /srand(N); don't drive simulation off wall-clockGetTime()in test builds. - Fixed timestep. Don't advance physics by
GetFrameTime()(which jitters under software GL). In a test mode, step a constantdta fixed number of frames — or add a "single-step frame" debug key and advance exactly N steps before the grab. Now "frame N" is reproducible → its screenshot is a golden image. - Freeze and pose. Add a pause/step debug key. Drive the game with a scripted input sequence to a known state, pause, then grab — a paused frame is static and comparable.
- Pick naturally-static screens. Menus, the level-1 spawn, pause overlays, and game-over screens are often deterministic even when gameplay isn't. Script the input to land on one of those and diff that.
- When you can't fully pin it, measure the noise floor. Run the same binary twice,
comparethe two shots — that AE is the inherent run-to-run variance. A refactor is "preserving" if pre-vs-post AE stays within that floor. Usecompare -metric AE -fuzz 2%to ignore sub-threshold per-pixel wobble, and/or crop to a stable region.
Even when pixel-diff isn't reliable, the screenshot is still worth grabbing: eyeball it to confirm the refactor didn't break rendering wholesale.
6. Reaching states normal input can't
To test a code path you can't drive from the keyboard/mouse (an internal reset, a state deep in a menu, a value only mouse-wheel-settable), add a temporary keybinding that forces the state, verify, then remove it. Example pattern:
// TEMP TEST KEYS (remove before commit)
if (IsKeyPressed(KEY_F2)) { /* force the app into the dirty/edge state */ }
if (IsKeyPressed(KEY_F3)) { TheFunctionUnderTest(); }
Drive F2 then F3 via xdotool, screenshot between, diff. This is how you exercise a function end-to-end (e.g. "does load-new-file reset the view?") without the real trigger. Build with the temp keys, verify, delete them, rebuild, and re-confirm the baseline is unchanged before committing.
7. A reusable harness
Wrap §2–§4 in one script so a whole scenario is a single command. This repo's
shot_input.sh takes an output path plus a list of actions and runs
them in order, with sleep N, key NAME, click X Y, drag X1 Y1 X2 Y2, and
rdrag (modifier+drag) helpers that already bake in the frame-gap from §4:
./shot_input.sh /tmp/out.png "sleep 12" "drag 450 120 1000 400" "click 150 200"
The win for an agent: one Bash call launches, drives, screenshots, and tears down — no multi-turn babysitting of background processes, and the action list reads like a test case.
Porting it to another raylib project
Change these and nothing else:
REPOand the run command (binary path + how it takes/needs a data file).- The window title in
xdotool search --name(must match yourInitWindowtitle). - The settle
sleep(§5) — or drop it to a small value if the app is static at once. - The coordinate cheat-sheet in the header comment — recompute your own hit-rects
from the layout code at your window size, or just pass raw
mousemove/mousedownactions.
8. Refactor verification philosophy
- Pure mechanical renames are compiler-proven. When you rename a field/function and the old name ceases to exist, a clean compile means every reference was updated — the compiler found any you missed. For these, pixel-diff is a belt-and-suspenders check, not the primary safety.
- Behavior-changing edits need the visual gate. Anything that alters control flow, values, or layout: establish a baseline screenshot before the change, then prove the intended pixels changed and nothing else did.
- Keep a stable baseline (
/tmp/base.png) for the duration of a refactor arc so every step diffs against the same known-good frame. - Commit at green points. Build clean + diff clean = a safe checkpoint.
9. Quick-reference: traps that cost time
| Symptom | Cause | Fix |
|---|---|---|
| Black/empty frames | No GL under Xvfb | LIBGL_ALWAYS_SOFTWARE=1, install mesa-dri |
import "not authorized" |
ImageMagick policy | Use raylib TakeScreenshot() instead |
| Keys do nothing | Window not focused | xdotool windowactivate $WID first |
| Click does nothing | Press+release same frame | mousedown → sleep → mouseup |
Scroll_Lock/Caps ignored |
X swallows lock keys | Use letters / F-keys for temp hooks |
| Click "misses" | Wrong rectangle / dead margin / min-drag threshold | Aim at a hit-rect center from layout code |
| Diff nonzero with no code change | Async load or animation not settled | Settle longer; or determinize (§5) |
| Diff flaky run-to-run | RNG / wall-clock / variable timestep | Seed RNG, fixed dt, measure noise floor |