Add the manual: nineteen chapters and a corrected README

docs/ is a narrative manual, not a second reference. Every header already
carries a substantial @file/@brief block and Doxyfile sets WARN_IF_UNDOCUMENTED
with WARN_AS_ERROR, so an undocumented symbol already fails CI. The gap was
navigation and worked examples. Chapters teach a task and link to the Doxygen
output; where a declaration or a constant table has to be in front of the
reader it arrives as a `c excerpt=` block, so the text *is* the header and
cannot diverge from it. That also preserves the hand-aligned bit-flag tables
scripts/reindent.el goes out of its way not to destroy.

The manual does not re-document its dependencies. libakerror owns the
ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol, SDL3 owns renderers and events,
Tiled owns the map format, jansson owns json_t. A chapter that restated any of
them would be wrong the day upstream changed and nothing here would notice --
the same drift this work exists to fix, arriving from a different direction. So
each chapter says what libakgl adds or constrains and links out for the rest.

Chapter 4 is the exception and the reason for it: libakerror documents the
mechanism, but only libakgl can say which statuses its own functions raise and
what they mean here, and that was written down nowhere. It carries three tables
-- libakgl's five status codes, the libakerror statuses libakgl actually raises
with their meaning in this library, and the exit-status trap where
`exit(AKGL_ERR_SDL)` is a wait status of 0 because the band starts at 256.

Every chapter was written against src/ rather than against the header comments,
which is how 27 false claims in those comments came to light. Where a chapter
documents a known defect rather than a design decision it says so and points at
TODO.md.

README.md keeps the development process and hands the reader to docs/. Its
task-oriented FAQ is deleted rather than moved, because one source of truth per
topic is the whole point and that FAQ's examples did not compile.

Census: 39 compiled snippets, 89 verbatim header excerpts, 4 JSON documents run
through the real loaders, one linked-and-executed program with its output
compared byte for byte, one generated figure. 11 norun blocks, each justified.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 20:58:37 -04:00
parent 8ac291d2dd
commit b938460127
22 changed files with 6306 additions and 264 deletions

288
docs/03-getting-started.md Normal file
View File

