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>
This commit is contained in:
126
docs/01-introduction.md
Normal file
126
docs/01-introduction.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 01. Introduction
|
||||
|
||||
libakgl is a C library for building 2D games on SDL3. This is version **0.7.0**. It ships
|
||||
156 functions declared across the twenty public headers in `include/akgl/`, plus
|
||||
`akgl_version()` from the generated `version.h` — 157 exported symbols in
|
||||
`libakgl.so.0.7`.
|
||||
|
||||
It gives you object pools instead of `malloc`, name-based registries instead of pointer
|
||||
plumbing, a per-frame tick that updates and draws every live actor, JSON asset formats for
|
||||
sprites and characters, a Tiled map loader, arcade physics, a controller/keyboard input
|
||||
layer, text, and a three-voice tone synthesizer.
|
||||
|
||||
## What it refuses to be
|
||||
|
||||
**It is not an engine.** There is no editor, no scene graph, no scripting layer, no asset
|
||||
pipeline that runs before the compiler, and no runtime that owns your `main`. You write a C
|
||||
program; libakgl is a library it calls.
|
||||
|
||||
The refusals are specific, and each one is a design decision documented in
|
||||
[Chapter 2](02-design-philosophy.md):
|
||||
|
||||
| Not here | Instead |
|
||||
|---|---|
|
||||
| Inheritance, RTTI, dynamic dispatch on a type tag | A record of function pointers you populate — `akgl_RenderBackend`, `akgl_PhysicsBackend` |
|
||||
| Runtime `malloc` | Five statically sized pools with reference counts; `AKGL_ERR_HEAP` when one is full |
|
||||
| An editor or a project format | Tiled TMJ maps and hand-writable JSON for sprites and characters |
|
||||
| A scripting layer | C. Behaviour attaches as function pointers on `akgl_Actor` |
|
||||
| Two worlds at once | Four swappable globals: `akgl_renderer`, `akgl_physics`, `akgl_camera`, `akgl_gamemap` |
|
||||
|
||||
The library also does not own your window. `akgl_render_2d_init` creates one for you, and
|
||||
`akgl_render_2d_bind` is the same job with that half removed, for a host that already has an
|
||||
`SDL_Renderer` — see [Chapter 8](08-rendering.md).
|
||||
|
||||
## What a frame costs
|
||||
|
||||
`PERFORMANCE.md` measures a 640x480 game with a full screen of 16-pixel tiles and 64 actors,
|
||||
software-rasterized. At 60 fps a frame is 16.67 ms:
|
||||
|
||||
| Part of the frame | Cost | Share of 16.67 ms |
|
||||
|---|---:|---:|
|
||||
| Logic: 64 actor updates + one physics sweep | 0.006 ms | 0.03% |
|
||||
| All-pairs collision over 64 actors, if you do it | 0.115 ms | 0.7% |
|
||||
| Clear + present | 0.023 ms | 0.1% |
|
||||
| 64 actor renders (48x48 blits) | 0.191 ms | 1.1% |
|
||||
| 1200 tile blits | 16.26 ms | 97.6% |
|
||||
| Six lines of HUD text | 0.076 ms | 0.5% |
|
||||
|
||||
**libakgl's own per-tile overhead is about 0.03 ms per frame, under 0.2%.** That number is
|
||||
the gap between `akgl_tilemap_draw` at 16.26 ms and a raw `SDL_RenderTexture` loop issuing
|
||||
the same 1200 blits from the same scattered source tiles at 16.23 ms. It covers the bounds
|
||||
arithmetic, the tileset scan, the offset-table lookup, the backend indirection and the error
|
||||
macros.
|
||||
|
||||
It does not cover the pixels, and the pixels are the frame. It does not tell you what
|
||||
libakgl costs on a GPU backend, where the blits get cheap and libakgl's share of a much
|
||||
shorter frame rises — `PERFORMANCE.md` says so explicitly and is the reason the
|
||||
per-operation numbers there matter more than these totals. And it is one laptop, one build
|
||||
type, one afternoon: treat the absolute numbers as that machine's and the ratios as the
|
||||
library's.
|
||||
|
||||
## What is not implemented
|
||||
|
||||
Named here rather than discovered later. Each gets a callout in the chapter where you would
|
||||
hit it, and an entry in `TODO.md`.
|
||||
|
||||
| Gap | Behaviour today | Chapter |
|
||||
|---|---|---|
|
||||
| Collision response | `akgl_physics_arcade_collide` raises `AKERR_API`, and `akgl_physics_simulate` never calls `collide` at all. An actor walks through a wall | [14](14-physics.md) |
|
||||
| Terminal velocity | Gravity accumulates into `ey` unbounded. `physics.drag.y` is the only brake | [14](14-physics.md) |
|
||||
| Friction / deceleration | Releasing a direction zeroes thrust and stops the actor dead. Right for Zelda, wrong for Mario | [14](14-physics.md) |
|
||||
| Savegames | `akgl_game_save` writes the name tables but not the objects, so a file is not yet enough to restore a session | [07](07-the-game-and-the-frame.md) |
|
||||
| Mesh drawing | `akgl_render_2d_draw_mesh` raises `AKERR_API`; the hook is reserved for a 3D backend | [08](08-rendering.md) |
|
||||
| A text cache | Every `akgl_text_rendertextat` rasterizes, uploads, blits and destroys | [16](16-text-and-fonts.md) |
|
||||
|
||||
## Who owns which documentation
|
||||
|
||||
**This manual documents libakgl. It does not re-document its dependencies.** Every project
|
||||
below is documented by the people who own its code, and a paraphrase here would be wrong the
|
||||
day one of them changes without anything in this repository noticing. So each chapter
|
||||
answers two questions — what does libakgl add or constrain here, and what does a libakgl
|
||||
caller actually write — and links out for the rest.
|
||||
|
||||
| Topic | Owned by | What this manual owes you |
|
||||
|---|---|---|
|
||||
| `ATTEMPT`/`CLEANUP`/`PROCESS`/`HANDLE`/`FINISH`, `PASS`, `CATCH`, `IGNORE` | libakerror (`deps/libakerror`) | Which statuses libakgl raises and what they mean here — [Chapter 4](04-errors.md) |
|
||||
| `aksl_strncpy`, `aksl_fclose`, `aksl_fgetc`, `aksl_snprintf` | libakstdlib (`deps/libakstdlib`) | Which ones libakgl requires you to use, and why |
|
||||
| `SDL_Renderer`, `SDL_Texture`, events, `SDL_PropertiesID` | [SDL3](https://wiki.libsdl.org/SDL3/) | The backend vtable, the frame contract, what libakgl does to the renderer's state |
|
||||
| Image decoding | [SDL3_image](https://wiki.libsdl.org/SDL3_image/) | Which formats reach a spritesheet, and when they are decoded |
|
||||
| Audio decoding, `MIX_Audio`, mixers and tracks | [SDL3_mixer](https://wiki.libsdl.org/SDL3_mixer/) | `akgl_load_start_bgm` and the track table — [Chapter 17](17-audio.md) |
|
||||
| TTF rasterizing and metrics | [SDL3_ttf](https://wiki.libsdl.org/SDL3_ttf/) | The font registry, the teardown ordering trap, the per-call cost — [Chapter 16](16-text-and-fonts.md) |
|
||||
| `json_t`, `json_decref`, the parser | [jansson](https://jansson.readthedocs.io/) | `akgl_get_json_*` status semantics and the borrowed-reference rule — [Chapter 18](18-utilities.md) |
|
||||
| The TMJ map format, layers, tilesets, custom properties | [Tiled](https://doc.mapeditor.org/en/stable/reference/json-map-format/) | libakgl's extensions and limits — [Chapter 13](13-tilemaps.md) |
|
||||
|
||||
The three-voice synthesizer in [Chapter 17](17-audio.md) is the one audio subsystem that is
|
||||
libakgl's own, and it is documented here in full. It has nothing to do with SDL3_mixer.
|
||||
|
||||
## Where the per-function reference lives
|
||||
|
||||
This manual is narrative. It teaches a task and links to the generated Doxygen for
|
||||
signatures, parameters and per-function `@throws` lists:
|
||||
|
||||
```sh norun
|
||||
doxygen Doxyfile
|
||||
```
|
||||
|
||||
Every header already carries a substantial `@file` block explaining its subsystem's design
|
||||
rationale, and `Doxyfile` sets `WARN_IF_UNDOCUMENTED = YES` with
|
||||
`WARN_AS_ERROR = FAIL_ON_WARNINGS`, so an undocumented symbol fails CI. The gap these
|
||||
chapters fill is navigation and worked examples, not reference text. **They deliberately do
|
||||
not restate the 156 signatures** — a hand-copied signature table is exactly the artifact
|
||||
that drifts, and it would compete with a reference that CI already keeps honest.
|
||||
|
||||
Where a chapter genuinely needs a declaration or a constant table in front of you, it uses
|
||||
an `excerpt=` block whose contents are checked against the header on every test run. The
|
||||
text you read *is* the header.
|
||||
|
||||
## Reading order
|
||||
|
||||
[Chapter 4](04-errors.md) comes before every subsystem chapter, because all 156 functions
|
||||
return `akerr_ErrorContext AKERR_NOIGNORE *` and you cannot read a single example until you
|
||||
can read that return value. After that, [Chapter 3](03-getting-started.md) gets a window on
|
||||
the screen, and the subsystem chapters can be read in any order.
|
||||
|
||||
If you would rather start by building something, the two tutorials —
|
||||
[Chapter 19](19-tutorial-sidescroller.md) and [Chapter 20](20-tutorial-jrpg.md) — are
|
||||
complete programs under `examples/`, built by default and smoke-run in CI.
|
||||
297
docs/02-design-philosophy.md
Normal file
297
docs/02-design-philosophy.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# 02. Design philosophy
|
||||
|
||||
Six decisions shape every API in this library. They are not preferences stated up front and
|
||||
then quietly abandoned in the implementation; each one is visible in the code, and this
|
||||
chapter shows the code that proves it.
|
||||
|
||||
None of this is an argument that you should build a game this way. It is an explanation of
|
||||
what libakgl will and will not do for you, so you can decide whether the tradeoffs are ones
|
||||
you want.
|
||||
|
||||
## Bounded, pre-declared resources — not dynamic ones
|
||||
|
||||
**libakgl does not call `malloc` at runtime.** Every runtime object comes out of a fixed,
|
||||
statically allocated array declared in `heap.h`. There are five such arrays — actors,
|
||||
sprites, spritesheets, characters and strings — and the ceilings are compile-time constants:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Allocation is a linear scan for a slot whose reference count is zero. That is the whole
|
||||
algorithm:
|
||||
|
||||
```c excerpt=src/heap.c
|
||||
akerr_ErrorContext *akgl_heap_next_actor(akgl_Actor **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
for (int i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
|
||||
if ( akgl_heap_actors[i].refcount != 0 ) {
|
||||
continue;
|
||||
}
|
||||
*dest = &akgl_heap_actors[i];
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKGL_ERR_HEAP, "Unable to find unused actor on the heap");
|
||||
}
|
||||
```
|
||||
|
||||
Two consequences follow, and both matter more than the absence of `malloc`.
|
||||
|
||||
**Exhaustion is a normal error, not a catastrophe.** `AKGL_ERR_HEAP` means "this pool is
|
||||
full". It is not `ENOMEM`, the process is not in trouble, and the fix is almost never a
|
||||
bigger pool — **it is usually a missing release.** Raising `AKGL_MAX_HEAP_*` before you have
|
||||
checked for a leak converts a bug that fails on frame 300 into one that fails on frame 3000.
|
||||
|
||||
**The pools are compile-time sized, so the ceiling is an ABI constraint.** The arrays live
|
||||
in the library, so libakgl and everything linking it must agree on the numbers. Overriding
|
||||
one means rebuilding the whole tree, not just your game.
|
||||
|
||||
The idiom for a pool object is claim, use, release in `CLEANUP`, which runs on every path
|
||||
out of the `ATTEMPT` block:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
/* Claim, use, release. The CLEANUP block runs on every path out of ATTEMPT. */
|
||||
akerr_ErrorContext *scratch_path(char *root, char *name)
|
||||
{
|
||||
akgl_String *buf = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_heap_next_string(&buf));
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_string_initialize(buf, name));
|
||||
CATCH(errctx, akgl_path_relative(root, buf->data, buf));
|
||||
SDL_Log("resolved to %s", buf->data);
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(buf));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
**If the pool has no layer for your type, add one.** A new kind of runtime object gets a new
|
||||
array, a `next`, and a `release`, in `heap.h` and `src/heap.c`. It does not get an
|
||||
allocator. [Chapter 5](05-the-heap.md) covers the layers, the reference-count asymmetry
|
||||
between `akgl_heap_next_string` and the other four, and what the ceilings cost you.
|
||||
|
||||
## Variation lives in a backend, not in a branch
|
||||
|
||||
There are exactly two pluggable subsystems, and both have the same shape: **a struct of
|
||||
function pointers, plus an initializer that populates it.**
|
||||
|
||||
```text
|
||||
akgl_RenderBackend akgl_PhysicsBackend
|
||||
+--------------------+ +--------------------+
|
||||
| sdl_renderer | | simulate |
|
||||
| shutdown | | gravity |
|
||||
| frame_start | | collide |
|
||||
| frame_end | | move |
|
||||
| draw_texture | | drag_x/y/z |
|
||||
| draw_mesh | | gravity_x/y/z |
|
||||
| draw_world | | max_timestep |
|
||||
+--------------------+ +--------------------+
|
||||
^ ^
|
||||
| populated by | populated by
|
||||
akgl_render_2d_bind akgl_physics_init_null
|
||||
akgl_render_2d_init akgl_physics_init_arcade
|
||||
(chosen by akgl_physics_factory)
|
||||
```
|
||||
|
||||
The shipped physics initializers are five assignments and nothing else:
|
||||
|
||||
```c excerpt=src/physics.c
|
||||
akerr_ErrorContext *akgl_physics_init_null(akgl_PhysicsBackend *self)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
|
||||
self->gravity = akgl_physics_null_gravity;
|
||||
self->collide = akgl_physics_null_collide;
|
||||
self->move = akgl_physics_null_move;
|
||||
self->simulate = akgl_physics_simulate;
|
||||
```
|
||||
|
||||
**To vary the behaviour, write an initializer and register it — do not add a conditional.**
|
||||
Nothing in `akgl_physics_simulate` knows which backend it is stepping, and nothing in the
|
||||
render path knows whether `draw_texture` came from the 2D backend or from yours. You can
|
||||
also mix: borrow the entry points that already do what you want and replace only the one
|
||||
that does not.
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/actor.h>
|
||||
|
||||
/* One tick of "everything floats up at a constant rate", as a backend. */
|
||||
static akerr_ErrorContext *floaty_gravity(akgl_PhysicsBackend *self, akgl_Actor *actor, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
FAIL_ZERO_RETURN(errctx, actor, AKERR_NULLPOINTER, "actor");
|
||||
actor->ey = -40.0f * dt;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *floaty_init(akgl_PhysicsBackend *self)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, self, AKERR_NULLPOINTER, "self");
|
||||
|
||||
/* Borrow the parts that already do the right thing. */
|
||||
self->simulate = akgl_physics_simulate;
|
||||
self->move = akgl_physics_arcade_move;
|
||||
self->collide = akgl_physics_null_collide;
|
||||
self->gravity = floaty_gravity;
|
||||
|
||||
self->gravity_time = SDL_GetTicksNS();
|
||||
self->max_timestep = AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
There is no inheritance, no RTTI and no dispatch on a type tag anywhere in the library.
|
||||
Behaviour attaches as a function pointer on a struct — which is also how an actor's own six
|
||||
behaviour hooks work, in [Chapter 12](12-actors.md).
|
||||
|
||||
## Bit flags are the default representation for state
|
||||
|
||||
Where another library would give you an enum and a setter per property, libakgl gives you a
|
||||
32-bit mask and four macros. An actor's state is the clearest case: it is a *set*, not a
|
||||
value, because an actor is alive and facing left and moving left all at once.
|
||||
|
||||
```c excerpt=include/akgl/actor.h
|
||||
#define AKGL_ACTOR_STATE_FACE_DOWN (1 << 0) // 1 0000 0000 0000 0001
|
||||
#define AKGL_ACTOR_STATE_FACE_LEFT (1 << 1) // 2 0000 0000 0000 0010
|
||||
#define AKGL_ACTOR_STATE_FACE_RIGHT (1 << 2) // 4 0000 0000 0000 0100
|
||||
#define AKGL_ACTOR_STATE_FACE_UP (1 << 3) // 8 0000 0000 0000 1000
|
||||
#define AKGL_ACTOR_STATE_ALIVE (1 << 4) // 16 0000 0000 0001 0000
|
||||
```
|
||||
|
||||
The iterator flags in `iterator.h` are the same idea for "what should this sweep do":
|
||||
|
||||
```c excerpt=include/akgl/iterator.h
|
||||
#define AKGL_ITERATOR_OP_UPDATE (1 << 0) // 1 Call the actor's updatefunc
|
||||
#define AKGL_ITERATOR_OP_RENDER (1 << 1) // 2 Call the actor's renderfunc
|
||||
#define AKGL_ITERATOR_OP_RELEASE (1 << 2) // 4 Release the object back to its heap layer
|
||||
#define AKGL_ITERATOR_OP_LAYERMASK (1 << 3) // 8 Skip anything whose layer != layerid
|
||||
#define AKGL_ITERATOR_OP_TILEMAPSCALE (1 << 4) // 16 Scale actors to the tilemap; otherwise force scale 1.0
|
||||
```
|
||||
|
||||
Both tables are hand-aligned, with the decimal value and the bit pattern in a comment per
|
||||
row, and both are protected from the reindent script's `tabify` step precisely so that
|
||||
alignment survives. The tables in the headers are the reference; these excerpts are checked
|
||||
against them.
|
||||
|
||||
**Use the macros, not the operators.** `AKGL_BITMASK_HAS(x, y)` is "every bit of `y` is set
|
||||
in `x`", not "any of them". It is fully parenthesized so that `!AKGL_BITMASK_HAS(a, b)`
|
||||
means what it reads as — until 0.5.0 it did not, and the negation bound to the `&`. Nothing
|
||||
in the tree negated it, which is the only reason that was latent rather than live.
|
||||
|
||||
## Errors carry context, and callers cannot ignore them
|
||||
|
||||
**Every one of libakgl's 156 functions returns `akerr_ErrorContext AKERR_NOIGNORE *`.**
|
||||
Results come back through pointer parameters; the return value is always the error. The
|
||||
`AKERR_NOIGNORE` attribute makes discarding it a compiler diagnostic.
|
||||
|
||||
That is a libakerror design and it is documented by libakerror.
|
||||
[Chapter 4](04-errors.md) covers the part that is genuinely libakgl's: which statuses these
|
||||
functions raise, what each one means *here*, and the traps libakgl's own structure creates.
|
||||
Read it before any subsystem chapter.
|
||||
|
||||
The reasoning behind the choice is operational rather than aesthetic. A game that fails on
|
||||
somebody else's machine leaves you a log file and nothing else, so an error has to say what
|
||||
failed, where, and why, at the point it happened — not three frames later at a `NULL`
|
||||
dereference with no history attached.
|
||||
|
||||
## Everything is referenced by name
|
||||
|
||||
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 for each state; a sprite
|
||||
names the sheet it cuts frames from. All of it is resolved at load time through eight
|
||||
registries:
|
||||
|
||||
```c excerpt=include/akgl/registry.h
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_ACTOR;
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_SPRITE;
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_SPRITESHEET;
|
||||
extern SDL_PropertiesID AKGL_REGISTRY_CHARACTER;
|
||||
```
|
||||
|
||||
The lookup itself is unremarkable, and that is the point:
|
||||
|
||||
```c excerpt=src/actor.c
|
||||
obj->basechar = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, basecharname, NULL);
|
||||
```
|
||||
|
||||
**This is what lets the whole asset graph be described in JSON files that reference each
|
||||
other by name**, with no build step and no code generation. It also means a typo in an asset
|
||||
file is a runtime failure rather than a compile error — which is the tradeoff, stated
|
||||
plainly. The registries are SDL property sets rather than an akgl type, so you can enumerate
|
||||
one with `SDL_EnumerateProperties`; that is exactly what `akgl_registry_iterate_actor` does.
|
||||
|
||||
Two sharp edges, both in [Chapter 6](06-the-registry.md): an uninitialized registry id is 0,
|
||||
which SDL treats as "no such property set" and silently drops writes to; and
|
||||
`AKGL_REGISTRY_SPRITESHEET` is keyed on the *resolved* image path, which is what makes two
|
||||
sprites naming the same PNG share one texture.
|
||||
|
||||
## One world at a time
|
||||
|
||||
The library keeps exactly one of everything, reachable through four globals:
|
||||
|
||||
```c excerpt=include/akgl/game.h
|
||||
extern akgl_Tilemap *akgl_gamemap;
|
||||
extern akgl_RenderBackend *akgl_renderer;
|
||||
extern akgl_PhysicsBackend *akgl_physics;
|
||||
extern SDL_FRect *akgl_camera;
|
||||
```
|
||||
|
||||
`akgl_game_init` points each of them at default storage — `akgl_default_renderer`,
|
||||
`akgl_default_physics`, `akgl_default_camera`, `akgl_default_gamemap`. **They are pointers
|
||||
so that you can substitute your own instance by reassigning one**, and the rest of the
|
||||
library goes on drawing and simulating through it without knowing.
|
||||
|
||||
That is the entire extent of the indirection. **There is no notion of two worlds at once.**
|
||||
No context handle is threaded through the API, no `akgl_World *` is passed to every call,
|
||||
and two simultaneous games in one process is not a thing this library supports. If you want
|
||||
a paused world behind a menu, you save the state you care about and swap the pointers back.
|
||||
|
||||
The globals all carry the `akgl_` prefix, and that is not cosmetic. Until 0.5.0 the library
|
||||
exported a bare `renderer`, `tests/character.c` defined an `SDL_Renderer *renderer` of its
|
||||
own, both had external linkage with the same spelling, and the executable's definition
|
||||
preempted the library's. Every texture load in that suite failed and the suite reported
|
||||
success anyway. A consuming game with a variable called `renderer` would have hit exactly
|
||||
the same thing, with no test to notice.
|
||||
|
||||
## What the six add up to
|
||||
|
||||
You get a library whose worst-case memory is knowable at compile time, whose failure modes
|
||||
arrive as messages instead of crashes, whose behaviour you extend by writing a function
|
||||
rather than by editing a switch, and whose assets are text files you can edit by hand.
|
||||
|
||||
You give up dynamic sizing, more than one world, and any ability to describe a game without
|
||||
writing C. Those are the tradeoffs. [Chapter 3](03-getting-started.md) puts a window on the
|
||||
screen.
|
||||
288
docs/03-getting-started.md
Normal file
288
docs/03-getting-started.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# 03. Getting started
|
||||
|
||||
This chapter gets libakgl into your build and a window on your screen. It assumes you have
|
||||
read [Chapter 4](04-errors.md), because the first program is written in the error protocol
|
||||
and will not make sense otherwise.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Dependency | Floor | Why libakgl needs it |
|
||||
|---|---|---|
|
||||
| SDL3 | 3.4.x (3.4.8 is vendored) | Window, renderer, events, properties, timing, mutexes |
|
||||
| SDL3_image | vendored | Decoding spritesheet and tileset images |
|
||||
| SDL3_mixer | vendored | `akgl_load_start_bgm`, the mixer device and track table |
|
||||
| SDL3_ttf | vendored | Font loading, rasterizing and metrics |
|
||||
| jansson | vendored | Parsing every JSON asset format |
|
||||
| libakerror | **>= 2.0.1, hard floor** | The error protocol. Part of libakgl's *public* interface |
|
||||
| libakstdlib | >= 0.2 | Checked wrappers over libc — `aksl_strncpy`, `aksl_fclose`, `aksl_snprintf` |
|
||||
| semver | vendored, in-tree | Savegame and version comparison |
|
||||
|
||||
All eight are git submodules under `deps/`, so a recursive clone gives you a build with no
|
||||
system packages at all.
|
||||
|
||||
**The libakerror floor is enforced, not advisory.** Two `#error` feature tests in
|
||||
`include/akgl/error.h` fire on a stale header:
|
||||
|
||||
```c excerpt=include/akgl/error.h
|
||||
#ifndef AKERR_EXIT_STATUS_UNREPRESENTABLE
|
||||
#error "libakgl requires libakerror >= 2.0.1: the akerror.h on the include path predates akerr_exit(). Rebuild and reinstall libakerror."
|
||||
#endif
|
||||
```
|
||||
|
||||
It is a hard floor because libakerror 2.0.0 made `akerr_next_error()` return a context that
|
||||
already holds its reference and moved `__akerr_last_ignored` into thread-local storage —
|
||||
and both of those expand at **your** call sites, through `IGNORE` and the `FAIL_*` macros,
|
||||
because `akerror.h` is part of libakgl's public interface. A tree that mixes headers and
|
||||
libraries across that line double-counts every reference and never returns a pool slot. The
|
||||
soname moved to `libakerror.so.2` to stop the library half happening by accident; the
|
||||
`#error` catches the header half in an install tree.
|
||||
|
||||
## Building libakgl itself
|
||||
|
||||
```sh norun
|
||||
git clone --recursive https://source.starfort.tech/andrew/libakgl.git
|
||||
cd libakgl
|
||||
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
cmake --build build --parallel
|
||||
ctest --test-dir build --output-on-failure
|
||||
```
|
||||
|
||||
If a dependency is already installed system-wide, libakgl finds it and skips the vendored
|
||||
copy. The rule is `if(NOT TARGET ...)` per dependency, so mixing is fine: a system SDL3 with
|
||||
a vendored jansson works.
|
||||
|
||||
## Route 1 — `add_subdirectory()`
|
||||
|
||||
This is what `akbasic` does, and it is the route with the fewest ways to go wrong: one set
|
||||
of compiler flags, one set of headers, nothing to install.
|
||||
|
||||
```cmake
|
||||
add_subdirectory(third_party/libakgl)
|
||||
|
||||
add_executable(mygame src/main.c)
|
||||
target_link_libraries(mygame PRIVATE akgl::akgl)
|
||||
```
|
||||
|
||||
`akgl::akgl` carries its own usage requirements, so the SDL3, libakerror and jansson include
|
||||
paths reach your target without you naming them.
|
||||
|
||||
**`AKGL_WERROR` is `OFF` by default, and leaving it off is the right thing for a consumer.**
|
||||
It is turned on only by two CI jobs in this repository. The reason is exactly your build: a
|
||||
target-level `-Werror` turns every diagnostic a newer compiler invents into a broken build
|
||||
for somebody else's project, and the warning set is not even stable across optimization
|
||||
levels — an `-O2` build reports ten `-Wstringop-truncation` warnings that `-O0` and
|
||||
`-fsyntax-only` do not. `-Wall` is on for every target this project owns, and the tree builds
|
||||
clean under it at every optimization level.
|
||||
|
||||
If you have already vendored SDL3 yourself, declare it before the `add_subdirectory` and
|
||||
libakgl will use yours rather than adding a second copy:
|
||||
|
||||
```cmake
|
||||
add_subdirectory(third_party/SDL) # declares SDL3::SDL3
|
||||
add_subdirectory(third_party/libakgl) # sees it and does not add its own
|
||||
```
|
||||
|
||||
## Route 2 — pkg-config
|
||||
|
||||
`make install` puts `akgl.pc` in `lib/pkgconfig/`. Here is what it contains, in full:
|
||||
|
||||
```text
|
||||
prefix=/usr/local
|
||||
exec_prefix=${prefix}
|
||||
libdir=${exec_prefix}/lib
|
||||
includedir=${exec_prefix}/include
|
||||
|
||||
Name: akgl
|
||||
Description: AKLabs Game library
|
||||
Version: 0.7.0
|
||||
Cflags: -I${includedir}/
|
||||
Libs: -L${libdir} -lakgl
|
||||
```
|
||||
|
||||
**`akgl.pc` names no dependencies at all.** There is no `Requires:` and no
|
||||
`Requires.private:` line, so `pkg-config --cflags --libs akgl` tells you nothing about
|
||||
libakerror, libakstdlib, SDL3, SDL3_image, SDL3_mixer, SDL3_ttf or jansson. That has always
|
||||
been wrong, and it is sharper than it looks: `akerror.h` is part of libakgl's *public*
|
||||
interface, so a consumer that builds against whatever `akerror.h` is on its include path and
|
||||
links whatever `libakerror.so` the loader finds can get exactly the mismatch pkg-config had
|
||||
every opportunity to prevent.
|
||||
|
||||
Until that is fixed, name them yourself. These are the modules whose headers libakgl's own
|
||||
public headers include:
|
||||
|
||||
```sh norun
|
||||
pkg-config --cflags --libs akgl akerror akstdlib sdl3 sdl3-ttf sdl3-mixer jansson
|
||||
```
|
||||
|
||||
`sdl3-image` is needed at link time but appears in no public header, so it belongs in the
|
||||
private half.
|
||||
|
||||
Recorded in `TODO.md` under "akgl.pc names no dependencies", along with why the fix wants
|
||||
its own commit: adding a `Requires:` line changes what `pkg-config --libs akgl` emits for
|
||||
every existing consumer.
|
||||
|
||||
**There is no CMake package config.** `install()` ships the library, the headers and
|
||||
`akgl.pc`, and nothing generates an `akglConfig.cmake`, so `find_package(akgl)` does not
|
||||
work against an install tree. Use `add_subdirectory()` or pkg-config.
|
||||
|
||||
## The startup order that actually works
|
||||
|
||||
Verified against `src/game.c`, not against the header comment:
|
||||
|
||||
```text
|
||||
1. fill in akgl_game.name, .version and .uri
|
||||
| akgl_game_init refuses to run without all three, and there are no
|
||||
| defaults -- the window title, SDL's app metadata and the savegame
|
||||
| compatibility check are all built from them
|
||||
v
|
||||
2. akgl_game_init()
|
||||
| error codes, version stamp, frame clock, state mutex, pools, the eight
|
||||
| registries, SDL app metadata, control maps, SDL_Init(VIDEO|GAMEPAD|
|
||||
| AUDIO), controller DB, gamepads, SDL_mixer, SDL_ttf, and finally the
|
||||
| four globals pointed at their default storage.
|
||||
|
|
||||
| What it does NOT do: create the window, choose a physics backend, or
|
||||
| load any configuration. Those read properties, which you have not set
|
||||
| yet.
|
||||
v
|
||||
3. akgl_registry_load_properties() or akgl_set_property()
|
||||
| screen size, physics constants, anything else
|
||||
v
|
||||
4. akgl_render_2d_init(akgl_renderer) <- creates the window; reads
|
||||
| game.screenwidth/screenheight
|
||||
| akgl_physics_factory(akgl_physics, name) <- "null" or "arcade"
|
||||
v
|
||||
5. load assets, then loop on akgl_game_update()
|
||||
```
|
||||
|
||||
**`akgl_game_init` does not choose a physics backend for you.** `physics.h` says the
|
||||
`physics.engine` property drives `akgl_physics_factory`; it does not. That string appears
|
||||
nowhere in `src/`, and `akgl_game_init` never calls the factory. You call it yourself, with
|
||||
an `akgl_String` holding `"null"` or `"arcade"`. The header comment is wrong and is left
|
||||
alone here deliberately — this manual documents what the code does, and correcting header
|
||||
prose is a separate commit.
|
||||
|
||||
## The first window
|
||||
|
||||
Sixty frames of an empty world, then out. It compiles under
|
||||
`-std=gnu99 -Wall -Werror` and runs headless.
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akgl/text.h>
|
||||
|
||||
int main(void)
|
||||
{
|
||||
akgl_String *engine = NULL;
|
||||
int frame = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
/* 1. akgl_game_init refuses to run without all three of these. */
|
||||
CATCH(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name),
|
||||
"firstwindow", sizeof(akgl_game.name) - 1));
|
||||
CATCH(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version),
|
||||
"0.1.0", sizeof(akgl_game.version) - 1));
|
||||
CATCH(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri),
|
||||
"net.example.firstwindow", sizeof(akgl_game.uri) - 1));
|
||||
|
||||
/* 2. SDL, the pools, the registries, audio, fonts, gamepads. */
|
||||
CATCH(errctx, akgl_game_init());
|
||||
|
||||
/* 3. Configuration. akgl_render_2d_init reads these two. */
|
||||
CATCH(errctx, akgl_set_property("game.screenwidth", "640"));
|
||||
CATCH(errctx, akgl_set_property("game.screenheight", "480"));
|
||||
|
||||
/* 4. The window and the renderer, then a physics backend. */
|
||||
CATCH(errctx, akgl_render_2d_init(akgl_renderer));
|
||||
CATCH(errctx, akgl_heap_next_string(&engine));
|
||||
CATCH(errctx, akgl_string_initialize(engine, "null"));
|
||||
CATCH(errctx, akgl_physics_factory(akgl_physics, engine));
|
||||
|
||||
/* 5. Load assets, then loop. */
|
||||
for ( frame = 0; frame < 60; frame++ ) {
|
||||
CATCH(errctx, akgl_game_update(NULL));
|
||||
}
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(engine));
|
||||
IGNORE(akgl_text_unloadallfonts());
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
|
||||
SDL_Quit();
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Five things in there are worth pointing at.
|
||||
|
||||
**`aksl_strncpy`, not `strncpy`.** Every name in this library is a registry key, handed to
|
||||
`strcmp`, `realpath` and SDL property calls, none of which stop at the end of the field.
|
||||
`strncpy(dest, src, sizeof(dest))` leaves `dest` unterminated whenever the source fills it.
|
||||
Bound `n` at `sizeof(dest) - 1` and let `aksl_strncpy` raise `AKERR_OUTOFBOUNDS` if the bytes
|
||||
do not fit.
|
||||
|
||||
**`FINISH_NORETURN`, not `FINISH`, in `main`.** `FINISH` ends by returning the error context
|
||||
from the enclosing function, and `main` returns `int`. With `FINISH_NORETURN` and no
|
||||
`HANDLE` block, an unhandled error goes to libakerror's default handler, which logs it and
|
||||
calls `akerr_exit()` — so `SDL_Quit()` and `return 0` are reached only on the success path.
|
||||
|
||||
**Never `exit(3)` an akerr status.** An exit status is one byte wide and libakgl's band
|
||||
starts at 256, so `exit(AKGL_ERR_SDL)` is a wait status of 0 and the shell sees a clean run.
|
||||
`akerr_exit()` maps 0 to 0, 1–255 to themselves, and anything else to
|
||||
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125). See [Chapter 4](04-errors.md), Table 3.
|
||||
|
||||
**`akgl_text_unloadallfonts()` before `SDL_Quit()`.** `SDL_Quit` destroys the property
|
||||
registry the fonts live in and takes the last reference to every one of them with it. This
|
||||
program loads no fonts, and the call is in the shutdown anyway because that is where it
|
||||
belongs the moment one is loaded — [Chapter 16](16-text-and-fonts.md).
|
||||
|
||||
**Sixty `akgl_game_update` calls log `Low FPS! 0` sixty times.** `akgl_game.fps` is a
|
||||
completed-second average, so it reads 0 for the first second of the process, which is under
|
||||
the low-FPS threshold. `akgl_game.lowfpsfunc` is a hook for exactly this: point it at
|
||||
something that sheds work, or at nothing.
|
||||
|
||||
## Running headless
|
||||
|
||||
On a server, in CI, or under valgrind, force the dummy drivers:
|
||||
|
||||
```sh norun
|
||||
SDL_VIDEO_DRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIO_DRIVER=dummy ./mygame
|
||||
```
|
||||
|
||||
These are the SDL3 spellings, and the ones `scripts/memcheck.sh` exports. SDL3 also still
|
||||
honours SDL2's `SDL_VIDEODRIVER` and `SDL_AUDIODRIVER` as aliases — `SDL_RENDER_DRIVER` was
|
||||
never spelled any other way. Without them, `akgl_game_init` fails at `SDL_Init` with
|
||||
`AKGL_ERR_SDL` and the message `Couldn't initialize SDL: No available video device`.
|
||||
|
||||
A program can set the same thing from inside itself with `SDL_SetHint` before
|
||||
`akgl_game_init`, which is what most of the test suites in `tests/` do:
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
|
||||
void go_headless(void)
|
||||
{
|
||||
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
|
||||
}
|
||||
```
|
||||
|
||||
The software renderer is a real rasterizer, not a stub — everything draws, nothing is
|
||||
displayed, and `SDL_RenderReadPixels` gives you the frame back. That is how the image
|
||||
assertions in `tests/` work.
|
||||
|
||||
## Where to go next
|
||||
|
||||
[Chapter 5](05-the-heap.md) for the pools you just claimed a string from,
|
||||
[Chapter 7](07-the-game-and-the-frame.md) for what `akgl_game_update` actually does, or
|
||||
[Chapter 19](19-tutorial-sidescroller.md) to start building a game.
|
||||
269
docs/04-errors.md
Normal file
269
docs/04-errors.md
Normal file
@@ -0,0 +1,269 @@
|
||||
# 04. Errors and status codes
|
||||
|
||||
Every one of libakgl's exported functions returns `akerr_ErrorContext AKERR_NOIGNORE *`.
|
||||
You cannot read a single example in this manual until you can read that return value, which
|
||||
is why this chapter comes before any subsystem.
|
||||
|
||||
## The protocol is libakerror's
|
||||
|
||||
`ATTEMPT` / `CLEANUP` / `PROCESS` / `HANDLE` / `HANDLE_GROUP` / `FINISH`, and the `PASS`,
|
||||
`CATCH`, `IGNORE`, `FAIL_*` and `SUCCEED_RETURN` macros, all belong to **libakerror**.
|
||||
They are documented by the project that owns them, in `deps/libakerror/README.md` —
|
||||
"Library Architecture", "Lifecycle of an error in the AKError library", and "Exit status".
|
||||
This manual does not restate them, because a copy here would be wrong the day libakerror
|
||||
changes and nothing in this repository would notice.
|
||||
|
||||
What is genuinely libakgl's, and is written down nowhere else, is **which statuses these
|
||||
156 functions raise and what each one means here**. That is the three tables below.
|
||||
|
||||
The house rules for *writing* code against the protocol — never a `*_RETURN` inside an
|
||||
`ATTEMPT`, never a bare `return` out of a `HANDLE`, `CLEANUP` before `PROCESS`, an
|
||||
`ATTEMPT` inside the loop rather than a `CATCH` inside one — are in `AGENTS.md` under
|
||||
"Error-Handling Protocol", and two of them are enforced by the `error_protocol` test.
|
||||
|
||||
## Table 1 — libakgl's own status codes
|
||||
|
||||
libakerror reserves statuses 0–255 for the host's `errno` values and its own `AKERR_*`
|
||||
codes; consumers allocate upward from `AKERR_FIRST_CONSUMER_STATUS`, which is **256**.
|
||||
libakgl claims a band of five starting there, under the owner string `"libakgl"`:
|
||||
|
||||
```c excerpt=include/akgl/error.h
|
||||
#define AKGL_ERR_OWNER "libakgl"
|
||||
#define AKGL_ERR_BASE AKERR_FIRST_CONSUMER_STATUS
|
||||
|
||||
#define AKGL_ERR_SDL (AKGL_ERR_BASE + 0) /**< An SDL call failed; the message carries SDL_GetError() */
|
||||
#define AKGL_ERR_REGISTRY (AKGL_ERR_BASE + 1) /**< A registry property or lookup operation failed */
|
||||
#define AKGL_ERR_HEAP (AKGL_ERR_BASE + 2) /**< A heap pool has no free object left to hand out */
|
||||
#define AKGL_ERR_BEHAVIOR (AKGL_ERR_BASE + 3) /**< A component did not behave the way its contract requires */
|
||||
#define AKGL_ERR_LOGICINTERRUPT (AKGL_ERR_BASE + 4) /**< Actor logic is telling the physics simulator to skip it */
|
||||
```
|
||||
|
||||
**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 5`, one past the last
|
||||
one, and exists only so `AKGL_ERR_COUNT` can be computed from it. Nothing in `src/` raises
|
||||
it and `akgl_error_init` does not name it. Do not write a `HANDLE(e, AKGL_ERR_LIMIT)`
|
||||
arm — it would catch nothing, and if a sixth real code is ever added it would silently
|
||||
start catching that instead.
|
||||
|
||||
| Code | Value | Means | Raised by | What the caller does |
|
||||
|---|---|---|---|---|
|
||||
| `AKGL_ERR_SDL` | 256 | An SDL call failed; the message carries `SDL_GetError()` | Around forty sites — anything touching a window, texture, renderer, mixer or the state mutex | Usually fatal at startup. Check the driver and the asset path |
|
||||
| `AKGL_ERR_REGISTRY` | 257 | A name lookup against a registry failed | **One site only**: `akgl_controller_default`, when `actorname` is not in `AKGL_REGISTRY_ACTOR` | Bind the control map *after* the actor is initialized |
|
||||
| `AKGL_ERR_HEAP` | 258 | A pool has no free slot | every `akgl_heap_next_*` | **Normally a missing release, not a small pool.** See [Chapter 5](05-the-heap.md) before raising `AKGL_MAX_HEAP_*` |
|
||||
| `AKGL_ERR_BEHAVIOR` | 259 | A component did not behave the way its contract requires | **Nothing in the library.** Only `tests/` raises it, through `testutil.h` | Available to you for the same purpose: asserting a contract in your own code |
|
||||
| `AKGL_ERR_LOGICINTERRUPT` | 260 | **Not a failure — a control signal.** "Skip the rest of this step for this actor" | your own `movementlogicfunc` | Raise it deliberately. See the note below |
|
||||
|
||||
`akgl_error_init` reserves the band all-or-nothing and registers a name for each of the
|
||||
five: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`,
|
||||
`"Logic Interrupt"`. Those names are what a stack trace prints.
|
||||
|
||||
Two rows deserve more than a cell.
|
||||
|
||||
**`AKGL_ERR_REGISTRY` is rarer than its name suggests.** The obvious candidates do not
|
||||
raise it. `akgl_actor_initialize` raises `AKERR_KEY` when the registry write fails, and
|
||||
`akgl_actor_set_character` raises `AKERR_NULLPOINTER` when the named character is not in
|
||||
`AKGL_REGISTRY_CHARACTER`. If you are writing a `HANDLE` arm for "that name was not
|
||||
registered", `AKERR_NULLPOINTER` and `AKERR_KEY` are the statuses you will actually see.
|
||||
|
||||
**`AKGL_ERR_LOGICINTERRUPT` only works from one place.** `akgl_physics_simulate` wraps each
|
||||
actor's step in an `ATTEMPT` and calls `actor->movementlogicfunc` through `CATCH`, with a
|
||||
`HANDLE(errctx, AKGL_ERR_LOGICINTERRUPT)` arm that does nothing — so a raise from your
|
||||
movement logic skips that actor's gravity, drag and move for this step and the loop carries
|
||||
on to the next actor. The backend's own `gravity` and `move` are called through `PASS` in
|
||||
the same block, and `PASS` returns out of the function. **A backend hook that raises
|
||||
`AKGL_ERR_LOGICINTERRUPT` aborts the whole physics step**, leaving every remaining actor
|
||||
unsimulated and `gravity_time` unadvanced. Raise it from `movementlogicfunc` and nowhere
|
||||
else.
|
||||
|
||||
## Table 2 — libakerror statuses libakgl raises, and what they mean here
|
||||
|
||||
The statuses are libakerror's; their libakgl meaning is not. This table was built by
|
||||
grepping every `FAIL_*` and `HANDLE*` site in `src/`, not by reading header prose.
|
||||
|
||||
| Status | What it means when a libakgl function raises it |
|
||||
|---|---|
|
||||
| `AKERR_NULLPOINTER` | By a wide margin the most common — roughly six of every seven raise sites in `src/`. A required pointer argument was `NULL` — *or* a required field was empty (`akgl_game.name`/`.version`/`.uri`), *or* a name was looked up and not found. `akgl_actor_set_character` and `akgl_get_property` both use it for "absent", and `registry_create` uses it for "SDL could not allocate the property set" |
|
||||
| `AKERR_KEY` | **A key is absent, or a registry write was refused.** A missing JSON object member (every `akgl_get_json_*_value`), a character with no sprite for a state, a physics backend name that is neither `null` nor `arcade`, a failed `SDL_SetPointerProperty` in `akgl_actor_initialize`. Frequently a `HANDLE` arm rather than a failure |
|
||||
| `AKERR_TYPE` | **A JSON value was present but the wrong type.** All of `src/json_helpers.c` and `akgl_get_json_tilemap_property`. This is the status that separates "you did not write that key" (`AKERR_KEY`) from "you wrote it as a string and it wants a number" |
|
||||
| `AKERR_OUTOFBOUNDS` | A value did not fit its destination, or an index ran off the end. `aksl_strncpy` truncation, an array index past the end of a JSON array (`akgl_get_json_array_index_*`), a ninth child on an actor, a tile past `AKGL_TILEMAP_MAX_*`, a control map id out of range |
|
||||
| `AKERR_VALUE` | A value parsed but was not usable: a version string that is not semver (`akgl_game_load_versioncmp`), two surfaces that differ (`akgl_compare_sdl_surfaces`), a zero-sized destination or an overlapping copy from the `aksl_` wrappers |
|
||||
| `AKERR_RELATIONSHIP` | **One site**: `akgl_actor_add_child`, when the child already has a parent. Detach it first |
|
||||
| `AKERR_API` | Two distinct meanings, so read the message. (1) **The function is not implemented** — `akgl_physics_arcade_collide` and `akgl_render_2d_draw_mesh` both `FAIL_RETURN(..., AKERR_API, "Not implemented")`, and both are reached by ordinary-looking calls. (2) **A savegame does not match this build** — `akgl_game_load` and `akgl_game_load_versioncmp` |
|
||||
| `AKERR_IO` | A read or write failed, or a savegame had trailing data after its name tables. Distinct from `AKERR_EOF` — that separation is the whole reason `aksl_fgetc` exists |
|
||||
| `AKERR_EOF` | End of input. Sometimes the *desired* outcome: `require_at_eof` in `src/game.c` handles it and treats anything else as a failure |
|
||||
| `AKERR_INDEX` | **libakgl never raises this.** It appears once, as a `HANDLE_GROUP` arm in `akgl_get_json_with_default`, so that a caller whose *own* code raises it still gets a default. An out-of-range JSON array index arrives as `AKERR_OUTOFBOUNDS` |
|
||||
|
||||
Statuses raised by the libraries underneath also reach you unchanged. `aksl_fopen` reports
|
||||
`ENOENT` and `EACCES` as themselves; `aksl_fclose` reports `ENOSPC` and `EDQUOT`.
|
||||
`akgl_error_init` can raise libakerror's `AKERR_STATUS_RANGE_OVERLAP`,
|
||||
`AKERR_STATUS_RANGE_FULL` or `AKERR_STATUS_NAME_FULL`.
|
||||
|
||||
[Chapter 21](21-appendix-limits.md) has the per-function cross-reference.
|
||||
|
||||
## Table 3 — the exit status trap
|
||||
|
||||
This is arithmetic, and it is silent.
|
||||
|
||||
| You write | Wait status the shell sees | Why |
|
||||
|---|---|---|
|
||||
| `exit(AKGL_ERR_SDL)` | **0 — a clean run** | An exit status is one byte. `AKGL_ERR_SDL` is 256, and 256 & 0xFF is 0 |
|
||||
| `akerr_exit(status)` | 0 → 0, 1–255 → itself, anything else → 125 | 125 is `AKERR_EXIT_STATUS_UNREPRESENTABLE` |
|
||||
|
||||
Every suite in `tests/` once reported success on the single most common failure a library
|
||||
built on SDL can have — a renderer that would not come up. `tests/character.c` aborted at
|
||||
its second of four tests and was green until 0.5.0.
|
||||
|
||||
**You do not normally need to do anything about this.** libakerror's default unhandled-error
|
||||
handler already calls `akerr_exit()`, so a `main` that ends in `FINISH_NORETURN` exits
|
||||
correctly. The trap is only live if you replace libakerror's
|
||||
`akerr_handler_unhandled_error` pointer with your own handler, or call `exit(3)` on a
|
||||
status yourself. If you do either, call `akerr_exit()`.
|
||||
|
||||
## Four traps that are libakgl's, not libakerror's
|
||||
|
||||
### `akgl_error_init()` must run before anything that can raise
|
||||
|
||||
`akgl_error_init` claims the status band *and* registers the five names. A code raised
|
||||
before it runs has no name in the registry, so every stack trace carrying it prints
|
||||
**"Unknown Error"** instead of "Heap Error" — and the status number, 258, means nothing to
|
||||
a reader.
|
||||
|
||||
`akgl_game_init` calls it first, before anything else that can fail — that is why the
|
||||
mutex creation and the `akgl_game.name` checks sit *after* it. A host with its own startup
|
||||
path, such as one binding an existing renderer with `akgl_render_2d_bind` instead of
|
||||
calling `akgl_game_init`, must call `akgl_error_init` itself. Repeat calls are a no-op, so
|
||||
a program that cannot order its initialization precisely may simply call it more than once.
|
||||
|
||||
### The SDL callbacks end in `FINISH_NORETURN`, and that exits the process
|
||||
|
||||
`SDL_EnumerateProperties` takes a `void`-returning callback, so a failure inside one has
|
||||
nowhere to go. All six of libakgl's therefore end in `FINISH_NORETURN`, which logs the
|
||||
trace and hands the context to libakerror's unhandled-error handler — **and the default
|
||||
handler exits the process.** They are `akgl_registry_iterate_actor` (`src/actor.c`),
|
||||
`akgl_character_state_sprites_iterate` (`src/character.c`), and the four savegame name
|
||||
iterators in `src/game.c`.
|
||||
|
||||
A reader meets this the first time a sprite name is wrong: an actor's `renderfunc` raises
|
||||
`AKERR_KEY`, the callback cannot return it, and the game exits mid-frame with a stack trace
|
||||
instead of returning an error you could have handled.
|
||||
|
||||
Two consequences worth planning for:
|
||||
|
||||
- A write error part-way through `akgl_game_save` terminates the game rather than reaching
|
||||
`akgl_game_save`'s return value. `game.h` says so on `akgl_game_save_actors`.
|
||||
- If you want a different outcome, point libakerror's `akerr_handler_unhandled_error`
|
||||
function pointer at your own handler — and call `akerr_exit()` from it, per Table 3.
|
||||
|
||||
Note that `akgl_game_update` does **not** go through the callback. It walks
|
||||
`akgl_heap_actors` directly and propagates through `PASS`, so an error from an actor's
|
||||
`updatefunc` does reach you. The callback path is the render sweep and the savegame writer.
|
||||
|
||||
### `akgl_get_json_with_default` hands back the context you gave it
|
||||
|
||||
This is the one libakgl function whose ownership rule you have to know before you write
|
||||
your first loader. It takes an incoming `akerr_ErrorContext *`, and when it does not handle
|
||||
that status it returns **the same pointer**, because its `FINISH(e, true)` passes `e` up.
|
||||
|
||||
So the context has exactly one owner at a time, and it is never both yours and its. A
|
||||
`CLEANUP` block that releases your original pointer *as well as* releasing what the call
|
||||
returned releases the same slot twice, and a double-released context corrupts the failure
|
||||
instead of reporting it. That is not hypothetical: it is how the first draft of the
|
||||
`AKERR_OUTOFBOUNDS` test in `tests/json_helpers.c` passed against the unfixed library.
|
||||
`AGENTS.md` documents the shape under "Testing Guidelines"; `TODO.md` item 18 records the
|
||||
history.
|
||||
|
||||
The correct shape: take the result into a local, `NULL` your own pointer immediately, and
|
||||
decide from the local.
|
||||
|
||||
It defaults on three statuses — `AKERR_KEY`, `AKERR_OUTOFBOUNDS` and `AKERR_INDEX` — so
|
||||
both a missing object member and a short array give you the default. It does **not** default
|
||||
on `AKERR_TYPE`: a key that is present with the wrong type is a mistake in the document, not
|
||||
an absent value, and it propagates.
|
||||
|
||||
### libakerror 2.0.1 is a hard floor, checked at compile time
|
||||
|
||||
`include/akgl/error.h` carries two `#error` feature tests, because libakerror publishes no
|
||||
version macro and each probes the narrowest thing that a required release introduced:
|
||||
|
||||
- `AKERR_FIRST_CONSUMER_STATUS` — added in 1.0.0 with the private status registry. Without
|
||||
it, `AKGL_ERR_BASE` does not exist and the failure lands much later, inside `src/heap.c`.
|
||||
- `AKERR_EXIT_STATUS_UNREPRESENTABLE` — added in 2.0.1 with `akerr_exit()`.
|
||||
|
||||
Both failures are an *installed header* being stale, not a build-tree problem. 2.0.0 is an
|
||||
ABI break — `akerr_next_error()` returns a context that already holds its reference, and
|
||||
`__akerr_last_ignored` is thread-local — and both expand at libakgl's own call sites through
|
||||
`IGNORE` and the `FAIL_*` macros. The soname is `libakerror.so.2`, so the *libraries* cannot
|
||||
be mixed by accident; the header can. Rebuild and reinstall libakerror.
|
||||
|
||||
## One worked example
|
||||
|
||||
A loader reading an optional integer out of a JSON document. It is the smallest piece of
|
||||
real libakgl code that exercises both halves of the chapter: a status that means "absent
|
||||
and that is fine", and the ownership rule on the function that turns it into a default.
|
||||
|
||||
```c
|
||||
#include <jansson.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/json_helpers.h>
|
||||
|
||||
/* Read an optional integer out of a loaded document, defaulting to 4. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *read_speed(json_t *doc, int *dest)
|
||||
{
|
||||
akerr_ErrorContext *keyerr = NULL;
|
||||
akerr_ErrorContext *unhandled = NULL;
|
||||
int def = 4;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, doc, AKERR_NULLPOINTER, "NULL document");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
|
||||
|
||||
keyerr = akgl_get_json_integer_value(doc, "speed", dest);
|
||||
unhandled = akgl_get_json_with_default(keyerr, &def, dest, sizeof(int));
|
||||
keyerr = NULL;
|
||||
if ( unhandled != NULL ) {
|
||||
return unhandled;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
json_t *doc = NULL;
|
||||
int speed = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_error_init());
|
||||
doc = json_pack("{s:i}", "width", 32);
|
||||
CATCH(errctx, read_speed(doc, &speed));
|
||||
} CLEANUP {
|
||||
if ( doc != NULL ) {
|
||||
json_decref(doc);
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Four things in there are libakgl-specific and worth naming:
|
||||
|
||||
1. **`akgl_error_init()` is the first call**, before anything that can raise.
|
||||
2. **`keyerr = NULL;` on the line after the call.** Ownership passed either way — handled
|
||||
or not — and the local is the only surviving reference. Nothing else in the function may
|
||||
touch `keyerr` again.
|
||||
3. **A `speed` key that is absent is not an error.** `akgl_get_json_integer_value` raises
|
||||
`AKERR_KEY`, `akgl_get_json_with_default` handles it and writes 4. A `speed` key that is
|
||||
present as a string *is* an error: `AKERR_TYPE` is not defaulted and comes back as
|
||||
`unhandled`.
|
||||
4. **`main` ends in `FINISH_NORETURN`.** That is what gets you `akerr_exit()` and a
|
||||
truthful exit status without writing the trap from Table 3.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Chapter 5](05-the-heap.md) — `AKGL_ERR_HEAP`, and why it is almost always a missing
|
||||
release.
|
||||
- [Chapter 6](06-the-registry.md) — the name lookups behind `AKERR_KEY` and
|
||||
`AKERR_NULLPOINTER`.
|
||||
- [Chapter 21](21-appendix-limits.md) — which function raises what.
|
||||
- `deps/libakerror/README.md` — the protocol itself, and the exit-status discussion.
|
||||
- The generated Doxygen reference — every function's own `@throws` list.
|
||||
347
docs/05-the-heap.md
Normal file
347
docs/05-the-heap.md
Normal file
@@ -0,0 +1,347 @@
|
||||
# 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 five 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.** Four of the five 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 five 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 21](21-appendix-limits.md) — every `AKGL_MAX_*` in one place.
|
||||
228
docs/06-the-registry.md
Normal file
228
docs/06-the-registry.md
Normal file
@@ -0,0 +1,228 @@
|
||||
# 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
|
||||
24–26). 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_*`.
|
||||
370
docs/07-the-game-and-the-frame.md
Normal file
370
docs/07-the-game-and-the-frame.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# 07. The game and the frame
|
||||
|
||||
`game.h` is the top of the library. `akgl_game_init` brings up SDL, the pools, the
|
||||
registries and the audio and font engines; `akgl_game_update` is one frame — update every
|
||||
actor, step the physics, draw the world.
|
||||
|
||||
**There is exactly one of everything.** `akgl_renderer`, `akgl_physics`, `akgl_camera` and
|
||||
`akgl_gamemap` are globals pointing at `akgl_default_*` storage, so a program can swap in
|
||||
its own instance by reassigning the pointer without the rest of the library knowing. That
|
||||
is the whole extent of the indirection: there is no notion of two worlds at once.
|
||||
|
||||
## The startup order
|
||||
|
||||
`game.h` documents a five-step sequence, and it holds up against `src/game.c`:
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/renderer.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
/* The startup order, in the order it has to happen. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *startup(void)
|
||||
{
|
||||
akgl_String *engine = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
/* 1. The three required fields. akgl_game_init refuses to run without them. */
|
||||
PASS(errctx, aksl_strncpy(akgl_game.name, sizeof(akgl_game.name),
|
||||
"manual-demo", sizeof(akgl_game.name) - 1));
|
||||
PASS(errctx, aksl_strncpy(akgl_game.version, sizeof(akgl_game.version),
|
||||
"1.0.0", sizeof(akgl_game.version) - 1));
|
||||
PASS(errctx, aksl_strncpy(akgl_game.uri, sizeof(akgl_game.uri),
|
||||
"tech.starfort.manual-demo", sizeof(akgl_game.uri) - 1));
|
||||
|
||||
/* 2. Bring the library up: errors, pools, registries, SDL, audio, fonts. */
|
||||
PASS(errctx, akgl_game_init());
|
||||
|
||||
/* 3. Configure, before anything reads the configuration. */
|
||||
PASS(errctx, akgl_set_property("game.screenwidth", "800"));
|
||||
PASS(errctx, akgl_set_property("game.screenheight", "600"));
|
||||
PASS(errctx, akgl_set_property("physics.gravity.y", "400.0"));
|
||||
|
||||
/* 4. The two subsystems that read it. This is where the window appears. */
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_string(&engine));
|
||||
CATCH(errctx, akgl_string_initialize(engine, "arcade"));
|
||||
CATCH(errctx, akgl_render_2d_init(akgl_renderer));
|
||||
CATCH(errctx, akgl_physics_factory(akgl_physics, engine));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(engine));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
/* 5. Load assets, then loop on akgl_game_update. */
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
**The three required fields have no defaults.** `akgl_game.name`, `.version` and `.uri` are
|
||||
each checked for a non-zero length and each raises `AKERR_NULLPOINTER` if empty. All three
|
||||
are load-bearing: `.uri` becomes the window title, all three become SDL's app metadata, and
|
||||
`.name`, `.uri` and `.version` are compared on savegame load. `.version` must be a valid
|
||||
semver string, or `akgl_game_load` fails later rather than here.
|
||||
|
||||
**Steps 3 and 4 cannot be swapped.** `akgl_render_2d_init` and `akgl_physics_init_arcade`
|
||||
each read their configuration once, at init, and never look again. Setting
|
||||
`physics.gravity.y` after the factory call changes nothing. See
|
||||
[Chapter 6](06-the-registry.md).
|
||||
|
||||
### What `akgl_game_init` does, in order
|
||||
|
||||
Verified against `src/game.c`. The order matters in three places, marked:
|
||||
|
||||
1. **`akgl_error_init()` — first, before anything that can raise.** Everything below reports
|
||||
through `AKGL_ERR_*` codes, and a code raised before its name is registered prints as
|
||||
"Unknown Error". See [Chapter 4](04-errors.md).
|
||||
2. Stamp `akgl_game.libversion` from `AKGL_VERSION`.
|
||||
3. Seed the frame clock: `gameStartTime`, `lastIterTime` and `lastFPSTime` all get
|
||||
`SDL_GetTicksNS()`.
|
||||
4. Install `akgl_game_lowfps` as `lowfpsfunc`.
|
||||
5. Create the state mutex. Failure is `AKGL_ERR_SDL`.
|
||||
6. **Take the state lock.** Everything from here to the end runs holding it.
|
||||
7. Check `name`, `version` and `uri` are non-empty.
|
||||
8. `akgl_heap_init()` — zero all five pools.
|
||||
9. The eight registry initializers: actor, sprite, spritesheet, character, font, music,
|
||||
properties, actor-state-strings. **Note that `akgl_registry_init` is not called** —
|
||||
`akgl_game_init` calls the eight individually. See [Chapter 6](06-the-registry.md).
|
||||
10. `SDL_SetAppMetadata` from the three fields.
|
||||
11. Zero all `AKGL_MAX_CONTROL_MAPS` control maps.
|
||||
12. `SDL_Init(SDL_INIT_VIDEO | SDL_INIT_GAMEPAD | SDL_INIT_AUDIO)`.
|
||||
13. Feed the bundled controller database to `SDL_AddGamepadMapping`, one entry at a time.
|
||||
A rejected entry is `AKGL_ERR_SDL` and aborts startup.
|
||||
14. `akgl_controller_open_gamepads()`.
|
||||
15. `MIX_Init()` and `MIX_CreateMixerDevice()` on the default playback device.
|
||||
16. `TTF_Init()`.
|
||||
17. Point `akgl_camera`, `akgl_renderer`, `akgl_physics` and `akgl_gamemap` at their default
|
||||
storage.
|
||||
18. Release the state lock.
|
||||
|
||||
**What it does not do: create the window, choose a physics backend, or load any
|
||||
configuration.** Those three read properties, so they belong to the caller, after step 3
|
||||
above.
|
||||
|
||||
**A failure anywhere from step 6 onward returns with the state mutex held.** `game.h` says
|
||||
so. On a single-threaded program that is survivable — SDL mutexes are recursive, so the
|
||||
same thread can take it again — but the mutex is never made available to another thread, so
|
||||
anything else that calls `akgl_game_state_lock` will spend a second retrying and then fail.
|
||||
A failed `akgl_game_init` is not something to retry; report it and exit.
|
||||
|
||||
## One frame
|
||||
|
||||
`akgl_game_update(opflags)` is the whole frame, and it is short enough to state exactly:
|
||||
|
||||
```text
|
||||
akgl_game_state_lock() <- retries, does not block
|
||||
akgl_game_update_fps() <- counts the frame, maybe fires lowfpsfunc
|
||||
for each slot in akgl_heap_actors:
|
||||
skip if refcount == 0
|
||||
skip if OP_LAYERMASK and actor->layer != opflags->layerid
|
||||
OP_TILEMAPSCALE ? akgl_tilemap_scale_actor(...) : actor->scale = 1.0
|
||||
actor->updatefunc(actor) <- exactly once per actor
|
||||
akgl_physics->simulate(akgl_physics, NULL)
|
||||
akgl_renderer->draw_world(akgl_renderer, NULL)
|
||||
akgl_game_state_unlock()
|
||||
```
|
||||
|
||||
Five things follow from that, and four of them are not obvious.
|
||||
|
||||
**The sweep walks the pool, not the registry.** It reads `akgl_heap_actors` directly and
|
||||
skips slots whose `refcount` is 0. That is why an actor released mid-frame simply stops
|
||||
being visited, and why the actor registry's contents do not affect the update pass at all.
|
||||
|
||||
**Each actor's `updatefunc` runs exactly once.** Until 0.5.0 this loop sat inside a walk
|
||||
over `AKGL_TILEMAP_MAX_LAYERS` that never compared an actor's `layer` to the layer being
|
||||
walked, so every actor updated sixteen times a frame — 70 µs of work to do 4.4 µs of it.
|
||||
Updating an actor is not a per-layer operation. **Drawing is**, and
|
||||
`akgl_render_2d_draw_world` walks the layers itself.
|
||||
|
||||
**`opflags` is not forwarded.** `simulate` and `draw_world` are both passed `NULL`, so they
|
||||
use their own defaults regardless of what you asked for. Setting
|
||||
`AKGL_ITERATOR_OP_LAYERMASK` restricts the *update* sweep and nothing else; the whole world
|
||||
still draws.
|
||||
|
||||
**A `NULL` `opflags` means `AKGL_ITERATOR_OP_UPDATE` alone** — every live actor once, no
|
||||
layer mask, and every actor's `scale` forced to 1.0 because `AKGL_ITERATOR_OP_TILEMAPSCALE`
|
||||
is not set. If your sprites are rendering at the wrong size, that flag is the first thing to
|
||||
check.
|
||||
|
||||
**A live actor's `updatefunc` is called without a `NULL` check.** A hand-built `akgl_Actor`
|
||||
that never went through `akgl_actor_initialize` crashes here rather than raising.
|
||||
|
||||
### There is no frame pacing
|
||||
|
||||
`akgl_game_update` does not sleep, does not wait for vertical blank, and does not ask SDL
|
||||
to. `akgl_render_2d_init` calls `SDL_CreateWindowAndRenderer` with no flags and never calls
|
||||
`SDL_SetRenderVSync`, so **the loop runs as fast as the machine will let it** and burns a
|
||||
core doing it.
|
||||
|
||||
That is a deliberate omission rather than an oversight — pacing policy belongs to the game —
|
||||
but it means the pacing is yours to add. Either call `SDL_SetRenderVSync(renderer, 1)` on
|
||||
`akgl_renderer->sdl_renderer` after `akgl_render_2d_init`, or measure the frame and
|
||||
`SDL_DelayNS` the remainder yourself. Physics is unaffected either way:
|
||||
`akgl_physics_simulate` computes its own `dt` from `SDL_GetTicksNS` and bounds it at
|
||||
`physics.max_timestep`.
|
||||
|
||||
### `akgl_game.fps` reads 0 for the first second
|
||||
|
||||
`akgl_game_update_fps` recomputes `fps` only when a **full second** has elapsed since the
|
||||
last recomputation, so it is a completed-second average and not an instantaneous figure. For
|
||||
the first second of the process it is 0.
|
||||
|
||||
0 is below the low-FPS threshold of 30. **So `lowfpsfunc` fires on every single frame until
|
||||
the first second is up** — several hundred `Low FPS! 0` lines from the default handler
|
||||
before the game has drawn anything worth measuring. That is expected, not a symptom.
|
||||
|
||||
If you replace `lowfpsfunc` with something that sheds work, guard it on `fps > 0` or it will
|
||||
shed work during startup, which is the worst moment for it. The hook is also what an
|
||||
embedder has to know about: `akgl_game_update_fps` installs the default if it finds the
|
||||
pointer `NULL`, because a host binding its own renderer with `akgl_render_2d_bind` never ran
|
||||
`akgl_game_init` and used to crash here on frame one.
|
||||
|
||||
A caller running its own loop rather than `akgl_game_update` has to call
|
||||
`akgl_game_update_fps` itself; nothing else does.
|
||||
|
||||
## The state lock
|
||||
|
||||
`akgl_game.state` is a single `int32_t` of application-defined flags. **The library never
|
||||
reads it.** The mutex guarding it, `akgl_game.statelock`, is the only thread-safety
|
||||
libakgl offers, and it protects the state flags — not the pools, not the registries, not
|
||||
the renderer.
|
||||
|
||||
`akgl_game_state_lock` **polls; it does not block.** It calls `SDL_TryLockMutex` every
|
||||
`AKGL_GAME_STATE_LOCK_RETRY_MS` (100 ms) until `AKGL_GAME_STATE_LOCK_BUDGET_MS` (1000 ms) is
|
||||
spent, then raises `AKGL_ERR_SDL`. The point is that a deadlock reports an error you can act
|
||||
on instead of hanging the process. `SDL_GetError()` is appended for whatever it is worth,
|
||||
but after a failed `SDL_TryLockMutex` it is usually stale or empty — contention is reported
|
||||
by the return value, not by an error string. **The status is the signal, not the text.**
|
||||
|
||||
Two behaviours to plan around:
|
||||
|
||||
**Every failure path in `akgl_game_update` returns holding the lock.** Every step from
|
||||
`akgl_game_state_lock` onward propagates with `PASS`, which returns immediately, so the
|
||||
`akgl_game_state_unlock` at the bottom is never reached. Because SDL mutexes are recursive
|
||||
the next frame on the same thread still gets the lock, so a single-threaded game does not
|
||||
deadlock — it accumulates one unmatched lock per failed frame, and the mutex is never
|
||||
released to any other thread again. **Treat a failed `akgl_game_update` as terminal.** If
|
||||
you genuinely must continue, unlock once yourself before the next frame — but only when you
|
||||
know the failure came from *inside* the frame rather than from the lock acquisition itself,
|
||||
because unlocking a mutex this thread does not hold is undefined behaviour in SDL.
|
||||
|
||||
**`akgl_game_state_lock` succeeds trivially before `akgl_game_init`.** `SDL_TryLockMutex`
|
||||
returns true when passed `NULL`, and `akgl_game.statelock` is `NULL` until step 5 of
|
||||
startup. It locks nothing and reports success.
|
||||
|
||||
`akgl_game_state_unlock` has no failure path and returns `NULL`. That includes the case
|
||||
that matters — unlocking a mutex this thread does not hold — which is undefined behaviour in
|
||||
SDL rather than an error here.
|
||||
|
||||
## Iterators are a work order
|
||||
|
||||
There is no iterator object, and **there is no `next()`**. Traversal is SDL's —
|
||||
`SDL_EnumerateProperties` over a registry — and `akgl_Iterator` is the `userdata` carried
|
||||
into each callback, saying *which* entries to touch and *what* to do to each one.
|
||||
|
||||
```c excerpt=include/akgl/iterator.h
|
||||
/** @brief Selects operations and an optional layer for actor traversal. */
|
||||
typedef struct {
|
||||
uint32_t flags; /**< Bitwise OR of the `AKGL_ITERATOR_OP_*` values below. */
|
||||
uint8_t layerid; /**< Layer to restrict the sweep to. Read only when #AKGL_ITERATOR_OP_LAYERMASK is set. */
|
||||
} akgl_Iterator;
|
||||
|
||||
#define AKGL_ITERATOR_OP_UPDATE (1 << 0) // 1 Call the actor's updatefunc
|
||||
#define AKGL_ITERATOR_OP_RENDER (1 << 1) // 2 Call the actor's renderfunc
|
||||
#define AKGL_ITERATOR_OP_RELEASE (1 << 2) // 4 Release the object back to its heap layer
|
||||
#define AKGL_ITERATOR_OP_LAYERMASK (1 << 3) // 8 Skip anything whose layer != layerid
|
||||
#define AKGL_ITERATOR_OP_TILEMAPSCALE (1 << 4) // 16 Scale actors to the tilemap; otherwise force scale 1.0
|
||||
```
|
||||
|
||||
The operations are **independent bits, not an enum**: one sweep can update, scale and
|
||||
render, and they run in that fixed order regardless of the order the bits were set in. Bits
|
||||
5 through 31 are declared `AKGL_ITERATOR_OP_UNDEFINED_*` and are unused.
|
||||
|
||||
Passing `NULL` where an `akgl_Iterator *` is expected is fine at the top-level entry points
|
||||
— `akgl_game_update`, `akgl_physics_simulate` and `akgl_render_2d_draw_world` each
|
||||
substitute their own defaults — but it is `AKERR_NULLPOINTER` once inside a callback.
|
||||
|
||||
**`akgl_game_update` reads only two of the five bits**: `AKGL_ITERATOR_OP_LAYERMASK` and
|
||||
`AKGL_ITERATOR_OP_TILEMAPSCALE`. It calls `updatefunc` unconditionally rather than checking
|
||||
`AKGL_ITERATOR_OP_UPDATE`, because updating every live actor once is the whole job. The
|
||||
callback `akgl_registry_iterate_actor` does check all of them, and that is the one the
|
||||
render sweep goes through.
|
||||
|
||||
## The main loop
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akerror.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/iterator.h>
|
||||
|
||||
/* A frame loop. A failed akgl_game_update is terminal: it returns holding the
|
||||
* state lock, and FINISH_NORETURN is what turns that into a stack trace and a
|
||||
* truthful exit status instead of a second frame on a poisoned mutex. */
|
||||
int main(void)
|
||||
{
|
||||
akgl_Iterator work = {
|
||||
.flags = (AKGL_ITERATOR_OP_UPDATE | AKGL_ITERATOR_OP_TILEMAPSCALE),
|
||||
.layerid = 0
|
||||
};
|
||||
SDL_Event event;
|
||||
bool running = true;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
ATTEMPT {
|
||||
while ( running == true ) {
|
||||
while ( SDL_PollEvent(&event) == true ) {
|
||||
if ( event.type == SDL_EVENT_QUIT ) {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
CATCH(errctx, akgl_game_update(&work));
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH_NORETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The `CATCH` inside the `while` is safe **only because the loop is the entire body of the
|
||||
`ATTEMPT`**. `CATCH` reports failure by `break`ing, and a `break` binds to the innermost
|
||||
enclosing loop, so it leaves the `while` and falls into `CLEANUP`/`PROCESS`/`FINISH` — which
|
||||
is what you want. Put anything after the loop and you have to account for reaching it after
|
||||
a failure. `AGENTS.md` spells the rule out, and `scripts/check_error_protocol.py` cannot see
|
||||
this one.
|
||||
|
||||
## There is no `akgl_game_shutdown`
|
||||
|
||||
**Teardown is entirely the caller's.** No function in this library undoes `akgl_game_init`.
|
||||
What there is, in the order it has to run:
|
||||
|
||||
| Step | Call | Why the order |
|
||||
|---|---|---|
|
||||
| 1 | `akgl_text_unloadfont` for each font you loaded | Must precede `TTF_Quit`. `text.h` documents the trap: `SDL_Quit` destroying the engine out from under a live `TTF_Font` is not a clean unload |
|
||||
| 2 | `akgl_heap_release_*` for anything you still hold | `akgl_heap_release_spritesheet` destroys `SDL_Texture`s and must run on the renderer's thread, before the renderer goes |
|
||||
| 3 | `akgl_audio_shutdown()` | Puts the synthesizer voices back |
|
||||
| 4 | `akgl_renderer->shutdown(akgl_renderer)` | `akgl_render_2d_shutdown` |
|
||||
| 5 | `TTF_Quit()`, `MIX_Quit()`, `SDL_Quit()` | SDL's own |
|
||||
|
||||
A process that is exiting anyway can skip most of it — the OS reclaims the pools, and SDL
|
||||
reclaims what it owns — but a program that tears a *level* down and builds another one has
|
||||
to get steps 1 and 2 right or it exhausts the pools. `akgl_heap_init_actor` plus
|
||||
`akgl_registry_init_actor` is the cheap version of that for actors only; see
|
||||
[Chapter 5](05-the-heap.md).
|
||||
|
||||
## Savegames are partial
|
||||
|
||||
`akgl_game_save` and `akgl_game_load` exist and work, and **they do not yet save your game.**
|
||||
Read this before building anything on them.
|
||||
|
||||
What `akgl_game_save` writes:
|
||||
|
||||
1. The `akgl_Game` struct verbatim — metadata, timing, FPS accounting and the state flags.
|
||||
2. Four name tables — actors, sprites, spritesheets, characters — each mapping a registered
|
||||
name to **the address the object had at save time**, each terminated by a zeroed name
|
||||
field and a zeroed pointer.
|
||||
|
||||
What it does not write: **the objects themselves.** Not one actor, sprite, sheet or
|
||||
character. The name tables are the mechanism that would let a loader reconnect pointers
|
||||
between objects that will sit at different addresses next run; the objects those pointers
|
||||
would point at are not in the file. `akgl_game_load` correspondingly rebuilds the four
|
||||
old-address-to-current-object maps and stops there. `game.h` says so on both functions.
|
||||
|
||||
Three further things to know:
|
||||
|
||||
- **`akgl_game_load` refuses a save that does not match this build**, comparing the library
|
||||
version, the game version, the game name and the game URI. Versions are compared by
|
||||
**exact** semver equality, not compatibility, because the file is a raw memory image and
|
||||
semver has no way to say "the layout did not move". A mismatch is `AKERR_API`.
|
||||
- **A save containing any registered spritesheet cannot be read back.** The writer uses
|
||||
`AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` (512) for that table and the reader used
|
||||
`AKGL_ACTOR_MAX_NAME_LENGTH` (128) for all four. That is fixed as of 0.5.0 (`TODO.md`,
|
||||
"Known and still open" item 7) — and what catches it is the `require_at_eof` check, which
|
||||
turns a field-width disagreement into `AKERR_IO` instead of a silent load with wrong maps.
|
||||
A reader whose widths disagree finds a run of zeros mid-entry, stops early, and would
|
||||
otherwise report success.
|
||||
- **`akgl_game_load` leaks four `SDL_PropertiesID` per call.** The maps it builds are never
|
||||
destroyed.
|
||||
|
||||
A write error part-way through the name tables **terminates the process** rather than
|
||||
returning. The entries go through `SDL_EnumerateProperties`, whose callbacks cannot report
|
||||
failure upward and end in `FINISH_NORETURN`; only the terminators are written through the
|
||||
error-reporting path. See [Chapter 4](04-errors.md).
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Chapter 4](04-errors.md) — the statuses everything here raises.
|
||||
- [Chapter 6](06-the-registry.md) — the configuration step 3 above writes into.
|
||||
- [Chapter 8](08-rendering.md) — `akgl_render_2d_init` versus `akgl_render_2d_bind`, and
|
||||
what `draw_world` does with the layers.
|
||||
- [Chapter 14](14-physics.md) — what `simulate` does with the `dt` this chapter does not
|
||||
pace.
|
||||
- [Chapter 21](21-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants.
|
||||
332
docs/08-rendering.md
Normal file
332
docs/08-rendering.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 08. Rendering
|
||||
|
||||
SDL3 already owns the renderer. `SDL_Renderer`, `SDL_Texture`, `SDL_FRect`, blend
|
||||
modes and the batching behaviour behind `SDL_RenderPresent` are documented in the
|
||||
[SDL3 wiki](https://wiki.libsdl.org/SDL3/CategoryRender), and this chapter does not
|
||||
restate any of it.
|
||||
|
||||
What libakgl adds is three things, and only three:
|
||||
|
||||
1. **A backend struct** — a record of function pointers, so a renderer can be
|
||||
replaced without a branch anywhere in the library.
|
||||
2. **A frame contract** — `frame_start`, then any number of `draw_*` calls, then
|
||||
`frame_end`.
|
||||
3. **A scene walk** — `draw_world`, which interleaves tilemap layers with the
|
||||
actors standing on them.
|
||||
|
||||
Per-function reference is generated from the headers by Doxygen and is CI-gated;
|
||||
this chapter teaches the shape and links there for signatures.
|
||||
|
||||
## The backend is a record of function pointers
|
||||
|
||||
There is no renderer base class, no vtable pointer, and no dynamic dispatch. There
|
||||
is a struct with an `SDL_Renderer *` and six function pointers on it:
|
||||
|
||||
```c excerpt=include/akgl/renderer.h
|
||||
typedef struct akgl_RenderBackend {
|
||||
SDL_Renderer *sdl_renderer;
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*shutdown)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*frame_start)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*frame_end)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_texture)(struct akgl_RenderBackend *self, SDL_Texture *texture, SDL_FRect *src, SDL_FRect *dest, double angle, SDL_FPoint *center, SDL_FlipMode flip);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_mesh)(struct akgl_RenderBackend *self);
|
||||
akerr_ErrorContext AKERR_NOIGNORE *(*draw_world)(struct akgl_RenderBackend *self, akgl_Iterator *opflags);
|
||||
} akgl_RenderBackend;
|
||||
```
|
||||
|
||||
A *backend* is therefore an initializer that fills those six slots in. One is
|
||||
shipped — the 2D SDL one, whose entry points are the `akgl_render_2d_*` functions —
|
||||
and a second renderer would be a second initializer pointing the same six slots at
|
||||
different functions. Nothing in the library tests "which renderer is this".
|
||||
|
||||
**Call through the pointers, not the names.** `akgl_render_2d_frame_start(b)` and
|
||||
`b->frame_start(b)` do the same thing today and stop doing the same thing the
|
||||
moment anybody swaps a backend in. Every call site inside libakgl goes through the
|
||||
pointer, and so should yours:
|
||||
|
||||
```c
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
akerr_ErrorContext *draw_one_frame(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
|
||||
PASS(errctx, akgl_renderer->draw_world(akgl_renderer, NULL));
|
||||
PASS(errctx, akgl_renderer->frame_end(akgl_renderer));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_renderer` is the global the rest of the library draws through. It is a
|
||||
pointer into `akgl_default_renderer`, set by `akgl_game_init`; see
|
||||
[Chapter 7](07-the-game-and-the-frame.md). Reassigning `akgl_renderer` is how a
|
||||
program installs its own backend — there is exactly one world, so there is exactly
|
||||
one of these.
|
||||
|
||||
Every entry point returns `akerr_ErrorContext AKERR_NOIGNORE *`. Statuses and what
|
||||
they mean are [Chapter 4](04-errors.md); the protocol itself is libakerror's and is
|
||||
documented in `deps/libakerror`.
|
||||
|
||||
## The frame contract
|
||||
|
||||
```text
|
||||
frame_start(self) clear the target to opaque black
|
||||
|
|
||||
+--> draw_world(self, opflags) the tilemap and its actors
|
||||
+--> draw_texture(self, ...) one blit
|
||||
+--> akgl_draw_* (chapter 09) lines, boxes, circles, fills
|
||||
|
|
||||
frame_end(self) present
|
||||
```
|
||||
|
||||
`frame_start` sets the draw colour to opaque black and calls `SDL_RenderClear`
|
||||
itself. **You do not need to clear again.** The FAQ half of the old `README.md`
|
||||
showed this:
|
||||
|
||||
```c norun
|
||||
PASS(e, akgl_renderer->frame_start(akgl_renderer));
|
||||
SDL_RenderClear(akgl_renderer->sdl_renderer);
|
||||
```
|
||||
|
||||
The second line is redundant — it clears an already-cleared target, unchecked, and
|
||||
at whatever draw colour was left behind. Drop it. (`norun` because it is quoted as
|
||||
an example of what *not* to write; it compiles, and that is the problem.)
|
||||
|
||||
Both check `self` and then `self->sdl_renderer` before touching either, so a `NULL`
|
||||
backend and a backend that was bound but never given a renderer both report
|
||||
`AKERR_NULLPOINTER` rather than crashing. `renderer.h` says the opposite — its
|
||||
`@param self` for `frame_start`, `frame_end` and `draw_texture` reads "dereferenced
|
||||
*before* it is checked -- a `NULL` @p self is a crash, not an error". Read
|
||||
`src/renderer.c`: the first statement in each is a `FAIL_ZERO_RETURN` on `self`.
|
||||
|
||||
## The camera
|
||||
|
||||
`akgl_camera` is an `SDL_FRect *` in **map coordinates**: `x`/`y` are the top-left
|
||||
corner of the view into the world, `w`/`h` its size. It is a plain pointer into
|
||||
`akgl_default_camera`, and everything that draws world content subtracts it.
|
||||
|
||||
`akgl_render_2d_init` points it at the full screen rectangle at startup. Scrolling
|
||||
is just writing to it:
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/* Centre the view on an actor, and do not scroll past the top-left of the map. */
|
||||
akerr_ErrorContext *camera_follow(akgl_Actor *obj)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
|
||||
akgl_camera->x = obj->x - (akgl_camera->w / 2.0f);
|
||||
akgl_camera->y = obj->y - (akgl_camera->h / 2.0f);
|
||||
if ( akgl_camera->x < 0.0f ) {
|
||||
akgl_camera->x = 0.0f;
|
||||
}
|
||||
if ( akgl_camera->y < 0.0f ) {
|
||||
akgl_camera->y = 0.0f;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
There is no camera object and no camera API — a rectangle is the whole model. A
|
||||
game that wants two views draws twice with the pointer moved between them.
|
||||
|
||||
## draw_world walks sixteen layers
|
||||
|
||||
`draw_world` is the one call that draws a scene rather than a thing. For each
|
||||
layer index `i` from 0 to `AKGL_TILEMAP_MAX_LAYERS` (16), it:
|
||||
|
||||
1. draws layer `i` of the global `akgl_gamemap` through `akgl_camera`, **if the map
|
||||
has that many layers**; then
|
||||
2. sweeps all `AKGL_MAX_HEAP_ACTOR` (64) actor pool slots and calls `renderfunc` on
|
||||
every live actor whose `layer` equals `i`.
|
||||
|
||||
```text
|
||||
layer 0 tilemap layer 0 ....... actors with layer == 0
|
||||
layer 1 tilemap layer 1 ....... actors with layer == 1
|
||||
layer 2 (map has only 2) actors with layer == 2
|
||||
... ...
|
||||
layer 15 -- actors with layer == 15
|
||||
```
|
||||
|
||||
That interleaving is the point: it is what puts an actor in front of the ground it
|
||||
stands on and behind the archway it walks under. An actor's `layer` field is set
|
||||
from the object layer it was placed in — see [Chapter 12](12-actors.md) and
|
||||
[Chapter 13](13-tilemaps.md).
|
||||
|
||||
The map layer is skipped once `i` reaches `akgl_gamemap->numlayers`, but the actor
|
||||
sweep is not: **an actor on layer 9 of a two-layer map still draws.** It just draws
|
||||
over bare background.
|
||||
|
||||
`draw_world` reads `akgl_gamemap`, `akgl_camera` and `akgl_heap_actors` directly
|
||||
rather than taking them as arguments. There is one world.
|
||||
|
||||
### opflags is accepted and ignored
|
||||
|
||||
The `akgl_Iterator *opflags` argument is part of the backend signature and is
|
||||
currently **ignored entirely**. `NULL` substitutes a zeroed iterator, and neither
|
||||
that nor a set of flags you built yourself reaches anything: `draw_world` sweeps
|
||||
the actor pool itself instead of going through `akgl_registry_iterate_actor`, so no
|
||||
`AKGL_ITERATOR_OP_*` bit — including `AKGL_ITERATOR_OP_LAYERMASK` — has any effect
|
||||
here. Pass `NULL`. When layer masking is wired up, the parameter is where it will
|
||||
land.
|
||||
|
||||
### Two things draw_world does not check
|
||||
|
||||
- **`akgl_gamemap` is dereferenced unguarded.** Calling `draw_world` before
|
||||
`akgl_tilemap_load` is a crash, not an error context.
|
||||
- **A live actor's `renderfunc` is called unguarded.** A hand-built actor that was
|
||||
never run through `akgl_actor_initialize` has a `NULL` there. Always initialize
|
||||
actors through the library.
|
||||
|
||||
The first failure aborts the frame and propagates unchanged; there is no
|
||||
draw-what-you-can behaviour.
|
||||
|
||||
> **Known defect (`TODO.md`, "Performance", item 6).** The layer loop always runs
|
||||
> all 16 iterations and rescans all 64 actor slots on each — 1024 refcount checks
|
||||
> per frame for a one-layer map. Invisible at 60 fps under the software renderer,
|
||||
> measurable on a 2 ms GPU frame. Bounding the walk by `numlayers` and building
|
||||
> per-layer actor lists in one pool pass is the recorded fix.
|
||||
|
||||
## The embedding seam: akgl_render_2d_bind vs akgl_render_2d_init
|
||||
|
||||
This is a first-class use case, not a footnote. Creating a window and populating a
|
||||
vtable are two separable jobs, and libakgl separates them.
|
||||
|
||||
| | `akgl_render_2d_init` | `akgl_render_2d_bind` |
|
||||
|---|---|---|
|
||||
| Reads configuration properties | yes — `game.screenwidth`, `game.screenheight` | no |
|
||||
| Creates a window and `SDL_Renderer` | yes | **no** |
|
||||
| Touches `self->sdl_renderer` | overwrites it | **never touches it** |
|
||||
| Points `akgl_camera` at the screen | yes | no |
|
||||
| Installs the six function pointers | yes, by calling `_bind` | yes |
|
||||
| Needs the registry initialized first | yes | no |
|
||||
|
||||
`akgl_render_2d_init` is the ordinary path for a program that owns its whole
|
||||
process. It reads `game.screenwidth` and `game.screenheight` from the property
|
||||
registry — both defaulting to the string `"0"`, which asks SDL for a zero-sized
|
||||
window — creates the window and renderer, sets `akgl_camera` to the full screen
|
||||
rectangle, and then calls `akgl_render_2d_bind` to fill in the vtable. Because it
|
||||
reads properties, `akgl_registry_init_properties` and your property writes have to
|
||||
happen first; see [Chapter 6](06-the-registry.md).
|
||||
|
||||
**The window title is `akgl_game.uri`.** Not `akgl_game.name` — the reverse-DNS
|
||||
identifier is what `SDL_CreateWindowAndRenderer` is handed. If your title bar reads
|
||||
`tech.starfort.mygame`, that is why.
|
||||
|
||||
`akgl_render_2d_bind` is the other half on its own. It writes six pointers and
|
||||
returns. It deliberately does not touch `sdl_renderer`, which is exactly what makes
|
||||
it usable by a host that already has one — an embedded interpreter, a level editor,
|
||||
an application with its own window that wants libakgl to draw inside it. This is
|
||||
what the sibling `akbasic` consumer uses.
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
/*
|
||||
* A host that already owns its window and SDL_Renderer. It never calls
|
||||
* akgl_render_2d_init, so libakgl never creates a second window.
|
||||
*/
|
||||
akerr_ErrorContext *host_bind_renderer(SDL_Renderer *mine, int w, int h)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, mine, AKERR_NULLPOINTER, "mine");
|
||||
|
||||
/* _bind never writes sdl_renderer, so put yours there first. */
|
||||
akgl_default_renderer.sdl_renderer = mine;
|
||||
PASS(errctx, akgl_render_2d_bind(&akgl_default_renderer));
|
||||
|
||||
/* Everything else in the library draws through the global. */
|
||||
akgl_renderer = &akgl_default_renderer;
|
||||
|
||||
/* _bind does not set the camera; _init would have. */
|
||||
akgl_camera = &akgl_default_camera;
|
||||
akgl_camera->x = 0.0f;
|
||||
akgl_camera->y = 0.0f;
|
||||
akgl_camera->w = (float)w;
|
||||
akgl_camera->h = (float)h;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
### Bind the global, not a backend of your own
|
||||
|
||||
This is the trap in the seam, and it is worth stating flatly:
|
||||
|
||||
**`draw_world` hands `self` to the tilemap and to each actor's `renderfunc`, but
|
||||
neither of them uses it.** `akgl_actor_render` and `akgl_tilemap_draw` both reach
|
||||
straight for the global `akgl_renderer`. So does `akgl_spritesheet_initialize`, and
|
||||
so does every texture load in the tilemap loader.
|
||||
|
||||
A host that binds a *private* `akgl_RenderBackend` and calls
|
||||
`mine.draw_world(&mine, NULL)` gets its tilemap and its actors drawn through
|
||||
whatever `akgl_renderer` points at — which, if `akgl_game_init` ran, is
|
||||
`akgl_default_renderer` with a `NULL` `sdl_renderer`. The failure is an
|
||||
`AKERR_NULLPOINTER` from deep inside a texture load, and it reads like a missing
|
||||
asset.
|
||||
|
||||
Set `akgl_renderer` to the backend you bound. The example above does.
|
||||
|
||||
A backend that has been bound but never given an `SDL_Renderer` refuses cleanly
|
||||
rather than crashing: every entry point checks `self->sdl_renderer` and reports
|
||||
`AKERR_NULLPOINTER`. That is the designed-for state and is what makes `_bind` safe
|
||||
to call before you have a renderer.
|
||||
|
||||
> **A header claim that is no longer true.** `renderer.h` ends
|
||||
> `akgl_render_2d_init`'s documentation with a `@note` saying the two pooled
|
||||
> strings holding the dimensions "are only released on the success path, so each
|
||||
> failed initialization leaks two string slots". They are released in a `CLEANUP`
|
||||
> block that runs on both paths, and `src/renderer.c` carries a comment saying
|
||||
> that is exactly why it was moved there. **There is no leak on the current
|
||||
> code.** Correcting the header comment is a separate commit; this chapter
|
||||
> documents what the code does.
|
||||
|
||||
## What the 2D backend refuses
|
||||
|
||||
**`draw_mesh` always fails.** Every call raises `AKERR_API` with the message "Not
|
||||
implemented". The hook exists so a 3D backend has a slot to fill. It is not a stub
|
||||
that quietly does nothing — a caller that reaches it finds out on the first call,
|
||||
which is the intent:
|
||||
|
||||
```c
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/renderer.h>
|
||||
|
||||
akerr_ErrorContext *try_the_mesh_hook(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_renderer->draw_mesh(akgl_renderer));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_API) {
|
||||
/* The 2D backend has no geometry path. Draw it as textures instead. */
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
**`akgl_render_2d_shutdown` is a documented no-op.** It validates `self` and
|
||||
succeeds. The window and `SDL_Renderer` are still SDL's and are reclaimed by
|
||||
`SDL_Quit`, so there is nothing here to release yet. Call it anyway — a backend
|
||||
that acquires anything will grow a failure path, and the hook is where it will go.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 09](09-drawing.md) — the immediate-mode primitives that draw through
|
||||
the same backend.
|
||||
- [Chapter 12](12-actors.md) — `renderfunc`, `layer`, and what `draw_world`
|
||||
actually calls.
|
||||
- [Chapter 13](13-tilemaps.md) — layers, and what `akgl_tilemap_draw` does with
|
||||
the camera.
|
||||
- [Chapter 07](07-the-game-and-the-frame.md) — where `frame_start` and `frame_end`
|
||||
sit inside `akgl_game_update`.
|
||||
332
docs/09-drawing.md
Normal file
332
docs/09-drawing.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# 09. Drawing
|
||||
|
||||
`draw.h` is immediate-mode plotting: put this on the screen *now*, as opposed to
|
||||
add this to the scene. It sits alongside the actor and tilemap rendering in
|
||||
[Chapter 08](08-rendering.md) rather than replacing it — a game can draw a world
|
||||
through `draw_world` and then scribble a debug rectangle over it in the same frame.
|
||||
|
||||
The shape comes from a BASIC-style graphics vocabulary. `DRAW`, `BOX`, `CIRCLE`,
|
||||
`PAINT`, `SSHAPE` and `GSHAPE` all map onto one of these, which is why the set is
|
||||
what it is and why there is a flood fill in a game library at all.
|
||||
|
||||
| Function | Draws | Notable |
|
||||
|---|---|---|
|
||||
| `akgl_draw_point` | one pixel | clipping is SDL's, not reported |
|
||||
| `akgl_draw_line` | a line, endpoints included | coincident endpoints draw one pixel |
|
||||
| `akgl_draw_rect` | an outline | zero or negative w/h draws nothing, silently |
|
||||
| `akgl_draw_filled_rect` | a solid box, outline included | same |
|
||||
| `akgl_draw_circle` | an outline | midpoint algorithm, integer, no AA |
|
||||
| `akgl_draw_flood_fill` | a connected region | CPU-side, bounded, **not reentrant** |
|
||||
| `akgl_draw_copy_region` | *reads* a rectangle into a surface | allocates when you ask it to |
|
||||
| `akgl_draw_paste_region` | a saved surface back onto the target | replaces, does not blend |
|
||||
| `akgl_draw_background` | an 8x8 grey checkerboard | diagnostic, not a general primitive |
|
||||
|
||||
Signatures are in the generated Doxygen. Statuses are [Chapter 04](04-errors.md).
|
||||
|
||||
**These are ordinary functions, not backend methods.** Unlike `frame_start` and
|
||||
`draw_world`, they are not slots on `akgl_RenderBackend` — they take an
|
||||
`akgl_RenderBackend *` as their first argument and reach through its
|
||||
`sdl_renderer`. Pass `akgl_renderer` unless you are deliberately drawing somewhere
|
||||
else. Every one of them validates both `self` and `self->sdl_renderer` before
|
||||
touching either.
|
||||
|
||||
## Colour is an argument, never a global
|
||||
|
||||
**Every entry point takes its colour as a parameter, and none of them reads or
|
||||
writes a current-colour global.**
|
||||
|
||||
The reasoning is worth stating because it looks like extra typing. A caller that
|
||||
has a notion of a current colour — a BASIC `COLOR` statement, a UI theme, a palette
|
||||
index — *already owns that state*. Giving the library a second copy creates two
|
||||
places for the answer to live and one opportunity for them to disagree. So there is
|
||||
no `akgl_draw_set_color`, and there never will be.
|
||||
|
||||
The other half of the rule is the one that matters at a call site: **the renderer's
|
||||
own draw colour is saved before each call and restored after it.** Drawing a red
|
||||
line does not leave the renderer red. In particular, it does not change what the
|
||||
next `SDL_RenderClear` — or the next `frame_start`, which clears — paints.
|
||||
|
||||
```c
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/* Two rectangles, two colours, no state carried between them. */
|
||||
akerr_ErrorContext *draw_health_bar(float32_t x, float32_t y, float32_t frac)
|
||||
{
|
||||
SDL_Color back = { 0x20, 0x20, 0x20, SDL_ALPHA_OPAQUE };
|
||||
SDL_Color fill = { 0xd0, 0x30, 0x30, SDL_ALPHA_OPAQUE };
|
||||
SDL_FRect rect;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
rect.x = x;
|
||||
rect.y = y;
|
||||
rect.w = 100.0f;
|
||||
rect.h = 8.0f;
|
||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &rect, back));
|
||||
|
||||
rect.w = 100.0f * frac;
|
||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &rect, fill));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Alpha is in the colour and blending follows the renderer's **current blend mode**,
|
||||
which these functions do not change. If you want alpha to blend rather than
|
||||
overwrite, set the blend mode yourself with
|
||||
[`SDL_SetRenderDrawBlendMode`](https://wiki.libsdl.org/SDL3/SDL_SetRenderDrawBlendMode).
|
||||
That one *is* renderer state libakgl leaves alone.
|
||||
|
||||
Restoring the colour happens in a `CLEANUP` block under `IGNORE()`. A failure to
|
||||
restore is logged rather than propagated, on the grounds that it must not mask the
|
||||
failure the cleanup is unwinding from.
|
||||
|
||||
## What the primitives look like
|
||||
|
||||
Every primitive in one frame, on a 320x240 surface. The picture below is generated by
|
||||
running exactly this listing — see `MAINTENANCE.md` if you are editing it.
|
||||
|
||||
```c screenshot=primitives
|
||||
SDL_Color red = { 0xd0, 0x30, 0x30, 0xff };
|
||||
SDL_Color green = { 0x30, 0xc0, 0x50, 0xff };
|
||||
SDL_Color blue = { 0x40, 0x70, 0xe0, 0xff };
|
||||
SDL_Color yellow = { 0xe0, 0xc0, 0x40, 0xff };
|
||||
SDL_FRect filled = { 20.0f, 20.0f, 80.0f, 50.0f };
|
||||
SDL_FRect framed = { 120.0f, 20.0f, 80.0f, 50.0f };
|
||||
|
||||
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &filled, blue));
|
||||
PASS(errctx, akgl_draw_rect(akgl_renderer, &framed, green));
|
||||
PASS(errctx, akgl_draw_circle(akgl_renderer, 260.0f, 45.0f, 25.0f, yellow));
|
||||
PASS(errctx, akgl_draw_line(akgl_renderer, 20.0f, 100.0f, 300.0f, 100.0f, red));
|
||||
|
||||
/*
|
||||
* A row of points, to show that a point is a pixel and not a dot of some
|
||||
* nominal size. At this scale they read as a dashed line.
|
||||
*/
|
||||
for ( float32_t x = 20.0f; x < 300.0f; x += 8.0f ) {
|
||||
PASS(errctx, akgl_draw_point(akgl_renderer, x, 115.0f, yellow));
|
||||
}
|
||||
```
|
||||
|
||||

|
||||
|
||||
The filled rectangle is `akgl_draw_filled_rect`; the outlined one beside it is
|
||||
`akgl_draw_rect`. Neither left the renderer blue or green — the line drawn afterwards is
|
||||
red because it asked to be, not because it inherited anything.
|
||||
|
||||
## Circles are integer midpoint circles
|
||||
|
||||
SDL3 has no circle primitive, so `akgl_draw_circle` plots one: the midpoint circle
|
||||
algorithm, integer arithmetic throughout, eight-way symmetry — one computed point
|
||||
in the second octant gives the other seven by reflection.
|
||||
|
||||
Three consequences you will see on screen:
|
||||
|
||||
- **The centre and the radius are rounded to whole pixels** (`SDL_lroundf`). A
|
||||
circle at `x = 10.5` is a circle at `x = 11`. There is no sub-pixel placement.
|
||||
- **The outline is one pixel wide and is not anti-aliased.** There is no
|
||||
thickness parameter and no smoothing. If you want a thick or smooth circle, draw
|
||||
concentric ones or blit a texture.
|
||||
- **There is no filled circle.** Only the outline. A filled disc is a flood fill of
|
||||
the interior, or a texture.
|
||||
|
||||
A radius of exactly 0 draws the centre pixel and nothing else. A negative radius is
|
||||
`AKERR_OUTOFBOUNDS` and the message reports it.
|
||||
|
||||
A failure inside the plotting loop is recorded in a flag and reported once at the
|
||||
end rather than aborting mid-circle — a `CATCH` there would `break` the loop rather
|
||||
than leave the function, the hazard `AGENTS.md` describes. **So an
|
||||
`AKGL_ERR_SDL` from `akgl_draw_circle` may leave a partial arc already on the
|
||||
target.** The same is true of `akgl_draw_background`.
|
||||
|
||||
## Flood fill
|
||||
|
||||
`akgl_draw_flood_fill` is the odd one out, and it is worth understanding why before
|
||||
using it. Every other primitive here is a command queued to the GPU. This one
|
||||
cannot be: **the region it fills is defined by what is already on the screen**, so
|
||||
it has to read the render target back, walk the region on the CPU, and blit the
|
||||
result over the area it touched.
|
||||
|
||||
```text
|
||||
SDL_RenderReadPixels(target) the whole target comes back
|
||||
|
|
||||
SDL_ConvertSurface(RGBA32) fixed 32-bit layout, so the walk
|
||||
| can compare and write whole words
|
||||
flood_region() scanline fill over a bounded span
|
||||
| stack; records a dirty bounding box
|
||||
SDL_RenderTexture(dirty, dirty) only what changed goes back
|
||||
```
|
||||
|
||||
### What counts as the region
|
||||
|
||||
**Four-connected**: the fill spreads up, down, left and right, never diagonally. A
|
||||
region joined only at a corner is two regions.
|
||||
|
||||
**The boundary is an exact colour comparison.** Any pixel whose packed RGBA value
|
||||
differs from the seed's, by any amount, is a boundary. There is no tolerance
|
||||
parameter.
|
||||
|
||||
That second one is the practical trap. **An anti-aliased edge stops the fill at its
|
||||
first blended pixel and leaves a fringe** — you get the flat interior and a halo of
|
||||
untouched blend pixels around it. Text rendered through SDL3_ttf, a scaled sprite,
|
||||
and anything drawn with smoothing all have such edges. Flood fill wants hard-edged
|
||||
art.
|
||||
|
||||
Filling a region that is already the requested colour is a **no-op that succeeds**,
|
||||
not an error: walking it would compare filled pixels against themselves and find
|
||||
nothing, so the function says so up front. A seed outside the render target is
|
||||
`AKERR_OUTOFBOUNDS`, and the message reports both the seed and the target size.
|
||||
|
||||
### The span stack, and what it costs you
|
||||
|
||||
The fill keeps a fixed stack of horizontal runs still to be examined rather than
|
||||
recursing per pixel:
|
||||
|
||||
```c excerpt=include/akgl/draw.h
|
||||
#define AKGL_DRAW_MAX_FLOOD_SPANS 4096
|
||||
```
|
||||
|
||||
At 4096 spans that array is 48 KB, which does not belong on the stack of a function
|
||||
a game may call every frame — so it is **file scope**. Two consequences, and both
|
||||
are hard rules rather than cautions:
|
||||
|
||||
- **`akgl_draw_flood_fill` is not reentrant.** Do not call it from inside a
|
||||
callback that a flood fill can reach. There is one span stack for the process.
|
||||
- **It is not thread-safe.** Nothing that touches an `SDL_Renderer` is, so this is
|
||||
not a new restriction — but the span stack means it stays unsafe even against a
|
||||
second renderer on a second thread.
|
||||
|
||||
An ordinary convex or moderately concave shape needs a few dozen pending spans. A
|
||||
region complicated enough to need more than 4096 at once reports
|
||||
`AKERR_OUTOFBOUNDS` — and **leaves the region partially filled**. There is no way
|
||||
to unwind a partial fill short of keeping a copy of the whole surface, and you
|
||||
asked for a bounded operation. Treat that status as "redraw the area", not as
|
||||
"retry".
|
||||
|
||||
```c
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
akerr_ErrorContext *paint_region(int x, int y, SDL_Color color)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_draw_flood_fill(akgl_renderer, x, y, color));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_OUTOFBOUNDS) {
|
||||
/*
|
||||
* Either the seed was off-target, or the region needed more than
|
||||
* AKGL_DRAW_MAX_FLOOD_SPANS pending spans -- in which case it is
|
||||
* already PARTIALLY filled. Redraw the area; do not retry the fill.
|
||||
*/
|
||||
LOG_ERROR(errctx);
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The colour is **written over** the region rather than blended, since the pixels
|
||||
going back are the ones just read out of it.
|
||||
|
||||
## Saving and restoring a region
|
||||
|
||||
`akgl_draw_copy_region` and `akgl_draw_paste_region` are `SSHAPE` and `GSHAPE`: read
|
||||
a rectangle of the render target into an `SDL_Surface`, and draw one back.
|
||||
|
||||
### copy_region allocates only when you ask it to
|
||||
|
||||
`dest` is an `SDL_Surface **`, and **`*dest` must be initialized before the call**.
|
||||
Its value selects between two behaviours:
|
||||
|
||||
| `*dest` on entry | What happens | Who owns the surface afterwards |
|
||||
|---|---|---|
|
||||
| `NULL` | a surface is allocated and written back through `dest` | **you do** — release it with `SDL_DestroySurface` |
|
||||
| a surface of *exactly* `src`'s dimensions | the pixels are copied into it | you already did |
|
||||
| a surface of any other size | `AKERR_OUTOFBOUNDS`, both sets of dimensions in the message | unchanged |
|
||||
|
||||
The reuse path exists so a caller saving the same region every frame does not churn
|
||||
allocations. Note that `akgl_draw_copy_region` is one of the few places in libakgl
|
||||
that hands you memory you have to free yourself — everything else comes from the
|
||||
pools in [Chapter 05](05-the-heap.md).
|
||||
|
||||
**`src` must fit entirely inside the render target.** This is checked and refused
|
||||
with `AKERR_OUTOFBOUNDS` rather than clipped, deliberately: SDL would clip the read
|
||||
and hand back a *smaller* surface than was asked for, and a caller pasting it back
|
||||
would not notice until the art was visibly wrong. A rectangle with no area is
|
||||
refused too.
|
||||
|
||||
### paste_region replaces, it does not blend
|
||||
|
||||
`akgl_draw_paste_region` sets `SDL_BLENDMODE_NONE` on the texture it uploads, so
|
||||
the saved pixels **overwrite** what is on the target. That is `GSHAPE`'s default
|
||||
behaviour and it is the one that makes a save/restore pair actually restore: a
|
||||
saved region's transparent pixels come back as transparent, rather than letting
|
||||
whatever is underneath show through them.
|
||||
|
||||
There is no scaling — the surface is drawn at its own size — and the surface is not
|
||||
consumed, so it may be pasted as many times as you like. Parts falling outside the
|
||||
target are clipped by SDL and not reported.
|
||||
|
||||
```c
|
||||
#include <akgl/draw.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/* SSHAPE then GSHAPE: save a 32x32 patch, draw over it, put it back. */
|
||||
akerr_ErrorContext *blink_cursor(int x, int y)
|
||||
{
|
||||
SDL_Surface *saved = NULL;
|
||||
SDL_Rect box;
|
||||
SDL_FRect cursor;
|
||||
SDL_Color white = { 0xff, 0xff, 0xff, SDL_ALPHA_OPAQUE };
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
box.x = x;
|
||||
box.y = y;
|
||||
box.w = 32;
|
||||
box.h = 32;
|
||||
cursor.x = (float32_t)x;
|
||||
cursor.y = (float32_t)y;
|
||||
cursor.w = 32.0f;
|
||||
cursor.h = 32.0f;
|
||||
|
||||
ATTEMPT {
|
||||
/* saved is NULL, so copy_region allocates it and we own the result. */
|
||||
CATCH(errctx, akgl_draw_copy_region(akgl_renderer, &box, &saved));
|
||||
CATCH(errctx, akgl_draw_filled_rect(akgl_renderer, &cursor, white));
|
||||
CATCH(errctx, akgl_draw_paste_region(akgl_renderer, saved, cursor.x, cursor.y));
|
||||
} CLEANUP {
|
||||
if ( saved != NULL ) {
|
||||
SDL_DestroySurface(saved);
|
||||
saved = NULL;
|
||||
}
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
## The checkerboard backdrop
|
||||
|
||||
`akgl_draw_background` paints an 8x8 grey checkerboard — the pattern an image
|
||||
editor uses to show transparency. It is a **diagnostic backdrop rather than a
|
||||
general primitive**: `charviewer` uses it so a sprite's transparent pixels are
|
||||
visible instead of blending into black.
|
||||
|
||||
It always starts at the origin. `w` and `h` are the extent to cover, not a
|
||||
rectangle, and both round *up* to whole 8-pixel cells — a height of 12 paints 16. A
|
||||
zero or negative extent paints nothing and is not reported.
|
||||
|
||||
Until 0.5.0 this was the one function in the library outside the error protocol: it
|
||||
returned `void`, drew through the global `akgl_renderer` without checking it, and
|
||||
left the renderer's draw colour changed. It takes a backend now and restores the
|
||||
colour it found, which is also what makes it testable without a world.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 08](08-rendering.md) — the backend these all take, and the frame the
|
||||
calls sit inside.
|
||||
- [Chapter 16](16-text-and-fonts.md) — text, which is textures rather than
|
||||
primitives.
|
||||
- [Chapter 05](05-the-heap.md) — the pools, and why `copy_region` returning owned
|
||||
memory is the exception rather than the rule.
|
||||
313
docs/10-spritesheets-and-sprites.md
Normal file
313
docs/10-spritesheets-and-sprites.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# 10. Spritesheets and sprites
|
||||
|
||||
Two objects, and the split between them is the whole design:
|
||||
|
||||
- An **`akgl_SpriteSheet`** owns one `SDL_Texture` — an image file uploaded to the
|
||||
GPU — and nothing else.
|
||||
- An **`akgl_Sprite`** is one animation: a frame size, a playlist of frame numbers,
|
||||
a dwell time, two loop flags, and a **borrowed** pointer to the sheet those
|
||||
frames are cut from.
|
||||
|
||||
So a walk cycle, an idle pose and a death animation drawn on the same PNG are three
|
||||
`akgl_Sprite` objects pointing at one `akgl_SpriteSheet`, and the image is uploaded
|
||||
once. That is not an optimization you have to ask for; it is what the loader does.
|
||||
|
||||
Both are pool objects — `akgl_heap_next_sprite`, `akgl_heap_next_spritesheet`, see
|
||||
[Chapter 05](05-the-heap.md) — and both publish themselves in a registry under
|
||||
their name, which is how [Chapter 11](11-characters.md) and the tilemap loader find
|
||||
them. See [Chapter 06](06-the-registry.md).
|
||||
|
||||
**The renderer has to exist before any of this.** Loading a sheet uploads a
|
||||
texture, so `akgl_render_2d_init` (or `akgl_render_2d_bind` with a live
|
||||
`SDL_Renderer` on it — see [Chapter 08](08-rendering.md)) comes first.
|
||||
|
||||
## One image, one texture: how sharing actually works
|
||||
|
||||
`akgl_sprite_load_json` does not load an image and hope. It:
|
||||
|
||||
1. resolves the sheet's `filename` to an **absolute, canonical path**;
|
||||
2. looks that path up in `AKGL_REGISTRY_SPRITESHEET`;
|
||||
3. reuses the sheet it finds, or claims one from the pool and loads it.
|
||||
|
||||
**The resolved path is the registry key.** That is what makes ten sprites cut from
|
||||
one image cost one texture, and it is why the resolution rule below matters more
|
||||
than it looks.
|
||||
|
||||
On the reuse path the sheet's reference count is **not** incremented — the sprite
|
||||
borrows it. `akgl_sprite_initialize` does not take a reference either. So:
|
||||
|
||||
> **Releasing a spritesheet out from under a live sprite leaves a dangling
|
||||
> pointer.** Nothing detects it. In practice, release sprites and sheets together
|
||||
> at the same lifecycle boundary (a level change), not individually.
|
||||
|
||||
### Where a sheet filename resolves from
|
||||
|
||||
This is a two-step rule and the first step surprises people:
|
||||
|
||||
```text
|
||||
"spritesheet": { "filename": "hero.png" } in docs/assets/hero_walk.json
|
||||
|
||||
step 1 realpath("hero.png")
|
||||
-> relative to the PROCESS'S CURRENT WORKING DIRECTORY
|
||||
-> if that file exists, that is the sheet. Done.
|
||||
|
||||
step 2 only if step 1 raised ENOENT:
|
||||
realpath(dirname("docs/assets/hero_walk.json") + "/" + "hero.png")
|
||||
-> relative to the SPRITE JSON'S OWN DIRECTORY
|
||||
```
|
||||
|
||||
**A sprite definition can sit next to its image and move with it** — that is step 2,
|
||||
and it is the behaviour the format is designed around. But step 1 runs first, so a
|
||||
file of the same name in the working directory shadows the one beside the JSON. If
|
||||
a sprite loads the wrong art, check the working directory before checking the path.
|
||||
|
||||
Both steps end in `realpath(3)`, so symlinks and `..` are folded out and the key is
|
||||
canonical. Two different spellings of the same file through `akgl_sprite_load_json`
|
||||
therefore land on **one** sheet. (Calling `akgl_spritesheet_initialize` directly
|
||||
skips all of this — it uses whatever string you hand it as the key verbatim, so
|
||||
there two spellings really are two sheets.)
|
||||
|
||||
`AKERR_KEY` from a sheet load usually means the registry is not up. `ENOENT` means
|
||||
neither step found the file.
|
||||
|
||||
## Frames are counted left to right, wrapping down
|
||||
|
||||
A sheet has no grid metadata that the library uses. The grid comes from the
|
||||
*sprite*: `width` and `height` are both the drawn frame size **and** the stride
|
||||
used to find a frame on the sheet.
|
||||
|
||||
```text
|
||||
A 192x96 sheet, with a sprite whose width and height are 48:
|
||||
|
||||
+----+----+----+----+
|
||||
| 0 | 1 | 2 | 3 | frame numbers count left to right from the
|
||||
+----+----+----+----+ top-left, then wrap to the next row
|
||||
| 4 | 5 | 6 | 7 |
|
||||
+----+----+----+----+
|
||||
|
||||
"frames": [ 4, 5, 6, 5 ] an animation is a playlist of frame numbers.
|
||||
index: 0 1 2 3 A frame may repeat; the order is yours.
|
||||
```
|
||||
|
||||
Two indices, and confusing them is the most common mistake here:
|
||||
|
||||
| Name | What it indexes | Where you see it |
|
||||
|---|---|---|
|
||||
| **frame number** | a cell on the sheet | the values inside `"frames"` |
|
||||
| **frame id** | a slot in the `"frames"` playlist | `akgl_Actor::curSpriteFrameId`, and the `frameid` argument to `akgl_spritesheet_coords_for_frame` |
|
||||
|
||||
`akgl_spritesheet_coords_for_frame` takes a **frame id**, looks up
|
||||
`frameids[frameid]`, multiplies by the sprite width, and wraps whole rows off the
|
||||
right-hand edge of the texture, stepping down one sprite height per row. The result
|
||||
is the source rectangle for the blit.
|
||||
|
||||
> **It does not bounds-check `frameid`.** An index past `frames` — or past
|
||||
> `AKGL_SPRITE_MAX_FRAMES` — reads a neighbouring struct member and yields a
|
||||
> nonsense rectangle rather than an error. It also dereferences `sheet->texture`
|
||||
> without checking it, so a registered-but-unloaded sheet is a crash.
|
||||
|
||||
`akgl_SpriteSheet` carries `sprite_w` and `sprite_h` fields. **They are vestigial.**
|
||||
Nothing in the library writes or reads them, and the two arguments
|
||||
`akgl_spritesheet_initialize` takes for them are accepted and discarded. The grid
|
||||
that is actually used is the sprite's `width`/`height`. They are still in the
|
||||
struct and in the signature because removing them is an ABI change.
|
||||
|
||||
## The sprite JSON format
|
||||
|
||||
```json kind=sprite setup=spritesheet
|
||||
{
|
||||
"spritesheet": {
|
||||
"filename": "spritesheet.png",
|
||||
"frame_width": 48,
|
||||
"frame_height": 48
|
||||
},
|
||||
"name": "hero walking left",
|
||||
"width": 48,
|
||||
"height": 48,
|
||||
"speed": 100,
|
||||
"loop": true,
|
||||
"loopReverse": true,
|
||||
"frames": [
|
||||
12,
|
||||
13,
|
||||
14
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `spritesheet.filename` | string | Image path. Resolved as above. Required. |
|
||||
| `spritesheet.frame_width` | integer | Read **only when the sheet is not already loaded**, then discarded. |
|
||||
| `spritesheet.frame_height` | integer | Same. |
|
||||
| `name` | string | Registry key in `AKGL_REGISTRY_SPRITE`. Required. |
|
||||
| `width` | integer | Frame width in pixels, and the horizontal stride. Must be >= 1. |
|
||||
| `height` | integer | Frame height in pixels, and the vertical stride. Must be >= 1. |
|
||||
| `speed` | integer | **Milliseconds** one frame is held. 0 to 4294. |
|
||||
| `loop` | boolean | Restart at the end rather than holding the last frame. |
|
||||
| `loopReverse` | boolean | With `loop`, walk back down instead of jumping to 0 — a ping-pong. |
|
||||
| `frames` | array of integers | Frame numbers, in playback order. At most 16, each 0..255. |
|
||||
|
||||
**Every one of those keys is required.** These are not optional-with-a-default
|
||||
lookups: an absent key is `AKERR_KEY` naming it, and a key of the wrong JSON type is
|
||||
`AKERR_TYPE`. `speed` as a string fails; `frames` as an object fails.
|
||||
|
||||
### speed is milliseconds in the file and nanoseconds in the struct
|
||||
|
||||
`akgl_Sprite::speed` is compared against `SDL_GetCurrentTime`, which counts
|
||||
nanoseconds. Nobody wants to type nanoseconds into an asset file, so the loader
|
||||
reads milliseconds and multiplies by `AKGL_TIME_ONEMS_NS` (1000000).
|
||||
|
||||
**If you set `speed` by hand rather than through the loader, scale it yourself.**
|
||||
Writing `spr->speed = 100` means 100 nanoseconds, and the animation advances every
|
||||
frame.
|
||||
|
||||
The field is `uint32_t`, so the multiply is what bounds the range: values above
|
||||
`UINT32_MAX / AKGL_TIME_ONEMS_NS` — **4294 ms** — would overflow, and are refused
|
||||
with `AKERR_OUTOFBOUNDS` rather than wrapping. A negative `speed` is refused too.
|
||||
|
||||
### What the loader actually validates
|
||||
|
||||
Read against `src/sprite.c`, not against the header prose:
|
||||
|
||||
| Check | Status | Message says |
|
||||
|---|---|---|
|
||||
| `strlen(filename) >= AKGL_MAX_STRING_LENGTH` | `AKERR_OUTOFBOUNDS` | the JSON path is too long for a pooled string |
|
||||
| `width <= 0` | `AKERR_VALUE` | "a sprite must be at least one pixel wide" |
|
||||
| `height <= 0` | `AKERR_VALUE` | "a sprite must be at least one pixel high" |
|
||||
| `speed < 0` or `speed > 4294` | `AKERR_OUTOFBOUNDS` | the value and the permitted range |
|
||||
| `frames` longer than `AKGL_SPRITE_MAX_FRAMES` (16) | `AKERR_OUTOFBOUNDS` | the declared count and the maximum |
|
||||
| a frame number outside 0..255 | `AKERR_OUTOFBOUNDS` | which index, its value, and the range |
|
||||
|
||||
The last two are recent and are worth knowing about because `sprite.h` still says
|
||||
otherwise. Its `@note` on `akgl_sprite_load_json` reads *"The `frames` array is not
|
||||
bounded against `AKGL_SPRITE_MAX_FRAMES`. A definition with more than 16 frames
|
||||
writes past `frameids` into the rest of the struct."* **That is no longer true** —
|
||||
the count is bounded before anything is written, and `tests/assets/` carries
|
||||
fixtures for both the boundary and the overflow. The same `@brief` describes
|
||||
`speed` as *"seconds, scaled to milliseconds"*, which is wrong in both units.
|
||||
Correcting those comments is a separate commit; this chapter documents the code.
|
||||
|
||||
On any failure the pooled sprite **and any sheet loaded for it** are released
|
||||
again, so a bad definition does not strand pool slots.
|
||||
|
||||
## Loading, and building one by hand
|
||||
|
||||
The ordinary path is one call per definition file:
|
||||
|
||||
```c
|
||||
#include <akgl/sprite.h>
|
||||
|
||||
akerr_ErrorContext *load_hero_sprites(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
/* Both cut from one image: the second call reuses the first's texture. */
|
||||
PASS(errctx, akgl_sprite_load_json("assets/hero_walk_left.json"));
|
||||
PASS(errctx, akgl_sprite_load_json("assets/hero_walk_right.json"));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
You can also assemble one from the pools directly, which is what the loader does
|
||||
underneath. `akgl_sprite_initialize` sets the name and the sheet and takes the
|
||||
first reference; **everything else is left at zero for you to fill in**:
|
||||
|
||||
```c
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/sprite.h>
|
||||
|
||||
/*
|
||||
* Two animations cut from one image, built without a JSON file. One sheet,
|
||||
* one texture, two sprites borrowing it.
|
||||
*/
|
||||
akerr_ErrorContext *build_hero_sprites(char *imagepath)
|
||||
{
|
||||
akgl_SpriteSheet *sheet = NULL;
|
||||
akgl_Sprite *walk = NULL;
|
||||
akgl_Sprite *idle = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, imagepath, AKERR_NULLPOINTER, "imagepath");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_spritesheet(&sheet));
|
||||
/* The two size arguments are discarded; the sprite's width/height win. */
|
||||
CATCH(errctx, akgl_spritesheet_initialize(sheet, 48, 48, imagepath));
|
||||
|
||||
CATCH(errctx, akgl_heap_next_sprite(&walk));
|
||||
CATCH(errctx, akgl_sprite_initialize(walk, "hero walking left", sheet));
|
||||
walk->width = 48;
|
||||
walk->height = 48;
|
||||
walk->frames = 3;
|
||||
walk->frameids[0] = 12;
|
||||
walk->frameids[1] = 13;
|
||||
walk->frameids[2] = 14;
|
||||
walk->speed = 100 * AKGL_TIME_ONEMS_NS; /* 100 ms, in nanoseconds */
|
||||
walk->loop = true;
|
||||
walk->loopReverse = true;
|
||||
|
||||
CATCH(errctx, akgl_heap_next_sprite(&idle));
|
||||
CATCH(errctx, akgl_sprite_initialize(idle, "hero standing left", sheet));
|
||||
idle->width = 48;
|
||||
idle->height = 48;
|
||||
idle->frames = 1;
|
||||
idle->frameids[0] = 13;
|
||||
idle->speed = 1000 * AKGL_TIME_ONEMS_NS;
|
||||
idle->loop = false;
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
A string literal is a safe `name` argument. `sprite.h` warns that the name is
|
||||
*"copied at a fixed `AKGL_SPRITE_MAX_NAME_LENGTH` bytes, so a shorter string reads
|
||||
past its end"* — that was a `memcpy` of the full field width and it is gone. The
|
||||
copy is `aksl_strncpy` now, which reads only what is there and always terminates.
|
||||
|
||||
## Animation is the actor's job, not the sprite's
|
||||
|
||||
An `akgl_Sprite` is a description. Nothing in it moves. The advancing is done by
|
||||
the actor holding it — `curSpriteFrameId`, `curSpriteFrameTimer` and
|
||||
`curSpriteReversing` are actor fields, and `changeframefunc` is an actor hook. See
|
||||
[Chapter 12](12-actors.md).
|
||||
|
||||
That is why the same sprite can be shared by a hundred actors that are all on
|
||||
different frames.
|
||||
|
||||
The three flag combinations, as the default `changeframefunc` reads them:
|
||||
|
||||
| `loop` | `loopReverse` | At the last frame |
|
||||
|---|---|---|
|
||||
| true | false | wrap to frame id 0 |
|
||||
| true | true | turn round and walk back down, turning again at 0 |
|
||||
| false | either | **wrap to frame id 0 anyway** |
|
||||
|
||||
The last row is not a typo. A sprite with `loop` clear still wraps rather than
|
||||
holding the final frame; there is no play-once behaviour in the default hook. A
|
||||
game that wants one binds its own `changeframefunc`.
|
||||
|
||||
## Known defects worth knowing here
|
||||
|
||||
- **Truncated names are registry keys.** `akgl_Sprite::name` is 128 bytes and
|
||||
`akgl_SpriteSheet::name` is 512. A longer name truncates *silently*, and two names
|
||||
that truncate the same collide — the second registration replaces the first with no
|
||||
error. Recorded in `TODO.md`, "Truncated registry keys can collide"; the fix is a
|
||||
contract change, since the headers currently promise truncation.
|
||||
- **The heap acquire functions are asymmetric.** `akgl_heap_next_string` increments
|
||||
`refcount`; `next_sprite` and `next_spritesheet` do not. `TODO.md` item 8. See
|
||||
[Chapter 05](05-the-heap.md).
|
||||
- **No test asserts a clean sprite or spritesheet load/release cycle.** The tilemap
|
||||
cycle is asserted over 64 iterations; the sprite, spritesheet and character ones
|
||||
are not. `TODO.md`, "Targets", row 16.
|
||||
- **`akgl_spritesheet_coords_for_frame` bounds-checks nothing.** See above.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 11](11-characters.md) — binding sprites to actor states.
|
||||
- [Chapter 12](12-actors.md) — what actually advances a frame.
|
||||
- [Chapter 05](05-the-heap.md) — the pools these come out of.
|
||||
- [Chapter 06](06-the-registry.md) — how a name becomes a lookup.
|
||||
328
docs/11-characters.md
Normal file
328
docs/11-characters.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# 11. Characters
|
||||
|
||||
**A character is the reusable template. An actor is the instance.**
|
||||
|
||||
Everything that is the same for every goblin on the map — top speed, acceleration,
|
||||
how long an animation frame is held, and which sprite to draw for which combination
|
||||
of state bits — lives in one `akgl_Character` and is shared by pointer. A hundred
|
||||
goblins cost one character. Everything that is unique to a particular goblin — where
|
||||
it is, which way it is going, which frame it is on — lives in its `akgl_Actor`. See
|
||||
[Chapter 12](12-actors.md).
|
||||
|
||||
Characters are pool objects (`akgl_heap_next_character`, 256 slots) published in
|
||||
`AKGL_REGISTRY_CHARACTER` under their name, which is how `akgl_actor_set_character`
|
||||
finds them. `akgl_registry_init` has to have run first.
|
||||
|
||||
```text
|
||||
akgl_Character "hero" akgl_Actor "player"
|
||||
+--------------------------------+ +---------------------+
|
||||
| sx, sy, sz top speeds |<---------| basechar |
|
||||
| ax, ay, az accelerations | borrowed| x, y, z |
|
||||
| speedtime frame dwell (ns) | | state (bitmask) |
|
||||
| state_sprites -----------------+---+ | curSpriteFrameId |
|
||||
+--------------------------------+ | +---------------------+
|
||||
|
|
||||
SDL_PropertiesID keyed by the DECIMAL state bitmask
|
||||
"18" -> akgl_Sprite *hero standing left
|
||||
"146" -> akgl_Sprite *hero walking left
|
||||
```
|
||||
|
||||
## The state-to-sprite map is an exact-match lookup
|
||||
|
||||
`state_sprites` is an `SDL_PropertiesID`. The key is **the decimal spelling of the
|
||||
whole state value** — `SDL_itoa(state, buf, 10)` — and the value is an
|
||||
`akgl_Sprite *`.
|
||||
|
||||
Two things follow, and both of them shape how you build a character:
|
||||
|
||||
**1. The key is the entire state word, not a set of bits to match against.** A
|
||||
sprite added for `FACE_LEFT | MOVING_LEFT` is *not* found by a lookup for
|
||||
`FACE_LEFT`. There is no subset fallback, no wildcard, no priority order, and no
|
||||
default sprite. **You need a mapping for every state combination the character can
|
||||
actually be in.**
|
||||
|
||||
**2. A missing mapping is an error, not a quiet miss.** `akgl_character_sprite_get`
|
||||
raises `AKERR_KEY` — this is the one lookup in the library that treats absence as a
|
||||
failure rather than as a successful answer of "nothing here", on the grounds that an
|
||||
actor with no sprite for its current state cannot be drawn. `*dest` is set to `NULL`
|
||||
alongside the error.
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
|
||||
/*
|
||||
* sprite_get treats "no mapping" as AKERR_KEY. Handle the status; do not test
|
||||
* the return value for NULL and hope.
|
||||
*/
|
||||
akerr_ErrorContext *sprite_or_none(akgl_Character *basechar, int state, akgl_Sprite **dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, basechar, AKERR_NULLPOINTER, "basechar");
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
*dest = NULL;
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, basechar->sprite_get(basechar, state, dest));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} HANDLE(errctx, AKERR_KEY) {
|
||||
/* No sprite for this exact combination. *dest is already NULL. */
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_actor_update` and `akgl_actor_render` both swallow that `AKERR_KEY` themselves
|
||||
— states change faster than art gets drawn, and a missing sprite should not stop the
|
||||
frame — so an under-mapped character shows up as an actor that silently does not
|
||||
draw. That is the symptom to recognize.
|
||||
|
||||
### The combinations you will actually need
|
||||
|
||||
Four facings, moving and standing, alive: eight mappings for the simplest walking
|
||||
character. Written out, with the decimal key each one lands on:
|
||||
|
||||
| Combination | Decimal key | What it draws |
|
||||
|---|---|---|
|
||||
| `ALIVE` | 16 | standing still, **no facing** — see below |
|
||||
| `ALIVE\|FACE_DOWN` | 17 | standing, facing the camera |
|
||||
| `ALIVE\|FACE_LEFT` | 18 | standing, facing left |
|
||||
| `ALIVE\|FACE_RIGHT` | 20 | standing, facing right |
|
||||
| `ALIVE\|FACE_UP` | 24 | standing, facing away |
|
||||
| `ALIVE\|FACE_DOWN\|MOVING_DOWN` | 1041 | walking towards the camera |
|
||||
| `ALIVE\|FACE_LEFT\|MOVING_LEFT` | 146 | walking left |
|
||||
| `ALIVE\|FACE_RIGHT\|MOVING_RIGHT` | 276 | walking right |
|
||||
| `ALIVE\|FACE_UP\|MOVING_UP` | 536 | walking away |
|
||||
|
||||
**Do not skip the first row.** The default `facefunc`
|
||||
(`akgl_actor_automatic_face`) clears every facing bit and then sets one only if a
|
||||
movement bit is set — so an actor that stops moving is left with *no* facing bit at
|
||||
all, and its state drops to bare `ALIVE`. Without a mapping for 16 it vanishes the
|
||||
moment it stops. This is a known rough edge; the implementation carries a `TODO`
|
||||
saying as much, and [Chapter 12](12-actors.md) covers the workaround.
|
||||
|
||||
The decimals are shown to make the mechanism concrete. **Write the constants**, or
|
||||
the state-name strings in JSON — never the numbers.
|
||||
|
||||
## Adding a mapping
|
||||
|
||||
`akgl_character_sprite_add` takes a reference on the sprite, so the character keeps
|
||||
it alive. The reference is taken *before* the property write, so a failed write
|
||||
cannot leave the sprite bound with no reference behind it.
|
||||
|
||||
Rebinding a state that is already mapped releases the sprite it displaced. **The
|
||||
header says otherwise** — `character.h`'s `@note` on `akgl_character_sprite_add`
|
||||
claims the displaced sprite's reference "is never given back". That was true and is
|
||||
not; `src/character.c` reads the old binding out first and releases it, with a
|
||||
comment explaining that without it a character rebinding a state while alive leaks
|
||||
one sprite slot per rebind.
|
||||
|
||||
> **A defect the release path does have.** The release is guarded by
|
||||
> `(displaced != NULL) && (displaced != ref)`. Re-adding the *same* sprite for the
|
||||
> *same* state therefore increments its reference count and releases nothing — one
|
||||
> leaked reference per redundant call. It is idempotent-looking and is not.
|
||||
> Harmless in a loader that runs once; not harmless in a loop.
|
||||
|
||||
State `0` is accepted and is a usable key.
|
||||
|
||||
## Building a character in code
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/character.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
|
||||
akerr_ErrorContext *build_hero(akgl_Sprite *idle_left, akgl_Sprite *walk_left)
|
||||
{
|
||||
akgl_Character *hero = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, idle_left, AKERR_NULLPOINTER, "idle_left");
|
||||
FAIL_ZERO_RETURN(errctx, walk_left, AKERR_NULLPOINTER, "walk_left");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_character(&hero));
|
||||
/* Zeroes the struct, creates the map, binds the two hooks, registers. */
|
||||
CATCH(errctx, akgl_character_initialize(hero, "hero"));
|
||||
|
||||
/* Everything numeric is left at zero for us. */
|
||||
hero->speedtime = 100 * AKGL_TIME_ONEMS_NS; /* 100 ms, in nanoseconds */
|
||||
hero->sx = 0.20f;
|
||||
hero->sy = 0.20f;
|
||||
hero->ax = 0.20f;
|
||||
hero->ay = 0.20f;
|
||||
|
||||
/* One mapping per exact combination we intend to draw. */
|
||||
CATCH(errctx,
|
||||
hero->sprite_add(
|
||||
hero,
|
||||
idle_left,
|
||||
AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT)
|
||||
);
|
||||
CATCH(errctx,
|
||||
hero->sprite_add(
|
||||
hero,
|
||||
walk_left,
|
||||
AKGL_ACTOR_STATE_ALIVE
|
||||
| AKGL_ACTOR_STATE_FACE_LEFT
|
||||
| AKGL_ACTOR_STATE_MOVING_LEFT)
|
||||
);
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`sprite_add` and `sprite_get` are function pointers on the struct, bound to
|
||||
`akgl_character_sprite_add` and `akgl_character_sprite_get` by
|
||||
`akgl_character_initialize`. Call through the pointers, as with the render backend
|
||||
in [Chapter 08](08-rendering.md) — that is what lets a game substitute its own
|
||||
lookup, a state machine with fallbacks for instance, on one character.
|
||||
|
||||
`az` and `sz` are **not** read from JSON. They stay 0 unless you set them by hand.
|
||||
|
||||
## The character JSON format
|
||||
|
||||
```json kind=character setup=character
|
||||
{
|
||||
"name": "hero",
|
||||
"speedtime": 100,
|
||||
"speed_x": 0.20,
|
||||
"speed_y": 0.20,
|
||||
"acceleration_x": 0.20,
|
||||
"acceleration_y": 0.20,
|
||||
"sprite_mappings": [
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE",
|
||||
"AKGL_ACTOR_STATE_FACE_LEFT"
|
||||
],
|
||||
"sprite": "hero standing left"
|
||||
},
|
||||
{
|
||||
"state": [
|
||||
"AKGL_ACTOR_STATE_ALIVE",
|
||||
"AKGL_ACTOR_STATE_FACE_LEFT",
|
||||
"AKGL_ACTOR_STATE_MOVING_LEFT"
|
||||
],
|
||||
"sprite": "hero walking left"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Meaning |
|
||||
|---|---|---|
|
||||
| `name` | string | Registry key in `AKGL_REGISTRY_CHARACTER`. |
|
||||
| `speedtime` | integer | **Milliseconds** a frame is held, scaled to nanoseconds by `AKGL_TIME_ONEMS_NS`. |
|
||||
| `speed_x`, `speed_y` | number | Top speed per axis. Copied onto an actor as `sx`/`sy`. |
|
||||
| `acceleration_x`, `acceleration_y` | number | Acceleration per axis, signed by direction each step. |
|
||||
| `sprite_mappings` | array of objects | One entry per state combination. |
|
||||
| `…[].sprite` | string | A name already in `AKGL_REGISTRY_SPRITE`. |
|
||||
| `…[].state` | array of strings | State names, **OR-ed together** into one key. |
|
||||
|
||||
All of them are required. An absent key is `AKERR_KEY` naming it; a wrong type is
|
||||
`AKERR_TYPE`.
|
||||
|
||||
`character.h`'s `@brief` describes `speedtime` as "in seconds". It is milliseconds
|
||||
— the struct field's own documentation says so and `src/character.c` multiplies by
|
||||
`AKGL_TIME_ONEMS_NS`.
|
||||
|
||||
**Sprites must be loaded first.** This loads characters, not sprites: every name in
|
||||
a `sprite` field has to already be in `AKGL_REGISTRY_SPRITE`, so
|
||||
`akgl_sprite_load_json` runs before `akgl_character_load_json`. A mapping naming an
|
||||
unregistered sprite fails with `AKERR_NULLPOINTER` (not `AKERR_KEY` — it is a
|
||||
lookup miss reported with a pointer status), and the message names the character,
|
||||
the state and the sprite.
|
||||
|
||||
Two keys you may see in existing files and should not copy: `movementspeed` and
|
||||
`velocity_x`. **Neither is read by anything.** They are ignored, silently.
|
||||
|
||||
### State names are prefixed
|
||||
|
||||
The strings come from `AKGL_ACTOR_STATE_STRING_NAMES` in
|
||||
`src/actor_state_string_names.c`, which `akgl_registry_init_actor_state_strings`
|
||||
turns into a registry. Entry `i` names the bit `1 << i`, and **every name carries
|
||||
the `AKGL_ACTOR_STATE_` prefix**:
|
||||
|
||||
```text
|
||||
AKGL_ACTOR_STATE_FACE_DOWN AKGL_ACTOR_STATE_MOVING_LEFT
|
||||
AKGL_ACTOR_STATE_FACE_LEFT AKGL_ACTOR_STATE_MOVING_RIGHT
|
||||
AKGL_ACTOR_STATE_FACE_RIGHT AKGL_ACTOR_STATE_MOVING_UP
|
||||
AKGL_ACTOR_STATE_FACE_UP AKGL_ACTOR_STATE_MOVING_DOWN
|
||||
AKGL_ACTOR_STATE_ALIVE AKGL_ACTOR_STATE_MOVING_IN
|
||||
AKGL_ACTOR_STATE_DYING AKGL_ACTOR_STATE_MOVING_OUT
|
||||
AKGL_ACTOR_STATE_DEAD AKGL_ACTOR_STATE_UNDEFINED_13 .. _31
|
||||
```
|
||||
|
||||
A name that is not in that list is `AKERR_KEY`, "Unknown actor state", quoting it —
|
||||
a typo is refused rather than skipped, because a skipped typo would bind a sprite to
|
||||
the wrong combination and show up as art that never appears. An empty `state` array
|
||||
is legal and gives you the key `0`.
|
||||
|
||||
Adding a new state means adding a bit in `include/akgl/actor.h` **and** its name at
|
||||
the matching index in `src/actor_state_string_names.c`. A bit whose name is missing
|
||||
cannot be referred to from JSON at all — which is exactly what happened to
|
||||
`MOVING_IN` and `MOVING_OUT` until 0.5.0.
|
||||
|
||||
> ### `util/assets/littleguy.json` is invalid against the current loader
|
||||
>
|
||||
> The sample data for the one shipped demo does not load, and it is worth naming
|
||||
> the three separate reasons because each is a different class of drift:
|
||||
>
|
||||
> 1. **The state names are unprefixed** — `"ACTOR_STATE_ALIVE"` rather than
|
||||
> `"AKGL_ACTOR_STATE_ALIVE"`. Every one fails with `AKERR_KEY`, "Unknown actor
|
||||
> state".
|
||||
> 2. **It has `velocity_x` and `velocity_y`**, which nothing reads, and lacks
|
||||
> `speed_x` and `speed_y`, which are required.
|
||||
> 3. **It has no `speedtime`, `acceleration_x` or `acceleration_y`** either — all
|
||||
> three required, all three `AKERR_KEY`.
|
||||
>
|
||||
> `tests/assets/testcharacter.json` is the file to copy from. Fixing the sample is
|
||||
> out of scope here (`plan.md`, "Out of scope, deliberately"); the finding is
|
||||
> recorded so nobody quotes it forward.
|
||||
|
||||
## Teardown
|
||||
|
||||
`akgl_heap_release_character` decrements the reference count, and at zero it walks
|
||||
`state_sprites` with `akgl_character_state_sprites_iterate`, releasing every sprite
|
||||
reference `sprite_add` took, destroys the property set, clears the registry entry
|
||||
and zeroes the slot.
|
||||
|
||||
That walk is an `SDL_EnumerateProperties` callback, so it returns `void` and ends in
|
||||
`FINISH_NORETURN`. **An error inside it exits the process** via libakerror's default
|
||||
unhandled-error handler. The same hazard, in its more common form, is in
|
||||
[Chapter 12](12-actors.md) under `akgl_registry_iterate_actor`; install your own
|
||||
`akerr_handler_unhandled_error` if a game should survive it.
|
||||
|
||||
An actor **borrows** its character and takes no reference on it. Releasing a
|
||||
character out from under a live actor leaves a dangling `basechar`.
|
||||
|
||||
## Known defects worth knowing here
|
||||
|
||||
- **Re-adding the same sprite for the same state leaks a reference.** See above.
|
||||
- **Long names register and unregister under different keys.**
|
||||
`akgl_character_initialize` writes the registry entry under the *caller's* `name`
|
||||
pointer, while `akgl_heap_release_character` clears it under the character's own
|
||||
truncated 128-byte copy. For a name over 127 bytes those differ, and the registry
|
||||
entry survives the release — pointing at a zeroed slot. Latent for ordinary names;
|
||||
related to `TODO.md`, "Truncated registry keys can collide", which covers the
|
||||
collision half.
|
||||
- **`speedtime` is written through an `int *` cast of a `uint64_t` field.**
|
||||
`src/character.c` passes `(int *)&obj->speedtime` to
|
||||
`akgl_get_json_integer_value`. It works because the struct was zeroed and the
|
||||
platform is little-endian; it is the same class of cast-over-a-signature-mismatch
|
||||
that `AGENTS.md` argues against under "Never silence a warning with a cast".
|
||||
- **`akgl_character_initialize` silently replaces a same-named character**, and the
|
||||
one it displaced becomes unreachable rather than being released.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 10](10-spritesheets-and-sprites.md) — the sprites these map to.
|
||||
- [Chapter 12](12-actors.md) — the state bitmask itself, and what sets it.
|
||||
- [Chapter 14](14-physics.md) — what `sx`/`sy` and `ax`/`ay` mean once an actor is
|
||||
simulated.
|
||||
447
docs/12-actors.md
Normal file
447
docs/12-actors.md
Normal file
@@ -0,0 +1,447 @@
|
||||
# 12. Actors
|
||||
|
||||
An actor is a live thing in the world. It carries only what is unique to it — where
|
||||
it is, which way it is moving, which animation frame it is on — and borrows speed,
|
||||
acceleration and the whole state-to-sprite map from the `akgl_Character` it points
|
||||
at. That is what makes a hundred of one kind of thing cheap. See
|
||||
[Chapter 11](11-characters.md) for the other half.
|
||||
|
||||
Actors are pool objects (`akgl_heap_next_actor`, 64 slots by default) published in
|
||||
`AKGL_REGISTRY_ACTOR` under their name.
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
|
||||
akerr_ErrorContext *spawn_player(void)
|
||||
{
|
||||
akgl_Actor *player = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_actor(&player));
|
||||
/* Zeroes it, sets scale 1.0 and movement_controls_face, installs the
|
||||
six default hooks, registers it, takes the first reference. */
|
||||
CATCH(errctx, akgl_actor_initialize(player, "player"));
|
||||
/* Until this runs the actor cannot update, render or simulate. */
|
||||
CATCH(errctx, akgl_actor_set_character(player, "hero"));
|
||||
|
||||
player->state = AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN;
|
||||
player->visible = true;
|
||||
player->layer = 1;
|
||||
player->x = 64.0f;
|
||||
player->y = 64.0f;
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_actor_initialize` does **not** set a character. `akgl_actor_set_character`
|
||||
looks one up by name, copies its `sx`/`sy` onto the actor and zeroes `ax`/`ay` so it
|
||||
starts from rest. The character is borrowed, not referenced. A name that is not
|
||||
registered comes back as `AKERR_NULLPOINTER` — a lookup miss reported with a pointer
|
||||
status rather than `AKERR_KEY`.
|
||||
|
||||
> `sz` is not copied and `az` is not zeroed, so an actor's depth speed keeps
|
||||
> whatever it had. The default movement logic re-copies all three every step, which
|
||||
> papers over it for anything that simulates.
|
||||
|
||||
## State is a 32-bit bitmask
|
||||
|
||||
An actor is facing left *and* moving left *and* alive at the same time, and **the
|
||||
whole combination is the key that selects a sprite**. It is not an enum and there is
|
||||
no current-state field.
|
||||
|
||||
Thirteen bits are defined:
|
||||
|
||||
```c excerpt=include/akgl/actor.h
|
||||
#define AKGL_ACTOR_STATE_FACE_DOWN (1 << 0) // 1 0000 0000 0000 0001
|
||||
#define AKGL_ACTOR_STATE_FACE_LEFT (1 << 1) // 2 0000 0000 0000 0010
|
||||
#define AKGL_ACTOR_STATE_FACE_RIGHT (1 << 2) // 4 0000 0000 0000 0100
|
||||
#define AKGL_ACTOR_STATE_FACE_UP (1 << 3) // 8 0000 0000 0000 1000
|
||||
#define AKGL_ACTOR_STATE_ALIVE (1 << 4) // 16 0000 0000 0001 0000
|
||||
#define AKGL_ACTOR_STATE_DYING (1 << 5) // 32 0000 0000 0010 0000
|
||||
#define AKGL_ACTOR_STATE_DEAD (1 << 6) // 64 0000 0000 0100 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_LEFT (1 << 7) // 128 0000 0000 1000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_RIGHT (1 << 8) // 256 0000 0001 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_UP (1 << 9) // 512 0000 0010 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_DOWN (1 << 10) // 1024 0000 0100 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_IN (1 << 11) // 2048 0000 1000 0000 0000
|
||||
#define AKGL_ACTOR_STATE_MOVING_OUT (1 << 12) // 4096 0001 0000 0000 0000
|
||||
```
|
||||
|
||||
The trailing bit pattern in that table is the position **within its own 16-bit
|
||||
half**, not the full 32-bit value. `actor.h` says so at the top of the block; read
|
||||
as a whole word it looks like bit 16 restarts at bit 0, which it does not.
|
||||
|
||||
**Bits 13 through 31 are undefined and are yours.** They are declared as
|
||||
`AKGL_ACTOR_STATE_UNDEFINED_13` .. `_31` and — this is the useful part — they are
|
||||
*already named* at the matching indices in `src/actor_state_string_names.c`, so a
|
||||
character JSON can bind a sprite to one without any code change. Renaming a bit means
|
||||
editing both files at the same index; see [Chapter 11](11-characters.md).
|
||||
|
||||
`state` is an `int32_t`, so bit 31 makes it negative and its decimal property key is
|
||||
`-2147483648`. It works. Prefer the low undefined bits.
|
||||
|
||||
Two composite masks are provided:
|
||||
|
||||
```c excerpt=include/akgl/actor.h
|
||||
#define AKGL_ACTOR_STATE_FACE_ALL (AKGL_ACTOR_STATE_FACE_DOWN | AKGL_ACTOR_STATE_FACE_LEFT | AKGL_ACTOR_STATE_FACE_RIGHT | AKGL_ACTOR_STATE_FACE_UP)
|
||||
#define AKGL_ACTOR_STATE_MOVING_ALL (AKGL_ACTOR_STATE_MOVING_LEFT | AKGL_ACTOR_STATE_MOVING_RIGHT | AKGL_ACTOR_STATE_MOVING_UP | AKGL_ACTOR_STATE_MOVING_DOWN)
|
||||
```
|
||||
|
||||
**`MOVING_ALL` does not include `MOVING_IN` or `MOVING_OUT`.** It is the four
|
||||
planar directions only. Clearing it leaves the two depth bits set.
|
||||
|
||||
### AKGL_BITMASK_HAS means all of them, not any of them
|
||||
|
||||
The four manipulation macros live in `game.h`:
|
||||
|
||||
| Macro | Means |
|
||||
|---|---|
|
||||
| `AKGL_BITMASK_HAS(x, y)` | **every** bit of `y` is set in `x` — `((x) & (y)) == (y)` |
|
||||
| `AKGL_BITMASK_HASNOT(x, y)` | at least one bit of `y` is missing from `x` |
|
||||
| `AKGL_BITMASK_ADD(x, y)` | set every bit of `y` in `x`; modifies `x` |
|
||||
| `AKGL_BITMASK_DEL(x, y)` | clear every bit of `y` in `x`; modifies `x` |
|
||||
|
||||
The "all, not any" reading is the one that catches people. `AKGL_BITMASK_HAS(state,
|
||||
AKGL_ACTOR_STATE_FACE_ALL)` asks whether the actor is facing **all four ways at
|
||||
once**, which is never. To ask "is it facing at all", test
|
||||
`(state & AKGL_ACTOR_STATE_FACE_ALL)` directly, or `AKGL_BITMASK_HASNOT` against a
|
||||
single bit.
|
||||
|
||||
All five macros are fully parenthesized, so `!AKGL_BITMASK_HAS(a, b)` means what it
|
||||
reads as. Until 0.5.0 it expanded to a bare `(x & y) == y` and a negation bound to
|
||||
the `&`, parsing as `!(a & b) == b`. Nothing in the tree negated it, which is the
|
||||
only reason it was latent rather than live — and the test that distinguishes the
|
||||
two parses is a bit that is *not* set whose value is *not* 1, not the obvious one.
|
||||
|
||||
## The six behaviour hooks
|
||||
|
||||
**Behaviour attaches as function pointers, not by inheritance.** There is no actor
|
||||
subclass. `akgl_actor_initialize` installs the library's defaults on every actor,
|
||||
and a game that wants a different flavour of anything replaces the pointer **on that
|
||||
one actor**.
|
||||
|
||||
| Hook | Default | Called by | Job |
|
||||
|---|---|---|---|
|
||||
| `updatefunc` | `akgl_actor_update` | `akgl_game_update`'s sweep | per-frame logic: `facefunc`, then advance the animation if due |
|
||||
| `renderfunc` | `akgl_actor_render` | `akgl_render_2d_draw_world` | per-frame draw |
|
||||
| `facefunc` | `akgl_actor_automatic_face` | `akgl_actor_update` | choose the facing bits |
|
||||
| `movementlogicfunc` | `akgl_actor_logic_movement` | `akgl_physics_simulate`, before gravity | turn movement bits into signed acceleration |
|
||||
| `changeframefunc` | `akgl_actor_logic_changeframe` | `akgl_actor_update`, when the frame is due | step to the next animation frame |
|
||||
| `addchild` | `akgl_actor_add_child` | you | attach a child actor |
|
||||
|
||||
Replace them after `akgl_actor_initialize`, never before — it overwrites all six.
|
||||
|
||||
The `movementlogicfunc` slot is the interesting one, because it is where
|
||||
`AKGL_ERR_LOGICINTERRUPT` stops being a table row in [Chapter 04](04-errors.md) and
|
||||
becomes something you write. It is **not a failure**; it is a control signal meaning
|
||||
"skip the rest of this tick for this actor", and `akgl_physics_simulate` swallows it:
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/*
|
||||
* A movementlogicfunc that freezes a dying actor. AKGL_ERR_LOGICINTERRUPT is a
|
||||
* control signal, not a failure -- akgl_physics_simulate swallows it and skips
|
||||
* the rest of this actor's step. Only a movementlogicfunc may raise it; from a
|
||||
* backend's gravity or move it aborts the whole step.
|
||||
*/
|
||||
static akerr_ErrorContext *frozen_movement(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
if ( AKGL_BITMASK_HAS(obj->state, AKGL_ACTOR_STATE_DYING) ) {
|
||||
FAIL_RETURN(
|
||||
errctx,
|
||||
AKGL_ERR_LOGICINTERRUPT,
|
||||
"%s is dying; skip its step",
|
||||
(char *)obj->name);
|
||||
}
|
||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *freeze_the_dying(akgl_Actor *obj)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
obj->movementlogicfunc = &frozen_movement;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The default `movementlogicfunc` re-copies the character's speed limits onto the
|
||||
actor each step — so editing a character takes effect next step — and sets `ax`/`ay`
|
||||
to plus or minus the character's acceleration according to which movement bits are
|
||||
set. It integrates nothing; the physics backend does that
|
||||
([Chapter 14](14-physics.md)). Opposing bits do not cancel: `MOVING_LEFT` wins over
|
||||
`MOVING_RIGHT` because it is tested first. **An actor with no movement bits keeps
|
||||
its previous acceleration** rather than being zeroed, which is why the input
|
||||
handlers clear it themselves on release.
|
||||
|
||||
The default `facefunc` clears every facing bit and sets the one matching the first
|
||||
movement bit it finds, in the order left, right, up, down — so a diagonal faces its
|
||||
horizontal component. An actor with `movement_controls_face` clear is left alone
|
||||
entirely, which is the hook for something that aims independently of the way it
|
||||
walks.
|
||||
|
||||
> **An actor that stops moving is left with no facing bit at all**, rather than
|
||||
> keeping the way it was last facing. Its state drops to bare `ALIVE` and no longer
|
||||
> matches a "standing still facing left" sprite. The implementation carries a `TODO`
|
||||
> saying as much. Two workarounds: map bare `ALIVE` to something, or clear
|
||||
> `movement_controls_face` and set the facing bits yourself.
|
||||
|
||||
## layer
|
||||
|
||||
`layer` is which tilemap layer the actor is drawn on, and it is set from the object
|
||||
layer it was placed in. `akgl_render_2d_draw_world` interleaves the map's layers
|
||||
with the actors standing on them, in ascending order — see
|
||||
[Chapter 08](08-rendering.md).
|
||||
|
||||
**Only layers 0 through 15 are drawn.** The field is a `uint32_t` and nothing
|
||||
range-checks it, but `draw_world`'s loop stops at `AKGL_TILEMAP_MAX_LAYERS` (16). An
|
||||
actor on layer 16 is never drawn and never reports why.
|
||||
|
||||
## Parents and children
|
||||
|
||||
`addchild` attaches one actor to another so it moves with it — a carried lantern, a
|
||||
turret on a tank, a party member walking behind the hero. Up to
|
||||
`AKGL_ACTOR_MAX_CHILDREN` (8) per parent.
|
||||
|
||||
**A child is not simulated.** The physics step reaches it, sees a parent, snaps it
|
||||
to the parent's position plus its own offset, and moves on: no thrust, no gravity,
|
||||
no drag, no acceleration. That is the whole behaviour, and games that use it depend
|
||||
on exactly that.
|
||||
|
||||
The parent takes a reference on the child, and **releasing a parent releases every
|
||||
child with it, recursively**. There is no detach function; `AKERR_RELATIONSHIP` is
|
||||
what you get for attaching an actor that already has a parent, and the only way out
|
||||
is to release and rebuild. Running out of slots is `AKERR_OUTOFBOUNDS`.
|
||||
|
||||
> **Nothing checks for a cycle.** Making an actor its own ancestor makes
|
||||
> `akgl_heap_release_actor` recurse until the stack runs out. This builds a tree,
|
||||
> not a graph, and the tree-ness is your responsibility.
|
||||
|
||||
### The offset lives in two places, and they disagree
|
||||
|
||||
This is a real defect and it will bite anyone using children, so it gets stated
|
||||
plainly rather than buried:
|
||||
|
||||
```text
|
||||
akgl_physics_simulate: actor->x = parent->x + actor->vx <- offset is vx
|
||||
akgl_actor_render: dest.x = parent->x + actor->x <- offset is x
|
||||
```
|
||||
|
||||
`src/physics.c` treats `vx`/`vy`/`vz` as the offset and writes the result into
|
||||
`x`/`y`/`z`, turning the child's `x` into an absolute world position.
|
||||
`src/actor.c` then treats `x`/`y` as an offset and adds the parent's position **a
|
||||
second time**. In `akgl_game_update` the physics step runs before the draw, so a
|
||||
child is drawn one full parent-offset away from where it should be.
|
||||
|
||||
`actor.h` documents both readings, in two different places, without noticing they
|
||||
contradict — `akgl_Actor::x` says "For a child, an offset from the parent" and
|
||||
`akgl_Actor::vy` says "On a child actor this is read as an offset from the parent
|
||||
instead."
|
||||
|
||||
It is invisible when the parent sits at the origin, which is why it survived. Until
|
||||
it is fixed, the guard is: **do not call `akgl_actor_render` for a child through
|
||||
`draw_world` if you also run the physics step.** Bind a `renderfunc` on the child
|
||||
that draws at `obj->x`/`obj->y` without re-adding the parent, or keep children
|
||||
purely decorative and position them yourself.
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/heap.h>
|
||||
|
||||
/* A lantern carried 8 px right of and 8 px above the player. */
|
||||
akerr_ErrorContext *attach_lantern(akgl_Actor *player)
|
||||
{
|
||||
akgl_Actor *lantern = NULL;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_heap_next_actor(&lantern));
|
||||
CATCH(errctx, akgl_actor_initialize(lantern, "player lantern"));
|
||||
CATCH(errctx, akgl_actor_set_character(lantern, "lantern"));
|
||||
lantern->state = AKGL_ACTOR_STATE_ALIVE;
|
||||
lantern->visible = true;
|
||||
lantern->layer = player->layer;
|
||||
/* The physics step reads the offset out of vx/vy/vz. */
|
||||
lantern->vx = 8.0f;
|
||||
lantern->vy = -8.0f;
|
||||
CATCH(errctx, player->addchild(player, lantern));
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
## The eight control handlers
|
||||
|
||||
The `akgl_actor_cmhf_*` functions ("control map handler function") are what
|
||||
`akgl_controller_default` binds the arrow keys and the D-pad to, and what a game's
|
||||
own `akgl_Control` bindings can point at. They take the control map's **target
|
||||
actor**, not a global "player", so they work for any number of locally controlled
|
||||
actors. See [Chapter 15](15-input.md).
|
||||
|
||||
| Handler | Sets | Clears first | Writes |
|
||||
|---|---|---|---|
|
||||
| `akgl_actor_cmhf_left_on` | `MOVING_LEFT \| FACE_LEFT` | `FACE_ALL \| MOVING_ALL` | `ax = -basechar->ax` |
|
||||
| `akgl_actor_cmhf_left_off` | — | `MOVING_LEFT` | `ax = 0`, `tx = 0` |
|
||||
| `akgl_actor_cmhf_right_on` | `MOVING_RIGHT \| FACE_RIGHT` | `FACE_ALL \| MOVING_ALL` | `ax = basechar->ax` |
|
||||
| `akgl_actor_cmhf_right_off` | — | `MOVING_RIGHT` | `ax = 0`, `tx = 0` |
|
||||
| `akgl_actor_cmhf_up_on` | `MOVING_UP \| FACE_UP` | `FACE_ALL \| MOVING_ALL` | `ay = -basechar->ay` |
|
||||
| `akgl_actor_cmhf_up_off` | — | `MOVING_UP` | `ay = 0`, `ty = 0` |
|
||||
| `akgl_actor_cmhf_down_on` | `MOVING_DOWN \| FACE_DOWN` | `FACE_ALL \| MOVING_ALL` | `ay = basechar->ay` |
|
||||
| `akgl_actor_cmhf_down_off` | — | `MOVING_DOWN` | `ay = 0`, `ty = 0` |
|
||||
|
||||
`ay` is negated for *up* because y grows downward.
|
||||
|
||||
**Two directions is not a diagonal.** Every `_on` handler clears the whole of
|
||||
`FACE_ALL | MOVING_ALL` before setting its own two bits, so holding left and up at
|
||||
once moves in whichever was pressed **last**. That is deliberate — it keeps the
|
||||
state word to one facing and one direction, which keeps the sprite mapping in
|
||||
[Chapter 11](11-characters.md) from needing a mapping per diagonal. A game that
|
||||
wants diagonals binds its own handlers that `AKGL_BITMASK_ADD` without clearing, and
|
||||
maps the extra combinations.
|
||||
|
||||
**The `_off` handlers clear exactly two fields.** `ax` and `tx` for the horizontal
|
||||
pair, `ay` and `ty` for the vertical. They do **not** touch `ex`/`ey` and they do
|
||||
not touch `vx`/`vy`.
|
||||
|
||||
`actor.h` still says otherwise — the block comment above them describes the `_off`
|
||||
handlers as "zeroing acceleration, thrust, environmental velocity and velocity on
|
||||
that axis", and `akgl_actor_cmhf_up_off` and `_down_off` each carry a `@note`
|
||||
saying they zero `ey`. **They did, and that was a defect**: `ey` is where the arcade
|
||||
backend accumulates gravity, so tapping down mid-jump stopped the character in the
|
||||
air. Velocity was never theirs to clear either — `simulate` recomputes `v` as
|
||||
`e + t` every step. Those notes predate the fix.
|
||||
|
||||
> **Known defect (`TODO.md`, "Arcade physics feel").** There is no friction and no
|
||||
> deceleration: zeroing `tx` on release **stops the actor dead**. Correct for Zelda,
|
||||
> wrong for Mario. Bind your own `_off` handler that decays `tx` over time if you
|
||||
> want momentum.
|
||||
|
||||
Releasing left while holding right also stops the rightward movement, since
|
||||
`left_off` clears the whole x axis whichever way the actor was going.
|
||||
|
||||
## Warning: an error inside `akgl_registry_iterate_actor` exits the process
|
||||
|
||||
`akgl_registry_iterate_actor` is an `SDL_EnumerateProperties` callback. SDL
|
||||
callbacks return `void`, so it has nowhere to propagate an error to — it ends in
|
||||
`FINISH_NORETURN`, which logs the stack trace and then calls libakerror's
|
||||
unhandled-error handler, **whose default implementation calls `akerr_exit()` and
|
||||
terminates the process**.
|
||||
|
||||
Everything it can go wrong on is ordinary content:
|
||||
|
||||
- a registry key with no pointer behind it — `AKERR_KEY`;
|
||||
- an actor whose `updatefunc` fails;
|
||||
- `akgl_tilemap_scale_actor` failing;
|
||||
- an actor whose `renderfunc` fails;
|
||||
- a `NULL` `userdata` — the iterator is required despite the `void *`.
|
||||
|
||||
**A wrong name in an asset file therefore kills the game rather than skipping an
|
||||
actor.** This is not theoretical: it is the first thing a reader hits when a sprite
|
||||
or character name is misspelled.
|
||||
|
||||
`akgl_game_update` does not use this callback — it sweeps the actor pool directly —
|
||||
so you reach it by driving a registry sweep yourself, which is what
|
||||
`util/charviewer.c` does:
|
||||
|
||||
```c norun
|
||||
SDL_EnumerateProperties(AKGL_REGISTRY_ACTOR, &akgl_registry_iterate_actor, (void *)&opflags);
|
||||
```
|
||||
|
||||
(`norun` because it needs a live registry and a populated `opflags`; it is quoted to
|
||||
show the shape, and `util/charviewer.c` is the compiled instance of it.)
|
||||
|
||||
The same hazard is in `akgl_character_state_sprites_iterate`, which
|
||||
**`akgl_heap_release_character` calls on every character release** — so that one is
|
||||
on an ordinary path, not an unusual one. See [Chapter 11](11-characters.md).
|
||||
|
||||
If a game should survive these, install your own handler:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
static akerr_ErrorContext *last_callback_error = NULL;
|
||||
|
||||
/*
|
||||
* The default akerr_handler_unhandled_error calls akerr_exit(). Replacing it
|
||||
* is the only way an error raised inside an SDL callback does not take the
|
||||
* process with it. Take a reference: the context is released after this
|
||||
* returns.
|
||||
*/
|
||||
static void survive_unhandled_error(akerr_ErrorContext *errctx)
|
||||
{
|
||||
if ( errctx == NULL ) {
|
||||
return;
|
||||
}
|
||||
LOG_ERROR(errctx);
|
||||
errctx->refcount += 1;
|
||||
last_callback_error = errctx;
|
||||
}
|
||||
|
||||
void install_survivable_handler(void)
|
||||
{
|
||||
akerr_handler_unhandled_error = &survive_unhandled_error;
|
||||
}
|
||||
```
|
||||
|
||||
Note what surviving costs you: the sweep carries on with the *next* entry, and the
|
||||
actor that failed simply did not update or draw this frame. Check
|
||||
`last_callback_error` somewhere, or you have traded a loud crash for a silent one.
|
||||
|
||||
## The rest of the struct
|
||||
|
||||
A few fields worth calling out; the full annotated list is in the generated Doxygen
|
||||
for `akgl_Actor`.
|
||||
|
||||
| Field | Note |
|
||||
|---|---|
|
||||
| `visible` | Deliberate hiding. Off-camera actors are skipped separately. |
|
||||
| `scale` | Draw scale. Written by `akgl_tilemap_scale_actor`, or forced to 1.0 when tilemap scaling is off — **it is overwritten every frame**, so setting it by hand does not stick. |
|
||||
| `actorData` | Your per-actor data. The library never reads or frees it. |
|
||||
| `curSpriteFrameId` | Index into the sprite's `frameids` playlist, **not** a frame number on the sheet. See [Chapter 10](10-spritesheets-and-sprites.md). |
|
||||
| `movetimer` | Unused. Nothing in the library reads or writes it. |
|
||||
| `vx`/`vy`/`vz` | Recomputed each step as `e + t`. Writing them directly is overwritten — except on a child, where they are the offset. |
|
||||
|
||||
## Known defects worth knowing here
|
||||
|
||||
- **The parent/child offset is double-counted at draw time.** See above.
|
||||
- **`akgl_heap_release_actor` recurses with no cycle check.**
|
||||
- **Long names register and unregister under different keys.**
|
||||
`akgl_actor_initialize` writes the registry entry under the *caller's* `name`
|
||||
pointer while `akgl_heap_release_actor` clears it under the actor's truncated
|
||||
128-byte copy. Identical for ordinary names; divergent past 127 bytes, and then
|
||||
the registry keeps an entry pointing at a zeroed slot. Related to `TODO.md`,
|
||||
"Truncated registry keys can collide".
|
||||
- **`akgl_actor_initialize` silently replaces a same-named actor**, and the one it
|
||||
displaced becomes unreachable rather than being released.
|
||||
- **No friction on release**, and **no terminal velocity** under gravity —
|
||||
`TODO.md`, "Arcade physics feel". [Chapter 14](14-physics.md) covers both.
|
||||
|
||||
## Where to look next
|
||||
|
||||
- [Chapter 11](11-characters.md) — the template, and the state-to-sprite map.
|
||||
- [Chapter 14](14-physics.md) — what happens to `t`, `e`, `v` and `a` each step.
|
||||
- [Chapter 15](15-input.md) — control maps, and binding the handlers above.
|
||||
- [Chapter 08](08-rendering.md) — where `renderfunc` gets called from.
|
||||
- [Chapter 04](04-errors.md) — `AKGL_ERR_LOGICINTERRUPT` and the rest of the
|
||||
status tables.
|
||||
406
docs/13-tilemaps.md
Normal file
406
docs/13-tilemaps.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# 13. Tilemaps
|
||||
|
||||
libakgl does not have a map format. It has a *loader* for somebody else's, and the
|
||||
somebody else is the [Tiled map editor](https://www.mapeditor.org/). Levels are authored in
|
||||
Tiled, saved as JSON (`.tmj`), and read by `akgl_tilemap_load`.
|
||||
|
||||
That division is deliberate, and it means this chapter owes you almost nothing about the
|
||||
format itself. What a layer is, what a tileset is, how `firstgid` works, how custom
|
||||
properties are stored — all of that is
|
||||
[the Tiled JSON map format reference](https://doc.mapeditor.org/en/stable/reference/json-map-format/),
|
||||
maintained by the people who write the editor. Read it there.
|
||||
|
||||
What this chapter owes you is the part Tiled cannot tell you: **which subset of the format
|
||||
libakgl actually reads, what it refuses, the three things it adds on top, and how big the
|
||||
resulting object is.** All four have bitten somebody.
|
||||
|
||||
## The size of an `akgl_Tilemap`
|
||||
|
||||
Start here, because it is the one that produces a crash rather than an error message.
|
||||
|
||||
```text
|
||||
sizeof(akgl_Tilemap) = 26,388,008 bytes = 25.2 MiB
|
||||
|
||||
layers[16] 16 x 1,120,296 = 17.1 MiB 512x512 ints of tile data, plus
|
||||
128 x 560-byte objects, per layer
|
||||
tilesets[16] 16 x 528,944 = 8.1 MiB 65,536 x 2 ints of tile offsets,
|
||||
plus a PATH_MAX image path, each
|
||||
everything else = ~0.0 MiB geometry, perspective, physics
|
||||
```
|
||||
|
||||
**An `akgl_Tilemap` belongs in static storage and nowhere else.** A default thread stack is
|
||||
8 MiB on Linux; this object is three times that, so a local one blows the stack before the
|
||||
loader writes its first byte, and it does so as a segfault with no error context. That is
|
||||
why the library's own map, `akgl_default_gamemap`, is a file-scope object in
|
||||
`src/game.c`, and why `akgl_gamemap` is a pointer to it rather than a copy.
|
||||
|
||||
The bounds are fixed at compile time, so the size is fixed too. Every field is present
|
||||
whether the map uses it or not: a 20x15 map with one tileset and two layers costs the same
|
||||
25.2 MiB as a 511x511 one. Raising any of the constants below raises this number and
|
||||
changes the ABI — see [Chapter 21](21-appendix-limits.md).
|
||||
|
||||
## Limits
|
||||
|
||||
```c excerpt=include/akgl/tilemap.h
|
||||
/** @brief Widest map, in tiles. Width times height is what is actually bounded. */
|
||||
#define AKGL_TILEMAP_MAX_WIDTH 512
|
||||
/** @brief Tallest map, in tiles. */
|
||||
#define AKGL_TILEMAP_MAX_HEIGHT 512
|
||||
/** @brief Layers per map. Also the number of draw passes akgl_render_2d_draw_world makes. */
|
||||
#define AKGL_TILEMAP_MAX_LAYERS 16
|
||||
/** @brief Tilesets per map. */
|
||||
#define AKGL_TILEMAP_MAX_TILESETS 16
|
||||
/** @brief Entries in a tileset's offset table. Indexed by *local* tile id, so a tileset with a high `firstgid` still starts at 0. */
|
||||
#define AKGL_TILEMAP_MAX_TILES_PER_IMAGE 65536
|
||||
/** @brief Longest tileset name. */
|
||||
#define AKGL_TILEMAP_MAX_TILESET_NAME_SIZE 512
|
||||
/** @brief Longest resolved tileset image path. */
|
||||
#define AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE PATH_MAX
|
||||
/** @brief Longest object name. Note that an object naming an actor is truncated at #AKGL_ACTOR_MAX_NAME_LENGTH (128) instead. */
|
||||
#define AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE 512
|
||||
/** @brief Objects in one object layer. Not enforced by the loader -- a longer group writes past the array. */
|
||||
#define AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER 128
|
||||
```
|
||||
|
||||
Two of these carry a wrinkle worth knowing before you lay out a level.
|
||||
|
||||
**The width and height bound is on the product, and it is exclusive.** `akgl_tilemap_load`
|
||||
refuses when `width * height >= AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT`, so
|
||||
262,144 cells is one too many: **a literal 512x512 map is rejected with
|
||||
`AKERR_OUTOFBOUNDS`**, and 512x511 is the largest rectangle that loads. The same
|
||||
off-by-one applies per tile layer in `akgl_tilemap_load_layer_tile`. Nothing is wrong with
|
||||
either check other than the `>=`; it is recorded here because "the maximum is 512x512" is
|
||||
what the constant names say and is not what the code does.
|
||||
|
||||
**The doc comment on `AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER` is stale.** It says the loader
|
||||
does not enforce it. The loader does — `akgl_tilemap_load_layer_objects` checks `j` at the
|
||||
top of the loop body and raises `AKERR_OUTOFBOUNDS` naming the layer, and
|
||||
`akgl_tilemap_load_tilesets` does the same against `AKGL_TILEMAP_MAX_TILESETS`. Both were
|
||||
genuinely unbounded and both were fixed in 0.5.0 (`TODO.md`, "Found while rewriting the
|
||||
Doxygen comments" item 17), with fixtures in `tests/tilemap.c` at exactly 128, 129 and 17.
|
||||
Trust the code.
|
||||
|
||||
## What the loader accepts
|
||||
|
||||
The loader reads a strict subset of the TMJ format. Everything in this section is a
|
||||
requirement, not a suggestion: a key that is absent raises `AKERR_KEY` and a key with the
|
||||
wrong JSON type raises `AKERR_TYPE`, both naming what was wanted. See
|
||||
[Chapter 4](04-errors.md) for what those statuses mean in libakgl.
|
||||
|
||||
| Level | Members `akgl_tilemap_load` requires | Notes |
|
||||
|---|---|---|
|
||||
| Map root | `tilewidth`, `tileheight`, `width`, `height`, `layers`, `tilesets` | `orientation` is read from nowhere and forced to 0; isometric is not implemented |
|
||||
| Every layer | `id`, `opacity`, `visible`, `x`, `y`, `type` | A `type` other than the three below keeps these fields and is otherwise skipped |
|
||||
| `tilelayer` | `width`, `height`, `data` | `data` must be a plain array of integers |
|
||||
| `imagelayer` | `image` | Layer `width`/`height` are taken from the loaded texture, not the JSON |
|
||||
| `objectgroup` | `objects` | See below — every object has requirements of its own |
|
||||
| Every object | `name`, `x`, `y`, `visible`, `type` | **Including plain rectangles.** An object with no `type` member fails the whole load |
|
||||
| Every tileset | `columns`, `firstgid`, `imagewidth`, `imageheight`, `margin`, `spacing`, `tilecount`, `tilewidth`, `tileheight`, `name`, `image` | The **embedded** form; see below |
|
||||
|
||||
Three of these deserve their own paragraph.
|
||||
|
||||
**Tilesets must be embedded, not external.** Tiled writes a tileset into the map's
|
||||
`tilesets` array in one of two shapes: the whole tileset inline, or a two-member stub
|
||||
`{"firstgid": N, "source": "tiles.tsj"}` pointing at a separate file.
|
||||
`akgl_tilemap_load_tilesets_each` reads `columns`, `image` and the rest directly out of the
|
||||
array element and there is no code anywhere in `src/` that opens a `source` file, so **an
|
||||
external tileset reference fails with `AKERR_KEY` on the missing `columns`.** Turn
|
||||
*Embed tileset* on when you save, or use Tiled's *Map → Embed Tilesets* command. The
|
||||
shipped fixture `tests/assets/testmap.tmj` is embedded, which is what makes it load.
|
||||
|
||||
**Every object needs a `type` string, and `type` is where the extensions live.** The loader
|
||||
reads it on every object in an object group and compares it against `"actor"` and
|
||||
`"perspective"`; anything else is recorded in the layer's object array and otherwise
|
||||
ignored. An object with the member missing entirely does not reach that comparison — it
|
||||
fails the load. If your editor writes the object's class under a different member name,
|
||||
that is the drift to check first; Tiled's own field naming has changed across releases, and
|
||||
the fixture in this repository is a Tiled 1.8.2 export.
|
||||
|
||||
**The map file's directory is the root every path resolves against**, so a map and its art
|
||||
move together. The two resolvers are not the same, though, and the difference matters:
|
||||
|
||||
| Path | Resolved by | Consequence |
|
||||
|---|---|---|
|
||||
| A tileset's `image` | `akgl_path_relative` | Canonicalized against the map's directory; an absolute path works |
|
||||
| An image layer's `image` | `aksl_snprintf("%s/%s", ...)` | A plain join. **Not** canonicalized, so an absolute path becomes `/maps//art/sky.png` and does not open |
|
||||
|
||||
Both are bounded at `AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE` (`PATH_MAX`). The image-layer
|
||||
join raises `AKERR_OUTOFBOUNDS` naming both lengths when it does not fit; it used to
|
||||
truncate silently, and an over-long path reported itself as a missing file with a name the
|
||||
caller never wrote.
|
||||
|
||||
## libakgl's three extensions to Tiled
|
||||
|
||||
None of these are part of the TMJ format. They are conventions the loader imposes on
|
||||
custom properties and object types, and Tiled will happily author all three without
|
||||
knowing what they mean.
|
||||
|
||||
### 1. Object type `actor` spawns a real actor
|
||||
|
||||
An object whose `type` is the string `"actor"` does not stay a rectangle. The loader
|
||||
claims an actor from the pool, initializes it, binds a character, publishes it in
|
||||
`AKGL_REGISTRY_ACTOR`, and copies the object's position, visibility and layer index onto
|
||||
it. The object's `name` is the registry key. See [Chapter 12](12-actors.md) for what an
|
||||
actor is and [Chapter 11](11-characters.md) for what a character supplies.
|
||||
|
||||
Two custom properties are required on the object:
|
||||
|
||||
| Property | Tiled type | Meaning |
|
||||
|---|---|---|
|
||||
| `character` | `string` | Name of a character already in `AKGL_REGISTRY_CHARACTER` |
|
||||
| `state` | `int` | The actor's initial `AKGL_ACTOR_STATE_*` bitmask, **as a number** |
|
||||
|
||||
**`state` is an integer here, and only here.** Character JSON accepts the string-array form
|
||||
— `["AKGL_ACTOR_STATE_ALIVE", "AKGL_ACTOR_STATE_FACE_DOWN"]` — because
|
||||
`AKGL_ACTOR_STATE_STRING_NAMES` exists to decode it. `akgl_tilemap_load_layer_object_actor`
|
||||
calls `akgl_get_json_properties_integer`, which demands a property Tiled declared as
|
||||
`"int"`, so a map that spells the state as names raises `AKERR_TYPE`. Add up the bit values
|
||||
from [Chapter 12](12-actors.md) and write the sum.
|
||||
|
||||
Two consequences of the registry being the identity:
|
||||
|
||||
- **The characters have to be loaded before the map.** An unregistered `character` name
|
||||
comes back as `AKERR_KEY` from `akgl_actor_set_character`, in the middle of a partly
|
||||
loaded map.
|
||||
- **Two objects naming one actor do not make two actors.** The second placement takes
|
||||
another reference on the first and overwrites its position, so the actor ends up wherever
|
||||
the last object put it. That is the mechanism for placing one actor from two layers; it
|
||||
is not a way to spawn a crowd.
|
||||
|
||||
An `actor` object with an empty `name` is refused with `AKERR_KEY` — an actor has to be
|
||||
addressable.
|
||||
|
||||
### 2. A `physics.model` property gives the map its own physics
|
||||
|
||||
A custom property named `physics.model` on the **map root** hands the map's name to
|
||||
`akgl_physics_factory` and stores the resulting backend in `map->physics`, then sets
|
||||
`map->use_own_physics`. Six more properties configure it, each defaulting to `0.0` when
|
||||
absent:
|
||||
|
||||
| Property | Tiled type | Sets |
|
||||
|---|---|---|
|
||||
| `physics.model` | `string` | Which backend — `null` or `arcade`. See [Chapter 14](14-physics.md) |
|
||||
| `physics.gravity.x` / `.y` / `.z` | `float` | The backend's gravity constants |
|
||||
| `physics.drag.x` / `.y` / `.z` | `float` | The backend's drag coefficients |
|
||||
|
||||
A swimming level and a walking level then differ by data rather than by code.
|
||||
|
||||
**Nothing in the library acts on `use_own_physics`.** `akgl_game_update` steps the global
|
||||
`akgl_physics` and never looks at the map's. The flag is a signal to *you*: read it after
|
||||
loading and point `akgl_physics` at `map->physics` if you want it honoured. The example
|
||||
below does exactly that.
|
||||
|
||||
A map with no `properties` array at all, or with properties but no `physics.model`, is the
|
||||
normal case: `akgl_tilemap_load_physics` returns success and the map uses the game's global
|
||||
backend. The `AKERR_KEY` handlers in that function are the "map did not ask" path, not
|
||||
error paths. A `physics.model` naming a backend that does not exist *is* an error.
|
||||
|
||||
### 3. `p_foreground` and `p_vanishing` define the pseudo-3D band
|
||||
|
||||
Two objects in an object layer, each with `"type": "perspective"` and a `scale` custom
|
||||
property declared `float`, define a band down the screen over which actors are scaled. That
|
||||
is what makes a character look further away as they walk up a top-down map.
|
||||
|
||||
```text
|
||||
y = 0 ----------------------------------------------------- top of the map
|
||||
.
|
||||
. actors above p_vanishing_y stay at
|
||||
. p_vanishing_scale
|
||||
p_vanishing_y ----------- [tiny actor] scale = p_vanishing_scale
|
||||
/ \
|
||||
/ \ linear interpolation between the
|
||||
/ \ two rows, at p_rate per pixel of y
|
||||
/ \
|
||||
p_foreground_y --------- [ big actor ] scale = p_foreground_scale
|
||||
actors below here stay at
|
||||
p_foreground_scale
|
||||
```
|
||||
|
||||
The names are matched literally: `p_foreground` sets the row where actors are at full size,
|
||||
`p_vanishing` the row where they are smallest. **Both the type and the name have to be
|
||||
right.** The loader checks `type == "perspective"` first and only then compares the name,
|
||||
so an object named `p_vanishing` with type `"object"` is silently just an object — no error,
|
||||
no perspective, and a very confusing afternoon.
|
||||
|
||||
Each marker contributes its `y` and its `scale`; `p_rate` is derived at load as the scale
|
||||
change per pixel between the two. **A marker at `y = 0` disables perspective**, because the
|
||||
rate is only computed when both `p_foreground_y` and `p_vanishing_y` are non-zero — and a
|
||||
map with no markers has both scales at 1.0 and a rate of 0, so the whole feature is a no-op
|
||||
rather than a special case. Perspective markers are forced invisible: they are geometry,
|
||||
not scenery.
|
||||
|
||||
`akgl_tilemap_scale_actor` applies the band, writing only the actor's `scale`. It is called
|
||||
per actor by `akgl_game_update` against the **global** `akgl_gamemap` when the frame's
|
||||
iterator carries `AKGL_ITERATOR_OP_TILEMAPSCALE`; see [Chapter 7](07-the-game-and-the-frame.md).
|
||||
The marker heights (`p_foreground_h`, `p_vanishing_h`) are recorded and not used by the
|
||||
current rate calculation.
|
||||
|
||||
## A minimal map
|
||||
|
||||
Two 16-pixel tiles square, one tile layer, one embedded tileset, and one actor object. The
|
||||
state value `17` is `AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_DOWN` — 16 plus 1, from
|
||||
the bit table in [Chapter 12](12-actors.md). Add the values you want; there is no symbolic
|
||||
form on this side.
|
||||
|
||||
```json kind=tilemap setup=tilemap
|
||||
{
|
||||
"compressionlevel": -1,
|
||||
"height": 2,
|
||||
"infinite": false,
|
||||
"nextlayerid": 3,
|
||||
"nextobjectid": 2,
|
||||
"orientation": "orthogonal",
|
||||
"renderorder": "right-down",
|
||||
"tiledversion": "1.8.2",
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16,
|
||||
"type": "map",
|
||||
"version": "1.8",
|
||||
"width": 2,
|
||||
"layers": [
|
||||
{
|
||||
"data": [1, 2, 3, 4],
|
||||
"height": 2,
|
||||
"id": 1,
|
||||
"name": "Tile Layer 1",
|
||||
"opacity": 1,
|
||||
"type": "tilelayer",
|
||||
"visible": true,
|
||||
"width": 2,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
},
|
||||
{
|
||||
"draworder": "topdown",
|
||||
"id": 2,
|
||||
"name": "Object Layer 1",
|
||||
"opacity": 1,
|
||||
"type": "objectgroup",
|
||||
"visible": true,
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"objects": [
|
||||
{
|
||||
"gid": 195,
|
||||
"height": 16,
|
||||
"id": 1,
|
||||
"name": "testactor",
|
||||
"rotation": 0,
|
||||
"type": "actor",
|
||||
"visible": true,
|
||||
"width": 16,
|
||||
"x": 16,
|
||||
"y": 16,
|
||||
"properties": [
|
||||
{ "name": "character", "type": "string", "value": "testcharacter" },
|
||||
{ "name": "state", "type": "int", "value": 17 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tilesets": [
|
||||
{
|
||||
"columns": 48,
|
||||
"firstgid": 1,
|
||||
"image": "assets/tileset.png",
|
||||
"imageheight": 576,
|
||||
"imagewidth": 768,
|
||||
"margin": 0,
|
||||
"name": "World_A1",
|
||||
"spacing": 0,
|
||||
"tilecount": 1728,
|
||||
"tileheight": 16,
|
||||
"tilewidth": 16
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Loading one
|
||||
|
||||
```c
|
||||
#include <akgl/tilemap.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/*
|
||||
* 25 MiB of tilemap. Static storage, never a local: a stack frame this size
|
||||
* overruns the default 8 MiB thread stack before the loader writes a byte.
|
||||
*/
|
||||
static akgl_Tilemap townmap;
|
||||
|
||||
akerr_ErrorContext AKERR_NOIGNORE *load_town(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
/* Reusing a map struct leaks the previous map's textures unless this runs
|
||||
* first -- akgl_tilemap_load zeroes rather than releases. */
|
||||
PASS(errctx, akgl_tilemap_release(&townmap));
|
||||
PASS(errctx, akgl_tilemap_load("assets/town.tmj", &townmap));
|
||||
|
||||
/* The map may have brought its own physics with it. */
|
||||
if ( townmap.use_own_physics == true ) {
|
||||
akgl_physics = &townmap.physics;
|
||||
}
|
||||
akgl_gamemap = &townmap;
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Loading a map has side effects well beyond the destination struct: textures are uploaded,
|
||||
actors are created in the pool, and names are published in the registry. So **the renderer,
|
||||
the pools, the registries and every character the map names have to be in place first.**
|
||||
The startup order that produces that is in [Chapter 7](07-the-game-and-the-frame.md).
|
||||
|
||||
Drawing is per layer. `akgl_tilemap_draw(map, viewport, layeridx)` draws one layer clipped
|
||||
to a viewport in map pixels — usually `akgl_camera` — and draws the tiles at the viewport's
|
||||
edges partially, so scrolling is smooth rather than snapping to the grid. An image layer
|
||||
ignores the viewport and is drawn once at the origin. `akgl_render_2d_draw_world` makes one
|
||||
pass per layer; see [Chapter 8](08-rendering.md).
|
||||
|
||||
## Known defects
|
||||
|
||||
Documented here because this is where a reader hits them. Each is cross-referenced to
|
||||
`TODO.md`.
|
||||
|
||||
**A failed load releases nothing.** `akgl_tilemap_load` zeroes the destination up front and
|
||||
then loads physics, geometry, layers and tilesets in order. If any of that fails, the
|
||||
textures already uploaded and the actors already created stay exactly where they are, and
|
||||
the destination holds a half-built map. `CLEANUP` gives back the JSON document and one pool
|
||||
string and nothing else. **Treat a failed load as needing `akgl_tilemap_release` and a
|
||||
fresh actor registry** before you try again — otherwise the retry inherits the corpse, and
|
||||
`akgl_tilemap_load` will zero the struct over the previous attempt's texture pointers and
|
||||
leak them.
|
||||
|
||||
**`akgl_tilemap_release` does not release the actors an object layer created.** It destroys
|
||||
tileset and image-layer textures and clears each pointer as it goes — that half is correct
|
||||
as of 0.5.0, and a second release is safe rather than a use-after-free (`TODO.md`, "Known
|
||||
and still open" item 2). The actors, and their references on characters and sprites, are
|
||||
yours to release. The header's `@warning` describing a tileset double-free is stale; the
|
||||
loop it describes was fixed and `tests/tilemap.c` releases the fixture three times and
|
||||
asserts every texture pointer is `NULL`.
|
||||
|
||||
**`akgl_tilemap_draw` does not bounds-check `layeridx`.** An index at or past `numlayers`
|
||||
reads a neighbouring layer, and one past 16 reads past the array. `akgl_tilemap_draw_tileset`
|
||||
checks its upper bound but not for a negative index.
|
||||
|
||||
**A tileset's `margin` is recorded and never applied.** `akgl_tilemap_compute_tileset_offsets`
|
||||
steps by tile size plus `spacing` and ignores `margin` entirely, so a tileset image with a
|
||||
border produces offsets shifted by it — every tile drawn from slightly the wrong place. The
|
||||
FIXME is at `src/tilemap.c:187`. Author tilesets with `margin: 0` until it is fixed.
|
||||
|
||||
**A second tileset wastes its whole offset table.** The offset table is indexed by local
|
||||
tile id from 0, so tileset B's entries sit at the front of B's own 65,536-entry table while
|
||||
`akgl_tilemap_draw` indexes it by `tilenum - firstgid` — correct, but it means every
|
||||
tileset pays 512 KiB regardless of how many tiles it has. The FIXME on
|
||||
`akgl_Tileset::tile_offsets` explains it. Consequence for you: prefer one large tileset per
|
||||
map to five small ones.
|
||||
|
||||
**Tileset images do not go through the spritesheet registry**, so an image shared between
|
||||
two maps is loaded twice, into two textures.
|
||||
|
||||
**`numlayers` is set before the layer bound is checked.** `akgl_tilemap_load_layers` assigns
|
||||
the JSON array's length and *then* refuses a seventeenth layer, so on that failure path the
|
||||
struct briefly claims more layers than it has. It is only reachable on a path that has
|
||||
already failed, but do not read `numlayers` off a map whose load raised.
|
||||
366
docs/14-physics.md
Normal file
366
docs/14-physics.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# 14. Physics
|
||||
|
||||
The physics subsystem has the same shape as the renderer: a record of function pointers
|
||||
plus an initializer that fills it in. Two backends ship. `null` accepts every call and
|
||||
changes nothing, which is what a menu screen or a test harness wants. `arcade` applies
|
||||
gravity, drag, thrust and a speed cap, which is what a 2D game wants.
|
||||
|
||||
**There is no "physics body" type.** The body *is* the actor plus the character it
|
||||
instantiates. An actor carries the position and the three velocity terms; its character
|
||||
supplies the accelerations and the top speeds. Nothing else participates. If you have read
|
||||
[Chapter 11](11-characters.md) and [Chapter 12](12-actors.md) you have already met every
|
||||
field this chapter uses.
|
||||
|
||||
## The model
|
||||
|
||||
```text
|
||||
thrust (tx, ty, tz) the actor's own effort, while it is moving
|
||||
+
|
||||
environ (ex, ey, ez) gravity, less drag -- accumulates across steps
|
||||
=
|
||||
velocity(vx, vy, vz) recomputed every step; writing it directly is overwritten
|
||||
(a CHILD actor is the exception -- see below)
|
||||
|
||||
caps (sx, sy, sz) from the character. Caps the THRUST VECTOR, not velocity
|
||||
accel (ax, ay, az) from the character, signed by the direction of travel
|
||||
```
|
||||
|
||||
Two properties of that arrangement carry all the consequences.
|
||||
|
||||
**The cap is on thrust, not on velocity.** `sx`/`sy`/`sz` are the character's own top
|
||||
speed, not a speed limit on the world, and `akgl_physics_arcade_gravity` touches only the
|
||||
environmental term. So a falling actor accelerates straight past its stated top speed —
|
||||
which is the point, for a falling one. It also means there is no terminal velocity; see
|
||||
below.
|
||||
|
||||
**The cap is on the thrust *vector*, scaled to an ellipse, not on each axis separately.**
|
||||
|
||||
```text
|
||||
ty
|
||||
| Capping each axis against its own limit
|
||||
......|...... admits the corner of the box: an actor
|
||||
..' | '.. holding two directions got both caps
|
||||
.' +---+---+ '. at once and travelled their
|
||||
: | | | : diagonal -- 41% faster than either
|
||||
: ----+---+---+---- : alone. Scaling the vector to the
|
||||
----:------+---+---+------:---- tx ellipse keeps a character
|
||||
: | | | : whose horizontal and vertical
|
||||
:. +---+---+ .: speeds differ moving at the ratio
|
||||
'.. | sx ..' it asked for, and makes them equal
|
||||
''|.......''' where the speeds are.
|
||||
|
|
||||
dotted ellipse: the cap solid box: the old per-axis clamp
|
||||
```
|
||||
|
||||
```c excerpt=src/physics.c
|
||||
overshoot = 0.0f;
|
||||
if ( actor->sx != 0.0f ) {
|
||||
overshoot += (actor->tx / actor->sx) * (actor->tx / actor->sx);
|
||||
} else {
|
||||
actor->tx = 0.0f;
|
||||
}
|
||||
if ( actor->sy != 0.0f ) {
|
||||
overshoot += (actor->ty / actor->sy) * (actor->ty / actor->sy);
|
||||
} else {
|
||||
actor->ty = 0.0f;
|
||||
}
|
||||
if ( actor->sz != 0.0f ) {
|
||||
overshoot += (actor->tz / actor->sz) * (actor->tz / actor->sz);
|
||||
} else {
|
||||
actor->tz = 0.0f;
|
||||
}
|
||||
if ( overshoot > 1.0f ) {
|
||||
thrustscale = 1.0f / sqrtf(overshoot);
|
||||
actor->tx *= thrustscale;
|
||||
actor->ty *= thrustscale;
|
||||
actor->tz *= thrustscale;
|
||||
}
|
||||
```
|
||||
|
||||
An axis whose top speed is zero cannot be thrust along at all — that is what the old
|
||||
per-axis clamp did with it — and it stays out of the magnitude entirely, because dividing
|
||||
by it would not end well.
|
||||
|
||||
**Only x and y ever accumulate thrust.** `akgl_physics_simulate` reads the
|
||||
`AKGL_ACTOR_STATE_MOVING_LEFT`/`RIGHT` bits into `tx` and `MOVING_UP`/`DOWN` into `ty`.
|
||||
`AKGL_ACTOR_STATE_MOVING_IN` and `MOVING_OUT` exist and nothing reads them, so `tz` is only
|
||||
ever zeroed or scaled — the z axis has gravity, drag and movement but **no thrust an actor
|
||||
can apply**. Set `tz` yourself from a `movementlogicfunc` if you need depth motion.
|
||||
|
||||
## One step, in order
|
||||
|
||||
`akgl_physics_simulate` is shared by both backends; what differs is the `gravity` and
|
||||
`move` they point at. It walks the whole actor pool in index order, and per actor:
|
||||
|
||||
```text
|
||||
refcount == 0 ? ------------------------------> skip (free pool slot)
|
||||
parent != NULL ? -> x,y,z = parent's + own v, -> skip (never simulates)
|
||||
basechar == NULL ? ------------------------------> skip (no speeds)
|
||||
LAYERMASK set and actor->layer != layerid ? -------> skip
|
||||
|
||||
tx += ax * dt if MOVING_LEFT or MOVING_RIGHT
|
||||
ty += ay * dt if MOVING_UP or MOVING_DOWN
|
||||
scale (tx,ty,tz) to the (sx,sy,sz) ellipse if it is outside
|
||||
|
||||
+- ATTEMPT ----------------------------------------------------------+
|
||||
| actor->movementlogicfunc(actor, dt) |
|
||||
| AKGL_ERR_LOGICINTERRUPT raised here is swallowed: the actor |
|
||||
| is skipped for the rest of the tick and the loop continues |
|
||||
| self->gravity(self, actor, dt) |
|
||||
| ex -= ex * drag_x * dt (and y, z, each only if drag is nonzero) |
|
||||
| vx = ex + tx (and y, z) |
|
||||
| self->move(self, actor, dt) |
|
||||
+--------------------------------------------------------------------+
|
||||
|
||||
after the loop: self->gravity_time = curtime
|
||||
```
|
||||
|
||||
**A child actor is snapped and skipped.** An actor with a `parent` has its position set to
|
||||
the parent's plus its own `vx`/`vy`/`vz`, read as a fixed offset rather than as a velocity,
|
||||
and then it is skipped entirely. Children do not simulate. That is the mechanism a party
|
||||
member or a held item is built on — see [Chapter 12](12-actors.md).
|
||||
|
||||
**The thrust accumulation happens *before* `movementlogicfunc`, which is what sets `ax` and
|
||||
`sx`.** So the `ax` and `sx` a step uses are the ones the *previous* step's movement logic
|
||||
installed, and an actor's very first step sees `sx == 0` and has its thrust zeroed. The
|
||||
header's summary — "movement logic, then gravity, then drag, then velocity, then move" —
|
||||
leaves that out. It is one frame of lag at the start of a movement and no player will see
|
||||
it, but it is the reason a unit test that calls `simulate` once and checks `tx` gets zero.
|
||||
|
||||
**`gravity_time` is stamped after the loop, not before it.** A `gravity` or `move` that
|
||||
raises returns straight out of `akgl_physics_simulate` without stamping, so the *next*
|
||||
step measures `dt` from the older epoch and takes a double-length step — bounded by
|
||||
`max_timestep`, but still a visible jump. Treat a raised status out of `simulate` as
|
||||
needing a re-stamp of `gravity_time` before you continue.
|
||||
|
||||
## `dt` is not yours to supply
|
||||
|
||||
`akgl_physics_simulate` takes no timestep. It calls `SDL_GetTicksNS()` itself and measures
|
||||
against `self->gravity_time`, then bounds the result:
|
||||
|
||||
```text
|
||||
dt = (SDL_GetTicksNS() - self->gravity_time) / 1e9
|
||||
if ( max_timestep > 0 and dt > max_timestep ) dt = max_timestep
|
||||
if ( dt < 0 ) dt = 0
|
||||
```
|
||||
|
||||
`max_timestep` comes from the `physics.max_timestep` property and defaults to
|
||||
`AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP` — a twentieth of a second, three frames at 60 Hz, so an
|
||||
ordinary dropped frame passes through untouched and a level load does not.
|
||||
|
||||
**That bound is not a nicety.** Everything below it multiplies by `dt`, so one enormous
|
||||
`dt` moves every actor by however far its velocity carries it over the whole stall. A
|
||||
quarter-second load under platformer gravity is a hundred pixels of fall between the first
|
||||
frame and the second, straight through whatever was underneath. That was a live defect
|
||||
until 0.6.0: `gravity_time` was never seeded, so the first step measured the entire time
|
||||
since SDL started. Both initializers seed it now, and the bound catches the rest. Advancing
|
||||
the world in slow motion through a hitch is the trade every engine makes here.
|
||||
|
||||
A zero or negative `max_timestep` disables the bound, for a caller who would rather have
|
||||
the real elapsed time and handle it themselves.
|
||||
|
||||
### Stepping deterministically in a test
|
||||
|
||||
Because `dt` is measured rather than passed, **there is no way to step the engine by an
|
||||
exact interval — but there is a way to step it by an exact bound.** `tests/physics_sim.c`
|
||||
documents the only technique that holds up under load:
|
||||
|
||||
```text
|
||||
sim_physics.max_timestep = dt; /* the step you want */
|
||||
sim_physics.gravity_time = 0; /* measured interval = whole process uptime */
|
||||
sim_physics.simulate(&sim_physics, NULL);
|
||||
```
|
||||
|
||||
Zeroing `gravity_time` makes the measured interval the process's whole uptime, which is
|
||||
always past the bound, so the step taken is `max_timestep` and nothing else. The scheduler
|
||||
cannot get in the way of that. The first version of that helper set `gravity_time` to
|
||||
`now - dt` and let the real clock supply the step; a machine busy enough to deschedule the
|
||||
process between that store and the `SDL_GetTicksNS()` inside `simulate` produced a longer
|
||||
step and a red suite, exactly once, under a parallel `ctest`. **That the engine cannot be
|
||||
stepped exactly is itself a finding**, recorded in `TODO.md` under "Arcade physics feel".
|
||||
|
||||
Two more rules from that suite, both learned the hard way. `main` must call `SDL_Init` —
|
||||
without it SDL sets its clock epoch on first use, `SDL_GetTicksNS()` returns something
|
||||
around 130 ns, and every `dt`-dependent assertion passes for a reason unrelated to the
|
||||
code. And read the numbers each simulation prints even when it is green: a change that
|
||||
keeps every assertion passing while moving the apex of a jump by 30 px is a change worth
|
||||
noticing.
|
||||
|
||||
## Choosing a backend
|
||||
|
||||
```c excerpt=src/physics.c
|
||||
if ( strncmp(type->data, "null", 4) == 0) {
|
||||
PASS(errctx, akgl_physics_init_null(self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
if ( strncmp(type->data, "arcade", 6) == 0) {
|
||||
PASS(errctx, akgl_physics_init_arcade(self));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
FAIL_RETURN(errctx, AKERR_KEY, "Invalid physics engine %s", type->data);
|
||||
```
|
||||
|
||||
**The match is on leading characters.** `"nullify"` selects `null` and `"arcadia"` selects
|
||||
`arcade`; anything else raises `AKERR_KEY` quoting what was asked for. Adding a backend
|
||||
means adding a name here, not a branch in the simulation.
|
||||
|
||||
**`akgl_game_init` does not call the factory, and there is no `physics.engine` property.**
|
||||
The `@file` block and the `akgl_physics_factory` doc comment in `include/akgl/physics.h`
|
||||
both say otherwise. They are wrong: `akgl_game_init` points `akgl_physics` at
|
||||
`akgl_default_physics` — zeroed storage whose four method pointers are all `NULL` — and
|
||||
never initializes it, and the string `physics.engine` appears nowhere in `src/`. The
|
||||
startup sequence in `include/akgl/game.h` has it right: **the application calls an
|
||||
initializer or the factory itself**, at step 4, after the configuration properties are set
|
||||
and before the first frame. Skip that and the first `akgl_game_update` calls through a
|
||||
`NULL` `simulate`.
|
||||
|
||||
The only caller of `akgl_physics_factory` inside the library is
|
||||
`akgl_tilemap_load_physics`, driven by a map's `physics.model` custom property — see
|
||||
[Chapter 13](13-tilemaps.md). `util/charviewer.c` shows the direct form,
|
||||
`akgl_physics_init_null(akgl_physics)`. Correcting the header comments is filed separately;
|
||||
until then, believe `game.h` and this chapter.
|
||||
|
||||
```c
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/physics.h>
|
||||
#include <akgl/registry.h>
|
||||
|
||||
/*
|
||||
* Bring up the arcade backend. akgl_game_init does NOT do this: it points
|
||||
* akgl_physics at akgl_default_physics, which is zeroed storage whose method
|
||||
* pointers are all NULL. Call an initializer, or the first akgl_game_update
|
||||
* calls through a NULL simulate.
|
||||
*
|
||||
* Properties first, because akgl_physics_init_arcade reads them.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *bring_up_physics(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_set_property("physics.gravity.y", "900.0"));
|
||||
/* There is no terminal-velocity setting. Drag is the mechanism: ey
|
||||
* approaches gravity_y / drag_y, so 900 / 1.8 caps the fall at 500 px/s. */
|
||||
PASS(errctx, akgl_set_property("physics.drag.y", "1.8"));
|
||||
|
||||
PASS(errctx, akgl_physics_init_arcade(akgl_physics));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_physics_init_arcade` reads seven properties — `physics.gravity.x`/`.y`/`.z`,
|
||||
`physics.drag.x`/`.y`/`.z` and `physics.max_timestep` — all with defaults, so an arcade
|
||||
backend with no configuration behaves like the null one until something is set. **Set the
|
||||
properties first.** With `AKGL_REGISTRY_PROPERTIES` uninitialized every read comes back as
|
||||
its default and this silently configures zero gravity and zero drag rather than reporting
|
||||
anything; see [Chapter 6](06-the-registry.md).
|
||||
|
||||
The gravity signs are chosen for screen space: x pulls toward screen left, y pulls down
|
||||
because y grows downward, z pulls away from the camera.
|
||||
|
||||
## `AKGL_ERR_LOGICINTERRUPT`
|
||||
|
||||
The one status in libakgl that is not a failure. Raised from an actor's
|
||||
`movementlogicfunc`, it means **"skip the rest of this tick for me"** — no gravity, no drag,
|
||||
no move for that actor this step — and `akgl_physics_simulate` handles it and carries on to
|
||||
the next actor. It is a control signal wearing an error's clothes.
|
||||
|
||||
**Only `movementlogicfunc` gets that treatment.** The `gravity` and `move` calls return
|
||||
straight out of the loop on any status, `AKGL_ERR_LOGICINTERRUPT` included, so a backend
|
||||
must never use it to opt an actor out from there — there it aborts the whole step, leaving
|
||||
the actors already processed advanced and the rest not.
|
||||
|
||||
```c
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/physics.h>
|
||||
|
||||
/*
|
||||
* A movement logic function that pins an actor to a floor at y = 400.
|
||||
*
|
||||
* The arcade backend never calls collide and never consults the tilemap, so
|
||||
* standing on something is the caller's job. Doing it here rather than after
|
||||
* the step is what keeps the actor from being drawn inside the floor for one
|
||||
* frame.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *stand_on_floor(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
|
||||
/* The default logic still has to run: it is what copies the character's
|
||||
* speeds and accelerations onto the actor. */
|
||||
PASS(errctx, akgl_actor_logic_movement(obj, dt));
|
||||
|
||||
if ( obj->y >= 400.0f ) {
|
||||
obj->y = 400.0f;
|
||||
/* ey is where gravity accumulates. Zero it, or the actor keeps
|
||||
* "falling" into the floor and cannot jump out of it. */
|
||||
obj->ey = 0.0f;
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Raising this from movement logic means "skip the rest of this tick for me".
|
||||
* akgl_physics_simulate handles it and moves on to the next actor; the actor
|
||||
* gets no gravity, no drag and no move this step. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *frozen_in_cutscene(akgl_Actor *obj, float32_t dt)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
(void)dt;
|
||||
FAIL_ZERO_RETURN(errctx, obj, AKERR_NULLPOINTER, "obj");
|
||||
FAIL_RETURN(errctx, AKGL_ERR_LOGICINTERRUPT, "cutscene");
|
||||
}
|
||||
```
|
||||
|
||||
## What is not implemented
|
||||
|
||||
Stated plainly, because all of it is reachable by an ordinary-looking call and none of it
|
||||
announces itself at compile time.
|
||||
|
||||
**There is no collision detection, at all.**
|
||||
|
||||
- `akgl_physics_arcade_collide` always raises `AKERR_API` with the message "Not
|
||||
implemented". It is deliberately loud rather than silently permissive.
|
||||
- **`akgl_physics_simulate` never calls `collide`.** Not for the arcade backend, not for
|
||||
the null one, not ever. The vtable slot exists and the simulation does not use it.
|
||||
- `akgl_physics_arcade_move` does `position += velocity * dt` and nothing else. It does not
|
||||
clamp to the map, consult the tilemap, or test anything. **An actor walks straight
|
||||
through a wall and off the edge of the world.**
|
||||
|
||||
`akgl_physics_null_collide` *succeeds*, and that is not an oversight: the null backend's
|
||||
contract is "nothing collides", which is an answer. The arcade one means "nobody has
|
||||
written this yet", and a caller who reaches it should find out.
|
||||
|
||||
Until it exists, collision belongs in your `movementlogicfunc`, as in the example above.
|
||||
The sidescroller tutorial in [Chapter 19](19-tutorial-sidescroller.md) does exactly that and
|
||||
says why. `akgl_collide_*` in [Chapter 18](18-utilities.md) gives you the rectangle tests.
|
||||
|
||||
### Four gaps in the feel
|
||||
|
||||
Found by `tests/physics_sim.c`, which runs the arcade backend the way a game does — a
|
||||
Mario-esque jump and fall, Zelda-style top-down walking, and a run reversed at full speed —
|
||||
and prints what the actor actually did. Three other defects that suite found *are* fixed in
|
||||
0.6.0. These four are not. All are in `TODO.md`, "Arcade physics feel".
|
||||
|
||||
| Gap | What you see | The workaround today |
|
||||
|---|---|---|
|
||||
| **No terminal velocity** | Gravity adds `gravity_y * dt` to `ey` every step and nothing bounds it. The jump simulation reaches 560 px/s in 0.7 s and would keep going | `physics.drag.y`. It makes `ey` approach `gravity_y / drag_y` instead of diverging. That is the only brake, and it is not documented as one anywhere else |
|
||||
| **Releasing a direction stops the actor dead** | `akgl_actor_cmhf_*_off` sets `tx` (or `ty`) to zero, so a character at full speed stops within one frame — 0.0 px of drift measured in the second after release | Correct for Zelda, wrong for Mario. There is no friction or deceleration anywhere in the backend; `ax` is the only rate and it applies only while a direction is held. Decay `tx` yourself in `movementlogicfunc` |
|
||||
| **Explicit Euler, so trajectories are frame-rate dependent** | The same jump traces a slightly different arc at 30 Hz than at 144 Hz. The reversal measures 0.500 s to turn around where the closed form predicts 0.489 s — about 2% at 60 Hz, growing with the step | Nothing. 2% is not a bug a player can see. It is recorded because the fix (a fixed-step accumulator) is the same piece of work as removing the slow-motion trade `max_timestep` makes |
|
||||
| **A large drag coefficient inverts velocity** | `ex -= ex * drag_x * dt` is a first-order decay, so it only decays for `drag * dt < 1`. Past that it overshoots zero; past `2` it diverges. **Nothing rejects it** | With `dt` bounded to the default 0.05 s that needs a drag above 20, which is not a plausible setting. But `max_timestep` is caller-settable, so keep `drag * max_timestep` below 1 |
|
||||
|
||||
The `_off` handlers used to be worse: they zeroed `ay`, `ey`, `ty` and `vy` together, and
|
||||
`ey` is where gravity accumulates, so tapping down mid-jump stopped the character in the
|
||||
air. They clear `ax`/`tx` (or `ay`/`ty`) and nothing else now. Velocity was never theirs to
|
||||
clear either — `simulate` recomputes `v` as `e + t` every step.
|
||||
|
||||
## Statuses
|
||||
|
||||
`akgl_physics_simulate` propagates whatever an actor's `movementlogicfunc`, or the
|
||||
backend's `gravity` or `move`, raises, and **the first failure aborts the whole step** —
|
||||
the actors already processed are advanced and the rest are not. The status meanings are in
|
||||
[Chapter 4](04-errors.md); the ones you will meet here are `AKERR_NULLPOINTER` (a `NULL`
|
||||
`self` or `self->move`), `AKERR_API` (`collide`, always), `AKERR_VALUE` and `ERANGE` (a
|
||||
`physics.*` property that is not a number, or does not fit a `double`), and
|
||||
`AKGL_ERR_LOGICINTERRUPT`, which is not a failure.
|
||||
351
docs/15-input.md
Normal file
351
docs/15-input.md
Normal file
@@ -0,0 +1,351 @@
|
||||
# 15. Input
|
||||
|
||||
libakgl does not have an input system so much as a *binding* system. SDL3 owns events,
|
||||
keycodes, keymods, gamepad enumeration and text composition, and it documents all of them
|
||||
in the [SDL3 wiki](https://wiki.libsdl.org/SDL3/) — start with
|
||||
[SDL_Event](https://wiki.libsdl.org/SDL3/SDL_Event),
|
||||
[SDL_Keycode](https://wiki.libsdl.org/SDL3/SDL_Keycode) and
|
||||
[CategoryGamepad](https://wiki.libsdl.org/SDL3/CategoryGamepad). Nothing here replaces any
|
||||
of it.
|
||||
|
||||
What libakgl adds is two things: a table that says *"when event X arrives from device Y
|
||||
carrying key or button Z, call this handler on this actor"*, and a small ring buffer of
|
||||
keystrokes for a caller who wants to ask "is there a key waiting" without owning the event
|
||||
loop.
|
||||
|
||||
## Input is pushed, not polled
|
||||
|
||||
**The host pumps every SDL event into `akgl_controller_handle_event`.** There is no
|
||||
`akgl_input_update()` that reads the keyboard state for you, and there is no internal event
|
||||
loop. Whatever drains the SDL event queue — your `while ( SDL_PollEvent(&e) )`, or SDL's
|
||||
`SDL_AppEvent` callback — hands each event over, unconditionally.
|
||||
|
||||
Unconditionally is the point: **an event nothing binds is not an error.** It returns
|
||||
success having done nothing, which is what makes the blanket call correct and keeps the
|
||||
host from having to know which events matter.
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akgl/actor.h>
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
/*
|
||||
* Bind WASD on top of control map 0, which akgl_controller_default has already
|
||||
* filled with the four arrow keys and the four D-pad directions.
|
||||
*
|
||||
* Bindings are appended and scanned in push order, and the first match stops
|
||||
* the scan -- so these fire only for keys the eight defaults do not claim.
|
||||
* There is no way to remove one; zeroing akgl_controlmaps[id] is the only reset.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *bind_wasd(int mapid, SDL_KeyboardID kbid)
|
||||
{
|
||||
akgl_Control control;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
PASS(errctx, akgl_controller_default(mapid, "player", kbid, 0));
|
||||
|
||||
/* Copied in by value, so one stack local does for every push. */
|
||||
SDL_memset(&control, 0x00, sizeof(akgl_Control));
|
||||
control.event_on = SDL_EVENT_KEY_DOWN;
|
||||
control.event_off = SDL_EVENT_KEY_UP;
|
||||
|
||||
control.key = SDLK_A;
|
||||
control.handler_on = &akgl_actor_cmhf_left_on;
|
||||
control.handler_off = &akgl_actor_cmhf_left_off;
|
||||
PASS(errctx, akgl_controller_pushmap(mapid, &control));
|
||||
|
||||
control.key = SDLK_D;
|
||||
control.handler_on = &akgl_actor_cmhf_right_on;
|
||||
control.handler_off = &akgl_actor_cmhf_right_off;
|
||||
PASS(errctx, akgl_controller_pushmap(mapid, &control));
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Every event, unconditionally. An event nothing binds is success, not a
|
||||
* failure, which is what makes the unconditional call correct. */
|
||||
akerr_ErrorContext AKERR_NOIGNORE *pump(void *appstate, SDL_Event *event)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_controller_handle_event(appstate, event));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
## Two independent paths out of one call
|
||||
|
||||
`akgl_controller_handle_event` does two unrelated things with the event it is given, and
|
||||
**neither one suppresses the other**:
|
||||
|
||||
```text
|
||||
akgl_controller_handle_event(appstate, event)
|
||||
|
|
||||
+----------------------+----------------------+
|
||||
| |
|
||||
(a) the keystroke ring, FIRST (b) the control maps, SECOND
|
||||
| |
|
||||
KEY_DOWN -> push {key, mod, ""} for map 0..7 (target != NULL):
|
||||
TEXT_INPUT -> attach text to the for control 0..31, in push order:
|
||||
newest press, or push event type == event_on/off ?
|
||||
a text-only entry device id == kbid / jsid ?
|
||||
| key/button == this binding ?
|
||||
| -> call the handler and
|
||||
akgl_controller_poll_key() STOP the entire scan
|
||||
akgl_controller_poll_keystroke()
|
||||
akgl_controller_flush_keys()
|
||||
```
|
||||
|
||||
The ring is filled **first**, before the maps are consulted, and *whether or not* a map
|
||||
claims the key. A key that drives an actor is still a key an embedded interpreter polling
|
||||
with `akgl_controller_poll_key()` wants to see.
|
||||
|
||||
### The control-map scan
|
||||
|
||||
```c excerpt=include/akgl/controller.h
|
||||
/** @brief How many control maps exist -- effectively the local player limit. */
|
||||
#define AKGL_MAX_CONTROL_MAPS 8
|
||||
/** @brief Bindings per control map. The default map installed by akgl_controller_default uses 8 of them. */
|
||||
#define AKGL_MAX_CONTROLS 32
|
||||
```
|
||||
|
||||
Eight maps means up to eight locally controlled players, each on its own keyboard and
|
||||
gamepad. A map with a `NULL` `target` is skipped entirely — that is how an unused slot is
|
||||
spelled.
|
||||
|
||||
**Maps are scanned in index order, bindings within a map in push order, and the first
|
||||
match wins and stops the whole scan.** A key bound in two maps only ever fires in the
|
||||
lower-numbered one. A key bound twice in one map only fires on the earlier push. This is
|
||||
what makes `akgl_controller_default` followed by your own pushes behave sanely: the
|
||||
defaults are already in slots 0–7 and your additions land after them.
|
||||
|
||||
A match requires **all three** of: the event type equals the binding's `event_on` or
|
||||
`event_off`; the device id on the event equals the map's `kbid` (keyboard events) or `jsid`
|
||||
(gamepad events); and the key or button equals the binding's. The device-id half is what
|
||||
keeps two players on two keyboards apart, and it is exact — `akgl_controller_list_keyboards`
|
||||
logs every attached keyboard and its id, which is how you find the number to pass.
|
||||
|
||||
There is no way to remove a binding. `akgl_controller_pushmap` appends and that is the
|
||||
whole API; the only reset is zeroing `akgl_controlmaps[id]` yourself. **Calling
|
||||
`akgl_controller_default` twice on one map leaves sixteen bindings**, and the first eight
|
||||
are the ones that fire.
|
||||
|
||||
### The keystroke ring
|
||||
|
||||
```c excerpt=src/controller.c
|
||||
static void keybuffer_push_key(SDL_Keycode key, SDL_Keymod mod)
|
||||
{
|
||||
int tail = 0;
|
||||
|
||||
if ( keybuffer_count >= AKGL_CONTROLLER_KEY_BUFFER ) {
|
||||
keybuffer_awaiting_text = false;
|
||||
return;
|
||||
}
|
||||
tail = (keybuffer_head + keybuffer_count) % AKGL_CONTROLLER_KEY_BUFFER;
|
||||
keybuffer[tail].key = key;
|
||||
keybuffer[tail].mod = mod;
|
||||
keybuffer[tail].text[0] = '\0';
|
||||
keybuffer_count += 1;
|
||||
keybuffer_awaiting_text = true;
|
||||
}
|
||||
```
|
||||
|
||||
Thirty-two entries (`AKGL_CONTROLLER_KEY_BUFFER`), one fixed array in the library's data
|
||||
segment, **one ring for the whole process** — it is a file-scope static, not per map and
|
||||
not per actor.
|
||||
|
||||
**A full buffer drops the newest keystroke, not the oldest.** A caller reading a line of
|
||||
input wants the characters that were typed first; overwriting the head would hand it the
|
||||
tail of what the user typed and silently lose the beginning.
|
||||
|
||||
Two pollers drain the same ring, and a keystroke taken by one is not waiting for the
|
||||
other:
|
||||
|
||||
| Call | Hands back | Discards |
|
||||
|---|---|---|
|
||||
| `akgl_controller_poll_key(&keycode, &available)` | The SDL keycode, as an `int` | Entries carrying only composed text and no keycode — there is no key to report |
|
||||
| `akgl_controller_poll_keystroke(&dest, &available)` | `key`, `mod` and the composed `text` | Nothing |
|
||||
| `akgl_controller_flush_keys()` | — | Everything waiting |
|
||||
|
||||
An empty ring is success, not a failure: `available` comes back `false` and the caller
|
||||
polls again next frame. **Check `available`, not the keycode against 0** — a text-only
|
||||
entry legitimately has a keycode of 0.
|
||||
|
||||
**The `text` field needs `SDL_StartTextInput()` on your window.** SDL reports one keystroke
|
||||
as two events — the key going down, and then the character it composed to, if it composed
|
||||
to anything — and it only sends the second while text input is started. Without that call
|
||||
`key` and `mod` still arrive and `text` is always the empty string. That is not a libakgl
|
||||
limitation to work around; **it is the only correct way to get a character out of SDL**, and
|
||||
it is what makes a keyboard layout, a compose key, a dead key and an IME commit work. `"`,
|
||||
`!`, `(`, `)`, `:` and `;` are all unreachable from a keycode alone, and so is every
|
||||
lower-case letter. See
|
||||
[SDL_StartTextInput](https://wiki.libsdl.org/SDL3/SDL_StartTextInput).
|
||||
|
||||
An entry with composed text and **no** keycode is not a degenerate case: an input method or
|
||||
a dead key finishes a character with no key press of its own, and a line editor wants that
|
||||
character. `akgl_controller_poll_keystroke` reports it; `akgl_controller_poll_key` cannot,
|
||||
and drops it on the way past.
|
||||
|
||||
Text longer than `AKGL_CONTROLLER_KEYSTROKE_TEXT` (8 bytes) — an IME committing a whole
|
||||
word at once — is truncated **on a code point boundary**, never through the middle of a
|
||||
UTF-8 sequence.
|
||||
|
||||
```c
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akgl/controller.h>
|
||||
#include <akgl/error.h>
|
||||
|
||||
/*
|
||||
* Drain the keystroke ring into a line buffer, the way an embedded interpreter
|
||||
* or a name-entry screen wants it.
|
||||
*
|
||||
* The `text` field is only populated while SDL text input is started on the
|
||||
* window, so a caller that wants punctuation, lower case, dead keys or an IME
|
||||
* commit calls SDL_StartTextInput() first. Without it `key` and `mod` still
|
||||
* arrive and `text` is always empty.
|
||||
*/
|
||||
akerr_ErrorContext AKERR_NOIGNORE *read_line(char *dest, size_t destsize, bool *done)
|
||||
{
|
||||
akgl_Keystroke stroke;
|
||||
bool available = false;
|
||||
size_t used = 0;
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "dest");
|
||||
FAIL_ZERO_RETURN(errctx, done, AKERR_NULLPOINTER, "done");
|
||||
|
||||
used = SDL_strlen(dest);
|
||||
*done = false;
|
||||
|
||||
/* The ATTEMPT block goes outside the loop: CATCH reports failure by
|
||||
* break-ing, which would bind to the loop instead of the block. */
|
||||
ATTEMPT {
|
||||
for ( ;; ) {
|
||||
CATCH(errctx, akgl_controller_poll_keystroke(&stroke, &available));
|
||||
if ( available == false ) {
|
||||
break;
|
||||
}
|
||||
if ( stroke.key == SDLK_RETURN ) {
|
||||
*done = true;
|
||||
break;
|
||||
}
|
||||
if ( (stroke.text[0] != '\0') && ((used + 1) < destsize) ) {
|
||||
dest[used] = stroke.text[0];
|
||||
used += 1;
|
||||
dest[used] = '\0';
|
||||
}
|
||||
}
|
||||
} CLEANUP {
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The ring is filled on whichever thread pumps events and **is not synchronized**. Poll it
|
||||
from that thread.
|
||||
|
||||
## The default bindings
|
||||
|
||||
`akgl_controller_default(controlmapid, actorname, kbid, jsid)` points a map at the actor
|
||||
registered under `actorname` and pushes **eight** bindings — the four arrow keys and the
|
||||
four D-pad directions — each wired to the matching `akgl_actor_cmhf_*_on`/`_off` pair:
|
||||
|
||||
| Key | Gamepad button | `handler_on` / `handler_off` |
|
||||
|---|---|---|
|
||||
| `SDLK_DOWN` | `SDL_GAMEPAD_BUTTON_DPAD_DOWN` | `akgl_actor_cmhf_down_on` / `_down_off` |
|
||||
| `SDLK_UP` | `SDL_GAMEPAD_BUTTON_DPAD_UP` | `akgl_actor_cmhf_up_on` / `_up_off` |
|
||||
| `SDLK_LEFT` | `SDL_GAMEPAD_BUTTON_DPAD_LEFT` | `akgl_actor_cmhf_left_on` / `_left_off` |
|
||||
| `SDLK_RIGHT` | `SDL_GAMEPAD_BUTTON_DPAD_RIGHT` | `akgl_actor_cmhf_right_on` / `_right_off` |
|
||||
|
||||
It is the "just give me something that works" path. A game wanting different keys builds
|
||||
its own bindings with `akgl_controller_pushmap`, as in the WASD example above.
|
||||
|
||||
**The default bindings cannot produce a diagonal.** Every `akgl_actor_cmhf_*_on` handler
|
||||
clears `AKGL_ACTOR_STATE_MOVING_ALL` before setting its own bit, so pressing Left while Up
|
||||
is held cancels Up rather than adding to it. That is fine for a four-way JRPG and wrong for
|
||||
anything that wants eight-way movement — and it means the thrust-vector ellipse cap in
|
||||
[Chapter 14](14-physics.md) is unreachable through these handlers. Eight-way movement wants
|
||||
your own handlers that `AKGL_BITMASK_ADD` the movement bit without clearing the others.
|
||||
|
||||
`akgl_controller_handle_button_down` / `_up` are a different thing again, and are **not**
|
||||
control-map bindings: they look the actor up by the literal name `"player"` in
|
||||
`AKGL_REGISTRY_ACTOR` rather than being told which actor to act on, so they only ever drive
|
||||
one. The `akgl_actor_cmhf_*` handlers are the general form; see
|
||||
[Chapter 12](12-actors.md).
|
||||
|
||||
## Gamepads
|
||||
|
||||
**SDL delivers no button events from a gamepad nobody has opened.** `akgl_game_init` calls
|
||||
`akgl_controller_open_gamepads()` for the devices present at startup; a device plugged in
|
||||
mid-session arrives as `SDL_EVENT_GAMEPAD_ADDED`, and `akgl_controller_handle_added` opens
|
||||
it. Route that event (and `SDL_EVENT_GAMEPAD_REMOVED`, to `akgl_controller_handle_removed`)
|
||||
or hot-plugged pads stay silent. No gamepads attached at all is success, not an error.
|
||||
|
||||
`akgl_controller_handle_added` logs the mapping it found, deliberately: **a controller with
|
||||
no entry in the mapping database produces no button events at all**, which is otherwise
|
||||
indistinguishable from a broken binding.
|
||||
|
||||
Which is why libakgl ships a database. `include/akgl/SDL_GameControllerDB.h` carries
|
||||
`AKGL_SDL_GAMECONTROLLER_DB_LEN` (2255) mappings from the community
|
||||
[SDL_GameControllerDB](https://github.com/mdqinc/SDL_GameControllerDB) project, and
|
||||
`akgl_game_init` feeds every one to `SDL_AddGamepadMapping` before opening anything.
|
||||
|
||||
That header is **generated, and tracked on purpose**. It is the offline fallback that keeps
|
||||
the library buildable when upstream is renamed, rate-limited or unreachable. Never
|
||||
hand-edit it; regeneration is `cmake --build build --target controllerdb` and nothing else
|
||||
runs the script. The full contract, including why a failed fetch used to destroy the very
|
||||
file it exists to protect, is in `AGENTS.md` under "Generated and Vendored Sources".
|
||||
|
||||
## Known defects
|
||||
|
||||
**A matched binding's handler pointer is not checked.** `akgl_controller_handle_event`
|
||||
calls `curcontrol->handler_on(...)` the moment the event type, device id and key all match,
|
||||
with no `NULL` test. A control pushed with a `NULL` `handler_on` or `handler_off` crashes
|
||||
when its event arrives — and since `akgl_controller_pushmap` copies whatever it is given,
|
||||
a `memset`-zeroed `akgl_Control` with only `event_on` filled in is a loaded gun. **Set both
|
||||
handlers on every binding you push**, even if one of them does nothing. There is no
|
||||
diagnostic for this at any layer.
|
||||
|
||||
**`appstate` is required and never read.** Every entry point in this subsystem —
|
||||
`akgl_controller_handle_event`, `handle_button_down`, `handle_button_up`, `handle_added`,
|
||||
`handle_removed` — raises `AKERR_NULLPOINTER` on a `NULL` `appstate` and then never looks
|
||||
at it. Pass any non-`NULL` pointer if your program has no app state.
|
||||
|
||||
**The analogue-axis, mouse and pen fields are declared and never consulted.**
|
||||
`akgl_Control::axis`, `axis_range_min` and `axis_range_max`, and `akgl_ControlMap::mouseid`
|
||||
and `penid`, are in the structs and `akgl_controller_handle_event` does not read any of
|
||||
them. Setting them has no effect; analogue sticks, mice and pens do not reach the control
|
||||
maps at all today. Handle those events yourself before calling
|
||||
`akgl_controller_handle_event`.
|
||||
|
||||
**Key auto-repeat is not filtered.** The ring push tests only `event->type ==
|
||||
SDL_EVENT_KEY_DOWN` and does not consult `event->key.repeat`, so a held key fills the
|
||||
32-entry ring at the platform's repeat rate. Check `repeat` in your event pump if you are
|
||||
polling for discrete presses.
|
||||
|
||||
Two defects this chapter used to carry are **fixed** and are recorded here so nobody
|
||||
re-derives them from the header prose:
|
||||
|
||||
- **A negative `controlmapid` is rejected.** Both `akgl_controller_pushmap` and
|
||||
`akgl_controller_default` check `controlmapid < 0` as well as the upper bound and raise
|
||||
`AKERR_OUTOFBOUNDS`. `tests/controller.c` passes `-1` and `-4096` to each. The
|
||||
`@warning` blocks in `include/akgl/controller.h` saying only the upper bound is checked
|
||||
are stale; `TODO.md`, "Known and still open" item 11.
|
||||
- **The `akgl_controller_handle_*` functions exist.** `controller.h` once declared four
|
||||
names that were defined under different spellings, so they linked nowhere. Fixed in
|
||||
0.5.0, and `scripts/check_api_surface.sh` runs as the `api_surface` test to stop that
|
||||
class of drift coming back.
|
||||
|
||||
## Statuses
|
||||
|
||||
The ones this subsystem raises, with their libakgl meanings in
|
||||
[Chapter 4](04-errors.md):
|
||||
|
||||
| Status | When |
|
||||
|---|---|
|
||||
| `AKERR_NULLPOINTER` | A `NULL` `appstate`, `event`, `control`, or poller destination; SDL cannot enumerate or open a device; no actor registered as `"player"` |
|
||||
| `AKERR_OUTOFBOUNDS` | `controlmapid` outside `0..AKGL_MAX_CONTROL_MAPS - 1`, or the map already holds `AKGL_MAX_CONTROLS` bindings |
|
||||
| `AKGL_ERR_REGISTRY` | `akgl_controller_default` could not find `actorname` — usually because the actor has not been created yet |
|
||||
| anything | Whatever the matched binding's handler raises, propagated unchanged |
|
||||
223
docs/16-text-and-fonts.md
Normal file
223
docs/16-text-and-fonts.md
Normal file
@@ -0,0 +1,223 @@
|
||||
# 16. Text and fonts
|
||||
|
||||
**SDL3_ttf owns fonts.** It opens the file, rasterizes the glyphs, and computes the metrics;
|
||||
its API and its behaviour are documented in the
|
||||
[SDL3_ttf wiki](https://wiki.libsdl.org/SDL3_ttf/). This chapter covers the three things
|
||||
libakgl adds on top: a name-keyed font registry, a one-call rasterize-and-blit, and two
|
||||
measure functions that need no window.
|
||||
|
||||
There is no akgl font type. A font is a `TTF_Font *`, and what libakgl gives you is a place
|
||||
to keep it.
|
||||
|
||||
## Fonts live in a registry, keyed by a name you choose
|
||||
|
||||
`akgl_text_loadfont(name, filepath, size)` opens the file and publishes the handle in
|
||||
`AKGL_REGISTRY_FONT` under `name`. The name is yours — it is not derived from the file — and
|
||||
`filepath` is used verbatim, not resolved against `SDL_GetBasePath()`.
|
||||
|
||||
**The size is baked into the handle, so one file at two sizes is two fonts under two
|
||||
names.** That is SDL_ttf's model, not libakgl's, and there is no way around it:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akstdlib.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <akgl/game.h>
|
||||
#include <akgl/registry.h>
|
||||
#include <akgl/text.h>
|
||||
|
||||
/* Two sizes of one file are two fonts under two names. */
|
||||
akerr_ErrorContext *hud_load_fonts(char *path)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_text_loadfont("hud", path, 14));
|
||||
PASS(errctx, akgl_text_loadfont("title", path, 32));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
akerr_ErrorContext *hud_draw_score(int score)
|
||||
{
|
||||
TTF_Font *font = NULL;
|
||||
SDL_Color white = { 255, 255, 255, 255 };
|
||||
char line[32];
|
||||
int count = 0;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
/* The registry hands back a raw TTF_Font *. It is not reference counted:
|
||||
nothing here takes a reference and nothing gives one back. */
|
||||
font = (TTF_Font *)SDL_GetPointerProperty(AKGL_REGISTRY_FONT, "hud", NULL);
|
||||
FAIL_ZERO_RETURN(errctx, font, AKERR_KEY, "No font registered as \"hud\"");
|
||||
|
||||
PASS(errctx, aksl_snprintf(&count, line, sizeof(line), "SCORE %d", score));
|
||||
PASS(errctx, akgl_text_rendertextat(font, line, white, 0, 8, 8));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
**Fonts are not reference counted.** The four asset pools in [Chapter 5](05-the-heap.md) all
|
||||
count references; the font registry does not. It holds a bare pointer. Two consequences:
|
||||
|
||||
- `akgl_text_unloadfont(name)` closes the font whether or not anything is still using it.
|
||||
Anything holding the `TTF_Font *` — a caller that fetched it earlier, a `hud_draw_score`
|
||||
that fetched it last frame and cached it — is left with a dangling pointer.
|
||||
- Loading over an existing name replaces the entry and closes the font it displaced. The new
|
||||
font is opened **first**, so a failed load leaves you with the font you already had.
|
||||
|
||||
`akgl_text_unloadfont` on a name that is not registered raises `AKERR_KEY`, which makes a
|
||||
double unload an error rather than a double close. `akgl_text_loadfont` raises `AKERR_KEY`
|
||||
when it cannot write the registry — in practice, because `akgl_registry_init_font()` has not
|
||||
run.
|
||||
|
||||
## The teardown ordering trap
|
||||
|
||||
**`akgl_text_unloadallfonts()` must run before `TTF_Quit()` and `SDL_Quit()`.** This is the
|
||||
one ordering constraint in the subsystem and it is easy to get wrong, because nothing fails
|
||||
loudly when you do.
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <akgl/text.h>
|
||||
|
||||
/* Shutdown order. Both of the other two orderings are wrong. */
|
||||
void shutdown_text(void)
|
||||
{
|
||||
IGNORE(akgl_text_unloadallfonts());
|
||||
TTF_Quit();
|
||||
SDL_Quit();
|
||||
}
|
||||
```
|
||||
|
||||
A font is reachable only by name, through `AKGL_REGISTRY_FONT`, which is an SDL property set.
|
||||
So:
|
||||
|
||||
| Order | What happens |
|
||||
|---|---|
|
||||
| `unloadallfonts`, `TTF_Quit`, `SDL_Quit` | Correct. Every font closed, then the registry destroyed, then SDL |
|
||||
| `SDL_Quit` first | **SDL_Quit destroys the property registry, taking the last reference to every font with it.** They are never closed and are now unreachable — a leak bounded by how many fonts you loaded, with no way to do anything about it |
|
||||
| `TTF_Quit` first | The `TTF_Font` handles are already invalid; `unloadallfonts` then closes freed pointers |
|
||||
|
||||
`akgl_text_unloadallfonts` enumerates the registry, closes every font, destroys the property
|
||||
set and sets `AKGL_REGISTRY_FONT` to 0. It has no failure path: an uninitialized registry is
|
||||
success rather than an error, because shutdown paths run after partial startups.
|
||||
|
||||
**Afterwards the registry is gone.** `akgl_text_loadfont` needs
|
||||
`akgl_registry_init_font()` again before it can register anything, so this is a shutdown
|
||||
call rather than a between-scenes one. To swap fonts mid-game, use `akgl_text_unloadfont`
|
||||
per name.
|
||||
|
||||
## Drawing is rasterize, upload, blit, destroy — every call
|
||||
|
||||
`akgl_text_rendertextat(font, text, color, wraplength, x, y)` does the whole job in one
|
||||
call: renders blended through SDL_ttf, uploads the surface as a texture, draws it through
|
||||
`akgl_renderer->draw_texture`, and destroys both the texture and the surface before it
|
||||
returns. **There is no cache.** Every call pays the full cost.
|
||||
|
||||
`PERFORMANCE.md` puts numbers on it, on a 640x480 software-rasterized frame:
|
||||
|
||||
| Operation | Cost |
|
||||
|---|---:|
|
||||
| `akgl_text_rendertextat`, 15 characters | 12,601.7 ns |
|
||||
| `akgl_text_measure`, 15 characters | 37.3 ns |
|
||||
| Six lines of HUD text | 0.076 ms — 0.5% of a 16.67 ms frame |
|
||||
|
||||
Measuring is 340 times cheaper than drawing, which tells you the whole cost is the rasterize
|
||||
and the upload. **Six HUD readouts is 76 µs a frame. That is fine at 60 fps on a laptop and
|
||||
it is 4% of a 2 ms GPU frame** — and it is being paid every frame for a score that changes
|
||||
once a second. A one-line cache keyed on (font, string, colour) would take it to nothing;
|
||||
`PERFORMANCE.md` calls it the single clearest optimisation in the library, and `TODO.md`
|
||||
carries it as a target.
|
||||
|
||||
So: **fine for a HUD line, wrong for a static body of text redrawn every frame.** If you are
|
||||
drawing a page of dialogue that does not change, rasterize it yourself once with SDL3_ttf,
|
||||
keep the texture, and blit it through `akgl_renderer->draw_texture`.
|
||||
|
||||
Two more properties of the draw:
|
||||
|
||||
- **Coordinates are screen coordinates, not world ones.** This does not go through
|
||||
`akgl_camera`, so a HUD stays put while the world scrolls under it. `x` and `y` are the
|
||||
top-left corner of the text, not a centre.
|
||||
- **`wraplength` greater than 0 wraps on word boundaries at that pixel width.** 0 or less
|
||||
draws a single line and breaks only on newlines in `text`.
|
||||
|
||||
The renderer guards are worth knowing because of what they catch. `akgl_text_rendertextat`
|
||||
raises `AKERR_NULLPOINTER` if `akgl_renderer`, its `sdl_renderer`, or its `draw_texture` is
|
||||
`NULL` — and that last one is the state a backend is in between being allocated and being
|
||||
run through `akgl_render_2d_bind()`. Reusing `AKERR_NULLPOINTER` for a failed rasterize or a
|
||||
failed texture upload is the same status doing double duty; the message carries
|
||||
`SDL_GetError()` and tells you which.
|
||||
|
||||
## Measuring needs no renderer and no window
|
||||
|
||||
`akgl_text_measure` and `akgl_text_measure_wrapped` touch nothing but the font. They work
|
||||
before `akgl_render_2d_init`, and in a program that never creates a window at all. A caller
|
||||
building a character grid measures one cell and derives the rest from it:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <akgl/text.h>
|
||||
|
||||
/* Measuring touches no renderer and no window, so a grid can be derived
|
||||
before anything is on screen. */
|
||||
akerr_ErrorContext *cell_size(TTF_Font *font, int *cellw, int *cellh)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_text_measure(font, "M", cellw, cellh));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* A negative wrap length is refused rather than passed through: SDL_ttf reads
|
||||
it as a very large unsigned width and silently stops wrapping. */
|
||||
akerr_ErrorContext *box_size(TTF_Font *font, char *body, int width, int *w, int *h)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_text_measure_wrapped(font, body, width, w, h));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The two differ in exactly the way `akgl_text_rendertextat`'s `wraplength` argument suggests:
|
||||
|
||||
| Function | Height reported | Width reported |
|
||||
|---|---|---|
|
||||
| `akgl_text_measure` | One line, whatever the text contains | The whole string on one line |
|
||||
| `akgl_text_measure_wrapped` | Every line the text wraps onto | The longest line, not `wraplength` |
|
||||
|
||||
**A negative `wraplength` is refused with `AKERR_OUTOFBOUNDS`**, and that is a deliberate
|
||||
libakgl decision rather than a passthrough. SDL_ttf takes the wrap width as an `int` and
|
||||
reads a negative one as a very large *unsigned* width, which silently disables wrapping —
|
||||
returning a measurement that is wrong rather than an error. A `wraplength` of 0 is legal and
|
||||
wraps on newlines only.
|
||||
|
||||
## The empty string
|
||||
|
||||
Both halves accept it. `akgl_text_measure("")` returns 0 wide by one line high, so a cursor
|
||||
sitting on an empty line has somewhere to be, and `akgl_text_rendertextat` with `""` returns
|
||||
success without rasterizing anything.
|
||||
|
||||
That is worth stating because it was not always true and the header still says otherwise.
|
||||
SDL_ttf refuses the empty string from both rasterizers with "Text has zero width", and until
|
||||
0.5.0 `akgl_text_rendertextat` passed that on as `AKERR_NULLPOINTER` — so the two halves of
|
||||
one header disagreed about one string, and a caller drawing a line that might be empty had
|
||||
to check for it. It is a `SUCCEED_RETURN` now, placed *after* the font, text and backend
|
||||
guards, so drawing nothing still refuses everything drawing something refuses.
|
||||
|
||||
> **Stale header prose.** `include/akgl/text.h:123-127` still documents the empty string as
|
||||
> refused, and `text.h:120-122` still warns that a failure after rasterizing leaks the
|
||||
> surface and the texture. Both describe pre-0.5.0 behaviour. `src/text.c:110-112` returns
|
||||
> success for `""`, and `src/text.c:140-146` destroys both objects in a `CLEANUP` block that
|
||||
> runs on every path. `TODO.md` items 27 and 23 record both as fixed. The header comments
|
||||
> want correcting in their own commit.
|
||||
|
||||
## Fonts, the pools, and what is not shared
|
||||
|
||||
Nothing about fonts goes through the akgl heap. A `TTF_Font` is about ten kilobytes once
|
||||
FreeType's own structures are counted, it is allocated by SDL_ttf, and it is freed by
|
||||
`TTF_CloseFont`. The registry stores a pointer and nothing else.
|
||||
|
||||
That is different from spritesheets, which *are* pooled and *are* shared by resolved path —
|
||||
see [Chapter 10](10-spritesheets-and-sprites.md). Two names pointing at the same `.ttf` at
|
||||
the same size are two open fonts, not one shared one.
|
||||
337
docs/17-audio.md
Normal file
337
docs/17-audio.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# 17. Audio
|
||||
|
||||
**There are two audio subsystems in libakgl and they have nothing to do with each other.**
|
||||
Not two layers, not two APIs over one engine — two unrelated things that happen to make
|
||||
sound. Knowing which one you are in is most of using either.
|
||||
|
||||
| | Three-voice synthesizer | The asset path |
|
||||
|---|---|---|
|
||||
| Header | `audio.h` | `assets.h` |
|
||||
| Functions | 10 | 1 |
|
||||
| Whose code | **libakgl's own, in full** | SDL3_mixer's, wrapped |
|
||||
| Reads a file | Never | Always |
|
||||
| Device | Its own `SDL_AudioStream`, opened by `akgl_audio_init` | The `MIX_Mixer` created by `akgl_game_init` |
|
||||
| Globals | `akgl_audio_voices[3]` | `akgl_mixer`, `akgl_bgm`, `akgl_tracks[64]` |
|
||||
| Vocabulary | Notes: waveform, frequency, envelope | Recordings: load, track, play |
|
||||
|
||||
The first half of this chapter documents the synthesizer completely, because it is ours. The
|
||||
second half is one function and a link, because the decoding, the formats, the mixers and
|
||||
the tracks all belong to
|
||||
[SDL3_mixer](https://wiki.libsdl.org/SDL3_mixer/) and are documented there.
|
||||
|
||||
## Part 1 — the three-voice synthesizer
|
||||
|
||||
### Where the design comes from
|
||||
|
||||
The vocabulary is not invented. Every entry point maps onto a Commodore 128 BASIC 7.0
|
||||
statement, and **the waveform enum values are literally the numbers `SOUND` takes**:
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
typedef enum {
|
||||
AKGL_AUDIO_WAVE_TRIANGLE = 0, /**< SOUND waveform 0. Soft, flute-like. */
|
||||
AKGL_AUDIO_WAVE_SAWTOOTH = 1, /**< SOUND waveform 1. Bright, brassy. */
|
||||
AKGL_AUDIO_WAVE_SQUARE = 2, /**< SOUND waveform 2. Hollow, reedy. The default. */
|
||||
AKGL_AUDIO_WAVE_NOISE = 3, /**< SOUND waveform 3. Unpitched; percussion. */
|
||||
AKGL_AUDIO_WAVE_SINE = 4 /**< No SOUND equivalent. A pure tone. */
|
||||
} akgl_AudioWaveform;
|
||||
```
|
||||
|
||||
| BASIC 7.0 | libakgl | Notes |
|
||||
|---|---|---|
|
||||
| `SOUND v, f, d` | `akgl_audio_tone(voice, hz, ms)` | One pitch, held for a gate length |
|
||||
| `SOUND v, f, d, dir, min, step` | `akgl_audio_sweep(voice, from_hz, to_hz, step_hz, ms)` | Direction comes from the two frequencies, not from a sign |
|
||||
| `SOUND v, f, d, , , , wf` | `akgl_audio_waveform(voice, waveform)` | Same five shapes, same first four numbers |
|
||||
| `ENVELOPE n, a, d, s, r` | `akgl_audio_envelope(voice, attack, decay, sustain, release)` | Set per voice rather than per numbered preset |
|
||||
| `VOL n` | `akgl_audio_volume(level)` | Master level, 0.0 to 1.0 |
|
||||
| — | `akgl_audio_stop(voice)` | Hard cut; skips the release |
|
||||
| — | `akgl_audio_voice_active(voice, &active)` | Wait out a note without a clock |
|
||||
|
||||
Three voices, because the machine this comes from had three and its music is written for
|
||||
three:
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
#define AKGL_AUDIO_MAX_VOICES 3
|
||||
```
|
||||
|
||||
Voices are addressed from **zero** here. A language whose own voices are numbered from one
|
||||
maps them itself.
|
||||
|
||||
### The vocabulary in practice
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/audio.h>
|
||||
|
||||
/* A coin pickup: a short square blip on voice 0. */
|
||||
akerr_ErrorContext *sfx_coin(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_audio_waveform(0, AKGL_AUDIO_WAVE_SQUARE));
|
||||
PASS(errctx, akgl_audio_envelope(0, 0, 20, 0.6f, 60));
|
||||
PASS(errctx, akgl_audio_tone(0, 1046.5f, 80));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* A laser: pitch walks down from 1200 Hz to 200 Hz, 40 Hz every 1/60 s. */
|
||||
akerr_ErrorContext *sfx_laser(void)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_audio_waveform(1, AKGL_AUDIO_WAVE_SAWTOOTH));
|
||||
PASS(errctx, akgl_audio_envelope(1, 0, 0, 1.0f, 30));
|
||||
PASS(errctx, akgl_audio_sweep(1, 1200.0f, 200.0f, 40.0f, 400));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Wait for a note to finish without keeping a clock. */
|
||||
akerr_ErrorContext *sfx_still_sounding(int voice, bool *sounding)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_audio_voice_active(voice, sounding));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
`akgl_audio_waveform` and `akgl_audio_envelope` are **settings**: they persist on the voice
|
||||
across notes and take effect on the next `akgl_audio_tone`. They do not reshape a note that
|
||||
is already sounding. `akgl_audio_volume` is the exception — it is applied after the voices
|
||||
are summed, so it does take effect on notes already playing.
|
||||
|
||||
**A voice you never described still makes a noise.** The table's defaults are a square wave,
|
||||
sustain 1.0, and no attack, decay or release — held at full level for as long as the gate is
|
||||
open. A *zeroed* voice would have a sustain of 0.0, which is silence, so a caller who sounded
|
||||
a note without first setting an envelope would get nothing and no error saying why. The first
|
||||
entry point to touch the table installs those defaults, whether or not a device is open.
|
||||
|
||||
### The envelope and the gate
|
||||
|
||||
`ms` is the length of the **gate**, not of the sound. The release runs after it, so a voice
|
||||
with a release stays audible slightly longer than `ms`:
|
||||
|
||||
```text
|
||||
level
|
||||
1.0 | /\
|
||||
| / \____________
|
||||
| / : \sustain release
|
||||
sus |___/ : \________________.
|
||||
| / : :\
|
||||
0.0 |_/______:______________________________:_\______ time
|
||||
|<-A-><-D->|<------- gate (ms) -------->|<-R->|
|
||||
| |
|
||||
gate opens gate closes
|
||||
```
|
||||
|
||||
Zero-length stages are skipped rather than divided by. A zero attack starts at full level; a
|
||||
zero decay drops to `sustain` at once; a zero release cuts off at once. `sustain` of 0.0 is
|
||||
legal and gives a plucked sound — audible only through its attack and decay. An attack plus
|
||||
decay longer than the gate is not an error either: the release simply starts partway up the
|
||||
ramp.
|
||||
|
||||
**A voice goes quiet on its own** once gate and release are both spent. The mixer clears
|
||||
`active`, which is why `akgl_audio_voice_active` is how you wait out a note.
|
||||
|
||||
### Sweeps
|
||||
|
||||
`akgl_audio_sweep` steps the pitch by `step_hz` every 1/60 of a second toward `to_hz`,
|
||||
stopping when it arrives and holding there for whatever is left of the gate:
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
#define AKGL_AUDIO_SWEEP_TICK_HZ 60
|
||||
```
|
||||
|
||||
Sixty, because the machine whose `SOUND` statement this serves advanced its sweep on a 60 Hz
|
||||
interrupt and its tunes are written for that rate. It divides the 44100 Hz sample rate
|
||||
exactly, so a step boundary always lands on a whole frame.
|
||||
|
||||
**Direction comes from the two frequencies, not from the sign of `step_hz`.** A downward
|
||||
sweep is `to_hz` below `from_hz` with `step_hz` still positive; a negative or zero `step_hz`
|
||||
raises `AKERR_OUTOFBOUNDS`. Equal frequencies are legal and are simply a held tone, so a
|
||||
caller translating a statement that computes its own limits does not have to special-case
|
||||
them.
|
||||
|
||||
**The step is taken on the mixer's own frame counter, and that is the whole reason this
|
||||
function exists.** A caller re-issuing tones ties audible pitch to how often it happens to
|
||||
run, so the same siren changes shape with the frame rate. Nothing outside the library can
|
||||
see that counter.
|
||||
|
||||
`akgl_audio_tone` clears any sweep left on the voice — a step of 0 is what says "one pitch,
|
||||
held" — so you never have to undo one.
|
||||
|
||||
### The voice table exists without a device
|
||||
|
||||
`akgl_audio_voices[3]` is process-wide static storage. It is reachable, settable and
|
||||
readable before `akgl_audio_init` has ever run, and every entry point works. What you cannot
|
||||
do is set up voices with no device open and expect to *hear* them.
|
||||
|
||||
That leaves two ways to get sound out, and **you must pick one**:
|
||||
|
||||
```text
|
||||
Path A -- libakgl owns the device
|
||||
---------------------------------
|
||||
akgl_audio_init()
|
||||
| opens an SDL_AudioStream, F32, mono, 44100 Hz, and resumes it
|
||||
| (SDL opens devices paused; this one does not, because a caller
|
||||
| who set up a voice and heard nothing would have no error to
|
||||
| explain it)
|
||||
v
|
||||
SDL's callback thread -> akgl_audio_mix(internal buffer, <= 512 frames)
|
||||
|
||||
|
||||
Path B -- the host owns the device
|
||||
----------------------------------
|
||||
your own audio pipeline -> akgl_audio_mix(your buffer, frames)
|
||||
| no device is ever opened here
|
||||
```
|
||||
|
||||
**Do not use both.** `akgl_audio_mix` mutates the voice table — it advances each voice's
|
||||
frame counter and clears `active` on voices that have finished — and **it does not take the
|
||||
stream lock.** Every other entry point does, when there is a stream to lock: the internal
|
||||
`lock_voices` is a no-op with no device open, which is exactly right for path B and exactly
|
||||
wrong if you mix the two. Calling `akgl_audio_mix` yourself while a device opened by
|
||||
`akgl_audio_init` is running is two threads advancing the same voices.
|
||||
|
||||
Path B is what a host with its own audio pipeline wants, and it is how `tests/audio.c`
|
||||
exercises the synthesis without depending on a sound card's timing:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/audio.h>
|
||||
|
||||
#define HOST_FRAMES 512
|
||||
|
||||
/* A host that owns its own audio pipeline pulls samples itself and never
|
||||
calls akgl_audio_init. Do not do both: this path does not take the
|
||||
stream lock. */
|
||||
akerr_ErrorContext *host_fill(float32_t *dest)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_audio_mix(dest, HOST_FRAMES));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Samples are single-precision, one channel, in the range -1.0 to 1.0. `dest` is **overwritten,
|
||||
not accumulated into**, and the frame count is trusted rather than checked against anything —
|
||||
`akgl_audio_mix` raises `AKERR_NULLPOINTER` for a `NULL` destination and `AKERR_OUTOFBOUNDS`
|
||||
for a negative count, and that is the extent of the validation. A count of 0 is a no-op.
|
||||
|
||||
Three voices at full level can sum past full scale. The mix is **clamped** rather than scaled
|
||||
by the voice count, which keeps a single voice at the level it was asked for instead of a
|
||||
third of it.
|
||||
|
||||
### Sample rate, mix size, and the numbers
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
/** @brief Sample rate of the generated stream, in frames per second. */
|
||||
#define AKGL_AUDIO_SAMPLE_RATE 44100
|
||||
|
||||
/** @brief Frames the device callback generates per pass through the mixer. */
|
||||
#define AKGL_AUDIO_MIX_FRAMES 512
|
||||
```
|
||||
|
||||
Durations are milliseconds everywhere in the API and frames everywhere inside it. The
|
||||
conversion truncates, so a duration under about 0.023 ms rounds to nothing. There is **no
|
||||
upper bound on frequency**: a frequency above half the sample rate aliases rather than being
|
||||
refused.
|
||||
|
||||
`akgl_audio_shutdown` closes the device and resets the voice table to its defaults —
|
||||
including the master volume, back to 1.0 — so a later `akgl_audio_init` starts from a known
|
||||
state. It is safe with no device open and safe to call twice. `akgl_audio_init` is a no-op
|
||||
when a device is already open, and resets the voice table **only on the first call**, so it
|
||||
does not silence a voice that is already sounding.
|
||||
|
||||
### What raises what
|
||||
|
||||
| Status | When |
|
||||
|---|---|
|
||||
| `AKERR_OUTOFBOUNDS` | A voice index outside 0–2; a non-positive frequency or sweep step; a zero `ms`; a `sustain` or `volume` outside 0.0–1.0; a waveform outside the five; a negative frame count |
|
||||
| `AKERR_NULLPOINTER` | `NULL` passed to `akgl_audio_voice_active`'s `active` or `akgl_audio_mix`'s `dest` |
|
||||
| `AKGL_ERR_SDL` | `akgl_audio_init` cannot open or resume a playback device. The message carries `SDL_GetError()` |
|
||||
|
||||
A zero-length tone is **refused** rather than treated as "stop", because `akgl_audio_stop`
|
||||
already means that.
|
||||
|
||||
## Part 2 — the asset path
|
||||
|
||||
One function:
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/assets.h>
|
||||
#include <akgl/game.h>
|
||||
|
||||
/* akgl_game_init creates akgl_mixer; this needs it. Nothing else in the
|
||||
library calls akgl_load_start_bgm, and there is no music registry entry. */
|
||||
akerr_ErrorContext *start_music(char *path)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_load_start_bgm(path));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
It loads `fname` through `akgl_mixer`, creates a `MIX_Track` for it, stores that track in
|
||||
`akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM]`, publishes the audio as `akgl_bgm`, and starts
|
||||
playback.
|
||||
|
||||
**Which formats work is SDL3_mixer's question, not libakgl's** — see the
|
||||
[SDL3_mixer documentation](https://wiki.libsdl.org/SDL3_mixer/). libakgl passes the path
|
||||
through verbatim; it is not resolved against `SDL_GetBasePath()`, so a relative path is
|
||||
relative to the process working directory. Loading is not deferred: `MIX_LoadAudio(..., true)`
|
||||
predecodes, so a large file costs its decode time here rather than at first play.
|
||||
|
||||
`akgl_game.h` reserves slot 1 of a 64-slot track table for the music and leaves the rest to
|
||||
you:
|
||||
|
||||
```c excerpt=include/akgl/game.h
|
||||
/** @brief Slot in ::akgl_tracks reserved for background music. Note that slot 0 is unused. */
|
||||
#define AKGL_GAME_AUDIO_TRACK_BGM 1
|
||||
/** @brief Size of the ::akgl_tracks table. Every simultaneous sound needs its own slot. */
|
||||
#define AKGL_GAME_AUDIO_MAX_TRACKS 64
|
||||
```
|
||||
|
||||
### Four honest statements about this half
|
||||
|
||||
**It requires `akgl_game_init`, not `akgl_audio_init`.** `akgl_mixer` is created by
|
||||
`akgl_game_init` at `src/game.c:250`, and it is the only place in the tree that creates it.
|
||||
`assets.h:17-18` says "`akgl_game_init` (or a bare `akgl_audio_init`) has to have run first";
|
||||
that is wrong. `akgl_audio_init` opens the *synthesizer's* `SDL_AudioStream` and never
|
||||
touches `akgl_mixer`, so calling only that leaves `akgl_load_start_bgm` handing `NULL` to
|
||||
`MIX_LoadAudio`.
|
||||
|
||||
**The music does not loop.** `src/assets.c:20` initialises `bgmprops` to 0,
|
||||
`src/assets.c:43` sets `MIX_PROP_PLAY_LOOPS_NUMBER` on it, and `src/assets.c:45` plays the
|
||||
track with it. **0 is SDL's "no property set" sentinel**, not a set this function owns, so the
|
||||
write is rejected — unchecked — and the play call is given no options. The music plays once
|
||||
and stops, and `akgl_load_start_bgm` reports success either way. `TODO.md` item 19; the fix
|
||||
is `SDL_CreateProperties()` into `bgmprops`, checked, and destroyed in `CLEANUP`.
|
||||
|
||||
**`AKGL_REGISTRY_MUSIC` exists and nothing populates it.** `akgl_registry_init_music()`
|
||||
creates the property set and `akgl_game_init` calls it, but no function in the library ever
|
||||
writes an entry. If you want music by name, that registry is yours to fill and yours to read
|
||||
— through `SDL_SetPointerProperty` and `SDL_GetPointerProperty` directly, as
|
||||
[Chapter 6](06-the-registry.md) describes. There is no `akgl_music_*` API.
|
||||
|
||||
**There are no audio fixtures anywhere in this repository.** Not one `.wav`, `.ogg`, `.mp3`,
|
||||
`.flac` or tracker module, in `tests/assets/` or `util/assets/` or anywhere else. Nothing in
|
||||
the tree calls `akgl_load_start_bgm` either. **This function has no test coverage at all**,
|
||||
which is worth knowing before you build on it: the loop defect above was found by reading
|
||||
the code, and a test would have caught it.
|
||||
|
||||
### The overwrite and the leak on the way out
|
||||
|
||||
`akgl_load_start_bgm` overwrites `akgl_tracks[AKGL_GAME_AUDIO_TRACK_BGM]` without releasing
|
||||
whatever was there, so calling it twice leaks a `MIX_Track`. On a failure *after* the audio
|
||||
loads, the audio is destroyed again before the error is returned — but the track, if one was
|
||||
created, is not. Both are startup-helper behaviour and both are visible in the twelve lines
|
||||
of `src/assets.c`; treat this as a "call it once at startup" function, which is what its
|
||||
`@brief` says it is.
|
||||
|
||||
## Which one do I want?
|
||||
|
||||
**Sound effects for a game you are writing from scratch: the synthesizer.** No files, no
|
||||
formats, no decode cost, no licensing question about the samples, and a laser is four lines.
|
||||
|
||||
**Music, or anything recorded: SDL3_mixer.** Either through `akgl_load_start_bgm` for the one
|
||||
background track, or by using `akgl_mixer` directly with SDL3_mixer's own API — `akgl_mixer`
|
||||
and `akgl_tracks` are public globals for exactly that reason, and there is nothing libakgl
|
||||
adds that you would be going around.
|
||||
437
docs/18-utilities.md
Normal file
437
docs/18-utilities.md
Normal file
@@ -0,0 +1,437 @@
|
||||
# 18. Utilities
|
||||
|
||||
`util.h`, `json_helpers.h` and `staticstring.h` hold the pieces that belong to no subsystem:
|
||||
rectangle overlap, asset path resolution, typed JSON accessors, and the pooled string type
|
||||
everything else passes around. The JSON accessors are the valuable part of this chapter —
|
||||
their status semantics are what every asset loader in the library is written against, and
|
||||
they are documented nowhere else.
|
||||
|
||||
## Collision helpers
|
||||
|
||||
Three functions, all axis-aligned, all in `util.h`:
|
||||
|
||||
| Function | Question it answers |
|
||||
|---|---|
|
||||
| `akgl_rectangle_points(dest, rect)` | What are the four corners of this `SDL_FRect`? |
|
||||
| `akgl_collide_point_rectangle(p, r, collide)` | Is this point inside those corners? |
|
||||
| `akgl_collide_rectangles(r1, r2, collide)` | Do these two rectangles overlap? |
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <SDL3/SDL.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
/* Do two actor boxes touch? Edges count as touching. */
|
||||
akerr_ErrorContext *boxes_touch(SDL_FRect *a, SDL_FRect *b, bool *hit)
|
||||
{
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_collide_rectangles(a, b, hit));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
|
||||
/* Is the mouse over a button? Corners first, then the point test. */
|
||||
akerr_ErrorContext *point_in_box(int x, int y, SDL_FRect *box, bool *hit)
|
||||
{
|
||||
akgl_RectanglePoints corners;
|
||||
akgl_Point p;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
p.x = x;
|
||||
p.y = y;
|
||||
p.z = 0;
|
||||
PASS(errctx, akgl_rectangle_points(&corners, box));
|
||||
PASS(errctx, akgl_collide_point_rectangle(&p, &corners, hit));
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
Four properties, all deliberate:
|
||||
|
||||
**Axis-aligned only.** There is no rotation support and no separating-axis test. An actor
|
||||
drawn with a non-zero `angle` still collides as its unrotated box.
|
||||
|
||||
**Edges count as touching.** A point exactly on a boundary is inside; two rectangles sharing
|
||||
an edge and no area overlap. Both comparisons are `>=` and `<=`.
|
||||
|
||||
**Coordinates are truncated from `float` to `int` on the way in.** A rectangle at
|
||||
`x = 10.9` has its corners at 10. That is right for tile-grid work and wrong for sub-pixel
|
||||
work; if you need the latter, do not round-trip through `akgl_rectangle_points`.
|
||||
|
||||
**`z` is carried and never used.** `akgl_Point` has one, `akgl_rectangle_points` never
|
||||
writes it, and neither collision test reads it. These are 2D tests.
|
||||
|
||||
### The arrangement it misses
|
||||
|
||||
`akgl_collide_rectangles` works corner by corner: it tests each rectangle's four corners
|
||||
against the other, eight tests, stopping at the first hit. Testing both directions is what
|
||||
catches one rectangle wholly inside the other, which has no corner in its neighbour.
|
||||
|
||||
**It still misses the cross.** A tall thin rectangle crossing a short wide one overlaps
|
||||
without either enclosing a corner of the other, and both are reported as not colliding:
|
||||
|
||||
```text
|
||||
+---+ +---+
|
||||
| | | |
|
||||
+---|---|---+ +---+---+---+ <- overlaps, but
|
||||
| | | | is | | | | no corner of
|
||||
+---|---|---+ reported +---+---+---+ either is inside
|
||||
| | as | | the other
|
||||
+---+ "no hit" +---+
|
||||
```
|
||||
|
||||
The fix is an edge-span comparison instead of a corner-containment one — `r1.x < r2.x + r2.w
|
||||
&& r2.x < r1.x + r1.w` on both axes — and it is not made here. If your game can produce that
|
||||
shape, and a long thin platform crossing a tall thin character is exactly that shape, test
|
||||
for it yourself. Recorded as an `@note` on the function.
|
||||
|
||||
This is separate from the fact that **nothing in the library calls these during a frame.**
|
||||
`akgl_physics_simulate` never calls `collide`, and `akgl_physics_arcade_collide` raises
|
||||
`AKERR_API` — see [Chapter 14](14-physics.md). Collision detection is yours to run, and these
|
||||
are the helpers for running it.
|
||||
|
||||
## Path resolution
|
||||
|
||||
Asset files name their neighbours relatively: a sprite definition names its spritesheet, a
|
||||
tilemap names its tilesets. "Relative" has to mean *relative to the file doing the naming*,
|
||||
not to wherever the game was launched from — and `akgl_path_relative` is what makes that
|
||||
true.
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/staticstring.h>
|
||||
#include <akgl/util.h>
|
||||
|
||||
/* Resolve one asset's reference to a neighbour. dest must already be a
|
||||
claimed pool string -- this writes into it, it does not claim one. */
|
||||
akerr_ErrorContext *resolve_neighbour(char *mapdir, char *reference)
|
||||
{
|
||||
akgl_String *resolved = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
PASS(errctx, akgl_heap_next_string(&resolved));
|
||||
|
||||
ATTEMPT {
|
||||
CATCH(errctx, akgl_path_relative(mapdir, reference, resolved));
|
||||
SDL_Log("%s -> %s", reference, resolved->data);
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(resolved));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The order it tries things in:
|
||||
|
||||
```text
|
||||
akgl_path_relative(root, path, dest)
|
||||
|
|
||||
1. realpath(path) <- relative to the process CWD
|
||||
| |
|
||||
| +-- succeeds -> absolute result into dest, done
|
||||
| |
|
||||
| +-- ENOENT ---+
|
||||
| |
|
||||
2. +-> realpath(root + "/" + path)
|
||||
|
|
||||
+-- succeeds -> absolute result, done
|
||||
|
|
||||
+-- fails -> the errno propagates
|
||||
```
|
||||
|
||||
Either way the result is **absolute**, with symlinks and `..` folded out, because both
|
||||
branches end in `realpath(3)`.
|
||||
|
||||
Three things to know:
|
||||
|
||||
- **`dest` must already be a claimed pool string.** This writes into it; it does not claim
|
||||
one for you. `root` is required even on the path where it goes unused.
|
||||
- **The fallback pastes `root` in front of `path` unconditionally**, so an absolute `path`
|
||||
that does not exist gets `root` prepended and fails on a nonsense spelling. Do not send
|
||||
absolute paths you expect to fall through.
|
||||
- **`AKERR_OUTOFBOUNDS`** if `root + "/" + path` would not fit `AKGL_MAX_STRING_LENGTH`
|
||||
(`PATH_MAX`); **`ENOENT`**, or whatever else `realpath(3)` reports — `EACCES`, `ELOOP`,
|
||||
`ENOTDIR` — if neither spelling names an existing file; **`AKGL_ERR_HEAP`** if the string
|
||||
pool cannot supply the scratch buffers.
|
||||
|
||||
> **Stale defect note.** `util.h:124-128` warns that the fallback path "returns straight out
|
||||
> of the ENOENT handler and so never releases the error context it was handling", costing one
|
||||
> of libakerror's 128 context slots per call for the life of the process, and `TODO.md` item
|
||||
> 15 records the same thing as open. **Both are out of date.** `src/util.c:115-129` sets a
|
||||
> flag in the `HANDLE(errctx, ENOENT)` block and calls `path_relative_root` *after* `FINISH`,
|
||||
> which is exactly the fix that entry proposes, and the code carries a comment explaining
|
||||
> why. The leak is gone; the two notes describing it are not.
|
||||
|
||||
## The JSON accessors
|
||||
|
||||
Every asset format in this library is JSON, parsed by
|
||||
[jansson](https://jansson.readthedocs.io/), and jansson's own accessors answer "missing
|
||||
key", "wrong type" and "index past the end" all with the same `NULL`. `json_helpers.h`
|
||||
splits them apart so a malformed asset file produces a message naming the key and what was
|
||||
wrong with it, instead of a `NULL` dereference three frames later.
|
||||
|
||||
**jansson owns `json_t`, `json_decref`, the parser and the reference-counting rules.** What
|
||||
this section documents is which status means what, and who owns the results.
|
||||
|
||||
### Five conventions that run through the whole set
|
||||
|
||||
**Absence is an error.** A missing key is `AKERR_KEY` — not a quiet `NULL`, not a zero. That
|
||||
is the opposite of the search functions in libakstdlib, and it is deliberate: an optional key
|
||||
is expressed by handing the resulting error to `akgl_get_json_with_default`, not by the
|
||||
accessor staying quiet. So `AKERR_KEY` in a loader is frequently a `HANDLE` block rather than
|
||||
a failure.
|
||||
|
||||
**Results come back through `dest`,** because the return value is the error context. Every
|
||||
argument is `NULL`-checked — the document or array, the key where there is one, and `dest` —
|
||||
and each raises `AKERR_NULLPOINTER` with a message saying which. `dest` is not written on any
|
||||
failure path.
|
||||
|
||||
**`json_t *` results are borrowed, not owned.** `akgl_get_json_object_value`,
|
||||
`akgl_get_json_array_value` and `akgl_get_json_array_index_object` hand back pointers *into*
|
||||
the document. They are valid until the document is freed and **must never be
|
||||
`json_decref`'d.** Doing so drops a reference the accessor never took and takes the sub-object
|
||||
out from under the document that owns it.
|
||||
|
||||
**Strings are the exception: they are copied.** `akgl_get_json_string_value` and
|
||||
`akgl_get_json_array_index_string` write into a pooled `akgl_String` you release yourself.
|
||||
`*dest` doubles as an input — `NULL` means "claim one for me", non-`NULL` means "write into
|
||||
this one" — and it must be *initialized* either way, because an indeterminate `*dest` is
|
||||
dereferenced. A value longer than `AKGL_MAX_STRING_LENGTH` is truncated silently.
|
||||
|
||||
**The document is never modified.** No accessor writes to it, adds to it, or reorders it.
|
||||
|
||||
### The status table
|
||||
|
||||
These are the four statuses this family raises. [Chapter 4](04-errors.md) carries the full
|
||||
tables for the library; this one says what each means *here*.
|
||||
|
||||
| Status | Means | Raised by |
|
||||
|---|---|---|
|
||||
| `AKERR_NULLPOINTER` | Any argument was `NULL` — the document, the key, or `dest` | All |
|
||||
| `AKERR_KEY` | **The key is absent.** The idiomatic "optional thing was not there" | The object accessors |
|
||||
| `AKERR_TYPE` | The key is present but is the wrong JSON type | All |
|
||||
| `AKERR_OUTOFBOUNDS` | The index is outside the array. A negative index reports the same way as one past the end | The `akgl_get_json_array_index_*` family |
|
||||
| `AKGL_ERR_HEAP` | `*dest` was `NULL` and the string pool is exhausted | The two string accessors |
|
||||
|
||||
**`AKERR_INDEX` is not in that list, and libakgl never raises it anywhere.** It appears in
|
||||
this subsystem only as a `HANDLE_GROUP` arm inside `akgl_get_json_with_default`, defending
|
||||
against a status no accessor produces. Harmless, and worth knowing before you write a
|
||||
`HANDLE` block for it.
|
||||
|
||||
> **Stale header prose.** The conventions block at the top of `json_helpers.h` says "**@p
|
||||
> dest is not `NULL`-checked** except where noted, so a `NULL` there is a crash rather than an
|
||||
> error context", and `akgl_get_json_string_value`'s `@param key` says it is "checked here,
|
||||
> unlike the other accessors in this file". **Neither is true.** All eleven accessors check
|
||||
> `dest`, and all seven that take a `key` check that too — `src/json_helpers.c` raises
|
||||
> `AKERR_NULLPOINTER` with "NULL destination pointer" or "NULL key string" at every one. The
|
||||
> per-function claim that a `NULL` key "is reported as a missing key rather than as a `NULL`
|
||||
> pointer" is wrong the same way.
|
||||
|
||||
### Integer is strict; number is not
|
||||
|
||||
This is the distinction that catches people:
|
||||
|
||||
| Accessor | `"w": 3` | `"w": 3.0` | `"w": "3"` |
|
||||
|---|---|---|---|
|
||||
| `akgl_get_json_integer_value` | 3 | **`AKERR_TYPE`** | `AKERR_TYPE` |
|
||||
| `akgl_get_json_number_value` | 3.0f | 3.0f | `AKERR_TYPE` |
|
||||
| `akgl_get_json_double_value` | 3.0 | 3.0 | `AKERR_TYPE` |
|
||||
|
||||
`3.0` is a *real* in JSON, and `akgl_get_json_integer_value` refuses it rather than
|
||||
truncating it. Use one of the number accessors where either spelling should be accepted;
|
||||
`_double_value` exists because the physics constants are `double`.
|
||||
|
||||
`akgl_get_json_boolean_value` is strict in the same direction: `0` and `1` are numbers in
|
||||
JSON and are refused. It wants `true` or `false`.
|
||||
|
||||
### Reading a document
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <jansson.h>
|
||||
#include <akgl/json_helpers.h>
|
||||
#include <akgl/heap.h>
|
||||
#include <akgl/staticstring.h>
|
||||
|
||||
/* A required key, an optional one with a default, and a borrowed sub-object. */
|
||||
akerr_ErrorContext *read_sprite_header(json_t *doc)
|
||||
{
|
||||
json_t *frames = NULL; /* BORROWED. Never json_decref this. */
|
||||
akgl_String *name = NULL; /* Copied, and pooled. Release it. */
|
||||
int width = 0;
|
||||
int width_default = 32;
|
||||
float32_t scale = 0.0f;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
ATTEMPT {
|
||||
/* Required: an absent "name" is AKERR_KEY and propagates. */
|
||||
CATCH(errctx, akgl_get_json_string_value(doc, "name", &name));
|
||||
|
||||
/* Optional: AKERR_KEY becomes width_default and a NULL return. */
|
||||
CATCH(errctx, akgl_get_json_with_default(
|
||||
akgl_get_json_integer_value(doc, "width", &width),
|
||||
&width_default,
|
||||
&width,
|
||||
sizeof(int)));
|
||||
|
||||
/* Strict: "scale": 2 and "scale": 2.0 are both accepted here.
|
||||
akgl_get_json_integer_value would refuse the second. */
|
||||
CATCH(errctx, akgl_get_json_number_value(doc, "scale", &scale));
|
||||
|
||||
/* Borrowed: valid until the document is freed. */
|
||||
CATCH(errctx, akgl_get_json_array_value(doc, "frames", &frames));
|
||||
SDL_Log("%s: %d wide, %.1f scale, %d frames",
|
||||
name->data, width, scale, (int)json_array_size(frames));
|
||||
} CLEANUP {
|
||||
IGNORE(akgl_heap_release_string(name));
|
||||
} PROCESS(errctx) {
|
||||
} FINISH(errctx, true);
|
||||
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
### `akgl_get_json_with_default` — how optional is spelled
|
||||
|
||||
Run the accessor, hand its error context here along with the fallback. A status it handles
|
||||
becomes `dest` holding the default and a `NULL` return; anything else propagates untouched.
|
||||
When the error *is* consumed it is also released, so you must not release it again. A `NULL`
|
||||
`e` — the accessor succeeded — returns at once without touching `dest`.
|
||||
|
||||
**It defaults on three statuses**, and this is the current behaviour, checked against
|
||||
`src/json_helpers.c:207-211`:
|
||||
|
||||
| Status | Defaulted? | Why |
|
||||
|---|---|---|
|
||||
| `AKERR_KEY` | Yes | The key is absent. That is what "optional" means for an object member |
|
||||
| `AKERR_OUTOFBOUNDS` | **Yes** | The index is past the end. That is what "optional" means for an array element |
|
||||
| `AKERR_INDEX` | Yes | Defensive only — nothing in libakgl raises it |
|
||||
| `AKERR_TYPE` | **No** | Propagates, and that is the correct line to draw |
|
||||
|
||||
**The `AKERR_TYPE` exclusion is the interesting one.** "The key is missing" and "the key is
|
||||
present but holds a string where a number belongs" are not the same situation. The first is
|
||||
an omitted setting and a default is exactly right for it. The second is a malformed document,
|
||||
and quietly substituting a default there would hide the defect and hand the game a value the
|
||||
author never wrote. So it propagates.
|
||||
|
||||
`AKERR_OUTOFBOUNDS` is the one that changed, because it is what the
|
||||
`akgl_get_json_array_index_*` family actually raises for a short array. **An array element
|
||||
does get a default now**, and "this element is optional" works for an array index as well as
|
||||
for an object member.
|
||||
|
||||
> **Stale header prose.** `include/akgl/json_helpers.h:230-234` still says the opposite —
|
||||
> that it defaults on `AKERR_KEY` and `AKERR_INDEX` "but *not* on `AKERR_OUTOFBOUNDS` … so
|
||||
> this pairs with the object accessors and does not currently give an array index a default".
|
||||
> That was true before 0.5.0. `src/json_helpers.c` now carries a third
|
||||
> `HANDLE_GROUP(e, AKERR_OUTOFBOUNDS)` arm, placed *above* the arm holding the `memcpy`
|
||||
> because `HANDLE_GROUP` emits no `break` and every arm falls into that body, and
|
||||
> `tests/json_helpers.c` covers it through both the integer and object index accessors.
|
||||
> `TODO.md` item 18 records it as fixed. The header comment wants correcting in its own
|
||||
> commit.
|
||||
|
||||
`defsize` is trusted, not derived: it is a `memcpy` through `void *` with no type
|
||||
information, and it must match the type both sides actually are.
|
||||
|
||||
**`defsize` is not the trap, though. Ownership is.**
|
||||
|
||||
```c
|
||||
#include <akerror.h>
|
||||
#include <jansson.h>
|
||||
#include <akgl/error.h>
|
||||
#include <akgl/json_helpers.h>
|
||||
|
||||
/* akgl_get_json_with_default hands BACK the context it was given when it
|
||||
does not handle the status. Take the result into a local, drop your own
|
||||
pointer, and release it yourself -- a CLEANUP block that also releases it
|
||||
double-releases, and a double-released context corrupts the failure
|
||||
instead of reporting it. */
|
||||
akerr_ErrorContext *optional_int(json_t *doc, char *key, int *dest, int fallback)
|
||||
{
|
||||
akerr_ErrorContext *keyerr = NULL;
|
||||
akerr_ErrorContext *defaulted = NULL;
|
||||
|
||||
PREPARE_ERROR(errctx);
|
||||
|
||||
keyerr = akgl_get_json_integer_value(doc, key, dest);
|
||||
defaulted = akgl_get_json_with_default(keyerr, &fallback, dest, sizeof(int));
|
||||
keyerr = NULL; /* ownership passed either way */
|
||||
if ( defaulted != NULL ) {
|
||||
defaulted->handled = true;
|
||||
defaulted = akerr_release_error(defaulted);
|
||||
FAIL_RETURN(errctx, AKGL_ERR_BEHAVIOR, "%s was present but not an integer", key);
|
||||
}
|
||||
SUCCEED_RETURN(errctx);
|
||||
}
|
||||
```
|
||||
|
||||
The inline form in the previous example — passing the accessor's return straight into
|
||||
`akgl_get_json_with_default` inside a `CATCH` — is safe, because you never hold a second
|
||||
pointer to the context. It is when you *keep* one that this matters, and a test written the
|
||||
naive way passes against broken code: the first draft of the `AKERR_OUTOFBOUNDS` test in
|
||||
`tests/json_helpers.c` did exactly that. See [Chapter 4](04-errors.md) and `AGENTS.md` under
|
||||
"Testing Guidelines".
|
||||
|
||||
### The Tiled property helpers are elsewhere
|
||||
|
||||
`akgl_get_json_properties_integer`, `_number`, `_float`, `_double`, `_string` and
|
||||
`akgl_get_json_tilemap_property` are declared in `tilemap.h`, not here. They read Tiled's
|
||||
custom-property array — a list of `{"name": …, "type": …, "value": …}` objects — rather than
|
||||
a plain JSON object, which is a different shape and belongs to a different chapter. See
|
||||
[Chapter 13](13-tilemaps.md).
|
||||
|
||||
## `akgl_String`
|
||||
|
||||
The library allocates nothing at runtime, so a string here is a fixed-capacity buffer claimed
|
||||
from the pool:
|
||||
|
||||
```c excerpt=include/akgl/staticstring.h
|
||||
#define AKGL_MAX_STRING_LENGTH PATH_MAX
|
||||
|
||||
/** @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;
|
||||
```
|
||||
|
||||
`data` is a plain `char[]`, so `str->data` goes anywhere a `char *` does. `refcount` belongs
|
||||
to the heap layer — [Chapter 5](05-the-heap.md) — and **`akgl_heap_next_string` is the one
|
||||
acquire function that takes the reference for you**, unlike the other four pools.
|
||||
|
||||
Two functions operate on the contents:
|
||||
|
||||
| Function | What it does |
|
||||
|---|---|
|
||||
| `akgl_string_initialize(obj, init)` | Copies `init` in, or zero-fills when `init` is `NULL`, and sets `refcount` to 1. Always NUL-terminates; truncates silently |
|
||||
| `akgl_string_copy(src, dest, count)` | Bounded copy between two already-claimed slots. `count` of 0 selects the whole buffer. Always NUL-terminates. Leaves `refcount` alone |
|
||||
|
||||
**Capacity is `PATH_MAX`**, which is what makes the string pool by far the largest of the
|
||||
five — 256 slots is about a megabyte — and why claiming the *last* free slot from it costs
|
||||
250 ns against 4 ns from the actor pool. Same algorithm, different cache behaviour; the scan
|
||||
touches one reference count every 4 KiB. `PERFORMANCE.md` has the measurement.
|
||||
|
||||
`akgl_string_copy` refuses a negative `count` or one above `AKGL_MAX_STRING_LENGTH` with
|
||||
`AKERR_OUTOFBOUNDS`, because both buffers are exactly that long and a larger count walked off
|
||||
the end of two pool slots at once.
|
||||
|
||||
## The two test-only helpers
|
||||
|
||||
`akgl_compare_sdl_surfaces` and `akgl_render_and_compare` are in `util.h` and are labelled in
|
||||
the header as "REALLY slow routines that are only useful in testing harnesses". They are not
|
||||
frame-path functions and this manual does not otherwise cover them:
|
||||
|
||||
- `akgl_compare_sdl_surfaces(s1, s2)` — dimensions, pitch and format first, then a `memcmp`
|
||||
over the raw pixels. A mismatch is reported as `AKERR_VALUE`, an error rather than an
|
||||
out-parameter.
|
||||
- `akgl_render_and_compare(t1, t2, x, y, w, h, writeout)` — draws each texture into the same
|
||||
rectangle, reads the framebuffer back after each, and compares. Answers "do these render
|
||||
the same", which is not the same question as "are these identical", because the renderer's
|
||||
scaling and blending sit in between. `writeout` names a PNG of the first render, under
|
||||
`SDL_GetBasePath()`, which is how you see what was actually drawn when an assertion fails.
|
||||
|
||||
Both have a scar worth repeating. Until 0.5.0 `akgl_render_and_compare` drew `t1` on **both**
|
||||
passes, so it always reported a match and every image assertion built on it asserted nothing.
|
||||
384
docs/21-appendix-limits.md
Normal file
384
docs/21-appendix-limits.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# 21. Appendix: limits, statuses and properties
|
||||
|
||||
Three reference tables that no single chapter owns: which function raises which status,
|
||||
every compile-time bound, and every configuration property the library reads.
|
||||
|
||||
The constant tables come in as `excerpt=` blocks against their headers, so they cannot
|
||||
drift from the values the library was built with — and so the hand-aligned columns those
|
||||
headers maintain survive.
|
||||
|
||||
## A. Status cross-reference
|
||||
|
||||
**What this table is.** The statuses a caller can expect to see out of each function. It is
|
||||
built from the `FAIL_*` and `HANDLE*` sites in `src/` — the function's own body and the
|
||||
`static` helpers only it calls — plus, where a nested libakgl call is part of the documented
|
||||
contract, that status too.
|
||||
|
||||
**It is not exhaustive, and cannot be.** Every libakgl function a given one calls can
|
||||
propagate anything from its own row, and so can libakstdlib: nearly everything that touches
|
||||
a file also carries whatever `aksl_fopen`, `aksl_fread`, `aksl_fwrite` and `aksl_fclose`
|
||||
report, including bare `errno` values such as `ENOENT`, `EACCES` and `ENOSPC`. Use the table
|
||||
to write the `HANDLE` arms you care about, not to prove a status cannot arrive.
|
||||
|
||||
`(H)` marks a status the function **handles** rather than raises.
|
||||
|
||||
What the statuses mean is [Chapter 4](04-errors.md).
|
||||
|
||||
### `error.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_error_init` | `AKERR_STATUS_RANGE_OVERLAP`, `AKERR_STATUS_RANGE_FULL`, `AKERR_STATUS_NAME_FULL` (all libakerror's) |
|
||||
|
||||
### `heap.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_heap_init`, `akgl_heap_init_actor` | *nothing — no failure path today* |
|
||||
| `akgl_heap_next_actor`, `_sprite`, `_spritesheet`, `_character`, `_string` | `AKGL_ERR_HEAP` |
|
||||
| `akgl_heap_release_actor`, `_sprite`, `_spritesheet`, `_character`, `_string` | `AKERR_NULLPOINTER` |
|
||||
|
||||
### `registry.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_registry_init` and the eight `akgl_registry_init_*` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_registry_load_properties` | `AKERR_NULLPOINTER`, `AKERR_KEY`, `AKERR_TYPE`, `AKGL_ERR_HEAP` |
|
||||
| `akgl_set_property` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_get_property` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKERR_VALUE`, `AKGL_ERR_HEAP` |
|
||||
|
||||
### `game.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_game_init` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_game_update_fps`, `akgl_game_lowfps` | *`void` — cannot report* |
|
||||
| `akgl_game_state_lock` | `AKGL_ERR_SDL` |
|
||||
| `akgl_game_state_unlock` | *nothing* |
|
||||
| `akgl_game_update` | `AKGL_ERR_SDL`, plus whatever `updatefunc`, `akgl_tilemap_scale_actor`, the physics backend or the renderer raises |
|
||||
| `akgl_game_save` | `AKERR_NULLPOINTER`, `AKERR_IO`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_game_save_actors` | `AKERR_NULLPOINTER`, `AKERR_IO` |
|
||||
| `akgl_game_load` | `AKERR_NULLPOINTER`, `AKERR_API`, `AKERR_EOF`, `AKERR_IO`, `AKERR_VALUE` |
|
||||
| `akgl_game_load_versioncmp` | `AKERR_NULLPOINTER`, `AKERR_VALUE`, `AKERR_API` |
|
||||
|
||||
### `actor.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_actor_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_actor_set_character` | `AKERR_NULLPOINTER` — **including "no such character"** |
|
||||
| `akgl_actor_add_child` | `AKERR_NULLPOINTER`, `AKERR_RELATIONSHIP`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_actor_update` | `AKERR_NULLPOINTER`, `AKERR_KEY` (H) |
|
||||
| `akgl_actor_render` | `AKERR_NULLPOINTER`, `AKERR_KEY` (H), `AKERR_OUTOFBOUNDS` (H) |
|
||||
| `akgl_actor_automatic_face`, `_logic_movement`, `_logic_changeframe` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_actor_cmhf_*_on`, `akgl_actor_cmhf_*_off` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_registry_iterate_actor` | `AKERR_NULLPOINTER`, `AKERR_KEY` — **`void`; unhandled means the process exits** |
|
||||
|
||||
### `character.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_character_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_character_sprite_add` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_character_sprite_get` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_character_load_json` | `AKERR_NULLPOINTER`, `AKERR_KEY` for a state name that is not in `AKGL_REGISTRY_ACTOR_STATE_STRINGS`, and `AKERR_TYPE` from the JSON accessors |
|
||||
| `akgl_character_state_sprites_iterate` | `AKERR_NULLPOINTER` — **`void`; unhandled means the process exits** |
|
||||
|
||||
### `sprite.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_sprite_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_sprite_load_json` | `AKERR_NULLPOINTER`, `AKERR_VALUE`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_spritesheet_initialize` | `AKERR_NULLPOINTER`, `AKERR_KEY`, `AKGL_ERR_SDL` |
|
||||
| `akgl_spritesheet_coords_for_frame` | `AKERR_NULLPOINTER` |
|
||||
|
||||
### `json_helpers.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_get_json_object_value`, `_boolean_`, `_integer_`, `_number_`, `_double_`, `_string_`, `_array_value` | `AKERR_NULLPOINTER`, **`AKERR_KEY` for absent**, **`AKERR_TYPE` for wrong type** |
|
||||
| `akgl_get_json_array_index_object`, `_integer`, `_string` | `AKERR_NULLPOINTER`, **`AKERR_OUTOFBOUNDS` for a short array**, `AKERR_TYPE` |
|
||||
| `akgl_get_json_with_default` | `AKERR_NULLPOINTER`; handles `AKERR_KEY` (H), `AKERR_OUTOFBOUNDS` (H), `AKERR_INDEX` (H) |
|
||||
|
||||
`akgl_get_json_string_value` and `akgl_get_json_array_index_string` also raise
|
||||
`AKGL_ERR_HEAP` when they have to claim a pooled string and the pool is empty.
|
||||
|
||||
### `physics.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_physics_init_null`, `akgl_physics_init_arcade` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_physics_factory` | `AKERR_NULLPOINTER`, **`AKERR_KEY` for an unknown backend name** |
|
||||
| `akgl_physics_simulate` | `AKERR_NULLPOINTER`; handles `AKGL_ERR_LOGICINTERRUPT` (H) |
|
||||
| `akgl_physics_null_gravity`, `_collide`, `_move` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_physics_arcade_gravity`, `_move` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_physics_arcade_collide` | `AKERR_NULLPOINTER`, **`AKERR_API` — not implemented** |
|
||||
|
||||
### `renderer.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_render_2d_init` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL`, plus `akgl_get_property`'s |
|
||||
| `akgl_render_2d_bind`, `_shutdown`, `_frame_start`, `_frame_end`, `_draw_texture`, `_draw_world` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_render_2d_draw_mesh` | **`AKERR_API` — not implemented** |
|
||||
|
||||
### `draw.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_draw_point`, `_line`, `_rect`, `_filled_rect` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_draw_background` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_draw_circle` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` |
|
||||
| `akgl_draw_flood_fill` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL`, `AKERR_OUTOFBOUNDS` past `AKGL_DRAW_MAX_FLOOD_SPANS` |
|
||||
| `akgl_draw_copy_region`, `_paste_region` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
|
||||
### `tilemap.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_tilemap_load` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_tilemap_load_layers` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_tilemap_load_layer_tile` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_tilemap_load_layer_image` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_tilemap_load_layer_objects` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_tilemap_load_layer_object_actor` | `AKERR_KEY` |
|
||||
| `akgl_tilemap_load_tilesets` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` past `AKGL_TILEMAP_MAX_TILESETS` |
|
||||
| `akgl_tilemap_load_tilesets_each` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_tilemap_load_physics` | handles `AKERR_KEY` (H) — every physics property is optional |
|
||||
| `akgl_get_json_tilemap_property` | `AKERR_NULLPOINTER`, `AKERR_KEY`, `AKERR_TYPE` |
|
||||
| `akgl_tilemap_draw`, `_draw_tileset` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_tilemap_scale_actor`, `akgl_tilemap_release` | `AKERR_NULLPOINTER` |
|
||||
|
||||
### `controller.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_controller_open_gamepads`, `_list_keyboards` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_controller_handle_event`, `_button_down`, `_button_up`, `_added`, `_removed` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_controller_pushmap` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_controller_default` | `AKERR_OUTOFBOUNDS`, **`AKGL_ERR_REGISTRY`** — the library's only site |
|
||||
| `akgl_controller_poll_key`, `_poll_keystroke` | `AKERR_NULLPOINTER` |
|
||||
|
||||
### `text.h`, `audio.h`, `assets.h`, `util.h`, `staticstring.h`
|
||||
|
||||
| Function | Raises |
|
||||
|---|---|
|
||||
| `akgl_text_loadfont` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_text_unloadfont` | `AKERR_NULLPOINTER`, `AKERR_KEY` |
|
||||
| `akgl_text_rendertextat` | `AKERR_NULLPOINTER` — including a `NULL` `akgl_renderer`, a `NULL` `sdl_renderer`, and a backend never run through `akgl_render_2d_bind` and so missing `draw_texture`. An **empty** string is not an error: it returns success having drawn nothing |
|
||||
| `akgl_text_measure`, `_measure_wrapped` | `AKERR_NULLPOINTER`. An empty string is legal and measures 0 wide by one line high |
|
||||
| `akgl_audio_tone`, `akgl_audio_sweep` | `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_audio_voice_active`, `akgl_audio_mix` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_load_start_bgm` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
|
||||
| `akgl_path_relative` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
|
||||
| `akgl_rectangle_points`, `akgl_collide_point_rectangle`, `akgl_collide_rectangles` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_compare_sdl_surfaces` | `AKERR_NULLPOINTER`, `AKERR_VALUE` |
|
||||
| `akgl_render_and_compare` | `AKERR_NULLPOINTER`, `AKERR_IO`, `AKGL_ERR_SDL` |
|
||||
| `akgl_string_initialize` | `AKERR_NULLPOINTER` |
|
||||
| `akgl_string_copy` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKERR_VALUE` |
|
||||
|
||||
## B. Compile-time limits
|
||||
|
||||
Everything below is fixed when the library is compiled. **Only the `AKGL_MAX_HEAP_*` five
|
||||
are overridable**, and even those have to be overridden for the whole build — see
|
||||
[Chapter 5](05-the-heap.md), "The ceilings are a compile-time ABI constraint". The rest are
|
||||
plain `#define`s with no `#ifndef` guard: changing one means editing the header and
|
||||
rebuilding libakgl and everything linking it.
|
||||
|
||||
### The object pools
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
Exceeding any of them is `AKGL_ERR_HEAP`.
|
||||
|
||||
### Strings
|
||||
|
||||
`AKGL_MAX_STRING_LENGTH` is `PATH_MAX`, which is 4096 on Linux. That is the capacity of
|
||||
every `akgl_String`, and it is why the string pool is over a megabyte on its own.
|
||||
|
||||
### Actors
|
||||
|
||||
```c excerpt=include/akgl/actor.h
|
||||
/** @brief Longest actor name, including the terminator. Names are truncated, not rejected. */
|
||||
#define AKGL_ACTOR_MAX_NAME_LENGTH 128
|
||||
/** @brief Children one actor can carry. A child moves with its parent rather than simulating. */
|
||||
#define AKGL_ACTOR_MAX_CHILDREN 8
|
||||
```
|
||||
|
||||
`AKGL_ACTOR_MAX_STATES` is 32, fixed by the width of `int32_t state` rather than chosen.
|
||||
A ninth child is `AKERR_OUTOFBOUNDS`; an over-long name is silently truncated.
|
||||
|
||||
### Sprites and spritesheets
|
||||
|
||||
```c excerpt=include/akgl/sprite.h
|
||||
#define AKGL_SPRITE_MAX_FRAMES 16
|
||||
#define AKGL_SPRITE_MAX_NAME_LENGTH 128
|
||||
#define AKGL_SPRITE_MAX_REGISTRY_SIZE 1024
|
||||
#define AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH 512
|
||||
```
|
||||
|
||||
`akgl_sprite_load_json` bounds the `frames` array against `AKGL_SPRITE_MAX_FRAMES` before
|
||||
writing anything, and refuses a frame number that will not fit a `uint8_t` rather than
|
||||
truncating it into an index naming a different tile (`TODO.md` item 16).
|
||||
|
||||
**`AKGL_SPRITE_MAX_REGISTRY_SIZE` is dead.** It is defined in `sprite.h` and referenced
|
||||
nowhere in `src/`, `include/`, `tests/` or `util/`. It bounds nothing. Do not size anything
|
||||
against it.
|
||||
|
||||
### Tilemaps
|
||||
|
||||
```c excerpt=include/akgl/tilemap.h
|
||||
/** @brief Widest map, in tiles. Width times height is what is actually bounded. */
|
||||
#define AKGL_TILEMAP_MAX_WIDTH 512
|
||||
/** @brief Tallest map, in tiles. */
|
||||
#define AKGL_TILEMAP_MAX_HEIGHT 512
|
||||
/** @brief Layers per map. Also the number of draw passes akgl_render_2d_draw_world makes. */
|
||||
#define AKGL_TILEMAP_MAX_LAYERS 16
|
||||
/** @brief Tilesets per map. */
|
||||
#define AKGL_TILEMAP_MAX_TILESETS 16
|
||||
/** @brief Entries in a tileset's offset table. Indexed by *local* tile id, so a tileset with a high `firstgid` still starts at 0. */
|
||||
#define AKGL_TILEMAP_MAX_TILES_PER_IMAGE 65536
|
||||
/** @brief Longest tileset name. */
|
||||
#define AKGL_TILEMAP_MAX_TILESET_NAME_SIZE 512
|
||||
/** @brief Longest resolved tileset image path. */
|
||||
#define AKGL_TILEMAP_MAX_TILESET_FILENAME_SIZE PATH_MAX
|
||||
/** @brief Longest object name. Note that an object naming an actor is truncated at #AKGL_ACTOR_MAX_NAME_LENGTH (128) instead. */
|
||||
#define AKGL_TILEMAP_MAX_OBJECT_NAME_SIZE 512
|
||||
/** @brief Objects in one object layer. Not enforced by the loader -- a longer group writes past the array. */
|
||||
#define AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER 128
|
||||
```
|
||||
|
||||
Three corrections to what those comments say, all verified against `src/tilemap.c`:
|
||||
|
||||
- **`AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER` *is* enforced.** The comment above says it is not.
|
||||
`akgl_tilemap_load_layer_objects` bounds `j` at the top of the loop body and raises
|
||||
`AKERR_OUTOFBOUNDS`; `akgl_tilemap_load_tilesets` does the same for
|
||||
`AKGL_TILEMAP_MAX_TILESETS`. Both landed in 0.5.0 (`TODO.md`, "Known and still open"
|
||||
item 17), and the header comment was not updated with them.
|
||||
- **Width and height are not bounded individually.** `akgl_tilemap_load` checks
|
||||
`width * height >= AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT`, and the comparison
|
||||
is `>=`, so the true ceiling is **262143 tiles** in any shape. A 1024×256 map loads; a
|
||||
512×512 one does not.
|
||||
- **`AKGL_TILEMAP_MAX_TILES_PER_IMAGE` is not checked anywhere.** It sizes
|
||||
`tile_offsets[65536][2]` in the struct and nothing validates a tileset against it. It is
|
||||
also why `akgl_Tileset` is large: 512 KiB of offset table per tileset, whatever the image
|
||||
actually holds.
|
||||
|
||||
### Controllers
|
||||
|
||||
```c excerpt=include/akgl/controller.h
|
||||
/** @brief How many control maps exist -- effectively the local player limit. */
|
||||
#define AKGL_MAX_CONTROL_MAPS 8
|
||||
/** @brief Bindings per control map. The default map installed by akgl_controller_default uses 8 of them. */
|
||||
#define AKGL_MAX_CONTROLS 32
|
||||
```
|
||||
|
||||
`AKGL_CONTROLLER_KEY_BUFFER` is 32 — the keystroke ring, sized so a program polling once a
|
||||
frame never loses a key to a fast typist. `AKGL_CONTROLLER_KEYSTROKE_TEXT` is 8, enough for
|
||||
one UTF-8 code point and its terminator; a longer commit is truncated **on a code point
|
||||
boundary** rather than through the middle of one.
|
||||
|
||||
### Drawing
|
||||
|
||||
`AKGL_DRAW_MAX_FLOOD_SPANS` is 4096 — the fixed stack of horizontal runs `akgl_draw_flood_fill`
|
||||
keeps instead of recursing per pixel. A region needing more pending runs at once reports
|
||||
`AKERR_OUTOFBOUNDS` rather than overflowing; an ordinary convex or moderately concave shape
|
||||
needs a few dozen.
|
||||
|
||||
### Audio
|
||||
|
||||
```c excerpt=include/akgl/audio.h
|
||||
/** @brief Sample rate of the generated stream, in frames per second. */
|
||||
#define AKGL_AUDIO_SAMPLE_RATE 44100
|
||||
|
||||
/** @brief Frames the device callback generates per pass through the mixer. */
|
||||
#define AKGL_AUDIO_MIX_FRAMES 512
|
||||
```
|
||||
|
||||
`AKGL_AUDIO_MAX_VOICES` is 3. `AKGL_AUDIO_SWEEP_TICK_HZ` is 60, and it divides the sample
|
||||
rate exactly, so a sweep step boundary always lands on a whole frame —
|
||||
`AKGL_AUDIO_SWEEP_TICK_FRAMES` is 735.
|
||||
|
||||
The SDL_mixer side is separate: `AKGL_GAME_AUDIO_MAX_TRACKS` is 64 playback tracks, and
|
||||
**slot 0 is unused** — `AKGL_GAME_AUDIO_TRACK_BGM` is slot 1, and the rest are yours.
|
||||
|
||||
### Time and the frame
|
||||
|
||||
| Constant | Value | Meaning |
|
||||
|---|---|---|
|
||||
| `AKGL_TIME_ONESEC_NS` | 1000000000 | Nanoseconds in a second — the unit `SDL_GetTicksNS` reports in |
|
||||
| `AKGL_TIME_ONEMS_NS` | 1000000 | Nanoseconds in a millisecond — the scale factor from JSON frame durations to internal ones |
|
||||
| `AKGL_GAME_STATE_LOCK_BUDGET_MS` | 1000 | How long `akgl_game_state_lock` keeps trying before raising |
|
||||
| `AKGL_GAME_STATE_LOCK_RETRY_MS` | 100 | How long it sleeps between attempts |
|
||||
| `AKGL_PHYSICS_DEFAULT_MAX_TIMESTEP` | 0.05 | Default `physics.max_timestep`, in seconds — three frames at 60 Hz |
|
||||
|
||||
`AKGL_TIME_ONEMS_NS` was called `AKGL_TIME_ONESEC_MS` until 0.5.0 and held 1000000 — a name
|
||||
and a value describing two different quantities. The state lock read it as a one-second
|
||||
budget and was wrong by a factor of a thousand, blocking for roughly sixteen minutes.
|
||||
|
||||
### The status band
|
||||
|
||||
| Constant | Value |
|
||||
|---|---|
|
||||
| `AKGL_ERR_BASE` | 256 (`AKERR_FIRST_CONSUMER_STATUS`) |
|
||||
| `AKGL_ERR_COUNT` | 5 |
|
||||
| `AKGL_ERR_LIMIT` | 261 — **the one-past-the-end sentinel, not a status** |
|
||||
|
||||
## C. Configuration properties
|
||||
|
||||
Everything the library reads out of `AKGL_REGISTRY_PROPERTIES`. **Every value is a string**,
|
||||
including the numbers, and each is parsed by whoever reads it.
|
||||
|
||||
| Property | Default | Read by | Read when | Meaning |
|
||||
|---|---|---|---|---|
|
||||
| `game.screenwidth` | `"0"` | `akgl_render_2d_init` | once, at init | Window and camera width in pixels. Passed straight to `SDL_CreateWindowAndRenderer` |
|
||||
| `game.screenheight` | `"0"` | `akgl_render_2d_init` | once, at init | Window and camera height in pixels |
|
||||
| `physics.gravity.x` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Constant acceleration on x, px/s² |
|
||||
| `physics.gravity.y` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Constant acceleration on y, px/s². Positive is down |
|
||||
| `physics.gravity.z` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Constant acceleration on z, px/s² |
|
||||
| `physics.drag.x` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Fraction of environmental velocity shed per second on x |
|
||||
| `physics.drag.y` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Same for y. **The only brake on falling** — there is no terminal velocity, and `ey` approaches `gravity_y / drag_y` |
|
||||
| `physics.drag.z` | `"0.0"` | `akgl_physics_init_arcade` | once, at init | Same for z |
|
||||
| `physics.max_timestep` | `"0.05"` | `akgl_physics_init_arcade` | once, at init | Ceiling on a single simulation `dt`, in seconds |
|
||||
|
||||
That is the complete list — nine properties, read by two functions, each exactly once.
|
||||
|
||||
**`physics.engine` is not read by anything.** `physics.h` says in two places that
|
||||
`akgl_game_init` passes it to `akgl_physics_factory`; `akgl_game_init` never calls the
|
||||
factory, and the string does not appear in `src/`. Pass `"null"` or `"arcade"` to
|
||||
`akgl_physics_factory` yourself. Correcting the header is a source change and is deliberately
|
||||
out of scope for this manual.
|
||||
|
||||
**Everything is read at init.** Setting any of these after `akgl_render_2d_init` or
|
||||
`akgl_physics_init_arcade` has no effect at all, and reports success. See
|
||||
[Chapter 6](06-the-registry.md), and the startup order in
|
||||
[Chapter 7](07-the-game-and-the-frame.md).
|
||||
|
||||
**A property set before `AKGL_REGISTRY_PROPERTIES` exists is silently discarded.**
|
||||
`akgl_set_property` does not check SDL's return value. `akgl_game_init` and
|
||||
`akgl_registry_init` both create the registry; a hand-rolled startup that creates neither
|
||||
gets no configuration and no error.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- [Chapter 4](04-errors.md) — what each status means.
|
||||
- [Chapter 5](05-the-heap.md) — the pools, and the ABI constraint on their ceilings.
|
||||
- [Chapter 6](06-the-registry.md) — the property store these live in.
|
||||
- The generated Doxygen reference — every function's own `@throws` list and full signature.
|
||||
64
docs/README.md
Normal file
64
docs/README.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# The libakgl manual
|
||||
|
||||
libakgl is a C library for building 2D games on SDL3. It is not an engine: there is no
|
||||
editor, no scripting layer, no inheritance and no runtime `malloc`. Behaviour attaches to a
|
||||
struct as function pointers, objects come from fixed pools, and every call reports failure
|
||||
through an error context you cannot silently ignore.
|
||||
|
||||
This manual teaches the library. It deliberately does **not** re-document its dependencies
|
||||
— libakerror owns the error-handling protocol, SDL3 owns renderers and events, Tiled owns
|
||||
the map format, jansson owns the JSON API. Each chapter says what libakgl adds or
|
||||
constrains, shows what a caller actually writes, and links out for the rest. For
|
||||
per-function reference, build the Doxygen output with `doxygen Doxyfile`.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **[1. Introduction](01-introduction.md)** | What libakgl is, what it refuses to be, the frame budget, and who owns which documentation |
|
||||
| **[2. Design philosophy](02-design-philosophy.md)** | Bounded pools, pluggable backends, bit flags, name-based registries, one world at a time |
|
||||
| **[3. Getting started](03-getting-started.md)** | Dependencies, `add_subdirectory` vs `akgl.pc`, and the smallest program that opens a window |
|
||||
| **[4. Errors and status codes](04-errors.md)** | The status tables, what each code means *here*, and the traps that fail silently |
|
||||
| **[5. The heap](05-the-heap.md)** | The five pools, `akgl_heap_next_*`, `akgl_String`, and why exhaustion usually means a missing release |
|
||||
| **[6. The registry](06-the-registry.md)** | The eight registries, configuration properties, and the id-0 silent no-op |
|
||||
| **[7. The game and the frame](07-the-game-and-the-frame.md)** | Startup order, `akgl_game_update`, the state lock, iterators, savegames |
|
||||
| **[8. Rendering](08-rendering.md)** | The backend vtable, the frame contract, cameras, and embedding a host's own `SDL_Renderer` |
|
||||
| **[9. Drawing](09-drawing.md)** | Points, lines, rectangles, circles, flood fill, and saving a region |
|
||||
| **[10. Spritesheets and sprites](10-spritesheets-and-sprites.md)** | One texture shared by path, the sprite JSON format, frame animation |
|
||||
| **[11. Characters](11-characters.md)** | State-to-sprite mappings, movement constants, the character JSON format |
|
||||
| **[12. Actors](12-actors.md)** | The state bitmask, the six behaviour hooks, parents and children, layers |
|
||||
| **[13. Tilemaps](13-tilemaps.md)** | Tiled TMJ, libakgl's three extensions, and the limits that bind a map |
|
||||
| **[14. Physics](14-physics.md)** | Thrust, environment and velocity; the `null` and `arcade` backends; what is not implemented |
|
||||
| **[15. Input](15-input.md)** | Control maps, push-not-poll dispatch, the keystroke ring, gamepads |
|
||||
| **[16. Text and fonts](16-text-and-fonts.md)** | Loading, drawing, measuring, and the teardown order that matters |
|
||||
| **[17. Audio](17-audio.md)** | The three-voice synthesizer, and the separate background-music path |
|
||||
| **[18. Utilities](18-utilities.md)** | Collision helpers, path resolution, the JSON accessors, static strings |
|
||||
| **[19. Tutorial: a 2D sidescroller](19-tutorial-sidescroller.md)** | Gravity, a jump, collision you write yourself, coins and hazards |
|
||||
| **[20. Tutorial: a top-down JRPG](20-tutorial-jrpg.md)** | A town map, NPCs spawned from map objects, four-way animation, a text box, a follower |
|
||||
| **[21. Appendix](21-appendix-limits.md)** | Every limit, every status, every configuration property |
|
||||
|
||||
Both tutorials are complete programs under [`examples/`](../examples). They build with the
|
||||
library and run headless in CI, and the chapters quote them rather than restating them — so
|
||||
a tutorial cannot drift from a program that compiles.
|
||||
|
||||
## Every example here is checked
|
||||
|
||||
The examples in these chapters are compiled, linked, run, and cross-checked against the
|
||||
source tree by the test suite. A snippet that stops compiling, an excerpt that no longer
|
||||
matches the header it quotes, or a JSON document the loader would reject turns
|
||||
`ctest -R docs_examples` red.
|
||||
|
||||
This exists because the documentation it replaces had drifted badly: the previous FAQ's
|
||||
examples did not compile — two unbalanced `PASS()` calls, a `sprite->frameids = [0, 1, 2, 3];`
|
||||
that is not C in any dialect, and, in the first snippet a reader ever saw, the exact
|
||||
`strncpy` call `AGENTS.md` forbids. The prose had drifted with it, and writing these
|
||||
chapters turned up **twenty-seven** header claims that were false against `src/`.
|
||||
|
||||
Where a chapter documents behaviour that is a known defect rather than a design decision,
|
||||
it says so and points at `TODO.md`. See `MAINTENANCE.md` if you are editing an example.
|
||||
|
||||
## Assets
|
||||
|
||||
The tutorial art, tiles and music are CC0, vendored under
|
||||
[`tutorials/assets/`](tutorials/assets) with provenance recorded per file in
|
||||
[`PROVENANCE.md`](tutorials/assets/PROVENANCE.md) and geometry documented in
|
||||
[`README.md`](tutorials/assets/README.md). CC0 specifically, not merely free: a reader who
|
||||
copies a tutorial into their own game inherits no obligation.
|
||||
BIN
docs/images/primitives.png
Normal file
BIN
docs/images/primitives.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 730 B |
Reference in New Issue
Block a user