41 Commits

Author SHA1 Message Date
75da766724 Record how to find out whether a tutorial teaches
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 21s
libakgl CI Build / performance (push) Failing after 20s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 19s
docs_examples proves every listing in docs/ compiles and still matches the file
it was quoted from. It cannot prove a chapter teaches anything -- a document can
be made entirely of verified excerpts and still be unfollowable, because what a
reader needs is the glue between them.

So AGENTS.md gains "Testing a Tutorial": hand the chapter to a subagent on a
weaker model with the library, its headers and the art but not the finished
program, and make them build and run the game. The weaker model is the point; it
will not paper over a gap with knowledge the document did not give it.

The section carries the rules that make the result mean something -- copy the
tree with examples/ and docs/ removed rather than asking them not to look, let
them read the public headers because a real reader has them, do not make them
hand-author a .tmj, give them a screenshot helper, and verify their binary
yourself -- plus the sandbox recipe, and the warning to prove the build path
works before handing it over.

It also records how to read the report, because a weaker model states its own
mistakes as documentation defects with total confidence. Reproduce before
believing; and a rejected finding usually still leaves something behind.

The evidence for doing any of this is in the table at the end: four read-only
reviews of chapters 20 and 21 found real gaps and missed all three of the
defects the first build-and-run found, including a published example output the
chapter could not produce.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 09:45:28 -04:00
cf0142ea85 Say how close an NPC has to be placed to be talked to
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 21s
libakgl CI Build / performance (push) Failing after 22s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 18s
A reader drew their own town, put the elder 115 pixels from anywhere the player
could stand, and spent the rest of their time looking for the bug in
jrpg_cmhf_talk. There is no bug: the handler finds nobody within TALK_RANGE and
returns success, which is indistinguishable from the game not being wired up.

The chapter stated the constant and not what it means in the editor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 09:36:25 -04:00
6e97d27e49 Fix what showed up when the tutorials were actually built and run
Two readers were given the library, the art, the chapter, and no reference
program, and told to compile and run. Both got a working game. These are the
defects that only came out because a compiler and a frame loop were involved --
four earlier read-only reviews had missed every one.

libakstdlib's header was never named. The chapters used aksl_strncpy,
aksl_snprintf and aksl_atoi throughout and said only "libakstdlib is documented
in deps/libakstdlib"; the obvious guess from the function prefix is <aksl.h> and
it is wrong. That was the only compile error the sidescroller reader hit, and it
was the chapter's fault. Both chapters now carry a per-file include table and say
which header it is.

The sidescroller's asset directory defaulted to ".". The chapter said the code
"falls back to `.`" without showing the #ifndef or that assetdir is initialised
from the macro, so a reader gets a game that only finds its art when launched
from exactly the right directory.

The sidescroller's summary line reported 0.0,0.0 airborne while the screenshot
showed the player standing on the ground. Teardown runs in main's CLEANUP block
and releases the actor pool before the summary prints. The chapter published an
example output a reader could not reproduce; the capture that makes it true is
now shown beside it.

The JRPG's CMake block interpolated ${JRPG_REPO_ROOT}, which is this repository's
own variable and undefined for anybody else -- so the font path came out wrong
and akgl_text_loadfont failed long after everything else had loaded. It is
${CMAKE_CURRENT_SOURCE_DIR} now, and the chapter says why both definitions are
needed rather than one.

Chapter 21 described --demo and a summary line in its build section that no step
wrote. Both are now a section: the script table, playback through
akgl_controller_handle_event rather than the handlers, the fixed clock step, and
printing the position before teardown. It also now says libakgl keeps no frame
counter, which a reader assumed it did.

Both map sections now say to draw it in Tiled and point at chapter 13, naming the
fields the loader needs that Tiled fills in automatically -- both readers
hand-wrote a .tmj before being given one and hit exactly those.

Excerpts across docs/ go from 190 to 198.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 09:34:16 -04:00
149bee0c99 Fill the gaps two readers found in the tutorials
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 22s
libakgl CI Build / performance (push) Failing after 22s
libakgl CI Build / memory_check (push) Failing after 20s
libakgl CI Build / mutation_test (push) Failing after 20s
Two agents were given nothing but the chapter text and asked to build the game
from it. Between them they named every place the chapters showed a fragment and
called it an explanation. This closes those.

Chapter 20 now shows what it previously only named: the whole ss_Game struct
rather than a truncated one, SS_COIN_COUNT and SS_HAZARD_COUNT, ss_ActorData,
the player and blob body rectangles, asset_path, hitbox, find_actor, respawn,
both jump handlers, the control-map function head, the gamepad buttons, the
blob's probe arithmetic, and a complete standalone CMakeLists rather than three
lines out of one. It also names the library globals a reader is expected to use
without declaring, lists all seven actor hooks rather than the two this game
replaces, and says in order what each of the two hook functions ends up doing.

Chapter 21 gains the same treatment: the header's includes and declarations, the
textbox colours and their SDL_Color type, TTF_Font, the jrpg_Townsfolk row type,
character_load in full, the player lookup, the frame loop, and per-file include
tables for both games.

Two claims were wrong and are corrected. "Each step is complete on its own" was
not true of a cumulative tutorial. And the first argument to
akgl_controller_handle_event is not a game-state word the dispatcher reads -- the
header says nothing reads it and it is required only as a non-NULL token.

Every step that produces code now ends with what the reader should see when they
run it. Excerpts across docs/ go from 137 to 190.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 09:08:57 -04:00
6553da812c Rewrite both tutorials as tutorials
They read as design commentary: why a choice was made, what the previous state
of the project was, which library defect a line works around. That is useful to
somebody who already knows the library and useless to somebody trying to build a
game, which is who a tutorial is for.

Both chapters now open with a screenshot of what the reader is building and a
numbered list of the steps to get there, and each step is its own section: what
you are trying to achieve, the code that achieves it, and the two or three things
about that code that are easy to get wrong. Chapter 20 starts from first
principles -- what a sprite, a character, an actor and a tilemap are, and the
four error macros -- and chapter 21 assumes it and covers what a top-down game
adds.

Where a line exists because of a defect, the line comes first and the defect
comes after it, with a pointer to the TODO entry that will remove the need. A
reader who is copying code should not have to read an apology before they can use
it.

The library's own history is gone from both. That belongs in the design chapters
and in git.

Two new docs_examples setups stage the tutorials' own art, so the sprite and
character files a reader is shown are loaded exactly as printed rather than
through a generic fixture. `json excerpt=` is new for the same reason: a Tiled
object is now quoted out of the real map instead of being retyped. Excerpts
across docs/ go from 91 to 137.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 08:51:39 -04:00
0972472cfa Take the tutorial figures out of the games themselves
Both examples gain --screenshot PATH and --screenshot-frame N. The capture sits
between the world being drawn and the frame being presented, because
SDL_RenderPresent is where the target stops being readable, and it works under
the dummy video driver and the software renderer like the rest of the headless
path does.

`cmake --build build --target docs_game_figures` regenerates
docs/images/sidescroller.png and docs/images/jrpg.png by running each game to a
chosen frame. Same contract as docs_screenshots: deliberate, never part of a
build, because the PNGs are tracked. There is no --check counterpart, and the
target says why -- the sidescroller drives its physics from the wall clock, so a
byte comparison would fail for reasons that have nothing to do with the
documentation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 08:38:18 -04:00
4e32328681 Remove the corner helpers akgl_collide_rectangles no longer uses
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 21s
libakgl CI Build / performance (push) Failing after 21s
libakgl CI Build / memory_check (push) Failing after 17s
libakgl CI Build / mutation_test (push) Failing after 19s
akgl_rectangle_points, akgl_collide_point_rectangle, akgl_Point and
akgl_RectanglePoints go. They were the intermediate form of an implementation
that changed: akgl_collide_rectangles was eight corner-containment tests built on
them, and it has been four span comparisons since the cross-case fix. Nothing
outside tests/ called either function, and a point-in-rectangle test is four
comparisons a caller can write without a struct conversion in front of them.

akgl_collide_rectangles stays. It has two correct callers in the sidescroller
asking a game-level overlap question -- a coin, a hazard, from an updatefunc --
where a bool is the whole answer and a proxy plus a narrowphase call would be
computing a normal nothing reads. TODO.md records the split rather than leaving
it to be rediscovered.

Public API removal, so 194 exported akgl_ symbols against 196, and the manual's
counts move with them. The perf suite loses its rectangle_points row; the
all-pairs sweep stays as the control it is now labelled, and PERFORMANCE.md says
what 0.8.0 measured against it -- 188.5 us for 32,640 pairs at 256 actors, where
a whole step with collision attached is 54.1 us doing strictly more.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 08:09:42 -04:00
3a6569e384 Build the shape before spawning a proxy from it
test_reset_and_empty called spawn() with an uninitialized stack akgl_CollisionShape
and built it on the next line. akgl_collision_proxy_initialize copies the shape,
so grid_insert read uninitialized half-extents to work out which cells to file
the proxy under -- eighteen findings inside grid_cellrect, and the test still
passed because the proxy_sync two lines later overwrote all of it.

Found by scripts/memcheck.sh, which is the only thing that could have found it:
the assertion it makes is about the query afterwards, and the query answers
correctly either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 08:01:17 -04:00
71e18184c0 Ship libccd's notice, and keep the rectangle helpers on purpose
libccd is compiled into libakgl.so and is BSD-3-Clause, so the notice has to
travel with the binary form. cmake --install now puts BSD-LICENSE at
share/doc/akgl/BSD-LICENSE.libccd, and the README says which dependency is in
that position and which are not -- everything else in deps/ is either a separate
shared object carrying its own notice, a header, or compiled into nothing.

The plan's last step was to delete akgl_rectangle_points,
akgl_collide_point_rectangle and akgl_collide_rectangles. That step is not taken,
and TODO.md carries the reasoning rather than leaving it to be rediscovered:
akgl_collide_rectangles has two correct callers in the sidescroller asking a
game-level overlap question that wants a bool, and it was fixed two commits into
this same series -- deleting it now would be a strange thing to do to a caller.
The other two are the weak case and are named as the candidates if a 0.9.0 wants
to trim.

akgl_RectanglePoints' comment still claimed akgl_collide_rectangles works corner
by corner, which stopped being true when the cross case was fixed. Corrected in
place, with the cross case named so the note explains why the shape changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 07:55:59 -04:00
986c80d0ec Bump to 0.8.0, close Target 12, and record what is still missing
Collision changed the collide slot's signature and grew four public structs, so
this is an ABI break and the soname moves to libakgl.so.0.8. The manual's counts
move with it: 195 functions across 23 headers, 183 of which return an error
context.

Target 12 -- "collision for 256 actors under 2 ms without the caller writing a
broad phase" -- is met. A new benchmark times the whole step with a world
attached, which is the number the target is actually about rather than a
narrowphase call in isolation: 15.4 us at 64 actors, and 54.1 us at 256 in a
purpose-built -DAKGL_MAX_HEAP_ACTOR=256 configuration. Over that 4x range the
step grew 3.5x while the all-pairs control grew 15.6x, which is the n-squared
the index exists to avoid. Plan item 7 is rewritten to record what shipped and
why both partitioners exist; the Construct and Phaser citations stay.

Three new TODO entries. Actor rotation, scoped to the five places it touches and
the one place it does not -- collision_support is written so rotating `dir` in
and the answer back is the whole narrowphase change, and rotational *response*
is explicitly a different piece of work. The swept narrowphase FLAG_BULLET
reserves, with the tunnelling arithmetic written out so a game can check its own
numbers against 1280 px/s. And the ccd arena being single-threaded and sized by
measurement at 7,264 bytes per GJK/EPA box pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 07:53:30 -04:00
cbf7c1b6c2 Delete the JRPG's collision too, and wall the map with proxies
cell_solid and feet_blocked go, and so does the prediction in the player's
movementlogicfunc. That prediction was only ever exact because the town has zero
gravity and zero drag -- v is t, so `x + tx * dt` is where the step lands. A map
with gravity would have had to fold ey in as well, which is the game
re-implementing the integrator. Resolution runs after the move now, so there is
nothing to predict.

The map's decoration layer carries `collidable`, which retires JRPG_LAYER_SOLID:
akgl_TilemapLayer has no name member, so the game and the map had to agree on an
index out of band and inserting a layer in Tiled broke it.

The edge of the world is not on any layer, so it is four static proxies covering
the outer ring plus a tile of overhang -- one pool slot per side instead of a
hundred solid tiles, and the first use of the static-proxy path in either
example. They carry LAYER_STATIC explicitly: shape_box defaults a shape to
LAYER_ACTOR, and a wall left on that layer is a wall everything walks through.

Only the player gets a shape. NPCs stand still and are spoken to; the follower
is a child snapped to its parent every step.

Verified against the old implementation with the demo script rewritten to hold
one direction: holding left into a building stops the player at x=122 with both,
and holding up into the map's edge stops them at y=-4 with both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 07:46:19 -04:00
490e62dbbf Delete the sidescroller's collision, and let the library do it
collision.c was 383 lines that re-implemented akgl_physics_simulate's own
arithmetic, because movementlogicfunc runs before gravity, drag and the move
and so had to predict where the actor would end up. The whole file goes. What
replaces it is three lines in main.c, a shape on the player and the blob, and a
`collidable` property on the map's terrain layer -- which also retires
SS_TERRAIN_LAYER_ID, a compiled-in layer id that broke whenever somebody
reordered layers in Tiled.

grounded stays a probe rather than becoming a contact test. A contact says
something pushed back this step; grounded asks whether there is a floor to push
off, and that stays true through the frame after landing. The blob keeps its own
wall and ledge probes for the same reason: the resolver does not say which side
of the blob it was pushed from, and says nothing at all about a floor that is
missing.

The summary line now carries the player's resting position, because exiting 0
was not evidence that collision ran. It reports 136.0,160.0 grounded after 240
autoplay frames -- feet at y=192, flush on the surface of terrain row 12.

The tutorial's collision section is rewritten around the library's, keeping the
quarter-of-a-pixel story as what a predicting resolver costs. The docs harness
learns `json excerpt=`, so the map property the chapter quotes is checked
against the map.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 07:35:08 -04:00
35a58670d0 Document collision, and correct what it made false elsewhere
The new chapter 15 covers the opt-in setup, where resolution runs in the step
and why it runs after the move, shapes and the z-extrusion trap, the asymmetric
masks and the defaults that matter more than the mechanism, tiles as a source
rather than proxies, the response hook and the three ways it is easy to write
wrongly, sensors, the four queries, both partitioners, and what is still not
implemented.

Collision falsified claims in eight other chapters. The physics chapter said
"There is no collision detection, at all" and that collide always raises
AKERR_API; actors had six behaviour hooks; the heap had five pools and four
non-claiming acquires; the status band held five codes; and the design
philosophy had exactly two pluggable subsystems. The appendix gains a
collision.h status section, the three new pool ceilings and a table of the
collision constants that are deliberately not in a public header.

Also fixes chapter labels the renumbering commit left pointing at their old
numbers -- "18. Utilities" linked to 19-utilities.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 07:25:52 -04:00
bace818998 Renumber chapters 15 to 21 up by one, to open a slot for collision
A pure rename plus link rewrite and nothing else. Collision is a pluggable
subsystem with two implementations, shapes, a response hook and a tile binding;
that is a chapter, and burying it inside the physics chapter is the shape this
manual otherwise avoids -- rendering and drawing are 8 and 9, spritesheets and
characters and actors are 10, 11 and 12.

Kept separate from writing the chapter because **nothing validates a
cross-reference target.** The example harness reads fenced blocks; it does not
follow links. A rename mixed into five hundred lines of new prose is not
reviewable, and a broken link would land silently. As its own commit it is
reviewable as a rename, and a grep for dangling `](NN-` targets is clean.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:15:25 -04:00
fcad2822a9 Measure collision, and say which numbers moved for what reason
Seven new benchmark rows, budgets set from a measured full-scale run rather
than guessed, and the three existing rows the work moved re-recorded from that
same run. The all-pairs sweep stays and is relabelled `control:` -- it is the
cost a caller paid before the library had a broad phase, measured on the same
machine in the same run, which is the only honest way to read a reduction.

What the numbers say:

- The box fast path is two to seven times cheaper than the general solver, and
  the disjoint case is cheaper still because the proxies' bounds reject it before
  any shape arithmetic runs. 9 to 20 ns is what a tile game actually pays.
- The grid beats the tree by 2.6x on the same population, which is the argument
  for the default measured here rather than cited from another engine. The tree
  also rebuilds on every move and that is not in its row, so a moving scene is
  worse than 2.6x.
- A grid `move` that changes nothing is 11.5 ns. That is the incremental claim
  in one number.

Two rows moved on a backend with **no collision world attached**, so no
collision code runs in either, and the commit says so rather than letting the
feature take credit:

- akgl_Actor grew from 415 to 464 bytes -- a 40-byte shape, an override flag and
  a proxy pointer. The step sweeps the whole pool, so that is about 3 KB more
  working set per frame. The empty-pool row is unchanged at 58.7 ns, which is
  what identifies the cost as per live actor rather than per slot.
- Reaching the collision check through CATCH cost more than the check. PASS and
  CATCH call akerr_valid_error_address, which walks AKERR_ARRAY_ERROR, so an
  early-returning function is not free when it is reached through one. The
  no-collision path now routes around the machinery entirely, which took the
  64-actor sweep from 2,018.7 ns back to 1,588.5. AGENTS.md records that lesson
  for writing benchmarks; this is the same thing in production code.

The remainder is the struct, and it is paid whether or not a game uses
collision.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:13:53 -04:00
cf930f68bb Add a second partitioner, so the vtable is a seam and not a decoration
A binary space partition on libakstdlib's tree links and lists, registered in
the factory as "bsp". It runs the same assertions the grid does, because
tests/partition.c is a table over partitioners and adding a row is all it takes
to be held to the contract.

**Use the grid.** This is here to prove the vtable works and to have something
to measure the grid against, and the file says so at the top. It rebuilds
whenever the proxy set changes, which is the shape PERFORMANCE.md records Phaser
using and capping out around five thousand bodies. It would earn its place in a
world with wildly non-uniform object sizes, or one larger than the grid's fixed
cell array covers.

The difference is visible in the code rather than buried in a benchmark: the
grid's `move` compares four integers and returns when a proxy has not left its
cells, and this one marks the whole partition stale. The incremental-move
assertion in the suite is therefore grid-only, and says why.

aksl_tree_iterate is not used, and the file carries the three reasons so the
next reader does not rediscover them: it is a complete traversal whose only
control signal stops the entire walk, so there is no way to prune a subtree --
which is the only operation a spatial query is made of; it carries no per-node
context, and a pruning descent needs each node's bounds and plane; and its
breadth-first modes allocate, while only the depth-first ones are malloc-free
and those are the ones without pruning. aksl_tree_insert is unusable for a
different reason again: it is a comparator-ordered BST, and a spatial insert has
to compare a leaf against a plane, which aksl_TreeCompareFunc cannot express.

What is used is the link structure and the lists, neither of which allocates,
which is why they fit here at all. The descent is an explicit stack rather than
recursion, so the depth bound is an array bound the compiler can see -- this
library already has one documented way to blow the C stack and does not need a
second.

Split planes are the median of the item centres on the longer axis, not the
spatial midpoint: actors in a tile game cluster on the floor, and a midpoint
split gives one empty child and one full one for several levels running. A split
that separates nothing degrades the node to a leaf rather than recursing forever
on the same set.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:08:54 -04:00
5e213061af Call collide from the simulation, after the move rather than before it
The collide slot has existed unused since the backend was written. It is wired
in now, and its signature changes from (self, a1, a2) to (self, actor, dt): the
old shape had nowhere to put a normal, a depth, or a piece of map geometry, and
no answer to which of the two actors it was supposed to be resolving.

**Resolution runs after `move`.** That is the decision everything else follows
from. A resolver called from movementlogicfunc runs before gravity, drag and the
move, so it has to predict where the actor will end up -- which means
re-implementing the integrator's arithmetic in the game, and being wrong
whenever the integrator changes. examples/sidescroller/collision.c carries forty
lines of exactly that, and says so.

Only `move` is subdivided. Gravity, drag, the thrust integration and the speed
ellipse all still run once against the whole dt, and sum(subdt) is dt, so an
actor with nothing to collide with takes one sub-step of exactly dt -- `dt/1.0f`
is dt exactly in IEEE-754 -- and follows the arithmetic path it always did. That
is what lets the integrator stay untouched and every recorded physics number
stay valid, and tests/physics.c and tests/physics_sim.c pass unchanged with no
world attached.

Collision is opt-in. A backend with no collision world costs one comparison per
actor per step and does nothing else.

Also here:

- `self->gravity` was never null-checked and is dereferenced unconditionally, so
  a hand-built backend with only `move` filled in crashed rather than reporting.
- Children are re-snapped in a post-pass, gated on collision being on. The
  in-loop snap uses whatever position the parent had when the child's slot came
  up in pool order, which is already sometimes a frame stale; that was harmless
  while a parent only moved by v*dt and is not once a parent can be pushed out
  of a wall mid-step.
- tests/physics.c's deliberate tripwire has been visited. It asserted that
  arcade collide always raised AKERR_API, with a comment saying it existed so
  that implementing collision would have to come back here. What replaces it
  asserts the opt-in contract instead.
- physics_sim.c's hard-coded "%d of 7 simulations" is derived now. A literal
  goes stale the first time somebody adds a simulation, which is this commit.

Three new whole-motion simulations, and the third took two attempts to make
honest:

- Landing and resting: 0.0000 px of drift over 120 steps after settling.
- Walking after landing: 107 px in a second, which is the symptom a player sees
  when a resting actor is caught on the ground it is standing on.
- A fast fall not tunnelling. The first version used gravity, so the step size
  grew every frame and the actor happened to land *inside* the floor rather than
  stepping over it -- it passed with sub-stepping disabled and proved nothing.
  It uses a constant 1200 px/s now, so every step is exactly 60 pixels against a
  32-pixel window in which an overlap exists, and the samples miss it cleanly.
  With sub-stepping the actor stops at y=288; without it, y=1300.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 07:05:09 -04:00
a8526ad55e Give an actor a say in how it answers a collision
A seventh behaviour hook, collidefunc, defaulting to akgl_actor_collide_block.
Contacts are delivered one actor at a time with the normal already pointing the
way that actor has to move, which removes the "which one is a1" ambiguity that
made the old physics `collide` slot unimplementable -- and means a game
overriding one actor's response never has to reason about the other's.

The default moves the actor out along the normal by the penetration depth and
removes the component of its motion that was going into the surface. Three
things about that are easy to write wrongly, and each has a test that names the
symptom rather than the assertion:

- **It writes `e` and `t`, never `v`.** akgl_physics_simulate recomputes velocity
  as `e + t` at the top of every step, so a write to `v` is discarded before
  anything reads it. Worse than useless: an actor holding a direction into a
  wall would keep accumulating thrust while standing still and leave at speed
  the instant the wall ended. Breaking this leaves the fall running at 300 px/s
  through the floor.

- **It removes the component, not the axis.** Zeroing the whole thrust vector
  costs the actor its motion *along* the surface as well, which is a character
  scraping a ceiling stopping dead and a character landing on a floor losing its
  run. Sliding along a wall is the difference between a wall and glue.

- **A separating contact is left alone.** An actor already moving out of a
  surface that gets its velocity zeroed is an actor stuck to a wall it is
  walking away from, and it reads to a player as the collision grabbing them.

`e` and `t` are treated separately rather than as their sum, so an actor
pressing into a floor loses its downward gravity without losing the sideways run
it is also doing.

A sensor is reported and never pushed -- walking through a coin is not being
blocked by it.

The default deliberately does not pre-load the environmental term with a step of
gravity. A resolver running *before* the step has to, or the step it is
correcting for re-adds gravity and commits a quarter pixel of penetration, which
is invisible and still fatal because every horizontal move then reads as
blocked. This library resolves after the move, so the case does not arise; the
sidescroller example carries that workaround because it had no choice.

Also asserted: the other six hooks survive. A seventh that displaced one of them
would be a silent regression somewhere unrelated.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:47:21 -04:00
e632abf720 Let a map say which layers are solid, and answer questions about them
A tile layer with a `collidable` boolean custom property is collision geometry.
Before this a game had to hard-code a layer index -- both examples do, because
akgl_TilemapLayer does not retain the name Tiled wrote -- and that index changes
the moment somebody reorders layers in the editor.

Solid tiles are **not** given proxies. At the maximum map size that is a quarter
of a million per layer, tens of megabytes of index to describe data that is
already a dense grid sitting in the tilemap. The world keeps a borrowed pointer
and reads layers[i].data[] over whatever cell range a query covers: nine array
reads for a 32-pixel actor on 16-pixel tiles, nothing to build at level load,
and nothing to maintain per frame. Static geometry that is not tile-aligned is
still an ordinary proxy with AKGL_COLLISION_FLAG_STATIC; both mechanisms exist
and tiles use the free one because there are a hundred thousand of them.

Four public queries come with it, all of which answer without resolving:
solid_at, box_blocked, query_box and settle. They are what a game reaches for
when it wants to know rather than to be pushed -- a ledge probe ahead of a
walking enemy, a check that a doorway is clear -- and the sidescroller cannot
drop its hand-rolled collision without them.

akgl_collision_settle is the one worth naming. Resolution stops a shape entering
geometry and has nothing to say about one that began inside it: what it does
instead is refuse every move, so an actor spawned in a wall is simply stuck.
Level authors produce that constantly, so settling walks a shape up a tile at a
time and refuses loudly rather than searching forever.

Two defects found by writing the tests:

- The fixture put an akgl_Tilemap on the stack and segfaulted before the first
  assertion. It is about 26 MB -- the layer and tileset arrays are sized for the
  worst case the format allows -- and tilemap.h says so. It is static now, with
  a comment saying why, because the next person to write a map fixture will
  reach for a local first as well.

- The far-edge nudge used AKGL_COLLISION_EPSILON, which is 1e-6. That is a
  sensible tolerance on a unit vector and a meaningless one on a map coordinate:
  `float` carries about seven significant digits, so at a coordinate of 144 the
  smallest representable step is around 1.5e-5 and `144.0f - 1e-6f` is exactly
  144.0f. The nudge did nothing, a box resting flush on the floor read as inside
  it, and every move it tried looked blocked -- an actor standing on the ground
  unable to walk. Two different quantities were sharing one constant; the tile
  one is now its own, at a thousandth of a pixel, which is what the sidescroller
  example independently arrived at.

Both breaks verified: removing the nudge and ignoring the `collidable` bit each
turn the suite red with the symptom named.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:38:12 -04:00
68e042bd47 Add a pluggable broad phase, and the incremental uniform grid behind it
akgl_Partitioner is a record of function pointers and an initializer, the same
shape as the render and physics backends. `move` is its own slot rather than
remove-then-insert, and that is the whole design: a uniform grid's move is a
comparison and a return when the proxy has not left the cells it was in, which
is the steady state for a walking actor and the permanent state for a static
one. Spelling it as remove-and-insert would turn the incremental grid into the
rebuild-every-frame tree that PERFORMANCE.md already measured and rejected.

The grid is a dense 128x128 array of cell heads over the world, cells keyed on
tile size, with two intrusive chains per entry -- one through the cell so a query
can walk it, one through the proxy so removal unlinks a whole span without
searching. Everything is an int16_t index rather than a pointer, so the
structure is relocatable and clearing it is a memset. A proxy covering more
cells than AKGL_COLLISION_GRID_MAX_SPAN spills onto one chain every query walks,
which is what makes the cell pool's ceiling provable rather than hopeful.

tests/partition.c compares every query against a linear scan over the same
proxies and asserts containment in one direction, not equality. That asymmetry
is the contract: over-reporting costs a narrowphase call, under-reporting is a
wall an actor walks through. The suite is a table over partitioners so the same
assertions run against every implementation, which is what the second one
landing later will be held to.

Three deliberate breaks, and two of them were not caught the first time:

- Removing the min-cell rule so a shared-cell pair reports repeatedly: caught.
- Dropping the unlink in `move`: **not caught**, initially. Stale entries do not
  produce wrong query answers, because the bounds re-check filters them out --
  they leak the cell pool until the grid stops working, on a timescale no unit
  test reaches by accident. Counting live pool entries across 200 moves is the
  only thing that sees it, and now does: 505 entries against an expected 5.
- Swapping floorf for a truncating cast: **not caught, and correctly so.**
  Everything below zero is clamped to cell 0 either way, so today the two are
  indistinguishable. The comment claiming truncation is a defect was overstating
  it and now says what is actually true: the clamp is the only thing hiding it,
  and a negative world origin or a relaxed clamp would make it real with no test
  standing in front of it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:29:02 -04:00
a3cb6ca79a Answer whether two shapes overlap, and how to undo it
akgl_collision_test takes two proxies and fills in a contact: a unit normal, a
depth, and a point. Three paths, cheapest first.

The proxies' bounds reject most pairs in four comparisons and no arithmetic,
which is free because the bounds were computed when the proxy was synced.

A box against a box is answered in closed form. That is not only cheaper than
the general solver, it is *exact*, and the difference shows up where it matters
most: an actor resting on a floor wants a normal of precisely (0, -1, 0), and an
iterative solver converges to something like (0.0001, -0.99999, 0), which
accumulates into a slow sideways creep. A tile game is almost entirely boxes, so
this is the path that runs.

Everything else goes to ccdMPRPenetration. MPR rather than GJK+EPA because MPR
allocates nothing at all, converges in fewer iterations, and its one weakness --
a coarser contact *point* -- is on a field the blocking resolver never reads.
EPA stays compiled and available behind the arena for a caller who one day wants
an accurate manifold. The header says the point is approximate so nobody builds
a damage falloff on it.

The normal points out of the second shape and toward the first, so a caller
moving `a` along it by `depth` separates the pair. libccd hands back the
opposite convention; converting once here saves every resolver from remembering
the sign, and getting it backwards would compile, would pass any "do these
collide" test, and would drag actors into walls. There is a test that asserts
the direction, and inverting the conversion turns it red.

AKGL_COLLISION_TEST_PLANAR flattens the normal into the xy plane. The extrusion
the setters apply already makes z the most expensive axis, so this is a net for
a caller who set a depth by hand -- and the failure it catches is silent: the
narrowphase reports a contact, the resolver pushes the actor into the screen,
and the actor does not move on screen while remaining inside the floor. It also
rescues the degenerate case, two shapes at exactly the same centre, where there
is no planar direction at all and a zero-length normal would be a wall that
stops nothing. Level authors stack things constantly.

Three things the tests found rather than assumed:

- The guard has two copies, one per path, and the box one is where it earns its
  place. Removing both is caught; removing only the iterative one is not,
  because the iterative solver is seeded from the line between the two centres
  and so converges to a planar answer on its own for a planar offset. That is
  measured and written down in the test rather than papered over with a fixture
  contrived to force it.
- A concentric pair has no preferred sign on the degenerate axis, so the test
  asserts the magnitude of the normal rather than its direction. The first
  version asserted -1 and failed against an equally correct +1.
- The box fast path and the iterative solver are two implementations of one
  answer, so they are driven over the same arrangements and required to agree on
  the decision and the axis. A shortcut nothing cross-checks is a shortcut
  waiting to diverge.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 06:18:09 -04:00
d0b057f47a Pool collision proxies, and tie one to the life of its actor
A proxy is what the broad phase will index: an actor's shape, where it is, and
the bounds that follow from those. It is a sixth heap layer, two per actor --
one for the actor itself and headroom for static geometry a game registers that
is not tile-aligned. Solid *tiles* deliberately get none: the broad phase will
read those out of the tilemap's own array, because one proxy per tile is tens of
megabytes to index data that is already a grid.

The pool follows the existing convention rather than improving on it.
akgl_heap_next_collision_proxy finds a free slot and does not claim it;
akgl_collision_proxy_initialize claims it. That is TODO.md item 8's asymmetry,
and it is kept on purpose: an acquire abandoned before initialization leaks
nothing, because the slot still reads as free, and a sixth pool with its own
rule would be worse than one defect with five instances. Fixing item 8 means
fixing all five together with every call site audited, and that is its own
change.

The proxy holds a *copy* of the shape rather than a pointer. An actor's own
logic may rewrite its shape mid-step -- a character crouching, a projectile
arming -- and a broad phase whose bounds came from a shape that has since
changed is a bug that only appears when two things happen in the same frame.

akgl_heap_release_actor now releases the actor's proxy. Without it the proxy
holds a borrowed `owner` into a slot that has just been zeroed, so the broad
phase keeps a registration whose owner reads as a free actor, and the next
contact against it is a collision with nothing. Verified by removing the release
and watching test_proxy_dies_with_its_actor go red.

The tests pin the convention as well as the behaviour: that two acquires with no
initialize between them return the same slot is asserted, not merely tolerated,
so that changing the convention has to change a test that says why it exists.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:56:21 -04:00
9056ff06d6 Add collision shapes, on the character and overridable per actor
A shape is a convex volume positioned relative to an actor's origin. It lives on
akgl_Character, so every goblin sharing a character shares one shape definition
the way they already share speeds and the state-to-sprite map, and an actor may
override it with akgl_Actor::shape_override.

The flag is not redundant with an empty shape. "This actor deliberately has no
collider" and "this actor has not been given one yet" are different states, and
without somewhere to record the difference akgl_actor_set_character cannot tell
whether it is allowed to overwrite what it finds.

include/akgl/collision.h is a leaf: it names actors and tilemaps as incomplete
struct pointers rather than including their headers, because both of those need
akgl_CollisionShape by value and a cycle forms otherwise. The `headers` suite
compiles it as the first include of a translation unit, so that property is
enforced rather than merely intended -- which is why the tilemap types got struct
tags two commits ago.

The masks are asymmetric and the defaults are the load-bearing part: layermask
ACTOR, collidemask STATIC. An actor given a shape and nothing else collides with
map geometry and with no other actor. "Everything with a hitbox shoves everything
else" would be a surprising default for a town full of scenery and a painful one
to discover after the fact; opting in to actor-versus-actor is one bit, opting
out of it would have been a hunt through every NPC a game spawns.

Every shape gets a z depth, because the narrowphase behind this is three
dimensional and answers with the *minimum* separating translation. A thin
extrusion makes z the cheapest axis, and an actor pushed along z goes nowhere a
player can see while remaining inside the floor -- a collision system reporting
success while doing nothing. The ratio of 2 is the smallest for which the z
overlap of any two shapes the setters build provably exceeds any planar
penetration they can reach.

