Files
libakgl/docs/05-the-heap.md
Tachikoma eabb9ad376
Some checks failed
libakgl CI Build / cmake_build (push) Successful in 9m7s
libakgl CI Build / performance (push) Successful in 9m44s
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / memory_check (push) Has been cancelled
Move outstanding work from TODO.md into the issue tracker
TODO.md carried two records in one file: what had been done, with the
measurements behind it, and what was left. The second half is what a tracker
is for, and keeping it here has already cost something -- AGENTS.md records a
round where eleven entries described code that had already changed, and this
file admitted to three more.

Every open item is now an issue on source.starfort.tech/andrew/libakgl,
labelled by kind and blast radius and milestoned by what it can land in: 0.9.x
for anything that breaks no ABI, 0.10.0 for new or changed public symbols,
1.0.0 for the design work. Four are epics: the performance plan (#60),
coverage (#61), actor rotation (#62), and the false header comments (#63).

Verified against the tree before filing rather than transcribed. Three entries
were already fixed and were not filed: the akgl_path_relative context leak, the
akgl_draw_background test extension, and the SDL enumeration audit -- keyboards,
gamepads and mappings are all freed in CLEANUP today. Two were reworded because
the code had moved: the fonts item is a missing teardown entry point rather than
a missing API, since akgl_text_unloadallfonts exists, and draw_world's tilemap
call is already bounded by numlayers, so only the per-layer actor rescan remains.

TODO.md keeps the part a tracker has no place for: why a decision went the way
it did, what the measurement was, and which arguments turned out to be wrong.

TODO.txt is deleted. Four of its eight entries had shipped -- actor-to-actor
collision, actor-to-world collision, automatic facing, image layers -- and the
four that had not are #74 through #77, with the GPU renderer's research links
kept because that is the part that took the time.

Every reference that named an item number or a moved section is repointed, in
the manual, the headers, the tests and the examples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:47:34 -04:00

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 (issue #14; also the @warning on heap.h itself).

Acquire Takes the reference? Who takes it
akgl_heap_next_string Yesrefcount 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_actor releases every non-NULL child recursively, clears the actor's entry from AKGL_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_sprite clears the AKGL_REGISTRY_SPRITE entry and zeroes the slot. It does not release the spritesheet — the sprite only borrowed it.
  • akgl_heap_release_spritesheet clears the registry entry, destroys the SDL_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_character walks the state-to-sprite map with akgl_character_state_sprites_iterate and AKGL_ITERATOR_OP_RELEASE, giving back every reference akgl_character_sprite_add took, then destroys the SDL_PropertiesID holding the map, clears the registry entry, and zeroes the slot.
  • akgl_heap_release_string zeroes 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_string does not clean the buffer. The contents are whatever the last holder left. Call akgl_string_initialize(str, NULL) if you need it clean — and note that akgl_get_property only copies the value and its terminator, leaving the rest of the buffer alone, so read the result as a C string rather than as AKGL_MAX_STRING_LENGTH bytes.
  • Several functions claim one for you. akgl_get_property, akgl_get_json_string_value and akgl_get_json_array_index_string all claim a slot when *dest is NULL, 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:

  1. A string claimed and never released. By far the most common: the pool is 256 slots and every akgl_get_property call can claim one. A per-frame read that forgets the release exhausts it in four seconds at 60 Hz.
  2. A *_RETURN macro inside an ATTEMPT, skipping the CLEANUP that held the release.
  3. A return out of a HANDLE block. 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.
  4. 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 4AKGL_ERR_HEAP in the status tables.
  • Chapter 6 — the registries the releases clear entries from.
  • Chapter 23 — every AKGL_MAX_* in one place.