Files
libakgl/docs/06-the-registry.md
Andrew Kesterson b938460127 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>
2026-08-01 20:58:37 -04:00

229 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 06. The registry
Nothing in this library is passed around by pointer where a name will do. An actor names
the character it instantiates, a character names the sprites it draws, a sprite names the
sheet it cuts frames from. All of it is resolved at load time through eight registries,
and that is what lets the whole asset graph be described in JSON files that reference each
other by name.
**The registries are SDL property sets, not akgl types.** They are `SDL_PropertiesID`
values with external linkage, so you can enumerate one with `SDL_EnumerateProperties`,
which is exactly what `akgl_registry_iterate_actor` and
`akgl_character_state_sprites_iterate` do.
## The eight registries
| Registry | Key | Value | Written by | Cleared by |
|---|---|---|---|---|
| `AKGL_REGISTRY_ACTOR` | actor name | `akgl_Actor *` | `akgl_actor_initialize` | `akgl_heap_release_actor` at refcount 0 |
| `AKGL_REGISTRY_SPRITE` | sprite name | `akgl_Sprite *` | `akgl_sprite_initialize` | `akgl_heap_release_sprite` at refcount 0 |
| `AKGL_REGISTRY_SPRITESHEET` | **resolved image path** | `akgl_SpriteSheet *` | `akgl_spritesheet_initialize` | `akgl_heap_release_spritesheet` at refcount 0 |
| `AKGL_REGISTRY_CHARACTER` | character name | `akgl_Character *` | `akgl_character_initialize` | `akgl_heap_release_character` at refcount 0 |
| `AKGL_REGISTRY_ACTOR_STATE_STRINGS` | state name | its bit value, as a number | `akgl_registry_init_actor_state_strings` | never |
| `AKGL_REGISTRY_FONT` | font name (yours) | `TTF_Font *` | `akgl_text_loadfont` | `akgl_text_unloadfont` |
| `AKGL_REGISTRY_MUSIC` | music name | audio handle | **nothing** — created but never populated | — |
| `AKGL_REGISTRY_PROPERTIES` | configuration key | string value | `akgl_set_property`, `akgl_registry_load_properties` | never |
Three of those rows are worth expanding.
**The spritesheet registry is keyed by path, not by name.** That is the sharing mechanism:
two sprites cutting frames out of the same image find the same `akgl_SpriteSheet` and the
same `SDL_Texture`, and the texture is loaded once.
**The actor-state-string registry exists so JSON can name a bit.**
`akgl_registry_init_actor_state_strings` walks `AKGL_ACTOR_STATE_STRING_NAMES` and maps
entry `i` to `1 << i`, which is what lets a character definition write
`"AKGL_ACTOR_STATE_FACE_LEFT"` instead of `2`. Two entries in that name table disagree with
`actor.h`: bits 11 and 12 are `UNDEFINED_11` and `UNDEFINED_12` rather than `MOVING_IN` and
`MOVING_OUT`, **so those two states cannot be named from JSON at all** (`TODO.md` items
2426). The individual name registrations are not checked, either.
**The music registry is empty.** `akgl_registry_init_music` creates it and nothing in the
library ever writes to it. It is there for you.
## What actually runs at startup
`registry.h` documents `akgl_registry_init` as creating "the seven asset registries", with
a `@warning` that it does not create `AKGL_REGISTRY_PROPERTIES`. **Both halves of that are
now wrong**, and the correction matters because the warning is what a reader would design
around.
Read against `src/registry.c` and `src/game.c`, this is what is true:
- `akgl_registry_init` creates **eight**, including `AKGL_REGISTRY_PROPERTIES`. The
properties call was added in 0.5.0 (`TODO.md`, "Known and still open" item 3, marked
fixed). Its order is spritesheet, sprite, character, actor, actor-state-strings, font,
music, properties.
- **`akgl_game_init` never calls `akgl_registry_init`.** It calls the eight individual
initializers itself, in a different order: actor, sprite, spritesheet, character, font,
music, properties, actor-state-strings.
So there are two supported startup paths and they do not share code. If you call
`akgl_game_init`, you have all eight and need do nothing. If you are building your own
startup — an embedder binding an existing renderer, say — call `akgl_registry_init` and you
also have all eight. What you must not do is call some subset by hand and assume the rest
followed.
`akgl_registry_init_actor` is the one initializer that behaves differently in a way you
would notice: since 0.5.0 every initializer destroys the previous property set before
creating the replacement, so none of them leaks on a second call, but the *actor* one is
the one meant to be called again — between levels, paired with `akgl_heap_init_actor`. Note
that it destroys the **registry**, not the actors; releasing those is
`akgl_heap_release_actor`'s job.
## The id-0 trap
Every `SDL_PropertiesID` in `src/registry.c` starts at 0, and SDL treats 0 as "no such
property set". **Reads return the default and writes are refused.** An uninitialized
registry therefore does not fail the way you would want it to.
How loud that failure is depends entirely on whether libakgl checks SDL's return value, and
it does not check everywhere:
| Call | Behaviour against an id of 0 |
|---|---|
| `akgl_actor_initialize`, `akgl_sprite_initialize`, `akgl_spritesheet_initialize`, `akgl_character_initialize` | **Loud.** The `SDL_SetPointerProperty` result is checked; you get `AKERR_KEY`, "Unable to add … to registry" |
| `akgl_actor_set_character` | **Loud.** `SDL_GetPointerProperty` returns `NULL` and the result is checked; you get `AKERR_NULLPOINTER` |
| `akgl_set_property` | **Silent.** The `SDL_SetStringProperty` result is not checked. The value is discarded and the call returns success |
| `akgl_registry_load_properties` | **Silent, and worse.** Same unchecked write, and it logs `Set property x = y` for every entry it dropped, then logs `Properties loaded` |
| `akgl_get_property` | **Silent.** You get the default you passed, every time |
| `akgl_registry_init_actor_state_strings` | **Silent.** The `SDL_SetNumberProperty` results are not checked |
The consequence is the one `registry.h` warns about, and it is real even though the
attribution has moved: **a startup path that leaves `AKGL_REGISTRY_PROPERTIES` at 0 gives
you a no-op `akgl_set_property` and an `akgl_get_property` that always hands back your
default** — which in turn means `akgl_render_2d_init` builds a window from its fallback
size and `akgl_physics_init_arcade` runs with no gravity, both reporting success.
If you are debugging configuration that appears not to apply, check
`AKGL_REGISTRY_PROPERTIES != 0` before you check anything else.
## Truncated keys
Every name is copied into a fixed-width field with `aksl_strncpy` bounded to
`sizeof(field) - 1`, which is the "**truncated, not rejected**" contract those headers
document. The fields:
| Object | Field | Bytes |
|---|---|---|
| `akgl_Actor::name` | `AKGL_ACTOR_MAX_NAME_LENGTH` | 128 |
| `akgl_Sprite::name` | `AKGL_SPRITE_MAX_NAME_LENGTH` | 128 |
| `akgl_Character::name` | `AKGL_CHARACTER_MAX_NAME_LENGTH` | 128 |
| `akgl_SpriteSheet::name` | `AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` | 512 |
**Two distinct names that agree on their first 127 bytes truncate to the same key**, and
the second `SDL_SetPointerProperty` silently replaces the first. The objects are different;
the registry cannot tell. That is recorded in `TODO.md`, "Truncated registry keys can
collide", and it is not being fixed because the fix is a contract change — refusing an
over-long name with `AKERR_OUTOFBOUNDS` — and every one of those headers currently promises
the opposite.
There is a second half that the `TODO.md` entry understates. It says the truncated name *is*
the registry key. That is true for sprites and spritesheets, which register under their own
copied field. It is **not** true for actors and characters:
- `akgl_actor_initialize` and `akgl_character_initialize` register under the **caller's**
string, untruncated.
- `akgl_heap_release_actor` and `akgl_heap_release_character` clear under the object's
**truncated** field.
For any name of 127 bytes or fewer those are the same string and nothing happens. Over that,
the entry is written under one key and cleared under another, so **releasing the object
leaves a live registry entry pointing at a zeroed pool slot** — and the next lookup of that
name hands out a pointer to freed storage. Keep asset names short; 127 bytes is generous for
something a human types into a JSON file.
## Configuration properties
`AKGL_REGISTRY_PROPERTIES` is a string-to-string store. **Everything is a string, including
numbers** — `game.screenwidth` is `"800"`, not `800` — and whoever reads a property parses
it.
These are every property the library itself reads, with the default it falls back to and
where it is read:
| Property | Default | Read by | Meaning |
|---|---|---|---|
| `game.screenwidth` | `"0"` | `akgl_render_2d_init` | Window and camera width, in pixels |
| `game.screenheight` | `"0"` | `akgl_render_2d_init` | Window and camera height, in pixels |
| `physics.gravity.x` | `"0.0"` | `akgl_physics_init_arcade` | Constant acceleration on x, px/s² |
| `physics.gravity.y` | `"0.0"` | `akgl_physics_init_arcade` | Constant acceleration on y, px/s². Positive is down |
| `physics.gravity.z` | `"0.0"` | `akgl_physics_init_arcade` | Constant acceleration on z, px/s² |
| `physics.drag.x` | `"0.0"` | `akgl_physics_init_arcade` | Fraction of environmental velocity shed per second on x |
| `physics.drag.y` | `"0.0"` | `akgl_physics_init_arcade` | Same for y. **This is the only brake on falling** — there is no terminal velocity |
| `physics.drag.z` | `"0.0"` | `akgl_physics_init_arcade` | Same for z |
| `physics.max_timestep` | `"0.05"` | `akgl_physics_init_arcade` | Upper bound on a single simulation `dt`, in seconds |
That is the whole list. Two things follow from it:
**The screen size defaults to 0×0.** `akgl_render_2d_init` passes whatever it parsed
straight to `SDL_CreateWindowAndRenderer`, so a program that never sets those two asks SDL
for a zero-sized window. Set them before calling it.
**`physics.engine` is not a property.** `physics.h` says in two places that `akgl_game_init`
passes a `physics.engine` property to `akgl_physics_factory`. It does not — `akgl_game_init`
never calls the factory at all, and the string `physics.engine` does not appear anywhere in
`src/`. Choosing a backend is an explicit call you make yourself, with a name of `"null"` or
`"arcade"`; see [Chapter 14](14-physics.md). The header comment is a known-false claim left
alone deliberately, since correcting it is a source change rather than a documentation one.
### Setting properties
Two ways in, and both must happen **after** `akgl_game_init` (which creates the registry)
and **before** `akgl_render_2d_init` or `akgl_physics_init_arcade` (which read it).
`akgl_set_property(name, value)` sets one. SDL copies the value, so your buffer can go away
afterwards.
`akgl_registry_load_properties(fname)` loads a JSON document. It expects a top-level
`properties` object whose members are **all strings**:
```json kind=properties
{
"properties": {
"game.screenwidth": "800",
"game.screenheight": "600",
"physics.gravity.y": "400.0",
"physics.drag.y": "0.9",
"physics.max_timestep": "0.05"
}
}
```
A member that is not a string is `AKERR_TYPE`; a document with no `properties` member is
`AKERR_KEY`; a file that will not open or will not parse is `AKERR_NULLPOINTER` with
jansson's line number and text in the message. Note that a failure part-way through leaks
one string-pool slot, because the per-property `CLEANUP` block is empty — a malformed
configuration file is worth failing on rather than retrying in a loop.
### Reading properties
`akgl_get_property(name, dest, def)` reads one into a pooled string. **Absence is not an
error**: an unset property yields `def`, which is how every caller inside the library gets a
working default without checking first.
Three details, all of which have caused a defect here before:
- **`*dest` decides who allocates.** `NULL` claims a slot from the string pool for you;
non-`NULL` is written in place. Either way **you** release it with
`akgl_heap_release_string`. Initialize your local to `NULL` on the first call.
- **`def` is effectively required.** A `NULL` default on an unset property is
`AKERR_NULLPOINTER`, not an empty result.
- **Only the value and its terminator are copied.** The rest of the destination keeps
whatever the previous holder left, so read the result as a C string and never as
`AKGL_MAX_STRING_LENGTH` bytes. A value of `AKGL_MAX_STRING_LENGTH` bytes or more is
`AKERR_OUTOFBOUNDS`; it will not fit an `akgl_String` with its terminator.
The full worked example — claim, read, parse, release in `CLEANUP` — is in
[Chapter 5](05-the-heap.md), because the ownership rule is the string pool's rather than the
registry's.
## Where to go next
- [Chapter 5](05-the-heap.md) — the pools the registries point into, and who releases what.
- [Chapter 7](07-the-game-and-the-frame.md) — where in startup the registries are created,
and where configuration has to be in place by.
- [Chapter 14](14-physics.md) — choosing a backend, and what the `physics.*` properties do.
- [Chapter 21](21-appendix-limits.md) — the configuration table again, alongside every
`AKGL_MAX_*`.