diff --git a/CMakeLists.txt b/CMakeLists.txt index 4b96c5c..1253a8e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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) diff --git a/TODO.md b/TODO.md index 3f9fb1d..c890a9d 100644 --- a/TODO.md +++ b/TODO.md @@ -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( )` 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 ``. 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.**