docs/22-ui.md covers the clay-backed UI: what clay owns against what libakgl owns, bring-up and the arena refusal contract, the frame bracket and where it sits in a real frame, the consumed-event rules (with the one-frame-stale hit test stated plainly), the three widgets, menus across keyboard, gamepad and mouse, writing raw CLAY() with an application-owned press edge, images and styles, and how layout errors surface from frame_end with a captured trace. Every listing is a checked block: one linked-and-run example with pinned output, eight excerpts quoting examples/uidemo and examples/jrpg/textbox.c, and the chapter figure is a frame out of uidemo via docs_game_figures. The chapter owes and pays the comparison the tutorials earn: the dialog widget's default style reproduces the JRPG textbox palette, the two listings sit side by side, and the trade is stated -- 125 lines you own completely against one call that costs you the subsystem. Neither is deprecated; chapter 21 keeps teaching the hand-rolled panel on purpose. The appendix moves to 23 (links in chapters 4, 5, 6, 7, 13 and the TOC updated) and gains the ui.h status cross-reference, the AKGL_UI_* limits table, and the new draw primitives' rows. Its status-band figures were stale at COUNT 5 / LIMIT 261 while the band already held six codes; now correct at 7 / 263. Chapter 4 gains AKGL_ERR_UI (262) in the excerpt, the table and the name list. Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
15 KiB
05. The heap
libakgl does not call malloc. Every runtime object comes out of a fixed, statically
allocated array declared in include/akgl/heap.h, and "allocation" is a linear scan for a
slot whose refcount is zero.
The consequence to design around is that exhaustion is an ordinary error, not an
out-of-memory catastrophe. AKGL_ERR_HEAP is a status you can handle. It is also,
almost always, a missing release rather than a pool that is genuinely too small.
The eight pools
A "heap layer" is one array plus its next/release pair. There are five:
| Layer | Ceiling | Default | Element | Total |
|---|---|---|---|---|
| Actor | AKGL_MAX_HEAP_ACTOR |
64 | 400 B | 25 KiB |
| Sprite | AKGL_MAX_HEAP_SPRITE |
1024 | 176 B | 176 KiB |
| Spritesheet | AKGL_MAX_HEAP_SPRITESHEET |
1024 | 536 B | 536 KiB |
| Character | AKGL_MAX_HEAP_CHARACTER |
256 | 184 B | 46 KiB |
| String | AKGL_MAX_HEAP_STRING |
256 | 4100 B | 1.0 MiB |
The whole thing is about 1.8 MiB of BSS, and the string pool is more than half of it
because every akgl_String is PATH_MAX bytes whether it holds a path or the four
characters "0.0".
The ceilings are all #ifndef-guarded:
#ifndef AKGL_MAX_HEAP_ACTOR
#define AKGL_MAX_HEAP_ACTOR 64
#endif
#ifndef AKGL_MAX_HEAP_SPRITE
#define AKGL_MAX_HEAP_SPRITE (AKGL_MAX_HEAP_ACTOR * 16)
#endif
#ifndef AKGL_MAX_HEAP_SPRITESHEET
#define AKGL_MAX_HEAP_SPRITESHEET AKGL_MAX_HEAP_SPRITE
#endif
#ifndef AKGL_MAX_HEAP_CHARACTER
#define AKGL_MAX_HEAP_CHARACTER 256
#endif
#ifndef AKGL_MAX_HEAP_STRING
#define AKGL_MAX_HEAP_STRING 256
#endif
The sprite pool is sixteen per actor, on the assumption of one sprite per state combination. The spritesheet pool is sized to match, though sharing by resolved path means far fewer are used in practice.
The five arrays are extern and public — akgl_heap_actors, akgl_heap_sprites,
akgl_heap_spritesheets, akgl_heap_characters, akgl_heap_strings — so the render and
physics sweeps can walk them directly instead of going through a registry.
akgl_game_update does exactly that.
The ceilings are a compile-time ABI constraint
This is the part that bites, and it does not announce itself.
The arrays are sized at compile time. Defining AKGL_MAX_HEAP_ACTOR to 256 before
including heap.h changes the declared extent of akgl_heap_actors in your translation
unit — but src/heap.c was compiled with whatever the library was built with, and that is
where the storage actually lives. Mismatch the two and your code indexes past the end of an
array that is still 64 entries long, in another object file, with no diagnostic anywhere.
your game.c libakgl.so
------------------------ ------------------------
#define AKGL_MAX_HEAP_ACTOR 256 (built with the default 64)
#include <akgl/heap.h>
akgl_Actor akgl_heap_actors[64];
akgl_heap_actors[200] ------------------> off the end. No warning.
Raise a ceiling for the whole build or not at all. With add_subdirectory, put it in a
target_compile_definitions that reaches libakgl's own sources as well as yours. Against
an installed libakgl.so, you cannot change it at all without rebuilding and reinstalling
the library, and the soname does not encode it — so a rebuilt game against an old installed
library is a silent overrun, not a link error.
Claiming and releasing
Each layer has an acquire and a release:
| Layer | Acquire | Release |
|---|---|---|
| Actor | akgl_heap_next_actor |
akgl_heap_release_actor |
| Sprite | akgl_heap_next_sprite |
akgl_heap_release_sprite |
| Spritesheet | akgl_heap_next_spritesheet |
akgl_heap_release_spritesheet |
| Character | akgl_heap_next_character |
akgl_heap_release_character |
| String | akgl_heap_next_string |
akgl_heap_release_string |
Every acquire raises AKGL_ERR_HEAP and nothing else. Every release raises
AKERR_NULLPOINTER and nothing else. Full signatures are in the Doxygen reference; the
behaviour worth knowing is below.
None of the next_* functions checks dest. A NULL there is a crash, not an error
context. That is deliberate — the check would cost a branch on the allocation path — but it
means these are the functions where you validate your own argument first.
None of them zeroes the slot either. What you get back is whatever the previous holder
left. The *_initialize functions memset before they do anything else, which is why the
normal shape is acquire-then-initialize and never acquire-then-use.
The refcount asymmetry
This is the one thing about the heap that will surprise you, and it is a known defect
rather than a design (TODO.md, "Known and still open" item 8; also the @warning on
heap.h itself).
| Acquire | Takes the reference? | Who takes it |
|---|---|---|
akgl_heap_next_string |
Yes — refcount is 1 on return |
the acquire itself |
akgl_heap_next_actor |
No | akgl_actor_initialize |
akgl_heap_next_sprite |
No | akgl_sprite_initialize |
akgl_heap_next_spritesheet |
No | akgl_spritesheet_initialize |
akgl_heap_next_character |
No | akgl_character_initialize |
Until a reference is taken, the slot is still free and the next acquire hands out the
same pointer. Seven of the eight acquires therefore leave a window in which two callers can
be holding the same object, and the window closes only when *_initialize runs. In
practice you never see it, because the four are always immediately followed by their
initializer — which is exactly why the asymmetry has survived. Write them adjacent and
nothing can get in between:
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/actor.h>
#include <akgl/heap.h>
/* Claim an actor slot and take the reference on it. */
akerr_ErrorContext AKERR_NOIGNORE *spawn_actor(akgl_Actor **dest, char *name)
{
akgl_Actor *obj = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "NULL name");
PASS(errctx, akgl_heap_next_actor(&obj));
/* obj->refcount is still 0. The slot is not yours yet. */
PASS(errctx, akgl_actor_initialize(obj, name));
/* obj->refcount is 1 now, and the actor is in AKGL_REGISTRY_ACTOR. */
*dest = obj;
SUCCEED_RETURN(errctx);
}
You do not have to take that on trust. This program is compiled, linked against the library and run by the test suite, and the output below it is what it actually prints:
akgl_Actor *first = NULL;
akgl_Actor *second = NULL;
akgl_String *str = NULL;
/* A string acquire takes the reference for you. */
PASS(errctx, akgl_heap_next_string(&str));
printf("string refcount after acquire: %d\n", str->refcount);
/* An actor acquire does not. */
PASS(errctx, akgl_heap_next_actor(&first));
printf("actor refcount after acquire: %d\n", first->refcount);
/* So a second acquire, before the initializer runs, finds the same free slot. */
PASS(errctx, akgl_heap_next_actor(&second));
printf("same slot handed out twice: %s\n", (first == second) ? "yes" : "no");
/* Initializing is what takes the reference and closes the window. */
PASS(errctx, akgl_actor_initialize(first, "player"));
printf("actor refcount after init: %d\n", first->refcount);
PASS(errctx, akgl_heap_next_actor(&second));
printf("now a distinct slot: %s\n", (first == second) ? "no" : "yes");
PASS(errctx, akgl_heap_release_actor(first));
PASS(errctx, akgl_heap_release_string(str));
string refcount after acquire: 1
actor refcount after acquire: 0
same slot handed out twice: yes
actor refcount after init: 1
now a distinct slot: yes
Note also that a failure between the two — akgl_actor_initialize rejecting the name, say
— needs no cleanup, precisely because nothing was claimed. That is the one thing the
asymmetry is good for.
The string layer is the opposite and is safe on its own, which is what makes the scratch-buffer idiom below work.
What each release actually does
They all decrement, and they all do the teardown when the count reaches zero. What the teardown is differs, and two of them reach outside the pool:
akgl_heap_release_actorreleases every non-NULLchild recursively, clears the actor's entry fromAKGL_REGISTRY_ACTOR, and zeroes the slot. The recursion has no cycle check: an actor reachable from its own child list recurses until the stack runs out.akgl_heap_release_spriteclears theAKGL_REGISTRY_SPRITEentry and zeroes the slot. It does not release the spritesheet — the sprite only borrowed it.akgl_heap_release_spritesheetclears the registry entry, destroys theSDL_Texture, and zeroes the slot. Any sprite still pointing at the sheet is left with a dangling pointer, since nothing takes a reference on a sheet on a sprite's behalf. Destroying a texture is a main-thread operation in SDL, so this must be called from the thread that owns the renderer.akgl_heap_release_characterwalks the state-to-sprite map withakgl_character_state_sprites_iterateandAKGL_ITERATOR_OP_RELEASE, giving back every referenceakgl_character_sprite_addtook, then destroys theSDL_PropertiesIDholding the map, clears the registry entry, and zeroes the slot.akgl_heap_release_stringzeroes the character data. Strings are not registered anywhere, so that is all there is to it.
One shared hazard: a slot whose refcount is already 0 is re-torn-down rather than
rejected. Harmless on a zeroed slot, destructive on a live one that was never registered.
akgl_heap_release_string is the exception to the library's usual NULL-is-a-no-op
convention: a NULL here is AKERR_NULLPOINTER, which is why every CLEANUP block in the
library wraps it in IGNORE().
akgl_String
A pooled string is a refcount and a PATH_MAX buffer:
/** @brief Provides a fixed-capacity, heap-managed string buffer. */
typedef struct
{
int refcount; /**< Pool bookkeeping; 0 means the slot is free. Owned by the heap layer. */
char data[AKGL_MAX_STRING_LENGTH]; /**< The characters. Not guaranteed NUL-terminated when filled to capacity. */
} akgl_String;
Capacity is fixed. akgl_string_initialize and akgl_string_copy truncate rather than
grow, and the truncation is silent — they both always NUL-terminate, but they do not tell
you that something was lost. If you need to know, measure first, or go through
aksl_strncpy with the full destination size and let it raise AKERR_OUTOFBOUNDS.
Two details that catch people:
akgl_heap_next_stringdoes not clean the buffer. The contents are whatever the last holder left. Callakgl_string_initialize(str, NULL)if you need it clean — and note thatakgl_get_propertyonly copies the value and its terminator, leaving the rest of the buffer alone, so read the result as a C string rather than asAKGL_MAX_STRING_LENGTHbytes.- Several functions claim one for you.
akgl_get_property,akgl_get_json_string_valueandakgl_get_json_array_index_stringall claim a slot when*destisNULL, and write in place when it is not. Either way the caller releases it.
The scratch-buffer idiom is claim, use, release unconditionally in CLEANUP:
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/error.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/staticstring.h>
/* The scratch-string idiom: claim, use, release unconditionally in CLEANUP. */
akerr_ErrorContext AKERR_NOIGNORE *screen_width(int *dest)
{
akgl_String *value = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
ATTEMPT {
CATCH(errctx, akgl_heap_next_string(&value));
CATCH(errctx, akgl_get_property("game.screenwidth", &value, "800"));
CATCH(errctx, aksl_atoi(value->data, dest));
} CLEANUP {
IGNORE(akgl_heap_release_string(value));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
The CLEANUP block runs on every path out of the ATTEMPT, which is the whole reason the
release goes there and not after the last CATCH. Never use a *_RETURN macro inside
that ATTEMPT — it returns past CLEANUP and leaks the slot. That mistake has shipped
in this library before.
Reading AKGL_ERR_HEAP
When a pool is exhausted the message names the layer:
Unable to find unused string on the heap
Before you raise the ceiling, look for the missing release. The realistic causes, in the order they actually happen:
- A string claimed and never released. By far the most common: the pool is 256 slots
and every
akgl_get_propertycall can claim one. A per-frame read that forgets the release exhausts it in four seconds at 60 Hz. - A
*_RETURNmacro inside anATTEMPT, skipping theCLEANUPthat held the release. - A
returnout of aHANDLEblock. That leaks a libakerror context slot rather than an akgl one, and shows up as"Unable to pull an error context from the array!"on the 129th call — different pool, same shape of bug. - Characters reloaded per level without releasing the previous ones, which exhausts the sprite pool rather than the character pool, since each character holds a reference per bound state.
Only after all four are ruled out is the ceiling actually too small — and a game that genuinely needs 256 actors should say so once, for the whole build, per the ABI note above.
Initialization and reset
akgl_heap_init zeroes all eight pools. akgl_game_init calls it for you, before any
registry exists.
Calling it again is a reset, not a refresh. It does not release textures, clear registries, or consult reference counts, so every live object becomes a dangling pointer and every registry entry points at a zeroed slot.
akgl_heap_init_actor zeroes the actor pool only, which is what makes it usable between
levels: the sprites, sheets and characters are the expensive ones because they own
textures. Pair it with akgl_registry_init_actor, the one registry initializer that
destroys the old property set before creating the replacement. See
Chapter 6.
Both return NULL and have no failure path today — they are a series of memsets. Check
them anyway; the signature exists so a layer that needs real setup has somewhere to report
from.
If you need a new kind of object
Add a layer here. Do not reach for the allocator: AGENTS.md makes that a standing rule,
and a malloc in the middle of a frame is the failure mode the pools exist to remove.
A layer is an array, an AKGL_MAX_HEAP_* ceiling, a next, and a release — five things,
all visible in src/heap.c.
Where to go next
- Chapter 4 —
AKGL_ERR_HEAPin the status tables. - Chapter 6 — the registries the releases clear entries from.
- Chapter 23 — every
AKGL_MAX_*in one place.