The test for that asserts the inequality across a spread of sizes rather than
asserting the constant is 2, so a change to the constant fails here rather than
in a game.

Two things about that test are worth recording, because the first version had
both:

- It could not fail. TEST_ASSERT expands to FAIL_BREAK, which reports by
  `break`ing, and the assertion was inside a nested `for` -- so the break left
  the loop rather than the ATTEMPT block and the failure evaporated. This is the
  hazard AGENTS.md documents for CATCH; it applies to the whole family. Verified
  by setting the ratio to 0.5 and watching the suite still pass, then fixing it
  and watching it report the 512x512 case by name.
- tests/collision_arena.c had the same bug in a loop added in the previous
  commit, and it is fixed here too.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:48:05 -04:00
46232a5ad1 Give libccd a static arena so libakgl still allocates nothing
libakgl does not call malloc at runtime. That is stated in seven places in the
manual and is AGENTS.md's second standing rule, and every object comes from a
fixed array in akgl/heap.h. libccd's EPA path does not know that: it builds its
expanding polytope out of realloc and free, and ccdGJKPenetration documents a
-2 return for when that fails.

The alternative was to compile only the three files MPR needs and leave EPA out
of the build. That works and it puts a copy of somebody else's source list in
this repository, where it rots silently on the next submodule bump. This instead
compiles all of libccd and points its allocator at a bump allocator over static
BSS, which keeps the promise literally true -- and keeps EPA available rather
than amputated, for the day a precise contact manifold is worth having.

The arena is reset at the top of each query rather than freed block by block, so
the lifetime is one narrowphase call, `free` is a no-op, and allocation is a
pointer bump. Exhaustion returns NULL, which libccd already handles by unwinding
to -2, which becomes AKGL_ERR_COLLISION naming the high-water mark -- a loud
failure with a number in it rather than a silently missed collision, which would
be a floor an actor falls through reported as success.

One GJK/EPA box pair costs 7,264 bytes, measured and printed by the suite. The
64 KB ceiling is that with about nine times headroom, and the high-water mark is
reported so the next reader can re-derive it rather than trust this line.

The redirect lives in src/ccd_arena_shim.h, injected with -include, and not in
CMake's COMPILE_DEFINITIONS. It was in COMPILE_DEFINITIONS first, and that is a
mistake worth recording: CMake cannot carry a function-like macro through a -D,
so it dropped __CCD_ALLOC_MEMORY without a diagnostic. libccd went on calling
the C library's realloc while the shim's `free` quietly discarded the results --
strictly worse than doing nothing, and invisible, because it leaks rather than
crashes.

What caught it was insisting the test prove the wiring rather than the outcome.
The first version asserted the arena balanced back to zero after a query, which
turned out to be the wrong assertion for a different reason -- free is a no-op
by design, so it cannot balance -- but a test that had merely checked "two boxes
collide" would have passed throughout, against an allocator nothing was using.
The suite now asserts what is actually provable: that a query allocates from the
arena at all, and that the process survives, since glibc aborts when the real
free(3) is handed a pointer it never issued.

Also here: AKGL_ERR_COLLISION, with the name registered -- tests/error.c asserts
the band and the names agree, and it caught the missing one immediately.
-fvisibility=hidden and CCD_STATIC_DEFINE keep every ccd* symbol out of
libakgl.so's dynamic table, which `nm -D` confirms is empty; AGENTS.md records a
shipped defect where an exported `renderer` was preempted by a same-named symbol
elsewhere, and a game linking a system libccd would hit exactly that.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:29:37 -04:00
6bb241cfb9 Register libccd and tg as submodules instead of stray directories
Both were sitting in deps/ untracked, with dangling .git pointer files into
.git/modules/{libccd,tg} and no .gitmodules entry at all. A clean clone of this
repository got neither, and `git submodule update` had nothing to update. tg's
worktree had also been emptied -- all 95 files staged as deletions inside its
own index -- so the directory held nothing but the pointer.

Both are now real submodules, pinned by SHA and checked against the tag:

  deps/libccd  7931e764a19ef6b21b443376c699bbc9c6d4fba8  = v2.1
  deps/tg      823f78db7e104ad5074288b4f362f0d3ef0fb0b6  = v0.7.9

Named "deps/libccd" and "deps/tg" to match the eight entries already there,
which meant re-registering rather than adopting the existing .git/modules
directories -- those were named "libccd" and "tg" while every other module lives
under .git/modules/deps/. Both checkouts were verified pristine at their tags
first, so nothing was lost by re-cloning.

URLs are https://, which is what both remotes already pointed at. Five of the
existing eight entries use git@github.com:, which a CI checkout with no SSH key
cannot use; that is not this commit's problem and is left alone.

tg is wired into nothing, deliberately, and TODO.md now says so with the reason
it is kept anyway -- indexed point-in-polygon against concave polygons is the
one job GJK and MPR structurally cannot do. That entry is a deadline: either the
consumer appears or the submodule comes out.

Nothing consumes either dependency yet, so the build and the full suite are
unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:16:06 -04:00
06f8e03a03 Give the tilemap types struct tags so they can be forward-declared
`typedef struct { ... } akgl_Tilemap;` declares no struct tag, so
`struct akgl_Tilemap` does not exist and no header can forward-declare it. That
is fine while every consumer includes tilemap.h, and it is a blocker the moment
a leaf header needs to name the type without pulling it in -- which the coming
collision header does, since tilemap.h reaches physics.h and a cycle forms
otherwise.

All four types in the file get a tag rather than just akgl_Tilemap: they are
siblings declared the same way in the same header, and tagging one of four is
the kind of inconsistency that makes the next reader wonder what is special
about it.

Layout-identical and no behaviour: a tag names a type, it does not change one.
Full suite, reindent and doxygen unchanged.

sprite.h and iterator.h declare their types the same way and are deliberately
left alone; nothing needs to forward-declare them yet.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:13:17 -04:00
842ef75ddf Report the overlap akgl_collide_rectangles could not see
It asked whether either rectangle enclosed one of the other's four corners --
eight akgl_collide_point_rectangle calls, stopping at the first hit. That is a
different question from "do these overlap", and it has the wrong answer for one
arrangement: a tall thin rectangle crossing a short wide one overlaps in a plus
sign with no corner of either inside the other, and all eight tests said no.

A long thin platform crossing a tall thin character is exactly that shape, so
this is a shape a 2D game produces, not a curiosity. util.h carried an @note
describing it and docs/18-utilities.md had a diagram of it, both under the
heading of a limitation rather than a defect, and there was no test for it at
all -- nor for full containment, nor for a shared edge.

It is four comparisons now, on both axes. `<=` rather than `<` because
akgl_collide_point_rectangle is inclusive on all four edges and these two have
always agreed that touching counts; a span test written with `<` would have
silently changed a contract both the header and the manual state.

The test was written first and failed on the cross before the fix went in.

Two answers change for a caller upgrading, and both are in the header note and
the chapter:

- The cross reports `true`, which is the point.
- The comparison is in float rather than through akgl_Point's int members, so a
  sub-pixel overlap is no longer truncated away. A pickup test that was
  accidentally forgiving by up to a pixel is no longer forgiving. Both tutorials
  use this for coins and hazards; both still pass.

Faster as a side effect rather than a goal, and worth recording because the
numbers move a documented budget: 24.9 ns -> 6.1 overlapping, 57.9 -> 6.1
disjoint, and the all-pairs sweep over 64 actors 115 us -> 12.2. The disjoint
case gained most because it was the one that ran all eight tests before
answering.

The three moved rows are re-recorded in PERFORMANCE.md and nothing else is.
akgl_rectangle_points is untouched by this change and reads 6.1 in the same run
against the 4.0 recorded, so 6 ns is this run's floor and the new figure means
"too cheap to measure" rather than "exactly 6.1" -- said in the prose so the
next reader does not re-baseline the table around it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:12:01 -04:00
943bf4324e Let a control map listen to any keyboard, and fix the dead arrow keys
The sidescroller's controls did nothing. `akgl_controller_handle_event` matched
`event->key.which == curmap->kbid` exactly, with no way to say "whatever
keyboard the player is typing on", so the game did the obvious thing and bound
`SDL_GetKeyboards()[0]`.

That cannot work. The id a key event *carries* is chosen by the video backend
and is not required to be an id `SDL_GetKeyboards()` reports. On X11 without
XInput2 every key event carries `SDL_GLOBAL_KEYBOARD_ID`, which is 0, while
`SDL_AddKeyboard` registers the attached keyboard as `SDL_DEFAULT_KEYBOARD_ID`,
which is 1; with XInput2 the events carry the physical slave device's
`sourceid` while `SDL_GetKeyboards()[0]` may be the master. Either way the map
matched nothing and every key press was dropped.

A `kbid` or `jsid` of 0 now matches any device of that kind. 0 is safe to spend:
SDL documents `which` as 0 when the source is unknown or virtual, and joystick
ids start at 1, so no real device is 0. A non-zero id still matches only that
device, which is what keeps two local players on two keyboards apart, and there
is a test for that half too. `util/charviewer.c` already passed 0 and depended
on the old behaviour by accident; it works under XInput2 now as well.

Two reasons this shipped, both closed:

- Every test in tests/controller.c dispatched an event whose id equalled the id
  the map was bound with, so none of them could see it.
- The sidescroller's smoke test called the control handlers directly instead of
  dispatching events, so it passed against a control map that matched nothing.
  It now pushes synthetic events through akgl_controller_handle_event from a
  deliberately non-zero device id and fails if the press does not arrive.
  Verified by breaking the binding and watching the run exit non-zero.

`test_controller_wildcard_device_ids` was written first and failed against the
unfixed library with "a map bound to keyboard 0 ignored a key press from device
11", which is the whole reason to trust it now that it passes.

The controls were documented, in one sentence. Chapter 19 has a proper table of
them, and chapter 15 explains why not to reach for SDL_GetKeyboards(). The
header said the match was exact and now says what it does.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:23:13 -04:00
15ac5d5dd0 Record what writing the manual found: 18 defects and 27 false claims
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 19s
libakgl CI Build / performance (push) Failing after 19s
libakgl CI Build / memory_check (push) Failing after 15s
libakgl CI Build / mutation_test (push) Failing after 17s
Twenty-one chapters and two games were written against src/ rather than against
the header comments, and the exercise turned up two classes of problem. Both are
recorded here because publishing a problem you cannot fix yet is a contribution,
and because the second class is the more dangerous one: every item in it was
documented, in a header, incorrectly.

The one to fix first is defect 1. akgl_game_update calls
akgl_physics->simulate() with no NULL check and akgl_default_physics is zeroed
BSS, so a program that never calls an initializer segfaults on its first frame
-- measured, exit 139, not an AKERR_NULLPOINTER. physics.h tells the reader
akgl_game_init selects a backend from a `physics.engine` property, which is
false in both halves, so a caller who believes the header writes exactly the
program that crashes. That is the worst first-contact experience in the library
and the fix is a NULL check.

Defect 2 is the subtlest. A child actor's offset is counted twice: physics.c
writes x as parent->x + vx, an absolute coordinate, and actor.c then draws at
parent->x + obj->x while actor_visible three lines above treats obj->x as
absolute. Two readings of one field inside one function, and actor.h documents
both of them without noticing. Confirmed by rendering the frame with and
without the guard and hashing the readback.

The rest run from silent invisibility (an actor on layer >= 16 is updated and
simulated and never drawn; a map-spawned actor has no facing bit and so no
sprite) through dead API (speedtime is loaded, mis-cast, and read by nothing;
there is no way to play a sound effect from a file at all) to asset provenance:
tests/assets/World_A1.png and util/assets/Actor1.png carry RPG Maker's default
filenames with no licence file, and util/assets/littleguy.json -- the sample
data for the one demo the library ships -- does not load.

The second section lists the 27 header comments that describe code that has
since changed. Nothing catches these: WARN_IF_UNDOCUMENTED proves a symbol has
a comment, not that the comment is true, and check_api_surface.sh strips
comments precisely because prose is not a declaration. Doxygen publishes them.

A third section marks entries in this file that are themselves stale, including
item 15, which describes a leak that src/util.c already fixes with exactly the
technique the item proposes. AGENTS.md warns that this file "carried eleven
entries describing code that had already changed"; that is still accumulating,
and a premise nobody has re-checked is where the next defect is hiding.

plan.md is the plan the work was executed from, kept because it records the
decisions and, in three places, corrections to itself -- including getting the
error-code count wrong in the document whose purpose was fixing wrong
documentation.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:20 -04:00
88aaa184e4 Add two tutorial games that build, run in CI, and cannot drift
examples/sidescroller and examples/jrpg are complete programs, built with the
library and exercised headless by ctest. The chapters quote them with
`c excerpt=examples/...` blocks rather than restating the code, so a chapter
cannot drift from a program that compiles -- the excerpt check fails the moment
the source moves. 34 excerpts in one chapter, 21 in the other.

The two are complementary. The sidescroller is the physics tutorial: gravity, a
jump, coins, hazards. The JRPG is the content-pipeline tutorial: a town map,
NPCs spawned from map objects, four-way per-facing animation, a text box, a
follower.

Both smoke tests drive real SDL_Events through akgl_controller_handle_event and
step the physics clock at a fixed 1/60s rather than sleeping, so a scripted run
is deterministic and finishes in under five seconds.

Writing them is what turned up most of the defects recorded in the next commit,
because a game exercises paths a unit test does not. Each workaround says in the
chapter which library gap forced it:

- collision is written in a custom movementlogicfunc, because
  akgl_physics_arcade_collide raises AKERR_API and akgl_physics_simulate never
  calls collide at all;
- the sidescroller cancels the step's own gravity when it blocks downward,
  because otherwise a quarter-pixel of penetration makes the *horizontal* sweep
  report blocked and the character walks backwards a tile at a time;
- both clear movement_controls_face on every map-spawned actor, because the
  default facefunc leaves a stopped actor with no facing bit, no sprite, and no
  draw;
- the JRPG's follower gets a renderfunc that nulls obj->parent for the duration
  of the draw, because a child's offset is counted twice.

Assets are CC0 from three Kenney packs, vendored with per-pack licence text,
per-file provenance, and the geometry contract in
docs/tutorials/assets/README.md. CC0 specifically rather than merely free: a
reader who copies a tutorial into their own game inherits no obligation.
scripts/fetch_tutorial_assets.sh refreshes them in the shape
mkcontrollermappings.sh was fixed into for 0.5.0 -- it checks curl's status,
refuses a pack page that does not say CC0, verifies the archive and the staged
dimensions, and leaves the tracked bytes untouched on any failure. Both failure
paths were tested, and a no-op refresh is byte-identical.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:59:00 -04:00
b938460127 Add the manual: nineteen chapters and a corrected README
docs/ is a narrative manual, not a second reference. Every header already
carries a substantial @file/@brief block and Doxyfile sets WARN_IF_UNDOCUMENTED
with WARN_AS_ERROR, so an undocumented symbol already fails CI. The gap was
navigation and worked examples. Chapters teach a task and link to the Doxygen
output; where a declaration or a constant table has to be in front of the
reader it arrives as a `c excerpt=` block, so the text *is* the header and
cannot diverge from it. That also preserves the hand-aligned bit-flag tables
scripts/reindent.el goes out of its way not to destroy.

The manual does not re-document its dependencies. libakerror owns the
ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol, SDL3 owns renderers and events,
Tiled owns the map format, jansson owns json_t. A chapter that restated any of
them would be wrong the day upstream changed and nothing here would notice --
the same drift this work exists to fix, arriving from a different direction. So
each chapter says what libakgl adds or constrains and links out for the rest.

Chapter 4 is the exception and the reason for it: libakerror documents the
mechanism, but only libakgl can say which statuses its own functions raise and
what they mean here, and that was written down nowhere. It carries three tables
-- libakgl's five status codes, the libakerror statuses libakgl actually raises
with their meaning in this library, and the exit-status trap where
`exit(AKGL_ERR_SDL)` is a wait status of 0 because the band starts at 256.

Every chapter was written against src/ rather than against the header comments,
which is how 27 false claims in those comments came to light. Where a chapter
documents a known defect rather than a design decision it says so and points at
TODO.md.

README.md keeps the development process and hands the reader to docs/. Its
task-oriented FAQ is deleted rather than moved, because one source of truth per
topic is the whole point and that FAQ's examples did not compile.

Census: 39 compiled snippets, 89 verbatim header excerpts, 4 JSON documents run
through the real loaders, one linked-and-executed program with its output
compared byte for byte, one generated figure. 11 norun blocks, each justified.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:37 -04:00
8ac291d2dd Compile, link and run every example in the documentation
libakgl exports 157 functions and the only user-facing documentation was a FAQ
in README.md whose examples did not compile: two unbalanced `PASS()` calls, a
`sprite->frameids = [0, 1, 2, 3];` that is not C in any dialect, a stray `9`
inside a bitmask expression, an `int screenwidth = NULL`, and -- in the first
snippet a reader ever saw -- the exact `strncpy` call AGENTS.md forbids.

That is not a reader routing around typos. It is what happens to samples that
nothing executes. This is akbasic's documentation harness with the interpreter
taken out and the ability to link and run put in.

The contract is a set of fence info strings, so an example is ordinary markdown
that still highlights on the forge:

  ```c                  compiled with -fsyntax-only -Wall -Werror
  ```c wrap=NAME        the same, wrapped in tests/docs_preludes/NAME.pre/.post
  ```c run=NAME         linked against akgl and run headless
  ```c excerpt=PATH     must still appear verbatim in PATH
  ```c screenshot=NAME  also the source of docs/images/NAME.png
  ```json kind=KIND     loaded through the real akgl_*_load_json
  ```output             the exact stdout of the runnable block above it

`run=` is why this links at all: -fsyntax-only proves a call typechecks, not
that the startup order works or that an ATTEMPT block gives back what it took.
`json kind=` exists because the asset formats are documented in prose and read
by four loaders with nothing tying the two together -- util/assets/littleguy.json
is already invalid against the loader it ships with.

A fence with no info string is a hard error, and so is an unknown one. The
failure mode this exists to prevent is passing because it quietly ran nothing,
so an unannotated block is a missing decision rather than a default. Exit status
is the number of failed examples; 2 for a usage error, which is a different
thing and has to be distinguishable.

Proven to fail on all ten of its failure modes -- untagged fence, non-compiling
snippet, stale excerpt, orphan output block, unknown info string, run= output
mismatch, invalid JSON, missing figure, a -Werror warning, and a dangling
preload= -- because a check that has never failed has not been tested.

`-Werror` here although AKGL_WERROR stays off for the library: that option is
off so a new compiler's diagnostic cannot break a consumer's build, and a doc
snippet is not a consumer. A sample that warns is a sample that teaches the
warning.

Registered as `docs_examples` and `docs_screenshots`. No docs-path filter in CI,
deliberately: documentation goes stale because the code moved, not because
somebody edited a chapter.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:58:11 -04:00
2766372f37 Reindent tools/ and examples/ too, in both copies of the scope list
`scripts/reindent.sh` and `scripts/hooks/pre-commit` each carried their own
copy of the directory list, and neither included `tools` or `examples`. Both
hold ordinary hand-written C, so `--check` reported a clean tree without having
looked at them and the hook let them through unformatted -- which defeats the
one thing that keeps this tree a fixed point of `indent-region`.

The comment in `reindent.sh` already said the scope is "directories whose C
sources are hand-maintained", so this is the list catching up with the stated
intent rather than a new policy.

The two copies are now marked as being two copies of one decision, because they
have already disagreed once and nothing detects it when they do.

`scripts/reindent.sh --check` is clean with the wider scope.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:57:37 -04:00
cc757ab578 Fix the two Doxygen errors that were failing the docs build
`Doxyfile` sets `WARN_AS_ERROR = FAIL_ON_WARNINGS` and CI builds the docs as a
step of `cmake_build`, so both of these failed the job:

- `akgl_character_initialize` documented `@param basechar` for a parameter the
  declaration spells `obj`. This is exactly the mismatch AGENTS.md warns about
  under Naming Conventions -- "parameter names must match between the
  declaration and the definition, Doxygen publishes the header spelling" -- and
  it cost two errors, one for the argument that does not exist and one for the
  argument left undocumented.
- `require_at_eof` asked for an explicit link to `#AKERR_EOF`. That symbol is
  libakerror's and `Doxyfile`'s INPUT is `include/akgl src`, so the link can
  never resolve. Backticks say the same thing to a reader without asking
  Doxygen to find a page it does not have.

`doxygen Doxyfile` now exits 0 with no diagnostics.

Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:57:26 -04:00
7cf213235d Plan the performance work the baseline points at
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 19s
libakgl CI Build / performance (push) Failing after 20s
libakgl CI Build / memory_check (push) Failing after 16s
libakgl CI Build / mutation_test (push) Failing after 16s
New "The plan" subsection under Performance in TODO.md: nine entries in
blast-radius order. Six are next (string-pool free list, text texture ring
cache, non-raising sprite lookup, hot-path log demotion, the tileset scan
fix with its two latent draw bugs, the draw_world layer loop); three are on
record but unscheduled (uniform-grid broad phase behind target 12, the
realistic map fixture for target 13, the footprint refactor for 14 and 15).

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
2026-08-01 17:21:03 -04:00
00b68f5873 Compare the measured frame to Construct and Phaser
New PERFORMANCE.md section putting the baseline next to what Construct and
Phaser publish about their own frames: their numbers assume GPU command
submission, so what transfers here is the culling and caching (text dirty
flags, collision cells), not the batching -- the control rows already showed
per-tile submission overhead at 0.2%. All claims cite primary sources.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
2026-08-01 17:21:02 -04:00
e4aa6a5084 Take libakerror 2.0.1 and drop the workaround it makes obsolete
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / performance (push) Failing after 19s
libakgl CI Build / memory_check (push) Failing after 15s
libakgl CI Build / mutation_test (push) Failing after 17s
Every libakgl test suite could report success while failing. libakerror's
default unhandled-error handler ended in exit(errctx->status), an exit
status is one byte wide, and libakgl's band starts at 256 -- so
AKGL_ERR_SDL, the most common failure a library built on SDL can have,
exited 0 and CTest recorded a pass. tests/character.c aborted at its
second of four tests on a bad renderer and was green for months.

0.5.0 worked around that here with TEST_TRAP_UNHANDLED_ERRORS() in
tests/testutil.h, and TODO.md ended the entry saying any consumer's
suites have the same problem and it was worth raising upstream. It was.
2.0.1 fixes it at the source: akerr_exit() owns the mapping and the
default handler calls it, so 0 exits 0, 1 through 255 exit themselves,
and anything else exits AKERR_EXIT_STATUS_UNREPRESENTABLE (125). The
trap and its 21 call sites are gone.

Verified by putting the original failure back rather than by reading the
release notes: a FAIL_BREAK(AKGL_ERR_SDL) in tests/character.c's main
exits 125 and CTest reports a failure. A standalone consumer raising the
same status unhandled exits 125 where it exited 0 before.

tests/actor.c installs its own handler and called exit(errctx->status)
from it, which is the same defect one layer up. It calls akerr_exit()
now.

2.0.0 also makes the error pool and the status registry thread safe,
which libakgl needs more than it knew: audio_stream_callback raises
error contexts on SDL's audio thread. With an unlocked pool that
callback and the main thread could scan AKERR_ARRAY_ERROR at the same
time and be handed the same slot. The comment there says so.

This is a hard dependency floor, not a preference. 2.0.0 moved
__akerr_last_ignored to thread-local storage and made akerr_next_error()
return a context that already holds its reference, and both expand at
libakgl's call sites -- and at a consumer's, because akerror.h is part
of libakgl's public interface. Mixing headers and libraries across that
line double-counts every reference and never returns a pool slot. The
soname moved to libakerror.so.2; include/akgl/error.h now also feature-
tests AKERR_EXIT_STATUS_UNREPRESENTABLE, which is the narrowest probe
for 2.0.1 since libakerror publishes no version macro.

0.7.0 for that reason: libakgl's own ABI is unchanged, but the one it
re-exports through its headers is not.

TODO.md records the pkg-config gap this makes sharper -- akgl.pc names
no dependencies at all, so nothing tells a pkg-config consumer which
libakerror it needs.

Clean build, 26/26 ctest, memcheck clean, warning-clean at -Wall
-Werror. libakgl.so.0.7 links libakerror.so.2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 13:05:43 -04:00
1b90f2ebd5 Report the failures libakstdlib's wrappers were already catching
Updates deps/libakstdlib to 669b2b3. That commit is a TODO entry rather
than new code, so the interesting part is what libakgl was not yet
calling: it links libakstdlib and uses ten of its wrappers while calling
the raw libc function at a good many more sites. Four of those were
carrying a real defect.

akgl_game_save checked every write and threw away the close. Every write
goes through aksl_fwrite, and for a record this size all of them land in
stdio's buffer -- nothing reaches the device until the close flushes it,
so a full disk, an exceeded quota or an NFS server going away is
reported only there. The function returned success over a savegame that
was never written. aksl_fclose surfaces it. tests/game.c reaches the
path through /dev/full, which accepts writes and fails at the flush;
against the unfixed code the test reports "expected a failure, got
success" with ENOSPC.

The close has to drop the FILE * before the status is examined, because
fclose(3) disassociates the stream either way and CLEANUP would close it
again. Written the other way round it aborted in glibc with "double free
detected in tcache" the first time a close actually failed, which is how
that ordering was found.

akgl_game_load's end-of-file check used fgetc(3), which spells "end of
file" and "the read failed" with the same EOF -- a disk error there read
as a clean end and passed the check it was meant to fail. aksl_fgetc
tells them apart. Extracted to require_at_eof, because inverting the
sense of a call inline needs a nested ATTEMPT whose break binds to the
wrong switch.

Two snprintf sites were truncating under a silenced
-Wformat-truncation. The compiler was right about both. The tileset one
handed the shortened path to IMG_LoadTexture, so a length error was
reported as a missing file, with SDL naming a path the caller never
wrote. Both use aksl_snprintf now and the suppression is gone; nothing
in the tree uses those macros any more. tests/tilemap.c covers the join,
and against the unfixed code it gets AKGL_ERR_SDL where it wants
AKERR_OUTOFBOUNDS.

The two src/game.c strncpy sites the fixed-width copy sweep missed --
the savegame name field and the libversion stamp -- use aksl_strncpy and
sizeof rather than a repeated 32.

Also makes tests/physics_sim.c deterministic. It placed gravity_time at
"now - dt" and let the real clock supply the step, so a machine busy
enough to deschedule the process between that store and the
SDL_GetTicksNS() inside simulate got a longer step. It went red once,
under a parallel ctest. It now sets max_timestep to the step it wants
and gravity_time to zero, so the bound supplies the step and the
scheduler cannot reach it.

TODO.md records what is deliberately not adopted: the pure-arithmetic
wrappers, which can only fail on NULL and which the tree already spells
two ways, and the collections, which the registries do not want because
SDL owns the property-set lifetime. AGENTS.md has the rule and the
fclose ordering trap.

26/26 ctest, memcheck clean, warning-clean at -Wall -Werror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 12:36:36 -04:00
147ab7dc78 Fix three arcade physics defects the unit tests could not see
tests/physics_sim.c 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.
tests/physics.c already checked that akgl_physics_simulate does the
arithmetic it claims. Every one of these got past it.

The first step was however long the level took to load. gravity_time was
never initialized and dt was unbounded, so a 250 ms load produced a
250 ms step: 100 px of fall where a 60 Hz frame under the same gravity
is 0.44 px, straight through whatever was underneath. Both initializers
seed the clock, and simulate bounds dt to max_timestep -- a new
physics.max_timestep property, default 0.05 s, read like gravity and
drag. Zero disables the bound. The field replaces the dead timer_gravity,
so the struct is the same size.

Releasing a vertical key cancelled gravity. The _off handlers zeroed ay,
ey, ty and vy together, and ey is where the arcade backend accumulates
gravity -- 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.

Diagonal movement was 41% too fast. Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the sx/sy/sz ellipse, which
also keeps a character whose horizontal and vertical top speeds differ
moving at the ratio it asked for. An axis with a zero max speed stays
out of the magnitude and is forced to zero, as the old clamp did.

Each fix was checked by reverting it and confirming the simulation goes
red: 100.1 px, vy 0.0 after a down tap, and 141% diagonal respectively.
tests/actor.c and tests/physics.c pinned the old behaviour in both
places and now assert the new contract.

Bumped to 0.6.0: akgl_PhysicsBackend changed. TODO.md records the four
things the simulations found and this does not fix -- no terminal
velocity, no deceleration on release, Euler's frame-rate dependence, and
a drag coefficient large enough to invert velocity.

26/26 ctest, memcheck clean, warning-clean at -Wall -Werror.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
2026-08-01 12:10:53 -04:00
d55778e625 Document the checks, the copy rule and the trap that made a test lie
Four gaps in the maintenance guide, each one something a maintainer will hit.

The two enforced checks were never named in it. api_surface
(scripts/check_api_surface.sh) and error_protocol
(scripts/check_error_protocol.py) fail builds and appear in ctest output, so the
guide now says what each one asserts and why -- including that the API check
strips comments before looking, because four of the nineteen symbols it found
were mentioned in header prose and that is not the same as being declared.

A new "Copying Into Fixed-Width Fields" section: never strncpy or memcpy into
one of this library's fixed-width fields, use aksl_strncpy, bound n at
sizeof(dest) - 1 to keep the truncate-not-reject contract, and know the one
place the NUL padding is load-bearing. Ten sites had the unterminated form and
one had the overreading memcpy form, and every name in this library is a
registry key.

The testing section gains the error-context ownership trap. TEST_EXPECT_* release
whatever the statement returns, and some functions return the context they were
given -- so a CLEANUP that also releases it double-releases, and a
double-released context corrupts the failure rather than reporting it. A test
written that way passes against broken code. That is not hypothetical; it is
what the first AKERR_OUTOFBOUNDS test did.

Also recorded: what ctest actually runs beyond the C suites, the gcovr
positional search path and why it must stay, and the surviving GCOV returncode 5
trap. Plus rebuild.log to .gitignore -- it had been sitting in git status long
enough to stop being noticed, which is the state a genuinely unexpected file
needs to stand out from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 11:19:00 -04:00
4d80664cd9 Build clean under -Wall, with -Werror in CI only
TODO.md item 37 said the cast sweep buys nothing until the build turns on the
warnings those casts suppress. This is that precondition, and the argument
turned out to be right with evidence: -Wall found three genuine signedness
mismatches in sprite.c, where uint32_t width, height and speed were passed
straight to akgl_get_json_integer_value(..., int *). A cast would have silenced
all three. Fixing them properly also turned up that speed is scaled by a million
into a 32-bit field, so anything past 4294 ms overflowed rather than being held.

The other real finding was ten -Wstringop-truncation warnings, every one a
fixed-width strncpy. Those leave a name field unterminated when the input fills
it -- and those fields are registry keys, handed to strcmp and SDL property
calls that do not stop at the field. Same overread class fixed twice already
this release. All ten now use aksl_strncpy from akstdlib, which always
terminates and never NUL-pads, bounded to size - 1 so an over-long name still
truncates rather than being refused. staticstring.h documented the old strncpy
semantics explicitly and has been rewritten to match.

Two sites in game.c are deliberately left on strncpy: both stage into a
pre-zeroed buffer for the savegame's fixed-width fields, where the NUL padding
is part of the format. Neither is flagged.

sprite.c also had a memcpy of a full 128-byte field from a NUL-terminated
string, which reads past the end of any shorter name. Not flagged by anything;
found while converting its neighbours.

-Werror is an option, default OFF, on only in the cmake_build and performance CI
jobs. libakgl is consumed with add_subdirectory -- akbasic does exactly that --
so a target-level -Werror turns every diagnostic a newer compiler invents into a
broken build for somebody else. The warning set is not even stable across build
types here: an -O2 build reports ten warnings that -O0 and -fsyntax-only do not,
because they need the middle end. The two jobs that set it are one of each.

-Wextra is deliberately not adopted. It adds 22 findings, 17 inherent to the
design: 13 -Wunused-parameter from backend vtables and SDL callbacks that must
match a signature, and 4 -Wimplicit-fallthrough from libakerror's own
PROCESS/HANDLE/HANDLE_GROUP, which fall through by design. Filed upstream as
deps/libakerror TODO item 8; the submodule bump carries it.

deps/semver/semver.c is listed directly in add_library, so the target's PRIVATE
options reach it. Exempted with -w so a future semver update cannot fail this
build -- verified by forcing -Wextra -Werror and watching our files fail while
semver compiled clean.

Verified: RelWithDebInfo, Debug and coverage builds all clean under -Werror;
-Werror actually fails on a planted unused variable; an embedded consumer with
AKGL_WERROR unset still configures and builds. 25/25 pass, memcheck clean,
reindent --check, check_api_surface and check_error_protocol clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:18:52 -04:00
187 changed files with 28691 additions and 906 deletions

View File

@@ -24,7 +24,8 @@ jobs:
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Debug \
-DAKGL_COVERAGE=ON
-DAKGL_COVERAGE=ON \
-DAKGL_WERROR=ON
cmake --build build --parallel
- name: Build API documentation
run: doxygen Doxyfile
@@ -90,7 +91,8 @@ jobs:
- name: Configure and build
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=RelWithDebInfo
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DAKGL_WERROR=ON
cmake --build build --parallel
# --verbose rather than --output-on-failure: a passing benchmark's output
# is the entire point of running it, and --output-on-failure prints

5
.gitignore vendored
View File

@@ -5,6 +5,11 @@ build*/
.aider*
*~
# rebuild.sh captures its cmake output here. A build artifact, not a record --
# it sat untracked in `git status` long enough to become invisible, which is the
# state a genuinely unexpected untracked file needs to stand out from.
rebuild.log
# Generated by configure_file into the build tree. include/ precedes the build
# tree on the include path, so a stray copy here would silently shadow the real
# one and pin every consumer to whatever version it was generated at.

6
.gitmodules vendored
View File

@@ -22,3 +22,9 @@
[submodule "deps/jansson"]
path = deps/jansson
url = git@github.com:akheron/jansson.git
[submodule "deps/libccd"]
path = deps/libccd
url = https://github.com/danfis/libccd.git
[submodule "deps/tg"]
path = deps/tg
url = https://github.com/tidwall/tg.git

339
AGENTS.md
View File

