Add the akgl_ui subsystem: clay-backed menus, HUDs and dialogs #3

Merged
andrew merged 15 commits from clay-ui into main 2026-08-02 16:14:50 -04:00
Owner

Nine commits bringing user interface layouts into the library on the clay layout engine (v0.14, vendored at deps/clay), per the plan we settled: hybrid API, runtime-optional, and the tutorials left untouched as the comparison point.

What this is

  • clay vendored the libccd way — sources-listed, one CLAY_IMPLEMENTATION TU (src/ui_clay.c, -w), symbols deliberately exported because consumers' CLAY() macros resolve against libakgl.so. clay.h installs beside semver.h; its zlib notice beside libccd's.
  • Runtime-optional, like collision: always compiled in, inert until akgl_ui_init. Static BSS arena, no malloc; every AKGL_UI_* ceiling is #ifndef-overridable and init refuses with both byte counts when the arena doesn't fit (measured: 812544 bytes needed at the default 1024 elements / 4096 words, against 1 MiB).
  • Two ways in, composable: akgl_ui_dialog / akgl_ui_label / akgl_ui_menu widgets for the common cases, and the whole CLAY() DSL between akgl_ui_frame_begin/frame_end when a screen outgrows them.
  • New draw primitives everyone gets: akgl_draw_filled_rounded_rect, akgl_draw_arc, akgl_draw_set_clip — fixed arrays, no VLAs.
  • The mouse arrives: akgl_ui_handle_event with a consumed contract (UI first, control maps second). Hit tests run against the previous frame's retained tree, with clay's full-screen root container excluded. mouseid/penid on control maps stay dead on purpose — TODO.md says where that work lands when a game wants mouse-driven actors.
  • examples/uidemo + headless example_uidemo test: title menu widget, options screen in raw CLAY(), HUD + dialog play screen, toured end-to-end by synthesized events. The tour caught a real bug — a stationary pointer pinning the menu selection against the keyboard — fixed with its own commit and regression test.
  • docs/22-ui.md (appendix moves to 23): one linked-and-run example, eight checked excerpts, and the side-by-side with examples/jrpg/textbox.c — the dialog widget's default style reproduces the textbox palette exactly so the 125-lines-vs-one-call trade is shown, not asserted. Chapter 21 keeps teaching the hand-rolled panel on purpose.
  • 0.8.0 → 0.9.0: new header, new status (AKGL_ERR_UI), new subsystem; the soname's major.minor makes the ABI change unmixable.

Known deferrals, all documented

Image tint and CUSTOM render commands skipped; per-corner radii collapse to top-left; immediate-mode text cost per frame — the text ring cache (TODO.md plan item 2) now has the UI executor as its second consumer. The AGENTS.md "Testing a Tutorial" weaker-model pass has not yet run against chapter 22.

Verification

Full non-perf suite green (33/33), including the new ui suite (lifecycle, arena exhaustion, fontId table, measure agreement, executor pixel round-trips, consumed contract, widget state machines), headers, api_surface, error_protocol, docs_examples, docs_screenshots, and all three example smoke runs. Every touched C file is a fixed point of scripts/reindent.sh --check.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71

