Files
libakgl/AGENTS.md

783 lines
42 KiB
Markdown
Raw Normal View History

# Repository Guidelines
## Project Structure & Module Organization
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
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`.
Stop a failed controller-DB fetch from destroying the tracked fallback Closes Defects -> Known and still open item 12, both halves. include/akgl/SDL_GameControllerDB.h is tracked on purpose: it is the offline fallback that keeps the library buildable when upstream is unreachable. The script that writes it 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 copy and exited 0. The safety net destroyed itself, and because CMake re-ran the generator on every build, an offline build was enough to do it. The script now runs under set -euo pipefail, fetches into a temporary directory, and moves the result into place only after checking curl's exit status (with --fail, so an HTTP error is a status rather than an error page in the body), a plausible minimum mapping count, and that no mapping carries a quote or backslash that would break the C string literal it becomes. Any of those failing leaves the tracked header exactly as it was and exits non-zero. Verified against all five failure modes -- unresolvable host, 404, truncated response, empty-but-successful response (the original failure exactly), and a response containing a quote. Each refuses, and the header's checksum is unchanged after every one. AKGL_CONTROLLERDB_URL and AKGL_CONTROLLERDB_MIN_LINES are environment-overridable, which is how. The build no longer runs it at all. The add_custom_command declared a relative OUTPUT, which CMake resolves against the binary directory while the script writes to the source directory, so the declared output never appeared and every build re-ran the command -- needing network access and leaving the tree dirty. It is an explicit `controllerdb` target now, and the header is no longer listed as a library source, which is all it was there for. The empty-initializer problem goes with it: an empty array initializer is a constraint violation in ISO C that compiles only as a GCC extension, and the minimum-count check guarantees at least one entry. Also here, because it rested on a premise that turned out to be false: the character suite is no longer excluded from CI's coverage and memory-check jobs, nor from the mutation harness by default. It was excluded on the grounds that it failed deliberately. It did not -- it was reporting success while running one of its four tests. 25/25 pass, memcheck clean, shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:54:31 -04:00
- **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.
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
- **Update it as a deliberate, standalone commit** when you actually want newer
mappings, so the diff is reviewable and bisectable.
Stop a failed controller-DB fetch from destroying the tracked fallback Closes Defects -> Known and still open item 12, both halves. include/akgl/SDL_GameControllerDB.h is tracked on purpose: it is the offline fallback that keeps the library buildable when upstream is unreachable. The script that writes it 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 copy and exited 0. The safety net destroyed itself, and because CMake re-ran the generator on every build, an offline build was enough to do it. The script now runs under set -euo pipefail, fetches into a temporary directory, and moves the result into place only after checking curl's exit status (with --fail, so an HTTP error is a status rather than an error page in the body), a plausible minimum mapping count, and that no mapping carries a quote or backslash that would break the C string literal it becomes. Any of those failing leaves the tracked header exactly as it was and exits non-zero. Verified against all five failure modes -- unresolvable host, 404, truncated response, empty-but-successful response (the original failure exactly), and a response containing a quote. Each refuses, and the header's checksum is unchanged after every one. AKGL_CONTROLLERDB_URL and AKGL_CONTROLLERDB_MIN_LINES are environment-overridable, which is how. The build no longer runs it at all. The add_custom_command declared a relative OUTPUT, which CMake resolves against the binary directory while the script writes to the source directory, so the declared output never appeared and every build re-ran the command -- needing network access and leaving the tree dirty. It is an explicit `controllerdb` target now, and the header is no longer listed as a library source, which is all it was there for. The empty-initializer problem goes with it: an empty array initializer is a constraint violation in ISO C that compiles only as a GCC extension, and the minimum-count check guarantees at least one entry. Also here, because it rested on a premise that turned out to be false: the character suite is no longer excluded from CI's coverage and memory-check jobs, nor from the mutation harness by default. It was excluded on the grounds that it failed deliberately. It did not -- it was reporting success while running one of its four tests. 25/25 pass, memcheck clean, shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:54:31 -04:00
- **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.
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
- Formatting tooling ignores it: `scripts/reindent.sh` and the pre-commit hook
both skip it.
Stop a failed controller-DB fetch from destroying the tracked fallback Closes Defects -> Known and still open item 12, both halves. include/akgl/SDL_GameControllerDB.h is tracked on purpose: it is the offline fallback that keeps the library buildable when upstream is unreachable. The script that writes it 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 copy and exited 0. The safety net destroyed itself, and because CMake re-ran the generator on every build, an offline build was enough to do it. The script now runs under set -euo pipefail, fetches into a temporary directory, and moves the result into place only after checking curl's exit status (with --fail, so an HTTP error is a status rather than an error page in the body), a plausible minimum mapping count, and that no mapping carries a quote or backslash that would break the C string literal it becomes. Any of those failing leaves the tracked header exactly as it was and exits non-zero. Verified against all five failure modes -- unresolvable host, 404, truncated response, empty-but-successful response (the original failure exactly), and a response containing a quote. Each refuses, and the header's checksum is unchanged after every one. AKGL_CONTROLLERDB_URL and AKGL_CONTROLLERDB_MIN_LINES are environment-overridable, which is how. The build no longer runs it at all. The add_custom_command declared a relative OUTPUT, which CMake resolves against the binary directory while the script writes to the source directory, so the declared output never appeared and every build re-ran the command -- needing network access and leaving the tree dirty. It is an explicit `controllerdb` target now, and the header is no longer listed as a library source, which is all it was there for. The empty-initializer problem goes with it: an empty array initializer is a constraint violation in ISO C that compiles only as a GCC extension, and the minimum-count check guarantees at least one entry. Also here, because it rested on a premise that turned out to be false: the character suite is no longer excluded from CI's coverage and memory-check jobs, nor from the mutation harness by default. It was excluded on the grounds that it failed deliberately. It did not -- it was reporting success while running one of its four tests. 25/25 pass, memcheck clean, shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:54:31 -04:00
> **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.
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
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 memory with the suites that already exist `cmake --build build --target memcheck` runs every registered CTest suite under valgrind. There are no new test programs, and there should not be any: tests/benchutil.h notices valgrind in LD_PRELOAD and drops the benchmark scale to 0.0005, which turns the perf suites into the broadest path coverage in the tree at a cost valgrind can survive. A benchmark walks one path a hundred thousand times; a leak check wants every path walked once. Same binaries, one flag apart, and the whole run is about thirty seconds. scripts/memcheck.sh wraps `ctest -T memcheck` because that command records defects and still exits 0, which cannot gate anything. It also forces the headless drivers, so the vendor GPU stack is never loaded -- that removes thousands of unfixable findings inside amdgpu_dri.so without suppressing anything, and it is what the suites are written for anyway. Only definite losses, invalid accesses and uninitialised reads count; "still reachable" is what SDL and FreeType keep for the process lifetime and says nothing about this library. The three suppressions in scripts/valgrind.supp are each a decision that a finding belongs to somebody else. Six defects, all filed in TODO.md under "Memory checking", the first of which is the one that matters: not one of the four json_load_file calls in src/ is ever matched by a json_decref, so every asset load abandons its parsed document -- 1.5 KB per sprite, 2.1 KB per character, 9 KB per load of the 2x2 fixture map, and on the order of a megabyte for a real one. A game that reloads a level on death leaks a level's worth of JSON every time. akgl_get_property also reads up to 4 KiB past the end of every property value it copies, and the savegame name tables read past the end of every registry key and write what they find into the file. tests/util.c zeroes three fixtures it used to leave as stack garbage. The library was never at fault there -- the tests handed it uninitialised floats and then made one real call with them -- but sixteen findings of noise in a new gate is how a gate gets ignored. The unit suites' TIMEOUT goes from 30 to 300 because CTest applies the same property to the checked run, where everything is twenty times slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:59:57 -04:00
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.
Stop a failed controller-DB fetch from destroying the tracked fallback Closes Defects -> Known and still open item 12, both halves. include/akgl/SDL_GameControllerDB.h is tracked on purpose: it is the offline fallback that keeps the library buildable when upstream is unreachable. The script that writes it 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 copy and exited 0. The safety net destroyed itself, and because CMake re-ran the generator on every build, an offline build was enough to do it. The script now runs under set -euo pipefail, fetches into a temporary directory, and moves the result into place only after checking curl's exit status (with --fail, so an HTTP error is a status rather than an error page in the body), a plausible minimum mapping count, and that no mapping carries a quote or backslash that would break the C string literal it becomes. Any of those failing leaves the tracked header exactly as it was and exits non-zero. Verified against all five failure modes -- unresolvable host, 404, truncated response, empty-but-successful response (the original failure exactly), and a response containing a quote. Each refuses, and the header's checksum is unchanged after every one. AKGL_CONTROLLERDB_URL and AKGL_CONTROLLERDB_MIN_LINES are environment-overridable, which is how. The build no longer runs it at all. The add_custom_command declared a relative OUTPUT, which CMake resolves against the binary directory while the script writes to the source directory, so the declared output never appeared and every build re-ran the command -- needing network access and leaving the tree dirty. It is an explicit `controllerdb` target now, and the header is no longer listed as a library source, which is all it was there for. The empty-initializer problem goes with it: an empty array initializer is a constraint violation in ISO C that compiles only as a GCC extension, and the minimum-count check guarantees at least one entry. Also here, because it rested on a premise that turned out to be false: the character suite is no longer excluded from CI's coverage and memory-check jobs, nor from the mutation harness by default. It was excluded on the grounds that it failed deliberately. It did not -- it was reporting success while running one of its four tests. 25/25 pass, memcheck clean, shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:54:31 -04:00
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.
Document the checks, the copy rule and the trap that made a test lie Four gaps in the maintenance guide, each one something a maintainer will hit. The two enforced checks were never named in it. api_surface (scripts/check_api_surface.sh) and error_protocol (scripts/check_error_protocol.py) fail builds and appear in ctest output, so the guide now says what each one asserts and why -- including that the API check strips comments before looking, because four of the nineteen symbols it found were mentioned in header prose and that is not the same as being declared. A new "Copying Into Fixed-Width Fields" section: never strncpy or memcpy into one of this library's fixed-width fields, use aksl_strncpy, bound n at sizeof(dest) - 1 to keep the truncate-not-reject contract, and know the one place the NUL padding is load-bearing. Ten sites had the unterminated form and one had the overreading memcpy form, and every name in this library is a registry key. The testing section gains the error-context ownership trap. TEST_EXPECT_* release whatever the statement returns, and some functions return the context they were given -- so a CLEANUP that also releases it double-releases, and a double-released context corrupts the failure rather than reporting it. A test written that way passes against broken code. That is not hypothetical; it is what the first AKERR_OUTOFBOUNDS test did. Also recorded: what ctest actually runs beyond the C suites, the gcovr positional search path and why it must stay, and the surviving GCOV returncode 5 trap. Plus rebuild.log to .gitignore -- it had been sitting in git status long enough to stop being noticed, which is the state a genuinely unexpected file needs to stand out from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:19:00 -04:00
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.
Run the benchmarks and the memory check in CI The perf suites were already running in CI, because they are ordinary CTest tests -- but only in the coverage job, which is a Debug build with gcov instrumentation. That is the one place their numbers cannot mean anything, and it was spending full-scale iteration counts to prove it. They now run there at AKGL_BENCH_SCALE=0.02, for path coverage alone, which takes the whole coverage run to four seconds. The `performance` job is where the timings are taken: RelWithDebInfo, full scale, `ctest -L perf`, which is the only configuration in which tests/benchutil.h enforces a budget. It keeps the tables as an artifact whether it passes or fails -- a red run says which measurement moved, and a green one is the next baseline for PERFORMANCE.md. The step uses --verbose rather than --output-on-failure, because --output-on-failure prints nothing at all for a passing benchmark, and sets pipefail, without which the status reaching the runner is tee's and a blown budget reads as a pass. The `memory_check` job runs scripts/memcheck.sh over every suite under valgrind and keeps the logs. It carries continue-on-error, and that is a deliberate, temporary lie: the six findings in TODO.md "Memory checking" are real and all of them are libakgl's, so gating on it today would only teach everyone to ignore a red build. The flag comes off with the commit that closes the last item, and item 34 -- the missing json_decref in all four asset loaders -- is four lines. Both new jobs were run locally against a clean copy of the tree with the exact commands the workflow uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:33:58 -04:00
## 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. |
Run the benchmarks and the memory check in CI The perf suites were already running in CI, because they are ordinary CTest tests -- but only in the coverage job, which is a Debug build with gcov instrumentation. That is the one place their numbers cannot mean anything, and it was spending full-scale iteration counts to prove it. They now run there at AKGL_BENCH_SCALE=0.02, for path coverage alone, which takes the whole coverage run to four seconds. The `performance` job is where the timings are taken: RelWithDebInfo, full scale, `ctest -L perf`, which is the only configuration in which tests/benchutil.h enforces a budget. It keeps the tables as an artifact whether it passes or fails -- a red run says which measurement moved, and a green one is the next baseline for PERFORMANCE.md. The step uses --verbose rather than --output-on-failure, because --output-on-failure prints nothing at all for a passing benchmark, and sets pipefail, without which the status reaching the runner is tee's and a blown budget reads as a pass. The `memory_check` job runs scripts/memcheck.sh over every suite under valgrind and keeps the logs. It carries continue-on-error, and that is a deliberate, temporary lie: the six findings in TODO.md "Memory checking" are real and all of them are libakgl's, so gating on it today would only teach everyone to ignore a red build. The flag comes off with the commit that closes the last item, and item 34 -- the missing json_decref in all four asset loaders -- is four lines. Both new jobs were run locally against a clean copy of the tree with the exact commands the workflow uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:33:58 -04:00
| `mutation_test` | Debug | One focused source file, so CI stays bounded. |
Stop a failed controller-DB fetch from destroying the tracked fallback Closes Defects -> Known and still open item 12, both halves. include/akgl/SDL_GameControllerDB.h is tracked on purpose: it is the offline fallback that keeps the library buildable when upstream is unreachable. The script that writes it 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 copy and exited 0. The safety net destroyed itself, and because CMake re-ran the generator on every build, an offline build was enough to do it. The script now runs under set -euo pipefail, fetches into a temporary directory, and moves the result into place only after checking curl's exit status (with --fail, so an HTTP error is a status rather than an error page in the body), a plausible minimum mapping count, and that no mapping carries a quote or backslash that would break the C string literal it becomes. Any of those failing leaves the tracked header exactly as it was and exits non-zero. Verified against all five failure modes -- unresolvable host, 404, truncated response, empty-but-successful response (the original failure exactly), and a response containing a quote. Each refuses, and the header's checksum is unchanged after every one. AKGL_CONTROLLERDB_URL and AKGL_CONTROLLERDB_MIN_LINES are environment-overridable, which is how. The build no longer runs it at all. The add_custom_command declared a relative OUTPUT, which CMake resolves against the binary directory while the script writes to the source directory, so the declared output never appeared and every build re-ran the command -- needing network access and leaving the tree dirty. It is an explicit `controllerdb` target now, and the header is no longer listed as a library source, which is all it was there for. The empty-initializer problem goes with it: an empty array initializer is a constraint violation in ISO C that compiles only as a GCC extension, and the minimum-count check guarantees at least one entry. Also here, because it rested on a premise that turned out to be false: the character suite is no longer excluded from CI's coverage and memory-check jobs, nor from the mutation harness by default. It was excluded on the grounds that it failed deliberately. It did not -- it was reporting success while running one of its four tests. 25/25 pass, memcheck clean, shellcheck clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 06:54:31 -04:00
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".
Run the benchmarks and the memory check in CI The perf suites were already running in CI, because they are ordinary CTest tests -- but only in the coverage job, which is a Debug build with gcov instrumentation. That is the one place their numbers cannot mean anything, and it was spending full-scale iteration counts to prove it. They now run there at AKGL_BENCH_SCALE=0.02, for path coverage alone, which takes the whole coverage run to four seconds. The `performance` job is where the timings are taken: RelWithDebInfo, full scale, `ctest -L perf`, which is the only configuration in which tests/benchutil.h enforces a budget. It keeps the tables as an artifact whether it passes or fails -- a red run says which measurement moved, and a green one is the next baseline for PERFORMANCE.md. The step uses --verbose rather than --output-on-failure, because --output-on-failure prints nothing at all for a passing benchmark, and sets pipefail, without which the status reaching the runner is tee's and a blown budget reads as a pass. The `memory_check` job runs scripts/memcheck.sh over every suite under valgrind and keeps the logs. It carries continue-on-error, and that is a deliberate, temporary lie: the six findings in TODO.md "Memory checking" are real and all of them are libakgl's, so gating on it today would only teach everyone to ignore a red build. The flag comes off with the commit that closes the last item, and item 34 -- the missing json_decref in all four asset loaders -- is four lines. Both new jobs were run locally against a clean copy of the tree with the exact commands the workflow uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:33:58 -04:00
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
## 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 . +`).
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
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.
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
### 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"
);
```
Build clean under -Wall, with -Werror in CI only TODO.md item 37 said the cast sweep buys nothing until the build turns on the warnings those casts suppress. This is that precondition, and the argument turned out to be right with evidence: -Wall found three genuine signedness mismatches in sprite.c, where uint32_t width, height and speed were passed straight to akgl_get_json_integer_value(..., int *). A cast would have silenced all three. Fixing them properly also turned up that speed is scaled by a million into a 32-bit field, so anything past 4294 ms overflowed rather than being held. The other real finding was ten -Wstringop-truncation warnings, every one a fixed-width strncpy. Those leave a name field unterminated when the input fills it -- and those fields are registry keys, handed to strcmp and SDL property calls that do not stop at the field. Same overread class fixed twice already this release. All ten now use aksl_strncpy from akstdlib, which always terminates and never NUL-pads, bounded to size - 1 so an over-long name still truncates rather than being refused. staticstring.h documented the old strncpy semantics explicitly and has been rewritten to match. Two sites in game.c are deliberately left on strncpy: both stage into a pre-zeroed buffer for the savegame's fixed-width fields, where the NUL padding is part of the format. Neither is flagged. sprite.c also had a memcpy of a full 128-byte field from a NUL-terminated string, which reads past the end of any shorter name. Not flagged by anything; found while converting its neighbours. -Werror is an option, default OFF, on only in the cmake_build and performance CI jobs. libakgl is consumed with add_subdirectory -- akbasic does exactly that -- so a target-level -Werror turns every diagnostic a newer compiler invents into a broken build for somebody else. The warning set is not even stable across build types here: an -O2 build reports ten warnings that -O0 and -fsyntax-only do not, because they need the middle end. The two jobs that set it are one of each. -Wextra is deliberately not adopted. It adds 22 findings, 17 inherent to the design: 13 -Wunused-parameter from backend vtables and SDL callbacks that must match a signature, and 4 -Wimplicit-fallthrough from libakerror's own PROCESS/HANDLE/HANDLE_GROUP, which fall through by design. Filed upstream as deps/libakerror TODO item 8; the submodule bump carries it. deps/semver/semver.c is listed directly in add_library, so the target's PRIVATE options reach it. Exempted with -w so a future semver update cannot fail this build -- verified by forcing -Wextra -Werror and watching our files fail while semver compiled clean. Verified: RelWithDebInfo, Debug and coverage builds all clean under -Werror; -Werror actually fails on a planted unused variable; an embedded consumer with AKGL_WERROR unset still configures and builds. 25/25 pass, memcheck clean, reindent --check, check_api_surface and check_error_protocol clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:18:52 -04:00
### 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.
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
### 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_<subsystem>_<verb>`, 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`,
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
`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.
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
- **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.
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
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.
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
- **`static` helpers drop the `akgl_` prefix**, which exists only to avoid
external collisions.
- **Include guards**: `_AKGL_<FILE>_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.
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
- **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".
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
- Validate every pointer parameter before dereferencing it, including the ones a
sibling function happens not to check.
Document the checks, the copy rule and the trap that made a test lie Four gaps in the maintenance guide, each one something a maintainer will hit. The two enforced checks were never named in it. api_surface (scripts/check_api_surface.sh) and error_protocol (scripts/check_error_protocol.py) fail builds and appear in ctest output, so the guide now says what each one asserts and why -- including that the API check strips comments before looking, because four of the nineteen symbols it found were mentioned in header prose and that is not the same as being declared. A new "Copying Into Fixed-Width Fields" section: never strncpy or memcpy into one of this library's fixed-width fields, use aksl_strncpy, bound n at sizeof(dest) - 1 to keep the truncate-not-reject contract, and know the one place the NUL padding is load-bearing. Ten sites had the unterminated form and one had the overreading memcpy form, and every name in this library is a registry key. The testing section gains the error-context ownership trap. TEST_EXPECT_* release whatever the statement returns, and some functions return the context they were given -- so a CLEANUP that also releases it double-releases, and a double-released context corrupts the failure rather than reporting it. A test written that way passes against broken code. That is not hypothetical; it is what the first AKERR_OUTOFBOUNDS test did. Also recorded: what ctest actually runs beyond the C suites, the gcovr positional search path and why it must stay, and the surviving GCOV returncode 5 trap. Plus rebuild.log to .gitignore -- it had been sitting in git status long enough to stop being noticed, which is the state a genuinely unexpected file needs to stand out from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:19:00 -04:00
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.
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
## 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 <akgl/sibling.h>`.
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
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.
Document the checks, the copy rule and the trap that made a test lie Four gaps in the maintenance guide, each one something a maintainer will hit. The two enforced checks were never named in it. api_surface (scripts/check_api_surface.sh) and error_protocol (scripts/check_error_protocol.py) fail builds and appear in ctest output, so the guide now says what each one asserts and why -- including that the API check strips comments before looking, because four of the nineteen symbols it found were mentioned in header prose and that is not the same as being declared. A new "Copying Into Fixed-Width Fields" section: never strncpy or memcpy into one of this library's fixed-width fields, use aksl_strncpy, bound n at sizeof(dest) - 1 to keep the truncate-not-reject contract, and know the one place the NUL padding is load-bearing. Ten sites had the unterminated form and one had the overreading memcpy form, and every name in this library is a registry key. The testing section gains the error-context ownership trap. TEST_EXPECT_* release whatever the statement returns, and some functions return the context they were given -- so a CLEANUP that also releases it double-releases, and a double-released context corrupts the failure rather than reporting it. A test written that way passes against broken code. That is not hypothetical; it is what the first AKERR_OUTOFBOUNDS test did. Also recorded: what ctest actually runs beyond the C suites, the gcovr positional search path and why it must stay, and the surviving GCOV returncode 5 trap. Plus rebuild.log to .gitignore -- it had been sitting in git status long enough to stop being noticed, which is the state a genuinely unexpected file needs to stand out from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:19:00 -04:00
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.
Codify the canonical C style and add reindent tooling AGENTS.md described style in one paragraph that said little more than "follow the surrounding code", which was not actionable once files had drifted apart. Replace it with an explicit specification. The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a 4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is one tab, depth 3 is a tab plus four spaces. Most of src/ already followed this; what looked like randomly mixed tabs and spaces was simply cc-mode output. Document the byte ladder explicitly, since editors that expand tabs or assume a 4-column tab silently corrupt it. Rules beyond indentation are derived from counted majorities in the existing code rather than invented: errctx over e (92 to 45), padded control parens (140 to 7), pointer binding to the identifier (486 to 5), and always-brace (only four unbraced bodies exist). Brace and else placement are called out as house conventions that cc-mode does not enforce, so "run the indenter" and "follow the guide" cannot conflict. Also record the naming, error-handling, and API-surface rules that a consistency sweep showed were being broken silently -- in particular that a *_RETURN macro inside an ATTEMPT block skips CLEANUP. Add the tooling to apply it: .dir-locals.el applies the style to every c-mode buffer scripts/reindent.el batch reindent via Emacs scripts/reindent.sh reindent or --check the tree scripts/hooks/pre-commit reindents staged sources reindent.el deliberately avoids Emacs' tabify: its 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, and lines starting inside a string literal are skipped. The hook checks staged content rather than the working tree, so what is committed is what was verified. It re-stages a fixed file only when the index and working tree agree, otherwise a partial `git add -p` would sweep unstaged work into the commit. Enable with: git config core.hooksPath scripts/hooks Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
Declare no-argument functions as `(void)`, not `()`.
Document the checks, the copy rule and the trap that made a test lie Four gaps in the maintenance guide, each one something a maintainer will hit. The two enforced checks were never named in it. api_surface (scripts/check_api_surface.sh) and error_protocol (scripts/check_error_protocol.py) fail builds and appear in ctest output, so the guide now says what each one asserts and why -- including that the API check strips comments before looking, because four of the nineteen symbols it found were mentioned in header prose and that is not the same as being declared. A new "Copying Into Fixed-Width Fields" section: never strncpy or memcpy into one of this library's fixed-width fields, use aksl_strncpy, bound n at sizeof(dest) - 1 to keep the truncate-not-reject contract, and know the one place the NUL padding is load-bearing. Ten sites had the unterminated form and one had the overreading memcpy form, and every name in this library is a registry key. The testing section gains the error-context ownership trap. TEST_EXPECT_* release whatever the statement returns, and some functions return the context they were given -- so a CLEANUP that also releases it double-releases, and a double-released context corrupts the failure rather than reporting it. A test written that way passes against broken code. That is not hypothetical; it is what the first AKERR_OUTOFBOUNDS test did. Also recorded: what ctest actually runs beyond the C suites, the gcovr positional search path and why it must stay, and the surviving GCOV returncode 5 trap. Plus rebuild.log to .gitignore -- it had been sitting in git status long enough to stop being noticed, which is the state a genuinely unexpected file needs to stand out from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:19:00 -04:00
## 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.
Report the failures libakstdlib's wrappers were already catching Updates deps/libakstdlib to 669b2b3. That commit is a TODO entry rather than new code, so the interesting part is what libakgl was not yet calling: it links libakstdlib and uses ten of its wrappers while calling the raw libc function at a good many more sites. Four of those were carrying a real defect. akgl_game_save checked every write and threw away the close. Every write goes through aksl_fwrite, and for a record this size all of them land in stdio's buffer -- nothing reaches the device until the close flushes it, so a full disk, an exceeded quota or an NFS server going away is reported only there. The function returned success over a savegame that was never written. aksl_fclose surfaces it. tests/game.c reaches the path through /dev/full, which accepts writes and fails at the flush; against the unfixed code the test reports "expected a failure, got success" with ENOSPC. The close has to drop the FILE * before the status is examined, because fclose(3) disassociates the stream either way and CLEANUP would close it again. Written the other way round it aborted in glibc with "double free detected in tcache" the first time a close actually failed, which is how that ordering was found. akgl_game_load's end-of-file check used fgetc(3), which spells "end of file" and "the read failed" with the same EOF -- a disk error there read as a clean end and passed the check it was meant to fail. aksl_fgetc tells them apart. Extracted to require_at_eof, because inverting the sense of a call inline needs a nested ATTEMPT whose break binds to the wrong switch. Two snprintf sites were truncating under a silenced -Wformat-truncation. The compiler was right about both. The tileset one handed the shortened path to IMG_LoadTexture, so a length error was reported as a missing file, with SDL naming a path the caller never wrote. Both use aksl_snprintf now and the suppression is gone; nothing in the tree uses those macros any more. tests/tilemap.c covers the join, and against the unfixed code it gets AKGL_ERR_SDL where it wants AKERR_OUTOFBOUNDS. The two src/game.c strncpy sites the fixed-width copy sweep missed -- the savegame name field and the libversion stamp -- use aksl_strncpy and sizeof rather than a repeated 32. Also makes tests/physics_sim.c deterministic. It placed gravity_time at "now - dt" and let the real clock supply the step, so a machine busy enough to deschedule the process between that store and the SDL_GetTicksNS() inside simulate got a longer step. It went red once, under a parallel ctest. It now sets max_timestep to the step it wants and gravity_time to zero, so the bound supplies the step and the scheduler cannot reach it. TODO.md records what is deliberately not adopted: the pure-arithmetic wrappers, which can only fail on NULL and which the tree already spells two ways, and the collections, which the registries do not want because SDL owns the property-set lifetime. AGENTS.md has the rule and the fclose ordering trap. 26/26 ctest, memcheck clean, warning-clean at -Wall -Werror. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 12:36:36 -04:00
## Reaching For libakstdlib
`deps/libakstdlib` wraps most of libc so a failure arrives as an
`akerr_ErrorContext *` with context instead of a return value the caller has to
remember to check. Use the wrapper **wherever the underlying call can actually
fail**, which in practice means anything touching a file, a format string, or a
fixed-width destination:
| Instead of | Use | Because |
|---|---|---|
| `fclose` | `aksl_fclose` | The flush happens here, so a full disk or an exceeded quota is reported *only* here |
| `fgetc` | `aksl_fgetc` | `fgetc(3)` returns `EOF` for both end-of-file and a read error; this raises `AKERR_EOF` and `AKERR_IO` separately |
| `snprintf` | `aksl_snprintf` | Truncation becomes `AKERR_OUTOFBOUNDS` naming both lengths, instead of a silently short string |
| `strncpy` into a fixed field | `aksl_strncpy` | See **Copying Into Fixed-Width Fields** |
| `fopen`/`fread`/`fwrite`/`realpath`/`atof`/`atoi` | the `aksl_` form | Already the convention in this tree |
**`fclose(3)` disassociates the stream whether or not it succeeds.** Drop the
`FILE *` before you look at the status, or `CLEANUP` closes it a second time:
```c
tmpfp = fp;
fp = NULL;
CATCH(errctx, aksl_fclose(tmpfp));
```
Written the other way round this aborts in glibc with `double free detected in
tcache`, and only on the path where a close actually fails -- which is to say,
never during development. `/dev/full` is how `tests/game.c` reaches it: writes
to that device are accepted and the `ENOSPC` surfaces at the flush.
**Do not convert the pure-arithmetic calls.** `memset`, `memcpy`, `strlen`,
`strcmp` and friends can only fail on a NULL argument, and the tree is
inconsistent about them already; see `TODO.md`, "libakstdlib wrappers not yet
adopted", before starting a sweep.
**Never silence `-Wformat-truncation`.** `include/akgl/error.h` still exports
`DISABLE_GCC_WARNING_FORMAT_TRUNCATION` and nothing uses it. Both sites that
did were genuinely truncating, and both reported the length problem as
something else -- one as a missing texture file, with SDL naming a path the
caller never wrote. `aksl_snprintf` is the answer.
## 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
Namespace every exported symbol, and bump to 0.5.0 Closes internal-consistency items 1 through 6, 12 and 13. Every include guard is _AKGL_<FILE>_H_, every in-project header include is angled, and every exported function, type and global carries the akgl_ prefix. This is an ABI break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename table. The renames were driven by renaming each declaration and letting the compiler find the uses, not by pattern substitution: renderer, physics and camera are also parameter and struct-member names, and a sed would have rewritten map->physics and every akgl_RenderBackend *renderer parameter without a word. Item 4 turned out not to be cosmetic. The library exported a global called renderer and tests/character.c defined an SDL_Renderer *renderer of its own; the executable's definition preempted the library's, akgl_sprite_load_json read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load in that suite failed. The suite reported success anyway, because libakerror's unhandled-error handler ends in exit(errctx->status), exit keeps only the low byte, and AKGL_ERR_SDL is exactly 256. So character had been green while running one of its four tests, and every suite in the tree was unable to fail on the most common status in a library built on SDL. Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which collapses any status a byte cannot carry onto 1, and every suite installs it. character binds a real backend with akgl_render_2d_bind. Its fourth test then runs for the first time and fails on a defect it has asserted all along, so akgl_heap_release_character now walks state_sprites with AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot -- TODO.md Defects item 21 and half of Carried over item 1. AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so akgl_game_state_lock waited roughly sixteen minutes rather than one second. It is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and tests/game.c holds the mutex from a second thread to assert the wait -- the contended path had no coverage at all. Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both install() and a generated translation unit per header, so a header that ships is a header that is checked. Writing that found registry.h, which used SDL_PropertiesID in eight declarations and included no SDL header. 23/23 suites pass, memcheck is clean, reindent --check is clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:32:21 -04:00
Tests use simple executable return codes and are registered through CTest in `CMakeLists.txt`; there is no declared coverage threshold.
Document the checks, the copy rule and the trap that made a test lie Four gaps in the maintenance guide, each one something a maintainer will hit. The two enforced checks were never named in it. api_surface (scripts/check_api_surface.sh) and error_protocol (scripts/check_error_protocol.py) fail builds and appear in ctest output, so the guide now says what each one asserts and why -- including that the API check strips comments before looking, because four of the nineteen symbols it found were mentioned in header prose and that is not the same as being declared. A new "Copying Into Fixed-Width Fields" section: never strncpy or memcpy into one of this library's fixed-width fields, use aksl_strncpy, bound n at sizeof(dest) - 1 to keep the truncate-not-reject contract, and know the one place the NUL padding is load-bearing. Ten sites had the unterminated form and one had the overreading memcpy form, and every name in this library is a registry key. The testing section gains the error-context ownership trap. TEST_EXPECT_* release whatever the statement returns, and some functions return the context they were given -- so a CLEANUP that also releases it double-releases, and a double-released context corrupts the failure rather than reporting it. A test written that way passes against broken code. That is not hypothetical; it is what the first AKERR_OUTOFBOUNDS test did. Also recorded: what ctest actually runs beyond the C suites, the gcovr positional search path and why it must stay, and the surviving GCOV returncode 5 trap. Plus rebuild.log to .gitignore -- it had been sitting in git status long enough to stop being noticed, which is the state a genuinely unexpected file needs to stand out from. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:19:00 -04:00
**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, "...");
}
```
Take libakerror 2.0.1 and drop the workaround it makes obsolete Every libakgl test suite could report success while failing. libakerror's default unhandled-error handler ended in exit(errctx->status), an exit status is one byte wide, and libakgl's band starts at 256 -- so AKGL_ERR_SDL, the most common failure a library built on SDL can have, exited 0 and CTest recorded a pass. tests/character.c aborted at its second of four tests on a bad renderer and was green for months. 0.5.0 worked around that here with TEST_TRAP_UNHANDLED_ERRORS() in tests/testutil.h, and TODO.md ended the entry saying any consumer's suites have the same problem and it was worth raising upstream. It was. 2.0.1 fixes it at the source: akerr_exit() owns the mapping and the default handler calls it, so 0 exits 0, 1 through 255 exit themselves, and anything else exits AKERR_EXIT_STATUS_UNREPRESENTABLE (125). The trap and its 21 call sites are gone. Verified by putting the original failure back rather than by reading the release notes: a FAIL_BREAK(AKGL_ERR_SDL) in tests/character.c's main exits 125 and CTest reports a failure. A standalone consumer raising the same status unhandled exits 125 where it exited 0 before. tests/actor.c installs its own handler and called exit(errctx->status) from it, which is the same defect one layer up. It calls akerr_exit() now. 2.0.0 also makes the error pool and the status registry thread safe, which libakgl needs more than it knew: audio_stream_callback raises error contexts on SDL's audio thread. With an unlocked pool that callback and the main thread could scan AKERR_ARRAY_ERROR at the same time and be handed the same slot. The comment there says so. This is a hard dependency floor, not a preference. 2.0.0 moved __akerr_last_ignored to thread-local storage and made akerr_next_error() return a context that already holds its reference, and both expand at libakgl's call sites -- and at a consumer's, because akerror.h is part of libakgl's public interface. Mixing headers and libraries across that line double-counts every reference and never returns a pool slot. The soname moved to libakerror.so.2; include/akgl/error.h now also feature- tests AKERR_EXIT_STATUS_UNREPRESENTABLE, which is the narrowest probe for 2.0.1 since libakerror publishes no version macro. 0.7.0 for that reason: libakgl's own ABI is unchanged, but the one it re-exports through its headers is not. TODO.md records the pkg-config gap this makes sharper -- akgl.pc names no dependencies at all, so nothing tells a pkg-config consumer which libakerror it needs. Clean build, 26/26 ctest, memcheck clean, warning-clean at -Wall -Werror. libakgl.so.0.7 links libakerror.so.2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 13:05:43 -04:00
**Never `exit(3)` on an akerr status — call `akerr_exit()`.** An exit status is
one byte wide and libakgl's status band starts at 256, so `exit(AKGL_ERR_SDL)`
is a wait status of 0 and a shell sees a clean run. Every suite in this
directory once reported success on the most common failure a library built on
SDL can have: `tests/character.c` aborted at its second of four tests on a bad
renderer and was green until 0.5.0. `akerr_exit()` (libakerror 2.0.1) maps 0 to
0, 1255 to themselves, and anything else to
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125). The default unhandled-error handler
calls it, so an ordinary suite needs no trap of its own — `tests/testutil.h`
carried one until 0.6.0 and it is gone. A suite that installs its *own* handler
still has to call `akerr_exit()` from it; `tests/actor.c` does. Add focused tests as `tests/<feature>.c`, create a matching `test_<feature>` 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.
Fix three arcade physics defects the unit tests could not see tests/physics_sim.c runs the arcade backend the way a game does -- a Mario-esque jump and fall, Zelda-style top-down walking, and a run reversed at full speed -- and prints what the actor actually did. tests/physics.c already checked that akgl_physics_simulate does the arithmetic it claims. Every one of these got past it. The first step was however long the level took to load. gravity_time was never initialized and dt was unbounded, so a 250 ms load produced a 250 ms step: 100 px of fall where a 60 Hz frame under the same gravity is 0.44 px, straight through whatever was underneath. Both initializers seed the clock, and simulate bounds dt to max_timestep -- a new physics.max_timestep property, default 0.05 s, read like gravity and drag. Zero disables the bound. The field replaces the dead timer_gravity, so the struct is the same size. Releasing a vertical key cancelled gravity. The _off handlers zeroed ay, ey, ty and vy together, and ey is where the arcade backend accumulates gravity -- tapping down mid-jump stopped the character in the air. They clear ax/tx (or ay/ty) and nothing else now. Velocity was never theirs to clear either: simulate recomputes v as e + t every step. Diagonal movement was 41% too fast. Thrust was capped per axis, so an actor holding two directions got both caps at once and travelled their diagonal. It is capped as a vector against the sx/sy/sz ellipse, which also keeps a character whose horizontal and vertical top speeds differ moving at the ratio it asked for. An axis with a zero max speed stays out of the magnitude and is forced to zero, as the old clamp did. Each fix was checked by reverting it and confirming the simulation goes red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively. tests/actor.c and tests/physics.c pinned the old behaviour in both places and now assert the new contract. Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four things the simulations found and this does not fix -- no terminal velocity, no deceleration on release, Euler's frame-rate dependence, and a drag coefficient large enough to invert velocity. 26/26 ctest, memcheck clean, warning-clean at -Wall -Werror. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 12:10:53 -04:00
### Physics simulations
`tests/physics_sim.c` is not a unit suite. `tests/physics.c` checks that
`akgl_physics_simulate` does the arithmetic it says it does; this one runs the
arcade backend for a second or two the way a game does and asks whether the
result is what a player would expect. It carries a Mario-esque jump and fall,
Zelda-style top-down walking, and a run reversed at full speed, because those
are the three shapes most 2D games are made of.
It found three defects that every existing unit test passed straight over, so
the distinction is worth keeping:
- **The first step was however long the level took to load.** `gravity_time`
was never initialized and `dt` was unbounded, so a 250 ms load produced a
250 ms step -- 100 px of fall where a 60 Hz frame is 0.44 px. Both
initializers seed the clock now, and `akgl_physics_simulate` bounds `dt` to
`max_timestep` (`physics.max_timestep`, default 0.05 s).
- **Releasing a vertical key cancelled gravity.** The `_off` handlers zeroed
`ay`, `ey`, `ty` and `vy` together, and `ey` is where the arcade backend
accumulates gravity, so tapping down mid-jump stopped the character in the
air. The handlers clear `ax`/`tx` (or `ay`/`ty`) and nothing else. Velocity
was never theirs to clear either -- `simulate` recomputes `v` as `e + t`
every step.
- **Diagonal movement was 41% too fast.** Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the `sx`/`sy`/`sz` ellipse now.
Two rules for working on this suite:
- **Drive the clock, do not sleep on it.** `sim_step()` sets `gravity_time` to
`now - dt` and calls `simulate` once, so a simulated second is 60 steps and
takes no wall-clock time. Only `test_sim_first_step_is_not_a_leap` uses a real
`SDL_Delay`, because a real stall is the thing it is measuring.
- **`main` must `SDL_Init`.** Without it, SDL sets its clock epoch on first use,
`SDL_GetTicksNS()` returns something around 130 ns, and every dt-dependent
assertion passes for a reason that has nothing to do with the code. The
first-step test passed exactly that way before the `SDL_Init` call was added,
and only started failing -- correctly -- once it was there.
Each simulation prints what it measured whether it passes or fails, and `main`
exits with the number that failed. Read the numbers when changing the backend;
a change that keeps every assertion green while moving the apex of a jump by
30 px is a change worth noticing.
Measure the library: perf suites, a recorded baseline, and targets tests/perf.c and tests/perf_render.c drive the hot paths hard enough to time them -- pool acquire and release at both ends of the scan, the registry, the state-to-sprite lookup, the per-actor update and render, the physics sweep, an all-pairs collision sweep, the JSON accessors, path resolution, the drawing primitives, text, asset loading, a screenful of tiles, and a whole frame through akgl_game_update. They are registered like any other suite but carry the `perf` label, so `ctest -L perf` runs only them and `-LE perf` leaves them out. Every measurement is held to a budget at roughly ten times the recorded baseline, enforced only in an optimized build at full scale. Three things had to be got right before the numbers meant anything, and each was wrong first: - No error checking 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. - Flush the renderer before stopping it. SDL batches, so the first version 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 doing the same pixel work with the same access pattern. With one, akgl_tilemap_draw turns out to cost 0.2% of the frame it appeared to own: 16.26 ms against a control's 16.23 ms for the same 1200 blits. The rasterizer is the frame. PERFORMANCE.md records the baseline, the frame budget it adds up to, and what the numbers say -- including that the string pool's acquire is 64x slower full than empty because it is a megabyte of PATH_MAX buffers, that a handled missing-sprite condition costs nine times the update it replaces, that text rasterizes and throws away a texture on every call, and that 28 MB of the library's BSS is one akgl_Tilemap. TODO.md gains a Performance section: five defects the stress tests found that the unit suites do not reach (a tilemap load leaking five pooled strings, two JSON accessors that turn pool exhaustion into a segfault rather than AKGL_ERR_HEAP, akgl_game_update crashing without akgl_game_init, and its actor update sweep running once per tilemap layer), and eighteen targets with today's number and whether it is met. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
### 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.
Check memory with the suites that already exist `cmake --build build --target memcheck` runs every registered CTest suite under valgrind. There are no new test programs, and there should not be any: tests/benchutil.h notices valgrind in LD_PRELOAD and drops the benchmark scale to 0.0005, which turns the perf suites into the broadest path coverage in the tree at a cost valgrind can survive. A benchmark walks one path a hundred thousand times; a leak check wants every path walked once. Same binaries, one flag apart, and the whole run is about thirty seconds. scripts/memcheck.sh wraps `ctest -T memcheck` because that command records defects and still exits 0, which cannot gate anything. It also forces the headless drivers, so the vendor GPU stack is never loaded -- that removes thousands of unfixable findings inside amdgpu_dri.so without suppressing anything, and it is what the suites are written for anyway. Only definite losses, invalid accesses and uninitialised reads count; "still reachable" is what SDL and FreeType keep for the process lifetime and says nothing about this library. The three suppressions in scripts/valgrind.supp are each a decision that a finding belongs to somebody else. Six defects, all filed in TODO.md under "Memory checking", the first of which is the one that matters: not one of the four json_load_file calls in src/ is ever matched by a json_decref, so every asset load abandons its parsed document -- 1.5 KB per sprite, 2.1 KB per character, 9 KB per load of the 2x2 fixture map, and on the order of a megabyte for a real one. A game that reloads a level on death leaks a level's worth of JSON every time. akgl_get_property also reads up to 4 KiB past the end of every property value it copies, and the savegame name tables read past the end of every registry key and write what they find into the file. tests/util.c zeroes three fixtures it used to leave as stack garbage. The library was never at fault there -- the tests handed it uninitialised floats and then made one real call with them -- but sixteen findings of noise in a new gate is how a gate gets ignored. The unit suites' TIMEOUT goes from 30 to 300 because CTest applies the same property to the checked run, where everything is twenty times slower. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:59:57 -04:00
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.
Record how to find out whether a tutorial teaches docs_examples proves every listing in docs/ compiles and still matches the file it was quoted from. It cannot prove a chapter teaches anything -- a document can be made entirely of verified excerpts and still be unfollowable, because what a reader needs is the glue between them. So AGENTS.md gains "Testing a Tutorial": hand the chapter to a subagent on a weaker model with the library, its headers and the art but not the finished program, and make them build and run the game. The weaker model is the point; it will not paper over a gap with knowledge the document did not give it. The section carries the rules that make the result mean something -- copy the tree with examples/ and docs/ removed rather than asking them not to look, let them read the public headers because a real reader has them, do not make them hand-author a .tmj, give them a screenshot helper, and verify their binary yourself -- plus the sandbox recipe, and the warning to prove the build path works before handing it over. It also records how to read the report, because a weaker model states its own mistakes as documentation defects with total confidence. Reproduce before believing; and a rejected finding usually still leaves something behind. The evidence for doing any of this is in the table at the end: four read-only reviews of chapters 20 and 21 found real gaps and missed all three of the defects the first build-and-run found, including a published example output the chapter could not produce. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 09:45:28 -04:00
## Testing a Tutorial
`ctest -R docs_examples` proves every listing in `docs/` still compiles and
still matches the file it was quoted from. It cannot prove the chapter *teaches*
anything: a document can be composed entirely of verified excerpts and still be
unfollowable, because what a reader needs is the glue between them.
**So test a tutorial by making somebody follow it.** Hand it to a subagent on a
weaker model with no context beyond the chapter, and have them build the thing it
describes. The weaker model is the point -- it will not paper over a gap with
knowledge the document did not give it, which is exactly the failure mode a
capable reviewer has.
### The rules that make the result mean something
- **Give them what a real reader has, and nothing more.** The chapter, the
library source, its public headers, and the art. **Not the finished program.**
Copy the tree with `examples/` and `docs/` removed rather than telling them not
to look -- a rule they can break is not a control.
- **Headers are fair game.** The manual defers signatures to Doxygen and a real
user has `include/akgl/*.h` in front of them. Forbidding those tests a reader
who does not exist.
- **They must compile it and run it.** This is the whole thing. A design nobody
built proves nothing, and the defects that matter most do not fail to compile.
- **Verify their work yourself.** Run the binary they produced. Look at the
screenshot they took. Do not take a report's word for what it built --
see "Reading the report" below.
- **Do not make them author assets by hand.** A real reader draws a map in Tiled;
hand-writing a `.tmj` tests nothing about the chapter and eats the whole
session. Give them `docs/tutorials/assets/`. The asset *formats* are already
covered by `docs_examples`, which runs the chapter's own JSON blocks through
`akgl_sprite_load_json` and `akgl_character_load_json`.
- **Give them a screenshot helper**, marked as scaffolding and not part of the
tutorial, so there is a picture to check. Screen output is evidence; an exit
status of 0 is not.
### Setting the sandbox up
One directory per reader, because two of them building at once in the same tree
collide:
```sh
rsync -a --exclude='.git' --exclude='build' --exclude='examples' --exclude='docs' \
. "$SB/reader/libakgl/"
cp docs/20-tutorial-sidescroller.md "$SB/reader/TUTORIAL.md"
cp -r docs/tutorials/assets/sidescroller "$SB/reader/art"
mkdir -p "$SB/reader/game"
```
A consumer using the CMake the chapter itself teaches --
`add_subdirectory(../libakgl libakgl)` plus the documented
`target_link_libraries` line -- configures and builds in about a minute from
cold, and seconds after that. Tell them to use
`cmake --build build --target <theirs> --parallel`, or they will also build
every test suite in the tree.
**Prove the path works before you hand it over.** Write a throwaway consumer
that opens a window and takes a screenshot, build it, run it, and delete it. A
reader who cannot build is a reader who finds nothing, and the fault will be
yours -- the screenshot helper's first draft included `akgl/renderer.h` for
`akgl_renderer`, which is declared in `akgl/game.h`, and would have cost them
the session.
### Reading the report
**Every finding is a hypothesis until you check it.** A weaker model reports its
own mistakes as documentation defects with total confidence, and both kinds are
worth having -- but only one is worth acting on.
- Reproduce the failure before believing it. One reader reported the dialogue
freeze broken; it had drawn its own map with the NPC out of reach and never
used the one it was given. Running the reference showed the player moving zero
pixels through the whole freeze window.
- A rejected finding usually still leaves something. That same map failure was
not a code defect, but it did show the chapter stated `TALK_RANGE` without
saying what it means when you are placing NPCs -- and that a too-distant NPC
produces *success*, not an error.
- Fix the document, not the reader. If they guessed a header name, the chapter
never named it.
### What this catches that review does not
Four read-only passes over the two tutorials in `docs/` found real gaps and
missed all three of these, which the first build-and-run found immediately:
| Defect | Why reading missed it |
|---|---|
| libakstdlib's header was never named -- `aksl_*` functions, `<akstdlib.h>` file | Every reviewer already knew, or did not have to write the `#include` |
| The asset directory defaulted to `"."` instead of the compiled-in macro | Prose said "falls back", and nobody had to run it from another directory |
| A published example output the chapter could not produce | The capture happened after teardown released the pool. Nothing fails to compile |
The third is the shape to remember: **a tutorial that prints an expected result
is making a claim, and a claim nobody executed is a claim that is probably
wrong.**
## 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.