@@ -81,6 +81,33 @@ Run mutation testing with `cmake --build build --target mutation`. For a quick s
Generate HTML and Cobertura coverage reports with `cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug`, then build and run CTest. Reports are written to `build-coverage/coverage/`; this mode requires `gcovr` and GCC or Clang.
Both `gcovr` invocations take the build tree as an explicit positional search
path. Given none, gcovr searches `--root` -- the source directory, where
developers keep their build trees -- and `--object-directory` does not narrow
that. A second instrumented tree left in the source directory therefore used to
be folded into the report, and two trees describing different line numbers for
the same function failed `coverage_reset` before any test ran. Leave the
positional path alone.
A different trap survives: rebuilding an existing coverage tree after editing a
source leaves `.gcda` files describing the old object layout, and
`coverage_reset` -- whose whole job is to delete them -- fails with `GCOV
returncode was 5` first. `find build-coverage -name '*.gcda' -delete` clears it.
### What `ctest` runs
Beyond the C suites, three registered tests are not C programs and are worth
knowing about when one of them fails:
| Test | What it checks | Script |
|---|---|---|
| `api_surface` | Every exported `akgl_*` symbol is declared in a header | `scripts/check_api_surface.sh` |
| `error_protocol` | No `*_RETURN` inside `ATTEMPT`, no `return` out of `HANDLE` | `scripts/check_error_protocol.py` |
| `headers` | Every public header compiles as the first include of a translation unit | generated from `AKGL_PUBLIC_HEADERS` |
`api_surface` needs `nm` and the built library; it exits 2 and is reported as
skipped when it cannot run, rather than passing quietly.
## Continuous integration
`.gitea/workflows/ci.yaml` runs four jobs on every push, and each one exists
@@ -243,6 +270,48 @@ akerr_ErrorContext *akgl_actor_update(akgl_Actor *obj)
);
```
### Compiler warnings
**`-Wall` is on for every target this project owns** -- the library, the test
suites and `charviewer` -- and the tree builds clean under it at every
optimization level.
**`-Werror` is not on by default.** It is `option(AKGL_WERROR ... OFF)`, turned
on only by the `cmake_build` and `performance` CI jobs. Two reasons, and both
are measured rather than assumed:
- libakgl is consumed with `add_subdirectory()`; `akbasic` does exactly that. A
target-level `-Werror` makes every diagnostic a newer compiler invents into a
broken build for somebody else's project.
- The warning set is not stable across build types. An `-O2` build reports ten
`-Wstringop-truncation` warnings that `-O0` and `-fsyntax-only` do not,
because they need the middle end. The two CI jobs that set `AKGL_WERROR` are
deliberately one of each: coverage at `-O0`, performance at `-O2`.
If you are checking this by hand, **compile for real at the optimization level
being shipped.** `-fsyntax-only` reported 6 of the 16 findings in `src/`.
**`-Wextra` is deliberately not adopted.** It adds 22 findings, 17 of which are
inherent to the design rather than defects: 13 `-Wunused-parameter`, because a
backend vtable entry or an SDL callback must match a signature whether it reads
every argument or not, and 4 `-Wimplicit-fallthrough` from libakerror's own
`PROCESS`/`HANDLE`/`HANDLE_GROUP`, which fall through between `case` labels by
design. Adopting it today would mean disabling both permanently to gain 5
`-Wsign-compare`. The fallthrough half is filed upstream as
`deps/libakerror/TODO.md` item 8; revisit when that lands.
**Vendored code is exempt, not fixed.** `deps/semver/semver.c` is listed
directly in `add_library(akgl ...)`, so the target's `PRIVATE` options reach it;
`set_source_files_properties(... COMPILE_OPTIONS "-w")` keeps a future `semver`
update from failing this build. The other vendored projects are separate targets
and are unaffected either way.
**Never silence a warning with a cast.** Three `-Wpointer-sign` findings in
`src/sprite.c` were real signedness mismatches -- `uint32_t *` passed where an
`int *` was expected -- and a cast would have hidden every one. That is the
whole argument of `TODO.md` item 37. Read into a correctly typed local, check
the range, and assign.
### No repository-wide formatter
There is no `clang-format` or linter wired into the build, and `cc-mode`'s
@@ -320,6 +389,19 @@ because the failure mode is silent.
- Validate every pointer parameter before dereferencing it, including the ones a
sibling function happens not to check.
The first two rules are enforced by `scripts/check_error_protocol.py`, running
as the `error_protocol` test: it flags a `*_RETURN` inside an `ATTEMPT` block
and a `return` out of a `HANDLE` block. Both have shipped before. Neither
produces a compiler diagnostic, and neither fails a test run until the pool it
drains is empty, which is why a linter is the only thing that catches them.
It cannot see the third hazard -- a `CATCH` inside a loop nested in an `ATTEMPT`
block, where `break` binds to the loop. When a loop is the *entire* body of an
`ATTEMPT` that is harmless, because `CLEANUP`, `PROCESS` and `FINISH` still run;
anything after the loop has to account for it. `akgl_tilemap_load_layer_objects`
carries a comment saying exactly that, and `akgl_draw_background` uses a flag
instead for the same reason.
## API Surface
Every function with external linkage must be declared in a header, and every
@@ -342,8 +424,87 @@ unit is the only shape that proves anything: a second `#include` after the
first proves nothing about the second, because by then the first has dragged
its dependencies in.
The declaration half is enforced too, by `scripts/check_api_surface.sh` running
as the `api_surface` test. It reads the built library's dynamic symbol table,
strips comments out of every public header, and fails on an exported `akgl_*`
symbol that is declared nowhere. **Stripping the comments is the point**: four
of the nineteen symbols this found were *mentioned* in `controller.h` prose,
which is not the same as being declared, and is how they stayed missing.
Declare no-argument functions as `(void)`, not `()`.
## Copying Into Fixed-Width Fields
**Never `strncpy` or `memcpy` into one of this library's fixed-width fields.
Use `aksl_strncpy()` from `akstdlib.h`.**
```c
PASS(errctx, aksl_strncpy(obj->name, sizeof(obj->name), name, sizeof(obj->name) - 1));
```
`strncpy(dest, src, sizeof(dest))` leaves `dest` **unterminated** whenever the
source fills it, and `memcpy(dest, src, sizeof(dest))` reads past the end of any
source shorter than the field. Every name in this library is a registry key,
handed to `strcmp`, `realpath` and SDL property calls, none of which stop at the
field. Ten sites had the first problem and one had the second; that whole class
is the same one as the savegame overread and the `akgl_get_property` overread
before it.
`aksl_strncpy` always terminates and never NUL-pads. Two details matter:
- **Bound `n` at `sizeof(dest) - 1`.** It raises `AKERR_OUTOFBOUNDS` when the
bytes do not fit, and capping `n` is what preserves the "truncated, not
rejected" contract those headers document. Drop the cap only when you mean to
change that contract.
- **It does not pad.** Where the padding is load-bearing -- the savegame name
tables write a fixed-width field and the reader steps that many bytes --
`strncpy` into a pre-zeroed buffer is still correct. `write_name_field()` in
`src/game.c` is the one place that applies, and it says so.
`-Wstringop-truncation` catches the first form at `-O2`. Nothing catches the
`memcpy` form; that one was found by reading its neighbours.
## Reaching For libakstdlib
`deps/libakstdlib` wraps most of libc so a failure arrives as an
`akerr_ErrorContext *` with context instead of a return value the caller has to
remember to check. Use the wrapper **wherever the underlying call can actually
fail**, which in practice means anything touching a file, a format string, or a
fixed-width destination:
| Instead of | Use | Because |
|---|---|---|
| `fclose` | `aksl_fclose` | The flush happens here, so a full disk or an exceeded quota is reported *only* here |
| `fgetc` | `aksl_fgetc` | `fgetc(3)` returns `EOF` for both end-of-file and a read error; this raises `AKERR_EOF` and `AKERR_IO` separately |
| `snprintf` | `aksl_snprintf` | Truncation becomes `AKERR_OUTOFBOUNDS` naming both lengths, instead of a silently short string |
| `strncpy` into a fixed field | `aksl_strncpy` | See **Copying Into Fixed-Width Fields** |
| `fopen`/`fread`/`fwrite`/`realpath`/`atof`/`atoi` | the `aksl_` form | Already the convention in this tree |
**`fclose(3)` disassociates the stream whether or not it succeeds.** Drop the
`FILE *` before you look at the status, or `CLEANUP` closes it a second time:
```c
tmpfp = fp;
fp = NULL;
CATCH(errctx, aksl_fclose(tmpfp));
```
Written the other way round this aborts in glibc with `double free detected in
tcache`, and only on the path where a close actually fails -- which is to say,
never during development. `/dev/full` is how `tests/game.c` reaches it: writes
to that device are accepted and the `ENOSPC` surfaces at the flush.
**Do not convert the pure-arithmetic calls.** `memset`, `memcpy`, `strlen`,
`strcmp` and friends can only fail on a NULL argument, and the tree is
inconsistent about them already; see `TODO.md`, "libakstdlib wrappers not yet
adopted", before starting a sweep.
**Never silence `-Wformat-truncation`.** `include/akgl/error.h` still exports
`DISABLE_GCC_WARNING_FORMAT_TRUNCATION` and nothing uses it. Both sites that
did were genuinely truncating, and both reported the length problem as
something else -- one as a missing texture file, with SDL naming a path the
caller never wrote. `aksl_snprintf` is the answer.
## Fixing Defects
**Read what the code does before deciding what it should do.** Every rule below
@@ -410,13 +571,82 @@ cheap to check and expensive to assume.
Tests use simple executable return codes and are registered through CTest in `CMakeLists.txt`; there is no declared coverage threshold.
**Every suite's `main()` must call `TEST_TRAP_UNHANDLED_ERRORS()` immediately
after `akgl_error_init()`.** libakerror's default unhandled-error handler ends
in `exit(errctx->status)`, `exit` keeps only the low byte, and libakgl's status
band starts at 256 — so a suite that failed with `AKGL_ERR_SDL` exited 0 and
CTest recorded a pass. That is not hypothetical: `tests/character.c` aborted at
its second of four tests on a bad renderer and was green until 0.5.0. The trap
in `tests/testutil.h` collapses any status a byte cannot carry onto 1. Add focused tests as `tests/<feature>.c`, create a matching `test_<feature>` target, and register it with `add_test`. Put reusable fixtures in `tests/assets/` and keep paths compatible with tests launched from the build tree. Coverage mode wraps the suite in a CTest fixture so counters are reset before tests and reports are generated afterward.
**Know who owns the error context an assertion consumes.** `TEST_EXPECT_OK`,
`TEST_EXPECT_STATUS` and `TEST_EXPECT_ANY_ERROR` all release whatever context
the statement returns. Some functions return *the context they were given* --
`akgl_get_json_with_default` hands its argument straight back when it does not
handle the status -- so a `CLEANUP` block that also releases that context
releases it twice, and a double-released context corrupts the failure instead of
reporting it. A test written that way **passes against the broken code**, which
is exactly what happened to the first draft of the `AKERR_OUTOFBOUNDS` test in
`tests/json_helpers.c`. When the call may hand the context back, take the result
into a local, `NULL` your own pointer, and release it yourself:
```c
defaulted = akgl_get_json_with_default(keyerr, &defval, &dest, sizeof(int));
keyerr = NULL; /* ownership passed either way */
if ( defaulted != NULL ) {
defaulted->handled = true;
defaulted = akerr_release_error(defaulted);
FAIL_BREAK(e, AKGL_ERR_BEHAVIOR, "...");
}
```
**Never `exit(3)` on an akerr status — call `akerr_exit()`.** An exit status is
one byte wide and libakgl's status band starts at 256, so `exit(AKGL_ERR_SDL)`
is a wait status of 0 and a shell sees a clean run. Every suite in this
directory once reported success on the most common failure a library built on
SDL can have: `tests/character.c` aborted at its second of four tests on a bad
renderer and was green until 0.5.0. `akerr_exit()` (libakerror 2.0.1) maps 0 to
0, 1255 to themselves, and anything else to
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125). The default unhandled-error handler
calls it, so an ordinary suite needs no trap of its own — `tests/testutil.h`
carried one until 0.6.0 and it is gone. A suite that installs its *own* handler
still has to call `akerr_exit()` from it; `tests/actor.c` does. Add focused tests as `tests/<feature>.c`, create a matching `test_<feature>` target, and register it with `add_test`. Put reusable fixtures in `tests/assets/` and keep paths compatible with tests launched from the build tree. Coverage mode wraps the suite in a CTest fixture so counters are reset before tests and reports are generated afterward.
### Physics simulations
`tests/physics_sim.c` is not a unit suite. `tests/physics.c` checks that
`akgl_physics_simulate` does the arithmetic it says it does; this one runs the
arcade backend for a second or two the way a game does and asks whether the
result is what a player would expect. It carries a Mario-esque jump and fall,
Zelda-style top-down walking, and a run reversed at full speed, because those
are the three shapes most 2D games are made of.
It found three defects that every existing unit test passed straight over, so
the distinction is worth keeping:
- **The first step was however long the level took to load.** `gravity_time`
was never initialized and `dt` was unbounded, so a 250 ms load produced a
250 ms step -- 100 px of fall where a 60 Hz frame is 0.44 px. Both
initializers seed the clock now, and `akgl_physics_simulate` bounds `dt` to
`max_timestep` (`physics.max_timestep`, default 0.05 s).
- **Releasing a vertical key cancelled gravity.** The `_off` handlers zeroed
`ay`, `ey`, `ty` and `vy` together, and `ey` is where the arcade backend
accumulates gravity, so tapping down mid-jump stopped the character in the
air. The handlers clear `ax`/`tx` (or `ay`/`ty`) and nothing else. Velocity
was never theirs to clear either -- `simulate` recomputes `v` as `e + t`
every step.
- **Diagonal movement was 41% too fast.** Thrust was capped per axis, so an
actor holding two directions got both caps at once and travelled their
diagonal. It is capped as a vector against the `sx`/`sy`/`sz` ellipse now.
Two rules for working on this suite:
- **Drive the clock, do not sleep on it.** `sim_step()` sets `gravity_time` to
`now - dt` and calls `simulate` once, so a simulated second is 60 steps and
takes no wall-clock time. Only `test_sim_first_step_is_not_a_leap` uses a real
`SDL_Delay`, because a real stall is the thing it is measuring.
- **`main` must `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 that has nothing to do with the code. The
first-step test passed exactly that way before the `SDL_Init` call was added,
and only started failing -- correctly -- once it was there.
Each simulation prints what it measured whether it passes or fails, and `main`
exits with the number that failed. Read the numbers when changing the backend;
a change that keeps every assertion green while moving the apex of a jump by
30 px is a change worth noticing.
### Performance suites
@@ -452,6 +682,101 @@ of a hundred thousand times, and the timings a checked run prints are labelled
as meaningless. **Do not add memory-check suites**: a new path worth checking
belongs in a benchmark, where it gets both.
## Testing a Tutorial
`ctest -R docs_examples` proves every listing in `docs/` still compiles and
still matches the file it was quoted from. It cannot prove the chapter *teaches*
anything: a document can be composed entirely of verified excerpts and still be
unfollowable, because what a reader needs is the glue between them.
**So test a tutorial by making somebody follow it.** Hand it to a subagent on a
weaker model with no context beyond the chapter, and have them build the thing it
describes. The weaker model is the point -- it will not paper over a gap with
knowledge the document did not give it, which is exactly the failure mode a
capable reviewer has.
### The rules that make the result mean something
- **Give them what a real reader has, and nothing more.** The chapter, the
library source, its public headers, and the art. **Not the finished program.**
Copy the tree with `examples/` and `docs/` removed rather than telling them not
to look -- a rule they can break is not a control.
- **Headers are fair game.** The manual defers signatures to Doxygen and a real
user has `include/akgl/*.h` in front of them. Forbidding those tests a reader
who does not exist.
- **They must compile it and run it.** This is the whole thing. A design nobody
built proves nothing, and the defects that matter most do not fail to compile.
- **Verify their work yourself.** Run the binary they produced. Look at the
screenshot they took. Do not take a report's word for what it built --
see "Reading the report" below.
- **Do not make them author assets by hand.** A real reader draws a map in Tiled;
hand-writing a `.tmj` tests nothing about the chapter and eats the whole
session. Give them `docs/tutorials/assets/`. The asset *formats* are already
covered by `docs_examples`, which runs the chapter's own JSON blocks through
`akgl_sprite_load_json` and `akgl_character_load_json`.
- **Give them a screenshot helper**, marked as scaffolding and not part of the
tutorial, so there is a picture to check. Screen output is evidence; an exit
status of 0 is not.
### Setting the sandbox up
One directory per reader, because two of them building at once in the same tree
collide:
```sh
rsync -a --exclude='.git' --exclude='build' --exclude='examples' --exclude='docs' \
. "$SB/reader/libakgl/"
cp docs/20-tutorial-sidescroller.md "$SB/reader/TUTORIAL.md"
cp -r docs/tutorials/assets/sidescroller "$SB/reader/art"
mkdir -p "$SB/reader/game"
```
A consumer using the CMake the chapter itself teaches --
`add_subdirectory(../libakgl libakgl)` plus the documented
`target_link_libraries` line -- configures and builds in about a minute from
cold, and seconds after that. Tell them to use
`cmake --build build --target <theirs> --parallel`, or they will also build
every test suite in the tree.
**Prove the path works before you hand it over.** Write a throwaway consumer
that opens a window and takes a screenshot, build it, run it, and delete it. A
reader who cannot build is a reader who finds nothing, and the fault will be
yours -- the screenshot helper's first draft included `akgl/renderer.h` for
`akgl_renderer`, which is declared in `akgl/game.h`, and would have cost them
the session.
### Reading the report
**Every finding is a hypothesis until you check it.** A weaker model reports its
own mistakes as documentation defects with total confidence, and both kinds are
worth having -- but only one is worth acting on.
- Reproduce the failure before believing it. One reader reported the dialogue
freeze broken; it had drawn its own map with the NPC out of reach and never
used the one it was given. Running the reference showed the player moving zero
pixels through the whole freeze window.
- A rejected finding usually still leaves something. That same map failure was
not a code defect, but it did show the chapter stated `TALK_RANGE` without
saying what it means when you are placing NPCs -- and that a too-distant NPC
produces *success*, not an error.
- Fix the document, not the reader. If they guessed a header name, the chapter
never named it.
### What this catches that review does not
Four read-only passes over the two tutorials in `docs/` found real gaps and
missed all three of these, which the first build-and-run found immediately:
| Defect | Why reading missed it |
|---|---|
| libakstdlib's header was never named -- `aksl_*` functions, `<akstdlib.h>` file | Every reviewer already knew, or did not have to write the `#include` |
| The asset directory defaulted to `"."` instead of the compiled-in macro | Prose said "falls back", and nobody had to run it from another directory |
| A published example output the chapter could not produce | The capture happened after teardown released the pool. Nothing fails to compile |
The third is the shape to remember: **a tutorial that prints an expected result
is making a claim, and a claim nobody executed is a claim that is probably
wrong.**
## Commit & Pull Request Guidelines
Recent commits use concise, imperative summaries such as `Fix a scale bug...` and often explain related changes in one sentence. Keep each commit scoped and describe user-visible behavior. Pull requests should summarize the change, identify affected modules, list the CMake/CTest commands run, and link relevant issues. Include screenshots only for rendering, map, or `charviewer` changes.

View File

@@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.10)
# The single source of truth for the library version. It drives the generated
# include/akgl/version.h, the shared library's VERSION and SOVERSION, and the
# Version field in akgl.pc. Bump it here and nowhere else.
project(akgl VERSION 0.5.0 LANGUAGES C)
project(akgl VERSION 0.8.0 LANGUAGES C)
# Memory checking reuses the suites that already exist -- `ctest -T memcheck`
# runs every registered test under valgrind -- rather than adding programs of its
@@ -38,6 +38,32 @@ endif()
include(CTest)
option(AKGL_COVERAGE "Instrument libakgl and generate coverage reports with CTest" OFF)
# -Wall is always on for code this project owns. -Werror is not, and the
# distinction is deliberate.
#
# libakgl is consumed with add_subdirectory() -- akbasic does exactly that -- so
# a target-level -Werror makes every new compiler diagnostic a broken build for
# somebody else's project. The warning set is not even stable across build types
# here: an -O2 build reports ten -Wstringop-truncation warnings that -O0 and
# -fsyntax-only do not, because they need the middle end. A newer GCC or a
# switch to clang moves the line again.
#
# So the errors live in CI, where the compiler is pinned and a new warning is a
# task for the maintainer rather than an outage for a consumer.
option(AKGL_WERROR "Treat compiler warnings as errors. For CI; leave OFF when embedding." OFF)
set(AKGL_WARNING_FLAGS -Wall)
if(AKGL_WERROR)
list(APPEND AKGL_WARNING_FLAGS -Werror)
endif()
# -Wextra is deliberately not here. It adds 22 findings, 17 of which are
# inherent to the design rather than defects: 13 -Wunused-parameter, because a
# backend vtable entry or an SDL callback has to match a signature whether it
# reads every argument or not, and 4 -Wimplicit-fallthrough from libakerror's
# own PROCESS/HANDLE/HANDLE_GROUP, which fall through between case labels by
# design. Adopting it would mean disabling both permanently to gain 5
# -Wsign-compare. See deps/libakerror/TODO.md item 8.
if(AKGL_COVERAGE)
if(NOT CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
message(FATAL_ERROR "AKGL_COVERAGE requires GCC or Clang")
@@ -109,8 +135,17 @@ akgl_add_vendored_dependency(SDL3_image::SDL3_image deps/SDL_image)
akgl_add_vendored_dependency(SDL3_mixer::SDL3_mixer deps/SDL_mixer)
akgl_add_vendored_dependency(SDL3_ttf::SDL3_ttf deps/SDL_ttf)
# libakerror 1.0.0 sizes its own status-name registry; consumers no longer do.
# libakgl claims its status codes at runtime in akgl_heap_init() instead.
# libakerror sizes its own status-name registry; consumers no longer do. libakgl
# claims its status codes at runtime in akgl_heap_init() instead.
#
# The floor is 2.0.1, and it is a hard one. 2.0.0 moved __akerr_last_ignored to
# thread-local storage and made akerr_next_error() return a context that already
# holds its reference -- both of which expand at *libakgl's* call sites through
# IGNORE and the FAIL macros, and at a *consumer's* too, 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 it happening by accident; the
# #error in include/akgl/error.h catches a stale header in an install tree.
set(AKGL_SUPPRESS_DEPENDENCY_TESTS FALSE)
@@ -139,7 +174,8 @@ endif()
# No version here: libakerror ships no akerrorConfigVersion.cmake, so asking
# for one makes find_package reject every install. The floor is enforced by
# the #error in include/akgl/error.h instead, which feature-tests
# AKERR_FIRST_CONSUMER_STATUS.
# AKERR_FIRST_CONSUMER_STATUS and AKERR_EXIT_STATUS_UNREPRESENTABLE -- the
# latter arriving in 2.0.1, which is the version libakgl needs.
if(NOT TARGET akerror::akerror)
find_package(akerror REQUIRED)
endif()
@@ -164,6 +200,8 @@ set(AKGL_PUBLIC_HEADERS
assets
audio
character
collision
collision_arena
controller
draw
error
@@ -219,11 +257,49 @@ add_custom_target(${AKGL_CONTROLLERDB_TARGET}
COMMENT "Fetching controller mappings and rewriting ${GAMECONTROLLERDB_H} ..."
)
# libccd supplies the collision narrowphase (GJK, EPA and MPR).
#
# Its sources are listed here rather than add_subdirectory()'d. Four things in
# deps/libccd/CMakeLists.txt make it unusable as a subproject, and none of them
# is ours to fix in a submodule:
#
# - project(libccd) declares no LANGUAGES, so it enables CXX and makes a C++
# toolchain a requirement of every build of libakgl. CI installs gcc.
# - it declares option(BUILD_SHARED_LIBS ... ON), a global cache variable, and
# calls include(CTest), which would register its own suites into ours and
# build the LGPL-licensed CU framework under src/testsuites/.
# - install(FILES "${CMAKE_BINARY_DIR}/ccd.pc") reads the *top level* binary
# dir while configure_file writes CMAKE_CURRENT_BINARY_DIR. Those are the
# same directory standalone and different ones embedded, so `cmake --install`
# fails on a missing file.
# - cmake_minimum_required(VERSION 2.8.11) warns on CMake 3.28 and is a hard
# error on CMake 4.
#
# Compiling the sources directly, the way deps/semver/semver.c already is, sidesteps
# all four and never configures the LGPL testsuites at all.
set(CCD_SINGLE 1)
configure_file(
${CMAKE_CURRENT_SOURCE_DIR}/deps/libccd/src/ccd/config.h.cmake.in
${CMAKE_CURRENT_BINARY_DIR}/ccd/config.h)
set(AKGL_CCD_SOURCES
deps/libccd/src/ccd.c
deps/libccd/src/mpr.c
deps/libccd/src/polytope.c
deps/libccd/src/support.c
deps/libccd/src/vec3.c)
# Add include directories
include_directories(${SDL3_INCLUDE_DIRS})
add_library(akgl SHARED
deps/semver/semver.c
${AKGL_CCD_SOURCES}
src/actor.c
src/collision.c
src/collision_arena.c
src/collision_bsp.c
src/collision_grid.c
src/collision_shape.c
src/actor_state_string_names.c
src/audio.c
src/text.c
@@ -261,6 +337,45 @@ set_target_properties(akgl PROPERTIES
SOVERSION ${AKGL_SOVERSION}
)
target_compile_options(akgl PRIVATE ${AKGL_WARNING_FLAGS})
# deps/semver/semver.c is vendored and listed directly in add_library(), so the
# target's PRIVATE options reach it. Exempt it: a future semver update must not
# be able to fail this build. (PRIVATE options do not reach SDL, jansson,
# akerror or akstdlib -- those are separate targets.)
set_source_files_properties(deps/semver/semver.c PROPERTIES COMPILE_OPTIONS "-w")
# libccd is vendored on the same terms as semver: -w so a future bump cannot fail
# this build on a warning we do not own.
#
# src/ccd_arena_shim.h is what keeps the "libakgl does not call malloc at
# runtime" promise true with the whole of libccd compiled in. Injected with
# -include, it redirects both halves of libccd's allocation -- the
# __CCD_ALLOC_MEMORY macro every CCD_ALLOC* expands to, and the four bare free()
# calls -- at the arena in src/collision_arena.c. It applies to these five files
# and nothing else.
#
# The redirect lives in that header rather than in COMPILE_DEFINITIONS because
# CMake cannot carry a function-like macro through a -D: it dropped it silently,
# and libccd went on calling the C library while free() became a no-op. Do not
# move it back.
#
# CCD_STATIC_DEFINE plus -fvisibility=hidden keeps every ccd* symbol out of
# libakgl.so's dynamic table. That is not tidiness: AGENTS.md records a shipped
# defect where an exported `renderer` in this library was preempted by a
# same-named symbol in a test executable, and a game linking both libakgl and a
# system libccd would hit exactly that.
set_source_files_properties(${AKGL_CCD_SOURCES} PROPERTIES
COMPILE_OPTIONS "-w;-fvisibility=hidden;-include;${CMAKE_CURRENT_SOURCE_DIR}/src/ccd_arena_shim.h"
COMPILE_DEFINITIONS "CCD_STATIC_DEFINE")
# PRIVATE, so that ccd/*.h never reaches a consumer's include path. The `headers`
# suite compiles each public header as the first include of a translation unit
# with only include/ available, so a public header that pulled in <ccd/ccd.h>
# would fail there immediately.
target_include_directories(akgl PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/deps/libccd/src"
"${CMAKE_CURRENT_BINARY_DIR}")
add_library(akgl::akgl ALIAS akgl)
add_executable(charviewer util/charviewer.c)
@@ -273,6 +388,8 @@ set(AKGL_TEST_SUITES
audio
bitmasks
character
collision
collision_arena
controller
draw
error
@@ -280,7 +397,9 @@ set(AKGL_TEST_SUITES
headers
heap
json_helpers
partition
physics
physics_sim
registry
renderer
sprite
@@ -475,9 +594,11 @@ target_link_libraries(akgl
foreach(suite IN LISTS AKGL_TEST_SUITES AKGL_PERF_SUITES)
target_link_libraries(akgl_test_${suite} PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
target_include_directories(akgl_test_${suite} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests")
target_compile_options(akgl_test_${suite} PRIVATE ${AKGL_WARNING_FLAGS})
endforeach()
target_link_libraries(charviewer PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer jansson::jansson -lm)
target_compile_options(charviewer PRIVATE ${AKGL_WARNING_FLAGS})
# When the vendored SDL satellite libraries are built in-tree they land in per-
# project subdirectories that are not on the loader's default search path, so a
@@ -530,6 +651,193 @@ if(AKGL_VENDORED_DEPENDENCIES)
endif()
endif()
# ---------------------------------------------------------------------------
# The documentation's own examples.
#
# docs/ is a manual full of C snippets, asset files and shell commands, and the
# README this project started with proves what happens to samples nothing
# executes: unbalanced PASS() calls, a stray `9` inside a bitmask expression, an
# `int screenwidth = NULL`, and the exact strncpy call AGENTS.md forbids. Two
# prose claims in the headers were false against src/ as well.
#
# Documentation goes stale because the *code* moved, not because somebody edited
# a chapter, so this is an ordinary test case rather than something a docs-only
# CI job runs behind a path filter: it fails when the library changes under the
# chapter, which is exactly the case a path filter would miss.
#
# tests/docs_examples.sh reads the fence info strings; docs/MAINTENANCE.md
# documents them.
# ---------------------------------------------------------------------------
# The snippets compile against the real include path, which is transitive
# through akerror, akstdlib, SDL3, SDL3_image and jansson. Writing it out from
# the target's own INCLUDE_DIRECTORIES keeps one source of truth: a hardcoded -I
# list in the script would rot exactly the way the documentation does.
#
# INTERFACE_INCLUDE_DIRECTORIES of the linked targets as well as libakgl's own,
# because akerror.h and akstdlib.h are part of libakgl's public interface and
# neither lives under include/akgl. akgl's own INCLUDE_DIRECTORIES does not
# carry them -- they arrive at a consumer through target_link_libraries -- so
# taking only that property leaves a snippet unable to find <akerror.h>.
set(AKGL_DOCS_DEPENDENCY_TARGETS
SDL3::SDL3
SDL3_image::SDL3_image
SDL3_mixer::SDL3_mixer
SDL3_ttf::SDL3_ttf
akstdlib::akstdlib
akerror::akerror
jansson::jansson
)
set(AKGL_DOCS_CFLAGS_FILE "${CMAKE_CURRENT_BINARY_DIR}/docs_cflags.txt")
# Each entry is wrapped in $<$<BOOL:...>:...>, because a target whose property
# is empty would otherwise contribute a bare `-I` line that swallows the next
# flag. $<FILTER> and $<REMOVE_DUPLICATES> would say it more directly and both
# need CMake 3.15; this file declares 3.10 and means it.
set(AKGL_DOCS_CFLAGS "$<$<BOOL:$<TARGET_PROPERTY:akgl,INCLUDE_DIRECTORIES>>:-I$<JOIN:$<TARGET_PROPERTY:akgl,INCLUDE_DIRECTORIES>,\n-I>\n>")
foreach(_docstarget IN LISTS AKGL_DOCS_DEPENDENCY_TARGETS)
string(APPEND AKGL_DOCS_CFLAGS
"$<$<BOOL:$<TARGET_PROPERTY:${_docstarget},INTERFACE_INCLUDE_DIRECTORIES>>:-I$<JOIN:$<TARGET_PROPERTY:${_docstarget},INTERFACE_INCLUDE_DIRECTORIES>,\n-I>\n>")
endforeach()
file(GENERATE
OUTPUT "${AKGL_DOCS_CFLAGS_FILE}"
CONTENT "${AKGL_DOCS_CFLAGS}"
)
# The link line for the `c run=` and `c screenshot=` blocks. Full library paths
# plus an rpath entry per directory: a vendored build leaves the SDL satellite
# libraries in per-project subdirectories that are not on the loader's default
# search path, which is the same problem AKGL_VENDORED_RPATH solves for the test
# executables.
set(AKGL_DOCS_LDFLAGS_FILE "${CMAKE_CURRENT_BINARY_DIR}/docs_ldflags.txt")
set(AKGL_DOCS_LDFLAGS "")
foreach(_docstarget akgl ${AKGL_DOCS_DEPENDENCY_TARGETS})
string(APPEND AKGL_DOCS_LDFLAGS "$<TARGET_LINKER_FILE:${_docstarget}>\n")
string(APPEND AKGL_DOCS_LDFLAGS "-Wl,-rpath,$<TARGET_FILE_DIR:${_docstarget}>\n")
endforeach()
string(APPEND AKGL_DOCS_LDFLAGS "-lm\n")
file(GENERATE
OUTPUT "${AKGL_DOCS_LDFLAGS_FILE}"
CONTENT "${AKGL_DOCS_LDFLAGS}"
)
# The json kind= validator: a host that brings libakgl up headless and hands the
# block to the loader that really reads that format. Not a test of its own -- it
# answers a question about a document rather than about the library -- and not
# instrumented, so a documentation run cannot flatter the coverage figure.
add_executable(akgl_docs_checkjson tools/docs_checkjson.c)
target_compile_options(akgl_docs_checkjson PRIVATE ${AKGL_WARNING_FLAGS})
target_link_libraries(akgl_docs_checkjson
PRIVATE akstdlib::akstdlib akerror::akerror akgl SDL3::SDL3 jansson::jansson -lm)
if(AKGL_VENDORED_DEPENDENCIES)
set_target_properties(akgl_docs_checkjson PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
endif()
# tools/docs_screenshot.c is deliberately not a target here. It supplies main()
# for a program whose other translation unit is a block of markdown, so it is
# compiled by tools/docs_screenshots.sh together with the listing it is hosting;
# there is nothing for `all` to build.
add_test(
NAME docs_examples
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tests/docs_examples.sh
--root "${CMAKE_CURRENT_SOURCE_DIR}"
--cflags-file "${AKGL_DOCS_CFLAGS_FILE}"
--ldflags-file "${AKGL_DOCS_LDFLAGS_FILE}"
--checkjson $<TARGET_FILE:akgl_docs_checkjson>
)
# Every argument is spelled out rather than assembled from a generator
# expression: an expression that evaluates to nothing still contributes an
# *empty argument*, which the script reads as the first filename. akbasic's
# suite passed having checked no documents at all that way, and was caught only
# because it reports what it ran.
set_tests_properties(docs_examples PROPERTIES
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
TIMEOUT 900
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software"
)
# Every figure in docs/ re-rendered and byte-compared against the tracked copy.
# docs_examples only asks whether the file *exists*, which catches a figure that
# was never generated and not one that stopped being of the code beside it --
# and that second failure is the one this whole arrangement is against.
#
# **A byte comparison of a rendered PNG is a deliberate bet**: that the dummy
# video driver and the software renderer are reproducible run to run and build to
# build. What is untested is an SDL upgrade that shifts one pixel of a diagonal,
# and the answer to that is to regenerate the figures in the same commit as the
# bump -- not to weaken this to a size check, which would pass for every wrong
# picture that happened to be 320x240.
add_test(
NAME docs_screenshots
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/docs_screenshots.sh
--root "${CMAKE_CURRENT_SOURCE_DIR}"
--cflags-file "${AKGL_DOCS_CFLAGS_FILE}"
--ldflags-file "${AKGL_DOCS_LDFLAGS_FILE}"
--check
)
set_tests_properties(docs_screenshots PROPERTIES
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}"
TIMEOUT 900
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software"
)
# Regenerating the figures is a deliberate act, never part of a build: the PNGs
# are tracked, and a rebuild that silently rewrote them would put a binary diff
# in front of anybody who happened to run `make`. Same contract as the
# controllerdb target above, and for the same reason.
if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR)
set(AKGL_DOCS_SCREENSHOTS_TARGET docs_screenshots)
else()
set(AKGL_DOCS_SCREENSHOTS_TARGET akgl_docs_screenshots)
endif()
add_custom_target(${AKGL_DOCS_SCREENSHOTS_TARGET}
COMMAND "${CMAKE_CURRENT_SOURCE_DIR}/tools/docs_screenshots.sh"
--root "${CMAKE_CURRENT_SOURCE_DIR}"
--cflags-file "${AKGL_DOCS_CFLAGS_FILE}"
--ldflags-file "${AKGL_DOCS_LDFLAGS_FILE}"
DEPENDS akgl
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
COMMENT "Regenerating the documentation figures in docs/images"
VERBATIM
)
# The two tutorial games. Guarded because they are written by a later pass and
# an absent directory must not break the configure -- and guarded again inside
# examples/CMakeLists.txt, one game at a time, for the same reason.
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/examples/CMakeLists.txt")
add_subdirectory(examples)
# The figures at the top of chapters 20 and 21 are frames out of the games
# themselves, taken by --screenshot between the world being drawn and the
# frame being presented. Same contract as docs_screenshots above: deliberate,
# never part of a build, because the PNGs are tracked.
#
# There is no --check counterpart. docs_screenshots can compare because its
# figures are one deterministic frame of drawing calls; a game frame is not.
# The sidescroller drives its physics from the wall clock, so which pixel the
# player occupies depends on how fast the machine ran, and a byte comparison
# would fail for reasons that have nothing to do with the documentation.
add_custom_target(docs_game_figures
COMMAND ${CMAKE_COMMAND} -E env
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy
$<TARGET_FILE:sidescroller>
--assets "${CMAKE_CURRENT_SOURCE_DIR}/docs/tutorials/assets/sidescroller"
--autoplay --frames 110
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/sidescroller.png"
--screenshot-frame 100
COMMAND ${CMAKE_COMMAND} -E env
SDL_VIDEODRIVER=dummy SDL_RENDER_DRIVER=software SDL_AUDIODRIVER=dummy
$<TARGET_FILE:jrpg>
--demo --frames 240
--screenshot "${CMAKE_CURRENT_SOURCE_DIR}/docs/images/jrpg.png"
--screenshot-frame 230
DEPENDS sidescroller jrpg
COMMENT "Regenerating the tutorial figures in docs/images"
VERBATIM
)
endif()
# Mutation testing copies the repository to scratch space, applies one small
# source change at a time, and verifies that the passing tests detect it. The
# intentionally failing character test is excluded by the harness.
@@ -574,6 +882,11 @@ install(FILES ${CMAKE_CURRENT_BINARY_DIR}/akgl.pc DESTINATION "lib/pkgconfig/")
install(FILES ${CMAKE_CURRENT_BINARY_DIR}/include/akgl/version.h DESTINATION "include/akgl/")
install(TARGETS akgl DESTINATION "lib/")
install(FILES "deps/semver/semver.h" DESTINATION "include/")
# libccd is compiled into libakgl.so, and its BSD-3 licence requires the notice
# to travel with the binary form. Nothing else in deps/ is linked in statically
# -- SDL, jansson, libakerror and libakstdlib are all separate shared objects
# that ship their own -- so this is the only third-party notice we owe.
install(FILES "deps/libccd/BSD-LICENSE" DESTINATION "share/doc/akgl/" RENAME "BSD-LICENSE.libccd")
foreach(header IN LISTS AKGL_PUBLIC_HEADERS)
install(FILES "include/akgl/${header}.h" DESTINATION "include/akgl/")
endforeach()

View File

@@ -125,10 +125,9 @@ reciprocal.
| `akgl_physics_simulate`, 64 live actors | frame | 1,216.8 | 821,841 |
| `akgl_physics_simulate`, empty pool | frame | 63.9 | 15,650,101 |
| logic frame: 64 updates + simulate | frame | 5,763.1 | 173,517 |
| `akgl_rectangle_points` | call | 4.0 | 248,412,026 |
| `akgl_collide_rectangles`, overlapping | call | 24.9 | 40,181,108 |
| `akgl_collide_rectangles`, disjoint | call | 57.9 | 17,261,906 |
| all-pairs collision sweep, 64 actors (2016 pairs) | sweep | 115,023.6 | 8,694 |
| `akgl_collide_rectangles`, overlapping | call | 6.1 | 164,345,776 |
| `akgl_collide_rectangles`, disjoint | call | 6.1 | 164,423,708 |
| control: all-pairs collision sweep, 64 actors (2016 pairs) | sweep | 11,769.1 | 84,968 |
| `akgl_string_initialize` | call | 32.1 | 31,197,432 |
| `akgl_string_copy`, full length | call | 32.2 | 31,075,004 |
| `json_load_file`, small document | load | 11,338.8 | 88,193 |
@@ -183,7 +182,7 @@ full screen of 16-pixel tiles and 64 actors:
| 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% |
| Collision over 64 actors, all-pairs, as a control | 0.012 ms | 0.07% |
| 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% |
@@ -304,17 +303,106 @@ parsing a small document is 11.3 µs, and the accessors are 14-41 ns each.
naming twenty assets pays 70 µs. Also noise, but worth knowing it is a syscall
and not a string operation.
### The collision helpers are fine; the missing broad phase is the problem
### Collision, measured
`akgl_collide_rectangles` is 24.9 ns when the rectangles overlap (it returns at
the first corner that hits) and 57.9 ns when they do not (all eight corner tests
run). Both are fine.
Added in 0.8.0. Every row below is from one run, on the machine described above.
What the library does not provide is a broad phase, so a caller that wants
collision writes the all-pairs loop: 2016 pairs for 64 actors, 115 µs a frame,
0.7% of a 60 fps budget. That is affordable. It is also O(n²): raise
`AKGL_MAX_HEAP_ACTOR` to 256 and the same loop is 32,640 pairs and 1.9 ms — over
10% of the frame, for a game that has done nothing yet.
| Operation | Unit | ns per unit | per second |
|---|---|---:|---:|
| narrowphase box/box, overlapping | call | 20.6 | 48,485,173 |
| narrowphase box/box, disjoint | call | 9.0 | 111,102,445 |
| narrowphase circle/circle, overlapping | call | 67.8 | 14,752,779 |
| grid `move`, proxy has not left its cells | actor | 11.5 | 86,944,486 |
| grid query, 64 actors | query | 43.2 | 23,155,384 |
| bsp query, 64 actors | query | 113.6 | 8,802,193 |
| tile query, actor standing on a floor | query | 15.6 | 64,142,653 |
| **a whole physics step with collision, 64 actors** | frame | 15,420.1 | 64,851 |
Three of those are worth reading rather than skimming.
**The box fast path is two to seven times cheaper than the general solver**, and
the disjoint case is cheaper still because the proxies' bounds reject it before
any shape arithmetic runs. A tile game is almost entirely box against box, so
9-20 ns describes a real frame and 67.8 is what a circle costs when a game asks
for one.
**The grid beats the tree by 2.6x on the same population.** That is the argument
for which one is the default, measured here rather than cited from somebody
else's engine. The tree also rebuilds whenever a proxy moves and that cost is not
in its row, so the gap in a scene that is actually moving is wider than 2.6x.
**A `move` that changes nothing costs 11.5 ns.** That is the incremental claim in
one number: an actor walking across a tile pays that on most frames and pays the
re-cell only when it crosses a boundary.
#### The whole step, which is the number that matters
**15.4 µs for 64 actors, of which 1.6 µs is the step without collision.** So
collision costs about 13.8 µs a frame at the default pool size — the proxy sync
pass, the broad phase, the narrowphase over whatever it returns, and the response
— which is **0.08% of a 16.67 ms frame**. The all-pairs control over the same
population is 12.1 µs, and it answers a strictly smaller question: it tests pairs
and does not sync, sub-step or resolve anything.
At `-DAKGL_MAX_HEAP_ACTOR=256`, a separate build measured on the same machine:
| Operation | Unit | ns per unit |
|---|---|---:|
| a whole physics step with collision, 256 actors | frame | 54,073.3 |
| all-pairs control over the same 256 actors (32,640 pairs) | sweep | 188,530.6 |
**54.1 µs against Target 12's 2 ms budget**, with the all-pairs control 3.5x more
expensive while still doing less. Population went up 4x and the step went up 3.5x,
which is the grid doing its job: the all-pairs sweep went up 15.6x over the same
range, as `n²` says it must. That measurement is from a `AKGL_BENCH_SCALE=0.3`
run of a purpose-built configuration and is not part of the CI baseline; the 64
row above is.
### Two rows moved, and not because collision is running
`akgl_physics_simulate` over 64 live actors went from 1,216.8 ns to 1,588.5, and
the logic frame moved with it -- **on a backend with no collision world
attached**, so no collision code runs in either. Two things account for it and
neither is the feature doing its job:
- **`akgl_Actor` grew from 415 to 464 bytes**, gaining a 40-byte collision shape,
an override flag and a proxy pointer. The step sweeps the whole pool, so that
is about 3 KB more working set streamed every frame. The empty-pool row is
unchanged at 58.7 ns, which is what says this is per *live* actor rather than
per slot.
- **Reaching the collision check through `CATCH` cost more than the check.**
`PASS` and `CATCH` call `akerr_valid_error_address`, which walks
`AKERR_ARRAY_ERROR`, so a function that early-returns is not free when it is
reached through one. Routing the no-collision path around the machinery
entirely took this row from 2,018.7 ns back to 1,588.5 -- the same lesson
`AGENTS.md` records for writing benchmarks, arriving in production code.
What is left is the struct, and it is paid whether or not a game uses collision.
That is worth knowing rather than smoothing over.
### The overlap test is fine; the all-pairs loop around it was the problem
`akgl_collide_rectangles` is 6.1 ns whether the rectangles overlap or not. It was
24.9 ns overlapping and 57.9 ns disjoint until 0.8.0, when it stopped being eight
corner-containment tests and became four span comparisons -- a change made to fix
the cross case it could not see, with the speed as a side effect rather than the
goal. The disjoint case improved most because it was the one that ran all eight
tests before answering.
**Read that 6.1 as a floor, not a measurement.** It is within this run's
resolution of a function that does almost nothing, and the rows above are not
re-baselined for it; only the rows the change actually moved are.
The cost was never the test. It was the loop a caller had to write around it,
because there was no broad phase: 2016 pairs for 64 actors, **12 µs** a frame,
0.07% of a 60 fps budget. Affordable, and still O(n²) -- at
`AKGL_MAX_HEAP_ACTOR=256` the same loop is 32,640 pairs and **188.5 µs measured**,
1.1% of the frame for a game that has done nothing yet.
**That loop is what 0.8.0 removed the need for.** A whole physics step with a
collision world attached is 54.1 µs at 256 actors, doing strictly more -- see
"Collision, measured" below. The all-pairs row stays as the `control:` it is
labelled, which is what it is measuring now.
### Spawning is cheap, and a third of it is a log line
@@ -325,6 +413,154 @@ reads**, and that is with output going to a sink that throws it away; write it t
a terminal and it is far worse. `akgl_actor_initialize` and
`akgl_character_sprite_add` both log unconditionally at `INFO`.
## How other engines spend the same frame
The natural question after a baseline like this is how it compares to the
engines people actually ship 2D games on. The comparison below is against
Construct (2 and 3) and Phaser (3 and 4), because both publish real
engineering detail: Ashley Gullen's Construct tech blogs and the Phaser docs
and source say what they built, what they rejected, and — most usefully — what
they measured. Godot and GameMaker publish similar batching guidance and add
nothing the other two don't. Sources are at the end of the section; the
construct.net posts are Cloudflare-walled to non-browsers and were verified
through Wayback captures.
One caveat governs the whole comparison, so it goes first. **Every number
these engines publish assumes the pixels are on a GPU.** Construct's ~300 CPU
cycles per sprite and Phaser's 16,384-quad batch ceiling are budgets for
*building command lists*, not for touching pixels. Two things follow:
- Ashley's argument that a frame costs max(logic, render) — because the CPU
builds commands while the GPU rasterizes the previous batch — inverts under
a software renderer. Here the frame really is logic *plus* render, which is
why 97.6% of ours is blits.
- What transfers from their playbooks is the **culling and the caching**:
camera culls, dirty-flagged text textures, collision cells. What does not
transfer is the **batching**, and the control rows above already proved it:
libakgl's per-tile submission overhead is 0.2% of the frame. Batching is a
solution to a problem this library has measured itself not to have — and
SDL's renderer batches internally anyway.
### Tilemaps: they optimize draw calls; ours cost 0.2%
Construct 2 merged runs of identical tiles into rectangles and drew each
rectangle in one call — 120 draw calls down to 22 in their worked example —
and the Construct 3 runtime renders an entire tilemap layer with zero texture
switches, which they measured at 40x over C2 when every visible tile differs.
Phaser culls tiles to the camera every frame (one-tile padding by default),
and Phaser 4 added an opt-in `TilemapGPULayer` that draws a whole layer as a
single shader quad at a fixed cost per screen pixel, capped at 4096x4096
tiles.
All of that machinery exists to cut draw-call submission cost. libakgl's
equivalent cost is the 0.03 ms gap between `akgl_tilemap_draw` and the raw-SDL
control row. The 16.26 ms is rasterization, which none of those techniques
touch — a merged rectangle still fills the same pixels in software. The one
tilemap idea worth keeping in the back pocket is the one Construct *rejected*
in 2013 on simplicity grounds and Phaser shipped in 2026: push the whole layer
to the GPU. That is a backend decision, not a loop optimization, and the
per-tile bookkeeping measured above (~25 ns) is what would remain of the frame
when it happens.
### Sprites: their per-sprite budget and ours are the same order
Construct measured its C3 runtime at roughly 300 CPU cycles to render one
sprite — about 65 ns on this machine's clock — and got there by replacing
polymorphic dispatch with monomorphic code, which they measured as 70-88%
faster on their quad benchmark. Phaser batches 16,384 quads at a time and
multi-texture batching turned 210 sprites across 15 textures from 212 draw
operations into 2. libakgl's actor render bookkeeping is ~250 ns by
subtraction (target 3), through one `draw_texture` function pointer per actor
with an error scope per call. Same order of magnitude, no batch layer, and at
64 actors the whole sweep is 1.1% of the frame. Their lesson that dispatch
indirection is where per-sprite CPU goes is worth remembering if the actor
ceiling ever rises by an order of magnitude; at 64 it is noise.
### Text: everyone else has the cache
Phaser's `Text` object re-rasterizes its canvas and re-uploads the texture
*only when the content or style changes*; its `BitmapText` never pays even
that — their docs state "you don't incur any speed penalty when updating their
content because the underlying texture doesn't change." Construct's Text
plugin documents the same failure mode this suite measured: automatic-
resolution mode "can cause the text to constantly re-render when being
smoothly scaled," and fixed-resolution mode is the documented escape hatch.
Both engines treat re-rasterizing an unchanged string as a defect. libakgl
does it unconditionally, every call, every frame: 12.6 µs a string. The
dirty-flag cache in targets 8 and 9 is not novel engineering; it is the
industry floor.
### Collision: Construct already ran this experiment
Construct's brute-force collision at 1000-vs-1000 objects was about a million
checks per tick and ran at 10 fps on a desktop; their viewport-sized uniform
collision cells cut that to ~40,000 checks — a 96% reduction, six times
faster — with incremental insert/remove as objects move and zero cost for
static objects. They evaluated quadtrees and rejected them as "more
complicated and can be more expensive to update as objects move around."
Phaser went the other way: Arcade Physics keeps dynamic bodies in an RTree
that is *cleared and rebuilt every frame*, and their own docs say a
"conservative estimate of around 5,000 bodies should be considered the max"
before turning the tree off beats keeping it.
libakgl has no broad phase at all, and at the current 64-actor ceiling it
does not need one: the all-pairs sweep is 115 µs, 0.7% of the frame. The
comparison settles *which* broad phase fits when the ceiling rises — the
incremental uniform grid, which is allocation-free at steady state and simple
enough that its authors chose it over the fancier structure, not the
rebuild-every-frame tree. The design is on record in `TODO.md` under
**Performance -> The plan**.
### Memory: stricter than either, and paying for it wrong
Construct budgets memory layout-by-layout — only the current layout's images
are resident, every image is width x height x 4 bytes regardless of source
format, and peak memory is the worst single layout. Their create/destroy
churn is a first-class engine metric (5x faster in the C3 runtime), and their
anti-jank advice — place one instance in the layout so its texture is loaded,
then destroy it at start — is a warm-the-pool idiom. Phaser's pooling is
`Group` objects with `maxSize` as a hard cap, recycling members by toggling
`active`. Both engines converge on what libakgl starts from: fixed budgets
and recycled objects.
The difference is what the budget buys. Their costs scale with what a scene
*uses*; libakgl's scale with what the build *allows* — 28 MB of BSS with 94%
in one worst-case-sized tilemap, 4 KiB string wipes for eleven-byte strings,
pool scans that walk the ceiling rather than the population. The static-pool
discipline is right, and stricter than either engine. Targets 6, 7, 14 and 15
are all the same correction: keep the fixed ceiling, stop paying for it per
operation.
### The frame split: the same shape as 2012
Ashley wrote in 2012 that "it's not unusual for even a fairly complex game to
spend 10% of the time on the logic, and 90% of the time on rendering — even
when hardware accelerated!" That is a stated profiling experience from the
canvas era, with no published methodology, and it is cited here as exactly
that and nothing more; Phaser publishes no frame-composition profile at all,
and no official Phaser bunnymark number exists to borrow (third-party figures
float around; none are cited here). libakgl's measured 97.6% blits against
under 1% bookkeeping is the same shape, more extreme, because software
rasterization is slower than a GPU and the logic is C rather than JavaScript.
Both lead to the conclusion already in this document: the pixels are the
whole frame, and the per-operation bookkeeping numbers are what matter the
day a GPU backend makes the pixels cheap.
### Sources
- Ashley Gullen, [How the Construct 2 WebGL renderer works](https://www.construct.net/en/blogs/ashleys-blog-2/construct-webgl-renderer-works-917) (2014)
- Ashley Gullen, [Tech blog: Tilemap tidbits](https://www.construct.net/en/blogs/ashleys-blog-2/tech-blog-tilemap-tidbits-913) (2013)
- Ashley Gullen, [Collision cell optimisation in r155](https://www.construct.net/en/blogs/ashleys-blog-2/collision-cell-optimisation-914) (2013)
- Ashley Gullen, [How render cells work](https://www.construct.net/en/blogs/ashleys-blog-2/render-cells-work-921) (2014)
- Ashley Gullen, [Optimisation: don't waste your time](https://www.construct.net/en/blogs/construct-official-blog-1/optimisation-dont-waste-time-768) (2012)
- Scirra, [Announcing the Construct 3 runtime](https://www.construct.net/en/blogs/construct-official-blog-1/announcing-construct-runtime-904) (2018)
- Construct 3 manual: [Performance tips](https://www.construct.net/en/make-games/manuals/construct-3/tips-and-guides/performance-tips), [Memory usage](https://www.construct.net/en/make-games/manuals/construct-3/tips-and-guides/memory-usage), [Text](https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/text), [Sprite Font](https://www.construct.net/en/make-games/manuals/construct-3/plugin-reference/sprite-font)
- Phaser, [Phaser 4 rendering concepts](https://phaser.io/tutorials/phaser-4-rendering-concepts)
- Phaser, [How to render thousands of sprites in Phaser 4](https://phaser.io/news/2026/05/phaser4-spritegpulayer-performance)
- Phaser, [Advanced rendering tutorial, part 2: multi-texture batching](https://phaser.io/tutorials/advanced-rendering-tutorial/part2)
- Phaser docs: [Text](https://docs.phaser.io/phaser/concepts/gameobjects/text), [BitmapText](https://docs.phaser.io/phaser/concepts/gameobjects/bitmap-text), [Group](https://docs.phaser.io/phaser/concepts/gameobjects/group)
- Phaser source: [physics/arcade/World.js](https://github.com/phaserjs/phaser/blob/master/src/physics/arcade/World.js), [tilemaps/components/CullTiles.js](https://github.com/phaserjs/phaser/blob/master/src/tilemaps/components/CullTiles.js), [core/Config.js](https://github.com/phaserjs/phaser/blob/master/src/core/Config.js)
## Defects these tests found
Six, and they are filed where defects live: `TODO.md`, under **Performance ->

343
README.md
View File

@@ -1,274 +1,71 @@
## How do I initialize a game
# libakgl
Initialize the global game object with info about your game
A C library for building 2D games on SDL3. Not an engine: 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 the
caller cannot silently ignore.
```c
strncpy((char *)&game.name, "sdl3-gametest", 256);
strncpy((char *)&game.version, "0.0.1", 32);
strncpy((char *)&game.uri, "net.aklabs.games.sdl3-gametest", 256);
## The manual
**[`docs/`](docs/README.md) is the manual** — twenty-one chapters covering every subsystem,
plus two complete tutorial games. Start at
[Chapter 3, Getting started](docs/03-getting-started.md) if you want a window on screen, or
[Chapter 2, Design philosophy](docs/02-design-philosophy.md) if you want to know why the
library is shaped the way it is.
The two tutorials build real, running programs that live under [`examples/`](examples):
| | |
|---|---|
| [A 2D sidescroller](docs/20-tutorial-sidescroller.md) | Gravity, a jump, collision you write yourself, coins and hazards |
| [A top-down JRPG](docs/21-tutorial-jrpg.md) | A town map, NPCs spawned from map objects, four-way animation, a text box, a follower |
For per-function reference, build the Doxygen output with `doxygen Doxyfile`; it is a CI
gate, so an undocumented symbol fails the build.
This README covers the development process only — hooks, mutation testing, benchmarks and
memory checking. Everything about *using* the library is in `docs/`.
> **The task-oriented FAQ that used to live here has moved into `docs/`, corrected.** It was
> not merely incomplete: its examples did not compile. Two unbalanced `PASS()` calls, a
> `sprite->frameids = [0, 1, 2, 3];` that is not C in any dialect, a stray `9` inside a
> bitmask expression, an `int screenwidth = NULL`, and — in the first snippet a reader ever
> saw — the exact `strncpy` call `AGENTS.md` forbids. The prose had drifted with it: its
> claim that the engine "ONLY supports TilED TMJ tilemaps with tileset external references"
> is backwards, and the loader accepts only *embedded* tilesets. Writing the manual turned
> up twenty-seven such claims across the headers. Every example in `docs/` is now compiled,
> linked, run or matched against the source tree by `ctest`, so that class of drift fails a
> build instead of reaching a reader.
## Documentation examples
Every fenced example in `docs/` carries an info string saying what it is, and the
`docs_examples` test acts on it: `c` blocks are compiled, `c run=` blocks are linked against
the library and executed headless with their output compared byte for byte, `c excerpt=`
blocks must still appear verbatim in the file they quote, `json` blocks are loaded through
the real `akgl_*_load_json`, and `c screenshot=` blocks generate the figures in
`docs/images/`. A block with no info string is a hard error, because the failure mode the
harness exists to prevent is passing while checking nothing.
```sh
ctest --test-dir build -R docs_examples --output-on-failure
ctest --test-dir build -R docs_screenshots --output-on-failure
cmake --build build --target docs_screenshots # regenerate the figures
```
Call the game initialization routines and lock the game state for further initialization
While editing one chapter, run it directly rather than the whole suite:
```c
PASS(e, akgl_game_init());
PASS(e, akgl_game_state_lock());
```
If you have a registry properties file, load it. If you don't have a properties file, use `akgl_set_property("prop_name", "prop_value")` to populate the required game properties.
```c
PASS(e, akgl_registry_load_properties(YOUR_REGISTR_FILEPATH));
```
Initialize your physics engine and renderer of choice
```c
PASS(e, akgl_render_2d_init(akgl_renderer));
PASS(e, akgl_physics_init_arcade(akgl_physics));
```
Unlock the game state
```c
PASS(e, akgl_game_state_unlock());
```
## What is in a properties file (or, What properties must I set if I don't have one?)
```json
{
"properties": {
"game.screenwidth": "640",
"game.screenheight": "480",
"physics.gravity.y": "1024.0",
"physics.drag.y": "1.0"
}
}
```
Physics properties (gravity and drag along X, Y and Z) are optional and default to `0`.
## How do I update and render the game world in my main loop
In your game loop (or in your `SDL_AppIterate` method), lock the game state, call the game update function, and then unlock the game state
```c
PASS(e, akgl_game_state_lock());
PASS(e, akgl_renderer->frame_start(akgl_renderer));
SDL_RenderClear(akgl_renderer->sdl_renderer);
PASS(e, akgl_game_update(NULL));
PASS(e, akgl_renderer->frame_end(akgl_renderer));
PASS(e, akgl_game_state_unlock());
```
## How do I get an actor on screen
Load a sprite for a character. Sprites are JSON documents describing 2D sprites, frames, and looping. Sprites are named, and sprite names must be unique.
```c
PASS(e, akgl_sprite_load_json(SOME_FILENAME))
```
Load a character from a JSON file. Characters map sprites to actor states and define physics characteristics like movement speed. Each character is named, and character names must be unique.
```c
PASS(e, akgl_character_load_json(SOME_FILENAME))
```
You don't strictly have to load sprites and characters from json files, you can initialize them yourself, it's just tedious work. Here's an example of initializing a 32x32 sprite from a spritesheet that uses the first 4 frames in a looping animation.
```c
akgl_SpriteSheet *sheet;
akgl_Sprite *sprite;
akgl_Character *character;
PASS(e, akgl_heap_next_spritesheet(&sheet);
PASS(e, akgl_spritesheet_initialize(sheet, 32, 32, IMAGE_FILENAME));
PASS(e, akgl_heap_next_sprite(&sprite));
PASS(e, akgl_sprite_initialize(sprite, SPRITE_NAME, &sheet);
sprite->frames = 4;
sprite->frameids = [0, 1, 2, 3];
sprite->width = 32;
sprite->height = 32;
sprite->speed = 1000;
sprite->loop = true;
strncpy((char *)&sprite->name, "SPRITE NAME", AKGL_SPRITE_MAX_NAME_LENGTH);
PASS(e, akgl_heap_next_character(&character));
PASS(e, akgl_character_initialize(&character, "CHAR NAME"));
PASS(e, akgl_character_sprite_add(&character, &sprite, STATE_MASK));
// Set the character acceleration and scale if desired
character->ax = 64.0;
character->ay = 64.0;
character->sx = 2.0;
character->sy = 2.0;
```
Initialize an actor. Actors are named ("player", "Quest NPC", whatever) and names must be unique.
```c
akgl_Actor *myactor = NULL;
PASS(e, akgl_heap_next_actor(&myactor);
PASS(e, akgl_actor_initialize(&myactor, "ACTOR_NAME"));
```
Assign a character to the actor by looking up the `akgl_Character` from the AKGL registry and assign it.
```c
myactor->basechar = SDL_GetPointerProperty(
AKGL_REGISTRY_CHARACTER,
"CHARACTER_NAME",
NULL);
FAIL_ZERO_BREAK(e, myactor->basechar, AKERR_REGISTRY, "Character missing");
```
Give the actor a position and a state, and turn it visible.
```c
myactor->state = 9AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT);
myactor->x = 320;
myactor->y = 240;
myactor->visible = true;
```
## What are in Sprite and Character files
Sprite files:
```json
{
"spritesheet": {
"filename": "RELATIVE_IMAGE_FILE_REFERENCE",
"frame_width": int,
"frame_height": int
},
"name": "UNIQUE_SPRITE_NAME",
"width": int,
"height": int,
"speed": int,
"loop": boolean,
"loopReverse": boolean,
"frames": [
int
]
}
```
* `frames` references the frame indexes in the spritesheet that should be used for this animation. Spritesheets are counted from the top left corner going to the right according to the spritesheet `frame_width` and `frame_height`.
* `loop` says whether or not we should loop the animation
* `loopReverse` says whether or not we should "bounce" the animation (when we reach the end of the frames, start counting back to the beginning, then count to the end, etc). Otherwise the frames are displayed from 0..n and then cycles back to 0.
* `speed` is the number of milliseconds each frame in the animation should appear on the screen
Character files:
```c
{
"name": "UNIQUE_CHARACTER_NAME",
"speedtime": 8,
"speed_x": 0,
"speed_y": 0,
"acceleration_x": 0,
"acceleration_y": 0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "menupointer"
}[, ...]
]
}
```
* `speedtime` appears to be legacy and unused.
* `speed_[xy]` and `acceleration_[xy]` are physics parameters that specify the top speed and acceleration rate (in pixels per nanosecond) of the character in physics simulations. The effect of acceleration depends on the physics simulation being used at the time (which may or may not account for gravity, drag, etc).
* `sprite_mappings` map a set of actor state flag bitmasks (assume everything in `state` is `OR`ed together) to a sprite name. The game engine uses this to automatically pick the correct sprite (by name) for a given set of state flags. You need one of these for every possible state the character may be used in.
## How do I load a tilemap from the filesystem and display it on screen with my actors
The engine ONLY supports TilED TMJ tilemaps with tileset external references. Load a tilemap into the global `akgl_Tilemap *gamemap` object.
```c
PASS(e, akgl_tilemap_load(PATHSTRING, akgl_gamemap));
```
Actors will be automatically populated from objects in the tilemap object layers. Actor state flags here must be expressed as an integer, you can't (yet) use the same array of strings that is used in character json files.
```json
"objects":[
{
"gid":147,
"height":16,
"id":1,
"name":"player",
"properties":[
{
"name":"character",
"type":"string",
"value":"little guy"
},
{
"name":"state",
"type":"int",
"value":24
}],
"rotation":0,
"type":"actor",
"visible":true,
"width":16,
"x":440.510088317656,
"y":140.347239175702
}[, ... ]
```
Check if the tilemap wants to use its own physics, and if you want to allow that, override the global physics simulation
```c
if ( akgl_gamemap->use_own_physics == true ) {
akgl_physics = &akgl_gamemap->physics;
}
```
Tilemap physics specification follows. A map can specify its own physics properties (drag, gravity) without specifying a custom model.
```json
"properties":[
{
"name":"physics.drag.y",
"type":"float",
"value":0
},
{
"name":"physics.gravity.y",
"type":"float",
"value":0
},
{
"name":"physics.model",
"type":"string",
"value":"arcade"
}],
```
The global `akgl_gamemap` object is automatically displayed if it is populated. Actors are drawn at the appropriate map layer depending on the actor's `layer` property (warning: this may be replaced with a `z` property soon.)
## How do I get the screen width and height
The most direct is to call `SDL_GetCurrentDisplayMode` to get the parameters from the returned `SDL_DisplayMode` structure (`->w` and `->h`).
The simplest way is to check the global `akgl_camera` object's `akgl_camera->w` and `akgl_camera->h` object. You may have more than one camera on a scene, and it's theoretically possible that the global camera object has been overriden and no longer represents the full screen.
The most reliable engine-centric way is to use `akgl_get_property` to get the property from the engine. Properties are read and stored as strings, so if you need to do these kinds of things a lot, cache the integer value somewhere.
```c
akgl_String *width = NULL;
int screenwidth = NULL;
PASS(e, akgl_get_property("game.screenwidth", &width, "0"));
PASS(e, aksl_atoi(width->data, &screenwidth));
PASS(e, akgl_heap_release_string(width));
```sh
./tests/docs_examples.sh --root . \
--cflags-file build/docs_cflags.txt \
--ldflags-file build/docs_ldflags.txt \
--checkjson build/akgl_docs_checkjson \
docs/14-physics.md
```
`docs/MAINTENANCE.md` is the reference for the info strings, the preludes and the fixtures.
There is deliberately no docs-path filter in CI: documentation goes stale because the *code*
moved, not because somebody edited a chapter.
## Git hooks
@@ -402,3 +199,21 @@ scripts/memcheck.sh -LE perf # skip the benchmarks
The run forces `SDL_VIDEO_DRIVER=dummy`, `SDL_RENDER_DRIVER=software` and `SDL_AUDIO_DRIVER=dummy` so the vendor GPU stack is never loaded — that removes thousands of unfixable findings inside the driver without suppressing anything. Definite leaks, invalid accesses and uninitialised reads are counted and set the exit status; "still reachable" is not, because that is what SDL and FreeType keep for the process lifetime. Suppressions for genuine third-party findings are in `scripts/valgrind.supp`.
What it found the first time it ran is in `TODO.md` under **Memory checking**.
## What is compiled in, and the notice it carries
**[libccd](https://github.com/danfis/libccd) is compiled into `libakgl.so`.** It is the
collision narrowphase -- GJK, EPA and MPR, the same code vendored in ODE, FCL and Bullet --
and it is **BSD-3-Clause**. Its licence is `deps/libccd/BSD-LICENSE`, and `cmake --install`
puts a copy at `share/doc/akgl/BSD-LICENSE.libccd`. If you redistribute a binary built from
this tree, that notice has to travel with it.
It is the only dependency in that position. SDL3, SDL3_image, SDL3_mixer, SDL3_ttf, jansson,
libakerror and libakstdlib are all linked as separate shared objects and carry their own
notices; `deps/semver` is a single header, MIT, installed alongside ours; and `deps/tg` is
vendored but **compiled into nothing** -- see `TODO.md`, "tg is vendored and has no
consumer".
libccd is given a static arena rather than the allocator it ships with, so "libakgl does not
call `malloc` at runtime" stays literally true with it linked in. That is
`src/collision_arena.c`, and [Chapter 15](docs/15-collision.md) explains why.

696
TODO.md
View File

@@ -358,22 +358,25 @@ below.**
so `akgl_physics_init_arcade` and `akgl_render_2d_init` quietly ignored
their configuration. `tests/registry.c` sets a property and reads it back.
37. **Redundant casts obscure the code.** **Still open, and deliberately so.**
Roughly 180 pointer casts across `src/`, a large majority no-ops --
`(json_t *)json` where `json` is already `json_t *`, `(char *)&obj->name` on
an array that decays anyway -- densest in `src/tilemap.c` and
`src/character.c`.
37. **Redundant casts obscure the code.** **Still open -- but the precondition
it named is now met.** Roughly 180 pointer casts across `src/`, a large
majority no-ops, densest in `src/tilemap.c` and `src/character.c`.
The argument for fixing it is real: a no-op cast suppresses exactly the
conversion warning that would catch a mismatch. The argument for not doing
it *here* is that it is the largest and least mechanical change on this
list, it touches almost every line of the two files with the most
outstanding functional defects, and the benefit only arrives once the build
actually turns those warnings on -- which it does not today. Doing this
without `-Wall -Wextra` on the library target buys nothing but churn.
This item used to say the benefit only arrives once the build turns on the
warnings those casts suppress, and that doing it first buys nothing but
churn. `-Wall` is on now (see `AGENTS.md`, "Compiler warnings"), so the
sweep can proceed as its own commit.
**Do them together**, in that order: enable the warnings, see what they say,
then remove the casts that were hiding something and the ones that were not.
**The argument turned out to be right, with evidence.** Turning `-Wall` on
found three genuine signedness mismatches in `src/sprite.c`: `obj->width`,
`obj->height` and `obj->speed` are `uint32_t` and were being passed straight
to `akgl_get_json_integer_value(..., int *)`. A cast would have silenced all
three. They are fixed by reading into an `int`, range-checking, and
assigning -- which also turned up that `speed` is scaled by 1,000,000 into a
32-bit field, so anything past 4294 ms overflowed rather than being held.
When doing the sweep: remove a cast, rebuild, and read what the compiler
says. A cast that was load-bearing will say so.
38. **`struct`-qualified parameters in two definitions.** `akgl_physics_null_move`
and `akgl_physics_arcade_move` use the typedef like the other eight.
@@ -409,20 +412,25 @@ the symbol collision described in item 4 — and exited 0 because that status wa
enough that `TODO.md` recorded the suite as passing and wondered which change
had fixed it. Nothing had; it had stopped running.
**Fixed** in `tests/testutil.h`: `TEST_TRAP_UNHANDLED_ERRORS()` installs a
handler that collapses any status a byte cannot carry onto 1, and every suite's
`main()` calls it immediately after `akgl_error_init()`. `tests/error.c` calls
it after its first test instead, because that suite has no `akgl_error_init()`
in `main()` and libakerror's lazy `akerr_init()` would overwrite the handler.
**Fixed twice.** 0.5.0 worked around it here, with a
`TEST_TRAP_UNHANDLED_ERRORS()` in `tests/testutil.h` that installed a handler
collapsing any status a byte cannot carry onto 1. Once installed, no other suite
changed colour, so this was not masking anything beyond `character` — but it
could have been at any time, and nothing would have said so.
Once installed, no other suite changed colour, so this was not masking anything
beyond `character` — but it could have been at any time, and nothing would have
said so.
That entry ended "any consumer's test suites have this problem; that is worth
raising upstream", and it was. **libakerror 2.0.1 fixes it at the source**:
`akerr_exit()` owns the status-to-exit-code mapping and the default handler
calls it, so 0 exits 0, 1 through 255 exit themselves, and anything else exits
`AKERR_EXIT_STATUS_UNREPRESENTABLE` (125) rather than a low byte that is either
a lie or a claim of success. There is no wider `exit()` to reach for —
`_exit()`, `_Exit()`, `quick_exit()` and the raw `exit_group` syscall all
truncate the same way, and even `waitid()` reports the truncated value.
Worth knowing: the fix belongs here rather than in libakerror only because
`exit(status)` is a reasonable thing for a library to do when statuses fit in a
byte. libakerror's own band does. Consumers' bands start at 256 by construction,
so any consumer's test suites have this problem. That is worth raising upstream.
The workaround is gone as of 0.6.0, along with its 21 call sites. Verified by
putting the original failure back: a `FAIL_BREAK(AKGL_ERR_SDL)` in
`tests/character.c`'s `main` now exits 125 and CTest records a failure, where it
exited 0 and passed before.
## Coverage status
@@ -1171,7 +1179,7 @@ fit in 5% of a frame, leaving 95% for the pixels and the game's own logic. At
| 9 | Zero texture creation or destruction per frame in steady state | one create + one destroy per line of text per frame | **missed** |
| 10 | A handled, routine condition costs no more than twice the path that succeeds | 616.5 ns vs 68.4 ns — 9x | **missed** |
| 11 | 256 actors simulated, updated and made ready to draw in under 1 ms | ~22 µs at 64 actors (5.8 µs measured logic + estimated render bookkeeping); linear, so ~90 µs extrapolated | **met, untested at that size** |
| 12 | Collision for 256 actors under 2 ms without the caller writing a broad phase | no broad phase exists; the naive loop is 1.9 ms at 256 actors | **missed** |
| 12 | Collision for 256 actors under 2 ms without the caller writing a broad phase | 54.1 µs for a whole physics step with collision at `-DAKGL_MAX_HEAP_ACTOR=256` | **met** |
| 13 | Level load under 100 ms for a map with 8 tilesets, 4 layers and 64 actors | 11.9 ms for a 2x2 map with one tileset | **unknown at that size** |
| 14 | Fixed per-load overhead under 1% of a level load | 11.5% — zeroing 26 MB of `akgl_Tilemap` | **missed** |
| 15 | Static footprint under 4 MB in the default configuration | 28 MB, 94% of it one tilemap | **missed** |
@@ -1230,6 +1238,132 @@ Notes on the ones worth arguing about:
layers, several tilesets — would make target 13 measurable and would probably
find something.
### The plan
The order below is the order the work should happen in, set by blast radius
per frame and per byte, not by how interesting the change is. Items 1 through
6 are the next changes; 7 through 9 are on record so the decision that
unblocks each is made with the numbers in front of it. The engine comparison
that informs several of these is in `PERFORMANCE.md` under **How other
engines spend the same frame**.
1. **String pool free-list index — targets 6 and 7, one change.** A next-free
hint per pool in `src/heap.c` (the five scans are `src/heap.c:52-116`)
takes the last-slot claim from 250.9 ns to constant time, and it lands
before anyone raises `AKGL_MAX_HEAP_*`, because today's cost grows with the
ceiling rather than the usage. Fold in item 8 under **Known and still
open** — `akgl_heap_next_string` is the only acquire that takes a
reference, and a free-list change touches every acquire anyway. Make the
release wipe (`src/heap.c:211`) proportional to bytes used: either `memset`
through `strnlen(data, AKGL_MAX_STRING_LENGTH) + 1` or a tracked length
field — and the length-field option must respect the layout history in
item 6, which put two real bugs on the bytes after `data`. Budgets to
move: `tests/perf.c:317`, `:333`, `:347`; re-record the three rows in
`PERFORMANCE.md` in the same commit.
2. **Text texture ring cache — targets 8 and 9, one cache.** A static
four-to-eight entry ring in `src/text.c` keyed on (`TTF_Font *`, text
bytes, `SDL_Color`, wraplength) — `x`/`y` stay out of the key, position
only affects `dest`. Invalidate in `akgl_text_unloadfont`
(`src/text.c:40-55`) and `akgl_text_unloadallfonts` (`:76-87`): a cached
texture keyed on a closed font whose address was reused is a stale hit, so
unload must sweep the ring. Verification is a counting test, not a
stopwatch — target 9 is *zero* texture create/destroy per steady-state
frame, so count them through a stub; plus a new perf row ("rendertextat,
cached") with the raw-SDL control row the perf rules require. Budget to
move: `tests/perf_render.c:391`.
3. **Non-raising sprite lookup — target 10, a design change.** Give
`akgl_character_sprite_get` (`src/character.c:84-94`) the companion that
returns `NULL` without raising, then convert the three sites that raise
and handle `AKERR_KEY` for a routine condition: `akgl_actor_update`
(`src/actor.c:165`, handled `:172`), `actor_visible` (`:209`, handled
`:212-216`) and `akgl_actor_render` (`:241`, handled `:245-249`). While
there, stop `akgl_actor_render` looking the sprite up twice on the success
path (`:241` and again inside `actor_visible` at `:242`). Takes the
sprite-less update from 616.5 ns toward the twice-success ceiling. Budget
to move: `tests/perf.c:588`.
4. **Demote the hot-path log lines.** `akgl_actor_initialize` logs every
spawn (`src/actor.c:51`) and `akgl_character_sprite_add` every binding
(`src/character.c:80`); 20% of a spawn is formatting a line nobody reads,
and that is with the output thrown away. Demote them (and
`src/character.c:285`, `src/renderer.c:34`, `src/tilemap.c:660`) from
`SDL_Log` to `SDL_LogDebug` — SDL checks priority before formatting, so
the cost disappears at default priority and the messages survive for
anyone who turns debug on. The spawn benchmark pair
(`tests/perf.c:410`, `:429`) already measures exactly this gap.
5. **Fix the tileset scan, and the two bugs hiding in it.** The per-tile scan
in `akgl_tilemap_draw` (`src/tilemap.c:756-798`) is the known
O(tiles x tilesets) FIXME, worth 0.8% of the frame at eight tilesets — but
it is also wrong twice. The range test uses `>=` on the upper bound
(`firstgid + tilecount >= tilenum`, `src/tilemap.c:760-761`), so the tile
one past the end of set A also matches set B's `firstgid`; and there is no
`break` after a match, so a tile matching two ranges is blitted twice.
Correct the test to `tilenum < firstgid + tilecount`, `break` on match,
and skip `tilenum == 0` before scanning at all. Verify with a counting
test — a stub `draw_texture` backend asserting exactly one blit per
non-empty visible cell, same pattern as the `tests/game.c` update counter —
because a boundary double-blit is invisible to a stopwatch and mostly
invisible on screen. File alongside, same entry: `start_x`/`start_y` are
never clamped to zero (`src/tilemap.c:719-728`), and the first-column
`src.x +=` accumulates across rows (`:766`). Mutation testing is
mandatory here; this is control flow. Do not reorganize the loader for
this — 0.8% does not justify it.
6. **Bound the draw_world layer loop.** `akgl_render_2d_draw_world`
(`src/renderer.c:150-167`) walks all `AKGL_TILEMAP_MAX_LAYERS` and rescans
all 64 actor slots per layer — 1024 refcount checks a frame for a
one-layer map. Bound the walk by `akgl_gamemap->numlayers` and build the
per-layer actor lists in one pool pass into static index arrays.
Invisible at 60 fps under the software renderer; measurable on a 2 ms GPU
frame. Counting test, same as item 5.
7. **Target 12: done in 0.8.0, and the prediction held.** The structure is
the incremental uniform grid, not the tree — cells keyed on tile size,
static arrays, insert/remove as actors move, wired into the `collide` slot
of `akgl_PhysicsBackend` that the simulation loop now calls. Construct
measured that design at a 96% check reduction over brute force and rejected
quadtrees as costlier to maintain; Phaser's rebuild-the-RTree-every-frame
approach carries a documented ~5,000-body ceiling and is the counterexample.
Citations stay in `PERFORMANCE.md`.
Measured here rather than cited: **the grid beats the BSP by 2.6x on the
same 64-proxy population** (44.3 ns against 116.1 ns per query), before
counting that the tree rebuilds whenever a proxy moves and the grid's `move`
is 11.9 ns when the cell rectangle has not changed. A whole physics step
with collision is 15.4 µs at 64 actors and 54.1 µs at 256, against a 2 ms
target. Over that 4x range the step grew 3.5x and the all-pairs control grew
15.6x, which is the `n²` the index exists to avoid.
**The BSP ships anyway.** A vtable with one implementation behind it has
never been asked to be a vtable; `tests/partition.c` runs its whole contract
against a table of both, so the seam is proven rather than asserted. It
would earn its place in a world with wildly non-uniform object sizes or one
larger than the grid's 128x128 cell array covers.
8. **Target 13 needs the fixture before it needs anything else.** A
128x128, four-layer, multi-tileset JSON map fixture, so
`akgl_tilemap_load` is measured on a map rather than on a PNG decode and a
`memset`. Today the loader benchmark uses the 2x2 fixture while
`perf_render` synthesizes its 128x128 map in-process
(`tests/perf_render.c:141-175`) — the draw path is measured at realistic
size and the load path is not. It would probably find something.
9. **Targets 14 and 15: the footprint refactor, sketched for when it is
scheduled.** One shared cell arena sized
`AKGL_TILEMAP_MAX_WIDTH * AKGL_TILEMAP_MAX_HEIGHT` once instead of
per-layer, and tileset offset tables sized by `tilecount` instead of by
`AKGL_TILEMAP_MAX_TILES_PER_IMAGE`. One correction to the record: the
FIXME at `include/akgl/tilemap.h:113-128` worries about wasted *leading*
entries per tileset, but `akgl_tilemap_compute_tileset_offsets`
(`src/tilemap.c:179-231`) indexes by local id from zero — the table is
merely oversized, not sparse. The refactor kills most of the 1.37 ms
`memset` in `akgl_tilemap_load` (`src/tilemap.c:615`) and takes BSS from
28 MB toward the 4 MB target. Real ABI break; belongs to a minor release,
as the note above already says.
## Memory checking
`cmake --build build --target memcheck` runs **the suites that already exist**
@@ -1858,6 +1992,158 @@ this library and told it is compatible.
outlived its own press, the code-point-boundary truncation, and both NULL arguments.
`src/controller.c` holds at 91%.
## Truncated registry keys can collide
Found while turning `-Wall` on. `akgl_actor_initialize` and
`akgl_character_initialize` document their name fields as "Truncated, not
rejected, if the source name is longer", and that is what they do -- the copy is
`aksl_strncpy` bounded to the field, so it always terminates now.
Termination was the overread half, and it is fixed. The other half is not: the
truncated name **is the registry key**. Two distinct 200-character names
truncate to the same 127-byte key, and the second `SDL_SetPointerProperty`
silently replaces the first. The objects are different; the registry cannot tell.
The same applies to `akgl_Sprite::name` (128), `akgl_SpriteSheet::name` (512)
and the tilemap's object and tileset names.
Whether that matters depends on whether long asset names are realistic, and 127
bytes is generous for a hand-written name in a JSON file. Recording it rather
than fixing it, because the fix is a contract change -- refuse an over-long name
with `AKERR_OUTOFBOUNDS` instead of truncating -- and every one of those headers
currently promises the opposite. `aksl_strncpy` already reports exactly that
status when the bytes do not fit, so the change is to stop capping `n` at
`size - 1` and let it raise.
## akgl.pc names no dependencies
`akgl.pc.in` carries `Libs: -L${libdir} -lakgl` and no `Requires:` or
`Requires.private:` line, so a consumer that finds libakgl through pkg-config is
told nothing about libakerror, libakstdlib, SDL3, SDL3_image, SDL3_mixer,
SDL3_ttf or jansson.
That has always been wrong, and libakerror 2.x makes it sharper: `akerror.h` is
part of libakgl's *public* interface -- `IGNORE` and the `FAIL_*` macros expand
in the consumer's own translation units and reference `__akerr_last_ignored`,
which is thread-local as of 2.0.0. A consumer that builds against whatever
`akerror.h` happens to be on its include path and links whatever `libakerror.so`
the loader finds can get a mismatch that pkg-config had every opportunity to
prevent and did not mention.
The fix is a `Requires:` line for the libraries whose headers libakgl's headers
include (akerror, akstdlib, SDL3) and `Requires.private:` for the rest, plus a
version floor of `akerror >= 2.0.1`. Not done here because it changes what
`pkg-config --libs akgl` emits for every existing consumer, and that wants to be
its own commit with an install-tree test behind it. The CMake path is not
affected -- `akglConfig.cmake` re-finds the targets.
## libakstdlib wrappers not yet adopted
`libakgl` links `libakstdlib` and uses ten of its wrappers. It calls the raw
libc function at a good many more sites. Adopting the four that were carrying a
real defect is done (see the 0.6.0 commit); this is what is left, and the reason
each is left.
**Adopted, for the record:** `aksl_fclose` (an unchecked close in
`akgl_game_save` lost buffered data silently), `aksl_fgetc` (`fgetc(3)` spells
EOF and a read error the same way), `aksl_snprintf` at two sites that were
truncating under a silenced `-Wformat-truncation`, and `aksl_strncpy` at the two
`src/game.c` sites the fixed-width copy sweep missed.
### The pure-arithmetic wrappers are deliberately not adopted
`memset` (38 sites), `strlen` (14), `strcmp` (9), `strncmp` (9), `memcpy` (6),
`memcmp` (2). Every one of these can only fail on a NULL argument, and at all
but a handful of those sites the argument is a stack object whose address cannot
be NULL. `aksl_memset` and `aksl_memcpy` are used twice each, which is the
inconsistency worth naming: there is no rule distinguishing those two sites from
the other 44.
Converting them costs an `errctx` check per line and buys a NULL check the
compiler already knows is redundant. Not converting them leaves the file mixing
two spellings of the same operation. **Pick one and write it down** -- the
recommendation is to convert only where the argument is a parameter or a heap
pointer, and say so in `AGENTS.md`, rather than either extreme.
### `DISABLE_GCC_WARNING_FORMAT_TRUNCATION` is now dead
`include/akgl/error.h` exports it and `RESTORE_GCC_WARNINGS`, and after the
`aksl_snprintf` conversion nothing in the tree uses either. They are public
macros, so removing them is an API change and is not done here. Both suppressed
sites turned out to be real truncations, which is the argument for deleting
rather than keeping them: the two times this library silenced that warning, the
compiler was right.
### No wrapper is used for the collections
`aksl_HashMap`, `aksl_List` and `aksl_TreeNode` exist and libakgl uses SDL
property sets for every registry instead. That is not an oversight -- the
registries hand `SDL_PropertiesID` values to callers and SDL owns the lifetime.
Recorded so nobody re-derives it.
## Arcade physics feel
Found by building `tests/physics_sim.c`, which runs the arcade backend as a game
would -- a Mario-esque jump and fall, Zelda-style top-down movement, and a
fast run reversed at speed -- and prints what the actor actually did. Three
defects it found are fixed in 0.6.0 (an unbounded first step, release handlers
zeroing the gravity accumulator, and per-axis thrust caps composing to a 141%
diagonal). These are what it found and did **not** fix.
### No terminal velocity
`akgl_physics_arcade_gravity` adds `gravity_y * dt` to `ey` every step and
nothing bounds it. `sy` caps *thrust*, deliberately -- it is the character's own
top speed, not a speed limit on the world -- so a fall accelerates without limit
for as long as it lasts. The jump simulation reaches 560 px/s in 0.7 s and would
keep going.
Drag is the mechanism that exists for this: `physics.drag.y` makes `ey`
approach `gravity_y / drag_y` instead of diverging. So the behaviour is
reachable, it just is not the default and is not documented as the way to get a
terminal velocity. Either document it that way or add an explicit
`physics.terminal_velocity`. Touches `src/physics.c` and
`include/akgl/physics.h`; no ABI change if it is a property rather than a field.
### Releasing a direction stops the actor dead
`akgl_actor_cmhf_*_off` sets `tx` (or `ty`) to zero, so a character running at
full speed stops within one frame of the key coming up -- the top-down
simulation measures 0.0 px of drift in the second after release. That is correct
for Zelda and wrong for Mario, who slides. There is no deceleration or ground
friction anywhere in the arcade backend; `ax` is the only rate, and it applies
only while a direction is held.
The shape that fits the existing model is a per-character deceleration that
decays `tx` toward zero over several frames instead of assigning zero, with the
current behaviour as the value that means "instant". That is a new
`akgl_Character` field and an ABI break, so it wants to land with other
character changes rather than alone.
### Integration is explicit Euler, so trajectories are frame-rate dependent
Velocity is applied to position using the velocity computed *this* step, and
thrust is accumulated before the cap, so the same jump traces a slightly
different arc at 30 Hz than at 144 Hz. The reversal simulation measures 0.500 s
to turn around where the closed form predicts 0.489 s -- about 2% at 60 Hz, and
it grows with the step.
`max_timestep` bounds how bad it gets (that is half of why it exists), and 2% is
not a bug a player can see. It is recorded because the fix -- a fixed-step
accumulator, integrating in whole `max_timestep` slices and carrying the
remainder -- would also remove the slow-motion trade that bounding the step
currently makes, and the two are the same piece of work.
### A large drag coefficient can invert velocity
`actor->ex -= actor->ex * self->drag_x * dt` is a first-order decay, so it only
decays for `drag * dt < 1`. Past that it overshoots zero, and past `2` it
diverges. With `dt` bounded to the default 0.05 s that needs a drag coefficient
above 20, which is not a plausible setting, so this is a sharp edge rather than
a live defect -- but nothing rejects it, and `max_timestep` is caller-settable.
`src/physics.c`, in the three drag blocks. The exact fix is
`expf(-drag * dt)` instead of `1 - drag * dt`, which is unconditionally stable.
## Carried over
1. **Make character-to-sprite state bindings release their references symmetrically.**
@@ -1909,3 +2195,359 @@ this library and told it is compatible.
non-square sprite draws at its own proportions through the library's own
`renderfunc`. This item is the half that remains: one uniform `scale`, with no way
to expand a single axis.
## Found while writing the manual
Twenty-one chapters and two tutorial games were written against `src/` rather than
against the header comments, and the exercise turned up two distinct classes of problem.
Both are recorded here because publishing a problem you cannot fix yet is still a
contribution, and because the second class is the more dangerous one: **every item in it
was documented, in a header, incorrectly.**
The manual documents each of these where a reader would hit it, and points here.
### Defects with no prior entry
1. **`akgl_game_update` calls `simulate` through a NULL pointer.** `akgl_game_update`
invokes `akgl_physics->simulate(akgl_physics, NULL)` with no NULL check, and
`akgl_default_physics` is zeroed BSS — all four method pointers are NULL. A program
that does not call `akgl_physics_init_arcade`/`_null` itself therefore **segfaults on
its first frame**, measured as exit 139, rather than raising `AKERR_NULLPOINTER`.
`physics.h:8-9,195` tells the reader `akgl_game_init` selects a backend from a
`physics.engine` property, so a caller who believes the header writes exactly the
program that crashes. This is the worst first-contact experience in the library and
the fix is a NULL check. Closing it touches `src/game.c` only.
2. **Parent/child offset is double-counted at draw time.** `src/physics.c:192-197` writes
a child's `x` as `parent->x + vx` — an absolute world coordinate. `src/actor.c:273-279`
then draws it at `parent->x + obj->x`, while `actor_visible` three lines above tests
the camera against the raw `obj->x` as absolute. Two readings of one field inside one
function. Confirmed by measurement: with the player at (280,146) and a (-14,+10)
offset, the guarded draw lands at (130,114) and the unguarded one at (410,260), off a
320x240 screen; rendering both and hashing `SDL_RenderReadPixels` gives different
images. Invisible only while the parent sits at the origin. `actor.h` documents **both**
readings, in two places. `examples/jrpg` works around it with a `renderfunc` that nulls
`obj->parent` for the duration of the draw.
3. **Actors and characters unregister under a different key than they register.**
`akgl_actor_initialize` (`src/actor.c:46`) and `akgl_character_initialize`
(`src/character.c:39`) register under the caller's untruncated `name`, while
`akgl_heap_release_actor` (`src/heap.c:132`) and `_release_character`
(`src/heap.c:165`) clear using the object's truncated 128-byte field. Past 127 bytes
those are different keys, so releasing leaves a live registry entry pointing at a
zeroed pool slot. Distinct from "Truncated registry keys can collide" above, which
describes sprites and spritesheets, where the truncated name genuinely *is* the key.
4. **Every actor spawned from a map is invisible on frame one.**
`akgl_actor_initialize` sets `movement_controls_face`, and the default `facefunc`
clears every facing bit and re-sets one only from a *movement* bit. An NPC has no
movement bit, so state 17 falls to 16, which maps to no sprite, and the actor is
silently skipped by the draw. The same thing makes a player character vanish the
moment they stop walking. Both tutorials clear the field on every live actor after
loading a map; that workaround should not be necessary.
5. **`use_own_physics` is set and never read.** `akgl_tilemap_load_physics` builds a
complete `akgl_PhysicsBackend` on the map when it declares a `physics.model` property,
and sets `map->use_own_physics` — and nothing in the library ever consults it. A map's
declared physics is silently ignored unless the application writes the switch itself.
`README.md` used to show that `if`, which made a caller-side workaround read like a
library feature.
6. **`akgl_TilemapLayer` does not record a layer's name.** `akgl_tilemap_load_layers`
reads `id`, `opacity`, `visible`, `x`, `y` and `type` and drops the `name` Tiled
wrote; the only `name` retained anywhere is per-object. A game therefore cannot ask
for "the terrain layer" and must hard-code a numeric layer id, which changes whenever
somebody reorders layers in the editor. Both tutorials hard-code one.
7. **`akgl_character_sprite_add` leaks a reference when the same sprite is re-added.**
`ref->refcount += 1` is unconditional but the matching release is guarded by
`displaced != ref`, so re-adding a sprite to a state it already occupies leaks a pool
slot per call.
8. **`akgl_tilemap_compute_tileset_offsets` silently requires `spacing == 0` and
`margin == 0`.** It adds `spacing` to the tile pitch but sets row 0's y offset to
`spacing` rather than 0, and ignores `margin` entirely. A tileset with a gutter — which
is most published tileset packs — renders misaligned with no diagnostic. This
materially constrains what art the library can consume; it ruled out several otherwise
suitable CC0 packs while sourcing the tutorial assets.
9. **`akgl_actor_render` hard-codes `SDL_FLIP_NONE`** (`src/actor.c:283`). There is no
mirrored blit, so a side-on character needs both facings drawn in the sheet. That is
why `docs/tutorials/assets/sidescroller/player.png` carries six frames rather than
three, and it doubles the art cost of every such character.
10. **`akgl_Actor::layer` is unbounded but `draw_world` stops at `AKGL_TILEMAP_MAX_LAYERS`.**
An actor assigned layer 16 or above is accepted, updated, simulated — and never drawn.
11. **`akgl_registry_load_properties` leaks one string-pool slot per failed property.**
The per-property `CLEANUP` block is empty. The string pool is 256 slots, so a
sufficiently malformed properties file drains it.
12. **`speedtime` is dead.** It is loaded from character JSON (`src/character.c:263`),
written through `(int *)&obj->speedtime` on a `uint64_t` field — correct only because
the struct was zeroed and the host is little-endian — and then read by nothing. Frame
timing comes from `sprite->speed`.
13. **`AKGL_SPRITE_MAX_REGISTRY_SIZE` is dead.** Defined in `sprite.h`, referenced
nowhere in `src/`, `include/`, `tests/` or `util/`.
14. **`AKGL_TILEMAP_MAX_TILES_PER_IMAGE` is checked nowhere**, and costs 512 KiB per
tileset regardless of the image's real tile count.
15. **There is no sound-effect API.** `akgl_audio_*` is a synthesizer that reads no files;
`akgl_load_start_bgm` is the only file-audio entry point, and its infinite-loop
request is set on property set 0, so background music plays once. A game cannot load
and play a sound effect through this library at all.
16. **A literal 512x512 tilemap is rejected.** Both bounds are `>=`, so the documented
`AKGL_TILEMAP_MAX_WIDTH`/`_HEIGHT` of 512 is off by one and 512x511 is the largest
map that loads.
17. **Unverified asset provenance.** `tests/assets/World_A1.png` and
`util/assets/Actor1.png` carry the default filenames of RPG Maker's bundled art and
ship with no license file, while `tests/assets/akgl_test_mono.ttf` sits beside
`akgl_test_mono.LICENSE.txt`. RPG Maker's bundled assets are licensed to users of that
product; redistributing them inside a C library is not something that license covers.
The tutorial assets under `docs/tutorials/assets/` deliberately do not depend on
either file, and `tests/docs_setups/tilemap.sh` says why it stages a different image.
Closing this means replacing two fixtures and the maps that reference them.
18. **`util/assets/littleguy.json` does not load.** The sample data for `charviewer`, the
one demo program the library ships, uses the pre-0.5.0 unprefixed state names
(`ACTOR_STATE_ALIVE`) and `velocity_x`/`velocity_y` instead of `speed_x`/`speed_y`.
The current loader accepts neither.
19. **A control map could not say "any keyboard", so binding one was a coin flip.**
`akgl_controller_handle_event` matched `event->key.which == curmap->kbid` exactly, and
there was no way to express "whatever keyboard the player types on". That is not a
theoretical gap, because the id a key event *carries* is chosen by the video backend
and does not have to be an id `SDL_GetKeyboards()` reports: on X11 without XInput2
every key event carries `SDL_GLOBAL_KEYBOARD_ID` (0) while SDL registers the keyboard
as `SDL_DEFAULT_KEYBOARD_ID` (1), and with XInput2 the events carry the physical slave
device's `sourceid` while `SDL_GetKeyboards()[0]` may be the master. A game that bound
`SDL_GetKeyboards()[0]` -- the obvious thing -- got a map that matched nothing and
controls that silently did nothing. `examples/sidescroller` shipped exactly that.
**Fixed**: `kbid`/`jsid` of 0 now match any device of that kind. 0 is safe to spend
because SDL documents `which` as 0 when the source is unknown or virtual and joystick
ids start at 1, so no real device is 0. A non-zero id still matches only that device,
which is what keeps two local players apart. `util/charviewer.c` was already passing 0
and depending on the old accidental behaviour, so it works under XInput2 now too.
The reason this reached a release is worth keeping: **every test in
`tests/controller.c` dispatched an event whose id equalled the id the map was bound
with**, so none of them could see it, and the sidescroller's smoke test called the
handlers directly rather than dispatching events, so it passed against a control map
that matched nothing. Both are fixed -- `test_controller_wildcard_device_ids` asserts
the contract, and the smoke test now drives `akgl_controller_handle_event` from a
non-zero device id and fails if the press does not arrive.
### Header comments that describe code that has changed
Twenty-seven claims across the public headers were false when checked against `src/`.
`AGENTS.md` already warns that this file "carried eleven entries describing code that had
already changed"; this is the same failure in the headers, and Doxygen publishes it.
Nothing catches these. `WARN_IF_UNDOCUMENTED` proves a symbol *has* a comment, not that
the comment is true, and `api_surface` strips comments precisely because prose is not a
declaration. The full list is in the manual, each noted in the chapter that covers the
subsystem, but the ones that would actively mislead a caller are:
- `physics.h:8-9,195` — the `physics.engine` property and `akgl_game_init` calling the
factory. Neither exists. See defect 1 above for what this costs.
- `README.md` (now corrected) — "ONLY supports TilED TMJ tilemaps with tileset **external**
references". Backwards: `"source"` appears nowhere in `src/tilemap.c`, and
`akgl_tilemap_load_tilesets_each` reads `columns`/`firstgid`/`tilecount`/`image` inline.
Only **embedded** tilesets load.
- `renderer.h` — `frame_start`/`frame_end`/`draw_texture` "dereference `self` before it is
checked". Each function's first statement is `FAIL_ZERO_RETURN(errctx, self, ...)`.
- `sprite.h` — `speed` is "seconds, scaled to milliseconds". It is milliseconds scaled to
nanoseconds. Also claims `frames` is unbounded (bounded at `src/sprite.c:207`) and that
`akgl_sprite_initialize` overreads via `memcpy` (it uses `aksl_strncpy`).
- `character.h` — `speedtime` "in seconds" (milliseconds), and `sprite_add` never releasing
a displaced sprite (`src/character.c:60,77-79` releases it).
- `actor.h` — the `cmhf` block comment says the `_off` handlers zero acceleration, thrust,
environmental *and* velocity. They zero only `ax`/`tx` or `ay`/`ty`; zeroing `ey` was the
gravity-cancel defect fixed in 0.6.0, and two `@note`s still describe it.
- `json_helpers.h` — the conventions block says `dest` is not NULL-checked and that only
`akgl_get_json_string_value` checks its key. All eleven accessors check `dest`, and all
seven key-taking accessors check `key`.
- `assets.h:17-18` — "`akgl_game_init` (or a bare `akgl_audio_init`) has to have run
first". `akgl_mixer` is created only in `akgl_game_init`; `akgl_audio_init` opens the
synthesizer's stream and never touches it, so `akgl_load_start_bgm` after only that
hands NULL to `MIX_LoadAudio`.
- `registry.h:54` — `akgl_registry_init` creating seven registries and not properties. It
creates eight including properties; the genuinely false part is that `akgl_game_init`
never calls it at all, calling the eight individually in a different order.
- `tilemap.h:59-60,354-357,447-450,462-468` — object and tileset counts unbounded, and
`akgl_tilemap_release` double-freeing. All fixed; the surviving half of the last one is
that `release` does not release the map's actors.
- `controller.h:232-234,262-263` — a negative `controlmapid` not rejected. Both call sites
check.
### Entries in this file that are themselves stale
- **"Known and still open" item 15** — `akgl_path_relative` leaking an error-context slot
per ENOENT call — is fixed. `src/util.c:115-129` sets a flag in the `HANDLE` block and
calls `path_relative_root` after `FINISH`, which is exactly the fix the item proposes.
- **`TODO.md:2027`**, under "akgl.pc names no dependencies", says "`akglConfig.cmake`
re-finds the targets". No such file is generated or installed — `install()` ships the
library, the headers and `akgl.pc` only, so `find_package(akgl)` cannot work against an
install tree at all. That makes the section's problem worse than it states.
- **`AGENTS.md:636`** says `sim_step()` sets `gravity_time` to `now - dt`.
`tests/physics_sim.c` sets it to `0` and drives the step through `max_timestep`; the
`now - dt` form was replaced because it went red under parallel `ctest`.
## tg is vendored and has no consumer
`deps/tg` is pinned at v0.7.9 and **nothing in the tree uses it**. It was
evaluated alongside `deps/libccd` for the collision work and lost, for two
reasons that are properties of the library rather than preferences:
- **It is 2D permanently.** Its own documentation says the z coordinate is
"only carried along and serve[s] no other purpose than to be available for
when it's desired to export to an output representation"; no predicate reads
it. libccd is natively 3D, so the 2D case is a restriction that lifts later.
- **Its predicates return `bool`.** Resolving a collision needs a minimum
translation vector, and `tg_geom_intersects` cannot supply one.
Secondary: every coordinate in its API is `double` against libakgl's
`float32_t`; `tg.c` is a single 16,000-line amalgamation that embeds a second
JSON parser next to the jansson already vendored; and it ships no build system,
so `akgl_add_vendored_dependency` would silently do nothing for it.
It is kept rather than dropped because there is one job it does that libccd
structurally cannot: **indexed point-in-polygon against concave polygons with
holes.** GJK and MPR are convex-only, always. A trigger volume or walkable
region authored as a concave Tiled polygon is the case, and tg answers it at
10M+ ops/sec with its own segment index.
**This entry is the deadline.** Either that consumer appears, or `deps/tg` comes
out. A vendored dependency with no caller is a question every future reader has
to answer, and 6 MB of it is cloned by every recursive checkout in CI. Closing
it means either writing the region-query API or `git rm` -- not leaving it here.
## Actor rotation, and collision under it
**No actor carries an angle.** `akgl_actor_render` hard-codes `SDL_FLIP_NONE`,
every collision shape is axis-aligned, and both example games depend on that
being true. Adding rotation is a small change in five named places and a large
change in one, and this entry exists so the large one is not a surprise.
What it touches, in the order it would have to be done:
1. **`akgl_Actor` grows an `angle`**, in radians, about z. Render consumes it:
`SDL_RenderTextureRotated` takes an angle in *degrees* and a centre, so
`akgl_actor_render` converts and picks a centre. The centre is a decision,
not a detail — a sprite rotating about its frame centre and one rotating
about its feet look nothing alike.
2. **`collision_support` rotates `dir` into local space** at the top and rotates
the answer back. That one function is written so this is *the whole
narrowphase change*: every arm of its switch already answers in the shape's
own frame, and the world centre is added by `collision_ccdobj` rather than
inside the switch. A `ccd_quat_t` on `collision_ccdobj`, one `ccdQuatRotVec`
in and one `ccdQuatRotVec` out, and MPR and GJK both work on rotated shapes
with nothing else touched. `collision_center` is unaffected — the centre of a
shape rotated about its own centre is the same point.
3. **The box fast path stops being valid for a rotated pair.** `collision_box_box`
is a closed form that assumes both boxes are axis-aligned; a rotated pair has
to fall through to MPR. That is a branch on `(a->angle != 0 || b->angle != 0)`
and a measurable cost — the fast path is 2-7x cheaper (`PERFORMANCE.md`,
"Collision, measured"), so a game that rotates everything pays for it.
4. **The broad-phase bound becomes the rotated bound.** `akgl_collision_shape_bounds`
returns the shape's own AABB; a rotated shape needs the AABB *of the rotated
shape*, which is larger. The function grows an angle parameter, and every
caller — `akgl_collision_settle`, the grid's `insert`/`move`, `ss_grounded` in
the sidescroller — passes one. A broad phase that keeps returning the
unrotated bound under-reports, which is the one failure mode the partitioner
contract forbids.
5. **`akgl_collision_proxy_sync` copies the angle** alongside the shape and the
position, since that is the one place the proxy's copy is refreshed.
**The z-extrusion invariant is unaffected.** Rotation is about z, so a shape's
half-extent along z does not change and `AKGL_COLLISION_DEPTH_RATIO` still makes
z overlap exceed any achievable planar penetration. See
[Chapter 15](docs/15-collision.md), "Why a 2D shape has a depth".
**What it does not touch:** the response. `akgl_actor_collide_block` works on a
normal and a velocity and does not care how the normal was found. Angular
velocity, torque and rotational response are a *different* piece of work and are
not implied by any of the above — a rotated shape that resolves linearly is a
perfectly coherent intermediate state, and it is the one a top-down shooter
actually wants.
## Swept narrowphase for fast movers
`AKGL_COLLISION_FLAG_BULLET` is defined, documented as reserved, and **does
nothing**. Sub-stepping bounds how far an actor travels between tests and it is
capped at `AKGL_COLLISION_MAX_SUBSTEPS` (8) sub-steps of at most
`AKGL_COLLISION_SUBSTEP_FRACTION` (0.5) of a cell, so the speed above which
tunnelling returns is:
```
v_max = substeps * fraction * cellsize / max_timestep
= 8 * 0.5 * 16 / 0.05
= 1280 px/s on 16-pixel tiles at the default max_timestep
```
Check your own numbers against that: a game with 8-pixel tiles halves it, and a
game that raises `physics.max_timestep` lowers it proportionally. A walking
character is nowhere near it; a projectile is a projectile.
The fix is a swept test rather than more sub-steps — sweeping the shape's AABB
over the sub-step's motion, testing that, and resolving at the time of first
contact rather than at the end position. It belongs behind the flag so it is
paid for only by the shapes that need it.
## The ccd arena is single-threaded and sized by measurement
`src/collision_arena.c` is one static arena of `AKGL_CCD_ARENA_BYTES` (64 KB)
with a bump pointer, reset at the top of every `akgl_collision_test`. Two things
follow that are true today and would not survive a change:
- **It is not thread-safe.** libakgl is single-threaded behind
`akgl_game.statelock`, and two threads in the narrowphase at once would
interleave allocations in the same arena. Threading libakgl means an arena per
thread, or a lock, before anything else.
- **The size is a measurement, not a guess.** One GJK/EPA box pair measures
7,264 bytes. Exhaustion is reported, not crashed: `ccdGJKPenetration` returns
-2 and `akgl_collision_test` raises `AKGL_ERR_COLLISION` with the high-water
mark in the message, so the failure names its own fix. MPR — the default on
the frame path — allocates nothing at all, so the arena is only under pressure
from a caller who has explicitly asked for EPA's manifold.
`akgl_ccd_arena_highwater()` is public so a game can assert its own ceiling.
## `akgl_collide_rectangles` stays; the corner helpers did not
The collision plan's last step was to **delete `akgl_rectangle_points`,
`akgl_collide_point_rectangle` and `akgl_collide_rectangles`** once the shape API
landed, on the grounds that collision now has a real narrowphase. Two of the
three went, in 0.8.0, along with `akgl_Point` and `akgl_RectanglePoints`. The
third stays, and this is the reasoning rather than an oversight.
**Why the two went.** They were the intermediate form of an implementation that
changed. `akgl_collide_rectangles` was eight corner-containment tests built on
them; it is four span comparisons now and does not go through either. Nothing
outside `tests/` called them, and a point-in-rectangle test is four comparisons a
caller can write without a struct conversion in front of it.
**Why the third stays.**
- **It has callers that are correct.** `examples/sidescroller/player.c` uses it
twice, for coins and for hazards. Those are overlap *questions* asked from an
`updatefunc` -- "am I touching this" -- and the right answer is a `bool`.
Routing them through a collision shape would mean a proxy, a broad-phase insert
and a narrowphase call to compute a normal and a depth that nothing reads.
- **It was fixed in this same series**, not left broken. It missed the cross case
and truncated through `akgl_Point`'s `int` members; it is exact in `float32_t`
now. Deleting it two commits after fixing it is a strange thing to do to a
caller.
- **A game-level overlap question is not always a physics question.** A minimap
marker, a UI hit test, "is the cursor over this". None of those wants to be
pushed, and none of them belongs in a collision world.
One thing found while doing this and fixed rather than recorded:
`akgl_RectanglePoints`' Doxygen comment still said `akgl_collide_rectangles`
"works corner by corner rather than by comparing edge spans", which had been
false since the fix. It went with the type.

1
deps/libccd vendored Submodule

Submodule deps/libccd added at 7931e764a1

1
deps/tg vendored Submodule

Submodule deps/tg added at 823f78db7e

135
docs/01-introduction.md Normal file
View File

@@ -0,0 +1,135 @@
# 01. Introduction
libakgl is a C library for building 2D games on SDL3. This is version **0.8.0**. It ships
193 functions declared across the twenty-three public headers in `include/akgl/`, plus
`akgl_version()` from the generated `version.h` — 194 exported `akgl_` symbols in
`libakgl.so.0.8`.
**0.8.0 is an ABI break.** The `collide` slot on `akgl_PhysicsBackend` changed signature,
four public structs grew, `akgl_physics_arcade_collide` stopped raising `AKERR_API`, and
four symbols were removed — `akgl_rectangle_points`, `akgl_collide_point_rectangle` and the
two types they used ([Chapter 19](19-utilities.md)). Collision is the reason for all of it;
it is [Chapter 15](15-collision.md).
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`, `akgl_Partitioner` |
| Runtime `malloc` | Eight statically sized pools with reference counts, plus a static arena for the narrowphase; `AKGL_ERR_HEAP` when a pool 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` |
| Collision you cannot switch off | A `collision` pointer on the backend. `NULL` is byte-identical to a build before collision existed |
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% |
| Collision over 64 actors, all-pairs, as a control | 0.012 ms | 0.07% |
| 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 |
|---|---|---|
| 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) |
| Rotation | No actor carries an angle, and every collision shape is axis-aligned | [15](15-collision.md) |
| Continuous collision | Sub-stepping is capped, so something moving faster than about 1280 px/s on 16-pixel tiles can still pass through a wall | [15](15-collision.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](17-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 18](18-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 17](17-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 19](19-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 18](18-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 181 of those 193
functions return `akerr_ErrorContext AKERR_NOIGNORE *` and you cannot read a single example
until you can read that return value. The dozen that do not are the `void` SDL enumeration
callbacks and the collision arena's accessors. 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 20](20-tutorial-sidescroller.md) and [Chapter 21](21-tutorial-jrpg.md) — are
complete programs under `examples/`, built by default and smoke-run in CI.

View File

@@ -0,0 +1,298 @@
# 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 seven, and what the ceilings cost you.
## Variation lives in a backend, not in a branch
There are exactly three pluggable subsystems, and all have the same shape: **a struct of
function pointers, plus an initializer that populates it.**
```text
akgl_RenderBackend akgl_PhysicsBackend akgl_Partitioner
+--------------------+ +--------------------+ +-----------------+
| sdl_renderer | | simulate | | reset |
| shutdown | | gravity | | insert |
| frame_start | | collide | | remove |
| frame_end | | move | | move |
| draw_texture | | drag_x/y/z | | query |
| draw_mesh | | gravity_x/y/z | | each_pair |
| draw_world | | max_timestep | | state |
+--------------------+ | collision | +-----------------+
^ +--------------------+ ^
| populated by ^ | populated by
akgl_render_2d_bind | populated by akgl_partitioner_init_grid
akgl_render_2d_init akgl_physics_init_null akgl_partitioner_init_bsp
akgl_physics_init_arcade (akgl_partitioner_factory)
(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
**181 of libakgl's 193 functions return `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
View 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, 1255 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 17](17-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 20](20-tutorial-sidescroller.md) to start building a game.

271
docs/04-errors.md Normal file
View File

@@ -0,0 +1,271 @@
# 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
193 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 0255 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 six 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 */
#define AKGL_ERR_COLLISION (AKGL_ERR_BASE + 5) /**< A collision query could not be answered; the message says why */
```
**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 6`, 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 seventh 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_ERR_COLLISION` | 261 | A collision query could not be answered | **One cause only**: `akgl_collision_test`, when the ccd arena is exhausted. The message carries the high-water mark | Raise `AKGL_CCD_ARENA_BYTES` to the reported figure. See [Chapter 15](15-collision.md) |
`akgl_error_init` reserves the band all-or-nothing and registers a name for each of the
six: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`,
`"Logic Interrupt"`, `"Collision Error"`. 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_render_2d_draw_mesh` does `FAIL_RETURN(..., AKERR_API, "Not implemented")`, and it is reached by an ordinary-looking call. (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 22](22-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, 1255 → 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 six 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 22](22-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
View 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 eight pools
A "heap layer" is one array plus its `next`/`release` pair. There are five:
| Layer | Ceiling | Default | Element | Total |
|---|---|---|---|---|
| Actor | `AKGL_MAX_HEAP_ACTOR` | 64 | 400 B | 25 KiB |
| Sprite | `AKGL_MAX_HEAP_SPRITE` | 1024 | 176 B | 176 KiB |
| Spritesheet | `AKGL_MAX_HEAP_SPRITESHEET` | 1024 | 536 B | 536 KiB |
| Character | `AKGL_MAX_HEAP_CHARACTER` | 256 | 184 B | 46 KiB |
| String | `AKGL_MAX_HEAP_STRING` | 256 | 4100 B | 1.0 MiB |
The whole thing is about 1.8 MiB of BSS, and **the string pool is more than half of it**
because every `akgl_String` is `PATH_MAX` bytes whether it holds a path or the four
characters `"0.0"`.
The ceilings are all `#ifndef`-guarded:
```c excerpt=include/akgl/heap.h
#ifndef AKGL_MAX_HEAP_ACTOR
#define AKGL_MAX_HEAP_ACTOR 64
#endif
#ifndef AKGL_MAX_HEAP_SPRITE
#define AKGL_MAX_HEAP_SPRITE (AKGL_MAX_HEAP_ACTOR * 16)
#endif
#ifndef AKGL_MAX_HEAP_SPRITESHEET
#define AKGL_MAX_HEAP_SPRITESHEET AKGL_MAX_HEAP_SPRITE
#endif
#ifndef AKGL_MAX_HEAP_CHARACTER
#define AKGL_MAX_HEAP_CHARACTER 256
#endif
#ifndef AKGL_MAX_HEAP_STRING
#define AKGL_MAX_HEAP_STRING 256
#endif
```
The sprite pool is sixteen per actor, on the assumption of one sprite per state
combination. The spritesheet pool is sized to match, though sharing by resolved path means
far fewer are used in practice.
The five arrays are `extern` and public — `akgl_heap_actors`, `akgl_heap_sprites`,
`akgl_heap_spritesheets`, `akgl_heap_characters`, `akgl_heap_strings` — so the render and
physics sweeps can walk them directly instead of going through a registry.
`akgl_game_update` does exactly that.
### The ceilings are a compile-time ABI constraint
This is the part that bites, and it does not announce itself.
The arrays are sized at compile time. Defining `AKGL_MAX_HEAP_ACTOR` to 256 before
including `heap.h` changes the *declared* extent of `akgl_heap_actors` in your translation
unit — but `src/heap.c` was compiled with whatever the library was built with, and that is
where the storage actually lives. Mismatch the two and your code indexes past the end of an
array that is still 64 entries long, in another object file, with no diagnostic anywhere.
```text
your game.c libakgl.so
------------------------ ------------------------
#define AKGL_MAX_HEAP_ACTOR 256 (built with the default 64)
#include <akgl/heap.h>
akgl_Actor akgl_heap_actors[64];
akgl_heap_actors[200] ------------------> off the end. No warning.
```
**Raise a ceiling for the whole build or not at all.** With `add_subdirectory`, put it in a
`target_compile_definitions` that reaches libakgl's own sources as well as yours. Against
an installed `libakgl.so`, you cannot change it at all without rebuilding and reinstalling
the library, and the soname does not encode it — so a rebuilt game against an old installed
library is a silent overrun, not a link error.
## Claiming and releasing
Each layer has an acquire and a release:
| Layer | Acquire | Release |
|---|---|---|
| Actor | `akgl_heap_next_actor` | `akgl_heap_release_actor` |
| Sprite | `akgl_heap_next_sprite` | `akgl_heap_release_sprite` |
| Spritesheet | `akgl_heap_next_spritesheet` | `akgl_heap_release_spritesheet` |
| Character | `akgl_heap_next_character` | `akgl_heap_release_character` |
| String | `akgl_heap_next_string` | `akgl_heap_release_string` |
Every acquire raises `AKGL_ERR_HEAP` and nothing else. Every release raises
`AKERR_NULLPOINTER` and nothing else. Full signatures are in the Doxygen reference; the
behaviour worth knowing is below.
**None of the `next_*` functions checks `dest`.** A `NULL` there is a crash, not an error
context. That is deliberate — the check would cost a branch on the allocation path — but it
means these are the functions where you validate your own argument first.
**None of them zeroes the slot either.** What you get back is whatever the previous holder
left. The `*_initialize` functions `memset` before they do anything else, which is why the
normal shape is acquire-then-initialize and never acquire-then-use.
### The refcount asymmetry
This is the one thing about the heap that will surprise you, and it is a known defect
rather than a design (`TODO.md`, "Known and still open" item 8; also the `@warning` on
`heap.h` itself).
| Acquire | Takes the reference? | Who takes it |
|---|---|---|
| `akgl_heap_next_string` | **Yes** — `refcount` is 1 on return | the acquire itself |
| `akgl_heap_next_actor` | No | `akgl_actor_initialize` |
| `akgl_heap_next_sprite` | No | `akgl_sprite_initialize` |
| `akgl_heap_next_spritesheet` | No | `akgl_spritesheet_initialize` |
| `akgl_heap_next_character` | No | `akgl_character_initialize` |
**Until a reference is taken, the slot is still free and the next acquire hands out the
same pointer.** Seven of the eight acquires therefore leave a window in which two callers can
be holding the same object, and the window closes only when `*_initialize` runs. In
practice you never see it, because the four are always immediately followed by their
initializer — which is exactly why the asymmetry has survived. Write them adjacent and
nothing can get in between:
```c
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/actor.h>
#include <akgl/heap.h>
/* Claim an actor slot and take the reference on it. */
akerr_ErrorContext AKERR_NOIGNORE *spawn_actor(akgl_Actor **dest, char *name)
{
akgl_Actor *obj = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "NULL name");
PASS(errctx, akgl_heap_next_actor(&obj));
/* obj->refcount is still 0. The slot is not yours yet. */
PASS(errctx, akgl_actor_initialize(obj, name));
/* obj->refcount is 1 now, and the actor is in AKGL_REGISTRY_ACTOR. */
*dest = obj;
SUCCEED_RETURN(errctx);
}
```
You do not have to take that on trust. This program is compiled, linked against the library
and run by the test suite, and the output below it is what it actually prints:
```c run=akglapp
akgl_Actor *first = NULL;
akgl_Actor *second = NULL;
akgl_String *str = NULL;
/* A string acquire takes the reference for you. */
PASS(errctx, akgl_heap_next_string(&str));
printf("string refcount after acquire: %d\n", str->refcount);
/* An actor acquire does not. */
PASS(errctx, akgl_heap_next_actor(&first));
printf("actor refcount after acquire: %d\n", first->refcount);
/* So a second acquire, before the initializer runs, finds the same free slot. */
PASS(errctx, akgl_heap_next_actor(&second));
printf("same slot handed out twice: %s\n", (first == second) ? "yes" : "no");
/* Initializing is what takes the reference and closes the window. */
PASS(errctx, akgl_actor_initialize(first, "player"));
printf("actor refcount after init: %d\n", first->refcount);
PASS(errctx, akgl_heap_next_actor(&second));
printf("now a distinct slot: %s\n", (first == second) ? "no" : "yes");
PASS(errctx, akgl_heap_release_actor(first));
PASS(errctx, akgl_heap_release_string(str));
```
```output
string refcount after acquire: 1
actor refcount after acquire: 0
same slot handed out twice: yes
actor refcount after init: 1
now a distinct slot: yes
```
Note also that a failure between the two — `akgl_actor_initialize` rejecting the name, say
— needs no cleanup, precisely because nothing was claimed. That is the one thing the
asymmetry is good for.
The string layer is the opposite and is safe on its own, which is what makes the
scratch-buffer idiom below work.
### What each release actually does
They all decrement, and they all do the teardown when the count reaches zero. What the
teardown *is* differs, and two of them reach outside the pool:
- **`akgl_heap_release_actor`** releases every non-`NULL` child recursively, clears the
actor's entry from `AKGL_REGISTRY_ACTOR`, and zeroes the slot. The recursion has **no
cycle check**: an actor reachable from its own child list recurses until the stack runs
out.
- **`akgl_heap_release_sprite`** clears the `AKGL_REGISTRY_SPRITE` entry and zeroes the
slot. **It does not release the spritesheet** — the sprite only borrowed it.
- **`akgl_heap_release_spritesheet`** clears the registry entry, **destroys the
`SDL_Texture`**, and zeroes the slot. Any sprite still pointing at the sheet is left with
a dangling pointer, since nothing takes a reference on a sheet on a sprite's behalf.
Destroying a texture is a main-thread operation in SDL, so this must be called from the
thread that owns the renderer.
- **`akgl_heap_release_character`** walks the state-to-sprite map with
`akgl_character_state_sprites_iterate` and `AKGL_ITERATOR_OP_RELEASE`, giving back every
reference `akgl_character_sprite_add` took, then destroys the `SDL_PropertiesID` holding
the map, clears the registry entry, and zeroes the slot.
- **`akgl_heap_release_string`** zeroes the character data. Strings are not registered
anywhere, so that is all there is to it.
One shared hazard: **a slot whose `refcount` is already 0 is re-torn-down rather than
rejected.** Harmless on a zeroed slot, destructive on a live one that was never registered.
`akgl_heap_release_string` is the exception to the library's usual `NULL`-is-a-no-op
convention: a `NULL` here is `AKERR_NULLPOINTER`, which is why every `CLEANUP` block in the
library wraps it in `IGNORE()`.
## `akgl_String`
A pooled string is a `refcount` and a `PATH_MAX` buffer:
```c excerpt=include/akgl/staticstring.h
/** @brief Provides a fixed-capacity, heap-managed string buffer. */
typedef struct
{
int refcount; /**< Pool bookkeeping; 0 means the slot is free. Owned by the heap layer. */
char data[AKGL_MAX_STRING_LENGTH]; /**< The characters. Not guaranteed NUL-terminated when filled to capacity. */
} akgl_String;
```
Capacity is fixed. `akgl_string_initialize` and `akgl_string_copy` **truncate rather than
grow, and the truncation is silent** — they both always NUL-terminate, but they do not tell
you that something was lost. If you need to know, measure first, or go through
`aksl_strncpy` with the full destination size and let it raise `AKERR_OUTOFBOUNDS`.
Two details that catch people:
- **`akgl_heap_next_string` does not clean the buffer.** The contents are whatever the last
holder left. Call `akgl_string_initialize(str, NULL)` if you need it clean — and note
that `akgl_get_property` only copies the value and its terminator, leaving the rest of
the buffer alone, so read the result as a C string rather than as
`AKGL_MAX_STRING_LENGTH` bytes.
- **Several functions claim one for you.** `akgl_get_property`,
`akgl_get_json_string_value` and `akgl_get_json_array_index_string` all claim a slot when
`*dest` is `NULL`, and write in place when it is not. Either way **the caller releases
it.**
The scratch-buffer idiom is claim, use, release unconditionally in `CLEANUP`:
```c
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/error.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/staticstring.h>
/* The scratch-string idiom: claim, use, release unconditionally in CLEANUP. */
akerr_ErrorContext AKERR_NOIGNORE *screen_width(int *dest)
{
akgl_String *value = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
ATTEMPT {
CATCH(errctx, akgl_heap_next_string(&value));
CATCH(errctx, akgl_get_property("game.screenwidth", &value, "800"));
CATCH(errctx, aksl_atoi(value->data, dest));
} CLEANUP {
IGNORE(akgl_heap_release_string(value));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
```
The `CLEANUP` block runs on every path out of the `ATTEMPT`, which is the whole reason the
release goes there and not after the last `CATCH`. **Never use a `*_RETURN` macro inside
that `ATTEMPT`** — it returns past `CLEANUP` and leaks the slot. That mistake has shipped
in this library before.
## Reading `AKGL_ERR_HEAP`
When a pool is exhausted the message names the layer:
```text
Unable to find unused string on the heap
```
Before you raise the ceiling, look for the missing release. The realistic causes, in the
order they actually happen:
1. **A string claimed and never released.** By far the most common: the pool is 256 slots
and every `akgl_get_property` call can claim one. A per-frame read that forgets the
release exhausts it in four seconds at 60 Hz.
2. **A `*_RETURN` macro inside an `ATTEMPT`**, skipping the `CLEANUP` that held the
release.
3. **A `return` out of a `HANDLE` block.** That leaks a libakerror context slot rather than
an akgl one, and shows up as `"Unable to pull an error context from the array!"` on the
129th call — different pool, same shape of bug.
4. **Characters reloaded per level without releasing the previous ones**, which exhausts
the *sprite* pool rather than the character pool, since each character holds a reference
per bound state.
Only after all four are ruled out is the ceiling actually too small — and a game that
genuinely needs 256 actors should say so once, for the whole build, per the ABI note above.
## Initialization and reset
`akgl_heap_init` zeroes all eight pools. `akgl_game_init` calls it for you, before any
registry exists.
**Calling it again is a reset, not a refresh.** It does not release textures, clear
registries, or consult reference counts, so every live object becomes a dangling pointer
and every registry entry points at a zeroed slot.
`akgl_heap_init_actor` zeroes the actor pool only, which is what makes it usable between
levels: the sprites, sheets and characters are the expensive ones because they own
textures. Pair it with `akgl_registry_init_actor`, the one registry initializer that
destroys the old property set before creating the replacement. See
[Chapter 6](06-the-registry.md).
Both return `NULL` and have no failure path today — they are a series of `memset`s. Check
them anyway; the signature exists so a layer that needs real setup has somewhere to report
from.
## If you need a new kind of object
Add a layer here. Do not reach for the allocator: `AGENTS.md` makes that a standing rule,
and a `malloc` in the middle of a frame is the failure mode the pools exist to remove.
A layer is an array, an `AKGL_MAX_HEAP_*` ceiling, a `next`, and a `release` — five things,
all visible in `src/heap.c`.
## Where to go next
- [Chapter 4](04-errors.md) — `AKGL_ERR_HEAP` in the status tables.
- [Chapter 6](06-the-registry.md) — the registries the releases clear entries from.
- [Chapter 22](22-appendix-limits.md) — every `AKGL_MAX_*` in one place.