Nine commits bringing user interface layouts into the library on the clay layout engine (v0.14, vendored at `deps/clay`), per the plan we settled: hybrid API, runtime-optional, and the tutorials left untouched as the comparison point. ## What this is - **clay vendored the libccd way** — sources-listed, one `CLAY_IMPLEMENTATION` TU (`src/ui_clay.c`, `-w`), symbols deliberately exported because consumers' `CLAY()` macros resolve against `libakgl.so`. `clay.h` installs beside `semver.h`; its zlib notice beside libccd's. - **Runtime-optional, like collision**: always compiled in, inert until `akgl_ui_init`. Static BSS arena, no `malloc`; every `AKGL_UI_*` ceiling is `#ifndef`-overridable and init refuses with both byte counts when the arena doesn't fit (measured: 812544 bytes needed at the default 1024 elements / 4096 words, against 1 MiB). - **Two ways in, composable**: `akgl_ui_dialog` / `akgl_ui_label` / `akgl_ui_menu` widgets for the common cases, and the whole `CLAY()` DSL between `akgl_ui_frame_begin`/`frame_end` when a screen outgrows them. - **New draw primitives** everyone gets: `akgl_draw_filled_rounded_rect`, `akgl_draw_arc`, `akgl_draw_set_clip` — fixed arrays, no VLAs. - **The mouse arrives**: `akgl_ui_handle_event` with a consumed contract (UI first, control maps second). Hit tests run against the previous frame's retained tree, with clay's full-screen root container excluded. `mouseid`/`penid` on control maps stay dead on purpose — TODO.md says where that work lands when a game wants mouse-driven actors. - **`examples/uidemo`** + headless `example_uidemo` test: title menu widget, options screen in raw `CLAY()`, HUD + dialog play screen, toured end-to-end by synthesized events. The tour caught a real bug — a stationary pointer pinning the menu selection against the keyboard — fixed with its own commit and regression test. - **`docs/22-ui.md`** (appendix moves to 23): one linked-and-run example, eight checked excerpts, and the side-by-side with `examples/jrpg/textbox.c` — the dialog widget's default style reproduces the textbox palette exactly so the 125-lines-vs-one-call trade is shown, not asserted. Chapter 21 keeps teaching the hand-rolled panel on purpose. - **0.8.0 → 0.9.0**: new header, new status (`AKGL_ERR_UI`), new subsystem; the soname's major.minor makes the ABI change unmixable. ## Known deferrals, all documented Image tint and CUSTOM render commands skipped; per-corner radii collapse to top-left; immediate-mode text cost per frame — the text ring cache (TODO.md plan item 2) now has the UI executor as its second consumer. The AGENTS.md "Testing a Tutorial" weaker-model pass has not yet run against chapter 22. ## Verification Full non-perf suite green (33/33), including the new `ui` suite (lifecycle, arena exhaustion, fontId table, measure agreement, executor pixel round-trips, consumed contract, widget state machines), `headers`, `api_surface`, `error_protocol`, `docs_examples`, `docs_screenshots`, and all three example smoke runs. Every touched C file is a fixed point of `scripts/reindent.sh --check`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
andrew added 9 commits 2026-08-02 12:49:31 -04:00
clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new
akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the
semver/libccd precedent: clay's CMakeLists declares no library target, builds
its examples by default, and requires CMake 3.27 against this project's 3.10.
src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on
the vendored-code terms but with clay's symbols deliberately exported --
consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them
never to link a second clay.

The subsystem is runtime-optional the way collision is: always compiled in,
inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_*
ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc),
and refuses with both numbers in the message when it does not fit. Measured:
812544 bytes at the default 1024 elements / 4096 measured words, against a
1 MiB arena. clay's void-callback layout errors are logged as they happen and
the first is stashed for the error protocol to raise later.

AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in
akgl_error_init. New `ui` test suite covers validation, the init/shutdown
lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same
contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h,
its licence beside libccd's, and the headers suite proves akgl/ui.h
self-contained -- clay.h compiles clean under -Wall.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
The UI subsystem's render executor needs all three -- clay emits rectangles
with corner radii, per-side borders, and scissor commands -- but they earn
their place in draw.h on the same terms as everything else there: a rounded
panel, a ring gauge and a clipped viewport are things a game wants with or
without a layout engine.

akgl_draw_filled_rounded_rect decomposes into three band fills plus four
quarter-circle triangle fans through SDL_RenderGeometry, with the radius
clamped to half the shorter side. akgl_draw_arc strokes between an outer and
inner radius as one triangle strip, stepping in proportion to the swept angle
under a fixed vertex cap -- no VLAs, unlike clay's own SDL3 reference
renderer. akgl_draw_set_clip wraps SDL_SetRenderClipRect and is the one draw
entry point where a NULL rectangle is legal, because that is how a clip is
cleared.

Pixel-readback tests cover the corner geometry (inside the arc filled,
outside it untouched), the radius clamp, the zero-radius fall-through, ring
and quarter-arc coverage at mid-stroke, sweep bounds, and clip set/clear
round trips.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
The UI subsystem now draws. akgl_ui_frame_begin/frame_end bracket one frame's
CLAY() declarations: begin clears the error stash and starts the clay layout,
end computes it and walks the render commands through a backend -- RECTANGLE
as (rounded) fills, BORDER as radius-shortened edge fills plus corner arcs,
TEXT through akgl_text_rendertextat one wrapped line per command, IMAGE as an
akgl_Sprite's first frame stretched to the bounding box, and the SCISSOR pair
through akgl_draw_set_clip, cleared again on any exit so a failed frame
cannot leave the world clipped.

