Closes Defects items 20, 22, 23 and 29, and the residual of item 38. The tilemap load leak was five pooled strings per load; the property-lookup fix in an earlier commit took it to two, and the last two were each a claim with no matching release -- the string every layer's `type` was read into, and the dirname the map's relative paths resolve against. tests/tilemap.c asserts the pool is exactly where it started after one load/release cycle and after 64. Finding those two was a matter of dumping the contents of every still-claimed slot after a cycle rather than reading the code again; 'tilelayer' and an assets directory named themselves immediately. Same file, same class, fixed with it: akgl_tilemap_load_layer_objects released its scratch string after reading each object's name and then kept using the slot, because akgl_get_json_string_value reuses a non-NULL destination without taking another reference. The slot was free while still live, so any other claim could have been handed it. akgl_character_sprite_add wrote over an existing binding without releasing the sprite it displaced, so a character that rebinds a state while alive leaked a sprite slot per rebind -- teardown only gives back what the map holds at the end. The new reference is taken before the write and given back if the write fails, so there is no window where a sprite is bound with nothing behind it. The write was unchecked too. Three failure-path leaks moved into CLEANUP blocks: akgl_render_2d_init's two pooled strings, akgl_controller_open_gamepads' enumeration array, and akgl_text_rendertextat's surface and texture -- the last being a leak per frame on a HUD line. akgl_text_unloadallfonts() closes every font in the registry and destroys it, which is what item 38 left open. Deliberately not a whole akgl_game_shutdown: tearing down the mixer, SDL_ttf and SDL in the right order is a design question, and this is the part that was simply missing. It is a new public symbol, which 0.5.0 already covers -- this release has not shipped. Every fix has a test that fails against the old code. 25/25 pass, memcheck clean, reindent --check, check_api_surface and check_error_protocol all clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
109 KiB
TODO
Internal consistency
Findings from a sweep of src/ and include/. These are consistency and
convention problems, not new functional defects — where one has a functional
consequence it is called out. Ordered roughly by blast radius. Items that
overlap the existing Defects list are cross-referenced rather than repeated.
1. Public naming conventions
Items 1 through 6 are resolved in 0.5.0, which is an ABI break and carries
the soname to libakgl.so.0.5. What changed, and what a consumer has to rename:
| Was | Is |
|---|---|
_ASSETS_H_, _CONTROLLER_H_, _DRAW_H_, _ERROR_H_, _JSON_HELPERS_H_, _PHYSICS_H_, _REGISTRY_H_, _RENDERER_H_, _TEXT_H_, _TILEMAP_H_, _UTIL_H_, _STRING_H_ |
_AKGL_<FILE>_H_ |
akgl_Actor_cmhf_* (8 functions) |
akgl_actor_cmhf_* |
akgl_game_updateFPS |
akgl_game_update_fps |
akgl_render_init2d, akgl_render_bind2d |
akgl_render_2d_init, akgl_render_2d_bind |
akgl_sprite_sheet_coords_for_frame |
akgl_spritesheet_coords_for_frame |
point, RectanglePoints |
akgl_Point, akgl_RectanglePoints |
window, bgm, game, gamemap, renderer, physics, camera |
the same, akgl_-prefixed |
_akgl_renderer, _akgl_physics, _akgl_camera, _akgl_gamemap |
akgl_default_renderer, akgl_default_physics, akgl_default_camera, akgl_default_gamemap |
HEAP_ACTOR, HEAP_SPRITE, HEAP_SPRITESHEET, HEAP_CHARACTER, HEAP_STRING |
akgl_heap_actors, akgl_heap_sprites, akgl_heap_spritesheets, akgl_heap_characters, akgl_heap_strings |
GAME_ControlMaps |
akgl_controlmaps |
AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH |
AKGL_CHARACTER_MAX_NAME_LENGTH |
AKGL_TIME_ONESEC_MS |
AKGL_TIME_ONEMS_NS |
include/akgl/staticstring.h also stopped guarding with _STRING_H_ — a name
several libc implementations use for their own <string.h> — and its
#include "string.h" is now #include <string.h>.
akgl_render_bind2d was not on the original list. It is the same defect as
akgl_render_init2d, 2d trailing where the six functions it installs carry it
in the middle, and renaming one without the other would have been worse than
renaming neither.
The renames were done by renaming each declaration and letting the compiler find
every use, rather than by pattern substitution. That distinction matters for
renderer, physics and camera, which are also parameter and struct-member
names: a sed would have rewritten map->physics and every
akgl_RenderBackend *renderer parameter, and nothing would have complained.
Item 6 had a live bug behind the name, now fixed. akgl_game_state_lock
counted its retry loop against a constant named "one second in milliseconds"
that held 1000000, so it retried 10,000 times at 100 ms and blocked for
roughly sixteen minutes before reporting failure. The budget is now
AKGL_GAME_STATE_LOCK_BUDGET_MS (1000), with the cadence named separately as
AKGL_GAME_STATE_LOCK_RETRY_MS. tests/game.c carries
test_game_state_lock_budget, which holds the mutex from a second thread and
asserts the call gives up between half a second and five. The old code fails it
by timing the suite out; a build that gave up without waiting at all fails the
lower bound. Nothing exercised the contended path before — the existing lock
test only ever took an uncontended mutex, which never reaches the retry loop.
Item 4 was not cosmetic, and the proof was sitting in the test suite.
renderer was exported from the shared library, and tests/character.c defined
an SDL_Renderer *renderer of its own. Both had external linkage and the same
spelling, so the executable's definition preempted the library's:
akgl_sprite_load_json read a SDL_Renderer * through an
akgl_RenderBackend *, every texture load in that suite failed with
Parameter 'renderer' is invalid, and the suite still reported success — see
"Test suites that could not fail" below. It now binds a real backend with
akgl_render_2d_bind, the way tests/sprite.c and tests/text.c do. A
consuming game with a variable called renderer would have hit the same thing
with no test to notice.
2. Header/implementation surface drift
Items 7, 8, 9, 11, 12, 13, 14 and 15 are resolved in 0.5.0. Item 10 is resolved for every pair it listed.
-
Nineteen non-static functions were defined in
src/but declared in no header. Each is now declared orstatic:akgl_controller_handle_button_down,_button_up,_added,_removedwere defined asgamepad_handle_*whilecontroller.hdeclared theakgl_controller_handle_*names it never defined. The definitions now carry the declared names, which closes this and Defects → Known and still open item 10 in one change, and their documentation moved from the definitions to the header where the convention puts it.tests/controller.cno longer needs its local re-declarations.akgl_game_save_actorsandakgl_game_load_versioncmpare declared ingame.hunder a new "part of the internal API" block;tests/game.creaches them through the header now instead of declaring them itself.- The four save-table iterators and
akgl_game_load_objectnamemaparestaticand have dropped the prefix --save_actorname_iterator,load_objectnamemapand so on. They are SDL enumeration callbacks and a file-local reader; nothing outsidegame.chas any business calling them. akgl_get_json_properties_number,_float,_double,akgl_tilemap_load_layer_image,akgl_tilemap_load_layer_object_actorandakgl_tilemap_load_physicsare declared intilemap.h's internal-API block, again with their documentation moved to the header. That is where the "does not need a renderer" work under Remaining work wanted them.akgl_path_relative_rootisstatic path_relative_root.akgl_path_relative_fromis deleted; see below.
This is enforced now.
scripts/check_api_surface.shreads the built library's dynamic symbol table, strips comments out of every public header, and fails when an exportedakgl_*symbol is declared nowhere. It runs as theapi_surfaceCTest test. Stripping comments is the point: four of these symbols were mentioned incontroller.hprose, which is not the same as being declared there and is exactly how they went unnoticed. -
akgl_game_init_screenwas declared and never defined. The declaration is gone. Screen setup isakgl_render_2d_init. -
Static helpers used three naming styles. They drop the
akgl_prefix now, which is what it is for:character_load_json_inner,character_load_json_state_int_from_strings,sprite_load_json_spritesheet, alongside theactor_visible,write_exactandwrite_name_fieldthat already did. -
Parameter names disagreed between declaration and definition. All six pairs agree now.
akgl_character_initializetakesobj,akgl_character_state_sprites_iteratetakesprops,akgl_heap_release_charactertakesptr,akgl_set_propertytakesvalue, and the twoakgl_tilemap_draw*functions takemap-- the header called themdestand documented them as "Output destination populated by the function", which they are not.akgl_get_json_with_defaultwas the interesting one: it took the incoming context aserrand named its own contexte, which is the name the convention reserves for an incoming one. It iseanderrctxnow, and renaming it was what surfaced that the two had been swapped rather than merely misspelled. -
Object-pool size macros were defined twice and the override hook was dead.
AKGL_MAX_HEAP_ACTOR,_SPRITE,_SPRITESHEETand_CHARACTERare defined once, inheap.h, inside the#ifndefguards that were always supposed to make them overridable.actor.h,sprite.handcharacter.hno longer define them.tests/header_pool_override.cis the regression test: it defines its own ceilings, includesheap.h, and#errors if the guard did not fire or ifAKGL_MAX_HEAP_SPRITEstopped deriving fromAKGL_MAX_HEAP_ACTOR. The assertion is the compile -- it deliberately disagrees with the built library's ceilings and never touches the pools, which is fine because overriding a ceiling means the library and everything linking it have to be rebuilt together anyway. -
Headers relied on their includers for types.
iterator.huseduint32_twithout<stdint.h>;json_helpers.husedjson_twithout<jansson.h>;util.husedSDL_FRectandboolwithout any SDL include. Each compiled only because of.c-file include ordering.Resolved, and enforced rather than merely fixed.
AKGL_PUBLIC_HEADERSinCMakeLists.txtis now the single list behind bothinstall()and a generated translation unit per header -- each including exactly that header and nothing before it -- linked into theheaderssuite. A header that ships is a header that is checked.Writing that check found a case this item had missed:
registry.husesSDL_PropertiesIDin eight declarations and included no SDL header at all. That is the argument for generating the check off the install list rather than hand-listing the headers somebody thought were at risk. -
Include spelling was split between quoted and angled forms. Resolved. Every in-project include in a public header uses
#include <akgl/sibling.h>, andstaticstring.h's#include "string.h"-- a relative-first lookup that reached the system header by accident -- is#include <string.h>.tests/*.cstill use#include "testutil.h", correctly: that one is test-local rather than installed. -
Empty parameter lists. Resolved.
akgl_game_init,akgl_game_update_fps,akgl_heap_init,akgl_heap_init_actorand the eightakgl_registry_init*functions declare and define(void). Before C23()means "unspecified arguments" and suppresses argument checking, so these were the entry points a caller could pass anything to. -
AKERR_NOIGNOREwas applied inconsistently at definition sites. Resolved. It is on the declarations, where it does its work, and on no definition insrc/.
3. Error-handling pattern
-
*_RETURNmacros are used insideATTEMPTblocks, which skipsCLEANUP. The established pattern isFAIL_*_BREAK/CATCHinsideATTEMPTand*_RETURNoutside it. Violations:src/tilemap.c:52—SUCCEED_RETURNon the success path ofakgl_get_json_tilemap_propertyreturns directly from insideATTEMPT, bypassing theCLEANUPat line 54 that releasestmpstrandtypestr. Every successful property lookup leaks two heap strings, and the string pool is only 256 entries.src/tilemap.c:620—FAIL_RETURNinsideATTEMPT.src/controller.c:286-289,306—FAIL_ZERO_RETURN/FAIL_NONZERO_RETURNinsideATTEMPT;src/controller.c:383—SUCCEED_RETURNinsideATTEMPT, which also leavesakgl_controller_defaultwith no return statement on the path that falls out ofFINISH.src/game.c:457,462—FAIL_NONZERO_RETURNinsideATTEMPT, skipping thefcloseinCLEANUPat line 482.
-
NULL-check discipline varies by function. In
src/json_helpers.conlyakgl_get_json_string_value(line 71) andakgl_get_json_array_index_string(line 128) validatekey/dest; the other eight accessors validate only the container and then dereferencedestunconditionally. Insrc/physics.cthe arcade backends checkactorbutakgl_physics_null_gravity,_null_collide, and_null_move(lines 15-34) check onlyself. Insrc/renderer.c:65,74,akgl_render_2d_frame_startand_frame_enddereferenceself->sdl_rendererwith no check onself, whileakgl_render_2d_draw_texture(line 82) checksselffirst. -
Error-context variable naming is split between
errctxande, sometimes within one file.src/util.cusesein the path helpers (lines 32-116) anderrctxin the geometry helpers (lines 118-259);src/heap.cuseserrctxeverywhere exceptakgl_heap_init_actor(line 50).src/actor.c,character.c,json_helpers.c,registry.c,sprite.c, andtilemap.cfavorerrctx;game.c,physics.c,renderer.c, andcontroller.cfavore. Pick one.
4. Types and macros
Items 19 through 23 are resolved in 0.5.0.
-
float/doubleused raw wheretypes.hdefines aliases. The four signatures and twelve struct fields that spelled them out now usefloat32_tandfloat64_tlike the actor and character structs. They are plain typedefs, so this is a spelling change and not an ABI one.float64_t's doc no longer says "unused so far". -
AKGL_COLLIDE_RECTANGLEShad unbalanced parentheses. Deleted. Three opens against two closes meant any expansion was a syntax error, it had no callers, and it duplicatedakgl_collide_rectangles. -
Bitmask macros were unparenthesized. All five are fully parenthesized, and
AKGL_BITMASK_CLEARno longer carries a semicolon inside its body.tests/bitmasks.ccovers the composition cases, and writing them turned up something worth recording: the obvious test does not catch this. For a bit that is set,!AKGL_BITMASK_HAS(mask, bit)misparses to!(mask & bit) == bit, which is0 == bit-- false, the same answer the correct parse gives. It only diverges for a bit that is not set and whose value is not 1:!(0)is 1, and1 == 64is false where the answer should be true. The suite now uses that shape, and fails against the old macros. -
The state and iterator bit macros mixed forms.
AKGL_ITERATOR_OP_UPDATEis(1 << 0)like its 31 siblings, and every1 << niniterator.handactor.his parenthesized, with the hand-aligned value columns preserved.The bit-pattern comments in
actor.hare not wrong so much as unlabelled: each shows the pattern within its own 16-bit half, which is why bit 16 looks like it restarts at bit 0. The section headings say so now. Rendering the full 32-bit value instead would push those lines past the 100-column fill.Newly recorded, and still open:
1 << 31is undefined behaviour on a signedint. It is(1 << 31)in both tables and wants to be an unsigned shift, butakgl_Actor::stateisint32_tandakgl_Iterator::flagsisuint32_t, so the two tables do not want the same answer. Worth deciding deliberately rather than sneaking auin. -
akgl_Framewas defined and never used. Deleted.
5. AKGL_ACTOR_STATE_STRING_NAMES disagrees with actor.h
All three resolved in 0.5.0.
-
The array bound differed between declaration and definition. The header declared
[AKGL_ACTOR_MAX_STATES+1](33) and the definition was a literal[32], so a consumer trusting the declared bound read past the object. Both are[AKGL_ACTOR_MAX_STATES]now, and the definition is sized by the macro rather than by a literal. -
Two entries named the wrong bit. Indices 11 and 12 said
AKGL_ACTOR_STATE_UNDEFINED_11and_12whereactor.hhasMOVING_INandMOVING_OUT, so no character JSON could ever bind a sprite to either state -- the name it would have to write was not in the registry.tests/registry.cnow walks the whole table: every entry non-NULL, every entry resolving to its own bit throughAKGL_REGISTRY_ACTOR_STATE_STRINGS, no two entries sharing a name (a duplicate silently overwrites and makes one bit unreachable), andMOVING_IN/MOVING_OUTnamed explicitly so a regression reads as what it is. -
The generation comment was stale. There is no generator, no Makefile and no
lib_src/. The comment is gone and the file's own header now says it is maintained by hand and states the two invariants that keep breaking.
6. Doxygen drift
-
Three struct doc comments in
tilemap.hwere rotated by one. Already correct -- the doxygen rewrite fixed this before it was checked here.akgl_TilemapObject,akgl_TilemapLayerandakgl_Tileseteach describe themselves. -
pointdocumented as two-dimensional with anx,yandz. Already correct, and the type isakgl_Pointnow. -
Doc comments on the definition rather than the header. Resolved with item 7: the four controller handlers and the six tilemap loader helpers had their documentation moved to the header when they were declared there.
7. Formatting and hygiene
Items 31 through 36, 38 and 41 are resolved in 0.5.0. Item 37 is not; see below.
-
Leftover debug code. The four
SDL_Loglines insrc/controller.cguarded byevent->type == 768 && event->key.which == 11 && event->key.key == 13-- decimal literals for one keyboard on one developer's machine, inside the per-event inner loop -- are gone. -
Large commented-out blocks. The five abandoned
SDL_GetBasePath()path-prefixing lines acrossassets.c,sprite.c,character.candtilemap.care gone. They were superseded byakgl_path_relative. -
Unused locals.
curTimeinakgl_game_updateand inakgl_render_2d_draw_world,jinakgl_render_2d_draw_world(shadowed by its own inner loop),targetinakgl_character_sprite_get, bothresultdeclarations inutil.c, andscreenwidth/screenheightinakgl_game_init.opflagsinakgl_heap_release_characteris no longer unused -- it is what drives the state-sprite walk added for Defects item 21. -
akgl_game_update's default flags OR-ed the same bit twice. It readsAKGL_ITERATOR_OP_UPDATE | AKGL_ITERATOR_OP_LAYERMASKnow. This is a statement of intent rather than a behaviour change, and the reason is worth knowing: nothing in that loop reads either bit. It never comparesactor->layerto the layer it is sweeping, which is exactly Performance item 32 -- every actor updated sixteen times a frame -- and that is still open. -
akgl_draw_backgroundwas the only public function outside the error protocol. It now takes anakgl_RenderBackend *like every other entry point indraw.h, returns an error context, checks the backend and itssdl_renderer, and restores the draw colour it found instead of leaving it changed.It was listed under Remaining work as needing the offscreen renderer harness. It does not, and never did -- what it needed was to stop reading the global.
tests/draw.ccovers the checkerboard pattern, the restored draw colour, zero and negative sizes, and aNULLbackend. -
akgl_registry_init_actorwas the only initializer that destroyed the registry it was replacing. All eight go through oneregistry_create()helper now, so the other seven stop leaking anSDL_PropertiesIDper call after the first -- which a game that resets between levels makes on every level.Fixed alongside it, because it is the same function:
akgl_registry_init()never calledakgl_registry_init_properties(), which is Defects → Known and still open item 3.AKGL_REGISTRY_PROPERTIESstayed 0 for any caller that did not also go throughakgl_game_init, makingakgl_set_propertya silent no-op andakgl_get_propertyalways return the caller's default -- soakgl_physics_init_arcadeandakgl_render_2d_initquietly ignored their configuration.tests/registry.csets a property and reads it back. -
Redundant casts obscure the code. Still open, and deliberately so. Roughly 180 pointer casts across
src/, a large majority no-ops --(json_t *)jsonwherejsonis alreadyjson_t *,(char *)&obj->nameon an array that decays anyway -- densest insrc/tilemap.candsrc/character.c.The argument for fixing it is real: a no-op cast suppresses exactly the conversion warning that would catch a mismatch. The argument for not doing it here is that it is the largest and least mechanical change on this list, it touches almost every line of the two files with the most outstanding functional defects, and the benefit only arrives once the build actually turns those warnings on -- which it does not today. Doing this without
-Wall -Wextraon the library target buys nothing but churn.Do them together, in that order: enable the warnings, see what they say, then remove the casts that were hiding something and the ones that were not.
-
struct-qualified parameters in two definitions.akgl_physics_null_moveandakgl_physics_arcade_moveuse the typedef like the other eight. -
text.cvalidated the wrong argument. Resolved earlier, alongside the text measurement work. -
akgl_path_relative_fromdisagreed on the output parameter. Moot: the function is deleted. See Defects → Known and still open item 4. -
dstvsdestfor output parameters.akgl_string_copyandakgl_path_relativetakedestnow, like everything else.
Test suites that could not fail
Found while renaming the exported globals, and the reason item 4 above is filed as a real defect rather than a style complaint. Both are fixed.
Every suite reported success on any status whose low byte is zero.
libakerror's default unhandled-error handler ends in exit(errctx->status).
exit keeps only the low byte of what it is given, and libakgl's status band
starts at AKERR_FIRST_CONSUMER_STATUS, which is 256. AKGL_ERR_SDL is
therefore exactly 256, exit(256) is a wait status of 0, and CTest recorded a
pass. The most common failure status in a library built on SDL was the one
status that could not fail a test.
tests/character.c was green while running one of its four tests. It
aborted in test_character_sprite_mgmt with
Failed loading asset .../spritesheet.png : Parameter 'renderer' is invalid —
the symbol collision described in item 4 — and exited 0 because that status was
AKGL_ERR_SDL. Two independent defects had to line up, and they did, for long
enough that TODO.md recorded the suite as passing and wondered which change
had fixed it. Nothing had; it had stopped running.
Fixed in tests/testutil.h: TEST_TRAP_UNHANDLED_ERRORS() installs a
handler that collapses any status a byte cannot carry onto 1, and every suite's
main() calls it immediately after akgl_error_init(). tests/error.c calls
it after its first test instead, because that suite has no akgl_error_init()
in main() and libakerror's lazy akerr_init() would overwrite the handler.
Once installed, no other suite changed colour, so this was not masking anything
beyond character — but it could have been at any time, and nothing would have
said so.
Worth knowing: the fix belongs here rather than in libakerror only because
exit(status) is a reasonable thing for a library to do when statuses fit in a
byte. libakerror's own band does. Consumers' bands start at 256 by construction,
so any consumer's test suites have this problem. That is worth raising upstream.
Coverage status
Generated with:
cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build-coverage --parallel
ctest --test-dir build-coverage --output-on-failure
Reports land in build-coverage/coverage/ (index.html, coverage.xml).
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 (80%) | 16/18 |
src/assets.c |
0/21 (0%) | 0/1 |
src/audio.c |
229/248 (92%) | 22/23 |
src/character.c |
104/118 (88%) | 6/7 |
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 |
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 (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 |
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 |
Branch coverage reads 21.2% and should not be used as a target. The akerror
control-flow macros (ATTEMPT/CATCH/PROCESS/FINISH, FAIL_*_RETURN)
expand into large branch trees per call site, most of them unreachable in normal
operation — src/game.c reports over 1700 branches across 230 lines. Track line
and function coverage; treat branch coverage as a relative signal within a file.
Mutation testing
scripts/mutation_test.py was run over the three new files as a smoke check
(--max-mutants 8 to 10 each, so these are samples rather than exhaustive
scores):
| File | Score | Surviving mutants |
|---|---|---|
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 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
(the whole reason the table defaults to a square wave at full level rather than
a zeroed struct), and no envelope test used a non-zero attack and decay
together, so the decay measuring from the wrong origin survived. src/draw.c
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 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
Every suite is registered through the AKGL_TEST_SUITES list in
CMakeLists.txt, which drives target creation, CTest registration, the
WORKING_DIRECTORY/TIMEOUT properties, the link line, and the
FIXTURES_REQUIRED akgl_coverage list together. Adding tests/<name>.c and the
name to that list is all a new suite needs; it can no longer be accidentally
left out of the coverage fixture.
Shared assertion helpers are in tests/testutil.h: TEST_ASSERT,
TEST_ASSERT_FEQ, TEST_EXPECT_STATUS, TEST_EXPECT_OK, TEST_EXPECT_ANY_ERROR,
and TEST_ASSERT_FLAG. All except the last expand to a break on failure, so
they belong directly inside an ATTEMPT block, not inside a loop nested in one.
Done:
tests/physics.c— both backends, the factory, and the full simulation loop including thrust clamping, drag, layer masking, parent/child positioning, and logic-interrupt handling. 100%.tests/heap.c— pool exhaustion for all five pools, refcount clamping, recursive child release, registry cleanup on release. 100%.tests/json_helpers.c— every typed accessor, both string accessors' allocate-vs-reuse paths, array bounds, andakgl_get_json_with_default. 100%.tests/controller.c— control map push and capacity, the default binding set, keyboard and gamepad dispatch including cross-device rejection, the dpad handlers, and device add/remove against the dummy drivers. 90%.tests/game.c— version gating, the save/load roundtrip, foreign-save rejection, truncated-table detection, the state lock, and FPS accounting. 54%.tests/actor.c— extended with the eight control-map handlers, automatic 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, 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, 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, bothdraw_texturepaths, the refusals a bound-but-unrendered backend gives, and thedraw_meshstub. 56%;akgl_render_init2dandakgl_render_2d_draw_worldare what is left, and both want the harness.tests/headers.c— thatakgl/controller.hcompiles 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
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, 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. Seven
existing tests hand-roll this today (tests/sprite.c:194, tests/character.c:200,
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.cextensions —akgl_render_init2dpopulating the camera from the property registry, anddraw_worldlayer ordering.tests/renderer.cexists and covers everything that does not need a world or the registry: the vtable binding, frame start/end against a NULLsdl_renderer, bothdraw_texturepaths includingangle != 0with a NULL center, and thedraw_meshstub. Notedefflagsatsrc/renderer.c:113is uninitialized until theifbody runs.tests/assets.c— BGM loading intoAKGL_REGISTRY_MUSICunder the dummy audio driver.tests/draw.cextensions —akgl_draw_backgroundat zero, negative and oversized dimensions.tests/draw.cexists and covers every other primitive against a software renderer;akgl_draw_backgroundis the one function in the file that still reads the globalrendererrather than taking a backend.tests/tilemap.cextensions —akgl_tilemap_draw,_draw_tileset, andakgl_tilemap_load_layer_image.
Does not need a renderer
src/tilemap.c—akgl_tilemap_scale_actoris pure math over three branches (src/tilemap.c:824-830);akgl_get_json_properties_number,_float, and_doubleneed only a JSON snippet;akgl_tilemap_load_physicsneeds a fixture with the physics property block present, absent, and malformed. Together roughly 100 of the 225 uncovered lines.src/game.c—akgl_game_init,akgl_game_update,akgl_game_lowfps, andakgl_game_updateFPS's frame loop need a window; revisit after the harness lands.src/registry.c—akgl_registry_load_propertiesneeds a fixture with apropertiesobject, plus the missing-file, missing-key, and wrong-value-type cases. Assert the loop atsrc/registry.c:148-158does not leak the string heap.
Defects
Fixed while building the suites
Each was found by a test written to assert correct behavior.
akgl_physics_simulatedereferencedselfbefore its NULL check.src/physics.c:132readself->gravity_timeat declaration time, three lines aboveFAIL_ZERO_RETURN(e, self, ...). A NULL backend segfaulted.akgl_game_savenever flushed or closed its stream.CLEANUPandPROCESSwere transposed, which put thefcloseinside thePROCESSswitch, where it only ran if an error context existed and reported success. An ordinary save produced an empty file.akgl_game_save_actorswrote name-table terminators from a single char.aksl_fwrite((void *)&nullval, 1, AKGL_ACTOR_MAX_NAME_LENGTH, fp)emitted 127 bytes of adjacent stack memory into the save file and produced a sentinel the loader could not recognize.akgl_game_load_objectnamemapswallowed read failures.CATCHused directly insidewhile (1)breaks the loop, not the function, so a truncated or corrupt name table loaded as a successful game.akgl_Actor_cmhf_up_onand_down_ondereferencedbasecharunguarded, unlike their left and right counterparts.akgl_actor_logic_movementcheckedactortwice instead of checkingactor->basecharbefore dereferencing it.- The gamepad handlers checked
appstatethree times each, so a NULL event or a missing player actor was never caught andplayer->statewas dereferenced regardless.
Known and still open
-
akgl_render_and_comparecompares a texture against itself.src/util.c:228andsrc/util.c:245both drawt1;t2is never rendered, so the function always passes and the image assertions intests/sprite.cassert nothing. -
akgl_tilemap_releasedouble-frees tileset textures.src/tilemap.c:849destroysdest->tilesets[i].textureinside the layers loop; it should bedest->layers[i].texture. It also never NULLs the pointers, so a second release is a use-after-free. -
akgl_registry_initnever initializes the properties registry. Fixed in 0.5.0, alongside internal-consistency item 36, which is the same function.akgl_registry_init()callsakgl_registry_init_properties()now, soAKGL_REGISTRY_PROPERTIESis live for callers that do not also go throughakgl_game_init-- which is what madeakgl_set_propertya silent no-op andakgl_get_propertyalways hand back the caller's default, and so what madeakgl_physics_init_arcadeandakgl_render_2d_initquietly ignore their configuration.tests/registry.csets a property and reads it back. -
akgl_path_relative_fromis a stub that leaks. Deleted in 0.5.0. It claimed a heap string, never wrote its output, and never released it, so 256 calls exhausted the string pool. It was declared in no header, called from nowhere, and duplicated whatakgl_path_relativealready does. Fixing an unfinished function nobody can reach is worse than deleting it; this also closes item 40, which was about the output-parameter shape it disagreed with the rest of the family on. -
akgl_compare_sdl_surfacesmemcmps without checking geometry.src/util.c:208comparess1->pitch * s1->hbytes ofs2without verifying the surfaces share dimensions, pitch, or format. -
akgl_string_initializeoverflows by four bytes wheninitis NULL. Fixed in 0.5.0: it zeroessizeof(obj->data). The four bytes it used to run past the end of the object landed on the next pool slot'srefcount, which is the field the allocator reads to decide whether a slot is free -- so the overrun could hand a live string out twice.tests/staticstring.cclaims two adjacent slots, stamps a sentinel into the second's refcount, and initializes the first.Fixed alongside it, because it is the same file and the same class:
akgl_string_copyaccepted acountaboveAKGL_MAX_STRING_LENGTH, which read past the end of one pool slot and wrote past the end of another. The header documented that as behaviour. It isAKERR_OUTOFBOUNDSnow, and a negative count is refused too. -
Savegame name lengths disagree between writer and reader.
akgl_game_save_actorswrites spritesheet names atAKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH(512) and character names atAKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH(128), butakgl_game_loadreads every table atAKGL_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. -
Heap acquire functions are asymmetric.
akgl_heap_next_stringincrementsrefcount;next_actor,next_sprite,next_spritesheet, andnext_characterdo not.tests/heap.cpins the current behavior and says so; decide whether to make them symmetric or document the split. -
tests/util.cdefinestest_akgl_collide_point_rectangle_logicbutmain()never calls it. -
controller.hdeclares functions that do not exist. Fixed in 0.5.0; the definitions carry the declaredakgl_controller_handle_*names now. See internal-consistency item 7, andscripts/check_api_surface.sh, which is what stops this class of drift coming back. -
akgl_controller_pushmapandakgl_controller_defaultaccept negative map ids. Both checkcontrolmapid >= AKGL_MAX_CONTROL_MAPSbut notcontrolmapid < 0, so a negative id indexes beforeGAME_ControlMaps. -
A failed controller-DB fetch silently destroys the tracked fallback.
include/akgl/SDL_GameControllerDB.his committed deliberately so the library still builds if upstream disappears. Butmkcontrollermappings.shhas noset -eand never checkscurl's exit status: on a failed fetch it writesmappings.txtempty, then overwrites the good tracked header withAKGL_SDL_GAMECONTROLLER_DB_LEN 0and an empty initializer — and exits 0. Verified by pointing the script at an unresolvable host.Two compounding problems:
add_custom_commandatCMakeLists.txt:89-93declaresOUTPUT include/akgl/SDL_GameControllerDB.has a relative path, which CMake resolves against the binary directory, while the script writes to the source directory. The declared output never appears, so the command is permanently out of date and re-runs on every build — making every build depend on network access and leaving the file dirty in the working tree each time.const char *SDL_GAMECONTROLLER_DB[] = {\n};is an empty initializer, which is a constraint violation in ISO C and compiles only as a GCC extension.
Fix: have the script fetch to a temporary file, check
curl's status and a plausible minimum line count, and leave the existing header untouched on failure (exiting non-zero). Separately, make regeneration explicit — a dedicatedcontrollerdbtarget, or anOUTPUTthat matches where the script actually writes — so an ordinary build neither needs the network nor dirties the tree. -
A stale build tree in the source directory breaks the coverage run.
CMakeLists.txt:208andCMakeLists.txt:223pass--root "${CMAKE_CURRENT_SOURCE_DIR}"to gcovr, and gcovr searches for.gcda/.gcnofiles under the root. The--object-directoryargument on the line below each does not narrow that search: pergcovr --helpit 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.With a
build-coverage/from an earlier session still present, a freshly configured-DAKGL_COVERAGE=ONtree fails incoverage_reset, before any test runs:AssertionError: Got function akgl_game_lowfps on multiple lines: 45, 46.45 and 46 are that function's line numbers before and after an unrelated
#includewas added tosrc/game.c: gcovr found 18 stale.gcnofiles describing the old layout, merged them with the current ones, and could not reconcile the two. Movingbuild-coverage/aside makes the same tree pass 18/18. Verified with gcovr 7.0.The
build*/entry in.gitignorehides these trees fromgit 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
.gcdafiles describing the old object layout, andcoverage_reset-- whose whole job is to delete them -- fails withGCOV returncode was 5before it gets the chance.find build-coverage -name '*.gcda' -deleteclears 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 twoadd_testblocks atCMakeLists.txt:204-211andCMakeLists.txt:220-229; nothing outside theAKGL_COVERAGEbranch. -
22 public symbols shipped without a version or soname bump.
42b60f7addedakgl_draw_point,_line,_rect,_filled_rect,_circle,_flood_fill,_copy_regionand_paste_region;akgl_audio_init,_shutdown,_tone,_stop,_waveform,_envelope,_volume,_voice_activeand_mix;akgl_controller_poll_keyand_flush_keys; andakgl_text_measureand_measure_wrapped.project(akgl VERSION 0.1.0)and thelibakgl.so.0.1soname were both left alone.That contradicts this repository's own stated rule, which
akbasic'sCLAUDE.mdquotes back: "for both 0.x libraries the soname carriesMAJOR.MINORdeliberately: 0.1 and 0.2 are different ABIs." Adding exported symbols under an unchanged soname has two consequences, and the first is the one that bites:- A binary compiled against the new headers links happily against a
libakgl.so.0.1built from the old tree, because the soname says they are the same ABI. It fails at symbol resolution rather than at configure time. AKGL_VERSION_AT_LEAST(0, 1, 0)is true for both trees, so a consumer cannot feature-test for the new API at all.akbasicpins the requirement by submodule commit instead, and says so in its README, which is not a thing a released library should make anybody do.
Fix: bump
project()to 0.2.0, which carries the soname tolibakgl.so.0.2through the existing logic atCMakeLists.txt:138.akstdlibConfigVersion.cmake'sSameMinorVersionequivalent then refuses the mismatch at configure time as well.Resolved by
1066ac7, which did exactly that while this was being written — the two crossed, rather than one following the other. Kept rather than deleted because the reason is still the useful part:akbasiccould not feature-test for the new API and had to pinlibakglby submodule commit in its README until this landed, which is the concrete cost of an additive release under an unchanged soname. Worth remembering the next time a handful of symbols looks too small to bump for. - A binary compiled against the new headers links happily against a
Found while rewriting the Doxygen comments
Each of these came out of reading an implementation against the contract its
header claimed. They are recorded inline as @note or @warning on the
function concerned, so a reader of the generated documentation finds them
without coming here first. Ordered by blast radius.
-
akgl_path_relativeleaks one error-context slot per call on its fallback path.src/util.c:120returnsakgl_path_relative_root(...)from inside theHANDLE(e, ENOENT)block.FINISHis what carriesRELEASE_ERROR, so returning before it never gives the context back. libakerror hands these out of a fixedAKERR_ARRAY_ERROR[128], and this is not a rare path — it is the ordinary one, taken every time an asset names a neighbour relative to its own file rather than to the working directory. Every tileset image, layer image and spritesheet in a map costs a slot, permanently. Once the array is exhausted every subsequent failure anywhere in the process has nowhere to report from.Fix: assign the result to a local,
break, and return afterFINISH; or hoist the fallback out of the handler entirely and let theHANDLEblock only record that a retry is wanted. Touchessrc/util.c:117-121only. -
akgl_sprite_load_jsondoes not bound theframesarray. Fixed in 0.5.0. The count is bounded againstAKGL_SPRITE_MAX_FRAMESbefore anything is written, and each element is read into anintand narrowed deliberately rather than written through auint32_t *cast of auint8_t *. A frame number that does not fit auint8_tis refused too, rather than truncated into an index that names a different tile.tests/sprite.ccovers exactly the maximum (must load, and every id must arrive), one past it, and the wide frame number, against three new fixtures. Against the old code the middle case loads happily. -
Two more unbounded array loads in the tilemap loader. Fixed in 0.5.0, both with the bound at the top of the loop body, the shape
akgl_tilemap_load_layersin the same file already used.tests/tilemap.ccovers both against generated fixtures: an object layer of exactly 128 (must load) and of 129, and a map with 17 tilesets. The object one is the reachable half -- 128 objects is not a large object layer andakgl_TilemapObjectis not small. -
akgl_get_json_with_defaultdefaults on a status the array accessors never raise.src/json_helpers.c:164-165handlesAKERR_KEYandAKERR_INDEX, butakgl_get_json_array_index_object,_integerand_stringall report a short array asAKERR_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 raiseAKERR_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.Fix: add a third
HANDLE_GROUP(err, AKERR_OUTOFBOUNDS). Note that the existing two rely onHANDLE_GROUPnot emitting abreak, so theKEYcase falls through into theINDEXbody — a third arm has to go above the one holding thememcpy, not below it. Touchessrc/json_helpers.c:164-167. -
The background music never loops.
src/assets.c:20initialisesbgmpropsto 0,src/assets.c:44setsMIX_PROP_PLAY_LOOPS_NUMBERon it, andsrc/assets.c:46plays the track with it. 0 is SDL's "no property set" sentinel, not a set this function owns, so the write is rejected — unchecked — and the play call is given no options. The music plays once and stops.akgl_load_start_bgmreports success either way.Fix:
SDL_CreateProperties()intobgmprops, check it, and destroy it inCLEANUP. Touchessrc/assets.c:20-47. -
akgl_character_sprite_addleaks a sprite reference when a state is remapped.src/character.c:51writes the new sprite over any existing entry for that state without releasing the one it displaces, whose refcount was incremented when it was added. The displaced sprite's pool slot is never reclaimed. The write itself is also unchecked, so a failure to record the mapping is reported as success.Fix: read the existing entry first and
akgl_heap_release_spriteit, and checkSDL_SetPointerProperty's return. Touchessrc/character.c:43-55. -
akgl_heap_release_characterabandons the whole state-to-sprite map.src/heap.c:150zeroed the character without walkingstate_sprites, so every sprite reference the character took inakgl_character_sprite_addwas lost and theSDL_PropertiesIDholding the map was never destroyed. Loading and releasing characters in a loop — level to level — exhausted the sprite pool and leaked an SDL property set each time.Fixed in 0.5.0. At a refcount of zero it enumerates
state_spriteswithakgl_character_state_sprites_iterateandAKGL_ITERATOR_OP_RELEASE, thenSDL_DestroyProperties, then zeroes the slot. The iterator existed for exactly this walk and simply was never called from here.The test was already written.
tests/character.chas asserted this contract all along — "character did not reduce reference count of its child sprites when released" — and had never once run, for the two reasons under "Test suites that could not fail". It runs now, and it fails against the old code.Still open and separate: item 20,
akgl_character_sprite_addleaking the displaced sprite when a state is remapped. Releasing at character teardown does not cover a binding replaced while the character is alive. -
akgl_path_relative_rootusesFAIL_RETURNinside itsATTEMPTblock. Fixed in 0.5.0 with internal-consistency item 16, which swept every such site. It isFAIL_BREAK, andpath_relative_rootisstaticnow.scripts/check_error_protocol.pyfails the build if one comes back. -
Three smaller leaks on failure paths. All three fixed in 0.5.0, each by moving the release into a
CLEANUPblock.akgl_render_2d_initreleased its two pooled strings only after bothaksl_atoicalls succeeded, so a non-numericgame.screenwidthleaked two of the pool's 256 entries.tests/renderer.cruns 512 failing initializations against each of the two properties and asserts the pool is unchanged; against the old code the first loop claims every slot.akgl_controller_open_gamepadsfreed the enumeration array only after the loop completed, so a gamepad that failed to open took the array with it. The open failure is recorded and reported after the loop, because aFAIL_ZERO_BREAKinside it would have broken the loop rather than the block. It also frees the array on the "no gamepads enumerated" path, which SDL still allocates for.akgl_text_rendertextatdestroyed the surface and texture only on the success path, so a failed upload leaked the surface and a failed draw leaked both -- once per frame on a HUD line.
-
akgl_get_propertyreads past the end of the property value. Already fixed, as Defects the memory checker found item 35 -- the two entries are the same defect found twice, from reading the code and from running valgrind. Recorded here only so the duplicate does not read as outstanding. -
akgl_character_load_json_state_int_from_stringschecks the same argument twice.src/character.c:123guardsstatesandsrc/character.c:124guardsstatesagain under the message "NULL destination integer" — the guard fordestwas never written. ANULLdestis dereferenced atsrc/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.Fix: change the second guard's subject to
dest. Touchessrc/character.c:124. -
akgl_actor_rendercomputes a sprite's drawn height from its width.src/actor.c:276setsdest.h = curSprite->width * obj->scalewhere it should readcurSprite->height. Every actor is therefore drawn square, and a non-square sprite is stretched or squashed. Invisible in the current assets because they are square; it will not stay that way.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
-
akgl_text_rendertextatrefuses the empty string, andakgl_text_measureaccepts it. SDL_ttf returnsNULLwith "Text has zero width" from bothTTF_RenderText_BlendedandTTF_RenderText_Blended_Wrappedfor"", sosrc/text.c:56'sFAIL_ZERO_RETURNon the surface reportsAKERR_NULLPOINTERfor 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, aPRINTof 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. Touchesakgl_text_rendertextatonly.tests/text.chas the case written and deliberately not asserted; it would become aTEST_EXPECT_OK. Until then the header carries the wart as a@notepointing here.
Performance
The first measured baseline is in PERFORMANCE.md, produced by tests/perf.c
and tests/perf_render.c (ctest --test-dir build -L perf). Both suites hold
every measurement to a budget set at roughly ten times the recorded baseline, so
an algorithmic regression fails the suite rather than being discovered by a
player. Read PERFORMANCE.md before arguing with anything below — every claim
here has a number behind it, and several of the things I expected to be slow are
not.
Defects the perf suites found
Ordered by blast radius. Numbering continues the Defects list above.
-
akgl_path_relativeleaked an error context on every root-fallback resolution.src/util.c:118took itsENOENTbranch byreturning from inside theHANDLEblock, which skips theRELEASE_ERRORthatFINISHends with. One entry ofAKERR_ARRAY_ERRORwas lost per call, and the 129th call hit "Unable to pull an error context from the array!" and exited the process. Every tilemap load resolves several paths this way, so a game that loaded fifty levels died in the loader.Fixed. The branch now records a flag and calls
akgl_path_relative_rootafterFINISH.tests/util.ccarriestest_akgl_path_relative_releases_contexts, which resolvesAKERR_MAX_ARRAY_ERROR * 2paths through that branch and asserts the pool is where it started; against the old code that test does not fail, it terminates the suite.This is the only
returnfrom inside aHANDLEblock insrc/— the other candidates useSUCCEED_RETURN, which releases correctly. Worth a grep before anyone writes a new one, and worth a line in AGENTS.md's error-handling protocol, which warns about*_RETURNinsideATTEMPTbut not about returning out ofHANDLE. -
akgl_tilemap_loadleaks five pooled strings per load. Measured by counting non-zeroHEAP_STRINGrefcounts across load/release cycles: five per cycle, exactly, andakgl_tilemap_releasegives none of them back. The 52nd map load in a process finds the pool empty.Blast radius: every level transition. A game with fifty levels, or one that reloads a level on death, hits it. The fix is a sweep of the loader's
CLEANUPblocks —akgl_tilemap_load,akgl_tilemap_load_layers,akgl_tilemap_load_layer_tile,akgl_tilemap_load_layer_objects, andakgl_tilemap_load_tilesets_eachall claim scratch strings — and it wants a test that asserts the pool is unchanged across a load/release cycle. That is its own commit, not a footnote to a benchmark.Until it is fixed, the tilemap-load benchmark in
tests/perf_render.creclaims the pool by hand between iterations, and says so. -
Two JSON accessors turn string-pool exhaustion into a segfault.
akgl_get_json_string_valueends itsATTEMPTwithFINISH(errctx, false)atsrc/json_helpers.c:89, so a failedakgl_heap_next_stringis swallowed rather than passed up, andsrc/json_helpers.c:91thenstrncpysAKGL_MAX_STRING_LENGTHbytes through the pointer it never set.akgl_get_json_array_index_stringhas the same pair atsrc/json_helpers.c:143andsrc/json_helpers.c:145. Item 29 is what makes this reachable, and this is what item 29 looks like from the outside: notAKGL_ERR_HEAP, a crash insidestrncpywith a NULL destination.Fix:
FINISH(errctx, true)in both. One word each, but it changes what callers see on a path they currently cannot survive, so it wants a test that exhausts the pool deliberately and assertsAKGL_ERR_HEAPcomes back out of both functions. -
akgl_game_updatesegfaults ifakgl_game_initdid not run. Fixed in 0.5.0:akgl_game_update_fpsinstallsakgl_game_lowfpswhen it finds the hookNULL.Worth keeping the reasoning.
game.fpsis 0 for the first second of the process, which is under the threshold, so the unguarded call fired on frame one. Onlyakgl_game_initinstalled the default -- andinclude/akgl/renderer.hdocuments the other path on purpose: a host that owns its own window callsakgl_render_2d_bindinstead ofakgl_render_2d_init. An embedder following that documentation crashed on its first frame, andakbasicis exactly that embedder.tests/game.cclears the hook and calls the function, which is precisely that state.tests/perf_render.cinstalled the default by hand as a workaround and no longer has to. -
akgl_game_updateruns the actor update sweep once per tilemap layer.src/game.c:617loopsioverAKGL_TILEMAP_MAX_LAYERSand the actor sweep nested inside it never comparesactor->layertoi, so every live actor'supdatefuncruns 16 times per frame. Measured: 68.4 ns per update, 64 actors, so 70 µs of work to do 4.4 µs of work.It hides behind the rasterizer today (a 640x480 software frame is 16 ms) and it will not hide behind a GPU backend, where a frame is nearer 2 ms and this is 3.5% of it. Fix: either filter by layer like
akgl_render_2d_draw_worlddoes, or hoist the update sweep out of the layer loop entirely — updating an actor is not a per-layer operation. The second is almost certainly right, and it is the one that needs a test assertingupdatefuncruns exactly once per actor perakgl_game_update. -
akgl_heap_release_characterleaks itsstate_spritesproperty set. Fixed in 0.5.0; see item 21, which is the same defect. The character-load benchmark intests/perf_render.cwas written around it and no longer has to be.
Targets
What a library like this should hit. These are not predictions of what the current code does — several are missed today, and each says which. The frame budget throughout is 16.67 ms (60 fps); where a target is stated per-operation it is because that is the number that survives a change of renderer.
The one that governs the rest: libakgl's own bookkeeping should never be the reason a frame is late. Everything the library decides — pool scans, registry lookups, state-to-sprite mapping, physics, visibility, error contexts — should fit in 5% of a frame, leaving 95% for the pixels and the game's own logic. At 64 actors and a screenful of tiles that is a ceiling of about 800 µs.
| # | Target | Today | Verdict |
|---|---|---|---|
| 1 | Library bookkeeping under 5% of a 60 fps frame at 64 actors + 1200 tiles | ~0.3% (excluding pixel work) | met |
| 2 | One actor's logic update under 200 ns | 68.4 ns | met |
| 3 | One actor's render bookkeeping, excluding the blit, under 500 ns | ~250 ns, by subtraction rather than direct measurement | met, weakly measured |
| 4 | Physics sweep under 25 ns per live actor, and proportional to live actors rather than pool size | 19 ns per live actor, but 63.9 ns for an empty pool | partly |
| 5 | Tilemap draw bookkeeping under 100 ns per tile, excluding the blit | ~25 ns | met |
| 6 | Pool acquire under 100 ns regardless of how full the pool is | 3.9 ns empty, 250.9 ns on the last free string slot | missed |
| 7 | Pool release proportional to the bytes actually used, not to the slot's capacity | 47.2 ns, a fixed 4 KiB wipe | missed |
| 8 | Re-drawing an unchanged line of text under 1 µs | 12.6 µs, every frame, no cache | missed |
| 9 | Zero texture creation or destruction per frame in steady state | one create + one destroy per line of text per frame | missed |
| 10 | A handled, routine condition costs no more than twice the path that succeeds | 616.5 ns vs 68.4 ns — 9x | missed |
| 11 | 256 actors simulated, updated and made ready to draw in under 1 ms | ~22 µs at 64 actors (5.8 µs measured logic + estimated render bookkeeping); linear, so ~90 µs extrapolated | met, untested at that size |
| 12 | Collision for 256 actors under 2 ms without the caller writing a broad phase | no broad phase exists; the naive loop is 1.9 ms at 256 actors | missed |
| 13 | Level load under 100 ms for a map with 8 tilesets, 4 layers and 64 actors | 11.9 ms for a 2x2 map with one tileset | unknown at that size |
| 14 | Fixed per-load overhead under 1% of a level load | 11.5% — zeroing 26 MB of akgl_Tilemap |
missed |
| 15 | Static footprint under 4 MB in the default configuration | 28 MB, 94% of it one tilemap | missed |
| 16 | No pool leaks across a load/release cycle of any asset type | tilemap leaks 5 strings per cycle (item 29) | missed |
| 17 | Pool exhaustion reports AKGL_ERR_HEAP and never crashes |
item 30 crashes | missed |
| 18 | Every benchmark held to 10x its recorded baseline, enforced in CI | done, ctest -L perf |
met |
Notes on the ones worth arguing about:
-
6 and 7 are the same fix. The acquire scan is 64x slower on a full string pool than an empty one purely because the pool is a megabyte and the scan touches one refcount per 4 KiB. A free-list index — one
intper layer, remembering where the last free slot was — takes both to constant time without changing the "nomalloc" rule at all. That is the change I would make first, and it is worth doing before anyone raisesAKGL_MAX_HEAP_*, because the cost of the current design grows with the ceiling rather than with the usage. -
8 and 9 are one cache. A single entry keyed on (font, string, colour, wrap) would cover the common case — a HUD field that changes once a second — and a four- or eight-entry ring would cover the rest. This is the clearest optimisation in the library and it is maybe forty lines.
-
10 is a design target, not a speed target. The answer is not a faster error context. It is that "this character has no sprite for this state" is a question with a boolean answer, and reporting it through
AKERR_KEYcosts nine times the update it replaces.akgl_character_sprite_getwants a companion that returnsNULLwithout raising. -
12 is a scope decision, not a defect. The library deliberately does not own a broad phase, and at 64 actors the naive all-pairs loop is 0.7% of a frame — genuinely fine. At 256 it is 11%. Either the ceiling stays where it is and this target is dropped, or a uniform grid keyed on tile size goes in. I do not think a spatial index belongs here yet; I think the target belongs on record so that raising
AKGL_MAX_HEAP_ACTORis a decision made with the number in front of it. -
14 and 15 are the same fix too.
akgl_Tilemapis 26 MB because every layer carries a 512x512intgrid and every tileset a 65,536-entry offset table, sized for the worst case at compile time. The pool rule does not require this: a layer could carry an index into one shared cell arena sized byAKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHTonce rather than sixteen times, and the offset table could be sized bytilecountrather than by the maximum. That is a real refactor with a real ABI break, so it belongs to 0.4 rather than to a patch release — but 28 MB of BSS on a handheld or an ESP32-class target is the difference between fitting and not. -
13 is untested and should not stay that way. The only map fixture in the tree is 2x2 with one tileset, which is why the load benchmark measures a PNG decode and a
memsetrather than a map. A realistic fixture — 128x128, four layers, several tilesets — would make target 13 measurable and would probably find something.
Memory checking
cmake --build build --target memcheck runs the suites that already exist
under valgrind — ctest -T memcheck with the headless drivers forced, wrapped by
scripts/memcheck.sh so that a finding is an exit status rather than a line in a
log nobody reads. There are no memory-check test programs, and there should never
be any: tests/benchutil.h notices that it is running under valgrind and divides
every benchmark's iteration count by two thousand, which turns the perf suites
into the broadest path coverage in the tree at a cost valgrind can survive. The
whole run is about thirty seconds.
The two halves fit together on purpose. A benchmark is a program that walks one path a hundred thousand times; a leak check wants every path walked once. Same binaries, same registration, one flag apart.
Third-party findings are suppressed in scripts/valgrind.supp, and the run
forces SDL_VIDEO_DRIVER=dummy / SDL_RENDER_DRIVER=software /
SDL_AUDIO_DRIVER=dummy so the vendor GPU stack is never loaded — that removes
thousands of unfixable findings inside amdgpu_dri.so without suppressing
anything at all. Only definite losses and invalid accesses are counted; "still
reachable" is what SDL and FreeType keep for the process lifetime and says
nothing about this library.
Defects the memory checker found
All six are fixed. They are kept here rather than deleted because the sizes
are measured and the reasoning is worth having next time somebody asks why a
loader ends in a CLEANUP block, or why a name field is staged through a zeroed
buffer. cmake --build build --target memcheck is clean, and the CI job that
runs it gates: the next one of these fails the build on the push that
introduces it.
Ordered by blast radius as they were found. Numbering continues the lists above. Every size below is measured, not estimated.
-
Every JSON loader leaks its parsed document. There are four
json_load_filecalls insrc/and not onejson_decrefanywhere in the library, so the whole parsed tree — objects, hashtables, strings — is abandoned on both the success and failure paths:Loader Site Leaked per call akgl_sprite_load_jsonsrc/sprite.c:140~1,500 bytes akgl_character_load_jsonsrc/character.c:232~2,150 bytes akgl_tilemap_loadsrc/tilemap.c:693~9,000 bytes (2x2 fixture map) akgl_registry_load_propertiessrc/registry.c:134not exercised by any test Blast radius: every asset load, forever. The map figure is for the 2x2 fixture; a real map's JSON is the size of its layer data, so a 128x128 map leaks on the order of a megabyte per load. A game that reloads a level on death leaks a level's worth of JSON each time, and this is the one item in this list that grows without bound.
Fixed.
json_decrefin theCLEANUPblock of each, on the success path as well as the failure one, with the handle nulled after so a second pass cannot double-release.akgl_registry_load_propertiesneeded its loop moved inside theATTEMPTblock first:propsis a borrowed reference into the document and was read after the block ended, so every exit from that loop leaked the document. The test is the memcheck run -- nothing in the public API can observe a jansson refcount, and inventing a hook to prove it would be testing the test. -
akgl_get_propertyreads up to 4 KiB past the end of the property value.src/registry.c:181copies a fixedAKGL_MAX_STRING_LENGTHbytes out of whateverSDL_GetStringPropertyreturns, and what it returns is anSDL_strdupof the value — four bytes for"0.0". Valgrind reports an invalid read on every call, twelve of them intests/physics.calone, becauseakgl_physics_init_arcadereads six properties andakgl_render_init2dreads two more.This has been in
registry.has a@noteabout the copy being "a fixed #AKGL_MAX_STRING_LENGTH bytes rather than the length" — filed as waste. It is not waste, it is an out-of-bounds read: today it walks into the rest of SDL's heap and returns garbage past the terminator, and on a value that lands at the end of a page it is a segfault in a getter.Fixed. The copy is bounded by the value's own length plus its terminator, and a value too long for an akgl_String is now refused with AKERR_OUTOFBOUNDS instead of silently truncated. The documented AKERR_NULLPOINTER for "unset with no default" is unchanged -- it is raised deliberately now rather than arriving from inside
aksl_memcpy.tests/registry.cfills the destination with a sentinel, reads a three-byte property back, and asserts every byte past the terminator still holds the sentinel; that assertion fails against the old code. -
The savegame name tables read past the end of every registry key, and write what they find into the file.
akgl_game_save_actorname_iterator(src/game.c:219) writesAKGL_ACTOR_MAX_NAME_LENGTH— 128 — bytes starting at the key SDL handed it, and SDL allocated that key to fit the name. Valgrind catches it on a 40-byte allocation. The three sibling iterators do the same thing atsrc/game.c:248,src/game.c:280andsrc/game.c:308, with 128, 512 and 128 byte fixed widths; only the actor one is reached by the current tests, because the other registries are empty in the save roundtrip.Two consequences, and the second is the interesting one. The read can fault if the key sits at the end of a page. And whatever it reads goes into the save file, so a savegame contains up to 500 bytes of this process's heap per registered object — anything that happened to be next to the key. That is a file a player might send someone.
Fixed. All four iterators now write through
write_name_field, which stages the key into a zeroed fixed-width buffer, so the padding is deterministic and nothing but the name leaves the process. A negative-array-size typedef fails the build if any of the four widths ever outgrows the staging buffer. Still open and unrelated to the overread: Defects -> Known and still open item 7, the same tables disagreeing about their widths between writer and reader. -
akgl_controller_list_keyboardsleaks the array SDL gives it.src/controller.c:188callsSDL_GetKeyboards, which allocates, and never callsSDL_freeon the result. Four bytes per call in the test environment — one keyboard id — but it is per call, and the function is shaped like something a game calls when a device is hotplugged.Fixed. One
SDL_free, in aCLEANUPblock so a failure inside the loop cannot take the array with it. Still worth asking the same question of every SDL enumeration insrc/controller.c:SDL_GetGamepadshas the same contract, and the dummy driver reports no gamepads, so no test reaches it. -
A font, once loaded, is never freed and cannot be.
akgl_text_loadfont(src/text.c:20) opens aTTF_Font, puts the pointer inAKGL_REGISTRY_FONT, and that is the last anyone can do about it: the header exposes no way to close a font, andSDL_Quitdestroying the property registry drops the last reference. 10,523 bytes per font — 736 of them SDL_ttf's, the rest FreeType's.This is bounded by how many fonts a game loads, so it is not the runaway that item 34 is. It was a gap in the API rather than only a leak: a game that switches fonts between scenes, or a tool like
charviewerthat reloads one while the user picks a size, had no way to give the old one back.Fixed, and this one is a new public symbol rather than a repair:
akgl_text_unloadfont(char *name)clears the registry entry and closes the font.akgl_text_loadfontcalls it when it replaces a live name, which closes the second leak the header used to document as intended behaviour -- but only after the new font has opened, so a failed reload leaves the caller with the font they already had. The version goes to 0.4.0 with it: an 0.3 consumer cannot be handed this library and told it is the same ABI.Still open: nothing closes the registry's remaining fonts at shutdown. A game that exits without unloading leaks them exactly once, which valgrind reports against the process rather than against a loop, and which
akgl_game_shutdownwould be the natural home for if it existed. -
Vendored
deps/semver's own unit test leaks 188 bytes across 16 blocks, from thecallocs intest_strcut_firstandtest_strcut_second(deps/semver/semver_unit.c:8and:21). Not libakgl's code and not libakgl's to fix; recorded so that nobody re-diagnoses it, and becausesemver_unitis registered as one of our CTest tests and so shows up in our memcheck run.Suppressed, which is what this list said the answer would be if it ever became noise, and gating the job made it noise. The two entries in
scripts/valgrind.suppname the functions rather than the file, so a rewrite of those cases stops being suppressed and comes back as a finding. They are the only entries there that hide a real leak in a program this build runs; the alternative was a fork of a vendored dependency.
Not defects, and why
tests/util.cfixtures were reading uninitialised stack floats. Three null-pointer tests declaredSDL_FRectandpointfixtures without initializing them and then made one real call with them at the end, which is sixteen "conditional jump depends on uninitialised value" findings for a test that is not about coordinates. The fixtures are zeroed now. The library was never at fault; a memory checker that reports noise gets ignored, which is the only reason this was worth touching.- "Still reachable" at exit is not counted. SDL's global state, the hint
table, the property registry and FreeType's library instance are one per
process and are reclaimed by
SDL_Quit/TTF_Quit. Counting them would bury the six findings above under a hundred that mean nothing. - The GPU driver's findings are avoided rather than suppressed. Running the
suite against the real driver produces thousands of findings inside
amdgpu_dri.so; running it headless produces none, and headless is what the suites are written for anyway.
Found while embedding libakgl in a consumer
-
ShadowingFixed in the same release it was introduced in, and worth keeping because the CMake behaviour behind it is not obvious and will catch the next person.add_test()unconditionally broke every embedding consumer.18399f2moved the vendored-dependency block out from behindif(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)— which was the point of that commit — and took theadd_test()/set_tests_properties()override out with it. The override's own comment claimed "an embedding consumer'sadd_test()still reaches CTest". It does not, and cannot.CMake chains command overrides exactly one level deep. Overriding
add_testmakes the builtin available as_add_test; overriding it a second time rebinds_add_testto the first override and the builtin becomes unreachable to everybody, permanently — there is no__add_test. Verified directly rather than inferred from the documentation:function(add_test) _add_test(${ARGV}) endfunction() # _add_test -> builtin function(add_test) _add_test(${ARGV}) endfunction() # _add_test -> the first override if(COMMAND __add_test) ... endif() # absentSo two projects in one tree cannot both shadow it.
akbasicshadows it first — it has to, forlibakerrorandlibakstdlib, which register their tests unconditionally — and its own registrations then recursed until CMake stopped at "Maximum recursion depth of 1000 exceeded". A consumer had no way to work around it: once the builtin is gone it is gone for that project too.Fix: the override is guarded on being top-level again, exactly as it was before
18399f2and exactly aslibakstdlibguards its own. The vendored-dependency block stays unconditional, which is what that commit was actually for. An embeddedlibakglleavesadd_testalone and lets its consumer suppress what it does not want — machinery the consumer has to have anyway.
Build notes
The vendored SDL satellite libraries are built into per-project subdirectories
that are not on the loader's default search path, and LD_LIBRARY_PATH is
searched ahead of RPATH — so a developer who has previously run rebuild.sh had
their installed libakgl.so shadow the one under test, and a developer who had
not saw every test abort before main() with "cannot open shared object file".
CMakeLists.txt now sets BUILD_RPATH on the library, the utility, and every
test target, and prepends the build tree to LD_LIBRARY_PATH for the CTest run.
API gaps blocking akbasic
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.
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. 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 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.
-
No way to measure rendered text.
include/akgl/text.hexposesakgl_text_loadfont()andakgl_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 advance width of one cell, and wrapping needs to know where a string crosses the right margin. The reference interpreter got this from SDL2_ttf'sfont.SizeUTF8("A")and derived its whole character grid from it.Wants
akerr_ErrorContext AKERR_NOIGNORE *akgl_text_measure(TTF_Font *font, char *text, int *w, int *h);overTTF_GetStringSize, and probably a companionakgl_text_measure_wrapped(font, text, wraplength, w, h)matching thewraplengthargumentakgl_text_rendertextatalready takes. Tests: a known string in a known font at a known size, the empty string, and a wrapped string wide enough to force two lines.This is the only one of the four that blocks work already designed and waiting.
akbasiccannot render any output throughlibakgluntil it lands.Resolved.
akgl_text_measure(font, text, w, h)andakgl_text_measure_wrapped(font, text, wraplength, w, h)are ininclude/akgl/text.h, overTTF_GetStringSizeandTTF_GetStringSizeWrapped. Neither needs a renderer. A negativewraplengthis refused withAKERR_OUTOFBOUNDSrather than passed through, because SDL_ttf reads it as a very large unsigned width and silently stops wrapping.tests/text.ccovers both againsttests/assets/akgl_test_mono.ttf, a 10 KB monospaced ASCII subset added for the purpose — being monospaced, it lets the suite assertwidth("AAAA") == 4 * width("A")instead of hardcoding glyph metrics that FreeType is free to round differently. Same change fixed item 39 below:akgl_text_loadfontcheckednametwice and never checkedfilepath. -
No immediate-mode drawing.
include/akgl/draw.hdeclares exactly one function,akgl_draw_background(int w, int h), andsrc/draw.cis at 0% coverage. BASIC 7.0's graphics verbs are all immediate-mode plotting against the current screen:DRAW(line and point),BOX,CIRCLE,PAINT(flood fill),LOCATE(set the pixel cursor),COLOR, andSSHAPE/GSHAPE(save and restore a rectangle of pixels).Wants an
akgl_draw_*family taking the renderer the host already initialized --akgl_draw_line,_rect,_filled_rect,_circle,_point,_flood_fill,_copy_region-- in the shape of the existingakgl_render_2d_draw_texture. SDL3'sSDL_RenderLine/SDL_RenderRect/SDL_RenderFillRectcover most of it; the circle and the flood fill do not exist in SDL3 and need writing. Tests belong with the offscreen renderer harness described under "Remaining work": render a known shape, read the target back, and compare against a reference surface with the existingakgl_compare_sdl_surfaces.Resolved.
include/akgl/draw.hnow declaresakgl_draw_point,_line,_rect,_filled_rect,_circle,_flood_fill,_copy_regionand_paste_region, all taking theakgl_RenderBackend *the host initialized, in the shape ofakgl_render_2d_draw_texture. Decisions worth knowing:- Color is an argument, not state. There is no current-color global to
get out of step with the caller's own. Each call saves and restores the
renderer's draw color, so drawing a line does not change what the host's
next
SDL_RenderClearpaints.tests/draw.casserts that. - The circle is a midpoint circle, integer arithmetic with eight-way
symmetry, plotted eight points per step through
SDL_RenderPoints. - The flood fill reads the target back, fills on the CPU, and blits only
the bounding box of what changed. It keeps a fixed
AKGL_DRAW_MAX_FLOOD_SPANS(4096) stack of horizontal runs at file scope rather than recursing per pixel; running out reportsAKERR_OUTOFBOUNDSand leaves the region partially filled, which is stated in the header. It is therefore not reentrant — neither is anything else that draws to a singleSDL_Renderer. _copy_regionallocates when*destisNULLand otherwise copies into the caller's surface, matchingakgl_get_json_string_valueand friends. A region that would be clipped by the target edge is refused rather than silently returning a smaller surface.
tests/draw.cdraws into a 64x64 software renderer under the dummy video driver and reads pixels back, so it did not need the offscreen harness. That harness is still wanted forsrc/renderer.c,src/text.candsrc/assets.c.akgl_draw_backgroundis untouched and still outside the error protocol (item 35). - Color is an argument, not state. There is no current-color global to
get out of step with the caller's own. Each call saves and restores the
renderer's draw color, so drawing a line does not change what the host's
next
-
No audio API at all.
SDL3_mixeris a vendored dependency andregistry.hdeclaresAKGL_REGISTRY_MUSIC, but there is nosrc/audio.c, noinclude/akgl/audio.h, and noakgl_*symbol that opens a mixer, loads a chunk, or plays a note. BASIC 7.0's sound verbs areSOUND(a tone on a voice, with a duration),PLAY(a string of notes in a Commodore-specific notation),ENVELOPE(ADSR per voice),FILTER,VOLandTEMPO.PLAYandENVELOPEwant a synthesised voice rather than a sample, which SDL3_mixer does not provide directly -- the honest first step is a small tone generator feedingSDL_AudioStream, withakgl_audio_init,akgl_audio_tone(voice, hz, ms),akgl_audio_envelope(voice, a, d, s, r)andakgl_audio_volume(level). This is the largest of the four and the one most worth designing before writing. Tests can run under the dummy audio driver and assert state transitions rather than sound.Resolved.
include/akgl/audio.handsrc/audio.cadd a three-voice tone generator overSDL_AudioStream:akgl_audio_init,_shutdown,_tone,_stop,_waveform,_envelope,_volume,_voice_activeand_mix. It is deliberately separate from the SDL3_mixer side of the library, which plays audio assets; nothing inaudio.creads a file. Decisions worth knowing:- The voice table works with no device open.
akgl_audio_initconnects it to one; without that a host can still pull samples itself throughakgl_audio_mix. That is not only for embedding — it is what makes the suite deterministic. A device pulls samples on SDL's audio thread whenever it likes, so a test that opened one and then asserted on voice state would be racing the callback.tests/audio.cmixes by hand and opens a device only in its last test. - Phase is derived from the frame counter, not accumulated. A float
increment of
hz / 44100is not exact, and adding it 44100 times a second walks a held note off pitch. - A voice that was never configured is audible. A zeroed voice has a sustain of 0.0, which is silence with no error to explain it, so the table defaults to a square wave held at full level.
- Three voices summing past full scale are clamped, not scaled, so one voice plays at the level it was asked for rather than a third of it.
- Everything that touches the table takes the stream lock when a device is open, since the callback reads it on another thread.
Still missing for a complete BASIC sound vocabulary:
FILTER(SDL3 has no filter primitive; this would need writing),TEMPOand thePLAYnote-string parser, both of which belong in the interpreter rather than here. - The voice table works with no device open.
-
No non-blocking keystroke read.
include/akgl/controller.his built around SDL event handlers the host pumps (akgl_controller_handle_eventand friends), which suits a game loop and does not suitGETandGETKEY-- those ask "is there a keystroke waiting, yes or no" and must not require the interpreter to own the event loop. Goal 3 ofakbasicforbids it owning one.Wants
akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_poll_key(int *keycode, bool *available);reading a small ring buffer thatakgl_controller_handle_eventalready fills, so the host keeps pumping events and the interpreter drains characters at its own pace. Tests: push syntheticSDL_EVENT_KEY_DOWNevents through the existing handler and drain them, plus the empty-buffer and overflow cases.Resolved.
akgl_controller_poll_key(int *keycode, bool *available)andakgl_controller_flush_keys(void)are ininclude/akgl/controller.h, over a fixedAKGL_CONTROLLER_KEY_BUFFER(32) ring insrc/controller.c.akgl_controller_handle_eventrecords everySDL_EVENT_KEY_DOWNbefore it scans the control maps, so a key bound to an actor still reaches a polling caller — the scan returns as soon as a binding claims the event, and doing it afterwards would have lost exactly the keys a game also acts on. An empty buffer is success withavailablefalse, not an error. A full buffer drops the newest key rather than overwriting the oldest, matching the Commodore keyboard buffer and keeping what was typed first.tests/controller.ccovers drain order, the release-is-not-a-keystroke case, a key shared with a control map, flush, both NULL arguments, and overflow plus reuse afterwards. -
An embedded
libakgldemands its dependencies be installed, while vendoring them.CMakeLists.txt:17gates the whole vendored-dependency block onCMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR, sodeps/SDL,deps/SDL_image,deps/SDL_mixer,deps/SDL_ttfanddeps/janssonare added only when this repository is the top-level project. Embedded withadd_subdirectory(), theelse()branch at:51runsfind_package(SDL3 REQUIRED)and friends instead, and configure fails on a machine that has none of them installed — with the submodules sitting right there indeps/, already checked out by the recursive clone the consumer just did.akbasicworks around it by adding those five subdirectories itself, immediately beforeadd_subdirectory(deps/libakgl). That works only because every lookup in theelse()branch is guarded withif(NOT TARGET ...)— which is the same escape hatchakerror::akerrorandakstdlib::akstdlibalready rely on, and it should not be the documented answer. Fix: add the vendored dependencies on both paths, still guarded byif(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. Anakgl_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; thefind_packagelookups for anything left over run afterwards, unchanged, so a checkout without submodules still resolves against the system. A macro rather than a function becauseadd_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, thoughdeps/SDL_mixerasks 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,_akstdliband_janssonall set, so any reliance on an installed copy would have failed the configure. It configures, builds and links. The fiveadd_subdirectorylines inakbasic'sCMakeLists.txtcan be deleted. -
include/akgl/controller.hdoes not compile on its own. Lines 35, 36 and 41 declare handler function pointers taking anakgl_Actor *, and the header includes onlySDL3/SDL.h,akerror.handtypes.h— none of which declares that type. Any translation unit that includesakgl/controller.hbeforeakgl/actor.hfails with "unknown type name 'akgl_Actor'".src/controller.cnever notices because it includesakgl/game.hfirst.This is the house rule in
AGENTS.md— keep headers self-contained, include what you use. Fix:#include "actor.h"incontroller.h, or forward-declarestruct akgl_Actorif the include order makes that circular. A one-line test that includes onlyakgl/controller.hwould have caught it and would keep catching it.Resolved.
controller.hincludes<akgl/actor.h>. Not a forward declaration:akgl_Actoris a typedef of a named struct, and repeating a typedef is C11, not C99. The include closes no cycle —actor.hreaches onlytypes.handcharacter.h, neither of which knows about the controller.tests/headers.cis the test, and it is a whole suite for one#includeon purpose: a second#includeafter 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 thattests/controller.chas to includeakgl/actor.hfirst, and the comment there saying so is gone. -
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 anSDL_Rendererfrom thegame.screenwidth/game.screenheightproperties and writes to thecameraglobal, and it installs the six function pointers that make anakgl_RenderBackendusable. A caller who already owns anSDL_Rendererwants only the second half — and that caller is not hypothetical, it is precisely the embedding host the API-gap section above exists to serve, since an embedded interpreter must not create the window.Today the only options are to call
akgl_game_init()and let libakgl own the window, or to assign the six pointers by hand, which is whatakbasic'stests/akgl_backends.cdoes. Fix: split outakgl_render_bind2d(akgl_RenderBackend *self)that installs the vtable and nothing else, and haveakgl_render_init2d()call it after it has made its window.tests/renderer.ccould 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 touchself->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 reportAKERR_NULLPOINTERinstead of crashing.tests/renderer.cis new and needs no display: it covers the binding (including that the caller'sSDL_Renderersurvives it, and that binding a backend without one is legal), frame start and end and bothdraw_texturepaths against a software renderer under the dummy video driver, the refusals a bound-but-unrendered backend gives, and thedraw_mesh"not implemented" stub.src/renderer.cgoes from 10% line coverage to 56%; what is left isakgl_render_init2ditself andakgl_render_2d_draw_world, both of which want the offscreen harness and the world globals.akbasic's hand-assigned vtable intests/akgl_backends.ccan be deleted. -
akgl_text_rendertextat()dereferences an uninitialised backend vtable.src/text.ccallsrenderer->draw_texture(renderer, ...)with no NULL check on eitherrendereror the function pointer, so a backend that has anSDL_Rendererbut has not been throughakgl_render_init2d()segfaults on the first line of text. That is exactly the state item 7 leaves a host in, and it is the same class of defect the draw commit at42b60f7added a test for — "a backend that exists but was never given an SDL_Renderer, which is the state a host is in between allocating one and initializing it; every draw entry point has to report it rather than dereference it." The text path still has it.Fix:
FAIL_ZERO_RETURNonrenderer, onrenderer->sdl_rendererand onrenderer->draw_texture, reportingAKGL_ERR_SDLorAKERR_NULLPOINTERas the draw entry points do. Test alongside the existingtests/draw.cbackend-without-a-renderer case.Resolved. All three are checked, with
AKERR_NULLPOINTERto 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@noteon this function already describes.tests/text.cgrew a software renderer under the dummy video driver — bound with the newakgl_render_bind2d, which is what made this cheap — and covers all three refusals plus the successful draw, wrapped and unwrapped.src/text.cgoes from 58% to 100% line coverage, and the threeakgl_text_rendertextatmutants the mutation run left surviving are dead. The third case is the one that matters: with a liveSDL_Rendererbehind 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.
-
SOUND's frequency sweep has noakgl_audio_*equivalent. BASIC 7.0'sSOUND voice, freq, dur, dir, min, stepramps the pitch fromfreqtowardmininstepincrements per tick, in the directiondirselects — a siren, a laser, the whole reasonSOUNDhas six arguments.akgl_audio_tone(voice, hz, ms)holds one pitch for one duration, and nothing changes pitch over the life of a note.akbasicrefuses those three arguments rather than faking them, and the reasoning is worth recording because it is what makes this alibakglgap rather than an interpreter one: the only way to fake a sweep from the interpreter is to re-issue tones from its step loop, which ties audible pitch to how often the host happens to call it — a tune that changes key with the frame rate. It has to be advanced on the mixer's own frame counter, which is where the phase is already derived from and which only this library can see.Fix:
akgl_audio_sweep(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms), advanced inakgl_audio_mix()beside the envelope. Tests would driveakgl_audio_mixby hand and assert the frame at which the pitch has moved, exactly astests/audio.calready does for the envelope stages.Resolved.
akgl_audio_sweep(voice, from_hz, to_hz, step_hz, ms)is ininclude/akgl/audio.h, with the step taken inakgl_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_hzstays positive;to_hzbelowfrom_hzsweeps down. A negative step is refused rather than silently meaning something. - It arrives and holds. The last step is clamped to
to_hzrather 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_toneis 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.cdrives 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.cholds at 92%.Still missing for a complete
SOUND: the oscillating third direction (C128dir2), which is a re-issue on arrival rather than a third kind of sweep, andFILTER,TEMPOand thePLAYnote-string parser as before. - One step every 1/60 second (
-
The keystroke ring carries a keycode and nothing else, so Shift is invisible.
akgl_controller_handle_event()(src/controller.c:104-105) pushesevent->key.keyinto the ring and drops the rest of the event, andakgl_controller_poll_key(int *keycode, bool *available)hands back only that. Item 4 asked for a non-blocking keystroke read and got one; what it did not ask for, and what a line editor turns out to need, is which character the keystroke actually produced.akbasichas now built one — anINPUTand a REPL prompt drawn in the window, insrc/sink_akgl.c— and the consequence is concrete: no shifted character is reachable, so",!,(,),:and;cannot be typed at all, and lower case cannot be typed either. A BASIC line editor that cannot type a double quote cannot enter a string literal. The interpreter folds letters to upper case, which is what a C128 does anyway and is the right resolution for letters; it is not a resolution for punctuation, and there is nothing the caller can do about it because the modifier state was discarded two layers down.Note this is not solvable by the caller polling
SDL_GetModState()at read time: by then the key is long released, and the whole point of a ring is that reads are decoupled from events.Fix: widen the ring entry to carry the modifier state and the composed text SDL already computes. Either
typedef struct akgl_Keystroke { SDL_Keycode key; SDL_Keymod mod; char text[8]; /* UTF-8, from SDL_EVENT_TEXT_INPUT; "" for a non-printing key */ } akgl_Keystroke; akerr_ErrorContext AKERR_NOIGNORE *akgl_controller_poll_keystroke(akgl_Keystroke *dest, bool *available);alongside the existing
akgl_controller_poll_key(), which stays as it is so nothing breaks — a game asking "was the up arrow pressed" wants exactly what it already gets. Fillingtextmeans handlingSDL_EVENT_TEXT_INPUTas well asSDL_EVENT_KEY_DOWN, which is the only correct way to get a character out of SDL anyway: it is what makes a keyboard layout, a compose key and a dead key work, none of which a keycode can express.Tests would push a shifted key-down plus its text-input event through
akgl_controller_handle_event()and assert both the keycode and the composed character come back, mirroring whattests/controller.calready does for the plain ring.Resolved.
akgl_Keystroke(keycode,SDL_Keymod, and eight bytes of UTF-8) andakgl_controller_poll_keystroke(akgl_Keystroke *dest, bool *available)are ininclude/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_INPUTwhile text input is started, so a host that wantstextpopulated callsSDL_StartTextInput()on its window. Without itkeyandmodstill arrive. That is documented on the function rather than left to be discovered.
tests/controller.ccovers 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.cholds at 91%.
Carried over
-
Make character-to-sprite state bindings release their references symmetrically.
akgl_character_sprite_add()increments the sprite refcount for every state-map binding, but no corresponding removal API exists. Replacing a state binding also leaves the previous sprite's refcount incremented. Whenakgl_heap_release_character()drops the character refcount to zero, it clears the character registry entry and zeroes the structure without enumeratingstate_sprites, decrementing the bound sprites, or destroying the SDL property map. Implement binding removal/replacement so each removed binding releases exactly one sprite reference. On final character release, enumerate every remaining binding (the existingakgl_character_state_sprites_iterate()release path may be reusable), release each reference, destroystate_sprites, and then clear the character. Add tests for removal, replacement, duplicate sprite bindings across multiple states, and final character release.The final-release half is done in 0.5.0 — see Defects item 21.
akgl_heap_release_characternow enumerates every remaining binding, releases each reference, destroysstate_sprites, and then clears the character, andakgl_character_state_sprites_iterateis covered by that path.Replacement is done too, as Defects item 20:
akgl_character_sprite_addreleases the sprite it displaces, andtests/character.ccovers binding, rebinding, and 200 alternating rebinds.Still open: removal. There is no API to unbind one state without binding something else over it --
akgl_character_sprite_del-- and no test for duplicate sprite bindings across several states. Neither leaks anything today; they are a gap in the surface rather than a defect. -
An actor cannot be scaled per axis.
akgl_Actor::scaleis onefloat32_tapplied to bothdest.wanddest.h, so there is no way to express "twice as wide, the same height". The VIC-II has had separate x- and y-expand bits since 1982 and Commodore BASIC 7.0'sSPRITEverb exposes both, so a BASIC interpreter drawing through libakgl cannot implement its own documented verb.Two shapes are plausible and the choice is a design decision rather than an obvious fix: add
scale_x/scale_yand keepscaleas a convenience that writes both, or replacescaleoutright and take the ABI break while the major version is still 0. The second is cleaner and the soname already carriesMAJOR.MINOR.Related to defect 26, and reached the same way:
akbasic's sprites are Commodore sprites, 24 wide and 21 high, andSPRITE n,,,,1expands one axis. It works around both by installing its ownrenderfuncon each actor rather than patching around the library.