228
docs/06-the-registry.md Normal file
View 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
2426). The individual name registrations are not checked, either.
**The music registry is empty.** `akgl_registry_init_music` creates it and nothing in the
library ever writes to it. It is there for you.
## What actually runs at startup
`registry.h` documents `akgl_registry_init` as creating "the seven asset registries", with
a `@warning` that it does not create `AKGL_REGISTRY_PROPERTIES`. **Both halves of that are
now wrong**, and the correction matters because the warning is what a reader would design
around.
Read against `src/registry.c` and `src/game.c`, this is what is true:
- `akgl_registry_init` creates **eight**, including `AKGL_REGISTRY_PROPERTIES`. The
properties call was added in 0.5.0 (`TODO.md`, "Known and still open" item 3, marked
fixed). Its order is spritesheet, sprite, character, actor, actor-state-strings, font,
music, properties.
- **`akgl_game_init` never calls `akgl_registry_init`.** It calls the eight individual
initializers itself, in a different order: actor, sprite, spritesheet, character, font,
music, properties, actor-state-strings.
So there are two supported startup paths and they do not share code. If you call
`akgl_game_init`, you have all eight and need do nothing. If you are building your own
startup — an embedder binding an existing renderer, say — call `akgl_registry_init` and you
also have all eight. What you must not do is call some subset by hand and assume the rest
followed.
`akgl_registry_init_actor` is the one initializer that behaves differently in a way you
would notice: since 0.5.0 every initializer destroys the previous property set before
creating the replacement, so none of them leaks on a second call, but the *actor* one is
the one meant to be called again — between levels, paired with `akgl_heap_init_actor`. Note
that it destroys the **registry**, not the actors; releasing those is
`akgl_heap_release_actor`'s job.
## The id-0 trap
Every `SDL_PropertiesID` in `src/registry.c` starts at 0, and SDL treats 0 as "no such
property set". **Reads return the default and writes are refused.** An uninitialized
registry therefore does not fail the way you would want it to.
How loud that failure is depends entirely on whether libakgl checks SDL's return value, and
it does not check everywhere:
| Call | Behaviour against an id of 0 |
|---|---|
| `akgl_actor_initialize`, `akgl_sprite_initialize`, `akgl_spritesheet_initialize`, `akgl_character_initialize` | **Loud.** The `SDL_SetPointerProperty` result is checked; you get `AKERR_KEY`, "Unable to add … to registry" |
| `akgl_actor_set_character` | **Loud.** `SDL_GetPointerProperty` returns `NULL` and the result is checked; you get `AKERR_NULLPOINTER` |
| `akgl_set_property` | **Silent.** The `SDL_SetStringProperty` result is not checked. The value is discarded and the call returns success |
| `akgl_registry_load_properties` | **Silent, and worse.** Same unchecked write, and it logs `Set property x = y` for every entry it dropped, then logs `Properties loaded` |
| `akgl_get_property` | **Silent.** You get the default you passed, every time |
| `akgl_registry_init_actor_state_strings` | **Silent.** The `SDL_SetNumberProperty` results are not checked |
The consequence is the one `registry.h` warns about, and it is real even though the
attribution has moved: **a startup path that leaves `AKGL_REGISTRY_PROPERTIES` at 0 gives
you a no-op `akgl_set_property` and an `akgl_get_property` that always hands back your
default** — which in turn means `akgl_render_2d_init` builds a window from its fallback
size and `akgl_physics_init_arcade` runs with no gravity, both reporting success.
If you are debugging configuration that appears not to apply, check
`AKGL_REGISTRY_PROPERTIES != 0` before you check anything else.
## Truncated keys
Every name is copied into a fixed-width field with `aksl_strncpy` bounded to
`sizeof(field) - 1`, which is the "**truncated, not rejected**" contract those headers
document. The fields:
| Object | Field | Bytes |
|---|---|---|
| `akgl_Actor::name` | `AKGL_ACTOR_MAX_NAME_LENGTH` | 128 |
| `akgl_Sprite::name` | `AKGL_SPRITE_MAX_NAME_LENGTH` | 128 |
| `akgl_Character::name` | `AKGL_CHARACTER_MAX_NAME_LENGTH` | 128 |
| `akgl_SpriteSheet::name` | `AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH` | 512 |
**Two distinct names that agree on their first 127 bytes truncate to the same key**, and
the second `SDL_SetPointerProperty` silently replaces the first. The objects are different;
the registry cannot tell. That is recorded in `TODO.md`, "Truncated registry keys can
collide", and it is not being fixed because the fix is a contract change — refusing an
over-long name with `AKERR_OUTOFBOUNDS` — and every one of those headers currently promises
the opposite.
There is a second half that the `TODO.md` entry understates. It says the truncated name *is*
the registry key. That is true for sprites and spritesheets, which register under their own
copied field. It is **not** true for actors and characters:
- `akgl_actor_initialize` and `akgl_character_initialize` register under the **caller's**
string, untruncated.
- `akgl_heap_release_actor` and `akgl_heap_release_character` clear under the object's
**truncated** field.
For any name of 127 bytes or fewer those are the same string and nothing happens. Over that,
the entry is written under one key and cleared under another, so **releasing the object
leaves a live registry entry pointing at a zeroed pool slot** — and the next lookup of that
name hands out a pointer to freed storage. Keep asset names short; 127 bytes is generous for
something a human types into a JSON file.
## Configuration properties
`AKGL_REGISTRY_PROPERTIES` is a string-to-string store. **Everything is a string, including
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 22](22-appendix-limits.md) — the configuration table again, alongside every
`AKGL_MAX_*`.