akgl_ui_font_register maps registry font names onto clay's uint16_t fontIds.
The table keeps the *name* and resolves it per use -- fonts are not reference
counted, and a cached TTF_Font* would dangle where a name reports "gone"
honestly. Clay_TextElementConfig.fontSize is deliberately ignored: libakgl
bakes the size into the handle at load, so one face at two sizes is two ids.

The measure bridge passes clay's non-NUL-terminated slices straight to
SDL_ttf's explicit-length TTF_GetStringSize -- no copy, no scratch. Its
signature has no error channel, so failures report zero-by-zero and stash a
message that frame_end raises, the same route clay's own void error handler
uses; layout errors take precedence over drawing and one bad frame leaves the
next one clean.

Tests drive real CLAY() layouts through the software renderer and read pixels
back: fill placement, border edges with an empty middle, a child clipped by
its container, text landing in its colour, slice-vs-whole measurement
agreement, fontId table dedupe/refusal/exhaustion, and the bracket's
begin/begin, end-without-begin and failure-recovery contracts.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
akgl_ui_handle_event is the mouse half of the input story -- keyboard and
gamepad stay with the control maps, because keyboard focus is something an
application declares rather than something a pointer position implies. It
handles motion, left-button presses and releases, the wheel and window
resizes; everything else, and everything while the subsystem is inert,
reports consumed=false and succeeds, the same pass-everything contract
akgl_controller_handle_event already has. The documented call order is UI
first, control maps second, skipping on consumed.

A press, release or wheel is consumed when the pointer is over any UI
element. The hit test asks clay against the layout the previous frame
retained -- this frame's does not exist while events are polled, and one
frame of staleness is the standard model's accepted cost -- with clay's
internal full-screen Clay__RootContainer excluded, because being inside the
window is not being over the interface (unexcluded, every click anywhere was
consumed; the test that pins this caught it).

Pointer state is fed to clay per event, as clay documents; the press edge is
latched once per frame_begin for the widgets to come, rather than trusting
Clay_PointerData's per-SetPointerState edge, which advances per mouse event.
frame_begin also drives Clay_UpdateScrollContainers from the accumulated
wheel (32 pixels per notch) and a wall-clock dt.

Tests pin the whole contract: pass-through before init, no consumption
before any layout exists, press/release inside vs beside a panel, right
button ignored, motion never consumed, wheel consumed only over the UI,
resize reaching the layout engine, and key events left alone.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
The other half of the hybrid decision: an application that wants a dialog
box, a score counter or a main menu gets each in one call, without touching a
CLAY() macro -- and an application that outgrows them still has clay's whole
DSL, because the widgets are nothing but functions that declare the same
elements between the same frame bracket.

akgl_ui_dialog is the JRPG textbox in one line: bottom panel spanning the
layout, AKGL_UI_DIALOG_HEIGHT tall, one-pixel border, wrapped text. The NULL
style *is* the textbox palette -- near-black fill, parchment edge and ink,
8px padding -- deliberately, so the widget and the 125-line hand-rolled panel
it stands beside in the comparison chapter produce the same picture. Showing
and hiding is the frame's business: call it while somebody is talking, don't
while nobody is; a declarative frame is the visible flag.

akgl_ui_label pins a fit-sized text chip to a corner or the centre, inset by
the style's padding. akgl_ui_menu declares a centred vertical menu from a
caller-owned struct (no heap layer: it owns no texture, no font, no registry
entry), with the selected row drawn inverted, hover moving the selection and
the frame-latched press edge activating it. akgl_ui_menu_handle_event drives
the same struct from Up/Down/Return and D-pad/South with wrapping, slotting
into the event chain after akgl_ui_handle_event; routing events to a menu is
the application's focus model, stated rather than invented.

