Make savegames, optional array elements, empty text and coverage work

Closes Known-and-still-open items 7 and 13, and Defects items 18, 25 and 27.

The savegame name tables carry no length prefix, so writer and reader have to
agree on a field width exactly. The writer used each object's own maximum name
length -- 512 for a spritesheet, a filename -- and the reader used
AKGL_ACTOR_MAX_NAME_LENGTH for all four. Four AKGL_GAME_SAVE_*_NAME_WIDTH
constants drive both sides now.

The failure turned out to be worse than "cannot be read back": a reader
stepping the wrong width does not run off anything, it finds a run of zeros
inside an entry, stops early, and reports success with silently wrong maps. A
test asserting only that the load succeeded passed against the broken reader.
So akgl_game_load checks it is at EOF once the tables are read, which turns a
width disagreement into AKERR_IO instead of a corruption. That is the assertion
the new roundtrip test -- the first with all four registries populated -- hangs
on.

akgl_get_json_with_default gains a third HANDLE_GROUP for AKERR_OUTOFBOUNDS,
which is what the array index accessors report, so "this element is optional"
works for an array element and not only for an object member. The arm goes
above the one holding the memcpy: HANDLE_GROUP emits no break and every arm
falls into that body.

That test needed a second attempt. with_default returns *the context it was
given* when it does not handle the status, so TEST_EXPECT_OK -- which releases
whatever the statement returns -- double-released it against a CLEANUP block
that released it too, and a double-released context corrupts the failure rather
than reporting it. The first draft passed against the unfixed library.

akgl_text_rendertextat returns success without rasterizing for the empty
string, matching akgl_text_measure, which has always accepted it. The check
sits after the font and backend guards, so drawing nothing still refuses what
drawing something refuses. tests/text.c had this case written and unasserted
waiting for the two halves of the header to agree.

character_load_json_state_int_from_strings guards dest rather than testing
states twice. Not asserted: the function is static with one call site that
passes a real pointer, so the guard cannot fire, and reaching it from a test
would mean giving it external linkage purely for that.

Both gcovr invocations take the build tree as an explicit positional search
path. gcovr searches --root when given none, which is the source directory,
where build trees live; --object-directory does not narrow it. Verified by
building two instrumented trees with a source edit between them: the old
invocation fails with "Got function write_exact on multiple lines: 46, 48" and
exits 64, the new one exits 0 and the full coverage run passes with the stale
tree still present.

25/25 pass, reindent --check, check_api_surface and check_error_protocol clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 07:18:31 -04:00
parent 947bc03601
commit 28fa929f6a
9 changed files with 307 additions and 93 deletions

145
TODO.md
View File