View 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 eight 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 22](22-appendix-limits.md) — `AKGL_GAME_*` and the rest of the constants.

332
docs/08-rendering.md Normal file
View 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
View 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));
}
```
![](images/primitives.png)
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 17](17-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.

View 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
View 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.

448
docs/12-actors.md Normal file
View File

@@ -0,0 +1,448 @@
# 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 seven 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 |
| `collidefunc` | `akgl_actor_collide_block` | `akgl_collision_resolve`, after the move | answer a contact — see [Chapter 15](15-collision.md) |
Replace them after `akgl_actor_initialize`, never before — it overwrites all seven.
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 16](16-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 16](16-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
View 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 22](22-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.

358
docs/14-physics.md Normal file
View File

@@ -0,0 +1,358 @@
# 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.
*
* With no collision world attached the backend consults nothing, 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. A game
* that attaches a world (Chapter 15) deletes this function instead of writing
* it.
*/
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.
**Collision is [Chapter 15](15-collision.md), and it is opt-in.** A backend with no
collision world attached does exactly what it did before collision existed: `move` is
`position += velocity * dt` and consults nothing. Attach a world and the step resolves after
every sub-move.
What remains missing here is the *feel*, below, and that is a different list from the one
this section used to carry.
### 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`, `move` or `collide`, 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_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.