Tests cover the dialog's geometry and palette by pixel, label anchoring by
quadrant, menu validation, the inverted highlight, keyboard and gamepad
wrap/activate/pass-through, and the full two-frame mouse protocol -- click
aimed at the second row's real box via Clay_GetElementData, seen by the next
frame's declaration as selection plus activation.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
A pointer parked over a menu row re-claimed the selection on every frame's
declaration, so keyboard navigation fought the mouse sixty times a second
and lost -- press Up, and the hover put the selection straight back. Found
by the UI demo's scripted tour: its mouse click leaves the pointer resting
on the menu, and the Up keystroke that followed did nothing.

Hover now moves the selection only on frames the pointer actually moved (or
clicked), through a frame-latched motion edge beside the existing press
edge. Parking the mouse claims nothing; moving it onto a row still selects,
clicking still selects and activates. Regression test drives the exact
sequence: click a row, move the selection away by keyboard, redeclare, and
assert the stationary pointer stole nothing back.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
examples/uidemo is the program the UI chapter quotes. Three screens, three
ways of building an interface: the title screen is one akgl_UiMenu plus a
heading label; the options screen is written in raw CLAY() declarations --
hover highlighting inside the declaration, clicks paired with the
application's own press edge -- because the widgets are a convenience, not a
boundary; and the play screen is two HUD labels and the one-call dialog over
a checkerboard standing in for a game world. No tilemap, no actors, no
physics: everything left on screen is the subject.

Its route_event() is the documented call-order contract in the flesh -- UI
first, consumed events stop there, then the current screen's keys -- and the
screen routing is the focus model, stated rather than invented.

The headless smoke run (example_uidemo, --frames 240 --demo) tours every
path through the real event chain: a mouse click on the centred menu (the
consumed path, landing on the middle row by symmetry), keyboard into and out
of the options screen, the dialog opened and dismissed, and Quit confirmed
from the menu. The run prints its score and settings so a pass can be read,
not just counted. docs_game_figures gains a uidemo frame -- the play screen
with the dialog up -- for the chapter to come.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
docs/22-ui.md covers the clay-backed UI: what clay owns against what libakgl
owns, bring-up and the arena refusal contract, the frame bracket and where it
sits in a real frame, the consumed-event rules (with the one-frame-stale hit
test stated plainly), the three widgets, menus across keyboard, gamepad and
mouse, writing raw CLAY() with an application-owned press edge, images and
styles, and how layout errors surface from frame_end with a captured trace.
Every listing is a checked block: one linked-and-run example with pinned
output, eight excerpts quoting examples/uidemo and examples/jrpg/textbox.c,
and the chapter figure is a frame out of uidemo via docs_game_figures.

The chapter owes and pays the comparison the tutorials earn: the dialog
widget's default style reproduces the JRPG textbox palette, the two listings
sit side by side, and the trade is stated -- 125 lines you own completely
against one call that costs you the subsystem. Neither is deprecated;
chapter 21 keeps teaching the hand-rolled panel on purpose.

The appendix moves to 23 (links in chapters 4, 5, 6, 7, 13 and the TOC
updated) and gains the ui.h status cross-reference, the AKGL_UI_* limits
table, and the new draw primitives' rows. Its status-band figures were stale
at COUNT 5 / LIMIT 261 while the band already held six codes; now correct at
7 / 263. Chapter 4 gains AKGL_ERR_UI (262) in the excerpt, the table and the
name list.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
Record the UI's paper trail and bump to 0.9.0
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 23s
libakgl CI Build / performance (push) Failing after 22s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 19s
f772da7296
Two TODO.md notes the UI work created. The text texture ring cache (plan
item 2) now has its second consumer, and the bigger one: the UI executor
draws every TEXT render command through akgl_text_rendertextat, one wrapped
line per command per frame, so a five-row menu re-rasterizes five lines at
60 Hz against the original case of one HUD line changing once a second.
And akgl_ControlMap's mouseid/penid fields stay dead deliberately -- the
mouse path lives in the UI subsystem because control maps and pointer state
are different consumers, and the note says where the work lands when a game
wants mouse-driven actors.

