# Repository Guidelines ## Project Structure & Module Organization This is a C library intended to support the development of video games. Public C headers live in `include/akgl/`; keep declarations there aligned with their implementations in `src/`. Tests are standalone C programs under `tests/`, with JSON, image, and map fixtures in `tests/assets/`. The `util/` directory contains the `charviewer` utility and its sample assets. Third-party and companion libraries are vendored in `deps/`. Treat `build/` and generated `akgl.pc` files as build outputs rather than hand-maintained source; `include/akgl/SDL_GameControllerDB.h` is generated too, but is tracked on purpose — see below. ## Generated and Vendored Sources ### `include/akgl/SDL_GameControllerDB.h` is generated, and is tracked deliberately `mkcontrollermappings.sh` regenerates this header by fetching the community controller database from `raw.githubusercontent.com`. **It is committed to the repository on purpose**: it is the offline fallback that keeps the library buildable if upstream is renamed, rate-limited, taken down, or simply unreachable from the build machine. Do not delete it, do not add it to `.gitignore`, and do not "clean up" the fact that a generated file is tracked. Working rules: - **Never hand-edit it.** It is machine-written; changes belong in `mkcontrollermappings.sh`. - **An ordinary build does not touch it.** Regeneration is `cmake --build build --target controllerdb`, and nothing else runs the script. Until 0.5.0 an `add_custom_command` re-ran it on every build, which made every build need network access and left the file modified with a fresh `$(date)` stamp and nothing of substance changed. - **Update it as a deliberate, standalone commit** when you actually want newer mappings, so the diff is reviewable and bisectable. - **A failed fetch cannot reach this file.** The script assembles the header in a temporary directory and moves it into place only after checking curl's exit status, a plausible minimum mapping count, and that no mapping contains a character that would break a C string literal. Any of those failing leaves the tracked copy untouched and exits non-zero. - Formatting tooling ignores it: `scripts/reindent.sh` and the pre-commit hook both skip it. > **Fixed in 0.5.0.** The safety net used to be self-defeating: > `mkcontrollermappings.sh` had no `set -e` and never checked `curl`, so a > failed fetch wrote `AKGL_SDL_GAMECONTROLLER_DB_LEN 0` and an empty array > **over the good tracked copy, and exited 0** — and because CMake re-ran the > generator on every build, an offline build silently destroyed the very > fallback the file exists to provide. Both halves are closed: the script > refuses rather than overwrites, and a build does not run it. ## Build, Test, and Development Commands Configure an out-of-tree development build: ```sh cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo ``` Build all library, utility, and test targets: ```sh cmake --build build --parallel ``` Run the complete test suite with failure details: ```sh ctest --test-dir build --output-on-failure ``` Run one test while iterating, for example `ctest --test-dir build -R sprite --output-on-failure`. The `rebuild.sh` script also installs into a developer-specific `/home/andrew/local` prefix and removes existing outputs; prefer the portable commands above unless that exact workflow is intended. Run only the performance suites with `ctest --test-dir build -L perf --output-on-failure`, or leave them out of an ordinary run with `-LE perf`. They take about 30 seconds together, print a table of nanoseconds per operation, and fail only when a measurement exceeds a budget set at roughly ten times the recorded baseline. `AKGL_BENCH_SCALE` scales every iteration count — `AKGL_BENCH_SCALE=0.1 ctest --test-dir build -L perf` for a quick look — and below 1.0 the budgets are reported but not enforced. The recorded baseline and what it means are in `PERFORMANCE.md`. Check for memory defects with `cmake --build build --target memcheck`, or `scripts/memcheck.sh` directly — it takes ctest's selection flags, so `scripts/memcheck.sh -R tilemap` and `scripts/memcheck.sh -LE perf` both work. It runs the suites that already exist under valgrind (`ctest -T memcheck`) with the headless drivers forced, and exits non-zero when valgrind finds a definite leak, an invalid access, or a read of uninitialised memory — which `ctest -T memcheck` on its own will not do. The whole run takes about thirty seconds because the perf suites scale themselves down under valgrind. Suppressions for third-party findings live in `scripts/valgrind.supp`; add one only when the finding genuinely cannot be fixed from this repository. Run mutation testing with `cmake --build build --target mutation`. For a quick smoke run, use `scripts/mutation_test.py --target src/tilemap.c --max-mutants 10`; the harness mutates only a scratch copy and runs every suite. Generate HTML and Cobertura coverage reports with `cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug`, then build and run CTest. Reports are written to `build-coverage/coverage/`; this mode requires `gcovr` and GCC or Clang. Both `gcovr` invocations take the build tree as an explicit positional search path. Given none, gcovr searches `--root` -- the source directory, where developers keep their build trees -- and `--object-directory` does not narrow that. A second instrumented tree left in the source directory therefore used to be folded into the report, and two trees describing different line numbers for the same function failed `coverage_reset` before any test ran. Leave the positional path alone. A different trap survives: rebuilding an existing coverage tree after editing a source leaves `.gcda` files describing the old object layout, and `coverage_reset` -- whose whole job is to delete them -- fails with `GCOV returncode was 5` first. `find build-coverage -name '*.gcda' -delete` clears it. ### What `ctest` runs Beyond the C suites, three registered tests are not C programs and are worth knowing about when one of them fails: | Test | What it checks | Script | |---|---|---| | `api_surface` | Every exported `akgl_*` symbol is declared in a header | `scripts/check_api_surface.sh` | | `error_protocol` | No `*_RETURN` inside `ATTEMPT`, no `return` out of `HANDLE` | `scripts/check_error_protocol.py` | | `headers` | Every public header compiles as the first include of a translation unit | generated from `AKGL_PUBLIC_HEADERS` | `api_surface` needs `nm` and the built library; it exits 2 and is reported as skipped when it cannot run, rather than passing quietly. ## Continuous integration `.gitea/workflows/ci.yaml` runs four jobs on every push, and each one exists because the others cannot do its work: | Job | Build | What it is for | |---|---|---| | `cmake_build` | Debug + `AKGL_COVERAGE=ON` | The unit suites and the coverage report. The perf suites run here too, at `AKGL_BENCH_SCALE=0.02`, for path coverage only — a timing taken under gcov is a measurement of gcov. | | `performance` | RelWithDebInfo | `ctest -L perf`. The only job where budgets are enforced, because `tests/benchutil.h` enforces them only when compiled optimized at full scale. Keeps the tables as an artifact. | | `memory_check` | RelWithDebInfo | `scripts/memcheck.sh`, every suite under valgrind. Gates: a definite leak, an invalid access or a branch on uninitialised memory fails the build. Keeps the valgrind logs as an artifact. | | `mutation_test` | Debug | One focused source file, so CI stays bounded. | Every suite runs in every job. The `character` suite used to be excluded from the coverage and memory-check jobs on the grounds that it failed deliberately. It did not: it was reporting success while running one of its four tests, for two independent reasons, and both are fixed. See TODO.md, "Test suites that could not fail". ## Coding Style The canonical style is Emacs `cc-mode` **`stroustrup`**, with tabs enabled. This is not advisory — new and edited code must match what `cc-mode` produces, so that reindenting a region never shows up as a diff. ### Emacs setup `.dir-locals.el` in the repository root already applies the style to every `c-mode` buffer, so nothing needs configuring by hand: ```elisp ((c-mode . ((c-file-style . "stroustrup") (indent-tabs-mode . t) (tab-width . 8) (fill-column . 100) (require-final-newline . t)))) ``` Interactively: `C-x h C-M-\` (`mark-whole-buffer` + `indent-region`) reindents the buffer. A correctly formatted file is a fixed point of that operation — reindenting must produce no change. ### Reindenting from the command line ```sh scripts/reindent.sh # reindent every tracked C source in place scripts/reindent.sh src/tilemap.c # reindent only the named files scripts/reindent.sh --check # list non-conforming files, change nothing ``` `--check` exits 1 when something needs reindenting and 2 if Emacs is missing, so it is usable from CI. The script drives Emacs in batch mode through `scripts/reindent.el`, which reindents, normalises leading whitespace to the canonical tab/space mix, strips trailing whitespace, and ensures a final newline. `include/akgl/SDL_GameControllerDB.h` is generated and always skipped. Note that `reindent.el` deliberately does **not** use Emacs' `tabify`: that function's `tabify-regexp` is `" [ \t]+"`, which is not anchored to the start of a line, so it rewrites runs of spaces anywhere and destroys the hand-aligned value columns in the bit-flag tables in `actor.h` and `iterator.h`. Only leading whitespace is ever rewritten. ### The pre-commit hook `scripts/hooks/pre-commit` reindents staged C sources before the commit is written. Enable it once per clone: ```sh git config core.hooksPath scripts/hooks ``` It inspects the *staged* content rather than the working tree, so what lands in the commit is what was checked. When a file needs reindenting it is fixed in the working tree and re-staged — but only if the index and working tree agree for that file. If they differ, re-staging would sweep unstaged work into the commit, so the hook stops and tells you to reindent and stage it yourself. It steps aside during a merge, and warns rather than blocking if Emacs is unavailable. `git commit --no-verify` bypasses it. For reference, `cc-mode` defines the style as: ```elisp ("stroustrup" (c-basic-offset . 4) (c-comment-only-line-offset . 0) (c-offsets-alist . ((statement-block-intro . +) (substatement-open . 0) (substatement-label . 0) (label . 0) (statement-cont . +)))) ``` ### Indentation and whitespace - **4 columns per level** (`c-basic-offset` 4). - **Tabs are 8 columns wide and are used for indentation** (`indent-tabs-mode` `t`, `tab-width` 8). Emacs emits the largest possible run of tabs and pads the remainder with spaces, which produces this ladder. Editors that expand tabs, or that assume a 4-column tab, will silently corrupt it: | Depth | Columns | Bytes | |---|---|---| | 1 | 4 | 4 spaces | | 2 | 8 | `TAB` | | 3 | 12 | `TAB` + 4 spaces | | 4 | 16 | `TAB` `TAB` | | 5 | 20 | `TAB` `TAB` + 4 spaces | - No trailing whitespace. Files end with a single newline. - `case` labels sit at the same column as their `switch` (`label . 0`). - Continuation lines of a wrapped expression indent one level (`statement-cont . +`). The whole tree conforms: `scripts/reindent.sh --check` is clean, and the pre-commit hook keeps it that way. Convert a file to the canonical style in its own commit, not mixed into a behavioral change. ### Braces - **Function bodies open on their own line, in column 0.** - **Control statements keep the brace on the same line**, one space before it. - **`else`, `else if`, and `while` of a `do`/`while` stay on the closing-brace line**: `} else {`, not a bare `else` on the next line. Note that this is a house convention rather than something `cc-mode` enforces — the `stroustrup` style governs indentation only and will not move a brace or an `else` for you. - **Always brace, even single-statement bodies.** ```c akerr_ErrorContext *akgl_actor_update(akgl_Actor *obj) { if ( obj->curSpriteFrameId == 0 ) { obj->curSpriteReversing = false; } else if ( obj->parent != NULL ) { obj->curSpriteFrameId -= 1; } else { obj->curSpriteFrameId += 1; } } ``` ### Spacing - **Spaces inside control-flow parentheses**: `if ( x == y ) {`, `while ( done == false ) {`, `for ( i = 0; i < len; i++ ) {`. This is the dominant existing convention (140 sites to 7) and `cc-mode` will not add or remove it. - No space between a function name and its argument list, at both call and definition sites, and no padding inside call parentheses: `SDL_Log("x %d", n)`. - Binary operators and assignment are surrounded by single spaces; unary operators are not separated from their operand. - **The pointer `*` binds to the identifier**, not the type: `char *name`, `akgl_Actor **dest`. Never `char* name`. - When a call is too long for one line, put each argument on its own line indented one level past the callee, with the closing `)` on its own line. This is the established shape for the `FAIL_*` and `CATCH` macros: ```c FAIL_ZERO_RETURN( errctx, SDL_SetPointerProperty(AKGL_REGISTRY_ACTOR, name, (void *)obj), AKERR_KEY, "Unable to add actor to registry" ); ``` ### Compiler warnings **`-Wall` is on for every target this project owns** -- the library, the test suites and `charviewer` -- and the tree builds clean under it at every optimization level. **`-Werror` is not on by default.** It is `option(AKGL_WERROR ... OFF)`, turned on only by the `cmake_build` and `performance` CI jobs. Two reasons, and both are measured rather than assumed: - libakgl is consumed with `add_subdirectory()`; `akbasic` does exactly that. A target-level `-Werror` makes every diagnostic a newer compiler invents into a broken build for somebody else's project. - The warning set is not stable across build types. An `-O2` build reports ten `-Wstringop-truncation` warnings that `-O0` and `-fsyntax-only` do not, because they need the middle end. The two CI jobs that set `AKGL_WERROR` are deliberately one of each: coverage at `-O0`, performance at `-O2`. If you are checking this by hand, **compile for real at the optimization level being shipped.** `-fsyntax-only` reported 6 of the 16 findings in `src/`. **`-Wextra` is deliberately not adopted.** It adds 22 findings, 17 of which are inherent to the design rather than defects: 13 `-Wunused-parameter`, because a backend vtable entry or an SDL callback must match a signature whether it reads every argument or not, and 4 `-Wimplicit-fallthrough` from libakerror's own `PROCESS`/`HANDLE`/`HANDLE_GROUP`, which fall through between `case` labels by design. Adopting it today would mean disabling both permanently to gain 5 `-Wsign-compare`. The fallthrough half is filed upstream as `deps/libakerror/TODO.md` item 8; revisit when that lands. **Vendored code is exempt, not fixed.** `deps/semver/semver.c` is listed directly in `add_library(akgl ...)`, so the target's `PRIVATE` options reach it; `set_source_files_properties(... COMPILE_OPTIONS "-w")` keeps a future `semver` update from failing this build. The other vendored projects are separate targets and are unaffected either way. **Never silence a warning with a cast.** Three `-Wpointer-sign` findings in `src/sprite.c` were real signedness mismatches -- `uint32_t *` passed where an `int *` was expected -- and a cast would have hidden every one. That is the whole argument of `TODO.md` item 37. Read into a correctly typed local, check the range, and assign. ### No repository-wide formatter There is no `clang-format` or linter wired into the build, and `cc-mode`'s indentation engine is not exactly reproducible by `clang-format`. Do not introduce one without discussion, and do not reformat code you are not otherwise changing — unrelated whitespace churn makes review harder and is the reason the style drifted in the first place. ## Naming Conventions - **Public functions**: `akgl__`, all lower snake_case — `akgl_actor_set_character`, `akgl_heap_next_string`. No camelCase, and never embed a type name (`akgl_Actor_cmhf_left_on` is wrong; it should be `akgl_actor_cmhf_left_on`). - **Public types**: `akgl_TypeName` — `akgl_Actor`, `akgl_SpriteSheet`, `akgl_PhysicsBackend`. Every type exported from a header takes the prefix. `point` and `RectanglePoints` shipped bare until 0.5.0; they are `akgl_Point` and `akgl_RectanglePoints` now, and neither is precedent. - **Constants and macros**: `AKGL_UPPER_SNAKE_CASE`. A constant belongs to the subsystem it describes: a character limit is `AKGL_CHARACTER_MAX_*`, not `AKGL_SPRITE_MAX_CHARACTER_*`. Name a constant for what its value *is* — a nanoseconds-per-millisecond scale factor is `AKGL_TIME_ONEMS_NS`. - **Exported globals** take the `akgl_` prefix like any other public symbol. Do not add bare names (`renderer`, `camera`, `window`), leading-underscore names (reserved at file scope), or SCREAMING_SNAKE names for mutable objects — `AKGL_UPPER_SNAKE_CASE` is for constants. This one is not cosmetic and there is a scar to prove it. Until 0.5.0 the library exported `renderer`, and `tests/character.c` defined a `SDL_Renderer *renderer` of its own. Both had external linkage and the same spelling, so the executable's definition preempted the shared library's: the library read a `SDL_Renderer *` through an `akgl_RenderBackend *`, every texture load in that suite failed, and the suite reported success anyway. A consuming game with a variable called `renderer` would have hit the same thing with no test to notice. - **`static` helpers drop the `akgl_` prefix**, which exists only to avoid external collisions. - **Include guards**: `_AKGL__H_`, matching the file name. Guard names without the project prefix risk colliding with system headers. - **Parameter names must match between the declaration and the definition** — Doxygen publishes the header spelling. Conventional names: `dest` for an output parameter (not `dst`), `self` for a backend receiver, `obj` for the instance being initialized or inspected, `e` for an incoming error context to be inspected. - **The local error context is named `errctx`** (92 sites to 45). New code uses `errctx`; convert `e` when you touch a function for other reasons. - **Header/source pairs are named by feature**: `include/akgl/sprite.h` and `src/sprite.c`. ## Error-Handling Protocol The `akerror` control-flow macros have a shape that must be followed exactly, because the failure mode is silent. - Inside an `ATTEMPT` block use the **`_BREAK`** variants (`FAIL_ZERO_BREAK`, `FAIL_NONZERO_BREAK`, `FAIL_BREAK`) and `CATCH`. Outside it use the **`_RETURN`** variants. - **Never use a `*_RETURN` macro inside an `ATTEMPT` block.** It returns past the `CLEANUP` block, so every release, `fclose`, and free in `CLEANUP` is skipped. This has already caused a heap-string leak on the success path of `akgl_get_json_tilemap_property`. - `CLEANUP` must precede `PROCESS`. Transposing them moves the cleanup body into the `PROCESS` switch, where it runs only when an error context exists. - `CATCH` reports failure by `break`ing, which binds to the innermost enclosing loop or `switch`. A `CATCH` written directly inside a `while` exits the loop rather than the function — put the `ATTEMPT` block inside the loop. - **Never `return` from inside a `HANDLE` block either.** `FINISH` ends with `RELEASE_ERROR`, so leaving before it means the handled context is never given back to `AKERR_ARRAY_ERROR` — one leaked slot per call, and the 129th call aborts the process with "Unable to pull an error context from the array!". `SUCCEED_RETURN` is safe because it releases first; a bare `return`, or a `return f(...)` that tail-calls the fallback, is not. Set a flag in the `HANDLE` block and act on it after `FINISH`. This has already cost a process-killing leak in `akgl_path_relative`; see TODO.md, "Performance". - Validate every pointer parameter before dereferencing it, including the ones a sibling function happens not to check. The first two rules are enforced by `scripts/check_error_protocol.py`, running as the `error_protocol` test: it flags a `*_RETURN` inside an `ATTEMPT` block and a `return` out of a `HANDLE` block. Both have shipped before. Neither produces a compiler diagnostic, and neither fails a test run until the pool it drains is empty, which is why a linter is the only thing that catches them. It cannot see the third hazard -- a `CATCH` inside a loop nested in an `ATTEMPT` block, where `break` binds to the loop. When a loop is the *entire* body of an `ATTEMPT` that is harmless, because `CLEANUP`, `PROCESS` and `FINISH` still run; anything after the loop has to account for it. `akgl_tilemap_load_layer_objects` carries a comment saying exactly that, and `akgl_draw_background` uses a flag instead for the same reason. ## API Surface Every function with external linkage must be declared in a header, and every declaration must have a definition. A non-`static` function that appears in no header is still in the ABI but is unreachable by callers; a declaration with no definition is a link error for anyone compiling against the header alone. If a function is exposed only so tests can reach it, declare it under the existing "part of the internal API" comment block in the relevant header. Headers must be self-contained: include what you use, so that a translation unit including a single `akgl` header compiles without a particular include order. Within the project use the angled form, `#include `. This is enforced. `AKGL_PUBLIC_HEADERS` in `CMakeLists.txt` lists every header installed into `include/akgl/`, and drives two things from that one list: what `install()` ships, and a generated translation unit per header — each including exactly that header and nothing before it — linked into the `headers` suite. A new header is covered by adding it to that list. One header per translation unit is the only shape that proves anything: a second `#include` after the first proves nothing about the second, because by then the first has dragged its dependencies in. The declaration half is enforced too, by `scripts/check_api_surface.sh` running as the `api_surface` test. It reads the built library's dynamic symbol table, strips comments out of every public header, and fails on an exported `akgl_*` symbol that is declared nowhere. **Stripping the comments is the point**: four of the nineteen symbols this found were *mentioned* in `controller.h` prose, which is not the same as being declared, and is how they stayed missing. Declare no-argument functions as `(void)`, not `()`. ## Copying Into Fixed-Width Fields **Never `strncpy` or `memcpy` into one of this library's fixed-width fields. Use `aksl_strncpy()` from `akstdlib.h`.** ```c PASS(errctx, aksl_strncpy(obj->name, sizeof(obj->name), name, sizeof(obj->name) - 1)); ``` `strncpy(dest, src, sizeof(dest))` leaves `dest` **unterminated** whenever the source fills it, and `memcpy(dest, src, sizeof(dest))` reads past the end of any source shorter than the field. Every name in this library is a registry key, handed to `strcmp`, `realpath` and SDL property calls, none of which stop at the field. Ten sites had the first problem and one had the second; that whole class is the same one as the savegame overread and the `akgl_get_property` overread before it. `aksl_strncpy` always terminates and never NUL-pads. Two details matter: - **Bound `n` at `sizeof(dest) - 1`.** It raises `AKERR_OUTOFBOUNDS` when the bytes do not fit, and capping `n` is what preserves the "truncated, not rejected" contract those headers document. Drop the cap only when you mean to change that contract. - **It does not pad.** Where the padding is load-bearing -- the savegame name tables write a fixed-width field and the reader steps that many bytes -- `strncpy` into a pre-zeroed buffer is still correct. `write_name_field()` in `src/game.c` is the one place that applies, and it says so. `-Wstringop-truncation` catches the first form at `-O2`. Nothing catches the `memcpy` form; that one was found by reading its neighbours. ## Fixing Defects **Read what the code does before deciding what it should do.** Every rule below is here because it was broken in this repository, and every one of them was cheap to check and expensive to assume. - **Check the surrounding code before designing around a hazard.** The savegame name-width fix first grew four `AKGL_GAME_SAVE_*_NAME_WIDTH` aliases, so the on-disk format's field widths could diverge from the object model's. They were deleted: `akgl_game_load` compares the save's `libversion` against `AKGL_VERSION` and refuses a mismatch **in the same function, sixteen lines above the tables being edited**, so the divergence they defended against cannot happen quietly. An abstraction defending an impossible case is worse than none, because the next reader has to work out what it is for. - **Abstract when the second consumer is real, not before.** A `#define` with one use, a wrapper with one caller, a parameter only ever passed one value: delete it and inline the thing. Those four aliases had exactly one use per side and each expanded to exactly one existing constant. - **A test that has not failed has not been tested.** Break the fix, watch the new test go red, put the fix back. Two tests in the 0.5.0 defect work passed against the unfixed library on the first attempt: - the savegame roundtrip asserted the load *succeeded*, and a reader stepping the wrong field width does not fail — it finds a run of zeros mid-entry, stops early, and reports success with silently wrong maps; - the `akgl_get_json_with_default` test used `TEST_EXPECT_OK`, which releases whatever context the statement returns — and that function hands *back* the context it was given when it does not handle the status, so the `CLEANUP` block released it a second time and the double release corrupted the failure instead of reporting it. Neither was discovered by reasoning. Both were discovered by reverting the fix and being surprised. - **Pick the test case that distinguishes the two behaviours, not the obvious one.** `!AKGL_BITMASK_HAS(mask, bit)` misparses to `!(mask & bit) == bit` without the parentheses — but for a bit that *is* set that is `0 == bit`, false, which is the same answer the correct parse gives. It only diverges for a bit that is **not** set whose value is **not** 1. The obvious test passes either way. - **Do not trust a comment, a TODO entry, or a CI exclusion that states a premise.** Verify it. `tests/character.c` was excluded from two CI jobs and from the mutation harness because it "fails deliberately". It did not: it was exiting 0 while running one of its four tests. `TODO.md` carried eleven entries describing code that had already changed. A premise nobody has re-checked is where the next defect is hiding. - **When a measurement moves, say what you measured.** Absolute benchmark numbers drift with the machine. The `akgl_game_update` fix is recorded as the *gap* between two rows of the same run — 92 µs before, noise after — because a later run read every row 15% high, including rows nothing had touched. Do not re-baseline a whole table to record one change. ## Rules - Add yourself (agent program name, model name and version) as a co-author on every commit message - Avoid dynamic memory allocation. Use the `akgl_heap_*` methods to retrieve necessary objects at runtime, and to release them when done. If the akgl heap facilities don't provide the kind of object needed, create a new heap layer to support that type of object. ## Testing Guidelines Tests use simple executable return codes and are registered through CTest in `CMakeLists.txt`; there is no declared coverage threshold. **Know who owns the error context an assertion consumes.** `TEST_EXPECT_OK`, `TEST_EXPECT_STATUS` and `TEST_EXPECT_ANY_ERROR` all release whatever context the statement returns. Some functions return *the context they were given* -- `akgl_get_json_with_default` hands its argument straight back when it does not handle the status -- so a `CLEANUP` block that also releases that context releases it twice, and a double-released context corrupts the failure instead of reporting it. A test written that way **passes against the broken code**, which is exactly what happened to the first draft of the `AKERR_OUTOFBOUNDS` test in `tests/json_helpers.c`. When the call may hand the context back, take the result into a local, `NULL` your own pointer, and release it yourself: ```c defaulted = akgl_get_json_with_default(keyerr, &defval, &dest, sizeof(int)); keyerr = NULL; /* ownership passed either way */ if ( defaulted != NULL ) { defaulted->handled = true; defaulted = akerr_release_error(defaulted); FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, "..."); } ``` **Every suite's `main()` must call `TEST_TRAP_UNHANDLED_ERRORS()` immediately after `akgl_error_init()`.** libakerror's default unhandled-error handler ends in `exit(errctx->status)`, `exit` keeps only the low byte, and libakgl's status band starts at 256 — so a suite that failed with `AKGL_ERR_SDL` exited 0 and CTest recorded a pass. That is not hypothetical: `tests/character.c` aborted at its second of four tests on a bad renderer and was green until 0.5.0. The trap in `tests/testutil.h` collapses any status a byte cannot carry onto 1. Add focused tests as `tests/.c`, create a matching `test_` target, and register it with `add_test`. Put reusable fixtures in `tests/assets/` and keep paths compatible with tests launched from the build tree. Coverage mode wraps the suite in a CTest fixture so counters are reset before tests and reports are generated afterward. ### Performance suites `tests/perf.c` (nothing that draws) and `tests/perf_render.c` (everything that does) are benchmarks, built and registered like any other suite but listed in `AKGL_PERF_SUITES` so they carry the `perf` label and a longer timeout. The harness is `tests/benchutil.h`. Three rules keep the numbers honest, and all three were learned by getting them wrong first: - **Nothing that checks an error goes inside the clock.** `PASS` and `CATCH` call `akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` — more work than several of the calls being measured. Use `BENCH_LOOP`, which stashes the context and stops at the first failure, and hand it to `PASS` afterwards. - **Flush the renderer before stopping the clock.** SDL batches: a `draw_*` call queues a command and returns. `BENCH_FLUSH_STOP` in `tests/perf_render.c` is there because the first version of that suite measured queueing, reported a tilemap frame 250 times faster than it is, and paid the real cost at teardown inside `SDL_DestroyTexture`. - **A drawing benchmark needs a raw-SDL control that does the same pixel work.** Without one there is no way to separate what libakgl costs from what the rasterizer costs, and the answer is not the one you would guess — see `PERFORMANCE.md`. Budgets are per-operation ceilings at roughly ten times the recorded baseline; they are enforced only in an optimized build at full scale. When a change moves a number for a good reason, re-record the baseline in `PERFORMANCE.md` in the same commit and say why it moved. These two suites are also the memory-check vehicle. `tests/benchutil.h` detects valgrind from `LD_PRELOAD` and drops the scale to `AKGL_BENCH_VALGRIND_SCALE`, so the same binaries walk every path once instead of a hundred thousand times, and the timings a checked run prints are labelled as meaningless. **Do not add memory-check suites**: a new path worth checking belongs in a benchmark, where it gets both. ## Commit & Pull Request Guidelines Recent commits use concise, imperative summaries such as `Fix a scale bug...` and often explain related changes in one sentence. Keep each commit scoped and describe user-visible behavior. Pull requests should summarize the change, identify affected modules, list the CMake/CTest commands run, and link relevant issues. Include screenshots only for rendering, map, or `charviewer` changes.