248
docs/15-collision.md Normal file
View File

@@ -0,0 +1,248 @@
# 15. Collision
Collision is **opt-in**. A physics backend with no collision world attached behaves exactly
as one did before any of this existed and costs one comparison per actor per step, so a game
that does not want it pays nothing and a game written before it existed keeps working.
Turning it on is three things: a world, a shape on anything that should collide, and — for
map geometry — a `collidable` property on the tile layers that are solid.
```c
#include <akgl/collision.h>
#include <akgl/game.h>
#include <akgl/physics.h>
static akgl_CollisionWorld world;
/* After the map is loaded and the physics backend is initialized. */
akerr_ErrorContext AKERR_NOIGNORE *turn_collision_on(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, akgl_collision_world_init(&world, NULL, 16.0f, 16.0f));
PASS(errctx, akgl_collision_bind_tilemap(&world, akgl_gamemap));
akgl_physics->collision = &world;
SUCCEED_RETURN(errctx);
}
```
`NULL` for the partitioner name means the default, which is the one to use. Binding a map
takes the cell size from its tiles, so the two arguments above are overwritten immediately
— they matter only for a world with no map.
## Where it runs in the step
Collision resolves **after** the move, not before it.
```text
movementlogicfunc
v
gravity
v
drag
v
v = e + t
v
+--------------------------------+
| move(subdt) | x substeps
| resolve(subdt) |
+--------------------------------+
```
That ordering is the whole reason a game can stop duplicating the integrator. A resolver
called from `movementlogicfunc` runs before gravity, drag and the move, so it has to
*predict* where the actor will end up — which means re-implementing the arithmetic above, in
the game, and being wrong whenever that arithmetic changes.
**Only `move` is subdivided.** Gravity, drag, the thrust integration and the speed ellipse
all still run once against the whole `dt`, and the sub-steps sum to `dt`. An actor with
nothing to collide with takes exactly one sub-step of exactly `dt`.
## Shapes
A shape is a convex volume positioned relative to an actor's origin. It lives on the
[character](11-characters.md), so every goblin sharing a character shares one definition —
and an individual actor can override it.
```c
#include <akgl/actor.h>
#include <akgl/collision.h>
/* A hitbox inset into a 32x32 sprite frame, because art does not reach the
* edges of its cell and a full-frame box catches on doorways the character
* visibly clears. */
akerr_ErrorContext AKERR_NOIGNORE *give_player_a_body(akgl_Actor *player)
{
SDL_FRect body = { .x = 8.0f, .y = 0.0f, .w = 16.0f, .h = 32.0f };
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, player, AKERR_NULLPOINTER, "player");
PASS(errctx, akgl_collision_shape_box(&player->shape, &body, 0.0f));
player->shape_override = true;
SUCCEED_RETURN(errctx);
}
```
`shape_override` is not redundant with an empty shape. "This actor deliberately has no
collider" and "this actor has not been configured yet" are different states, and without
somewhere to record the difference `akgl_actor_set_character` cannot tell whether it is
allowed to overwrite what it finds.
Three kinds: box, circle, and a capsule on either axis. A box against a box is answered in
closed form, which is both cheaper and *exact* — an actor resting on a floor gets a normal of
precisely `(0, -1, 0)` rather than one converged to a tolerance, and the difference between
those accumulates into a slow sideways creep.
### Why a 2D shape has a depth
The narrowphase is three-dimensional, which is what lets these shapes carry into a 3D game
later. It answers with the **minimum** translation that separates two volumes — and if a 2D
shape were extruded into a thin slab, the cheapest way to separate two of them would be
along z. The narrowphase would report a contact, the resolver would push the actor into the
screen, and **on screen nothing would happen at all while the actor sank through the floor.**
So the setters give every shape a depth of `AKGL_COLLISION_DEPTH_RATIO` times its largest
planar half-extent unless told otherwise. That is not a comfortable-looking number; it is the
smallest ratio for which the z overlap of any two shapes built this way provably exceeds any
planar penetration they can reach. Pass a depth by hand only if you know what that costs, and
leave `AKGL_COLLISION_TEST_PLANAR` on if you do.
## Masks: what collides with what
Every shape carries two words, and they are asymmetric on purpose.
| Field | Means |
|---|---|
| `layermask` | which layers this shape *is on* — what others test against |
| `collidemask` | which layers this shape *responds to* |
`a` is resolved against `b` when `b->layermask & a->collidemask`. The reverse is a separate
question with its own answer, which is how a player blocks against a pushable crate while the
crate ignores everything, and how a bullet collides with a wall while the wall ignores the
bullet.
**The defaults matter more than the mechanism.** A new shape is on
`AKGL_COLLISION_LAYER_ACTOR` and responds to `AKGL_COLLISION_LAYER_STATIC` — so **an actor
given a shape and nothing else collides with map geometry and with no other actor.**
"Everything with a hitbox shoves everything else" is a surprising default for a town full of
scenery and a painful one to discover after the fact. Opting in to actor-versus-actor is one
added bit:
```c norun
player->shape.collidemask |= AKGL_COLLISION_LAYER_ENEMY | AKGL_COLLISION_LAYER_PICKUP;
```
## Tiles
A tile layer is solid when it carries a `collidable` boolean custom property in Tiled. The
map declares what is solid; before this a game had to hard-code a layer index, and
`akgl_TilemapLayer` does not retain the name Tiled wrote, so that index broke the moment
somebody reordered layers in the editor.
Solid tiles are **not** given proxies. At the maximum map size that would be a quarter of a
million per layer — tens of megabytes of index describing data that is already a dense grid
sitting in the tilemap. The world keeps a borrowed pointer and reads `layers[i].data[]`
directly over whatever cell range a query covers: nine array reads for a 32-pixel actor on
16-pixel tiles, nothing built at level load, nothing maintained per frame.
Static geometry that is *not* tile-aligned — a slope, a Tiled object rectangle, a platform
that only moves between levels — is still an ordinary proxy carrying
`AKGL_COLLISION_FLAG_STATIC`. Both mechanisms exist; tiles use the free one because there are
a hundred thousand of them.
## Responding to a contact
Every actor gets a seventh behaviour hook, `collidefunc`, defaulting to
`akgl_actor_collide_block`. Contacts arrive one actor at a time, with the normal already
pointing the way *that* actor has to move — so a game overriding one actor's response never
has to reason about the other's.
The default pushes the actor out along the normal and removes the component of its motion
going into the surface. Three details of that are worth knowing, because each is a way the
same function is easy to write wrongly:
- **It writes `e` and `t`, never `v`.** The step recomputes `v = e + t` at the top of every
frame, so a write to `v` is discarded before anything reads it. An actor holding a
direction into a wall would otherwise accumulate thrust while standing still and leave at
speed the instant the wall ended.
- **It removes the component, not the axis.** Zeroing the whole thrust vector costs the
motion *along* the surface too. Sliding along a wall is the difference between a wall and
glue.
- **A separating contact is left alone.** An actor already moving out of a surface whose
velocity gets zeroed is an actor stuck to a wall it is walking away from, and it reads to a
player as the collision grabbing them.
A contact carries the global tile id, layer and cell when the other side was a tile, which is
what tells a spike from a floor without a second lookup.
### Sensors
A shape flagged `AKGL_COLLISION_FLAG_SENSOR` reports a contact and never pushes. Coins,
trigger volumes, damage zones. The default response returns having changed nothing — walking
through a coin is not being blocked by it.
## Asking without being pushed
Four queries answer a question and resolve nothing. They are what a game reaches for when it
wants to *know*: a ledge probe ahead of a walking enemy, a check that a doorway is clear, a
spawn point that needs validating.
| Function | Answers |
|---|---|
| `akgl_collision_solid_at` | is the tile under this point solid |
| `akgl_collision_box_blocked` | would this rectangle overlap anything solid, tiles and proxies both |
| `akgl_collision_query_box` | visit every proxy that may overlap this rectangle |
| `akgl_collision_settle` | lift a shape out of geometry it was placed inside |
`akgl_collision_settle` deserves the explanation. Resolution stops a shape *entering*
geometry and has nothing to say about one that began inside it — what it does instead is
refuse every move, so an actor spawned in a wall is simply stuck. Level authors produce that
constantly, because an editor rounds an object onto a step. Settling walks the shape up a
tile at a time and refuses loudly rather than searching forever.
## Partitioners
The broad phase is pluggable, the same way the renderer and the physics backend are: a record
of function pointers plus an initializer.
**Use the grid.** It is the default, and `PERFORMANCE.md` measures it at 2.6 times faster
than the tree on the same population — before counting that the tree also rebuilds whenever
anything moves. A grid `move` for a proxy that has not left its cells is 11.5 ns and touches
nothing.
The BSP ships alongside it so that "pluggable" means something: a vtable with one
implementation behind it has never been asked to be a vtable, and the partitioner suite runs
its entire contract against every entry in a table. It would earn its place in a world with
wildly non-uniform object sizes, or one larger than the grid's fixed cell array covers.
```c norun
/* Only if you have measured your own scene and the tree wins. */
akgl_collision_world_init(&world, "bsp", 16.0f, 16.0f);
```
## What is not implemented
- **No rotation.** No actor carries an angle, `akgl_actor_render` hard-codes
`SDL_FLIP_NONE`, and every shape is axis-aligned. The support functions are written so that
adding it touches one static function in the narrowphase; see `TODO.md`.
- **No restitution and no friction.** The default response blocks. Anything bouncier is your
`collidefunc`.
- **No continuous collision.** Sub-stepping bounds how far an actor travels between tests, and
it is capped: above roughly `8 x 0.5 x cellsize` per step — about 1280 px/s on 16-pixel
tiles at the default `max_timestep` — an actor can still pass through a wall. That is a
projectile, not a walker. `AKGL_COLLISION_FLAG_BULLET` reserves the bit for the swept
narrowphase that would fix it, and does nothing today.
- **No concave shapes.** The narrowphase is convex-only, always. A concave collider is
several convex ones.
- **Resolution is pool-order dependent.** An actor low in the pool sees the world one
sub-step stale. This is the arcade bargain, and it is the same one Construct and Phaser
make.
## Where to look next
- [Chapter 14](14-physics.md) — the step this runs inside, and the four gaps in the feel that
collision does not close.
- [Chapter 12](12-actors.md) — the other six behaviour hooks.
- [Chapter 13](13-tilemaps.md) — the map, and what else its custom properties can say.
- [Chapter 20](20-tutorial-sidescroller.md) — a game that uses all of this.