0.8.0 to 0.9.0: a new public header, a new status code, three new drawing
primitives and a new subsystem is new API, and while the major version is 0
the soname carries major.minor -- libakgl.so.0.9 is what makes 0.8.0 and
0.9.0 unmixable at load time, which is the honesty the bump is for.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
andrew added 1 commit 2026-08-02 13:06:25 -04:00
Fill the gaps the chapter 22 weaker-model reader found
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 22s
libakgl CI Build / performance (push) Failing after 22s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 18s
f2efbdf1b7
The AGENTS.md "Testing a Tutorial" pass: chapter 22 was handed to a reader on
a weaker model with the library tree stripped of examples/ and docs/, and
they had to build a menu/HUD/dialog program from the chapter alone. They
built one that compiled and ran -- and reported success their own screenshots
disproved, which is why the protocol says to verify: their synthesized key
events set only the scancode, akgl_ui_menu_handle_event matches keycodes, and
their program never actually left the title screen. With that one line fixed,
everything they had built from the chapter worked.

Three findings survived verification against eight reported:

- The chapter never stated its prerequisites, so the reader burned most of
  the session on library bring-up it does not cover and invented goto error
  handling for main(). A new "What this chapter assumes" paragraph points at
  chapters 3, 7 and 17, and at the demo's startup() and main() as the
  complete reference.
- Nothing named the menu's exact keys, or that they are keycodes rather than
  scancodes -- invisible with a real keyboard, fatal for synthesized events,
  which the demo's own --demo script teaches. The menus section now names
  SDLK_UP/DOWN/RETURN, the gamepad buttons, and the key.key trap.
- Verifying their dialog screenshot showed a message wrapping past the fixed
  AKGL_UI_DIALOG_HEIGHT runs visibly out of the panel. Deliberate -- same as
  the hand-rolled textbox, and visible overflow beats silent truncation --
  but undocumented; akgl_ui_dialog's header note now says so.

Rejected for the record, per protocol: akgl_game_init already initializes the
property registry (src/game.c:219), and the font they "found" in a different
directory was the build tree's file(COPY) of tests/assets.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
Author
Owner

Chapter 22 weaker-model reader test — complete (commit f2efbdf)

Ran the AGENTS.md "Testing a Tutorial" protocol against the UI chapter: tree copied with examples/ and docs/ removed, chapter as TUTORIAL.md, the test font, and a screenshot helper marked as scaffolding. Build path proven first with a throwaway consumer (add_subdirectory + target_link_libraries(... akgl) — configures and builds clean, draws a label). The reader, on a weaker model, had to build a title-menu / score-HUD / dialog program from the chapter alone, drive it headlessly with synthesized events, and screenshot it.

What happened

The reader built a program that compiled, ran, exited 0 — and reported success their own screenshots disproved. Their "play screen with dialog" capture was the title menu again: their synthesized key events set only scancode, akgl_ui_menu_handle_event matches keycodes (key.key), so Return never fired and the program never left the title screen. Their report claimed the dialog and score "confirmed working". This is exactly the failure mode the protocol's verify-their-work-yourself rule exists for, and it caught it.

With that one line fixed by me (plus an off-by-one where they screenshot and close the dialog on the same frame), everything they had built from the chapter worked: menu with inverted highlight, score label ticking top-right, dialog wrapping at the bottom. The chapter's actual teachings — the frame bracket, the widgets, the event routing, the activation pattern — all survived contact with a reader who had nothing else.

Findings: 3 confirmed of 8 reported

Confirmed and fixed in f2efbdf:

  1. No prerequisites statement. The reader burned most of the session on library bring-up the chapter never covers, and invented goto error handling for main() because no runnable snippet shows one. Added "What this chapter assumes" pointing at chapters 3, 7 and 17, and at the demo's startup()/main() as the complete reference.
  2. Menu keys unnamed, keycode-vs-scancode unstated. Invisible with a real keyboard (SDL fills both fields), fatal for synthesized events — which the demo's own --demo script teaches. The menus section now names SDLK_UP/DOWN/RETURN, the gamepad buttons, and the key.key trap.
  3. Dialog text overflows the fixed-height panel, undocumented. Found by me looking at their screenshot, not by them: a message wrapping past AKGL_UI_DIALOG_HEIGHT runs visibly out of the panel. Deliberate (same behaviour as the hand-rolled textbox; visible overflow beats silent truncation) — now said so in akgl_ui_dialog's header docs.