@@ -717,13 +717,30 @@ Each was found by a test written to assert correct behavior.
read past the end of one pool slot and wrote past the end of another. The
header documented that as behaviour. It is `AKERR_OUTOFBOUNDS` now, and a
negative count is refused too.
7. **Savegame name lengths disagree between writer and reader.**
`akgl_game_save_actors` writes spritesheet names at
`AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` (512) and character names at
`AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH` (128), but `akgl_game_load` reads
every table at `AKGL_ACTOR_MAX_NAME_LENGTH` (128). A save with any registered
spritesheet cannot be read back. The current roundtrip test passes only
because empty registries write nothing but zeroed sentinels.
7. **Savegame name lengths disagree between writer and reader.** **Fixed in
0.5.0.** Four `AKGL_GAME_SAVE_*_NAME_WIDTH` constants now drive both sides,
so the two cannot drift again. The writer used each object's own maximum name
length -- 512 for a spritesheet, which is a filename -- and the reader used
`AKGL_ACTOR_MAX_NAME_LENGTH` for all four. The other three are 128 as well, so
only the spritesheet table was wrong.
**The failure was worse than "cannot be read back", which is what made the
test interesting.** The tables carry no length prefix and end at a zeroed
sentinel, so a reader stepping the wrong width does not run off anything --
it finds a run of zeros somewhere inside an entry, stops early, and reports
success with silently wrong maps. A test that only asserted the load
succeeded passed against the broken reader.
So `akgl_game_load` now checks that the stream is at EOF once the four tables
are read. That turns a width disagreement into `AKERR_IO` instead of a
corruption, and it is the assertion `tests/game.c` actually hangs the test
on. The new roundtrip test registers a name in each of the four registries,
with a full-length one in the spritesheet registry, and against a mismatched
reader it fails with `AKERR_IO`.
That EOF check has to move when the objects themselves start being written;
there is a comment at the site saying so.
8. **Heap acquire functions are asymmetric.** `akgl_heap_next_string` increments
`refcount`; `next_actor`, `next_sprite`, `next_spritesheet`, and
`next_character` do not. `tests/heap.c` pins the current behavior and says so;
@@ -778,40 +795,27 @@ Each was found by a test written to assert correct behavior.
it so a future regeneration shows no spurious diff.
13. **A stale build tree in the source directory breaks the coverage run.**
`CMakeLists.txt:208` and `CMakeLists.txt:223` pass
`--root "${CMAKE_CURRENT_SOURCE_DIR}"` to gcovr, and gcovr searches for
`.gcda`/`.gcno` files under the root. The `--object-directory` argument on
the line below each does *not* narrow that search: per `gcovr --help` it
only identifies "the path between gcda files and the directory where the
compiler was originally run". So every instrumented build tree left inside
the source directory is folded into the report alongside the one actually
being measured.
**Fixed in 0.5.0.** Both `gcovr` invocations take the build tree as an
explicit positional search path and neither passes `--object-directory`.
With a `build-coverage/` from an earlier session still present, a freshly
configured `-DAKGL_COVERAGE=ON` tree fails in `coverage_reset`, before any
test runs:
gcovr searches for `.gcda`/`.gcno` under its search paths, and with none
given it searches `--root` -- the source directory, which is where
developers keep their build trees. `--object-directory` does not narrow
that; per gcovr's own help it only identifies "the path between gcda files
and the directory where the compiler was originally run".
AssertionError: Got function akgl_game_lowfps on multiple lines: 45, 46.
**Verified by reproducing it.** Two instrumented trees were built inside the
source directory with a source edit between them, so they described
different line numbers for the same functions. The old invocation fails with
`Got function write_exact on multiple lines: 46, 48` and exits 64; the new
one exits 0 and the whole 25-test coverage run passes with the stale tree
still sitting there.
45 and 46 are that function's line numbers before and after an unrelated
`#include` was added to `src/game.c`: gcovr found 18 stale `.gcno` files
describing the old layout, merged them with the current ones, and could not
reconcile the two. Moving `build-coverage/` aside makes the same tree pass
18/18. Verified with gcovr 7.0.
The `build*/` entry in `.gitignore` hides these trees from `git status`,
which makes the state easier to get into and no easier to notice.
A second, smaller version of the same thing: rebuilding an existing
coverage tree after editing a test leaves `.gcda` files describing the old
object layout, and `coverage_reset` -- whose whole job is to delete them --
fails with `GCOV returncode was 5` before it gets the chance. `find
build-coverage -name '*.gcda' -delete` clears it. Observed with gcovr 7.0.
Fix: pass the build tree to gcovr as an explicit search path instead of
letting it default to `--root`, so only the tree under measurement is
considered. Touches the two `add_test` blocks at `CMakeLists.txt:204-211`
and `CMakeLists.txt:220-229`; nothing outside the `AKGL_COVERAGE` branch.
The second, smaller version of the same thing -- rebuilding a coverage tree
after editing a test leaves `.gcda` files describing the old object layout,
and `coverage_reset` fails with `GCOV returncode was 5` before it can delete
them -- is unchanged. `find build-coverage -name '*.gcda' -delete` clears
it. That one is gcov's, not gcovr's search path.
14. **22 public symbols shipped without a version or soname bump.** 42b60f7 added
`akgl_draw_point`, `_line`, `_rect`, `_filled_rect`, `_circle`, `_flood_fill`,
@@ -888,18 +892,21 @@ without coming here first. Ordered by blast radius.
and `akgl_TilemapObject` is not small.
18. **`akgl_get_json_with_default` defaults on a status the array accessors never
raise.** `src/json_helpers.c:164-165` handles `AKERR_KEY` and `AKERR_INDEX`,
but `akgl_get_json_array_index_object`, `_integer` and `_string` all report
a short array as `AKERR_OUTOFBOUNDS`. So the "this element is optional" form
silently does not work for an array — the error propagates instead of being
replaced by the default. Only the object accessors, which do raise
`AKERR_KEY`, are actually served by this function today. Nothing in tree
passes an array accessor's error here, which is why it has not been noticed.
raise.** **Fixed in 0.5.0**: a third `HANDLE_GROUP(e, AKERR_OUTOFBOUNDS)`,
placed *above* the arm holding the `memcpy`, since `HANDLE_GROUP` emits no
`break` and every arm falls into that body.
Fix: add a third `HANDLE_GROUP(err, AKERR_OUTOFBOUNDS)`. Note that the
existing two rely on `HANDLE_GROUP` not emitting a `break`, so the `KEY`
case falls through into the `INDEX` body — a third arm has to go *above* the
one holding the `memcpy`, not below it. Touches `src/json_helpers.c:164-167`.
`tests/json_helpers.c` covers it through the integer and object index
accessors, so the fix is pinned to the status rather than to one call site.
Worth knowing for the next test written against this function: it returns
*the context it was given* when it does not handle the status, so
`TEST_EXPECT_OK` -- which releases whatever the statement returns -- will
double-release it against a `CLEANUP` block that also releases it, and a
double-released context corrupts the failure instead of reporting it. The
first draft of this test did exactly that and passed against the unfixed
library. It takes the result into a local and hands ownership over
explicitly now.
19. **The background music never loops.** `src/assets.c:20` initialises
`bgmprops` to 0, `src/assets.c:44` sets `MIX_PROP_PLAY_LOOPS_NUMBER` on it,
@@ -975,15 +982,16 @@ without coming here first. Ordered by blast radius.
running valgrind. Recorded here only so the duplicate does not read as
outstanding.
25. **`akgl_character_load_json_state_int_from_strings` checks the same argument
twice.** `src/character.c:123` guards `states` and `src/character.c:124`
guards `states` again under the message "NULL destination integer" — the
guard for `dest` was never written. A `NULL` `dest` is dereferenced at
`src/character.c:132`. Only reachable from inside the character loader, which
always passes a real pointer, so it is a latent hole rather than a live bug.
25. **`character_load_json_state_int_from_strings` checks the same argument
twice.** **Fixed in 0.5.0**: the second guard's subject is `dest`, which is
what it was always meant to be.
Fix: change the second guard's subject to `dest`. Touches
`src/character.c:124`.
**Not asserted, deliberately.** The function is `static` and has one call
site, which passes `&stateval` -- so a `NULL` `dest` is unreachable from the
public API and the guard cannot fire. Testing it would mean giving the
function external linkage purely to reach a defensive check, which undoes
internal-consistency item 9. The fix is a one-word correction to a guard
that is there for the next call site, not for this one.
26. **`akgl_actor_render` computes a sprite's drawn height from its width.**
**Fixed in 0.5.0**: `dest.h` takes `curSprite->height`. Every actor was
@@ -999,22 +1007,17 @@ without coming here first. Ordered by blast radius.
### 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.
**Fixed in 0.5.0**: it returns success without rasterizing when
`text[0] == '\0'`, matching the measure side.
`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.
The check sits after the font, text and backend guards rather than before
them, so drawing nothing still refuses the things drawing something refuses
-- a caller does not get a different contract for an empty string.
**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.
`tests/text.c` had the case written and deliberately unasserted, waiting for
the two halves of the header to agree. It is a `TEST_EXPECT_OK` now, on both
the wrapped and unwrapped paths, and against the old code it reports
`AKERR_NULLPOINTER`.
## Performance