@@ -0,0 +1,288 @@
# 03. Getting started
This chapter gets libakgl into your build and a window on your screen. It assumes you have
read [Chapter 4](04-errors.md), because the first program is written in the error protocol
and will not make sense otherwise.
## Dependencies
| Dependency | Floor | Why libakgl needs it |
|---|---|---|
| SDL3 | 3.4.x (3.4.8 is vendored) | Window, renderer, events, properties, timing, mutexes |
| SDL3_image | vendored | Decoding spritesheet and tileset images |
| SDL3_mixer | vendored | `akgl_load_start_bgm`, the mixer device and track table |
| SDL3_ttf | vendored | Font loading, rasterizing and metrics |
| jansson | vendored | Parsing every JSON asset format |
| libakerror | **>= 2.0.1, hard floor** | The error protocol. Part of libakgl's *public* interface |
| libakstdlib | >= 0.2 | Checked wrappers over libc — `aksl_strncpy`, `aksl_fclose`, `aksl_snprintf` |
| semver | vendored, in-tree | Savegame and version comparison |
All eight are git submodules under `deps/`, so a recursive clone gives you a build with no
system packages at all.
**The libakerror floor is enforced, not advisory.** Two `#error` feature tests in
`include/akgl/error.h` fire on a stale header:
```c excerpt=include/akgl/error.h
#ifndef AKERR_EXIT_STATUS_UNREPRESENTABLE
#error "libakgl requires libakerror >= 2.0.1: the akerror.h on the include path predates akerr_exit(). Rebuild and reinstall libakerror."
#endif
```
It is a hard floor because libakerror 2.0.0 made `akerr_next_error()` return a context that
already holds its reference and moved `__akerr_last_ignored` into thread-local storage —
and both of those expand at **your** call sites, through `IGNORE` and the `FAIL_*` macros,
because `akerror.h` is part of libakgl's public interface. A tree that mixes headers and
libraries across that line double-counts every reference and never returns a pool slot. The
soname moved to `libakerror.so.2` to stop the library half happening by accident; the
`#error` catches the header half in an install tree.
## Building libakgl itself
```sh norun
git clone --recursive https://source.starfort.tech/andrew/libakgl.git
cd libakgl
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --parallel
ctest --test-dir build --output-on-failure
```
If a dependency is already installed system-wide, libakgl finds it and skips the vendored
copy. The rule is `if(NOT TARGET ...)` per dependency, so mixing is fine: a system SDL3 with
a vendored jansson works.
## Route 1 — `add_subdirectory()`
This is what `akbasic` does, and it is the route with the fewest ways to go wrong: one set
of compiler flags, one set of headers, nothing to install.
```cmake
add_subdirectory(third_party/libakgl)
add_executable(mygame src/main.c)
target_link_libraries(mygame PRIVATE akgl::akgl)
```
`akgl::akgl` carries its own usage requirements, so the SDL3, libakerror and jansson include
paths reach your target without you naming them.
**`AKGL_WERROR` is `OFF` by default, and leaving it off is the right thing for a consumer.**
It is turned on only by two CI jobs in this repository. The reason is exactly your build: a
target-level `-Werror` turns every diagnostic a newer compiler invents into a broken build
for somebody else's project, and the warning set is not even stable across optimization
levels — an `-O2` build reports ten `-Wstringop-truncation` warnings that `-O0` and
`-fsyntax-only` do not. `-Wall` is on for every target this project owns, and the tree builds
clean under it at every optimization level.
If you have already vendored SDL3 yourself, declare it before the `add_subdirectory` and
libakgl will use yours rather than adding a second copy:
```cmake
add_subdirectory(third_party/SDL) # declares SDL3::SDL3
add_subdirectory(third_party/libakgl) # sees it and does not add its own
```
## Route 2 — pkg-config
`make install` puts `akgl.pc` in `lib/pkgconfig/`. Here is what it contains, in full:
```text
prefix=/usr/local
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${exec_prefix}/include
Name: akgl
Description: AKLabs Game library
Version: 0.7.0
Cflags: -I${includedir}/
Libs: -L${libdir} -lakgl
```
**`akgl.pc` names no dependencies at all.** There is no `Requires:` and no
`Requires.private:` line, so `pkg-config --cflags --libs akgl` tells you nothing about
libakerror, libakstdlib, SDL3, SDL3_image, SDL3_mixer, SDL3_ttf or jansson. That has always
been wrong, and it is sharper than it looks: `akerror.h` is part of libakgl's *public*
interface, so a consumer that builds against whatever `akerror.h` is on its include path and
links whatever `libakerror.so` the loader finds can get exactly the mismatch pkg-config had
every opportunity to prevent.
Until that is fixed, name them yourself. These are the modules whose headers libakgl's own
public headers include:
```sh norun
pkg-config --cflags --libs akgl akerror akstdlib sdl3 sdl3-ttf sdl3-mixer jansson
```
`sdl3-image` is needed at link time but appears in no public header, so it belongs in the
private half.
Recorded in `TODO.md` under "akgl.pc names no dependencies", along with why the fix wants
its own commit: adding a `Requires:` line changes what `pkg-config --libs akgl` emits for
every existing consumer.
**There is no CMake package config.** `install()` ships the library, the headers and
`akgl.pc`, and nothing generates an `akglConfig.cmake`, so `find_package(akgl)` does not
work against an install tree. Use `add_subdirectory()` or pkg-config.
## The startup order that actually works
Verified against `src/game.c`, not against the header comment:
```text
1. fill in akgl_game.name, .version and .uri
| akgl_game_init refuses to run without all three, and there are no
| defaults -- the window title, SDL's app metadata and the savegame
| compatibility check are all built from them
v
2. akgl_game_init()
| error codes, version stamp, frame clock, state mutex, pools, the eight
| registries, SDL app metadata, control maps, SDL_Init(VIDEO|GAMEPAD|
| AUDIO), controller DB, gamepads, SDL_mixer, SDL_ttf, and finally the
| four globals pointed at their default storage.
|
| What it does NOT do: create the window, choose a physics backend, or
| load any configuration. Those read properties, which you have not set
| yet.
v
3. akgl_registry_load_properties() or akgl_set_property()
| screen size, physics constants, anything else
v
4. akgl_render_2d_init(akgl_renderer) <- creates the window; reads
| game.screenwidth/screenheight
| akgl_physics_factory(akgl_physics, name) <- "null" or "arcade"
v
5. load assets, then loop on akgl_game_update()
```
**`akgl_game_init` does not choose a physics backend for you.** `physics.h` says the
`physics.engine` property drives `akgl_physics_factory`; it does not. That string appears
nowhere in `src/`, and `akgl_game_init` never calls the factory. You call it yourself, with
an `akgl_String` holding `"null"` or `"arcade"`. The header comment is wrong and is left
alone here deliberately — this manual documents what the code does, and correcting header
prose is a separate commit.
## The first window
Sixty frames of an empty world, then out. It compiles under
`-std=gnu99 -Wall -Werror` and runs headless.
```c
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/physics.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/staticstring.h>
#include <akgl/text.h>
int main(void)
{
akgl_String *engine = NULL;
int frame = 0;
PREPARE_ERROR(errctx);
ATTEMPT {
/* 1. akgl_game_init refuses to run without all three of these. */
CATCH(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name),
"firstwindow", sizeof(akgl_game.name) - 1));
CATCH(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version),
"0.1.0", sizeof(akgl_game.version) - 1));
CATCH(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri),
"net.example.firstwindow", sizeof(akgl_game.uri) - 1));
/* 2. SDL, the pools, the registries, audio, fonts, gamepads. */
CATCH(errctx, akgl_game_init());
/* 3. Configuration. akgl_render_2d_init reads these two. */
CATCH(errctx, akgl_set_property("game.screenwidth", "640"));
CATCH(errctx, akgl_set_property("game.screenheight", "480"));
/* 4. The window and the renderer, then a physics backend. */
CATCH(errctx, akgl_render_2d_init(akgl_renderer));
CATCH(errctx, akgl_heap_next_string(&engine));
CATCH(errctx, akgl_string_initialize(engine, "null"));
CATCH(errctx, akgl_physics_factory(akgl_physics, engine));
/* 5. Load assets, then loop. */
for ( frame = 0; frame < 60; frame++ ) {
CATCH(errctx, akgl_game_update(NULL));
}
} CLEANUP {
IGNORE(akgl_heap_release_string(engine));
IGNORE(akgl_text_unloadallfonts());
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
SDL_Quit();
return 0;
}
```
Five things in there are worth pointing at.
**`aksl_strncpy`, not `strncpy`.** Every name in this library is a registry key, handed to
`strcmp`, `realpath` and SDL property calls, none of which stop at the end of the field.
`strncpy(dest, src, sizeof(dest))` leaves `dest` unterminated whenever the source fills it.
Bound `n` at `sizeof(dest) - 1` and let `aksl_strncpy` raise `AKERR_OUTOFBOUNDS` if the bytes
do not fit.
**`FINISH_NORETURN`, not `FINISH`, in `main`.** `FINISH` ends by returning the error context
from the enclosing function, and `main` returns `int`. With `FINISH_NORETURN` and no
`HANDLE` block, an unhandled error goes to libakerror's default handler, which logs it and
calls `akerr_exit()` — so `SDL_Quit()` and `return 0` are reached only on the success path.
**Never `exit(3)` an akerr status.** An exit status is one byte wide and libakgl's band
starts at 256, so `exit(AKGL_ERR_SDL)` is a wait status of 0 and the shell sees a clean run.
`akerr_exit()` maps 0 to 0, 1255 to themselves, and anything else to
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125). See [Chapter 4](04-errors.md), Table 3.
**`akgl_text_unloadallfonts()` before `SDL_Quit()`.** `SDL_Quit` destroys the property
registry the fonts live in and takes the last reference to every one of them with it. This
program loads no fonts, and the call is in the shutdown anyway because that is where it
belongs the moment one is loaded — [Chapter 16](16-text-and-fonts.md).
**Sixty `akgl_game_update` calls log `Low FPS! 0` sixty times.** `akgl_game.fps` is a
completed-second average, so it reads 0 for the first second of the process, which is under
the low-FPS threshold. `akgl_game.lowfpsfunc` is a hook for exactly this: point it at
something that sheds work, or at nothing.
## Running headless
On a server, in CI, or under valgrind, force the dummy drivers:
```sh norun
SDL_VIDEO_DRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIO_DRIVER=dummy ./mygame
```
These are the SDL3 spellings, and the ones `scripts/memcheck.sh` exports. SDL3 also still
honours SDL2's `SDL_VIDEODRIVER` and `SDL_AUDIODRIVER` as aliases — `SDL_RENDER_DRIVER` was
never spelled any other way. Without them, `akgl_game_init` fails at `SDL_Init` with
`AKGL_ERR_SDL` and the message `Couldn't initialize SDL: No available video device`.
A program can set the same thing from inside itself with `SDL_SetHint` before
`akgl_game_init`, which is what most of the test suites in `tests/` do:
```c
#include <SDL3/SDL.h>
void go_headless(void)
{
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
}
```
The software renderer is a real rasterizer, not a stub — everything draws, nothing is
displayed, and `SDL_RenderReadPixels` gives you the frame back. That is how the image
assertions in `tests/` work.
## Where to go next
[Chapter 5](05-the-heap.md) for the pools you just claimed a string from,
[Chapter 7](07-the-game-and-the-frame.md) for what `akgl_game_update` actually does, or
[Chapter 19](19-tutorial-sidescroller.md) to start building a game.