Rejected after reproduction, for the record: "must call akgl_registry_init_properties before akgl_set_property" (akgl_game_init already does, src/game.c:219); "fonts live in libakgl/assets/ not tests/assets/" (that's the build tree's file(COPY) of tests/assets); plus three complaints that were artifacts of the sandbox or the headless harness rather than the chapter (missing examples/, headless env vars, dummy-driver behaviour).

Verification

docs_examples green on the amended chapter (1 linked+run program with pinned output, 8 excerpts), headers green, reader's amended binary re-run by hand with both screenshots inspected. Sandbox preserved locally if you want to poke at what they wrote.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71

## Chapter 22 weaker-model reader test — complete (commit f2efbdf) Ran the AGENTS.md "Testing a Tutorial" protocol against the UI chapter: tree copied with `examples/` and `docs/` removed, chapter as `TUTORIAL.md`, the test font, and a screenshot helper marked as scaffolding. Build path proven first with a throwaway consumer (`add_subdirectory` + `target_link_libraries(... akgl)` — configures and builds clean, draws a label). The reader, on a weaker model, had to build a title-menu / score-HUD / dialog program from the chapter alone, drive it headlessly with synthesized events, and screenshot it. ### What happened **The reader built a program that compiled, ran, exited 0 — and reported success their own screenshots disproved.** Their "play screen with dialog" capture was the title menu again: their synthesized key events set only `scancode`, `akgl_ui_menu_handle_event` matches keycodes (`key.key`), so Return never fired and the program never left the title screen. Their report claimed the dialog and score "confirmed working". This is exactly the failure mode the protocol's *verify-their-work-yourself* rule exists for, and it caught it. With that **one line fixed by me** (plus an off-by-one where they screenshot and close the dialog on the same frame), everything they had built from the chapter worked: menu with inverted highlight, score label ticking top-right, dialog wrapping at the bottom. The chapter's actual teachings — the frame bracket, the widgets, the event routing, the activation pattern — all survived contact with a reader who had nothing else. ### Findings: 3 confirmed of 8 reported Confirmed and fixed in f2efbdf: 1. **No prerequisites statement.** The reader burned most of the session on library bring-up the chapter never covers, and invented `goto` error handling for `main()` because no runnable snippet shows one. Added "What this chapter assumes" pointing at chapters 3, 7 and 17, and at the demo's `startup()`/`main()` as the complete reference. 2. **Menu keys unnamed, keycode-vs-scancode unstated.** Invisible with a real keyboard (SDL fills both fields), fatal for synthesized events — which the demo's own `--demo` script teaches. The menus section now names `SDLK_UP/DOWN/RETURN`, the gamepad buttons, and the `key.key` trap. 3. **Dialog text overflows the fixed-height panel, undocumented.** Found by me looking at their screenshot, not by them: a message wrapping past `AKGL_UI_DIALOG_HEIGHT` runs visibly out of the panel. Deliberate (same behaviour as the hand-rolled textbox; visible overflow beats silent truncation) — now said so in `akgl_ui_dialog`'s header docs. Rejected after reproduction, for the record: "must call `akgl_registry_init_properties` before `akgl_set_property`" (`akgl_game_init` already does, src/game.c:219); "fonts live in `libakgl/assets/` not `tests/assets/`" (that's the build tree's `file(COPY)` of tests/assets); plus three complaints that were artifacts of the sandbox or the headless harness rather than the chapter (missing `examples/`, headless env vars, dummy-driver behaviour). ### Verification `docs_examples` green on the amended chapter (1 linked+run program with pinned output, 8 excerpts), `headers` green, reader's amended binary re-run by hand with both screenshots inspected. Sandbox preserved locally if you want to poke at what they wrote. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
andrew added 1 commit 2026-08-02 13:39:22 -04:00
Unbreak CI: https submodule URLs, and three suites that assumed a display
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m4s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
2242cfac9f
CI has never gone green in the retained run history, and every failure is
the same 20-second death during checkout: six submodules -- SDL, SDL_image,
SDL_mixer, SDL_ttf, jansson, semver -- were registered with git@github.com:
SSH URLs, and the runner has no GitHub key. The https submodules (libccd,
tg, clay, and the two starfort ones) clone fine in the same run. All six now
use https, which is what every stanza added since already did; these are
public upstream repositories nothing here pushes to, so SSH bought nothing.

