Record the akbasic API gaps as resolved and bump to 0.3.0
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / mutation_test (push) Failing after 17s

All ten items under "API gaps blocking akbasic" are closed. Items 7, 9 and
10 add public symbols and item 9 adds three fields to akgl_AudioVoice, so
the version and the soname go with them -- an 0.2 consumer cannot be handed
this library and told it is compatible.

Also refreshes the coverage numbers against a fresh run (79.6% line, 87.2%
function, all 21 suites), records the mutation smoke runs over the two files
that changed most, and files one new defect the text tests turned up:
akgl_text_rendertextat refuses the empty string while akgl_text_measure
accepts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 12:54:04 -04:00
parent 57bf1c7649
commit f35443e84d
2 changed files with 234 additions and 46 deletions

View File

@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10)
# The single source of truth for the library version. It drives the generated
# include/akgl/version.h, the shared library's VERSION and SOVERSION, and the
# Version field in akgl.pc. Bump it here and nowhere else.
project(akgl VERSION 0.2.0 LANGUAGES C)
project(akgl VERSION 0.3.0 LANGUAGES C)
include(CTest)
option(AKGL_COVERAGE "Instrument libakgl and generate coverage reports with CTest" OFF)

278
TODO.md
View File

@@ -379,30 +379,36 @@ ctest --test-dir build-coverage --output-on-failure
Reports land in `build-coverage/coverage/` (`index.html`, `coverage.xml`).
**Line coverage 77.2%, function coverage 83.1%** (2035/2637 lines), up from a
39.6% / 44.3% baseline. All 19 suites pass. (An earlier revision of this file
recorded `character` as an intentionally failing suite; it passes now, and
nothing in `tests/character.c` or `src/character.c` has changed since — the fix
came from elsewhere in the tree and this note was never updated.)
**Line coverage 79.6%, function coverage 87.2%** (2189/2749 lines, 164/188
functions), up from 77.2% / 83.1% and from a 39.6% / 44.3% baseline. All 21
suites pass. (An earlier revision of this file recorded `character` as an
intentionally failing suite; it passes now, and nothing in `tests/character.c`
or `src/character.c` has changed since — the fix came from elsewhere in the tree
and this note was never updated.)
Note the trap described in "Known and still open" item 13 while regenerating
these numbers: a coverage tree rebuilt after a source edit fails `coverage_reset`
with `GCOV returncode was 5`. `find build-coverage -name '*.gcda' -delete` clears
it.
| File | Lines | Functions |
|---|---|---|
| `src/actor.c` | 205/258 (79%) | 16/18 |
| `src/actor.c` | 205/258 (80%) | 16/18 |
| `src/assets.c` | 0/21 (0%) | 0/1 |
| `src/audio.c` | 189/207 (91%) | 19/20 |
| `src/audio.c` | 229/248 (92%) | 22/23 |
| `src/character.c` | 104/118 (88%) | 6/7 |
| `src/controller.c` | 247/270 (91%) | 12/12 |
| `src/controller.c` | 300/329 (91%) | 16/16 |
| `src/draw.c` | 253/267 (95%) | 11/12 |
| `src/error.c` | 9/9 (100%) | 1/1 |
| `src/game.c` | 124/231 (54%) | 10/15 |
| `src/game.c` | 128/235 (54%) | 12/17 |
| `src/heap.c` | 111/111 (100%) | 12/12 |
| `src/json_helpers.c` | 111/111 (100%) | 11/11 |
| `src/physics.c` | 140/140 (100%) | 10/10 |
| `src/registry.c` | 76/102 (75%) | 11/12 |
| `src/renderer.c` | 7/70 (10%) | 1/7 |
| `src/registry.c` | 76/102 (74%) | 11/12 |
| `src/renderer.c` | 42/75 (56%) | 7/8 |
| `src/sprite.c` | 93/101 (92%) | 5/5 |
| `src/staticstring.c` | 16/17 (94%) | 2/2 |
| `src/text.c` | 26/45 (58%) | 3/4 |
| `src/text.c` | 48/48 (100%) | 4/4 |
| `src/tilemap.c` | 201/426 (47%) | 10/20 |
| `src/util.c` | 121/131 (92%) | 7/8 |
| `src/version.c` | 2/2 (100%) | 1/1 |
@@ -423,7 +429,7 @@ scores):
|---|---|---|
| `src/draw.c` | 90% | Deleting the `FAIL_ZERO_BREAK` on `SDL_CreateTextureFromSurface` in `akgl_draw_flood_fill` |
| `src/audio.c` | 75% | Deleting `SUCCEED_RETURN` from the static `check_voice`; deleting `spec.freq` before opening a device |
| `src/text.c` | 50% | Three in `akgl_text_rendertextat`, which has no test yet, plus one `SUCCEED_RETURN` deletion |
| `src/text.c` | 50% | Three in `akgl_text_rendertextat`, which had no test yet, plus one `SUCCEED_RETURN` deletion |
The first pass over `src/audio.c` scored 50% and named two real gaps, both of
which are now tested: nothing asserted that an *unconfigured* voice is audible
@@ -434,11 +440,31 @@ scored 70% first and named one: the circle was only checked at its four axis
points, which a mis-signed octant reflection survives, so it now checks that
every plotted pixel has a mirror in the other three quadrants.
Re-run as a smoke check after the akbasic API work, again at 10 sampled
mutants each:
| File | Score | Surviving mutants |
|---|---|---|
| `src/controller.c` | 40% | Three `SDL_Log` deletions; `keybuffer_head`'s initialiser; `count > 0` in `keybuffer_attach_text`; the `mod` a text-only entry is given |
| `src/audio.c` | 60% | The `break` on the last `switch` case; `errctx->handled` in the device callback; `ensure_voices()` in the mixer; the mixer's voice loop bound |
Only one of those was a real gap and it is now asserted: nothing checked that
a text-only ring entry reports no modifiers. Of the rest, three are equivalent
mutants rather than misses — a ring buffer whose head starts at 1 behaves
identically, `count > 0` cannot be false where it is checked, and deleting the
`break` on a `switch`'s last case changes nothing — and the mixer's `v <=`
bound reads one voice past a zeroed table, which is undefined rather than
observable. `SDL_Log` deletions are unobservable by construction.
What is left is honestly untestable from here. Deleting a `SUCCEED_RETURN`
leaves a non-void function falling off its end, which is undefined rather than
observably wrong, and the surviving SDL branches are allocation failures the
suite has no way to provoke. The `src/text.c` survivors go away with the
offscreen harness.
suite has no way to provoke. The three `src/text.c` survivors in
`akgl_text_rendertextat` are gone: it is tested now, against a software
renderer, and deleting any of its three backend checks fails the suite. That
was not free — the first draft of the test used a made-up `SDL_Renderer`
pointer, which SDL refuses on its own, so the deleted-check mutant survived it.
A live renderer was what made the check observable.
### Suites
@@ -472,45 +498,60 @@ Done:
facing, movement logic, the animation frame state machine, `akgl_actor_update`,
and character/sprite binding lookups. 80%.
- `tests/audio.c` — every waveform, the ADSR envelope stage by stage, gate
expiry and release, voice summing and clamping, the master level, and device
open/shutdown under the dummy driver. 91%.
expiry and release, the frequency sweep frame by frame, voice summing and
clamping, the master level, and device open/shutdown under the dummy driver.
92%.
- `tests/draw.c` — every primitive against a 64x64 software renderer with the
pixels read back, including flood-fill containment, save/paste roundtrip, and
that drawing restores the renderer's draw color. 95%.
- `tests/text.c` — font loading into the registry and both measurement entry
points against a monospaced fixture font. 58%; the rest of `src/text.c` is
`akgl_text_rendertextat`, which needs the harness.
- `tests/text.c` — font loading into the registry, both measurement entry points
against a monospaced fixture font, and drawing through a bound backend over a
software renderer, including every way the backend can be unusable. 100%.
- `tests/renderer.c` — the 2D vtable binding, frame start and end, both
`draw_texture` paths, the refusals a bound-but-unrendered backend gives, and
the `draw_mesh` stub. 56%; `akgl_render_init2d` and `akgl_render_2d_draw_world`
are what is left, and both want the harness.
- `tests/headers.c` — that `akgl/controller.h` compiles as the first include in
a translation unit. The assertion is the compile; `main()` only has to return
zero.
## Remaining work
### Needs the offscreen renderer harness
`src/renderer.c` (63 lines), `akgl_text_rendertextat` in `src/text.c` (19),
`akgl_draw_background` in `src/draw.c` (13), `src/assets.c` (21),
`akgl_render_init2d` and `akgl_render_2d_draw_world` in `src/renderer.c` (33
lines), `akgl_draw_background` in `src/draw.c` (13), `src/assets.c` (21),
`akgl_actor_render`/`actor_visible` in `src/actor.c` (53), and the drawing half
of `src/tilemap.c` all need a live `renderer` global.
of `src/tilemap.c` all need a live `renderer` global, a window, or the world
globals.
`akgl_text_rendertextat` was on this list and is not any more: `tests/text.c`
builds a software renderer and binds a backend to it with `akgl_render_bind2d`
in nine lines, which is enough for anything that only needs *a* renderer rather
than the whole world. `tests/renderer.c` and `tests/draw.c` do the same. The
harness is still wanted, but for what is left it is a convenience rather than
the blocker it was.
Build `tests/harness.c` / `tests/harness.h` with `akgl_test_init_headless()` and
`akgl_test_shutdown_headless()`: set the dummy video and audio drivers,
`SDL_Init()`, `akgl_heap_init()`, `akgl_registry_init()`, create a software
`SDL_CreateWindowAndRenderer`, and point the global `renderer` at it. Four
`SDL_CreateWindowAndRenderer`, and point the global `renderer` at it. Seven
existing tests hand-roll this today (`tests/sprite.c:194`, `tests/character.c:200`,
`tests/tilemap.c:421`, `tests/charviewer.c:42`); collapse them onto the shared
harness in the same change. The new suites set the driver hints inline and need
no window, so they are unaffected.
`tests/tilemap.c:421`, `tests/charviewer.c:42`, plus `tests/draw.c`,
`tests/renderer.c` and `tests/text.c`); collapse them onto the shared harness in
the same change.
Then:
- **`tests/renderer.c`** — `akgl_render_init2d` populating all six function
pointers and the camera; frame start/end against a NULL `sdl_renderer`; the
rotated `draw_texture` path including `angle != 0` with a NULL center; the
`draw_mesh` "not implemented" stub; and `draw_world` layer ordering. Note
`defflags` at `src/renderer.c:113` is uninitialized until the `if` body runs.
- **`tests/renderer.c` extensions** — `akgl_render_init2d` populating the camera
from the property registry, and `draw_world` layer ordering. `tests/renderer.c`
exists and covers everything that does not need a world or the registry: the
vtable binding, frame start/end against a NULL `sdl_renderer`, both
`draw_texture` paths including `angle != 0` with a NULL center, and the
`draw_mesh` stub. Note `defflags` at `src/renderer.c:113` is uninitialized
until the `if` body runs.
- **`tests/assets.c`** — BGM loading into `AKGL_REGISTRY_MUSIC` under the dummy
audio driver.
- **`tests/text.c` extensions** — text rendering with wrap on and off.
`tests/text.c` exists and covers font loading and measurement; only
`akgl_text_rendertextat` is left.
- **`tests/draw.c` extensions** — `akgl_draw_background` at zero, negative and
oversized dimensions. `tests/draw.c` exists and covers every other primitive
against a software renderer; `akgl_draw_background` is the one function in the
@@ -857,6 +898,26 @@ without coming here first. Ordered by blast radius.
Fix: one word. Touches `src/actor.c:276`. Worth a test that renders a
deliberately non-square sprite and asserts on the destination rectangle.
### Found while closing the akbasic API gaps
27. **`akgl_text_rendertextat` refuses the empty string, and `akgl_text_measure` accepts it.**
SDL_ttf returns `NULL` with "Text has zero width" from both `TTF_RenderText_Blended` and
`TTF_RenderText_Blended_Wrapped` for `""`, so `src/text.c:56`'s `FAIL_ZERO_RETURN` on the
surface reports `AKERR_NULLPOINTER` for what a caller means as "draw nothing". Verified
directly against SDL_ttf 3.x with the fixture font.
`akgl_text_measure("")` is documented as legal and returns 0 wide by one line high, which
is what a cursor sitting on an empty line needs, so the two halves of the same header
disagree about the same string. The functional consequence is a caller that draws a line of
text which may be empty — a line editor, a `PRINT` of an empty string, a HUD field that has
not been filled in yet — has to test for it before every call or handle a failure that
means nothing went wrong.
**Fix:** return success without rasterizing when `text[0] == '\0'`, matching the measure
side, and say so in the header. Touches `akgl_text_rendertextat` only. `tests/text.c` has
the case written and deliberately not asserted; it would become a `TEST_EXPECT_OK`. Until
then the header carries the wart as a `@note` pointing here.
## Build notes
The vendored SDL satellite libraries are built into per-project subdirectories
@@ -872,26 +933,32 @@ test target, and prepends the build tree to `LD_LIBRARY_PATH` for the CTest run.
`akbasic` (the C port of the BASIC interpreter, `source.starfort.tech/andrew/akbasic`) is
being built to link into `libakgl` as a scripting engine for game authors.
**Items 1 through 4 are all resolved, and `akbasic` has since consumed every one of them.**
Its `libakgl`-backed text sink, its graphics backend, its sound backend and its input backend
are written and tested, and the BASIC 7.0 graphics verbs (`GRAPHIC`, `COLOR`, `DRAW`, `BOX`,
`CIRCLE`, `PAINT`, `SCALE`, `SSHAPE`, `GSHAPE`, `LOCATE`), the sound verbs (`SOUND`,
`ENVELOPE`, `VOL`, `PLAY`, `TEMPO`) and the console input verbs (`GET`, `GETKEY`, `SCNCLR`)
all work against them.
**All ten items are resolved.** Items 1 through 4 landed first and `akbasic` has since
consumed every one of them: its `libakgl`-backed text sink, its graphics backend, its sound
backend and its input backend are written and tested, and the BASIC 7.0 graphics verbs
(`GRAPHIC`, `COLOR`, `DRAW`, `BOX`, `CIRCLE`, `PAINT`, `SCALE`, `SSHAPE`, `GSHAPE`, `LOCATE`),
the sound verbs (`SOUND`, `ENVELOPE`, `VOL`, `PLAY`, `TEMPO`) and the console input verbs
(`GET`, `GETKEY`, `SCNCLR`) all work against them.
Doing that turned up **items 5 through 9**, filed below. Four are things `akbasic` had to work
around to build at all; each workaround is commented at its site over there with the words
"filed upstream", so they can be found and deleted when these land.
Doing that turned up **items 5 through 9**. Four were things `akbasic` had to work around to
build at all; each workaround is commented at its site over there with the words "filed
upstream". **Those workarounds can now be deleted** — including the five `add_subdirectory`
lines for the vendored SDL projects and the hand-assigned render vtable in
`tests/akgl_backends.c`.
Building the *standalone* SDL frontend on top of all that — a real window, a real event pump
and a line editor — turned up **item 10**, and re-confirmed items 6 and 7: both workarounds
had to be written a second time, in `src/frontend_akgl.c`, because a host that owns a window is
exactly the caller they inconvenience.
These are filed here rather than worked around in `akbasic` because growing `libakgl` to serve
These were filed here rather than worked around in `akbasic` because growing `libakgl` to serve
a consumer is the wanted outcome. Each entry says what the BASIC verb needs, what the
`akgl_*` entry point should look like, and what would cover it.
Items 7, 9 and 10 add public symbols and item 9 adds three fields to `akgl_AudioVoice`, so the
project version goes to **0.3.0** and the soname with it — an 0.2 consumer cannot be handed
this library and told it is compatible.
1. **No way to measure rendered text.** `include/akgl/text.h` exposes `akgl_text_loadfont()`
and `akgl_text_rendertextat()`, and nothing that reports how large a string will be in a
given font. A terminal-style text surface cannot be built on that: a cursor needs the
@@ -1047,6 +1114,28 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
`if(NOT TARGET ...)` so a consumer that has already declared them wins. The suppression of
their CTest registration should move with them.
**Resolved.** The vendored block no longer sits behind
`CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR`. An
`akgl_add_vendored_dependency(<target> <dir>)` macro adds each of the seven submodules when
nothing has already declared that target *and* the submodule is actually checked out; the
`find_package` lookups for anything left over run afterwards, unchanged, so a checkout
without submodules still resolves against the system. A macro rather than a function
because `add_subdirectory()` inside a function runs in that function's variable scope.
The CTest suppression moved with them, and is lifted again before this project registers
its own suites, so an embedding consumer's `add_test()` still reaches CTest. Two smaller
consequences of the move: `find_package(PkgConfig)` now runs only when something has to
come from the system (a fully vendored build needs no pkg-config of its own, though
`deps/SDL_mixer` asks for it separately), and the build-tree RPATH block keys on whether
anything was vendored rather than on being the top-level project, so an embedded build's
tests can also find the satellite libraries.
Verified by configuring and building a scratch consumer that embeds this repository with
`CMAKE_DISABLE_FIND_PACKAGE_SDL3`, `_SDL3_image`, `_SDL3_mixer`, `_SDL3_ttf`, `_akerror`,
`_akstdlib` and `_jansson` all set, so any reliance on an installed copy would have failed
the configure. It configures, builds and links. **The five `add_subdirectory` lines in
`akbasic`'s `CMakeLists.txt` can be deleted.**
6. **`include/akgl/controller.h` does not compile on its own.** Lines 35, 36 and 41 declare
handler function pointers taking an `akgl_Actor *`, and the header includes only
`SDL3/SDL.h`, `akerror.h` and `types.h` — none of which declares that type. Any translation
@@ -1058,6 +1147,17 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
`struct akgl_Actor` if the include order makes that circular. A one-line test that includes
only `akgl/controller.h` would have caught it and would keep catching it.
**Resolved.** `controller.h` includes `<akgl/actor.h>`. Not a forward declaration:
`akgl_Actor` is a typedef of a named struct, and repeating a typedef is C11, not C99. The
include closes no cycle — `actor.h` reaches only `types.h` and `character.h`, neither of
which knows about the controller.
`tests/headers.c` is the test, and it is a whole suite for one `#include` on purpose: a
second `#include` after the first proves nothing about the second, because by then the
first has dragged its dependencies in. Covering another header means another file shaped
like that one. It also stopped being true that `tests/controller.c` has to include
`akgl/actor.h` first, and the comment there saying so is gone.
7. **There is no way to attach a 2D backend to a renderer the caller already has.**
`akgl_render_init2d()` (`src/renderer.c:17`) does two separable things: it creates a window
and an `SDL_Renderer` from the `game.screenwidth`/`game.screenheight` properties and writes
@@ -1073,6 +1173,21 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
and nothing else, and have `akgl_render_init2d()` call it after it has made its window.
`tests/renderer.c` could then cover the vtable half without a display at all.
**Resolved.** `akgl_render_bind2d(akgl_RenderBackend *self)` installs the six pointers and
returns; `akgl_render_init2d()` calls it once the window and the camera exist. It
deliberately does *not* touch `self->sdl_renderer`, which is the whole point — a host that
already owns one keeps it, and a host that does not gets a backend whose entry points all
report `AKERR_NULLPOINTER` instead of crashing.
`tests/renderer.c` is new and needs no display: it covers the binding (including that the
caller's `SDL_Renderer` survives it, and that binding a backend without one is legal),
frame start and end and both `draw_texture` paths against a software renderer under the
dummy video driver, the refusals a bound-but-unrendered backend gives, and the `draw_mesh`
"not implemented" stub. `src/renderer.c` goes from 10% line coverage to 56%; what is left
is `akgl_render_init2d` itself and `akgl_render_2d_draw_world`, both of which want the
offscreen harness and the world globals. **`akbasic`'s hand-assigned vtable in
`tests/akgl_backends.c` can be deleted.**
8. **`akgl_text_rendertextat()` dereferences an uninitialised backend vtable.**
`src/text.c` calls `renderer->draw_texture(renderer, ...)` with no NULL check on either
`renderer` or the function pointer, so a backend that has an `SDL_Renderer` but has not been
@@ -1086,6 +1201,24 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
`renderer->draw_texture`, reporting `AKGL_ERR_SDL` or `AKERR_NULLPOINTER` as the draw
entry points do. Test alongside the existing `tests/draw.c` backend-without-a-renderer case.
**Resolved.** All three are checked, with `AKERR_NULLPOINTER` to match the draw entry
points, and they are checked *before* anything is rasterized rather than after: refusing
early costs nothing and leaks nothing, where refusing after the rasterize would hit the
leak the `@note` on this function already describes.
`tests/text.c` grew a software renderer under the dummy video driver — bound with the new
`akgl_render_bind2d`, which is what made this cheap — and covers all three refusals plus
the successful draw, wrapped and unwrapped. `src/text.c` goes from 58% to **100%** line
coverage, and the three `akgl_text_rendertextat` mutants the mutation run left surviving
are dead. The third case is the one that matters: with a *live* `SDL_Renderer` behind a
backend that was never bound, the old code got all the way to a NULL function pointer.
Checking it against a made-up renderer pointer, as the first draft of the test did, is
worth nothing — SDL refuses the bogus handle and the call fails for the wrong reason, which
a deleted-check mutant survives.
Found while writing that test and filed below as item 27: SDL_ttf refuses the empty string
on both rasterizing paths, so drawing an empty line is an error while measuring one is not.
9. **`SOUND`'s frequency sweep has no `akgl_audio_*` equivalent.** BASIC 7.0's
`SOUND voice, freq, dur, dir, min, step` ramps the pitch from `freq` toward `min` in `step`
increments per tick, in the direction `dir` selects — a siren, a laser, the whole reason
@@ -1104,6 +1237,36 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
hand and assert the frame at which the pitch has moved, exactly as `tests/audio.c` already
does for the envelope stages.
**Resolved.** `akgl_audio_sweep(voice, from_hz, to_hz, step_hz, ms)` is in
`include/akgl/audio.h`, with the step taken in `akgl_audio_mix()` off the mixer's own frame
counter. Decisions worth knowing:
- **One step every 1/60 second** (`AKGL_AUDIO_SWEEP_TICK_HZ`), because that is the rate the
machine this vocabulary comes from advanced its sweep at, and its tunes are written for
it. It divides 44100 exactly, so a step boundary always lands on a whole frame — which is
what lets the suite assert the exact frame the pitch moves on rather than a tolerance.
- **Direction comes from the two frequencies, not from the sign of the step.** `step_hz`
stays positive; `to_hz` below `from_hz` sweeps down. A negative step is refused rather
than silently meaning something.
- **It arrives and holds.** The last step is clamped to `to_hz` rather than overshooting,
and equal frequencies are a legal held tone rather than an error, so a caller computing
its own limits does not have to special-case them.
- **`akgl_audio_tone` is now the same start path with a step of 0**, which is also what
makes a voice reused for a plain tone stop sweeping. That is one function, `start_note`,
rather than two copies of the same six assignments.
- **A swept voice accumulates its phase** instead of deriving it from the frame counter.
Deriving assumes a constant frequency; under a sweep it jumps the waveform at every step,
which is an audible click. The drift the derived form exists to avoid is not something a
note that is changing pitch anyway can be said to suffer from.
`tests/audio.c` drives the mixer by hand: one tick's frames do not move the pitch, the next
frame does, both directions clamp at their target, a gate shorter than the sweep cuts it off
part way, and a plain tone on a swept voice stays put. `src/audio.c` holds at 92%.
Still missing for a complete `SOUND`: the oscillating third direction (C128 `dir` 2), which
is a re-issue on arrival rather than a third kind of sweep, and `FILTER`, `TEMPO` and the
`PLAY` note-string parser as before.
10. **The keystroke ring carries a keycode and nothing else, so Shift is invisible.**
`akgl_controller_handle_event()` (`src/controller.c:104-105`) pushes `event->key.key` into
the ring and drops the rest of the event, and `akgl_controller_poll_key(int *keycode, bool
@@ -1146,6 +1309,31 @@ a consumer is the wanted outcome. Each entry says what the BASIC verb needs, wha
`akgl_controller_handle_event()` and assert both the keycode and the composed character
come back, mirroring what `tests/controller.c` already does for the plain ring.
**Resolved.** `akgl_Keystroke` (keycode, `SDL_Keymod`, and eight bytes of UTF-8) and
`akgl_controller_poll_keystroke(akgl_Keystroke *dest, bool *available)` are in
`include/akgl/controller.h`; the ring now holds those instead of bare keycodes.
`akgl_controller_poll_key()` is untouched from its caller's side. Decisions worth knowing:
- **The text is attached to the press that is still waiting for it**, tracked by a flag
rather than by "whatever entry is newest". Without the flag, a press dropped by a full
buffer hands its text to some older key that happens to be sitting at the end of the
ring — which is wrong in exactly the case where things are already going badly.
- **Text with no press behind it is buffered on its own**, with a keycode of 0. An input
method commit and a character finished by a dead key have no key press of their own, and
a line editor wants the character regardless. `akgl_controller_poll_key()` discards those
on the way past rather than reporting a keystroke with no key.
- **Oversized text is cut on a code point boundary.** An IME can commit several characters
at once and an entry holds one; truncating on a byte boundary would leave a partial UTF-8
sequence that nothing downstream can render.
- **SDL only sends `SDL_EVENT_TEXT_INPUT` while text input is started**, so a host that
wants `text` populated calls `SDL_StartTextInput()` on its window. Without it `key` and
`mod` still arrive. That is documented on the function rather than left to be discovered.
`tests/controller.c` covers the shifted-key case end to end (the `"` that started this),
a non-printing key composing to nothing, a text-only entry through both pollers, text that
outlived its own press, the code-point-boundary truncation, and both NULL arguments.
`src/controller.c` holds at 91%.
## Carried over
1. **Make character-to-sprite state bindings release their references symmetrically.**