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

13 KiB
Raw Blame History

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

{
    "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, because the ownership rule is the string pool's rather than the registry's.

Where to go next

  • Chapter 5 — the pools the registries point into, and who releases what.
  • Chapter 7 — where in startup the registries are created, and where configuration has to be in place by.
  • Chapter 14 — choosing a backend, and what the physics.* properties do.
  • Chapter 21 — the configuration table again, alongside every AKGL_MAX_*.