Second problem, waiting right behind the first: the character, sprite and
tilemap suites were the only three of seventeen that never set the dummy
video/audio/software-render hints, so on a headless runner they die in
SDL_Init with "No available video device" before testing anything. They now
set the same three hints the other fourteen suites set. (How this survived:
every local run happens on a machine with a display, and CI never got far
enough to run a test.)

Pre-flighted locally against every job the workflow defines, headless with
no driver variables in the environment, exactly as the runner would: Debug
with -Werror and coverage builds clean and all 37 tests pass including the
coverage fixtures; RelWithDebInfo with -Werror builds clean and the perf
suites pass their budgets at full scale; doxygen Doxyfile exits 0 under
FAIL_ON_WARNINGS; and the new ui suite runs under valgrind with zero errors
and no leaks.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
andrew added 1 commit 2026-08-02 14:02:11 -04:00
Install graphviz where doxygen runs
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m54s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
219d7182f1
The submodule fix got the cmake_build job through checkout, dependencies,
configure and the whole compile for the first time in the run history -- and
into the next latent failure: `doxygen Doxyfile` generates dot graphs and the
Doxyfile fails on warnings, but the runner image has no `dot`, so every graph
reported "Problems running dot: exit code=127" and the step failed after an
otherwise clean build. Locally graphviz was installed, which is why the
pre-flight missed it. One package in the one job that builds documentation.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
andrew added 1 commit 2026-08-02 14:19:09 -04:00
Stop set_tests_properties from eating the test environment lists
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m56s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
d8458d1e68
The example games failed on the CI runner with "ALSA: Couldn't open audio
device" despite SDL_AUDIODRIVER=dummy sitting right there in their CMake --
because it never reached the process. set_tests_properties parses its
PROPERTIES arguments as name/value pairs, so a semicolon-separated value
list is split and every element after the first is consumed as a bogus
property name: ENVIRONMENT kept only SDL_VIDEODRIVER=dummy, and the
LD_LIBRARY_PATH prepend list kept only its first directory. ctest
--show-only=json-v1 shows the truncation plainly.

Nobody could see it. Video survived because SDL3 falls through its driver
list to dummy on a headless machine with no hint at all; audio survived on
every developer machine because the real device works there; the library
paths survived because RPATH covered them. A runner with no sound card was
the first environment where any of it mattered.

