Files
libakgl/AGENTS.md
Andrew Kesterson bcd49fc5b1 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

18 KiB

Repository Guidelines

Project Structure & Module Organization

This is a C library intended to support the development of video games. Public C headers live in include/akgl/; keep declarations there aligned with their implementations in src/. Tests are standalone C programs under tests/, with JSON, image, and map fixtures in tests/assets/. The util/ directory contains the charviewer utility and its sample assets. Third-party and companion libraries are vendored in deps/. Treat build/ and generated akgl.pc files as build outputs rather than hand-maintained source; include/akgl/SDL_GameControllerDB.h is generated too, but is tracked on purpose — see below.

Generated and Vendored Sources

include/akgl/SDL_GameControllerDB.h is generated, and is tracked deliberately

mkcontrollermappings.sh regenerates this header by fetching the community controller database from raw.githubusercontent.com. It is committed to the repository on purpose: it is the offline fallback that keeps the library buildable if upstream is renamed, rate-limited, taken down, or simply unreachable from the build machine. Do not delete it, do not add it to .gitignore, and do not "clean up" the fact that a generated file is tracked.

Working rules:

  • Never hand-edit it. It is machine-written; changes belong in mkcontrollermappings.sh.
  • Do not commit incidental regeneration churn. The build regenerates the header on every run, and the script stamps $(date) into a comment, so an ordinary build leaves the file modified with nothing of substance changed. Revert that before committing: git checkout -- include/akgl/SDL_GameControllerDB.h.
  • Update it as a deliberate, standalone commit when you actually want newer mappings, so the diff is reviewable and bisectable.
  • Never commit a copy with AKGL_SDL_GAMECONTROLLER_DB_LEN 0. That is the signature of a failed fetch, not an empty upstream — see the defect note below. Check the constant before staging this file.
  • Formatting tooling ignores it: scripts/reindent.sh and the pre-commit hook both skip it.

Known defect (tracked in TODO.md). The safety net is currently self-defeating. mkcontrollermappings.sh has no set -e and does not check curl's exit status, so when the fetch fails it writes a header with AKGL_SDL_GAMECONTROLLER_DB_LEN 0 and an empty array over the good tracked copy, and exits 0. Because CMake re-runs the generator on every build, an offline build silently destroys the very fallback the file exists to provide. Until that is fixed, treat a dirty SDL_GameControllerDB.h after a build as suspect and check the length constant before committing it.

Build, Test, and Development Commands

Configure an out-of-tree development build:

cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo

Build all library, utility, and test targets:

cmake --build build --parallel

Run the complete test suite with failure details:

ctest --test-dir build --output-on-failure

Run one test while iterating, for example ctest --test-dir build -R sprite --output-on-failure. The rebuild.sh script also installs into a developer-specific /home/andrew/local prefix and removes existing outputs; prefer the portable commands above unless that exact workflow is intended.

Run only the performance suites with ctest --test-dir build -L perf --output-on-failure, or leave them out of an ordinary run with -LE perf. They take about 30 seconds together, print a table of nanoseconds per operation, and fail only when a measurement exceeds a budget set at roughly ten times the recorded baseline. AKGL_BENCH_SCALE scales every iteration count — AKGL_BENCH_SCALE=0.1 ctest --test-dir build -L perf for a quick look — and below 1.0 the budgets are reported but not enforced. The recorded baseline and what it means are in PERFORMANCE.md.

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 excludes the intentionally failing character test.

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.

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:

((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

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:

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:

("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 . +).

Most of src/ already conforms. The known non-conforming files are src/json_helpers.c, src/util.c (from akgl_rectangle_points onward), src/assets.c, src/staticstring.c, parts of src/actor.c, and the headers include/akgl/util.h and include/akgl/staticstring.h — all of which use a 2-column offset. Convert a file to the canonical style in its own commit, not mixed into a behavioral change.

Braces

  • Function bodies open on their own line, in column 0.
  • Control statements keep the brace on the same line, one space before it.
  • else, else if, and while of a do/while stay on the closing-brace line: } else {, not a bare else on the next line. Note that this is a house convention rather than something cc-mode enforces — the stroustrup style governs indentation only and will not move a brace or an else for you.
  • Always brace, even single-statement bodies.
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:

    FAIL_ZERO_RETURN(
        errctx,
        SDL_SetPointerProperty(AKGL_REGISTRY_ACTOR, name, (void *)obj),
        AKERR_KEY,
        "Unable to add actor to registry"
        );
    

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_TypeNameakgl_Actor, akgl_SpriteSheet, akgl_PhysicsBackend. Every type exported from a header takes the prefix; bare names like point and RectanglePoints are defects, not precedent.
  • Constants and macros: AKGL_UPPER_SNAKE_CASE. A constant belongs to the subsystem it describes: a character limit is AKGL_CHARACTER_MAX_*, not AKGL_SPRITE_MAX_CHARACTER_*. Name a constant for what its value is — a nanoseconds-per-millisecond scale factor is AKGL_TIME_ONEMS_NS.
  • Exported globals take the akgl_ prefix like any other public symbol. Do not add bare names (renderer, camera, window), leading-underscore names (reserved at file scope), or SCREAMING_SNAKE names for mutable objects — AKGL_UPPER_SNAKE_CASE is for constants.
  • 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 breaking, which binds to the innermost enclosing loop or switch. A CATCH written directly inside a while exits the loop rather than the function — put the ATTEMPT block inside the loop.
  • Never return from inside a HANDLE block either. FINISH ends with RELEASE_ERROR, so leaving before it means the handled context is never given back to AKERR_ARRAY_ERROR — one leaked slot per call, and the 129th call aborts the process with "Unable to pull an error context from the array!". SUCCEED_RETURN is safe because it releases first; a bare return, or a return f(...) that tail-calls the fallback, is not. Set a flag in the HANDLE block and act on it after FINISH. This has already cost a process-killing leak in akgl_path_relative; see TODO.md, "Performance".
  • Validate every pointer parameter before dereferencing it, including the ones a sibling function happens not to check.

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>.

Declare no-argument functions as (void), not ().

Rules

  • Add yourself (agent program name, model name and version) as a co-author on every commit message
  • Avoid dynamic memory allocation. Use the akgl_heap_* methods to retrieve necessary objects at runtime, and to release them when done. If the akgl heap facilities don't provide the kind of object needed, create a new heap layer to support that type of object.

Testing Guidelines

Tests use simple executable return codes and are registered through CTest in CMakeLists.txt; there is no declared coverage threshold. 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.

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.

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.