372
docs/16-input.md Normal file
View File

@@ -0,0 +1,372 @@
# 16. 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 07 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 — `akgl_controller_list_keyboards` logs every
attached keyboard and its id, which is how you find the number to pass.
### Bind device 0 unless you mean a specific device
**A `kbid` or `jsid` of `0` matches every device of that kind**, and it is what a
single-player game wants.
Do not reach for `SDL_GetKeyboards()` to fill it in. That call reports what is *attached*;
the id a key event actually *carries* is chosen by the video backend, and the two need not
agree. On X11 without XInput2 every key event carries `SDL_GLOBAL_KEYBOARD_ID`, which is
`0`, while SDL registers the attached keyboard as `SDL_DEFAULT_KEYBOARD_ID`, which is `1`.
With XInput2 the events carry the physical slave device's `sourceid` and
`SDL_GetKeyboards()[0]` may be the master. Binding `SDL_GetKeyboards()[0]` therefore gives
you a map that matches nothing at all, and controls that silently do nothing.
That is not hypothetical — the sidescroller in [Chapter 20](20-tutorial-sidescroller.md)
shipped exactly that bug, and its smoke test passed anyway because the test called the
handlers instead of dispatching events.
`0` is the right spelling for "any" because SDL documents `which` as `0` when the source is
unknown or virtual, and joystick ids start at 1 — so no real device is `0` and nothing is
given up by spending it.
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/17-text-and-fonts.md Normal file
View File

@@ -0,0 +1,223 @@
# 17. 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/18-audio.md Normal file
View File

@@ -0,0 +1,337 @@
# 18. 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 02; a non-positive frequency or sweep step; a zero `ms`; a `sustain` or `volume` outside 0.01.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.

440
docs/19-utilities.md Normal file
View File

@@ -0,0 +1,440 @@
# 19. 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.
## The rectangle overlap test
One function, in `util.h`:
| Function | Question it answers |
|---|---|
| `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);
}
```
Three properties, all deliberate:
**Axis-aligned only.** There is no rotation support and no separating-axis test here. If you
want either, you want a collision shape — [Chapter 15](15-collision.md).
**Edges count as touching.** Two rectangles sharing an edge and no area overlap. Every
comparison is `<=`, and a version written with `<` would silently change what every pickup
test in a consumer answers.
**The comparison is in `float32_t`.** No intermediate form, no truncation, so an overlap
smaller than a pixel registers.
### The arrangement it used to miss
`akgl_collide_rectangles` compares the two rectangles' spans on both axes. Until 0.8.0 it
asked a different question — whether either rectangle enclosed one of the other's four
corners, eight tests, stopping at the first hit — and that question has the wrong answer for
one arrangement:
```text
+---+ +---+
| | | |
+---|---|---+ +---+---+---+ <- overlaps, but
| | | | was | | | | no corner of
+---|---|---+ reported +---+---+---+ either is inside
| | as | | the other
+---+ "no hit" +---+
```
A tall thin rectangle crossing a short wide one overlaps in a plus sign with no corner of
either inside the other, so all eight tests said no — and a long thin platform crossing a
tall thin character is exactly that shape. The header carried a `@note` describing it rather
than a fix.
It is four comparisons now, `r1.x <= r2.x + r2.w && r2.x <= r1.x + r1.w` on both axes.
**If you are upgrading from 0.7.x, three things change.** The cross now reports `true`, which
is the point. The comparison no longer round-trips through an `int` intermediate, so overlaps
and gaps smaller than a pixel are seen rather than truncated away — a pickup test that was
accidentally forgiving by up to a pixel is no longer forgiving. And **four symbols are
gone**:
| Removed in 0.8.0 | Why | What to write instead |
|---|---|---|
| `akgl_rectangle_points` | Built the corner form `akgl_collide_rectangles` no longer uses | Nothing. It had no other caller |
| `akgl_collide_point_rectangle` | Same | `p.x >= r.x && p.x <= r.x + r.w` on both axes — four comparisons, and no `SDL_FRect`-to-corners conversion in front of them |
| `akgl_Point` | Existed to carry an `int` position into the above, with a `z` nothing ever read | `SDL_FPoint`, if you want a point type |
| `akgl_RectanglePoints` | Existed to carry four of them | — |
Nothing outside `tests/` called either function. They were the intermediate form of an
implementation that changed.
### What stays, and why it is not collision
**The library runs its own collision** — [Chapter 15](15-collision.md) — and it does not use
this. `akgl_collision_test` works in centres and half-extents, answers with a normal and a
depth rather than a boolean, and handles circles and capsules as well as boxes.
`akgl_collide_rectangles` stays because a game-level overlap question is not always a physics
question: a coin, a minimap marker, a UI hit test, "is the cursor over this". Both tutorials
use it for pickups and hazards from an `updatefunc`, where a `bool` is the whole answer and a
proxy, a broad-phase insert and a narrowphase call would be computing a normal nothing reads.
For anything that should push or be pushed, use a collision shape.
## 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 seven 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.

File diff suppressed because it is too large Load Diff

1254
docs/21-tutorial-jrpg.md Normal file

File diff suppressed because it is too large Load Diff

429
docs/22-appendix-limits.md Normal file
View File