Every list-valued test property now goes through set_property(TEST ...),
which takes real list arguments -- the examples' ENVIRONMENT triplets and
vendored-path modifications, the suites' LD_LIBRARY_PATH prepends, and the
docs harness's driver variables. Verified by property dump (3 environment
entries and all 7 prepends present) and by running the example tests with
ALSA_CONFIG_PATH=/dev/null, which now pass where a real ALSA open would
fail: the dummy driver is finally the one being asked.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
andrew added 1 commit 2026-08-02 14:29:46 -04:00
Pin upload-artifact to v3: v4 refuses to run against Gitea
Some checks failed
libakgl CI Build / cmake_build (push) Successful in 9m18s
libakgl CI Build / performance (push) Successful in 9m44s
libakgl CI Build / memory_check (push) Failing after 18m50s
libakgl CI Build / mutation_test (push) Has been cancelled
be3c8ae850
The env-list fix got every test green on the runner for the first time --
37 of 37, the JUnit publish included -- and the job then failed in the
artifact upload: upload-artifact@v4 speaks GitHub's v2 artifact service and
hard-refuses any host that is not github.com ("not currently supported on
GHES"). Gitea implements the v3 artifact API, so the three upload steps --
coverage, the benchmark tables, the valgrind logs -- pin to v3.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
andrew added 1 commit 2026-08-02 15:12:45 -04:00
Exclude the two shell-script tests from the memcheck run
All checks were successful
libakgl CI Build / cmake_build (push) Successful in 8m54s
libakgl CI Build / performance (push) Successful in 9m48s
libakgl CI Build / memory_check (push) Successful in 14m39s
libakgl CI Build / mutation_test (push) Successful in 27m24s
5560edf410
The memory job's first-ever complete valgrind pass on the runner reported
every real suite and all three example games clean -- and 414 findings in
docs_examples and docs_screenshots. Those two tests are bash scripts that
drive gcc and run the compiled snippets as children; valgrind wraps the test
command and does not trace children, so all 414 were /bin/bash's own
by-design leaks and not one byte of libakgl. Memchecking them proves nothing
the suites and example games do not already prove as real binaries under the
same valgrind run.

memcheck.sh now excludes exactly those two by name, with the reasoning in a
comment beside the exclusion -- per the suppressions file's own rule that
nothing gets quieted without a stated reason. Verified locally: full run over
the RelWithDebInfo tree, every suite and example under valgrind, zero
findings, exit 0.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
andrew merged commit bbb7b8f494 into main 2026-08-02 16:14:50 -04:00
Author
Owner

Merged — first fully green CI run in the repository's history

All four jobs green on 5560edf: cmake_build 8m54s, performance 9m48s (budgets enforced at full scale), memory_check 14m39s (every suite and all three example games under valgrind, zero findings), mutation_test 27m24s. Merged to main as bbb7b8f.

The CI failure was never this PR — no run in the retained history had ever passed, and getting to green peeled five layers, each hidden behind the one before it:

  1. Six submodules on git@github.com: SSH URLs the runner has no key for → https (2242cfa).
  2. Three test suites assumed a display — the only three of seventeen not setting the dummy-driver hints → hints added (2242cfa).
  3. No graphviz on the runnerdoxygen Doxyfile fails on warnings and every dot graph errored → package added (219d718).
  4. set_tests_properties eats list values — PROPERTIES parses as name/value pairs, so every multi-var test ENVIRONMENT was silently truncated to its first entry and the LD_LIBRARY_PATH prepend lists to their first directory. Invisible for years: SDL3 falls through to the dummy video driver headless anyway, real audio works on every dev machine, RPATH covered the paths. A runner with no sound card was the first place it mattered → set_property(TEST ...) everywhere a list is set (d8458d1).
  5. upload-artifact@v4 hard-refuses non-github.com hosts → pinned to v3, which Gitea implements (be3c8ae); and valgrind was memchecking /bin/bash for the two shell-script docs tests (414 findings, all bash's own) → excluded by name with the reasoning beside the exclusion (5560edf).

Items 1 and 3–5 were latent in CI config that had never executed end to end; item 2 and 4 were real defects in the tree that this PR flushed out. Every fix was pre-flighted locally in CI-shaped builds before pushing — the one thing that couldn't be (the runner's own package set and artifact API) is what took the extra iterations.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71

## Merged — first fully green CI run in the repository's history All four jobs green on `5560edf`: cmake_build 8m54s, performance 9m48s (budgets enforced at full scale), memory_check 14m39s (every suite and all three example games under valgrind, zero findings), mutation_test 27m24s. Merged to main as `bbb7b8f`. The CI failure was never this PR — no run in the retained history had ever passed, and getting to green peeled five layers, each hidden behind the one before it: 1. **Six submodules on `git@github.com:` SSH URLs** the runner has no key for → https (`2242cfa`). 2. **Three test suites assumed a display** — the only three of seventeen not setting the dummy-driver hints → hints added (`2242cfa`). 3. **No graphviz on the runner** — `doxygen Doxyfile` fails on warnings and every dot graph errored → package added (`219d718`). 4. **`set_tests_properties` eats list values** — PROPERTIES parses as name/value pairs, so every multi-var test ENVIRONMENT was silently truncated to its first entry and the LD_LIBRARY_PATH prepend lists to their first directory. Invisible for years: SDL3 falls through to the dummy video driver headless anyway, real audio works on every dev machine, RPATH covered the paths. A runner with no sound card was the first place it mattered → `set_property(TEST ...)` everywhere a list is set (`d8458d1`). 5. **`upload-artifact@v4` hard-refuses non-github.com hosts** → pinned to v3, which Gitea implements (`be3c8ae`); and **valgrind was memchecking `/bin/bash`** for the two shell-script docs tests (414 findings, all bash's own) → excluded by name with the reasoning beside the exclusion (`5560edf`). Items 1 and 3–5 were latent in CI config that had never executed end to end; item 2 and 4 were real defects in the tree that this PR flushed out. Every fix was pre-flighted locally in CI-shaped builds before pushing — the one thing that couldn't be (the runner's own package set and artifact API) is what took the extra iterations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: andrew/libakgl#3