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
348 lines
15 KiB
Markdown
348 lines
15 KiB
Markdown
# 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:
|
|
|
|
```c excerpt=include/akgl/heap.h
|
|
#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.
|
|
|
|
```text
|
|
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:
|
|
|
|
```c
|
|
#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:
|
|
|
|
```c run=akglapp
|
|
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));
|
|
```
|
|
|
|
```output
|
|
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:
|
|
|
|
```c excerpt=include/akgl/staticstring.h
|
|
/** @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`:
|
|
|
|
```c
|
|
#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:
|
|
|
|
```text
|
|
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](06-the-registry.md).
|
|
|
|
Both return `NULL` and have no failure path today — they are a series of `memset`s. 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](04-errors.md) — `AKGL_ERR_HEAP` in the status tables.
|
|
- [Chapter 6](06-the-registry.md) — the registries the releases clear entries from.
|
|
- [Chapter 23](23-appendix-limits.md) — every `AKGL_MAX_*` in one place.
|