@@ -0,0 +1,429 @@
# 22. 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`, `_collision_proxy`, `_collision_cell`, `_bspnode` | `AKGL_ERR_HEAP` |
| `akgl_heap_release_actor`, `_sprite`, `_spritesheet`, `_character`, `_string`, `_collision_proxy`, `_collision_cell`, `_bspnode` | `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`, plus `akgl_collision_resolve`'s. A no-op when `self->collision` is `NULL` |
### `collision.h`
| Function | Raises |
|---|---|
| `akgl_collision_shape_box`, `_shape_circle`, `_shape_capsule`, `_shape_bounds`, `_shape_interacts` | `AKERR_NULLPOINTER` |
| `akgl_collision_world_init` | `AKERR_NULLPOINTER`, **`AKERR_VALUE` for a non-positive cell size**, plus `akgl_partitioner_factory`'s |
| `akgl_collision_bind_tilemap` | `AKERR_NULLPOINTER`, plus `akgl_get_json_properties_boolean`'s; handles `AKERR_KEY` (H) for a layer with no `collidable` property |
| `akgl_collision_test` | `AKERR_NULLPOINTER`, **`AKGL_ERR_COLLISION` when the ccd arena is exhausted** — the message carries the high-water mark |
| `akgl_collision_resolve` | `AKERR_NULLPOINTER`, plus `akgl_collision_test`'s and the actor's own `collidefunc`'s |
| `akgl_collision_settle` | `AKERR_NULLPOINTER`, **`AKERR_VALUE` when the shape is not clear within `maxsteps`** |
| `akgl_collision_solid_at`, `_box_blocked`, `_query_box`, `_substeps`, `_sync_actors` | `AKERR_NULLPOINTER` |
| `akgl_collision_proxy_initialize`, `_proxy_sync`, `_cell_initialize` | `AKERR_NULLPOINTER` |
| `akgl_partitioner_factory` | `AKERR_NULLPOINTER`, **`AKERR_KEY` for an unknown partitioner name** |
| `akgl_partitioner_init_grid`, `_init_bsp` | `AKERR_NULLPOINTER`. The BSP's `insert` raises `AKGL_ERR_HEAP` past `AKGL_MAX_HEAP_COLLISION_PROXY` proxies |
| `akgl_actor_collide_block` | `AKERR_NULLPOINTER` |
### `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_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_*` eight
and `AKGL_CCD_ARENA_BYTES` 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
```
```c excerpt=include/akgl/heap.h
#ifndef AKGL_MAX_HEAP_COLLISION_PROXY
#define AKGL_MAX_HEAP_COLLISION_PROXY (AKGL_MAX_HEAP_ACTOR * 2)
#endif
```
```c excerpt=include/akgl/heap.h
#ifndef AKGL_MAX_HEAP_BSPNODE
#define AKGL_MAX_HEAP_BSPNODE (AKGL_MAX_HEAP_COLLISION_PROXY)
#endif
#ifndef AKGL_MAX_HEAP_COLLISION_CELL
#define AKGL_MAX_HEAP_COLLISION_CELL (AKGL_MAX_HEAP_COLLISION_PROXY * 16)
#endif
```
Exceeding any of them is `AKGL_ERR_HEAP`.
### Collision
Three of these are not in a public header, because nothing outside the broad phase may size
anything against them:
| Constant | Value | Where | Bounds |
|---|---:|---|---|
| `AKGL_CCD_ARENA_BYTES` | 65536 | `collision_arena.h` | The narrowphase's whole allocation budget. Overridable. One GJK/EPA box pair measures 7,264 bytes |
| `AKGL_COLLISION_GRID_COLS` / `_ROWS` | 128 | `src/collision_grid.c` | The dense cell-head array, 32 KB. A map wider than 128 cells wraps rather than growing |
| `AKGL_COLLISION_GRID_MAX_SPAN` | 16 | `src/collision_grid.c` | Cells one proxy may occupy. Anything larger spills to one oversized chain, which is what makes `AKGL_MAX_HEAP_COLLISION_CELL` provable rather than hopeful |
| `AKGL_COLLISION_MAX_SUBSTEPS` | 8 | `src/collision.c` | Sub-steps per actor per step, and therefore the speed above which tunnelling returns |
| `AKGL_COLLISION_MAX_ITERATIONS` | 100 | `src/collision.c` | `ccd_t.max_iterations`. libccd's own default is `(unsigned long)-1` — an unbounded loop inside a frame |
| `AKGL_COLLISION_DEPTH_RATIO` | 2.0 | `collision.h` | The z extrusion, as a multiple of the largest planar half-extent. See [Chapter 15](15-collision.md) before changing it |
### 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.

271
docs/MAINTENANCE.md Normal file
View File

@@ -0,0 +1,271 @@
# Maintaining the manual
This file documents the harness that keeps `docs/` honest. It is not a chapter —
a reader learning libakgl never needs it — and it is checked by the same harness
it describes, so the file that publishes the convention is held to it.
**The harness checks that every listing is true. It cannot check that a chapter
teaches.** For that, the tutorials get a second test: the chapter is handed to a
subagent on a weaker model with the library, the headers and the art but *not*
the finished program, and that reader has to build and run the game. See
`AGENTS.md`, "Testing a Tutorial". Four read-only reviews of chapters 20 and 21
missed three defects the first build-and-run found in minutes.
## Documentation examples
**Every fenced block in `docs/*.md` is compiled, linked, run, or matched against
a source file by `ctest`.** The test is `docs_examples`; it runs in an ordinary
build and it fails the build.
It exists because the documentation this manual replaced had not been executed
since the day it was typed. In four hundred lines of `README.md`: two `PASS()`
calls with unbalanced parentheses, a `sprite->frameids = [0, 1, 2, 3];` that is
not C in any dialect, a stray `9` inside a bitmask expression, an
`int screenwidth = NULL`, and — in the first snippet a reader ever saw — the
exact `strncpy` call `AGENTS.md` forbids under **Copying Into Fixed-Width
Fields**. Two prose claims in the headers were false against `src/` as well.
That is not a reader routing around typos; it is what happens to samples nothing
runs.
Documentation goes stale because the **code** moved, not because somebody edited
a chapter. So this is an ordinary test case rather than something a docs-only CI
job runs behind a path filter — it fails when the library changes under the
chapter, which is precisely the case a path filter would miss.
**An untagged block is a failure**, not a default, and so is a tag this harness
does not know. That is deliberate: the way a harness like this dies is by
quietly matching nothing and passing, so an untagged block is a missing decision
rather than a free pass.
### The tags
| Info string | What happens |
|---|---|
| `c` | A whole translation unit. Compiled with `-fsyntax-only -std=gnu99 -Wall -Werror` against the real include path. It shows its own `#include` lines |
| `c wrap=NAME` | The same, with `tests/docs_preludes/NAME.pre` before it and `NAME.post` after |
| `c run=NAME` | Wrapped in `NAME`, **linked against `akgl` and run headless**. Must exit 0 and must not report an unhandled or ignored error |
| `c excerpt=PATH` | Must still appear in `PATH`, ignoring comments and whitespace. **Not compiled** |
| `json excerpt=PATH` | The same check against an asset file, for a fragment too large to show whole |
| `c screenshot=NAME` | Compiled as an `akglframe` body, and the source of `docs/images/NAME.png`. Optionally `size=WxH` |
| `json kind=KIND` | Written to a file and loaded through libakgl's own loader. `KIND` is `sprite`, `character`, `tilemap` or `properties` |
| `output` | The **exact stdout** of the runnable block above it, compared byte for byte |
| `sh` | Run in a sandbox. Must exit 0. A leading `$ ` is stripped |
| `sh norun` | Shown, not run. Destructive, networked, or it re-enters this suite |
| `cmake` | **Never executed.** Hand-maintained, by decision |
| `text` | **Never executed.** A block diagram, or a stack trace captured from a real run |
| `norun` | Shown, never executed, whatever it is |
These attributes combine with several of the above:
| Attribute | What it does |
|---|---|
| `setup=NAME` | Runs `tests/docs_setups/NAME.sh` in the sandbox first, with the repository root as its only argument. Valid on `c run=`, `json`, `sh` and `c screenshot=` |
| `size=WxH` | A figure's surface size. Only on `c screenshot=`; the default is 320x240 |
| `name=NAME` | Gives a `json` block a name a later block in the same chapter can preload |
| `preload=A,B` | Loads those named `json` blocks, in that order, before this one |
### Which one to reach for
In this order, and a step down the list is a decision that belongs in the prose:
- **`c run=akglapp` with an `output` block below it.** The strongest shape there
is. `-fsyntax-only` proves a call typechecks; it does not prove the startup
order works, that the sprite loaded, or that the `ATTEMPT` block gave back
what it took. A `run` block proves all three, and the `output` block pins what
the reader will actually see.
- **`c run=akglapp` alone**, where the example has no output. The harness still
asserts a clean exit and that nothing raised.
- **`c wrap=akglbody`**, for a call sequence whose effect is not visible on
stdout.
- **`c`**, for a translation unit the chapter wants shown whole — a header, a
backend definition, a `main`.
- **`c excerpt=PATH`**, for a constant table, a `typedef` or a declaration.
Compiling one of those would only redefine the type; the question worth asking
is whether it still says what the header says.
- **`json kind=`**, for any asset file.
- **`norun`**, for a fragment or something that cannot be run here. Say why in
the prose. **A rising `norun` count is a review finding** — it is the harness
being talked out of its job.
### Preludes
`tests/docs_preludes/NAME.pre` and `NAME.post` bracket the block. They exist so
an example can be written the way a reader wants to read it while still
compiling — four lines of `PREPARE_ERROR` scaffolding repeated in twenty
chapters teaches the protocol twenty times and the call once.
| Prelude | What the block supplies |
|---|---|
| `akglfile` | File scope: declarations, a `movementlogicfunc`, a backend vtable. Includes only; there is no `.post` |
| `akglbody` | The inside of a function returning an error context, with `errctx` already prepared |
| `akglapp` | The same body, plus a `main` that brings libakgl up headless and runs it. This is what `run=` uses |
| `akglframe` | One frame of drawing, for a figure. Linked against `tools/docs_screenshot.c` |
All four begin with `tests/docs_preludes/docs_prelude.h`, which is the include
set — every public header, since the `headers` suite already proves each one is
self-contained. A block with no `wrap=` gets none of it and shows its own
includes, which is usually what a chapter wants a reader to see.
What does **not** belong in a prelude is anything that would let a wrong example
compile. A prelude declaring `akgl_game_init` itself would defeat the entire
check.
Compiler diagnostics point at the markdown: the harness emits a `#line`
directive before each body, so a broken example reports as
`docs/14-physics.md:112: error: too few arguments`, not as a line in a scratch
file nobody can open.
### Why `-Werror` here and not on the library
`AGENTS.md` keeps `AKGL_WERROR` **off** by default, and for a good reason:
libakgl is consumed with `add_subdirectory()``akbasic` does exactly that — so
a target-level `-Werror` turns every diagnostic a newer compiler invents into a
broken build for somebody else's project.
A documentation snippet is not a consumer. A sample that warns is a sample that
teaches the warning, and it is the maintainer of this repository who sees the
failure. So the snippets get `-Werror` and the library does not.
`gnu99` rather than `c99`, because `akerror.h` uses `PATH_MAX`, which
`<limits.h>` hides under `__STRICT_ANSI__`. A stricter dialect would fail on the
dependency rather than on the example.
### Asset formats
A `json kind=` block is written to a file and handed to `akgl_docs_checkjson`,
which brings libakgl up headless and calls the loader that really reads that
format — `akgl_sprite_load_json`, `akgl_character_load_json`,
`akgl_tilemap_load` or `akgl_registry_load_properties`.
This exists because the formats are described in prose here and parsed in
`src/`, with nothing tying the two together. That gap is not hypothetical:
`util/assets/littleguy.json` ships beside the loader it is invalid against.
The loaders really open the images and tilesets a block names, so a block that
references one needs a `setup=`. Adding a format is a row in the `KINDS` table
in `tools/docs_checkjson.c`, not a branch.
**Three of the four formats are not self-contained.** A character names sprites
that have to be in `AKGL_REGISTRY_SPRITE` already; a tilemap names characters,
and really opens its tileset image. There are two ways to satisfy that, and
which one to use depends on whether the dependency is something the reader
should see.
**Show it, with `name=` and `preload=`.** A `json kind=sprite name=herostand`
block earlier in a chapter and a `json kind=character preload=herostand` block
later in the same one are loaded into a single process, in that order. The
dependency ends up in front of the reader — which is right when the chapter is
teaching how the two files relate. Names are per-document, so one chapter cannot
reach into another and make the order documents are checked in load-bearing.
**Hide it, with `setup=`.** When the chapter is about one file and the others
would be noise, the fixtures go in a setup script and the `prepare` column of
the `KINDS` table in `tools/docs_checkjson.c` loads them:
| Setup | What it stages | What loads it |
|---|---|---|
| `setup=spritesheet` | `./spritesheet.png`, a 12x8 grid of 48x48 frames | the `sprite` row's `prepare` |
| `setup=character` | `./sprites/*.json` plus a sheet, named `hero standing left` and `hero walking left` | the `character` row's `prepare` |
| `setup=tilemap` | `./sprites/*.json`, `./characters/testcharacter.json`, and `assets/tileset.png` | the `tilemap` row's `prepare` |
| `setup=sidescroller` | every PNG and sprite file from `docs/tutorials/assets/sidescroller`, in `./` and `./sprites` | both rows' `prepare` |
| `setup=jrpg` | the same, from `docs/tutorials/assets/jrpg` | both rows' `prepare` |
The last two are how the tutorials show a reader a *real* asset file rather than a
generic one. A beginner following chapter 20 or 21 types what is on the page, so
what is on the page has to be a file that loads — the setup stages the tutorial's
own art so the block on the page is the block in the repository.
Those names are a contract between the setup script and the chapter using it.
Change one and change the other, in the same commit. `assets/tileset.png` is
deliberately not the RPG-Maker-named image `tests/assets/testmap.tmj` uses — see
the comment in `tests/docs_setups/tilemap.sh`.
Two things about map fixtures, both confirmed against `src/tilemap.c` rather
than against the prose:
- **Tilesets must be embedded.** `"source"` appears nowhere in the loader;
`akgl_tilemap_load_tilesets_each` reads `columns`, `firstgid`, `tilecount` and
`image` straight off each element of the map's own `tilesets` array. The
external reference Tiled writes by default does not load.
- **Every object in an object group needs a `type` string**, plain rectangles
included. A missing one fails the whole load.
### Figures are output, not assets
A chapter about drawing wants a picture of what the drawing calls draw, and the
way a picture goes wrong is that it stops being of the code beside it —
silently, because a hand-taken screenshot still looks like a screenshot long
after the call changed. So a figure is generated from the listing it
illustrates:
```sh norun
cmake --build build --target docs_screenshots
```
`tools/docs_screenshot.c` is a small second host: the dummy video driver, a
software renderer, one call to the block's `docs_frame()`, read the target back,
write a PNG. `tools/docs_screenshots.sh` compiles the listing against it.
Three rules, and `docs/images/README.md` carries the long version:
- **The PNGs are tracked.** A reader on the forge has no build tree. They are
generated files tracked on purpose, so **do not hand-edit one** — change the
listing and regenerate.
- **The build never regenerates them.** The target is only ever run
deliberately.
- **`docs_examples` fails a tagged block with no image**, so a figure cannot be
added to a chapter and then forgotten. Whether the image is *of that listing*
is the separate `docs_screenshots` test's question: it re-renders every figure
into a scratch directory and compares byte for byte, and it never repairs what
it finds.
### When `docs_examples` fails
The message names the file and the line of the block's **opening fence**. For an
output mismatch it prints both sides through `cat -A`, because the errors it
catches are trailing spaces and missing newlines, which a plain diff renders
invisibly.
**Fix the documentation, not the expectation** — unless the library is what
changed, in which case fix the library first and the documentation second. Never
edit an `output` block to match output you have not looked at.
Run one document at a time while you work:
```sh norun
./tests/docs_examples.sh --root . \
--cflags-file build/docs_cflags.txt \
--ldflags-file build/docs_ldflags.txt \
--checkjson build/akgl_docs_checkjson \
docs/14-physics.md
```
Every path is made absolute by the script before it starts, because it `cd`s
into sandbox directories. A relative one used to fail every example and every
`setup=` — invisible under CTest, which passes absolute paths, and immediate for
anyone running one document by hand.
Omit a flag and the blocks that need it are **skipped and counted**, not
silently passed, so a partial invocation cannot look like a clean run.
**Exit status is the number of failed examples**, the house convention; `2` is a
usage or setup error, which is a different thing and has to be distinguishable.
A document named on the command line that cannot be read is `2` — never a
silent fall back to the default list, which is how a suite ends up passing
having checked nothing.
### Read the census line
The last two lines of a run report **what it executed, by kind**:
```text
ran: 41 c snippets, 6 linked programs, 6 output comparisons, 18 excerpts, 4 json blocks, 2 shell blocks, 3 figures
skipped: 5 norun, 2 cmake, 7 text, 0 c (no compiler flags given), 0 run (no linker flags given), 0 json (no validator given)
```
That count is part of the result, not decoration. A harness that passes because
a chapter stopped matching the extractor looks exactly like a harness that
passes because the documentation is correct, and the census is the only thing
that tells them apart. In `akbasic` it caught exactly that: a CMake generator
expression evaluated to an empty argument, the script read it as a filename, and
the suite passed having checked no documents at all.

68
docs/README.md Normal file
View File

@@ -0,0 +1,68 @@
# 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 eight 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 seven 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. Collision](15-collision.md)** | Shapes, masks, tiles as geometry, the response hook, and the two partitioners |
| **[16. Input](16-input.md)** | Control maps, push-not-poll dispatch, the keystroke ring, gamepads |
| **[17. Text and fonts](17-text-and-fonts.md)** | Loading, drawing, measuring, and the teardown order that matters |
| **[18. Audio](18-audio.md)** | The three-voice synthesizer, and the separate background-music path |
| **[19. Utilities](19-utilities.md)** | Rectangle overlap, path resolution, the JSON accessors, static strings |
| **[20. Tutorial: a 2D sidescroller](20-tutorial-sidescroller.md)** | Thirteen steps from an empty directory to a game with gravity, a jump, coins and hazards. **Start here** |
| **[21. Tutorial: a top-down JRPG](21-tutorial-jrpg.md)** | Thirteen more, for four-way movement, NPCs, a text box, a follower, and freezing the world |
| **[22. Appendix](22-appendix-limits.md)** | Every limit, every status, every configuration property |
**If you are new, read chapter 20 first and read it in order.** It builds a working game from
an empty directory and teaches the library as it needs each piece; chapter 21 assumes it.
Both 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. The picture at the top of each chapter is a frame
out of the game itself, regenerated by `cmake --build build --target docs_game_figures`.
## 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.

40
docs/images/README.md Normal file
View File

@@ -0,0 +1,40 @@
# The figures in this directory are generated, and are tracked deliberately
Every `.png` beside this file is **output**, not art. Each one is rendered by
`tools/docs_screenshots.sh` from a ```` ```c screenshot=NAME ```` block in a
chapter — the same code the reader is looking at, compiled against
`tools/docs_screenshot.c`, run headless, and read back off the render target.
They are committed to the repository **on purpose**: a reader looking at the
manual on the forge has no build tree, and a chapter whose figures only exist
after `cmake --build` renders as a page of broken images. This is the same
tracked-generated-artifact contract `AGENTS.md` spells out for
`include/akgl/SDL_GameControllerDB.h`. Do not delete them, do not add them to
`.gitignore`, and do not "clean up" the fact that generated files are tracked.
Working rules:
- **Never hand-edit one, and never replace one with a hand-taken screenshot.**
The whole point of the arrangement is that the picture follows the code. A
figure that was retouched is a figure that has stopped being of the listing
beside it, silently — which is exactly the failure this replaced.
- **An ordinary build does not touch them.** Regeneration is
`cmake --build build --target docs_screenshots`, and nothing else runs the
script. A build that quietly rewrote eight binaries would put that diff in
front of whoever happened to run `make`.
- **Regenerate as a deliberate change**, in the same commit as whatever moved
the picture, so the binary diff is attributable.
- **`ctest -R docs_screenshots` re-renders every figure and compares it byte for
byte**, into a scratch directory. It never repairs what it finds: a test that
fixes what it is measuring passes the second time for the wrong reason.
- **`ctest -R docs_examples` fails a tagged block with no image here**, so a new
figure cannot be added to a chapter and then forgotten.
A byte comparison of a rendered PNG is a deliberate bet: that the dummy video
driver and the software renderer are reproducible run to run and build to build.
They are. What that does not cover is an SDL upgrade shifting one pixel of a
diagonal — and the answer to that is to regenerate the figures in the same
commit as the bump, not to weaken the check to a size comparison that would pass
for every wrong picture of the right dimensions.
See `docs/MAINTENANCE.md` for the fence tags and the rest of the harness.

BIN
docs/images/jrpg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
docs/images/primitives.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1,121 @@
Creative Commons Legal Code
CC0 1.0 Universal
CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
HEREUNDER.
Statement of Purpose
The laws of most jurisdictions throughout the world automatically confer
exclusive Copyright and Related Rights (defined below) upon the creator
and subsequent owner(s) (each and all, an "owner") of an original work of
authorship and/or a database (each, a "Work").
Certain owners wish to permanently relinquish those rights to a Work for
the purpose of contributing to a commons of creative, cultural and
scientific works ("Commons") that the public can reliably and without fear
of later claims of infringement build upon, modify, incorporate in other
works, reuse and redistribute as freely as possible in any form whatsoever
and for any purposes, including without limitation commercial purposes.
These owners may contribute to the Commons to promote the ideal of a free
culture and the further production of creative, cultural and scientific
works, or to gain reputation or greater distribution for their Work in
part through the use and efforts of others.
For these and/or other purposes and motivations, and without any
expectation of additional consideration or compensation, the person
associating CC0 with a Work (the "Affirmer"), to the extent that he or she
is an owner of Copyright and Related Rights in the Work, voluntarily
elects to apply CC0 to the Work and publicly distribute the Work under its
terms, with knowledge of his or her Copyright and Related Rights in the
Work and the meaning and intended legal effect of CC0 on those rights.
1. Copyright and Related Rights. A Work made available under CC0 may be
protected by copyright and related or neighboring rights ("Copyright and
Related Rights"). Copyright and Related Rights include, but are not
limited to, the following:
i. the right to reproduce, adapt, distribute, perform, display,
communicate, and translate a Work;
ii. moral rights retained by the original author(s) and/or performer(s);
iii. publicity and privacy rights pertaining to a person's image or
likeness depicted in a Work;
iv. rights protecting against unfair competition in regards to a Work,
subject to the limitations in paragraph 4(a), below;
v. rights protecting the extraction, dissemination, use and reuse of data
in a Work;
vi. database rights (such as those arising under Directive 96/9/EC of the
European Parliament and of the Council of 11 March 1996 on the legal
protection of databases, and under any national implementation
thereof, including any amended or successor version of such
directive); and
vii. other similar, equivalent or corresponding rights throughout the
world based on applicable law or treaty, and any national
implementations thereof.
2. Waiver. To the greatest extent permitted by, but not in contravention
of, applicable law, Affirmer hereby overtly, fully, permanently,
irrevocably and unconditionally waives, abandons, and surrenders all of
Affirmer's Copyright and Related Rights and associated claims and causes
of action, whether now known or unknown (including existing as well as
future claims and causes of action), in the Work (i) in all territories
worldwide, (ii) for the maximum duration provided by applicable law or
treaty (including future time extensions), (iii) in any current or future
medium and for any number of copies, and (iv) for any purpose whatsoever,
including without limitation commercial, advertising or promotional
purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
member of the public at large and to the detriment of Affirmer's heirs and
successors, fully intending that such Waiver shall not be subject to
revocation, rescission, cancellation, termination, or any other legal or
equitable action to disrupt the quiet enjoyment of the Work by the public
as contemplated by Affirmer's express Statement of Purpose.
3. Public License Fallback. Should any part of the Waiver for any reason
be judged legally invalid or ineffective under applicable law, then the
Waiver shall be preserved to the maximum extent permitted taking into
account Affirmer's express Statement of Purpose. In addition, to the
extent the Waiver is so judged Affirmer hereby grants to each affected
person a royalty-free, non transferable, non sublicensable, non exclusive,
irrevocable and unconditional license to exercise Affirmer's Copyright and
Related Rights in the Work (i) in all territories worldwide, (ii) for the
maximum duration provided by applicable law or treaty (including future
time extensions), (iii) in any current or future medium and for any number
of copies, and (iv) for any purpose whatsoever, including without
limitation commercial, advertising or promotional purposes (the
"License"). The License shall be deemed effective as of the date CC0 was
applied by Affirmer to the Work. Should any part of the License for any
reason be judged legally invalid or ineffective under applicable law, such
partial invalidity or ineffectiveness shall not invalidate the remainder
of the License, and in such case Affirmer hereby affirms that he or she
will not (i) exercise any of his or her remaining Copyright and Related
Rights in the Work or (ii) assert any associated claims and causes of
action with respect to the Work, in either case contrary to Affirmer's
express Statement of Purpose.
4. Limitations and Disclaimers.
a. No trademark or patent rights held by Affirmer are waived, abandoned,
surrendered, licensed or otherwise affected by this document.
b. Affirmer offers the Work as-is and makes no representations or
warranties of any kind concerning the Work, express, implied,
statutory or otherwise, including without limitation warranties of
title, merchantability, fitness for a particular purpose, non
infringement, or the absence of latent or other defects, accuracy, or
the present or absence of errors, whether or not discoverable, all to
the greatest extent permissible under applicable law.
c. Affirmer disclaims responsibility for clearing rights of other persons
that may apply to the Work or any use thereof, including without
limitation any person's Copyright and Related Rights in the Work.
Further, Affirmer disclaims responsibility for obtaining any necessary
consents, permissions or other rights required for any use of the
Work.
d. Affirmer understands and acknowledges that Creative Commons is not a
party to this document and has no duty or obligation with respect to
this CC0 or use of the Work.

View File

@@ -0,0 +1,21 @@
Music Jingles
by Kenney Vleugels (Kenney.nl)
------------------------------
License (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
You may use these assets in personal and commercial projects.
Credit (Kenney or www.kenney.nl) would be nice but is not mandatory.
------------------------------
Donate: http://support.kenney.nl
Request: http://request.kenney.nl
Follow on Twitter for updates:
@KenneyNL

View File

@@ -0,0 +1,22 @@
Pixel Line Platformer (1.0)
Created/distributed by Kenney (www.kenney.nl)
Creation date: 15-08-2021
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
------------------------------
Donate: http://support.kenney.nl
Patreon: http://patreon.com/kenney/
Follow on Twitter for updates:

View File

@@ -0,0 +1,23 @@
RPG Urban Pack 1.0
Created/distributed by Kenney (www.kenney.nl)
Creation date: 05-01-2019
------------------------------
License: (Creative Commons Zero, CC0)
http://creativecommons.org/publicdomain/zero/1.0/
This content is free to use in personal, educational and commercial projects.
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
------------------------------
Donate: http://support.kenney.nl
Request: http://request.kenney.nl
Patreon: http://patreon.com/kenney/
Follow on Twitter for updates:

View File

@@ -0,0 +1,151 @@
# Provenance of the tutorial assets
Everything under `docs/tutorials/assets/` is **CC0 1.0** or was written for this
repository. Nothing here carries an attribution requirement, a share-alike
clause, or a field-of-use restriction, which is the whole point: a reader who
copies a tutorial into their own game inherits whatever obligation these assets
carry, and CC0 carries none. "Free to download" is not the same thing and was
not accepted -- each pack's licence was read on its Kenney page *and* in the
`License.txt` it ships, and `scripts/fetch_tutorial_assets.sh` re-checks both on
every refresh and refuses to vendor a pack that fails either.
`LICENSE` is the CC0 1.0 legal code. The three `LICENSE.kenney_*.txt` files are
the licence statements the packs themselves ship, kept beside the art in the
same way `tests/assets/akgl_test_mono.LICENSE.txt` sits beside its font.
The two tables below account for all 56 asset files: 14 carry upstream bytes and
42 were written here. This file and `README.md` are the remaining two, and are
documentation of the same kind as the rest of `docs/`.
## Upstream packs
| Pack | URL | Licence | Licence file in this tree |
|-----------------------|------------------------------------------------|---------|--------------------------------------------|
| Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | `LICENSE.kenney_pixel-line-platformer.txt` |
| RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | `LICENSE.kenney_rpg-urban-pack.txt` |
| Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `LICENSE.kenney_music-jingles.txt` |
Kenney asks for credit and does not require it. Credit is given here and in the
tutorial chapters.
## Files carrying upstream bytes
| File | Pack | Source URL | Licence | Cropped / repacked into |
|--------------------------------------------|-----------------------|-----------------------------------------------------------------|---------|----------------------------------------------------------------------------------------------------|
| `LICENSE` | creativecommons.org | https://creativecommons.org/publicdomain/zero/1.0/legalcode.txt | CC0 1.0 | The CC0 1.0 Universal legal code, fetched verbatim |
| `LICENSE.kenney_pixel-line-platformer.txt` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
| `LICENSE.kenney_rpg-urban-pack.txt` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
| `LICENSE.kenney_music-jingles.txt` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | The pack's own `License.txt`, copied verbatim |
| `sidescroller/tiles.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | `Tilemap/tilemap_packed.png` copied verbatim: 160x96, 10x6 tiles of 16x16, no spacing, no margin |
| `sidescroller/player.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tiles 40, 41, 42 and their horizontal mirrors, bottom-centred in six 32x32 cells |
| `sidescroller/coin.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tile 44, bottom-centred in one 32x32 cell |
| `sidescroller/hazard.png` | Pixel Line Platformer | https://kenney.nl/assets/pixel-line-platformer | CC0 1.0 | Tiles 55, 56, 51, 52, bottom-centred in four 32x32 cells |
| `sidescroller/jingle_start.ogg` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `Audio/8-Bit jingles/jingles_NES00.ogg`, copied verbatim (1.8 s) |
| `jrpg/tiles.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | `Tilemap/tilemap_packed.png` copied verbatim: 432x288, 27x18 tiles of 16x16, no spacing, no margin |
| `jrpg/player.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 0 (tileset columns 23-26, rows 0-2) regrouped into twelve 32x32 cells |
| `jrpg/npc_shopkeeper.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 3 (tileset columns 23-26, rows 9-11) regrouped into twelve 32x32 cells |
| `jrpg/npc_elder.png` | RPG Urban Pack | https://kenney.nl/assets/rpg-urban-pack | CC0 1.0 | Character block 2 (tileset columns 23-26, rows 6-8) regrouped into twelve 32x32 cells |
| `jrpg/jingle_start.ogg` | Music Jingles | https://kenney.nl/assets/music-jingles | CC0 1.0 | `Audio/Pizzicato jingles/jingles_PIZZI07.ogg`, copied verbatim (1.3 s) |
## Files written for this repository
These contain no upstream content. They are libakgl source in JSON form: they
name the art above, they do not embed it.
| File | Kind | What it is |
|-------------------------------------------------|-----------|---------------------------------------------------------------------------------|
| `jrpg/character_jrpg_elder.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `jrpg/character_jrpg_player.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `jrpg/character_jrpg_shopkeeper.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `jrpg/sprite_jrpg_elder_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_elder_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_player_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_idle_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_idle_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_walk_down.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_walk_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_walk_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/sprite_jrpg_shopkeeper_walk_up.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `jrpg/town.tmj` | map | Tiled 1.8 TMJ, tileset **embedded**. 30x20 cells, ground + decoration + actors |
| `sidescroller/character_ss_coin.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `sidescroller/character_ss_hazard_blob.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `sidescroller/character_ss_hazard_moth.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `sidescroller/character_ss_player.json` | character | State-name to sprite-name bindings; contains no upstream content |
| `sidescroller/level1.tmj` | map | Tiled 1.8 TMJ, tileset **embedded**. 40x15 cells, background + terrain + actors |
| `sidescroller/sprite_ss_coin.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_hazard_blob.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_hazard_moth.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_idle_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_idle_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_jump_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_jump_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_run_left.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
| `sidescroller/sprite_ss_player_run_right.json` | sprite | Frame list into the sheet named by its own `spritesheet.filename` |
## How the repacking works, and why
**Tilesets are copied verbatim.** Both packs already ship a 16x16 grid with zero
spacing and zero margin, which is the only geometry
`akgl_tilemap_compute_tileset_offsets` gets right -- it adds `spacing` to the
tile pitch but sets the *first* row's y offset to `spacing` rather than zero,
and it ignores `margin` completely. A packed sheet with a 1 px gutter, which is
what most of Kenney's older packs ship, would render every tile in row 0 one
pixel low. That ruled several otherwise-suitable packs out.
**Character sheets are repacked into one row of 32x32 cells.** The asset
contract for these tutorials is 16x16 tiles and 32x32 character frames, and
neither pack ships 32x32 art, so each 16x16 source tile is composited at (8, 16)
inside its cell -- horizontally centred, sitting on the cell's bottom edge. The
art is not resampled: a 2x upscale would put 2x2 pixel blocks next to 1x1 tiles,
and every sprite in the repository would stop matching the source pixel for
pixel. The cost is that three quarters of each cell is empty, and the benefit is
one rule the tutorials can rely on: **an actor drawn into a 32x32 destination
rectangle has its feet on the bottom edge of that rectangle and its body
centred**, whichever sheet it came from.
**Left-facing frames are stored, not mirrored at draw time.**
`akgl_actor_render` calls `draw_texture` with `SDL_FLIP_NONE` hard-coded
(`src/actor.c`), so there is no way to ask for a mirrored blit. The sidescroller
sheet therefore carries frames 0-2 facing right and frames 3-5 as their
horizontal mirrors. The JRPG sheets need no mirroring: the RPG Urban Pack draws
both side views.
**One frame is used twice.** Pixel Line Platformer has no dedicated jump pose.
Frame 1 is the passing position of the run cycle, which is the airborne one, and
`ss_player_jump_right` reuses it. Said here rather than left for a reader to
notice.
**The coin does not animate.** Pixel Line Platformer ships one gold pickup tile
and no rotation frames, so `sprite_ss_coin.json` is a single frame. Nothing was
invented to pad it out.
## Reproducing this
```sh
scripts/fetch_tutorial_assets.sh
```
Fetches all three packs, checks each page and each shipped `License.txt` says
CC0, verifies the source images are the size the frame arithmetic assumes,
repacks into a temporary directory, checks every staged file's dimensions, and
only then moves anything into place. A failure at any step leaves the tracked
copies untouched and exits non-zero. The output is byte-for-byte reproducible --
ImageMagick's wall-clock `date:create` and `date:modify` chunks are stripped, so
a refresh that changes nothing produces no diff.
The script does not touch the JSON or the TMJ maps. Those are hand-maintained.

View File

@@ -0,0 +1,162 @@
# The tutorial asset contract
What `examples/sidescroller/` and `examples/jrpg/` are drawing, and the exact
numbers they have to use. Licensing is in `PROVENANCE.md`; refreshing the art
from upstream is `scripts/fetch_tutorial_assets.sh`.
Every claim below was checked against `src/`, not against header prose, and the
whole set was loaded through the real library -- `akgl_sprite_load_json`,
`akgl_character_load_json`, `akgl_tilemap_load` -- before being committed.
## Geometry
| Thing | Value | Why it is that value |
|-------------------------|--------|---------------------------------------------------------------------------|
| Tile | 16x16 | Both tilesets ship on a 16x16 grid |
| Tileset spacing, margin | 0, 0 | `akgl_tilemap_compute_tileset_offsets` gets nothing else right; see below |
| Character frame | 32x32 | The fixed contract for these tutorials |
| Art inside a frame | 16x16 | Composited at (8, 16): centred, sitting on the cell's bottom edge |
| Frames per sheet row | all | Every sheet is a single row, so `coords_for_frame` never has to wrap |
| Frames per animation | max 16 | `AKGL_SPRITE_MAX_FRAMES`; a 17th is `AKERR_OUTOFBOUNDS` at load |
| Frame id | 0..255 | `uint8_t`; a larger id is `AKERR_OUTOFBOUNDS` at load |
**The one alignment rule.** `akgl_actor_render` draws into a rectangle of
`sprite->width` by `sprite->height` with its top-left at the actor's `x`, `y`.
Because every frame's art is bottom-centred in its cell, an actor at `(x, y)`
has its feet at `y + 32` and its 16 px body spanning `x + 8` to `x + 23`. Ground
contact, pickup tests and hitboxes should use that inner rectangle, not the
32x32 cell.
## Sidescroller sheets
`sidescroller/player.png` -- 192x32, six frames.
| Frame | Facing | Pose | Used by |
|-------|--------|---------------------------------|-----------------------------------------------|
| 0 | right | contact | `ss_player_idle_right`, `ss_player_run_right` |
| 1 | right | passing, airborne | `ss_player_run_right`, `ss_player_jump_right` |
| 2 | right | opposite contact | `ss_player_run_right` |
| 3 | left | contact (mirror of 0) | `ss_player_idle_left`, `ss_player_run_left` |
| 4 | left | passing, airborne (mirror of 1) | `ss_player_run_left`, `ss_player_jump_left` |
| 5 | left | opposite contact (mirror of 2) | `ss_player_run_left` |
The left-facing frames exist as their own art because `akgl_actor_render` passes
`SDL_FLIP_NONE` to `draw_texture` unconditionally (`src/actor.c`). There is no
way to ask the library for a mirrored blit.
`sidescroller/coin.png` -- 32x32, one frame. The pack ships no rotation frames.
`sidescroller/hazard.png` -- 128x32, four frames.
| Frame | What | Used by |
|-------|--------------------------|------------------|
| 0 | red blob, ground, pose A | `ss_hazard_blob` |
| 1 | red blob, ground, pose B | `ss_hazard_blob` |
| 2 | moth, flying, wings up | `ss_hazard_moth` |
| 3 | moth, flying, wings down | `ss_hazard_moth` |
## JRPG sheets
`jrpg/player.png`, `jrpg/npc_shopkeeper.png` and `jrpg/npc_elder.png` are
384x32, twelve frames, and share one layout.
| Frames | Facing | Poses | Idle sprite uses | Walk sprite uses |
|--------|--------|-----------------------|------------------|------------------|
| 0-2 | down | stand, step A, step B | 0 | 1, 0, 2, 0 |
| 3-5 | left | stand, step A, step B | 3 | 4, 3, 5, 3 |
| 6-8 | right | stand, step A, step B | 6 | 7, 6, 8, 6 |
| 9-11 | up | stand, step A, step B | 9 | 10, 9, 11, 9 |
The stand frame between the two steps is what makes it read as a walk rather
than a shuffle; it is the same three-frame cycle the source art was drawn for.
## States
A character's sprite map is keyed by `SDL_itoa(state)` and looked up with
`SDL_GetPointerProperty` (`akgl_character_sprite_get`), so **the match is on the
whole integer, not on a mask test**. A state the character has no exact entry
for makes the actor invisible for that frame -- `actor_visible` treats
`AKERR_KEY` as "nothing to draw", which is an answer, not an error.
These are the values the library's own input handlers produce.
`akgl_actor_cmhf_<dir>_on` clears every `FACE_*` and `MOVING_*` bit before
setting its own pair, and `_off` clears only its `MOVING_*` bit, so the facing
survives the key release and no two `MOVING_*` bits are ever set at once.
| State | Bits | Sidescroller sprite | JRPG sprite |
|-------|-------------------------------------|---------------------|------------------|
| 16 | ALIVE | (coin, hazards) | -- |
| 17 | ALIVE, FACE_DOWN | `..._idle_right` | `..._idle_down` |
| 18 | ALIVE, FACE_LEFT | `..._idle_left` | `..._idle_left` |
| 20 | ALIVE, FACE_RIGHT | `..._idle_right` | `..._idle_right` |
| 24 | ALIVE, FACE_UP | -- | `..._idle_up` |
| 146 | ALIVE, FACE_LEFT, MOVING_LEFT | `..._run_left` | `..._walk_left` |
| 276 | ALIVE, FACE_RIGHT, MOVING_RIGHT | `..._run_right` | `..._walk_right` |
| 530 | ALIVE, FACE_LEFT, MOVING_UP | `..._jump_left` | -- |
| 532 | ALIVE, FACE_RIGHT, MOVING_UP | `..._jump_right` | -- |
| 536 | ALIVE, FACE_UP, MOVING_UP | `..._jump_right` | `..._walk_up` |
| 658 | ALIVE, FACE_LEFT, MOVING_LEFT, UP | `..._jump_left` | -- |
| 788 | ALIVE, FACE_RIGHT, MOVING_RIGHT, UP | `..._jump_right` | -- |
| 1041 | ALIVE, FACE_DOWN, MOVING_DOWN | -- | `..._walk_down` |
The four jump states are there because a sidescroller that routes its jump
through `akgl_actor_cmhf_up_on` lands on 536 -- that handler clears `FACE_RIGHT`
and sets `FACE_UP`, which in a side view is not a facing at all. A game that
sets `ey` directly instead never reaches those states, and nothing breaks
either way.
State names in character JSON are the **prefixed** spellings from
`src/actor_state_string_names.c`: `AKGL_ACTOR_STATE_ALIVE`, not
`ACTOR_STATE_ALIVE`. `util/assets/littleguy.json` uses the old unprefixed names
and `velocity_x`, and does not load against the current library. Do not copy it.
## Maps
Both maps are Tiled 1.8 TMJ with the tileset **embedded**.
`akgl_tilemap_load` reads `columns`, `firstgid`, `tilecount`, `image`,
`imagewidth`, `imageheight`, `margin`, `spacing`, `tilewidth`, `tileheight` and
`name` straight out of each element of the map's `tilesets` array
(`akgl_tilemap_load_tilesets_each`). The string `source` appears nowhere in
`src/tilemap.c` and nothing in the library ever opens a `.tsj`, so an external
tileset reference fails with `AKERR_KEY` on the missing `columns`. `README.md`
in the repository root says the opposite; it is wrong.
| Map | Cells | Layers | Objects |
|---------------------------|-------|-------------------------------------------|---------|
| `sidescroller/level1.tmj` | 40x15 | background (tile), terrain (tile), actors | 7 |
| `jrpg/town.tmj` | 30x20 | ground (tile), decoration (tile), actors | 3 |
Global tile ids are `(row * columns) + column + 1`; `0` means an empty cell and
is skipped. `sidescroller/tiles.png` has ten columns and 60 tiles;
`jrpg/tiles.png` has twenty-seven columns and 486 tiles, the last four columns
of which are the character art the sheets above were cut from. Those cells are
never referenced by `town.tmj`.
Other things the loader insists on, all of them checked:
- **Every object needs a `type` string**, including ones that are not actors. A
missing `type` fails the whole load, not just that object.
- An actor object needs a non-empty `name`, a `character` **string** property
and a `state` **int** property. The string-array form of `state` works in
character JSON and is not accepted here.
- The `character` named must already be in `AKGL_REGISTRY_CHARACTER`, which
means every sprite and character JSON has to be loaded before the map.
- A map's `properties` are optional; if present, `physics.model` must name a
backend that exists (`null` or `arcade`) or the load fails. Gravity and drag
keys are `float`.
- Tileset image paths resolve through `akgl_path_relative` against the map's own
directory. Image-layer paths do not -- those go through a plain `"%s/%s"`
join, so an absolute path there does not work. Keep every path relative.
## Physics
The sidescroller map asks for the `arcade` backend with `physics.gravity.y`
900.0 and `physics.drag.y` 1.5. There is no terminal velocity in the backend;
`ey` approaches `gravity_y / drag_y`, so those two numbers set it to 600 px/s.
The JRPG map asks for `arcade` with both gravity components at 0.0.
`ss_player` has `speed_y` and `acceleration_y` of 0.0 on purpose: a zero top
speed on an axis means `akgl_physics_simulate` zeroes that axis's thrust
outright, so holding a vertical direction cannot make the player fly. A jump
belongs in `ey`.

View File

@@ -0,0 +1,70 @@
{
"name": "jrpg_elder",
"speedtime": 150,
"speed_x": 60.0,
"speed_y": 60.0,
"acceleration_x": 400.0,
"acceleration_y": 400.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN"
],
"sprite": "jrpg_elder_idle_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN",
"AKGL_ACTOR_STATE_MOVING_DOWN"
],
"sprite": "jrpg_elder_walk_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "jrpg_elder_idle_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "jrpg_elder_walk_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "jrpg_elder_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "jrpg_elder_walk_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP"
],
"sprite": "jrpg_elder_idle_up"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "jrpg_elder_walk_up"
}
]
}

View File

@@ -0,0 +1,70 @@
{
"name": "jrpg_player",
"speedtime": 150,
"speed_x": 60.0,
"speed_y": 60.0,
"acceleration_x": 400.0,
"acceleration_y": 400.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN"
],
"sprite": "jrpg_player_idle_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN",
"AKGL_ACTOR_STATE_MOVING_DOWN"
],
"sprite": "jrpg_player_walk_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "jrpg_player_idle_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "jrpg_player_walk_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "jrpg_player_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "jrpg_player_walk_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP"
],
"sprite": "jrpg_player_idle_up"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "jrpg_player_walk_up"
}
]
}

View File

@@ -0,0 +1,70 @@
{
"name": "jrpg_shopkeeper",
"speedtime": 150,
"speed_x": 60.0,
"speed_y": 60.0,
"acceleration_x": 400.0,
"acceleration_y": 400.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN"
],
"sprite": "jrpg_shopkeeper_idle_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN",
"AKGL_ACTOR_STATE_MOVING_DOWN"
],
"sprite": "jrpg_shopkeeper_walk_down"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "jrpg_shopkeeper_idle_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "jrpg_shopkeeper_walk_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "jrpg_shopkeeper_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "jrpg_shopkeeper_walk_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP"
],
"sprite": "jrpg_shopkeeper_idle_up"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "jrpg_shopkeeper_walk_up"
}
]
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 729 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 783 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 640 B

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_idle_down",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_idle_left",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
3
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_idle_right",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
6
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_idle_up",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
9
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_walk_down",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
1,
0,
2,
0
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_walk_left",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
4,
3,
5,
3
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_walk_right",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
7,
6,
8,
6
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_elder.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_elder_walk_up",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
10,
9,
11,
9
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_idle_down",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_idle_left",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
3
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_idle_right",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
6
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_idle_up",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
9
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_walk_down",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
1,
0,
2,
0
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_walk_left",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
4,
3,
5,
3
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_walk_right",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
7,
6,
8,
6
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_player_walk_up",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
10,
9,
11,
9
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_idle_down",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_idle_left",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
3
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_idle_right",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
6
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_idle_up",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
9
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_walk_down",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
1,
0,
2,
0
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_walk_left",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
4,
3,
5,
3
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_walk_right",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
7,
6,
8,
6
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "npc_shopkeeper.png",
"frame_width": 32,
"frame_height": 32
},
"name": "jrpg_shopkeeper_walk_up",
"width": 32,
"height": 32,
"speed": 150,
"loop": true,
"loopReverse": false,
"frames": [
10,
9,
11,
9
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,16 @@
{
"name": "ss_coin",
"speedtime": 200,
"speed_x": 0.0,
"speed_y": 0.0,
"acceleration_x": 0.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "ss_coin"
}
]
}

View File

@@ -0,0 +1,46 @@
{
"name": "ss_hazard_blob",
"speedtime": 180,
"speed_x": 24.0,
"speed_y": 0.0,
"acceleration_x": 200.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "ss_hazard_blob"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "ss_hazard_blob"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "ss_hazard_blob"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "ss_hazard_blob"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "ss_hazard_blob"
}
]
}

View File

@@ -0,0 +1,46 @@
{
"name": "ss_hazard_moth",
"speedtime": 120,
"speed_x": 40.0,
"speed_y": 40.0,
"acceleration_x": 300.0,
"acceleration_y": 300.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE"
],
"sprite": "ss_hazard_moth"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "ss_hazard_moth"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "ss_hazard_moth"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "ss_hazard_moth"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "ss_hazard_moth"
}
]
}

View File

@@ -0,0 +1,89 @@
{
"name": "ss_player",
"speedtime": 120,
"speed_x": 90.0,
"speed_y": 0.0,
"acceleration_x": 600.0,
"acceleration_y": 0.0,
"sprite_mappings": [
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT"
],
"sprite": "ss_player_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT"
],
"sprite": "ss_player_run_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT"
],
"sprite": "ss_player_idle_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT"
],
"sprite": "ss_player_run_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_DOWN"
],
"sprite": "ss_player_idle_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_UP",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "ss_player_jump_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "ss_player_jump_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "ss_player_jump_left"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_RIGHT",
"AKGL_ACTOR_STATE_MOVING_RIGHT",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "ss_player_jump_right"
},
{
"state": [
"AKGL_ACTOR_STATE_ALIVE",
"AKGL_ACTOR_STATE_FACE_LEFT",
"AKGL_ACTOR_STATE_MOVING_LEFT",
"AKGL_ACTOR_STATE_MOVING_UP"
],
"sprite": "ss_player_jump_left"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 B

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 383 B

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "coin.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_coin",
"width": 32,
"height": 32,
"speed": 200,
"loop": true,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,17 @@
{
"spritesheet": {
"filename": "hazard.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_hazard_blob",
"width": 32,
"height": 32,
"speed": 180,
"loop": true,
"loopReverse": false,
"frames": [
0,
1
]
}

View File

@@ -0,0 +1,17 @@
{
"spritesheet": {
"filename": "hazard.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_hazard_moth",
"width": 32,
"height": 32,
"speed": 120,
"loop": true,
"loopReverse": false,
"frames": [
2,
3
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_idle_left",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
3
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_idle_right",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
0
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_jump_left",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
4
]
}

View File

@@ -0,0 +1,16 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_jump_right",
"width": 32,
"height": 32,
"speed": 250,
"loop": true,
"loopReverse": false,
"frames": [
1
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_run_left",
"width": 32,
"height": 32,
"speed": 90,
"loop": true,
"loopReverse": false,
"frames": [
3,
4,
5,
4
]
}

View File

@@ -0,0 +1,19 @@
{
"spritesheet": {
"filename": "player.png",
"frame_width": 32,
"frame_height": 32
},
"name": "ss_player_run_right",
"width": 32,
"height": 32,
"speed": 90,
"loop": true,
"loopReverse": false,
"frames": [
0,
1,
2,
1
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

16
examples/CMakeLists.txt Normal file
View File

@@ -0,0 +1,16 @@
# The two tutorial games.
#
# Each one is a complete, running program that the matching chapter quotes with
# `c excerpt=examples/...` blocks rather than restating -- so a tutorial cannot
# drift from a program that builds, because the excerpt check fails the moment
# the source moves.
#
# Every subdirectory is guarded on its own. The games are written by separate
# passes and land at different times, and a configure that fails because one of
# them is not there yet would block the other. There is nothing clever about the
# guard; it exists so an incomplete tree still builds.
foreach(_example sidescroller jrpg)
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/${_example}/CMakeLists.txt")
add_subdirectory(${_example})
endif()
endforeach()

View File

@@ -0,0 +1,72 @@
# The top-down JRPG tutorial game, quoted by docs/20-tutorial-jrpg.md.
#
# It is a real target built by `all`, not a snippet: a tutorial whose program
# does not compile is the failure this whole documentation effort exists to
# stop, and the chapter's `c excerpt=` blocks fail the moment this source moves
# under them.
add_executable(jrpg
jrpg.c
world.c
textbox.c
)
target_link_libraries(jrpg
PRIVATE akstdlib::akstdlib akerror::akerror akgl
SDL3::SDL3 SDL3_ttf::SDL3_ttf SDL3_image::SDL3_image SDL3_mixer::SDL3_mixer
jansson::jansson -lm)
target_include_directories(jrpg PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}")
target_compile_options(jrpg PRIVATE ${AKGL_WARNING_FLAGS})
# The game finds its data through two absolute paths baked in here. A game that
# ships would install its assets and resolve them against SDL_GetBasePath(); a
# tutorial has to run from the build tree, from the source tree, and from
# whatever working directory CTest hands it, so the paths are compiled in.
get_filename_component(JRPG_REPO_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE)
target_compile_definitions(jrpg PRIVATE
JRPG_ASSET_DIR="${JRPG_REPO_ROOT}/docs/tutorials/assets/jrpg"
# tests/assets/akgl_test_mono.ttf, which ships beside its licence file.
JRPG_FONT_FILE="${JRPG_REPO_ROOT}/tests/assets/akgl_test_mono.ttf"
)
# The vendored SDL satellite libraries land in per-project subdirectories that
# are not on the loader's default search path, exactly as they do for the test
# suites. AKGL_VENDORED_RPATH is set by the top-level CMakeLists.txt and is only
# defined when something was actually vendored.
if(AKGL_VENDORED_DEPENDENCIES)
set_target_properties(jrpg PROPERTIES BUILD_RPATH "${AKGL_VENDORED_RPATH}")
endif()
# The headless smoke run. `--demo` drives the arrow keys from a script and steps
# the physics clock by a fixed 1/60 s, so the run is deterministic and finishes
# in well under a second; `--frames` bounds it. Between them the run walks the
# player into a wall, opens the text box, and exercises the follower's render
# hook -- rather than just proving that main() returns.
#
# add_test() reaches the real command here: the top-level CMakeLists.txt shadows
# it to suppress the vendored projects' registrations and lifts the suppression
# again long before examples/ is added.
add_test(NAME example_jrpg COMMAND jrpg --frames 320 --demo)
set_tests_properties(example_jrpg PROPERTIES
TIMEOUT 120
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy"
)
# ENVIRONMENT above replaces the environment wholesale, so LD_LIBRARY_PATH has
# to go in the same property rather than a second one. Only needed when the
# dependencies were vendored; an installed build resolves them normally.
if(AKGL_VENDORED_DEPENDENCIES)
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.22")
set(JRPG_TEST_ENV_MOD "")
foreach(dir IN LISTS AKGL_TEST_LIBPATH)
list(APPEND JRPG_TEST_ENV_MOD "LD_LIBRARY_PATH=path_list_prepend:${dir}")
endforeach()
set_tests_properties(example_jrpg
PROPERTIES ENVIRONMENT_MODIFICATION "${JRPG_TEST_ENV_MOD}")
else()
string(REPLACE ";" ":" JRPG_TEST_LIBPATH_JOINED "${AKGL_TEST_LIBPATH}")
set_tests_properties(example_jrpg PROPERTIES
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_RENDER_DRIVER=software;SDL_AUDIODRIVER=dummy;LD_LIBRARY_PATH=${JRPG_TEST_LIBPATH_JOINED}:$ENV{LD_LIBRARY_PATH}"
)
endif()
endif()

Some files were not shown because too many files have changed in this diff Show More