131 Commits

Author SHA1 Message Date
963d511224 Point AGENTS.md at the issue tracker for outstanding work
Some checks are pending
libakgl CI Build / cmake_build (push) Waiting to run
libakgl CI Build / performance (push) Waiting to run
libakgl CI Build / memory_check (push) Waiting to run
libakgl CI Build / mutation_test (push) Waiting to run
The rules section now says where new work goes: an issue on the forge, with the
file and line, the functional consequence, and what closing it would touch,
carrying status::grooming until its scope is settled. TODO.md is the record of
decisions and measurements, and adding outstanding items back to it is what the
split exists to prevent -- a description of work still to do goes stale the
moment somebody does it.

Two rules go with it. Say what you learn about an open issue in the issue,
because an issue nobody has re-checked is worth what an unverified comment is
worth. And file a dependency's defect against that dependency rather than
working around it here.

The 'do not trust a comment, a TODO entry, or a CI exclusion' lesson keeps its
evidence and gains the three entries found stale during the migration.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 19:24:20 -04:00
eabb9ad376 Move outstanding work from TODO.md into the issue tracker
Some checks failed
libakgl CI Build / cmake_build (push) Successful in 9m7s
libakgl CI Build / performance (push) Successful in 9m44s
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / memory_check (push) Has been cancelled
TODO.md carried two records in one file: what had been done, with the
measurements behind it, and what was left. The second half is what a tracker
is for, and keeping it here has already cost something -- AGENTS.md records a
round where eleven entries described code that had already changed, and this
file admitted to three more.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 18:47:34 -04:00
bbb7b8f494 Add the akgl_ui subsystem: clay-backed menus, HUDs and dialogs (#3)
All checks were successful
libakgl CI Build / cmake_build (push) Successful in 8m59s
libakgl CI Build / performance (push) Successful in 9m42s
libakgl CI Build / memory_check (push) Successful in 14m43s
libakgl CI Build / mutation_test (push) Successful in 27m33s
Merges the clay-ui branch: the akgl_ui subsystem on clay v0.14, the uidemo example, chapter 22, the weaker-model tutorial-test fixes, and the CI repairs that produced this repository's first fully green run.
2026-08-02 16:14:49 -04:00
5560edf410 Exclude the two shell-script tests from the memcheck run
All checks were successful
libakgl CI Build / cmake_build (push) Successful in 8m54s
libakgl CI Build / performance (push) Successful in 9m48s
libakgl CI Build / memory_check (push) Successful in 14m39s
libakgl CI Build / mutation_test (push) Successful in 27m24s
The memory job's first-ever complete valgrind pass on the runner reported
every real suite and all three example games clean -- and 414 findings in
docs_examples and docs_screenshots. Those two tests are bash scripts that
drive gcc and run the compiled snippets as children; valgrind wraps the test
command and does not trace children, so all 414 were /bin/bash's own
by-design leaks and not one byte of libakgl. Memchecking them proves nothing
the suites and example games do not already prove as real binaries under the
same valgrind run.

memcheck.sh now excludes exactly those two by name, with the reasoning in a
comment beside the exclusion -- per the suppressions file's own rule that
nothing gets quieted without a stated reason. Verified locally: full run over
the RelWithDebInfo tree, every suite and example under valgrind, zero
findings, exit 0.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 15:12:39 -04:00
be3c8ae850 Pin upload-artifact to v3: v4 refuses to run against Gitea
Some checks failed
libakgl CI Build / cmake_build (push) Successful in 9m18s
libakgl CI Build / performance (push) Successful in 9m44s
libakgl CI Build / memory_check (push) Failing after 18m50s
libakgl CI Build / mutation_test (push) Has been cancelled
The env-list fix got every test green on the runner for the first time --
37 of 37, the JUnit publish included -- and the job then failed in the
artifact upload: upload-artifact@v4 speaks GitHub's v2 artifact service and
hard-refuses any host that is not github.com ("not currently supported on
GHES"). Gitea implements the v3 artifact API, so the three upload steps --
coverage, the benchmark tables, the valgrind logs -- pin to v3.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 14:29:42 -04:00
d8458d1e68 Stop set_tests_properties from eating the test environment lists
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m56s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
The example games failed on the CI runner with "ALSA: Couldn't open audio
device" despite SDL_AUDIODRIVER=dummy sitting right there in their CMake --
because it never reached the process. set_tests_properties parses its
PROPERTIES arguments as name/value pairs, so a semicolon-separated value
list is split and every element after the first is consumed as a bogus
property name: ENVIRONMENT kept only SDL_VIDEODRIVER=dummy, and the
LD_LIBRARY_PATH prepend list kept only its first directory. ctest
--show-only=json-v1 shows the truncation plainly.

Nobody could see it. Video survived because SDL3 falls through its driver
list to dummy on a headless machine with no hint at all; audio survived on
every developer machine because the real device works there; the library
paths survived because RPATH covered them. A runner with no sound card was
the first environment where any of it mattered.

Every list-valued test property now goes through set_property(TEST ...),
which takes real list arguments -- the examples' ENVIRONMENT triplets and
vendored-path modifications, the suites' LD_LIBRARY_PATH prepends, and the
docs harness's driver variables. Verified by property dump (3 environment
entries and all 7 prepends present) and by running the example tests with
ALSA_CONFIG_PATH=/dev/null, which now pass where a real ALSA open would
fail: the dummy driver is finally the one being asked.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 14:19:05 -04:00
219d7182f1 Install graphviz where doxygen runs
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m54s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
The submodule fix got the cmake_build job through checkout, dependencies,
configure and the whole compile for the first time in the run history -- and
into the next latent failure: `doxygen Doxyfile` generates dot graphs and the
Doxyfile fails on warnings, but the runner image has no `dot`, so every graph
reported "Problems running dot: exit code=127" and the step failed after an
otherwise clean build. Locally graphviz was installed, which is why the
pre-flight missed it. One package in the one job that builds documentation.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 14:02:07 -04:00
2242cfac9f Unbreak CI: https submodule URLs, and three suites that assumed a display
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 8m4s
libakgl CI Build / memory_check (push) Has been cancelled
libakgl CI Build / mutation_test (push) Has been cancelled
libakgl CI Build / performance (push) Has been cancelled
CI has never gone green in the retained run history, and every failure is
the same 20-second death during checkout: six submodules -- SDL, SDL_image,
SDL_mixer, SDL_ttf, jansson, semver -- were registered with git@github.com:
SSH URLs, and the runner has no GitHub key. The https submodules (libccd,
tg, clay, and the two starfort ones) clone fine in the same run. All six now
use https, which is what every stanza added since already did; these are
public upstream repositories nothing here pushes to, so SSH bought nothing.

Second problem, waiting right behind the first: the character, sprite and
tilemap suites were the only three of seventeen that never set the dummy
video/audio/software-render hints, so on a headless runner they die in
SDL_Init with "No available video device" before testing anything. They now
set the same three hints the other fourteen suites set. (How this survived:
every local run happens on a machine with a display, and CI never got far
enough to run a test.)

Pre-flighted locally against every job the workflow defines, headless with
no driver variables in the environment, exactly as the runner would: Debug
with -Werror and coverage builds clean and all 37 tests pass including the
coverage fixtures; RelWithDebInfo with -Werror builds clean and the perf
suites pass their budgets at full scale; doxygen Doxyfile exits 0 under
FAIL_ON_WARNINGS; and the new ui suite runs under valgrind with zero errors
and no leaks.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 13:39:17 -04:00
f2efbdf1b7 Fill the gaps the chapter 22 weaker-model reader found
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 17s
libakgl CI Build / mutation_test (push) Failing after 18s
The AGENTS.md "Testing a Tutorial" pass: chapter 22 was handed to a reader on
a weaker model with the library tree stripped of examples/ and docs/, and
they had to build a menu/HUD/dialog program from the chapter alone. They
built one that compiled and ran -- and reported success their own screenshots
disproved, which is why the protocol says to verify: their synthesized key
events set only the scancode, akgl_ui_menu_handle_event matches keycodes, and
their program never actually left the title screen. With that one line fixed,
everything they had built from the chapter worked.

Three findings survived verification against eight reported:

- The chapter never stated its prerequisites, so the reader burned most of
  the session on library bring-up it does not cover and invented goto error
  handling for main(). A new "What this chapter assumes" paragraph points at
  chapters 3, 7 and 17, and at the demo's startup() and main() as the
  complete reference.
- Nothing named the menu's exact keys, or that they are keycodes rather than
  scancodes -- invisible with a real keyboard, fatal for synthesized events,
  which the demo's own --demo script teaches. The menus section now names
  SDLK_UP/DOWN/RETURN, the gamepad buttons, and the key.key trap.
- Verifying their dialog screenshot showed a message wrapping past the fixed
  AKGL_UI_DIALOG_HEIGHT runs visibly out of the panel. Deliberate -- same as
  the hand-rolled textbox, and visible overflow beats silent truncation --
  but undocumented; akgl_ui_dialog's header note now says so.

Rejected for the record, per protocol: akgl_game_init already initializes the
property registry (src/game.c:219), and the font they "found" in a different
directory was the build tree's file(COPY) of tests/assets.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 13:06:22 -04:00
f772da7296 Record the UI's paper trail and bump to 0.9.0
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 23s
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 19s
Two TODO.md notes the UI work created. The text texture ring cache (plan
item 2) now has its second consumer, and the bigger one: the UI executor
draws every TEXT render command through akgl_text_rendertextat, one wrapped
line per command per frame, so a five-row menu re-rasterizes five lines at
60 Hz against the original case of one HUD line changing once a second.
And akgl_ControlMap's mouseid/penid fields stay dead deliberately -- the
mouse path lives in the UI subsystem because control maps and pointer state
are different consumers, and the note says where the work lands when a game
wants mouse-driven actors.

0.8.0 to 0.9.0: a new public header, a new status code, three new drawing
primitives and a new subsystem is new API, and while the major version is 0
the soname carries major.minor -- libakgl.so.0.9 is what makes 0.8.0 and
0.9.0 unmixable at load time, which is the honesty the bump is for.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:34:12 -04:00
8f848d80be Document the UI subsystem: chapter 22, with the appendix moving to 23
docs/22-ui.md covers the clay-backed UI: what clay owns against what libakgl
owns, bring-up and the arena refusal contract, the frame bracket and where it
sits in a real frame, the consumed-event rules (with the one-frame-stale hit
test stated plainly), the three widgets, menus across keyboard, gamepad and
mouse, writing raw CLAY() with an application-owned press edge, images and
styles, and how layout errors surface from frame_end with a captured trace.
Every listing is a checked block: one linked-and-run example with pinned
output, eight excerpts quoting examples/uidemo and examples/jrpg/textbox.c,
and the chapter figure is a frame out of uidemo via docs_game_figures.

The chapter owes and pays the comparison the tutorials earn: the dialog
widget's default style reproduces the JRPG textbox palette, the two listings
sit side by side, and the trade is stated -- 125 lines you own completely
against one call that costs you the subsystem. Neither is deprecated;
chapter 21 keeps teaching the hand-rolled panel on purpose.

The appendix moves to 23 (links in chapters 4, 5, 6, 7, 13 and the TOC
updated) and gains the ui.h status cross-reference, the AKGL_UI_* limits
table, and the new draw primitives' rows. Its status-band figures were stale
at COUNT 5 / LIMIT 261 while the band already held six codes; now correct at
7 / 263. Chapter 4 gains AKGL_ERR_UI (262) in the excerpt, the table and the
name list.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:32:20 -04:00
4ddd8d1ad3 Add the UI demo: title menu, raw-CLAY options screen, HUD and dialog
examples/uidemo is the program the UI chapter quotes. Three screens, three
ways of building an interface: the title screen is one akgl_UiMenu plus a
heading label; the options screen is written in raw CLAY() declarations --
hover highlighting inside the declaration, clicks paired with the
application's own press edge -- because the widgets are a convenience, not a
boundary; and the play screen is two HUD labels and the one-call dialog over
a checkerboard standing in for a game world. No tilemap, no actors, no
physics: everything left on screen is the subject.

Its route_event() is the documented call-order contract in the flesh -- UI
first, consumed events stop there, then the current screen's keys -- and the
screen routing is the focus model, stated rather than invented.

The headless smoke run (example_uidemo, --frames 240 --demo) tours every
path through the real event chain: a mouse click on the centred menu (the
consumed path, landing on the middle row by symmetry), keyboard into and out
of the options screen, the dialog opened and dismissed, and Quit confirmed
from the menu. The run prints its score and settings so a pass can be read,
not just counted. docs_game_figures gains a uidemo frame -- the play screen
with the dialog up -- for the chapter to come.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:24:58 -04:00
badaf570ab Stop a stationary pointer from pinning the menu selection
A pointer parked over a menu row re-claimed the selection on every frame's
declaration, so keyboard navigation fought the mouse sixty times a second
and lost -- press Up, and the hover put the selection straight back. Found
by the UI demo's scripted tour: its mouse click leaves the pointer resting
on the menu, and the Up keystroke that followed did nothing.

Hover now moves the selection only on frames the pointer actually moved (or
clicked), through a frame-latched motion edge beside the existing press
edge. Parking the mouse claims nothing; moving it onto a row still selects,
clicking still selects and activates. Regression test drives the exact
sequence: click a row, move the selection away by keyboard, redeclare, and
assert the stationary pointer stole nothing back.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:24:57 -04:00
0679d40ee4 Add the widget helpers: dialog, HUD label, and menu
The other half of the hybrid decision: an application that wants a dialog
box, a score counter or a main menu gets each in one call, without touching a
CLAY() macro -- and an application that outgrows them still has clay's whole
DSL, because the widgets are nothing but functions that declare the same
elements between the same frame bracket.

akgl_ui_dialog is the JRPG textbox in one line: bottom panel spanning the
layout, AKGL_UI_DIALOG_HEIGHT tall, one-pixel border, wrapped text. The NULL
style *is* the textbox palette -- near-black fill, parchment edge and ink,
8px padding -- deliberately, so the widget and the 125-line hand-rolled panel
it stands beside in the comparison chapter produce the same picture. Showing
and hiding is the frame's business: call it while somebody is talking, don't
while nobody is; a declarative frame is the visible flag.

akgl_ui_label pins a fit-sized text chip to a corner or the centre, inset by
the style's padding. akgl_ui_menu declares a centred vertical menu from a
caller-owned struct (no heap layer: it owns no texture, no font, no registry
entry), with the selected row drawn inverted, hover moving the selection and
the frame-latched press edge activating it. akgl_ui_menu_handle_event drives
the same struct from Up/Down/Return and D-pad/South with wrapping, slotting
into the event chain after akgl_ui_handle_event; routing events to a menu is
the application's focus model, stated rather than invented.

Tests cover the dialog's geometry and palette by pixel, label anchoring by
quadrant, menu validation, the inverted highlight, keyboard and gamepad
wrap/activate/pass-through, and the full two-frame mouse protocol -- click
aimed at the second row's real box via Clay_GetElementData, seen by the next
frame's declaration as selection plus activation.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:17:32 -04:00
566b870a5c Feed the mouse to the UI and tell the host which events it consumed
akgl_ui_handle_event is the mouse half of the input story -- keyboard and
gamepad stay with the control maps, because keyboard focus is something an
application declares rather than something a pointer position implies. It
handles motion, left-button presses and releases, the wheel and window
resizes; everything else, and everything while the subsystem is inert,
reports consumed=false and succeeds, the same pass-everything contract
akgl_controller_handle_event already has. The documented call order is UI
first, control maps second, skipping on consumed.

A press, release or wheel is consumed when the pointer is over any UI
element. The hit test asks clay against the layout the previous frame
retained -- this frame's does not exist while events are polled, and one
frame of staleness is the standard model's accepted cost -- with clay's
internal full-screen Clay__RootContainer excluded, because being inside the
window is not being over the interface (unexcluded, every click anywhere was
consumed; the test that pins this caught it).

Pointer state is fed to clay per event, as clay documents; the press edge is
latched once per frame_begin for the widgets to come, rather than trusting
Clay_PointerData's per-SetPointerState edge, which advances per mouse event.
frame_begin also drives Clay_UpdateScrollContainers from the accumulated
wheel (32 pixels per notch) and a wall-clock dt.

Tests pin the whole contract: pass-through before init, no consumption
before any layout exists, press/release inside vs beside a panel, right
button ignored, motion never consumed, wheel consumed only over the UI,
resize reaching the layout engine, and key events left alone.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:10:35 -04:00
3b3bfcbe10 Render clay layouts: fonts, text measurement, executor and the frame bracket
The UI subsystem now draws. akgl_ui_frame_begin/frame_end bracket one frame's
CLAY() declarations: begin clears the error stash and starts the clay layout,
end computes it and walks the render commands through a backend -- RECTANGLE
as (rounded) fills, BORDER as radius-shortened edge fills plus corner arcs,
TEXT through akgl_text_rendertextat one wrapped line per command, IMAGE as an
akgl_Sprite's first frame stretched to the bounding box, and the SCISSOR pair
through akgl_draw_set_clip, cleared again on any exit so a failed frame
cannot leave the world clipped.

akgl_ui_font_register maps registry font names onto clay's uint16_t fontIds.
The table keeps the *name* and resolves it per use -- fonts are not reference
counted, and a cached TTF_Font* would dangle where a name reports "gone"
honestly. Clay_TextElementConfig.fontSize is deliberately ignored: libakgl
bakes the size into the handle at load, so one face at two sizes is two ids.

The measure bridge passes clay's non-NUL-terminated slices straight to
SDL_ttf's explicit-length TTF_GetStringSize -- no copy, no scratch. Its
signature has no error channel, so failures report zero-by-zero and stash a
message that frame_end raises, the same route clay's own void error handler
uses; layout errors take precedence over drawing and one bad frame leaves the
next one clean.

Tests drive real CLAY() layouts through the software renderer and read pixels
back: fill placement, border edges with an empty middle, a child clipped by
its container, text landing in its colour, slice-vs-whole measurement
agreement, fontId table dedupe/refusal/exhaustion, and the bracket's
begin/begin, end-without-begin and failure-recovery contracts.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 11:06:27 -04:00
064e6569e8 Add rounded-rect fill, arc stroke and clip-rect drawing primitives
The UI subsystem's render executor needs all three -- clay emits rectangles
with corner radii, per-side borders, and scissor commands -- but they earn
their place in draw.h on the same terms as everything else there: a rounded
panel, a ring gauge and a clipped viewport are things a game wants with or
without a layout engine.

akgl_draw_filled_rounded_rect decomposes into three band fills plus four
quarter-circle triangle fans through SDL_RenderGeometry, with the radius
clamped to half the shorter side. akgl_draw_arc strokes between an outer and
inner radius as one triangle strip, stepping in proportion to the swept angle
under a fixed vertex cap -- no VLAs, unlike clay's own SDL3 reference
renderer. akgl_draw_set_clip wraps SDL_SetRenderClipRect and is the one draw
entry point where a NULL rectangle is legal, because that is how a clip is
cleared.

Pixel-readback tests cover the corner geometry (inside the arc filled,
outside it untouched), the radius clamp, the zero-radius fall-through, ring
and quarter-arc coverage at mid-stroke, sweep bounds, and clip set/clear
round trips.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 10:57:23 -04:00
b7ff54b09f Vendor clay and bring the UI subsystem up: arena, lifecycle, status band
clay v0.14 (deps/clay, zlib licence) supplies the layout engine for the new
akgl_ui subsystem. Sources-listed rather than add_subdirectory()'d, on the
semver/libccd precedent: clay's CMakeLists declares no library target, builds
its examples by default, and requires CMake 3.27 against this project's 3.10.
src/ui_clay.c is the one CLAY_IMPLEMENTATION translation unit, compiled -w on
the vendored-code terms but with clay's symbols deliberately exported --
consumers' CLAY() macros resolve against libakgl.so, and akgl/ui.h warns them
never to link a second clay.

The subsystem is runtime-optional the way collision is: always compiled in,
inert until akgl_ui_init(), which bounds clay to the overridable AKGL_UI_*
ceilings, checks Clay_MinMemorySize() against a static BSS arena (no malloc),
and refuses with both numbers in the message when it does not fit. Measured:
812544 bytes at the default 1024 elements / 4096 measured words, against a
1 MiB arena. clay's void-callback layout errors are logged as they happen and
the first is stashed for the error protocol to raise later.

AKGL_ERR_UI joins the status band before AKGL_ERR_LIMIT, named in
akgl_error_init. New `ui` test suite covers validation, the init/shutdown
lifecycle, and arena exhaustion via the akgl_ui_arena_limit test hook (same
contract as akgl_ccd_arena_set_limit). clay.h is installed beside semver.h,
its licence beside libccd's, and the headers suite proves akgl/ui.h
self-contained -- clay.h compiles clean under -Wall.

Co-Authored-By: Claude Code (Claude Fable 5, claude-fable-5) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KzBDV2fqgnUAcqCKqKvc71
2026-08-02 10:53:11 -04:00
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
b899f09fc8 Note that defect 26 is fixed under the per-axis scale item
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / performance (push) Failing after 18s
libakgl CI Build / memory_check (push) Failing after 14s
libakgl CI Build / mutation_test (push) Failing after 16s
Upstream filed the per-axis scale item while the drawn-height-from-width bug
was still open, and recorded akbasic working around both together. That half is
fixed, so a non-square sprite now draws at its own proportions through the
library's own renderfunc. The uniform-scale half stands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:37:16 -04:00
9ec0b41460 Write down the rule the 0.5.0 defect work kept re-learning
A "Fixing Defects" section in AGENTS.md: read what the code does before
deciding what it should do. Six rules, every one of them broken in this
repository during the 0.5.0 work, and every one cheap to check and expensive to
assume.

The one that prompted it: the savegame fix grew four name-width aliases to let
the on-disk format diverge from the object model, and akgl_game_load already
refuses a save from another build sixteen lines above the tables being edited.
The abstraction defended a case that cannot arise.

The rest are the same mistake in other clothes -- two tests that passed against
the unfixed library, a bitmask case that gives the same answer under both
parses, three CI exclusions resting on a premise nobody had re-checked, and
eleven TODO entries describing code that had already changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:52 -04:00
58b99e0477 Drop the savegame name-width aliases
They were four #defines, one per table, each expanding to exactly one existing
constant and used exactly once per side. That is indirection with no second
consumer, and this repository's own rule is to abstract when the second
consumer is real and not before.

The reasoning behind them does not survive contact either. They were meant to
let the on-disk format's field widths diverge from the object model's -- but
raising AKGL_ACTOR_MAX_NAME_LENGTH is an ABI change, which bumps the version,
and akgl_game_load refuses a save whose libversion does not match before it
reads a single name table. The divergence they anticipated cannot happen
quietly.

The reader now names the same constant the writer does, per table, which is all
the fix ever needed to be. What actually catches a disagreement is the EOF
check at the end of akgl_game_load, and that works however the widths are
spelled: reverting the spritesheet reader to the wrong constant still fails the
roundtrip test with AKERR_IO.

25/25 pass, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:51 -04:00
28fa929f6a Make savegames, optional array elements, empty text and coverage work
Closes Known-and-still-open items 7 and 13, and Defects items 18, 25 and 27.

The savegame name tables carry no length prefix, so writer and reader have to
agree on a field width exactly. The writer used each object's own maximum name
length -- 512 for a spritesheet, a filename -- and the reader used
AKGL_ACTOR_MAX_NAME_LENGTH for all four. Four AKGL_GAME_SAVE_*_NAME_WIDTH
constants drive both sides now.

The failure turned out to be worse than "cannot be read back": a reader
stepping the wrong width does not run off anything, it finds a run of zeros
inside an entry, stops early, and reports success with silently wrong maps. A
test asserting only that the load succeeded passed against the broken reader.
So akgl_game_load checks it is at EOF once the tables are read, which turns a
width disagreement into AKERR_IO instead of a corruption. That is the assertion
the new roundtrip test -- the first with all four registries populated -- hangs
on.

akgl_get_json_with_default gains a third HANDLE_GROUP for AKERR_OUTOFBOUNDS,
which is what the array index accessors report, so "this element is optional"
works for an array element and not only for an object member. The arm goes
above the one holding the memcpy: HANDLE_GROUP emits no break and every arm
falls into that body.

That test needed a second attempt. with_default returns *the context it was
given* when it does not handle the status, so TEST_EXPECT_OK -- which releases
whatever the statement returns -- double-released it against a CLEANUP block
that released it too, and a double-released context corrupts the failure rather
than reporting it. The first draft passed against the unfixed library.

akgl_text_rendertextat returns success without rasterizing for the empty
string, matching akgl_text_measure, which has always accepted it. The check
sits after the font and backend guards, so drawing nothing still refuses what
drawing something refuses. tests/text.c had this case written and unasserted
waiting for the two halves of the header to agree.

character_load_json_state_int_from_strings guards dest rather than testing
states twice. Not asserted: the function is static with one call site that
passes a real pointer, so the guard cannot fire, and reaching it from a test
would mean giving it external linkage purely for that.

Both gcovr invocations take the build tree as an explicit positional search
path. gcovr searches --root when given none, which is the source directory,
where build trees live; --object-directory does not narrow it. Verified by
building two instrumented trees with a source edit between them: the old
invocation fails with "Got function write_exact on multiple lines: 46, 48" and
exits 64, the new one exits 0 and the full coverage run passes with the stale
tree still present.

25/25 pass, reindent --check, check_api_surface and check_error_protocol clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:51 -04:00
947bc03601 Re-verdict performance targets 16 and 17
Both cited defects that are fixed. 17 is met outright; 16 is qualified, because
the tilemap cycle is the one that was leaking and the one now asserted, and
nothing yet asserts the same of a sprite, spritesheet or character cycle -- a
gap in the tests rather than a known leak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:51 -04:00
b0a4e36ded Regenerate the controller database with the fixed generator
A deliberate standalone commit, as AGENTS.md asks for this file.

Content is byte-identical -- 2255 mappings, not one changed -- which is the
point: it demonstrates the rewritten mkcontrollermappings.sh produces exactly
what the old one did on the success path. The only differences are the $(date)
stamp and the include guard, which is _AKGL_SDL_GAMECONTROLLERDB_H_ now so it
matches every other header in include/akgl/.

That rename was made in the generator rather than by hand, per the rule about
generated files, and the tracked copy carries it so the next regeneration shows
no spurious diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:50 -04:00
d9f0187ecf Stop a failed controller-DB fetch from destroying the tracked fallback
Closes Defects -> Known and still open item 12, both halves.

include/akgl/SDL_GameControllerDB.h is tracked on purpose: it is the offline
fallback that keeps the library buildable when upstream is unreachable. The
script that writes it had no set -e and never checked curl, so a failed fetch
wrote AKGL_SDL_GAMECONTROLLER_DB_LEN 0 and an empty array over the good copy
and exited 0. The safety net destroyed itself, and because CMake re-ran the
generator on every build, an offline build was enough to do it.

The script now runs under set -euo pipefail, fetches into a temporary
directory, and moves the result into place only after checking curl's exit
status (with --fail, so an HTTP error is a status rather than an error page in
the body), a plausible minimum mapping count, and that no mapping carries a
quote or backslash that would break the C string literal it becomes. Any of
those failing leaves the tracked header exactly as it was and exits non-zero.

Verified against all five failure modes -- unresolvable host, 404, truncated
response, empty-but-successful response (the original failure exactly), and a
response containing a quote. Each refuses, and the header's checksum is
unchanged after every one. AKGL_CONTROLLERDB_URL and
AKGL_CONTROLLERDB_MIN_LINES are environment-overridable, which is how.

The build no longer runs it at all. The add_custom_command declared a relative
OUTPUT, which CMake resolves against the binary directory while the script
writes to the source directory, so the declared output never appeared and every
build re-ran the command -- needing network access and leaving the tree dirty.
It is an explicit `controllerdb` target now, and the header is no longer listed
as a library source, which is all it was there for.

The empty-initializer problem goes with it: an empty array initializer is a
constraint violation in ISO C that compiles only as a GCC extension, and the
minimum-count check guarantees at least one entry.

Also here, because it rested on a premise that turned out to be false: the
character suite is no longer excluded from CI's coverage and memory-check jobs,
nor from the mutation harness by default. It was excluded on the grounds that
it failed deliberately. It did not -- it was reporting success while running
one of its four tests.

25/25 pass, memcheck clean, shellcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:28 -04:00
913834a3af Draw actors at their sprite's height, and update each one once a frame
Closes Defects item 26 and Performance item 32.

akgl_actor_render set dest.h from curSprite->width, so every actor was drawn
square and a non-square sprite was stretched or squashed. Invisible in the
fixtures because they are all square, so tests/actor.c gets a 48x24 sprite and
a render backend whose draw_texture records the rectangle it is handed instead
of drawing it. That recording backend is the first coverage akgl_actor_render
has had at all -- every other test in the file stubs renderfunc out.

akgl_game_update looped over AKGL_TILEMAP_MAX_LAYERS with the actor sweep
nested inside it and never compared an actor's layer to the layer it was on, so
every live actor's updatefunc ran sixteen times a frame. The sweep is hoisted
out: updating an actor is not a per-layer operation, and
akgl_render_2d_draw_world already walks the layers for the half that is.
AKGL_ITERATOR_OP_LAYERMASK is honoured rather than ignored now, so a caller who
wants one layer can still ask, and gets each of those actors once.

Counting is the assertion, deliberately. The defect was invisible in the frame
total because the tilemap blits are three orders of magnitude larger, so a
timing test would have measured the rasterizer. tests/game.c counts calls into
a stub updatefunc and reports 16 against the old code.

PERFORMANCE.md records the timing side as what it honestly is: the gap between
the akgl_game_update and draw_world rows of the same run, 92 us before and
noise in both directions after. The absolute table is not re-taken -- a later
run on this machine read every row about 15% high, including rows nothing has
touched.

25/25 pass, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:27 -04:00
3ee8c60491 Record the fixes whose TODO.md edits were dropped
Eleven entries still described the old code: known-and-still-open items 1, 2,
5, 9 and 11, error-handling items 16, 17 and 18, and defects 20, 29 and 30. The
code and the tests were right; the document was not.

The edits were lost the same way each time. The scripts that made them built
the whole file in memory and wrote it once at the end, so an assertion failure
partway through -- on an entry whose text had already been reworded -- threw
away that script's earlier, correct replacements along with the failed one.
Written one entry at a time now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:27 -04:00
3f37d38f2d Re-record coverage after the 0.5.0 defect work
83.9% line and 91.2% function, from 79.6% / 87.2%. src/tilemap.c moved the
most, 47% to 62%, because bounding the loaders and proving the pool accounting
needed them driven rather than merely called.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:34:27 -04:00
9c2f80bcb9 Give back every pooled string and sprite reference the loaders take
Closes Defects items 20, 22, 23 and 29, and the residual of item 38.

The tilemap load leak was five pooled strings per load; the property-lookup
fix in an earlier commit took it to two, and the last two were each a claim
with no matching release -- the string every layer's `type` was read into, and
the dirname the map's relative paths resolve against. tests/tilemap.c asserts
the pool is exactly where it started after one load/release cycle and after 64.

Finding those two was a matter of dumping the contents of every still-claimed
slot after a cycle rather than reading the code again; 'tilelayer' and an
assets directory named themselves immediately.

Same file, same class, fixed with it: akgl_tilemap_load_layer_objects released
its scratch string after reading each object's name and then kept using the
slot, because akgl_get_json_string_value reuses a non-NULL destination without
taking another reference. The slot was free while still live, so any other
claim could have been handed it.

akgl_character_sprite_add wrote over an existing binding without releasing the
sprite it displaced, so a character that rebinds a state while alive leaked a
sprite slot per rebind -- teardown only gives back what the map holds at the
end. The new reference is taken before the write and given back if the write
fails, so there is no window where a sprite is bound with nothing behind it.
The write was unchecked too.

Three failure-path leaks moved into CLEANUP blocks: akgl_render_2d_init's two
pooled strings, akgl_controller_open_gamepads' enumeration array, and
akgl_text_rendertextat's surface and texture -- the last being a leak per frame
on a HUD line.

akgl_text_unloadallfonts() closes every font in the registry and destroys it,
which is what item 38 left open. Deliberately not a whole akgl_game_shutdown:
tearing down the mixer, SDL_ttf and SDL in the right order is a design
question, and this is the part that was simply missing. It is a new public
symbol, which 0.5.0 already covers -- this release has not shipped.

Every fix has a test that fails against the old code.

25/25 pass, memcheck clean, reindent --check, check_api_surface and
check_error_protocol all clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:55 -04:00
8a90cbf275 Drop the perf suite's lowfpsfunc workaround, and record item 31
akgl_game_update_fps installs the default itself now, so the benchmark no
longer has to. Leaving the hook NULL there is what keeps that true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:38 -04:00
21d69192e3 Report the failures that used to be crashes
Closes Defects items 30 and 31 and Known-and-still-open items 1, 2, 5, 9 and 11.

Both string accessors in json_helpers.c ended their ATTEMPT block with
FINISH(errctx, false), which swallows the failure, and then strncpy'd through
the pointer akgl_heap_next_string never set. So the one condition the pool
exists to report -- it is full, which in practice means something is not
releasing -- arrived as a segfault somewhere else entirely. It is
FINISH(errctx, true) now, and tests/json_helpers.c claims every slot and
asserts AKGL_ERR_HEAP comes back out of both. That test segfaults against the
old code, which is also how the tilemap leak test in the previous commit
confirmed this one.

akgl_tilemap_release tested layers[i].texture and destroyed
tilesets[i].texture, so every tileset texture was freed twice on one release
and no image layer's texture was freed at all. Pointers are cleared as they go,
so a second release is safe instead of a use-after-free.

akgl_game_update_fps called game.lowfpsfunc() unguarded, on a path taken on
frame one because fps is 0 for the first second. Only akgl_game_init installs
it, and renderer.h documents the other path deliberately -- a host that owns
its window binds a backend instead. It installs the default when it finds NULL.

akgl_controller_pushmap and akgl_controller_default checked only the upper
bound, so a negative id indexed before akgl_controlmaps.

The two test-harness helpers were quietly worthless. akgl_render_and_compare
drew t1 on both passes, so it always reported a match and every image assertion
built on it asserted nothing; and akgl_compare_sdl_surfaces memcmp'd
s1->pitch * s1->h bytes out of both surfaces without checking that the second
was the same size, so a smaller one was read past its end. Both fixed, both
tested. tests/util.c also now calls the collide-point test it has defined and
never run.

25/25 pass, memcheck clean, reindent --check and check_error_protocol clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:37 -04:00
230278d303 Bound every array a data file can index
Closes Defects items 16 and 17 and Known-and-still-open item 6. All three let
an asset file, or a caller's argument, write past a fixed array.

akgl_sprite_load_json took its frame count straight from the document and wrote
that many entries into a 16-byte frameids -- through a uint32_t * cast of a
uint8_t *, so each write touched four bytes and the overrun reached four bytes
past the array, into the rest of akgl_Sprite and then the next pool slot. The
count is checked first now, each id is read into an int and narrowed
deliberately, and a frame number too large for a uint8_t is refused rather than
truncated into an index for a different tile.

The tilemap loader had the same shape twice: objects[j] with no check against
AKGL_TILEMAP_MAX_OBJECTS_PER_LAYER and tilesets[i] with none against
AKGL_TILEMAP_MAX_TILESETS. akgl_tilemap_load_layers already bounded its own
loop, so the pattern was in the same file. The object one is the reachable
half -- 128 objects is not a large object layer.

akgl_string_initialize zeroed sizeof(akgl_String) starting at `data`, which
begins after the refcount in front of it, so it ran four bytes past the end of
the object and onto the *next* slot's refcount -- the field the allocator reads
to decide whether a slot is free. Same file, same class, fixed with it:
akgl_string_copy accepted a count larger than the buffers, reading past one
pool slot and writing past another, which the header documented as behaviour.

Every case has a test that fails against the old code, with five new fixtures.
Exactly-the-maximum is asserted alongside one-past in each, so the bound cannot
be fixed by making the limit off by one.

25/25 pass, memcheck clean, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:37 -04:00
6b1cf437d3 Record that akgl_registry_init now brings up the properties registry
Missed in the previous commit, which made the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:37 -04:00
19530f6a97 Fix the type, macro and state-table defects, and the leftover debris
Closes internal-consistency items 19 through 36, 38 and 41. Item 37, the ~180
redundant casts, is deliberately left open with its reasoning in TODO.md: the
benefit only arrives once the build turns on the warnings those casts suppress,
and doing it before that is churn across the two files with the most
outstanding functional defects.

The two that were real bugs are in the actor state table.
AKGL_ACTOR_STATE_STRING_NAMES was declared [AKGL_ACTOR_MAX_STATES+1] and
defined [32], so a consumer trusting the declared bound read past the object;
and indices 11 and 12 were named UNDEFINED_11 and UNDEFINED_12 where actor.h
has MOVING_IN and MOVING_OUT, so no character JSON could bind a sprite to
either state. tests/registry.c now walks the whole table -- every entry
non-NULL, every entry resolving to its own bit, no two entries sharing a name.

The bitmask macros are parenthesized and AKGL_BITMASK_CLEAR has lost the
semicolon inside its body. Writing tests/bitmasks.c for that turned up
something worth knowing: the obvious test does not catch it. For a bit that is
set, the misparse `!(mask & bit) == bit` gives the same answer as the correct
one. It only diverges for an unset bit whose value is not 1, and that is the
shape the suite uses now.

akgl_draw_background was the last public function outside the error protocol.
It takes a backend like everything else in draw.h, restores the draw colour it
found, and is tested -- TODO.md had it filed under "needs the offscreen
renderer harness", which was never true; what it needed was to stop reading the
global.

All eight registry initializers go through one helper, so the seven that leaked
an SDL_PropertiesID on every call after the first no longer do. Fixed in the
same place because it is the same function: akgl_registry_init never called
akgl_registry_init_properties, which made akgl_set_property a silent no-op for
anyone not going through akgl_game_init -- Defects, Known and still open item 3.

Also: AKGL_COLLIDE_RECTANGLES (three open parens, two closes) and akgl_Frame
deleted, float32_t/float64_t used consistently, the developer-specific debug
logging removed from the controller inner loop, the abandoned SDL_GetBasePath
comments removed, nine unused locals removed, and dst renamed to dest.

akgl_game_update's default flags no longer OR the same bit twice. That changes
nothing today, and the reason is Performance item 32: the loop never reads
either bit, which is why every actor is updated sixteen times a frame. Still
open.

25/25 pass, memcheck clean, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:36 -04:00
a60220d39b Name every local error context errctx
Closes internal-consistency item 18. The convention was settled -- AGENTS.md
records errctx winning 92 sites to 45 -- but the other 45 were still there, and
four files favoured `e` throughout while six favoured errctx, sometimes within
one file.

Its own commit because it is a rename and nothing else. All 333 changed lines
are a single identifier substitution: applying \be\b -> errctx to each removed
line reproduces the added line exactly, and the counts match at 333 either way.

`e` keeps its meaning where the convention actually wants it -- an incoming
error context being inspected, as in akgl_get_json_with_default(e, ...) -- which
is why that function was left alone.

25/25 pass, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:36 -04:00
76ea04205c Stop returning past CLEANUP, and validate the arguments every sibling validates
Closes internal-consistency items 16 and 17.

Ten *_RETURN macros sat inside ATTEMPT blocks, which return past CLEANUP and
skip every release in it. The one that mattered was the success path of
akgl_get_json_tilemap_property: it leaked two of the string pool's 256 entries
on every lookup that *found* what it was asked for, and a map load does that
several times per layer. tests/tilemap.c now runs each of its three paths --
found, absent, wrong type -- twice the pool size and asserts the pool is where
it started. Against the old code that test does not merely fail, it segfaults,
which is Defects item 30 seen from the outside: pool exhaustion arriving as a
NULL strncpy rather than as AKGL_ERR_HEAP.

Two of the conversions needed more than swapping the macro. In
akgl_get_json_tilemap_property a plain break would have fallen through to the
"property not found" FAIL_RETURN after FINISH, reporting a miss for something
found, so the success path sets a flag. In akgl_collide_rectangles the eight
early exits are followed by `*collide = false;`, which would have overwritten
the hit that broke out; each corner test writes the flag itself, so that line
is gone rather than moved. The same function also released its scratch string
once per loop iteration while continuing to use it, so the slot was free while
still live -- one claim now covers the whole scan.

akgl_controller_default is the other behavioural one: its SUCCEED_RETURN was
the last statement in the ATTEMPT block, so the path that falls out of FINISH
reached the closing brace of a non-void function.

scripts/check_error_protocol.py keeps both rules enforced -- a *_RETURN inside
ATTEMPT, and a return out of a HANDLE block -- as the error_protocol test.
Neither produces a compiler diagnostic and neither fails a test run until the
pool it drains is empty, which is why both have already shipped once.

For item 17: the eight typed JSON accessors that validated their container and
then wrote through dest unconditionally now check key and dest as the two
string accessors always did; the null physics backend checks its actors like
the arcade one; and akgl_render_2d_frame_start, _frame_end and _shutdown check
self, which the first two read straight through. tests/renderer.c calls all
three with NULL, which segfaulted before.

25/25 pass, memcheck clean, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:35 -04:00
3a262bee54 Give every exported function a declaration, and check that it stays that way
Closes internal-consistency items 7 through 15. Nineteen non-static functions
were in the ABI with no declaration anywhere, so no consumer could call them
and any consumer could collide with them.

The four gamepad_handle_* functions are the ones that mattered: controller.h
declared akgl_controller_handle_button_down and three siblings that did not
exist, so anything compiled against the header alone failed to link. The
definitions carry the declared names now, which also closes Defects -> Known
and still open item 10, and their documentation moved to the header.

The rest are either declared under a "part of the internal API" block --
akgl_game_save_actors and akgl_game_load_versioncmp, which tests/game.c had to
declare for itself, plus six tilemap loader helpers the untested-loader work
wants to reach -- or static, which is what the four save iterators and
load_objectnamemap should always have been. akgl_path_relative_from is deleted:
declared nowhere, called from nowhere, never wrote its output, and leaked a
pooled string on every call, so it closes Known and still open item 4 and item
40 by ceasing to exist.

scripts/check_api_surface.sh keeps it closed. It reads the built library's
dynamic symbol table and every public header with comments stripped, and fails
on an exported akgl_* symbol that is declared nowhere. Stripping comments is
the whole point -- four of these were mentioned in controller.h prose, which is
how they went unnoticed.

The pool-size ceilings are defined once, in heap.h, so the #ifndef override
hook fires for the first time; actor.h, sprite.h and character.h were defining
the same four unconditionally from headers heap.h includes above its own guard.
tests/header_pool_override.c fails the compile if that regresses.

Also here: (void) rather than () on the twelve no-argument entry points,
AKERR_NOIGNORE only on declarations, static helpers with the akgl_ prefix
dropped, and the six parameter-name mismatches. akgl_get_json_with_default had
its two contexts swapped rather than merely misspelled -- the incoming one was
`err` and its own was `e`, which is the name reserved for an incoming one.

24/24 pass, reindent --check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:35 -04:00
9924d74dcc Namespace every exported symbol, and bump to 0.5.0
Closes internal-consistency items 1 through 6, 12 and 13. Every include guard
is _AKGL_<FILE>_H_, every in-project header include is angled, and every
exported function, type and global carries the akgl_ prefix. This is an ABI
break; the soname goes to libakgl.so.0.5. TODO.md carries the full rename
table.

The renames were driven by renaming each declaration and letting the compiler
find the uses, not by pattern substitution: renderer, physics and camera are
also parameter and struct-member names, and a sed would have rewritten
map->physics and every akgl_RenderBackend *renderer parameter without a word.

Item 4 turned out not to be cosmetic. The library exported a global called
renderer and tests/character.c defined an SDL_Renderer *renderer of its own;
the executable's definition preempted the library's, akgl_sprite_load_json
read a SDL_Renderer * through an akgl_RenderBackend *, and every texture load
in that suite failed. The suite reported success anyway, because libakerror's
unhandled-error handler ends in exit(errctx->status), exit keeps only the low
byte, and AKGL_ERR_SDL is exactly 256. So character had been green while
running one of its four tests, and every suite in the tree was unable to fail
on the most common status in a library built on SDL.

Both are fixed. tests/testutil.h gains TEST_TRAP_UNHANDLED_ERRORS(), which
collapses any status a byte cannot carry onto 1, and every suite installs it.
character binds a real backend with akgl_render_2d_bind. Its fourth test then
runs for the first time and fails on a defect it has asserted all along, so
akgl_heap_release_character now walks state_sprites with
AKGL_ITERATOR_OP_RELEASE and destroys the property set before zeroing the slot
-- TODO.md Defects item 21 and half of Carried over item 1.

AKGL_TIME_ONESEC_MS said "one second in milliseconds" and held 1000000, so
akgl_game_state_lock waited roughly sixteen minutes rather than one second. It
is AKGL_TIME_ONEMS_NS now, the budget is its own named constant, and
tests/game.c holds the mutex from a second thread to assert the wait -- the
contended path had no coverage at all.

Headers are self-contained and it is enforced: AKGL_PUBLIC_HEADERS drives both
install() and a generated translation unit per header, so a header that ships
is a header that is checked. Writing that found registry.h, which used
SDL_PropertiesID in eight declarations and included no SDL header.

23/23 suites pass, memcheck is clean, reindent --check is clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 07:33:35 -04:00
93e8e4afa4 Record that an actor cannot be scaled per axis
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / performance (push) Failing after 21s
libakgl CI Build / memory_check (push) Failing after 15s
libakgl CI Build / mutation_test (push) Failing after 16s
akgl_Actor::scale is one float32_t applied to both dest.w and dest.h, so
"twice as wide, the same height" cannot be expressed. The VIC-II has had
separate x- and y-expand bits since 1982 and Commodore BASIC 7.0's SPRITE
verb exposes both, so a BASIC interpreter drawing through this library
cannot implement its own documented verb.

Reached from akbasic, whose sprites are 24x21. It works around this and
defect 26 together by installing its own renderfunc on each actor rather
than patching around the library.

Two other defects were filed alongside this one before the rebase and
both are gone: the drawn-height-from-width bug is already defect 26, and
the akgl_path_relative context leak was fixed in c622754.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:55:19 -04:00
5dfa49482c Guard the add_test shadow on being top-level again
CMake chains command overrides one level deep: a second override rebinds
_add_test to the first and the builtin becomes unreachable to everyone. So two
projects in one tree cannot both shadow add_test, and akbasic has to shadow it
first for libakerror and libakstdlib -- which left its own registrations
recursing to CMake's depth limit.

The unconditional shadow arrived with the vendored-dependency fix in 18399f2.
That half stays; only the guard comes back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:54:48 -04:00
76c6240280 Make the memory check a gate
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 18s
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 16s
It was reporting and shrugging. A definite leak, a read past the end of an
allocation or a branch on uninitialised memory now fails the build on the
push that introduces it, which is the only point at which it is cheap to
fix.

The six findings the job had on its first run were fixed rather than
excused, so the gate closes on a clean tree instead of on a backlog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:49:22 -04:00
fb58bb01b0 Fix every memory defect the checker found, and bump to 0.4.0
Six findings, all of them libakgl's, all closed. The memcheck run is clean.

Not one of the four json_load_file calls in src/ was ever matched by a
json_decref, so every asset load abandoned its parsed document: 1.5 KB per
sprite, 2.1 KB per character, 9 KB per load of the 2x2 fixture map, and on
the order of a megabyte for a real one. Each loader now releases it in the
CLEANUP block it already had, on the success path as well as the failure
one. akgl_registry_load_properties needed its loop moved inside the ATTEMPT
block first: props is a borrowed reference into the document and is read
after the block ends, so every exit from that loop leaked the whole tree.

akgl_get_property copied a fixed AKGL_MAX_STRING_LENGTH bytes out of what
SDL handed back, which is SDL's strdup of the value -- four bytes for
"0.0". That read up to 4 KiB past the end of somebody else's allocation on
every property read, returned whatever was there past the terminator, and
would have faulted on a value that landed at the end of a page. It copies
the value and its terminator now, and refuses a value too long for an
akgl_String rather than truncating it into an unterminated buffer. The
header note that described the overread as a quirk describes correct
behaviour instead.

The four savegame name tables wrote a fixed-width field starting at the
registry key, and SDL sizes that allocation to the name. They read past it
on every entry and put what they found into the save file: up to half a
kilobyte of this process's heap per registered object, in a file a player
might send to somebody. They stage through a zeroed buffer now, and a
negative-array-size typedef fails the build if a table's width ever outgrows
it.

akgl_controller_list_keyboards never freed the array SDL_GetKeyboards
allocated for it.

A font could be opened and published and never handed back -- there was no
way to close one, so a game that changed fonts between scenes leaked ten
kilobytes each time, and loading over a live name leaked the font it
displaced. akgl_text_unloadfont is that missing half, and akgl_text_loadfont
calls it when it replaces a name, after the new font has opened so a failed
reload leaves the caller with the font they had. A new public symbol takes
the version to 0.4.0 and the soname with it: an 0.3 consumer cannot be
handed this library and told it is the same ABI.

tests/registry.c fills a destination with a sentinel and asserts the bytes
past the terminator survive a read, which fails against the old copy.
tests/text.c covers unload, double unload, unloading a name that was never
registered, and replacement closing the displaced font. The JSON releases
have no test of their own and cannot sensibly have one -- nothing in the
public API can observe a jansson refcount -- so the memcheck run is their
test, which is an argument for gating it rather than against.

The two remaining findings are in deps/semver's own unit test, which is
vendored. They are suppressed by function name, so a rewrite of those cases
comes back as a finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:49:11 -04:00
80fdf2e098 Run the benchmarks and the memory check in CI
The perf suites were already running in CI, because they are ordinary CTest
tests -- but only in the coverage job, which is a Debug build with gcov
instrumentation. That is the one place their numbers cannot mean anything,
and it was spending full-scale iteration counts to prove it. They now run
there at AKGL_BENCH_SCALE=0.02, for path coverage alone, which takes the
whole coverage run to four seconds.

The `performance` job is where the timings are taken: RelWithDebInfo, full
scale, `ctest -L perf`, which is the only configuration in which
tests/benchutil.h enforces a budget. It keeps the tables as an artifact
whether it passes or fails -- a red run says which measurement moved, and a
green one is the next baseline for PERFORMANCE.md. The step uses --verbose
rather than --output-on-failure, because --output-on-failure prints nothing
at all for a passing benchmark, and sets pipefail, without which the status
reaching the runner is tee's and a blown budget reads as a pass.

The `memory_check` job runs scripts/memcheck.sh over every suite under
valgrind and keeps the logs. It carries continue-on-error, and that is a
deliberate, temporary lie: the six findings in TODO.md "Memory checking" are
real and all of them are libakgl's, so gating on it today would only teach
everyone to ignore a red build. The flag comes off with the commit that
closes the last item, and item 34 -- the missing json_decref in all four
asset loaders -- is four lines.

Both new jobs were run locally against a clean copy of the tree with the
exact commands the workflow uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:33:58 -04:00
af304dc2f9 Check memory with the suites that already exist
`cmake --build build --target memcheck` runs every registered CTest suite
under valgrind. There are no new test programs, and there should not be any:
tests/benchutil.h notices valgrind in LD_PRELOAD and drops the benchmark
scale to 0.0005, which turns the perf suites into the broadest path coverage
in the tree at a cost valgrind can survive. A benchmark walks one path a
hundred thousand times; a leak check wants every path walked once. Same
binaries, one flag apart, and the whole run is about thirty seconds.

scripts/memcheck.sh wraps `ctest -T memcheck` because that command records
defects and still exits 0, which cannot gate anything. It also forces the
headless drivers, so the vendor GPU stack is never loaded -- that removes
thousands of unfixable findings inside amdgpu_dri.so without suppressing
anything, and it is what the suites are written for anyway. Only definite
losses, invalid accesses and uninitialised reads count; "still reachable" is
what SDL and FreeType keep for the process lifetime and says nothing about
this library. The three suppressions in scripts/valgrind.supp are each a
decision that a finding belongs to somebody else.

Six defects, all filed in TODO.md under "Memory checking", the first of
which is the one that matters: not one of the four json_load_file calls in
src/ is ever matched by a json_decref, so every asset load abandons its
parsed document -- 1.5 KB per sprite, 2.1 KB per character, 9 KB per load of
the 2x2 fixture map, and on the order of a megabyte for a real one. A game
that reloads a level on death leaks a level's worth of JSON every time.
akgl_get_property also reads up to 4 KiB past the end of every property
value it copies, and the savegame name tables read past the end of every
registry key and write what they find into the file.

tests/util.c zeroes three fixtures it used to leave as stack garbage. The
library was never at fault there -- the tests handed it uninitialised floats
and then made one real call with them -- but sixteen findings of noise in a
new gate is how a gate gets ignored.

The unit suites' TIMEOUT goes from 30 to 300 because CTest applies the same
property to the checked run, where everything is twenty times slower.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:59:57 -04:00
8a920860c5 Keep the defect list in TODO.md and point PERFORMANCE.md at it
The six defects the perf suites turned up were written out in full in both
files. TODO.md is where a defect belongs -- file, line, functional
consequence, blast radius, what closing it touches -- and duplicating that
in a report guarantees the two drift, with no way to tell which copy is
current.

PERFORMANCE.md keeps what is actually its argument: that stress testing
reaches failures a suite calling each function a handful of times does not,
and that two of the six kill the process rather than slowing it down. The
detail is one reference away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:42:41 -04:00
bcd49fc5b1 Measure the library: perf suites, a recorded baseline, and targets
tests/perf.c and tests/perf_render.c drive the hot paths hard enough to
time them -- pool acquire and release at both ends of the scan, the
registry, the state-to-sprite lookup, the per-actor update and render, the
physics sweep, an all-pairs collision sweep, the JSON accessors, path
resolution, the drawing primitives, text, asset loading, a screenful of
tiles, and a whole frame through akgl_game_update. They are registered like
any other suite but carry the `perf` label, so `ctest -L perf` runs only
them and `-LE perf` leaves them out. Every measurement is held to a budget
at roughly ten times the recorded baseline, enforced only in an optimized
build at full scale.

Three things had to be got right before the numbers meant anything, and
each was wrong first:

- No error checking inside the clock. PASS and CATCH call
  akerr_valid_error_address, which walks AKERR_ARRAY_ERROR -- more work than
  several of the calls being measured.
- Flush the renderer before stopping it. SDL batches, so the first version
  measured queueing, reported a tilemap frame 250 times faster than it is,
  and paid the real cost at teardown inside SDL_DestroyTexture.
- A drawing benchmark needs a raw-SDL control doing the same pixel work
  with the same access pattern. With one, akgl_tilemap_draw turns out to
  cost 0.2% of the frame it appeared to own: 16.26 ms against a control's
  16.23 ms for the same 1200 blits. The rasterizer is the frame.

PERFORMANCE.md records the baseline, the frame budget it adds up to, and
what the numbers say -- including that the string pool's acquire is 64x
slower full than empty because it is a megabyte of PATH_MAX buffers, that a
handled missing-sprite condition costs nine times the update it replaces,
that text rasterizes and throws away a texture on every call, and that 28 MB
of the library's BSS is one akgl_Tilemap.

TODO.md gains a Performance section: five defects the stress tests found
that the unit suites do not reach (a tilemap load leaking five pooled
strings, two JSON accessors that turn pool exhaustion into a segfault
rather than AKGL_ERR_HEAP, akgl_game_update crashing without
akgl_game_init, and its actor update sweep running once per tilemap layer),
and eighteen targets with today's number and whether it is met.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:24:05 -04:00
c6227545a6 Release the error context when a path resolves against its root
akgl_path_relative took its ENOENT branch by returning from inside the
HANDLE block, which skips the RELEASE_ERROR that FINISH ends with. The
handled context was never given back, so one entry of AKERR_ARRAY_ERROR
was lost per call and the 129th call hit "Unable to pull an error context
from the array!" and exited the process.

That branch is not an edge case: every asset path inside a tilemap is
resolved relative to the map's own directory, so it is taken several times
per map load. A game that loaded fifty levels died in the loader. It was
found by a benchmark that loaded the fixture map in a loop, which is the
first thing in this tree to call it more than a hundred times.

The fallback now runs after FINISH, so the context is released on the way
past. tests/util.c resolves AKERR_MAX_ARRAY_ERROR * 2 paths through the
branch and asserts the pool is where it started -- against the old code
that test does not fail, it terminates the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:23:47 -04:00
f35443e84d Record the akbasic API gaps as resolved and bump to 0.3.0
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / mutation_test (push) Failing after 17s
All ten items under "API gaps blocking akbasic" are closed. Items 7, 9 and
10 add public symbols and item 9 adds three fields to akgl_AudioVoice, so
the version and the soname go with them -- an 0.2 consumer cannot be handed
this library and told it is compatible.

Also refreshes the coverage numbers against a fresh run (79.6% line, 87.2%
function, all 21 suites), records the mutation smoke runs over the two files
that changed most, and files one new defect the text tests turned up:
akgl_text_rendertextat refuses the empty string while akgl_text_measure
accepts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:54:04 -04:00
57bf1c7649 Include actor.h from controller.h so the header compiles on its own
controller.h declares three handler pointers taking an akgl_Actor * and
included nothing that declares that type, so any translation unit reaching
for it before akgl/actor.h failed with "unknown type name 'akgl_Actor'".
src/controller.c never noticed because it includes akgl/game.h first, and
tests/controller.c carried a comment explaining the workaround.

Included rather than forward-declared: akgl_Actor is a typedef of a named
struct and repeating a typedef is C11, not C99. It closes no cycle --
actor.h reaches only types.h and character.h.

tests/headers.c is a whole suite for one #include on purpose. A second
#include after the first proves nothing about the second, because by then
the first has dragged its dependencies in; covering another header means
another file shaped like this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:53:09 -04:00
a3eada1b3f Carry modifiers and composed text in the keystroke ring
The ring held a keycode and dropped the rest of the event, so a line editor
built on akgl_controller_poll_key() could not reach a single shifted
character: no double quote, no colon, no parenthesis, and no lower case.
The modifier state was discarded two layers below the caller, and polling
SDL_GetModState() at read time cannot recover it -- the key is long
released by then, which is the point of a ring.

akgl_Keystroke carries the keycode, the modifiers and the UTF-8 text SDL
composed, and akgl_controller_poll_keystroke() hands back all three.
akgl_controller_poll_key() is unchanged for its callers: a game asking
whether the up arrow was pressed already gets what it wants.

The text comes from SDL_EVENT_TEXT_INPUT, which is the only correct way to
get a character out of SDL, and is attached to the press still waiting for
it -- tracked with a flag rather than by "whatever entry is newest", so a
press dropped by a full buffer cannot hand its text to an older key. Text
with no press behind it (an IME commit, a dead key) is buffered on its own
with keycode 0, and the keycode-only poller discards those on the way past.
Oversized text is cut on a code point boundary, and a sequence the string
ends in the middle of is dropped rather than read past its terminator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:52:38 -04:00
34a076b851 Add akgl_audio_sweep for a note that changes pitch
BASIC's SOUND ramps a tone's pitch toward a limit, and nothing here could
do it. Faking it from the caller means re-issuing tones from its own step
loop, which ties audible pitch to how often that loop runs -- a siren that
changes shape with the frame rate. The step has to be taken on the mixer's
frame counter, which only this library can see.

akgl_audio_sweep(voice, from_hz, to_hz, step_hz, ms) steps once every
1/60 second, the rate the machine this vocabulary comes from used, which
divides 44100 exactly so a step always lands on a whole frame. Direction
comes from the two frequencies rather than the sign of the step, the last
step clamps to the target, and equal frequencies are a held tone rather
than an error. akgl_audio_tone() is the same start path with a step of 0,
so a voice reused for a plain tone stops sweeping.

A swept voice accumulates its phase instead of deriving it from the frame
counter: deriving assumes a constant frequency and would jump the waveform
at every step. tests/audio.c drives the mixer by hand and asserts the exact
frame the pitch moves on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:52:21 -04:00
42b53dcb20 Check the renderer backend before drawing text
akgl_text_rendertextat() called renderer->draw_texture() with no check on
the global renderer, its sdl_renderer or the function pointer itself, so a
backend that has an SDL_Renderer but was never bound segfaults on the first
line of text -- the state akgl_render_bind2d() exists to get a host out of.
All three are now checked, with AKERR_NULLPOINTER as the draw entry points
report, and checked before anything is rasterized rather than after.

tests/text.c grows a software renderer with a bound backend and covers all
three refusals plus the successful draw, wrapped and unwrapped: src/text.c
goes from 58% to 100% line coverage. A made-up SDL_Renderer pointer is not
enough to test this -- SDL refuses the bogus handle on its own and the call
fails for the wrong reason, which a deleted check survives.

Also documents what the tests found: SDL_ttf refuses the empty string, so
drawing an empty line is an error while measuring one is not. Filed in
TODO.md rather than pinned in the suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:52:20 -04:00
ede3452c49 Split akgl_render_bind2d out of akgl_render_init2d
A host that owns its own window -- an embedded interpreter, which must not
create one -- wants the six 2D function pointers without the window, the
camera and the property registry that came with them. Until now the only
options were to let libakgl own the window or to assign the vtable by hand.

akgl_render_bind2d() installs the methods and nothing else, deliberately
leaving self->sdl_renderer alone so a caller's own renderer survives it and
a caller without one gets a backend that reports AKERR_NULLPOINTER rather
than crashing. akgl_render_init2d() calls it once its window exists.

tests/renderer.c is new and needs no display: the binding, frame start and
end, both draw_texture paths and the draw_mesh stub against a software
renderer under the dummy video driver. src/renderer.c goes from 10% line
coverage to 56%.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:51:58 -04:00
18399f2726 Add the vendored dependencies whether or not libakgl is top-level
Embedded with add_subdirectory(), the deps/ submodules a recursive clone
just checked out were ignored and find_package(SDL3 REQUIRED) ran instead,
so configure failed on a machine with nothing installed. Each dependency is
now added by akgl_add_vendored_dependency(), which skips it when the target
is already declared or the submodule is missing; the find_package lookups
run afterwards for whatever is left. The CTest suppression moves with them
and is lifted before this project registers its own suites.

Two consequences of the move: pkg-config is only looked for when something
has to come from the system, and the build-tree RPATH keys on whether
anything was vendored rather than on being the top-level project, so an
embedded build's tests can find the satellite libraries too.

Verified by configuring and building a consumer that embeds this repository
with CMAKE_DISABLE_FIND_PACKAGE_* set for all seven dependencies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:51:46 -04:00
54df954ed6 File the keystroke ring's missing modifier state as item 10
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / mutation_test (push) Failing after 17s
akbasic built a line editor on akgl_controller_poll_key() and found the ring
carries a keycode and nothing else. No shifted character is reachable, so a
BASIC string literal cannot be typed at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:18:25 -04:00
55675cc9de Record the twelve defects reading the code for documentation turned up
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 21s
libakgl CI Build / mutation_test (push) Failing after 16s
Writing an honest @throws list means reading the implementation against the
contract its header claimed, and the two disagreed in more places than the
status codes. Recorded as items 14-25 under Defects, ordered by blast radius,
each with file, line, consequence and what closing it would touch. They are
also noted inline as @note or @warning on the function concerned, so a reader
of the generated documentation finds them without coming here first.

The three that matter most:

- akgl_path_relative returns from inside its ENOENT handler, skipping the
  RELEASE_ERROR that FINISH carries, so it leaks one of libakerror's 128
  error-context slots per call. That is not a rare path - it is the ordinary
  one, taken whenever an asset names a neighbour relative to its own file.
- akgl_sprite_load_json writes json_array_size() entries into a 16-element
  frameids array with no bound check, and does it through a uint32_t * cast of
  a uint8_t *. The same unbounded shape appears twice more in the tilemap
  loader, for objects per layer and tilesets per map.
- akgl_heap_release_character zeroes the character without walking its
  state-to-sprite map, so every sprite reference it took is lost and the SDL
  property set holding the map is leaked. Releasing characters between levels
  exhausts the sprite pool. akgl_character_state_sprites_iterate exists to do
  that walk and is not called from there.

Also: the background music never loops, because MIX_PROP_PLAY_LOOPS_NUMBER is
set on property set 0 rather than one akgl_load_start_bgm owns; and
akgl_get_json_with_default defaults on AKERR_INDEX while the array accessors
report a short array as AKERR_OUTOFBOUNDS, so the optional-element form
silently does not work for arrays.

No code changes. Every src/*.c:NNN citation in the new section was checked
against the file after the preceding commit shifted the line numbers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:24:41 -04:00
f0858b0d38 Document what the functions actually do instead of that they can fail
The Doxygen comments were generated from the declarations, so 217 @throws
lines across 21 headers read "When the corresponding validation or operation
fails" and told a caller nothing beyond the status name. The @param lines were
the same shape: every output was "Output destination populated by the
function", every instance "Object to initialize, inspect, or modify".

Rewritten against the implementations, following the pattern libakstdlib
already uses:

- @throws names the condition. akgl_sprite_load_json separated AKERR_KEY
  (absent) from AKERR_TYPE (present, wrong type) from AKERR_OUTOFBOUNDS
  (filename too long, or array indexed past its end), and gained AKGL_ERR_SDL
  and AKGL_ERR_HEAP, which it raises and never declared.
- Parameters say whether they are required, what a NULL means, and what is
  written on a failure path. Where an argument is not checked, the doc says so:
  akgl_heap_next_actor's dest is a crash on NULL, not an error, and
  akgl_render_2d_frame_start dereferences self before testing it.
- The conventions move up to the file blocks so the per-function docs stay
  short. json_helpers.h states once that absence is an error here and that
  json_t * results are borrowed; heap.h explains the pool model and the
  acquire asymmetry; physics.h carries the thrust/environmental/velocity table.
- Struct fields, enum values, macros and exported globals are documented,
  including the dead ones - sprite_w/sprite_h, movetimer, p_scale and
  timer_gravity are read by nothing, and say so.

Also fixes ten comments in error.h and audio.h that opened with /** rather
than /**<, so Doxygen attached them to the following entity and rendered the
text as part of the macro's value. Verified against the generated HTML.

Comments only - no declaration changed. Doxygen builds clean under
WARN_AS_ERROR, scripts/reindent.sh --check passes, 19/19 suites pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:23:15 -04:00
582008a411 File five defects akbasic found consuming the new APIs
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 18s
libakgl CI Build / mutation_test (push) Failing after 17s
akbasic has now consumed all four of the API gaps this file listed as blocking
it: its text sink, graphics backend, audio backend and input backend are written
and tested, and the BASIC 7.0 graphics, sound and console verbs work against
them. Doing that turned up six things worth fixing here -- five of which still
are.

Four are workarounds akbasic had to write to build at all, and each is commented
at its site over there with the words "filed upstream":

- An embedded libakgl requires SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to
  be installed, while vendoring all five. The dependency block is gated on being
  top-level, so an add_subdirectory() consumer takes the find_package path and
  fails with the submodules sitting right there in deps/.
- include/akgl/controller.h does not compile on its own: it declares handlers
  taking an akgl_Actor * and includes nothing that declares the type. src/
  never notices because it includes game.h first.
- akgl_render_init2d() fuses two separable jobs -- creating a window, and
  installing the backend vtable -- so a caller who already owns an SDL_Renderer
  has no way to get the second without the first. That caller is exactly the
  embedding host this section exists to serve.
- akgl_text_rendertextat() dereferences renderer->draw_texture with no check,
  which is the state item 7 leaves a host in. Same class of defect the draw
  commit added a test for; the text path still has it.

The fifth is a real API gap: SOUND's frequency sweep has no equivalent, and it
cannot be faked in the interpreter without tying audible pitch to the host's
frame rate, so it has to be advanced on the mixer's own frame counter.

The sixth was that 42b60f7 shipped 22 public symbols under an unchanged VERSION
and soname. 1066ac7 fixed that independently while this was being written -- the
two crossed rather than one following the other -- so it is filed as resolved
rather than dropped. The reason is the part worth keeping: akbasic could not
feature-test for the new API and had to pin libakgl by submodule commit in its
README until that landed, which is the concrete cost of an additive release
under an unchanged soname.

All five outstanding items were re-verified against this tree after the rebase,
not just carried forward: controller.h still includes neither actor.h nor a
forward declaration, render_init2d still calls SDL_CreateWindowAndRenderer
before installing the vtable, text.c:62 still reaches through draw_texture
unchecked, the vendored-dependency block is still gated on being top-level, and
there is still no akgl_audio_sweep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:47:16 -04:00
1066ac716e Bump libakgl to 0.2.0
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / mutation_test (push) Failing after 18s
Require libakstdlib 0.2 when consuming an installed dependency, matching the API adopted by the preceding commit.

Co-Authored-By: Codex GPT-5 <noreply@openai.com>
2026-07-31 08:45:38 -04:00
996cacb10c Consume libakstdlib 0.2.0
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 19s
libakgl CI Build / mutation_test (push) Failing after 17s
Pass fixed buffer sizes to the bounded formatting and realpath wrappers. Route save-game records through exact-transfer helpers so the required fread/fwrite count parameter stays local to the complete-record contract.

Co-Authored-By: Codex GPT-5 <noreply@openai.com>
2026-07-31 08:27:15 -04:00
42b60f725d Close the test gaps mutation testing found in audio and draw
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 19s
libakgl CI Build / mutation_test (push) Failing after 17s
A mutation smoke run scored src/audio.c at 50% and src/draw.c at 70% and named
three real holes:

- Nothing asserted that an unconfigured voice is audible, which is the entire
  reason the voice table defaults to a square wave at full level instead of a
  zeroed struct. Deleting the defaults survived.
- No envelope test used a non-zero attack and decay together, so the decay
  measuring from the start of the note rather than from the end of the attack
  survived.
- The circle was only checked at its four axis points, which a mis-signed
  octant reflection survives. It now checks that every plotted pixel has a
  mirror in the other three quadrants.

Also adds a backend that exists but was never given an SDL_Renderer, which is
the state a host is in between allocating one and initializing it; every draw
entry point has to report it rather than dereference it.

audio 50% -> 75%, draw 70% -> 90%. The survivors are recorded in TODO.md and
are honestly untestable from here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:30:18 -04:00
4208d9d471 Regenerate the coverage numbers
77.2% line and 83.1% function coverage across 2637 lines, up from 72.2% / 78.6%,
with the three new suites at 91% (audio), 95% (draw) and 58% (text). All 19
suites pass: the note recording character as an intentional failure was stale,
and neither tests/character.c nor src/character.c has changed since it was
written.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:17:18 -04:00
f56f88710f Initialize the flood fill's dirty rectangle at its declaration
GCC cannot see that it is only read on a path where flood_region() wrote it,
and warns under -Wmaybe-uninitialized.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:15:08 -04:00
2a3ca48d8f Synthesise tones on three voices
There was no audio API at all: SDL3_mixer was vendored and AKGL_REGISTRY_MUSIC
was declared, but nothing opened a mixer, and a mixer plays recordings anyway.
BASIC 7.0's SOUND, PLAY, ENVELOPE and VOL describe notes, so this adds a tone
generator -- three voices, five waveforms, an ADSR envelope each, mixed to one
float stream and fed to an SDL_AudioStream.

The voice table works whether or not a device is open. akgl_audio_init connects
it to one; a host that owns its own audio pipeline can call akgl_audio_mix
instead. That is also what makes the tests deterministic: a device pulls samples
on SDL's audio thread whenever it likes, so tests/audio.c mixes by hand and only
opens a device in its last test.

Phase is computed from the frame counter rather than accumulated, because a
float increment of hz/44100 added 44100 times a second walks a held note off
pitch. A voice that has never been configured defaults to a square wave at full
level, since a zeroed voice would have a sustain of 0.0 and make no sound with
no error to explain why. Voices summing past full scale are clamped rather than
scaled, so one voice plays at the level it was asked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:13:18 -04:00
dba0f8db89 Draw shapes immediately against a renderer
BASIC 7.0's graphics verbs -- DRAW, BOX, CIRCLE, PAINT, SSHAPE and GSHAPE -- all
plot against the current screen right now rather than adding to a scene, and
draw.h had exactly one function in it. This adds akgl_draw_point, _line, _rect,
_filled_rect, _circle, _flood_fill, _copy_region and _paste_region, each taking
the akgl_RenderBackend the host already initialized.

Color is an argument rather than a current-color global, so there is no second
copy of state to disagree with the caller's own, and each call restores the
renderer's draw color when it is done. SDL3 has no circle and no flood fill: the
circle is a midpoint circle, and the fill reads the target back, walks the
region with a fixed 4096-entry span stack, and blits back only the bounding box
of what changed. Exhausting that stack reports AKERR_OUTOFBOUNDS and says in the
header that the region is left partially filled.

tests/draw.c draws into a 64x64 software renderer under the dummy video driver
and reads the pixels back, so it needs no display and no offscreen harness.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 07:05:36 -04:00
1ddc64010a Let a caller take keystrokes without owning the event loop
akbasic's GET and GETKEY ask "is there a keystroke waiting, yes or no" and must
not require the interpreter to pump SDL events itself. akgl_controller_poll_key
drains one key per call from a fixed 32-entry ring that
akgl_controller_handle_event fills, so the host keeps pumping and the embedded
interpreter reads at its own pace. akgl_controller_flush_keys discards a
backlog.

The recording happens before the control-map scan, not after: that scan returns
as soon as a binding claims the event, so recording afterwards would have
dropped exactly the keys a game also acts on. A full buffer refuses the newest
keystroke rather than overwriting the oldest, so a caller reading a line gets
what was typed first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:57:50 -04:00
17e6e04c79 Measure rendered text without drawing it
akbasic needs the advance width of one character cell to build a terminal-style
text surface, and the wrapped size to know where a string crosses the right
margin. akgl_text_measure and akgl_text_measure_wrapped report both over
TTF_GetStringSize and TTF_GetStringSizeWrapped; neither needs a renderer, so
this half of the text subsystem is testable without the offscreen harness.

A negative wrap length is refused with AKERR_OUTOFBOUNDS: SDL_ttf reads it as a
very large unsigned width and quietly stops wrapping, which would return a
measurement that is wrong rather than an error the caller can see.

Also fixes akgl_text_loadfont, which checked name twice and passed an unchecked
filepath to TTF_OpenFont (TODO item 39).

The fixture font is a 10 KB monospaced ASCII subset of Liberation Mono, renamed
because "Liberation" is a Reserved Font Name; see
tests/assets/akgl_test_mono.LICENSE.txt. Monospaced so the suite can assert
width("AAAA") == 4 * width("A") rather than hardcode glyph metrics.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:55:25 -04:00
5f03475e0f Record the API gaps blocking akbasic
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 19s
libakgl CI Build / mutation_test (push) Failing after 17s
akbasic is a C port of the BASIC interpreter being built to link into libakgl as
a scripting engine for game authors. Its interpreter core is complete and passes
its whole acceptance corpus against a stdio text sink; four gaps here block the
libakgl-backed sink and the graphics, sound and console verbs of Commodore
BASIC 7.0.

Filed rather than worked around downstream, because growing libakgl to serve a
consumer is the wanted outcome. Each entry says what the BASIC verb needs, what
the akgl_* entry point should look like, and what would cover it.

Only the first blocks work already designed and waiting. text.h can load a font
and render a string and cannot say how large that string will be, so there is no
way to build a terminal-style surface on it -- a cursor needs the advance width
of a cell and wrapping needs to know where a string crosses the margin. The
reference interpreter derived its whole character grid from SDL2_ttf's
SizeUTF8("A"). akgl_text_measure() over TTF_GetStringSize closes it.

The other three block verbs not yet started: draw.h declares one function and
src/draw.c is at 0%, so DRAW/BOX/CIRCLE/PAINT have nothing to plot with; there
is no audio API at all despite SDL3_mixer being vendored, and PLAY/ENVELOPE want
a synthesised voice SDL3_mixer does not provide; and controller.h is built
around event handlers the host pumps, which suits a game loop but not GET/GETKEY
-- those ask whether a keystroke is waiting and must not require the interpreter
to own the event loop, which goal 3 forbids anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 06:29:17 -04:00
6dfe7487ae Record the stale-build-tree coverage defect
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 19s
libakgl CI Build / mutation_test (push) Failing after 16s
Defects #13: gcovr searches for gcov data under --root, which
CMakeLists.txt:208 and :223 set to the source directory, so any
instrumented build tree left there is merged into the report. The
--object-directory argument beside each does not narrow the search; it
only maps gcda files back to the compiler's working directory.

A leftover build-coverage/ therefore fails coverage_reset before any
test runs, with gcovr unable to reconcile a function reported on two
different lines. The build*/ entry in .gitignore keeps the offending
tree out of git status, so the state is easy to get into and hard to
spot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:58:55 -04:00
c2b16d3c18 Upgrade to libakstdlib 0.1.0
Bump deps/libakstdlib five commits to 95e5002, which versions the library
at 0.1.0 with an soname and a ConfigVersion file, upgrades its own
libakerror to 1.0.0, namespaces its coverage target when embedded, and
raises its libc-wrapper coverage. The public header diff is purely
additive -- nothing removed, no signature changed -- so libakgl's nine
aksl_* call sites needed no edits. What broke was the build.

A default `cmake -S . -B build` stopped configuring:

    add_executable cannot create target "test_version" because another
    target with the same name already exists. The existing target is an
    executable created in source directory ".../deps/libakstdlib"

The versioning commit added tests/test_version.c to libakstdlib.
EXCLUDE_FROM_ALL keeps that target from being built, but
add_subdirectory still creates it, so it collided with the version suite
added in the preceding commit. Build libakgl's test programs as
akgl_test_<name> instead. The CTest names are unchanged -- ctest -R
version still selects it -- and a dependency is now free to ship a test
of any name.

Ask find_package for libakstdlib 0.1 rather than any version. 0.1.0
ships akstdlibConfigVersion.cmake with SameMinorVersion compatibility,
mirroring its libakstdlib.so.0.1 soname, so this accepts any 0.1.x and
refuses 0.2 and 1.0. Unversioned, the non-embedded path would have taken
an ABI-incompatible libakstdlib without complaint.

libakerror is deliberately left unversioned in find_package. It installs
akerrorConfig.cmake and akerrorTargets.cmake but no
akerrorConfigVersion.cmake, so find_package(akerror 1.0) fails against a
correct install rather than a stale one. Its floor stays with the #error
in include/akgl/error.h. Worth fixing upstream.

Verified: clean configure, build and 16/16 ctest; a -DAKGL_COVERAGE=ON
tree configures, builds and passes 18/18, so libakstdlib's embedded
coverage-target rename does not collide here. Against a temp-prefix
install of 95e5002, find_package(akstdlib) accepts 0.1 and 0.1.0 and
refuses 0.2 and 1.0, and find_package(akerror 1.0) fails for the missing
version file as described.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:55:18 -04:00
2be9831c0c Ignore out-of-tree build directories
The first .gitignore entry was "./build/*". A leading ./ is not a valid
gitignore pattern, so it matched nothing and build/ turned up as
untracked in every git status. Use build*/, which also covers the
instrumented trees scripts/coverage.py and -DAKGL_COVERAGE=ON create.

Nothing under a build* path is tracked, so this hides no existing file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:42:41 -04:00
c5a7b6053d Derive the library version from one place and give the .so a soname
The version was written down twice and agreed with itself by luck.
AKGL_VERSION was hand-maintained in include/akgl/game.h, and nothing
connected it to the build, which had no version at all:

- project() declared no VERSION, so the @PROJECT_VERSION@ substitution
  in akgl.pc.in expanded to nothing and every installed akgl.pc shipped
  an empty Version field. pkg-config --modversion returned "" and
  --atleast-version could not work at all.
- libakgl.so had no VERSION or SOVERSION, so there was no soname and no
  symlink chain. A stale libakgl could be paired with new headers
  silently, which is the failure libakerror added its own soname to
  prevent.
- MY_LIBRARY_VERSION at CMakeLists.txt:288 was never set anywhere. It
  built main_lib_dest as "lib/akgl-", and main_lib_dest was then never
  read. Dead line, removed.

project(akgl VERSION 0.1.0) is now the only place the number appears. It
drives the generated include/akgl/version.h, the shared library VERSION
and SOVERSION, and the Version field in akgl.pc. Held at 0.1.0 to match
the constant it replaces, so akgl_game_load_versioncmp still compares
savegames against the same value.

include/akgl/version.h is generated by configure_file from version.h.in
and publishes AKGL_VERSION, AKGL_VERSION_MAJOR/MINOR/PATCH and
AKGL_VERSION_AT_LEAST(major, minor, patch). That macro is the point:
libakstdlib could not write that test against libakerror, which
publishes no version macro, and had to feature-test on
AKERR_FIRST_CONSUMER_STATUS instead. game.h now includes the generated
header rather than defining the version itself, so consumers see
AKGL_VERSION exactly where they saw it before.

While the major version is 0 the soname carries major.minor, giving
libakgl.so.0.1. A plain SOVERSION 0 would assert that 0.1.0 and 0.2.0
are ABI-interchangeable, and they are not -- the preceding commit alone
added akgl_error_init. At 1.0.0 the soname becomes the major alone,
matching libakerror.

akgl_version() in src/version.c reports the version of the libakgl that
is actually linked, where AKGL_VERSION reports what the caller compiled
against. Comparing the two is the only way to catch a stale shared
library on the loader path; the macro alone cannot see it. It returns a
string rather than an akerr_ErrorContext * because it cannot fail, for
the same reason akerr_name_for_status does.

Add tests/version.c: the string and the numeric components must describe
one version, the linked library must agree with the headers, and
AKGL_VERSION_AT_LEAST must order correctly at the major, minor and patch
boundaries. Verified out of band that bumping project() to 1.2.3 moves
version.h, akgl.pc and the soname together, and that an install produces
the libakgl.so -> .so.0.1 -> .so.0.1.0 chain with SONAME libakgl.so.0.1.

Also ignore include/akgl/version.h. include/ precedes the build tree on
the include path, so a stray copy there would shadow the generated one
and pin every consumer to whatever version it was generated at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:36:50 -04:00
9b124f2e27 Migrate to the libakerror 1.0.0 status registry
libakerror 1.0.0 replaced the consumer-sized status-name array with a
private registry and made status-code ownership explicit and enforced.
AKERR_MAX_ERR_VALUE and __AKERR_ERROR_NAMES are gone, and the registry
entry points raise akerr_ErrorContext * instead of returning int. See
deps/libakerror/UPGRADING.md.

The break was not only source-level. libakgl's codes sat at
AKERR_LAST_ERRNO_VALUE + 18 through + 22, and 1.0.0 claimed exactly
those five offsets for its own AKERR_STATUS_* registry codes, so every
AKGL_ERR_* was aliasing a libakerror status. HANDLE(e,
AKGL_ERR_LOGICINTERRUPT) at physics.c:222 would have swallowed a
foreign-name refusal.

- Move the band to AKERR_FIRST_CONSUMER_STATUS (256) as fixed offsets,
  so a libc that grows an errno cannot move the codes, and add
  AKGL_ERR_OWNER, AKGL_ERR_LIMIT and AKGL_ERR_COUNT to describe it.
- Reserve the range and register the names through the owned entry
  points, PASS-ing each: these are AKERR_NOIGNORE, and the old
  akerr_name_for_status calls discarded failure silently.
- Drop the AKERR_MAX_ERR_VALUE=256 compile definition.
- Guard on AKERR_FIRST_CONSUMER_STATUS in include/akgl/error.h, which
  now includes <akerror.h> so the guard is reliable. The embedded build
  is fine, but the find_package path can pick up a stale installed
  header, and 1.0.0 has an soname, so that pairing is an ABI mismatch
  rather than a compile problem. Same guard libakstdlib already carries.

Registration also moves out of akgl_heap_init into a new akgl_error_init
in src/error.c. It was in the heap pool's initializer only because that
was the first thing akgl_game_init called, and the upgrade turned five
fire-and-forget name calls into a library-wide ownership claim that can
fail. That placement was hiding a defect: game.c raises AKGL_ERR_SDL
when SDL_CreateMutex fails, five lines before akgl_heap_init ran, so the
earliest error path in the library was guaranteed to print "Unknown
Error". akgl_error_init is now the first statement in akgl_game_init.

Callers that drive subsystems directly must call akgl_error_init first;
it is idempotent, so ordering it precisely is not required. The eleven
test suites that relied on akgl_heap_init to name their statuses now
call it explicitly, or their failure messages would have degraded to
"Unknown Error".

Add tests/error.c: assert every code reads back its registered name,
that the name table and AKGL_ERR_COUNT agree, that a foreign owner is
refused with AKERR_STATUS_NAME_FOREIGN and AKERR_STATUS_RANGE_OVERLAP,
and that repeating the init is a no-op. That last one is a live
constraint, not a triviality -- libakerror treats only an identical
reservation as a repeat, so a subset or superset raises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:20:28 -04:00
b014eb2360 Document git hook installation in the README
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 19s
libakgl CI Build / mutation_test (push) Failing after 16s
The pre-commit hook is version controlled under scripts/hooks/, but Git
configuration is not cloned, so the hook does nothing until each clone is
pointed at it. Nothing said so anywhere a new contributor would look.

Cover installation, verification, what the hook actually does, the Emacs
requirement, bypassing, and uninstalling.

Two things worth calling out explicitly:

- core.hooksPath replaces the hooks directory outright rather than adding
  to it, so any pre-existing .git/hooks entries silently stop firing.
  Document a symlink alternative for anyone who keeps local hooks.
- The documented --check exit codes are 0 conforming, 1 non-conforming,
  2 environment failure, so a CI job failing on any non-zero status will
  not mistake a missing Emacs for a clean tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:22:47 -04:00
e423f9594e Accept absolute paths in reindent.sh --check
`--check` prefixed every path with the repository root unconditionally, so
passing an absolute path produced a bogus `$root//abs/path`, cp failed, and
the subsequent cmp reported the file as non-conforming. The exit status
happened to be 1, which looked correct while being reached for the wrong
reason.

In-place mode was unaffected -- it cds to the root and hands the list
straight to Emacs, where absolute paths resolve normally, which is why the
pre-commit hook worked.

Normalize both the copy and the compare through an abspath helper so
relative and absolute paths behave identically in either mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:22:47 -04:00
772f960865 Record internal consistency findings and the controller DB defect
Add an "Internal consistency" section to TODO.md: 41 numbered findings
from a sweep of src/ and include/, each cited by file:line and grouped by
blast radius. These are convention problems, not a re-audit of behavior,
but several have live functional consequences:

- AKGL_TIME_ONESEC_MS is misnamed. Its value is nanoseconds-per-
  millisecond, but akgl_game_state_lock uses it as a one-second budget
  against a 100ms delay loop, so the lock blocks ~16 minutes.
- SUCCEED_RETURN inside an ATTEMPT block at tilemap.c:52 bypasses the
  CLEANUP two lines below, leaking two heap strings on every successful
  tilemap property lookup, against a 256-entry pool.
- AKGL_ACTOR_STATE_STRING_NAMES is declared [33] and defined [32], and
  names bits 11 and 12 as UNDEFINED where actor.h defines MOVING_IN and
  MOVING_OUT, so no character JSON can bind a sprite to those states.
- 19 non-static functions are defined in src/ but declared in no header,
  and akgl_game_init_screen is declared but never defined.
- AKGL_COLLIDE_RECTANGLES has unbalanced parentheses and cannot compile.
- text.c:19 checks `name` twice and never validates `filepath`.

Item 30 is marked resolved by the reindent in the preceding commit.

Also add Defects #12: a failed controller-DB fetch silently destroys the
tracked fallback. include/akgl/SDL_GameControllerDB.h is committed on
purpose so the library still builds if upstream disappears, but
mkcontrollermappings.sh has no `set -e` and never checks curl's exit
status -- on failure it overwrites the good header with
AKGL_SDL_GAMECONTROLLER_DB_LEN 0 and an empty initializer, and exits 0.
Verified against an unresolvable host. CMakeLists.txt:89-93 compounds
this: the custom command's OUTPUT is relative, so CMake resolves it
against the binary dir while the script writes to the source dir, leaving
the command permanently out of date and re-running on every build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:58 -04:00
28bde4176d Reindent all C sources to the canonical stroustrup style
Mechanical whitespace-only change across src/, include/, tests/, and
util/, applied with scripts/reindent.sh. No behavioral change.

Most files already conformed. The genuine outliers indented at 2 columns
(json_helpers.c, util.c from akgl_rectangle_points onward, assets.c,
staticstring.c, akgl_actor_add_child, util.h, staticstring.h) or used
spaces where the canonical form is a tab (draw.c). cc-mode also re-aligns
line-continuation backslashes in multi-line macros to its default
c-backslash-column, which is required for the tree to be a fixed point of
the indenter.

Verified whitespace-only: `git diff -w` over this change is empty apart
from three trailing blank lines removed at EOF in src/heap.c,
src/registry.c, and tests/tilemap.c. The build produces no new errors and
ctest is unchanged at 13/14, with `character` still the one intentional
failure.

The tree is now a fixed point of `scripts/reindent.sh --check`.

include/akgl/SDL_GameControllerDB.h is generated and excluded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:37 -04:00
8d21d5c7dd Codify the canonical C style and add reindent tooling
AGENTS.md described style in one paragraph that said little more than
"follow the surrounding code", which was not actionable once files had
drifted apart. Replace it with an explicit specification.

The canonical style is Emacs cc-mode "stroustrup" with tabs enabled: a
4-column offset with 8-column tabs, so depth 1 is four spaces, depth 2 is
one tab, depth 3 is a tab plus four spaces. Most of src/ already followed
this; what looked like randomly mixed tabs and spaces was simply cc-mode
output. Document the byte ladder explicitly, since editors that expand
tabs or assume a 4-column tab silently corrupt it.

Rules beyond indentation are derived from counted majorities in the
existing code rather than invented: errctx over e (92 to 45), padded
control parens (140 to 7), pointer binding to the identifier (486 to 5),
and always-brace (only four unbraced bodies exist). Brace and else
placement are called out as house conventions that cc-mode does not
enforce, so "run the indenter" and "follow the guide" cannot conflict.

Also record the naming, error-handling, and API-surface rules that a
consistency sweep showed were being broken silently -- in particular that
a *_RETURN macro inside an ATTEMPT block skips CLEANUP.

Add the tooling to apply it:

  .dir-locals.el          applies the style to every c-mode buffer
  scripts/reindent.el     batch reindent via Emacs
  scripts/reindent.sh     reindent or --check the tree
  scripts/hooks/pre-commit  reindents staged sources

reindent.el deliberately avoids Emacs' tabify: its tabify-regexp is
" [ \t]+", which is not anchored to the start of a line, so it rewrites
runs of spaces anywhere and destroys the hand-aligned value columns in
the bit-flag tables in actor.h and iterator.h. Only leading whitespace is
ever rewritten, and lines starting inside a string literal are skipped.

The hook checks staged content rather than the working tree, so what is
committed is what was verified. It re-stages a fixed file only when the
index and working tree agree, otherwise a partial `git add -p` would
sweep unstaged work into the commit.

Enable with: git config core.hooksPath scripts/hooks

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 18:17:26 -04:00
bc782fbffe Add controller test suite and reach 72 percent line coverage
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 20s
libakgl CI Build / mutation_test (push) Failing after 17s
Cover control map capacity, the default binding set, keyboard and gamepad
dispatch including cross-device rejection, the dpad handlers, and device
add and remove against the dummy SDL drivers. Synthesize SDL events directly
rather than pumping the event queue, so the suite needs no real hardware.

Fix the three-way appstate check in the four gamepad handlers, which tested
appstate in place of both the event pointer and the looked-up player actor,
so a null event or a missing player was dereferenced instead of reported.

Record the new coverage status in TODO.md along with the seven defects the
suites uncovered and fixed, eleven that remain open, and the reason the
build tree has to precede LD_LIBRARY_PATH for the CTest run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:08:38 -04:00
22162db2da Add physics, heap, json_helpers, game, and actor test suites
Raise line coverage from 39.6 to 61.8 percent with four new suites and an
extension to the actor suite, and register every suite through a single
CMake list so a new test file cannot be left out of the coverage fixture.
Give the test targets a build-tree RPATH and prepend the build tree to
LD_LIBRARY_PATH for CTest, so a developer with a previously installed
libakgl.so exercises the library that was just compiled.

Fix six defects the new tests exposed:

- akgl_physics_simulate read self->gravity_time before its NULL check, so a
  NULL backend crashed instead of reporting AKERR_NULLPOINTER.
- akgl_game_save transposed CLEANUP and PROCESS, which placed the fclose
  inside the PROCESS switch. An ordinary save never flushed or closed its
  stream and produced an empty file.
- akgl_game_save_actors wrote each name table terminator from the address of
  a single char, emitting stack contents into the save file and a sentinel
  the loader could not recognize.
- akgl_game_load_objectnamemap used CATCH directly inside while(1), where the
  break leaves the loop rather than propagating, so a truncated name table
  loaded as a successful game.
- akgl_Actor_cmhf_up_on and _down_on dereferenced actor->basechar with no
  NULL check, unlike their left and right counterparts.
- akgl_actor_logic_movement checked actor twice instead of checking
  actor->basechar before dereferencing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 02:03:21 -04:00
6f6bd2d563 Plan a unit test suite from the coverage baseline
Record the 39.6 percent line and 44.3 percent function coverage baseline
measured with the AKGL_COVERAGE build, and plan twelve test suites to raise
it to roughly 70 percent lines and 80 percent functions. Tier the work by
what each suite needs: pure logic suites for physics, json_helpers, heap,
and savegame handling that require no renderer; renderer suites gated behind
a shared offscreen harness; and controller and frame-loop coverage deferred.

Note that vendored dependency shared objects are absent from the loader path
in a fresh out-of-tree build, which reports every test as zero coverage, and
record ten suspected defects found while reading the uncovered code so the
new tests assert correct behavior rather than pinning the current one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 01:40:06 -04:00
ff88f48fa2 Add CTest coverage reporting
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 19s
libakgl CI Build / mutation_test (push) Failing after 16s
Instrument libakgl in opt-in coverage builds, use CTest fixtures to reset counters and emit Cobertura XML and detailed HTML reports, and upload those reports from Gitea CI. Document the local coverage workflow for contributors.

Co-authored-by: Codex (GPT-5) <noreply@openai.com>
2026-07-30 01:25:26 -04:00
549b27d3eb Document the libakgl API with Doxygen
Add file, structure, global, and function documentation across all libakgl-owned headers and sources, including parameter contracts and likely AKERR/AKGL_ERR exceptions. Add a strict Doxyfile and build the generated API documentation in Gitea CI with warnings treated as failures.

Co-authored-by: Codex (GPT-5) <noreply@openai.com>
2026-07-30 01:10:31 -04:00
a2995e81df Add Gitea test and mutation pipeline
Some checks failed
libakgl CI Build / cmake_build (push) Failing after 18s
libakgl CI Build / mutation_test (push) Failing after 16s
Build libakgl with recursive vendored dependencies, run the passing CTest suite with JUnit reporting, and publish a bounded static-string mutation gate with a 50 percent score threshold.

Co-authored-by: Codex (GPT-5) <noreply@openai.com>
2026-07-29 18:46:26 -04:00
dc1bd0a798 Add mutation testing harness
Port the scratch-copy mutation runner used by libakerror and libakstdlib, covering libakgl source files with sampling, thresholds, JUnit reports, and test timeouts. Add a CMake target and documentation, preserve the intentionally failing character test exclusion, and migrate the sprite test to the renderer backend so mutation baselines are green.

Co-authored-by: Codex (GPT-5) <noreply@openai.com>
2026-07-29 18:42:09 -04:00
3854b33750 Update tilemap tests for renderer backends
Migrate tilemap tests to pointer-based game globals and current loader signatures, initialize the renderer callback used by image comparisons, and refresh the character fixture schema. Preserve loaded tileset names by removing a duplicate heap allocation.

Co-authored-by: Codex (GPT-5) <noreply@openai.com>
2026-07-29 18:25:36 -04:00
cf9ebb206f Repair embedded dependency build and test setup
Isolate vendored test registration, namespace project error codes, and update dependency revisions. Fix mutable sprite path handling, out-of-tree fixtures, and charviewer initialization while documenting the outstanding character-to-sprite refcount bug.

Co-authored-by: Codex (GPT-5) <noreply@openai.com>
2026-07-29 18:17:08 -04:00
74867ea82e README 2026-06-20 13:19:58 -04:00
4e510dd6d6 README updates 2026-06-19 10:47:07 -04:00
dca03cb50d Start README docs 2026-06-19 09:52:08 -04:00
243 changed files with 57431 additions and 2286 deletions

16
.dir-locals.el Normal file
View File

@@ -0,0 +1,16 @@
;;; Directory Local Variables -*- no-byte-compile: t -*-
;;; See AGENTS.md -> "Coding Style" for the rationale.
;;;
;;; The canonical style is cc-mode "stroustrup" with tabs enabled: 4 columns per
;;; level, tabs 8 columns wide, so depth 1 is four spaces, depth 2 is one tab,
;;; depth 3 is a tab plus four spaces. A correctly formatted file is a fixed
;;; point of `indent-region' under these settings.
;;;
;;; Reindent a whole file with C-x h C-M-\, or the tree with
;;; `scripts/reindent.sh'.
((c-mode . ((c-file-style . "stroustrup")
(indent-tabs-mode . t)
(tab-width . 8)
(fill-column . 100)
(require-final-newline . t))))

212
.gitea/workflows/ci.yaml Normal file
View File

@@ -0,0 +1,212 @@
name: libakgl CI Build
run-name: ${{ gitea.actor }} libakgl test
on: [push]
jobs:
cmake_build:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install build dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y \
cmake doxygen gcc gcovr graphviz pkg-config \
libasound2-dev libfreetype-dev libharfbuzz-dev \
libpng-dev libtiff-dev libwebp-dev \
libudev-dev libx11-dev libxcursor-dev libxext-dev \
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
libxss-dev libxtst-dev
- name: Configure and build
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=Debug \
-DAKGL_COVERAGE=ON \
-DAKGL_WERROR=ON
cmake --build build --parallel
- name: Build API documentation
run: doxygen Doxyfile
# The perf suites run here too -- they are ordinary CTest tests -- but this
# is a Debug build with coverage instrumentation, where a timing is a
# measurement of gcov. AKGL_BENCH_SCALE cuts their iteration counts so
# they contribute path coverage without spending ten minutes proving
# nothing; benchutil.h already refuses to enforce budgets in an
# unoptimized build. The performance job below is where the timings are
# taken and the budgets are checked.
- name: Test (JUnit)
env:
AKGL_BENCH_SCALE: '0.02'
run: |
export LD_LIBRARY_PATH="$PWD/build:$PWD/build/deps/SDL:$PWD/build/deps/SDL_image:$PWD/build/deps/SDL_mixer:$PWD/build/deps/SDL_ttf"
ctest \
--test-dir build \
--output-on-failure \
--output-junit "$(pwd)/ctest-junit.xml"
- name: Publish test results
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: 'ctest-junit.xml'
annotate_only: true
detailed_summary: true
include_passed: true
fail_on_failure: 'true'
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@v3
with:
name: code-coverage
path: build/coverage/
if-no-files-found: warn
# The benchmarks, in the only build where their numbers mean anything.
# tests/benchutil.h enforces a budget per measurement only when it is compiled
# optimized and running at full scale, which is exactly this job and no other:
# the coverage job above is Debug, and the memory job below is under valgrind.
# A budget is roughly ten times the baseline recorded in PERFORMANCE.md, which
# is what makes a shared, slower runner viable -- it catches an algorithmic
# regression and ignores a busy machine. If a budget still proves flaky here,
# raise that one budget with a measurement behind it and re-record the
# baseline; do not drop the gate.
performance:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install build dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y \
cmake gcc pkg-config \
libasound2-dev libfreetype-dev libharfbuzz-dev \
libpng-dev libtiff-dev libwebp-dev \
libudev-dev libx11-dev libxcursor-dev libxext-dev \
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
libxss-dev libxtst-dev
- name: Configure and build
run: |
cmake -S . -B build \
-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
# nothing at all for a green run. pipefail because the step ends in a
# pipe, whose status would otherwise be tee's -- always 0 -- and a blown
# budget would be reported as a passing step.
- name: Benchmarks (JUnit)
run: |
set -o pipefail
export LD_LIBRARY_PATH="$PWD/build:$PWD/build/deps/SDL:$PWD/build/deps/SDL_image:$PWD/build/deps/SDL_mixer:$PWD/build/deps/SDL_ttf"
ctest \
--test-dir build \
-L perf \
--verbose \
--output-junit "$(pwd)/perf-junit.xml" \
2>&1 | tee "$(pwd)/perf-baseline.txt"
- name: Publish benchmark results
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: 'perf-junit.xml'
annotate_only: true
detailed_summary: true
include_passed: true
fail_on_failure: 'true'
# Kept whether the job passed or failed: the tables are the point. A run
# that went red says which measurement moved, and a run that went green is
# the next baseline if somebody re-records PERFORMANCE.md.
- name: Upload benchmark tables
if: always()
uses: actions/upload-artifact@v3
with:
name: performance-baseline
path: perf-baseline.txt
if-no-files-found: warn
# Every suite under valgrind. No memory-check programs of their own: the perf
# suites scale themselves down when they detect valgrind, which turns them
# into the broadest path coverage in the tree at a cost this job can afford.
#
# This job gates. A definite leak, a read past the end of an allocation, or a
# branch on uninitialised memory fails the build on the push that introduced
# it -- not on the day somebody gets around to caring. The six findings this
# job had on its first run were fixed to make that possible rather than
# excused into a warning.
memory_check:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install memory-check dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y \
cmake gcc pkg-config valgrind \
libasound2-dev libfreetype-dev libharfbuzz-dev \
libpng-dev libtiff-dev libwebp-dev \
libudev-dev libx11-dev libxcursor-dev libxext-dev \
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
libxss-dev libxtst-dev
# RelWithDebInfo rather than Debug: valgrind needs the symbols, and -O0
# would leave every inlined frame in the stacks it prints.
- name: Configure and build
run: |
cmake -S . -B build \
-DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --parallel
# Every suite, character included. It was excluded from both this job
# and the coverage job on the grounds that it failed deliberately -- it
# did not. It was reporting success while running one of its four tests;
# see TODO.md, "Test suites that could not fail".
- name: Memory check
run: scripts/memcheck.sh
- name: Upload valgrind logs
if: always()
uses: actions/upload-artifact@v3
with:
name: valgrind-logs
path: build/Testing/Temporary/MemoryChecker.*.log
if-no-files-found: warn
mutation_test:
runs-on: ubuntu-latest
steps:
- name: Check out repository code
uses: actions/checkout@v4
with:
submodules: recursive
- name: Install mutation-test dependencies
run: |
sudo apt-get update -y
sudo apt-get install -y \
cmake gcc pkg-config python3 \
libasound2-dev libfreetype-dev libharfbuzz-dev \
libpng-dev libtiff-dev libwebp-dev \
libudev-dev libx11-dev libxcursor-dev libxext-dev \
libxfixes-dev libxi-dev libxrandr-dev libxrender-dev \
libxss-dev libxtst-dev
# Keep CI bounded to a focused source file. Run the default target locally
# for the complete libakgl-owned src/ tree.
- name: Mutation testing
run: |
python3 scripts/mutation_test.py \
--target src/staticstring.c \
--junit mutation-junit.xml \
--threshold 50
- name: Publish mutation results
if: always()
uses: mikepenz/action-junit-report@v4
with:
report_paths: 'mutation-junit.xml'
annotate_only: true
detailed_summary: true
include_passed: true
fail_on_failure: 'false'

15
.gitignore vendored
View File

@@ -1,3 +1,16 @@
./build/*
# A leading ./ is not a valid gitignore pattern, so "./build/*" matched nothing
# and every out-of-tree build tree showed up as untracked. This also covers the
# instrumented trees scripts/coverage.py and the AKGL_COVERAGE option create.
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.
include/akgl/version.h

26
.gitmodules vendored
View File

@@ -1,24 +1,34 @@
[submodule "deps/semver"]
path = deps/semver
url = git@github.com:h2non/semver.c.git
url = https://github.com/h2non/semver.c.git
[submodule "deps/SDL"]
path = deps/SDL
url = git@github.com:libsdl-org/SDL.git
url = https://github.com/libsdl-org/SDL.git
[submodule "deps/SDL_image"]
path = deps/SDL_image
url = git@github.com:libsdl-org/SDL_image.git
url = https://github.com/libsdl-org/SDL_image.git
[submodule "deps/SDL_mixer"]
path = deps/SDL_mixer
url = git@github.com:libsdl-org/SDL_mixer.git
url = https://github.com/libsdl-org/SDL_mixer.git
[submodule "deps/SDL_ttf"]
path = deps/SDL_ttf
url = git@github.com:libsdl-org/SDL_ttf.git
url = https://github.com/libsdl-org/SDL_ttf.git
[submodule "deps/libsdlerror"]
path = deps/libakerror
url = https://source.home.aklabs.net/andrew/libsdlerror.git
url = https://source.starfort.tech/andrew/libakerror.git
[submodule "deps/libakstdlib"]
path = deps/libakstdlib
url = https://source.home.aklabs.net/andrew/libakstdlib.git
url = https://source.starfort.tech/andrew/libakstdlib.git
[submodule "deps/jansson"]
path = deps/jansson
url = git@github.com:akheron/jansson.git
url = https://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
[submodule "clay"]
path = deps/clay
url = https://github.com/nicbarker/clay.git
branch = v0.14

796
AGENTS.md Normal file
View File

@@ -0,0 +1,796 @@
# Repository Guidelines
## Project Structure & Module Organization
This is a C library intended to support the development of video games. Public C headers live in `include/akgl/`; keep declarations there aligned with their implementations in `src/`. Tests are standalone C programs under `tests/`, with JSON, image, and map fixtures in `tests/assets/`. The `util/` directory contains the `charviewer` utility and its sample assets. Third-party and companion libraries are vendored in `deps/`. Treat `build/` and generated `akgl.pc` files as build outputs rather than hand-maintained source; `include/akgl/SDL_GameControllerDB.h` is generated too, but is tracked on purpose — see below.
## Generated and Vendored Sources
### `include/akgl/SDL_GameControllerDB.h` is generated, and is tracked deliberately
`mkcontrollermappings.sh` regenerates this header by fetching the community
controller database from `raw.githubusercontent.com`. **It is committed to the
repository on purpose**: it is the offline fallback that keeps the library
buildable if upstream is renamed, rate-limited, taken down, or simply
unreachable from the build machine. Do not delete it, do not add it to
`.gitignore`, and do not "clean up" the fact that a generated file is tracked.
Working rules:
- **Never hand-edit it.** It is machine-written; changes belong in
`mkcontrollermappings.sh`.
- **An ordinary build does not touch it.** Regeneration is
`cmake --build build --target controllerdb`, and nothing else runs the script.
Until 0.5.0 an `add_custom_command` re-ran it on every build, which made every
build need network access and left the file modified with a fresh `$(date)`
stamp and nothing of substance changed.
- **Update it as a deliberate, standalone commit** when you actually want newer
mappings, so the diff is reviewable and bisectable.
- **A failed fetch cannot reach this file.** The script assembles the header in
a temporary directory and moves it into place only after checking curl's exit
status, a plausible minimum mapping count, and that no mapping contains a
character that would break a C string literal. Any of those failing leaves the
tracked copy untouched and exits non-zero.
- Formatting tooling ignores it: `scripts/reindent.sh` and the pre-commit hook
both skip it.
> **Fixed in 0.5.0.** The safety net used to be self-defeating:
> `mkcontrollermappings.sh` had no `set -e` and never checked `curl`, so a
> failed fetch wrote `AKGL_SDL_GAMECONTROLLER_DB_LEN 0` and an empty array
> **over the good tracked copy, and exited 0** — and because CMake re-ran the
> generator on every build, an offline build silently destroyed the very
> fallback the file exists to provide. Both halves are closed: the script
> refuses rather than overwrites, and a build does not run it.
## Build, Test, and Development Commands
Configure an out-of-tree development build:
```sh
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
```
Build all library, utility, and test targets:
```sh
cmake --build build --parallel
```
Run the complete test suite with failure details:
```sh
ctest --test-dir build --output-on-failure
```
Run one test while iterating, for example `ctest --test-dir build -R sprite --output-on-failure`. The `rebuild.sh` script also installs into a developer-specific `/home/andrew/local` prefix and removes existing outputs; prefer the portable commands above unless that exact workflow is intended.
Run only the performance suites with `ctest --test-dir build -L perf --output-on-failure`, or leave them out of an ordinary run with `-LE perf`. They take about 30 seconds together, print a table of nanoseconds per operation, and fail only when a measurement exceeds a budget set at roughly ten times the recorded baseline. `AKGL_BENCH_SCALE` scales every iteration count — `AKGL_BENCH_SCALE=0.1 ctest --test-dir build -L perf` for a quick look — and below 1.0 the budgets are reported but not enforced. The recorded baseline and what it means are in `PERFORMANCE.md`.
Check for memory defects with `cmake --build build --target memcheck`, or
`scripts/memcheck.sh` directly — it takes ctest's selection flags, so
`scripts/memcheck.sh -R tilemap` and `scripts/memcheck.sh -LE perf` both work.
It runs the suites that already exist under valgrind (`ctest -T memcheck`) with
the headless drivers forced, and exits non-zero when valgrind finds a definite
leak, an invalid access, or a read of uninitialised memory — which `ctest -T
memcheck` on its own will not do. The whole run takes about thirty seconds
because the perf suites scale themselves down under valgrind. Suppressions for
third-party findings live in `scripts/valgrind.supp`; add one only when the
finding genuinely cannot be fixed from this repository.
Run mutation testing with `cmake --build build --target mutation`. For a quick smoke run, use `scripts/mutation_test.py --target src/tilemap.c --max-mutants 10`; the harness mutates only a scratch copy and runs every suite.
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
because the others cannot do its work:
| Job | Build | What it is for |
|---|---|---|
| `cmake_build` | Debug + `AKGL_COVERAGE=ON` | The unit suites and the coverage report. The perf suites run here too, at `AKGL_BENCH_SCALE=0.02`, for path coverage only — a timing taken under gcov is a measurement of gcov. |
| `performance` | RelWithDebInfo | `ctest -L perf`. The only job where budgets are enforced, because `tests/benchutil.h` enforces them only when compiled optimized at full scale. Keeps the tables as an artifact. |
| `memory_check` | RelWithDebInfo | `scripts/memcheck.sh`, every suite under valgrind. Gates: a definite leak, an invalid access or a branch on uninitialised memory fails the build. Keeps the valgrind logs as an artifact. |
| `mutation_test` | Debug | One focused source file, so CI stays bounded. |
Every suite runs in every job. The `character` suite used to be excluded from
the coverage and memory-check jobs on the grounds that it failed deliberately.
It did not: it was reporting success while running one of its four tests, for
two independent reasons, and both are fixed. See TODO.md, "Test suites that
could not fail", which is the record of how that happened.
## Coding Style
The canonical style is Emacs `cc-mode` **`stroustrup`**, with tabs enabled. This
is not advisory — new and edited code must match what `cc-mode` produces, so that
reindenting a region never shows up as a diff.
### Emacs setup
`.dir-locals.el` in the repository root already applies the style to every
`c-mode` buffer, so nothing needs configuring by hand:
```elisp
((c-mode . ((c-file-style . "stroustrup")
(indent-tabs-mode . t)
(tab-width . 8)
(fill-column . 100)
(require-final-newline . t))))
```
Interactively: `C-x h C-M-\` (`mark-whole-buffer` + `indent-region`) reindents
the buffer. A correctly formatted file is a fixed point of that operation —
reindenting must produce no change.
### Reindenting from the command line
```sh
scripts/reindent.sh # reindent every tracked C source in place
scripts/reindent.sh src/tilemap.c # reindent only the named files
scripts/reindent.sh --check # list non-conforming files, change nothing
```
`--check` exits 1 when something needs reindenting and 2 if Emacs is missing, so
it is usable from CI. The script drives Emacs in batch mode through
`scripts/reindent.el`, which reindents, normalises leading whitespace to the
canonical tab/space mix, strips trailing whitespace, and ensures a final
newline. `include/akgl/SDL_GameControllerDB.h` is generated and always skipped.
Note that `reindent.el` deliberately does **not** use Emacs' `tabify`: that
function's `tabify-regexp` is `" [ \t]+"`, which is not anchored to the start of
a line, so it rewrites runs of spaces anywhere and destroys the hand-aligned
value columns in the bit-flag tables in `actor.h` and `iterator.h`. Only leading
whitespace is ever rewritten.
### The pre-commit hook
`scripts/hooks/pre-commit` reindents staged C sources before the commit is
written. Enable it once per clone:
```sh
git config core.hooksPath scripts/hooks
```
It inspects the *staged* content rather than the working tree, so what lands in
the commit is what was checked. When a file needs reindenting it is fixed in the
working tree and re-staged — but only if the index and working tree agree for
that file. If they differ, re-staging would sweep unstaged work into the commit,
so the hook stops and tells you to reindent and stage it yourself. It steps
aside during a merge, and warns rather than blocking if Emacs is unavailable.
`git commit --no-verify` bypasses it.
For reference, `cc-mode` defines the style as:
```elisp
("stroustrup"
(c-basic-offset . 4)
(c-comment-only-line-offset . 0)
(c-offsets-alist . ((statement-block-intro . +)
(substatement-open . 0)
(substatement-label . 0)
(label . 0)
(statement-cont . +))))
```
### Indentation and whitespace
- **4 columns per level** (`c-basic-offset` 4).
- **Tabs are 8 columns wide and are used for indentation** (`indent-tabs-mode`
`t`, `tab-width` 8). Emacs emits the largest possible run of tabs and pads the
remainder with spaces, which produces this ladder. Editors that expand tabs, or
that assume a 4-column tab, will silently corrupt it:
| Depth | Columns | Bytes |
|---|---|---|
| 1 | 4 | 4 spaces |
| 2 | 8 | `TAB` |
| 3 | 12 | `TAB` + 4 spaces |
| 4 | 16 | `TAB` `TAB` |
| 5 | 20 | `TAB` `TAB` + 4 spaces |
- No trailing whitespace. Files end with a single newline.
- `case` labels sit at the same column as their `switch` (`label . 0`).
- Continuation lines of a wrapped expression indent one level (`statement-cont . +`).
The whole tree conforms: `scripts/reindent.sh --check` is clean, and the
pre-commit hook keeps it that way. Convert a file to the canonical style in its
own commit, not mixed into a behavioral change.
### Braces
- **Function bodies open on their own line, in column 0.**
- **Control statements keep the brace on the same line**, one space before it.
- **`else`, `else if`, and `while` of a `do`/`while` stay on the closing-brace
line**: `} else {`, not a bare `else` on the next line. Note that this is a
house convention rather than something `cc-mode` enforces — the `stroustrup`
style governs indentation only and will not move a brace or an `else` for you.
- **Always brace, even single-statement bodies.**
```c
akerr_ErrorContext *akgl_actor_update(akgl_Actor *obj)
{
if ( obj->curSpriteFrameId == 0 ) {
obj->curSpriteReversing = false;
} else if ( obj->parent != NULL ) {
obj->curSpriteFrameId -= 1;
} else {
obj->curSpriteFrameId += 1;
}
}
```
### Spacing
- **Spaces inside control-flow parentheses**: `if ( x == y ) {`, `while ( done == false ) {`,
`for ( i = 0; i < len; i++ ) {`. This is the dominant existing convention
(140 sites to 7) and `cc-mode` will not add or remove it.
- No space between a function name and its argument list, at both call and
definition sites, and no padding inside call parentheses: `SDL_Log("x %d", n)`.
- Binary operators and assignment are surrounded by single spaces; unary
operators are not separated from their operand.
- **The pointer `*` binds to the identifier**, not the type: `char *name`,
`akgl_Actor **dest`. Never `char* name`.
- When a call is too long for one line, put each argument on its own line
indented one level past the callee, with the closing `)` on its own line. This
is the established shape for the `FAIL_*` and `CATCH` macros:
```c
FAIL_ZERO_RETURN(
errctx,
SDL_SetPointerProperty(AKGL_REGISTRY_ACTOR, name, (void *)obj),
AKERR_KEY,
"Unable to add actor to registry"
);
```
### 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 issue #5. 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
indentation engine is not exactly reproducible by `clang-format`. Do not
introduce one without discussion, and do not reformat code you are not otherwise
changing — unrelated whitespace churn makes review harder and is the reason the
style drifted in the first place.
## Naming Conventions
- **Public functions**: `akgl_<subsystem>_<verb>`, all lower snake_case —
`akgl_actor_set_character`, `akgl_heap_next_string`. No camelCase, and never
embed a type name (`akgl_Actor_cmhf_left_on` is wrong; it should be
`akgl_actor_cmhf_left_on`).
- **Public types**: `akgl_TypeName` — `akgl_Actor`, `akgl_SpriteSheet`,
`akgl_PhysicsBackend`. Every type exported from a header takes the prefix.
`point` and `RectanglePoints` shipped bare until 0.5.0; they are
`akgl_Point` and `akgl_RectanglePoints` now, and neither is precedent.
- **Constants and macros**: `AKGL_UPPER_SNAKE_CASE`. A constant belongs to the
subsystem it describes: a character limit is `AKGL_CHARACTER_MAX_*`, not
`AKGL_SPRITE_MAX_CHARACTER_*`. Name a constant for what its value *is* — a
nanoseconds-per-millisecond scale factor is `AKGL_TIME_ONEMS_NS`.
- **Exported globals** take the `akgl_` prefix like any other public symbol. Do
not add bare names (`renderer`, `camera`, `window`), leading-underscore names
(reserved at file scope), or SCREAMING_SNAKE names for mutable objects —
`AKGL_UPPER_SNAKE_CASE` is for constants.
This one is not cosmetic and there is a scar to prove it. Until 0.5.0 the
library exported `renderer`, and `tests/character.c` defined a
`SDL_Renderer *renderer` of its own. Both had external linkage and the same
spelling, so the executable's definition preempted the shared library's: the
library read a `SDL_Renderer *` through an `akgl_RenderBackend *`, every
texture load in that suite failed, and the suite reported success anyway. A
consuming game with a variable called `renderer` would have hit the same
thing with no test to notice.
- **`static` helpers drop the `akgl_` prefix**, which exists only to avoid
external collisions.
- **Include guards**: `_AKGL_<FILE>_H_`, matching the file name. Guard names
without the project prefix risk colliding with system headers.
- **Parameter names must match between the declaration and the definition** —
Doxygen publishes the header spelling. Conventional names: `dest` for an
output parameter (not `dst`), `self` for a backend receiver, `obj` for the
instance being initialized or inspected, `e` for an incoming error context to
be inspected.
- **The local error context is named `errctx`** (92 sites to 45). New code uses
`errctx`; convert `e` when you touch a function for other reasons.
- **Header/source pairs are named by feature**: `include/akgl/sprite.h` and
`src/sprite.c`.
## Error-Handling Protocol
The `akerror` control-flow macros have a shape that must be followed exactly,
because the failure mode is silent.
- Inside an `ATTEMPT` block use the **`_BREAK`** variants (`FAIL_ZERO_BREAK`,
`FAIL_NONZERO_BREAK`, `FAIL_BREAK`) and `CATCH`. Outside it use the
**`_RETURN`** variants.
- **Never use a `*_RETURN` macro inside an `ATTEMPT` block.** It returns past the
`CLEANUP` block, so every release, `fclose`, and free in `CLEANUP` is skipped.
This has already caused a heap-string leak on the success path of
`akgl_get_json_tilemap_property`.
- `CLEANUP` must precede `PROCESS`. Transposing them moves the cleanup body into
the `PROCESS` switch, where it runs only when an error context exists.
- `CATCH` reports failure by `break`ing, which binds to the innermost enclosing
loop or `switch`. A `CATCH` written directly inside a `while` exits the loop
rather than the function — put the `ATTEMPT` block inside the loop.
- **Never `return` from inside a `HANDLE` block either.** `FINISH` ends with
`RELEASE_ERROR`, so leaving before it means the handled context is never given
back to `AKERR_ARRAY_ERROR` — one leaked slot per call, and the 129th call
aborts the process with "Unable to pull an error context from the array!".
`SUCCEED_RETURN` is safe because it releases first; a bare `return`, or a
`return f(...)` that tail-calls the fallback, is not. Set a flag in the
`HANDLE` block and act on it after `FINISH`. This has already cost a
process-killing leak in `akgl_path_relative`; see TODO.md, "Performance".
- 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
declaration must have a definition. A non-`static` function that appears in no
header is still in the ABI but is unreachable by callers; a declaration with no
definition is a link error for anyone compiling against the header alone. If a
function is exposed only so tests can reach it, declare it under the existing
"part of the internal API" comment block in the relevant header.
Headers must be self-contained: include what you use, so that a translation unit
including a single `akgl` header compiles without a particular include order.
Within the project use the angled form, `#include <akgl/sibling.h>`.
This is enforced. `AKGL_PUBLIC_HEADERS` in `CMakeLists.txt` lists every header
installed into `include/akgl/`, and drives two things from that one list: what
`install()` ships, and a generated translation unit per header — each including
exactly that header and nothing before it — linked into the `headers` suite. A
new header is covered by adding it to that list. One header per translation
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 issue #19, which is the decision this
waits on, 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
is here because it was broken in this repository, and every one of them was
cheap to check and expensive to assume.
- **Check the surrounding code before designing around a hazard.** The savegame
name-width fix first grew four `AKGL_GAME_SAVE_*_NAME_WIDTH` aliases, so the
on-disk format's field widths could diverge from the object model's. They were
deleted: `akgl_game_load` compares the save's `libversion` against
`AKGL_VERSION` and refuses a mismatch **in the same function, sixteen lines
above the tables being edited**, so the divergence they defended against
cannot happen quietly. An
abstraction defending an impossible case is worse than none, because the next
reader has to work out what it is for.
- **Abstract when the second consumer is real, not before.** A `#define` with
one use, a wrapper with one caller, a parameter only ever passed one value:
delete it and inline the thing. Those four aliases had exactly one use per
side and each expanded to exactly one existing constant.
- **A test that has not failed has not been tested.** Break the fix, watch the
new test go red, put the fix back. Two tests in the 0.5.0 defect work passed
against the unfixed library on the first attempt:
- the savegame roundtrip asserted the load *succeeded*, and a reader stepping
the wrong field width does not fail — it finds a run of zeros mid-entry,
stops early, and reports success with silently wrong maps;
- the `akgl_get_json_with_default` test used `TEST_EXPECT_OK`, which releases
whatever context the statement returns — and that function hands *back* the
context it was given when it does not handle the status, so the `CLEANUP`
block released it a second time and the double release corrupted the
failure instead of reporting it.
Neither was discovered by reasoning. Both were discovered by reverting the fix
and being surprised.
- **Pick the test case that distinguishes the two behaviours, not the obvious
one.** `!AKGL_BITMASK_HAS(mask, bit)` misparses to `!(mask & bit) == bit`
without the parentheses — but for a bit that *is* set that is `0 == bit`,
false, which is the same answer the correct parse gives. It only diverges for
a bit that is **not** set whose value is **not** 1. The obvious test passes
either way.
- **Do not trust a comment, an issue, or a CI exclusion that states a
premise.** Verify it. `tests/character.c` was excluded from two CI jobs and
from the mutation harness because it "fails deliberately". It did not: it was
exiting 0 while running one of its four tests. `TODO.md`, when it still held
outstanding work, carried eleven entries describing code that had already
changed, and three more were found stale when it was migrated to the tracker.
A premise nobody has re-checked is where the next defect is hiding.
- **When a measurement moves, say what you measured.** Absolute benchmark
numbers drift with the machine. The `akgl_game_update` fix is recorded as the
*gap* between two rows of the same run — 92 µs before, noise after — because
a later run read every row 15% high, including rows nothing had touched. Do
not re-baseline a whole table to record one change.
## Rules
- **Outstanding work goes in the issue tracker, not in a file.** Open an issue at
<https://source.starfort.tech/andrew/libakgl/issues> — `tea issues create --repo andrew/libakgl`
— with the file and line it touches, the functional consequence, and what closing it would
touch. Label it by kind and blast radius, and leave `status::grooming` on it until its scope
and approach are settled. **Do not add outstanding items to `TODO.md`**; that file is the
record of decisions, measurements and defects already closed, and the reason for the split is
that a description of work still to do goes stale the moment somebody does it.
- **When you learn something about an open issue, say so in the issue** — including that it is
worse than recorded, that it is already fixed, or that its premise has expired. An issue
nobody has re-checked is worth what an unverified comment is worth.
- **A defect in a dependency is filed against that dependency**, not worked around here.
`libakerror`, `libakstdlib` and `akbasic` all have trackers on the same forge. Comment the
workaround at its site with the words "filed upstream" and delete it when the fix lands.
- Add yourself (agent program name, model name and version) as a co-author on every commit message
- Avoid dynamic memory allocation. Use the `akgl_heap_*` methods to retrieve necessary objects at runtime, and to release them when done. If the akgl heap facilities don't provide the kind of object needed, create a new heap layer to support that type of object.
## Testing Guidelines
Tests use simple executable return codes and are registered through CTest in `CMakeLists.txt`; there is no declared coverage threshold.
**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
`tests/perf.c` (nothing that draws) and `tests/perf_render.c` (everything that
does) are benchmarks, built and registered like any other suite but listed in
`AKGL_PERF_SUITES` so they carry the `perf` label and a longer timeout. The
harness is `tests/benchutil.h`. Three rules keep the numbers honest, and all
three were learned by getting them wrong first:
- **Nothing that checks an error goes inside the clock.** `PASS` and `CATCH`
call `akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` — more work
than several of the calls being measured. Use `BENCH_LOOP`, which stashes the
context and stops at the first failure, and hand it to `PASS` afterwards.
- **Flush the renderer before stopping the clock.** SDL batches: a `draw_*` call
queues a command and returns. `BENCH_FLUSH_STOP` in `tests/perf_render.c` is
there because the first version of that suite measured queueing, reported a
tilemap frame 250 times faster than it is, and paid the real cost at teardown
inside `SDL_DestroyTexture`.
- **A drawing benchmark needs a raw-SDL control that does the same pixel work.**
Without one there is no way to separate what libakgl costs from what the
rasterizer costs, and the answer is not the one you would guess — see
`PERFORMANCE.md`.
Budgets are per-operation ceilings at roughly ten times the recorded baseline;
they are enforced only in an optimized build at full scale. When a change moves
a number for a good reason, re-record the baseline in `PERFORMANCE.md` in the
same commit and say why it moved.
These two suites are also the memory-check vehicle. `tests/benchutil.h` detects
valgrind from `LD_PRELOAD` and drops the scale to
`AKGL_BENCH_VALGRIND_SCALE`, so the same binaries walk every path once instead
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.

File diff suppressed because it is too large Load Diff

18
Doxyfile Normal file
View File

@@ -0,0 +1,18 @@
PROJECT_NAME = libakgl
PROJECT_BRIEF = "Game development support library"
OUTPUT_DIRECTORY = build/docs
INPUT = include/akgl src
FILE_PATTERNS = *.h *.c
RECURSIVE = YES
EXCLUDE_PATTERNS = */SDL_GameControllerDB.h
EXTRACT_ALL = YES
EXTRACT_STATIC = YES
WARN_IF_UNDOCUMENTED = YES
WARN_IF_DOC_ERROR = YES
WARN_AS_ERROR = FAIL_ON_WARNINGS
GENERATE_HTML = YES
GENERATE_LATEX = NO
QUIET = YES

602
PERFORMANCE.md Normal file
View File

@@ -0,0 +1,602 @@
# libakgl performance baseline
This is where the library actually spends its time, measured rather than guessed.
The numbers below are the first recorded baseline: libakgl 0.3.0, at commit
`f35443e` plus the perf suites themselves. Everything here is reproducible with
two commands, and the suites that produced it are checked in as
`tests/perf.c` and `tests/perf_render.c`.
Read it in this order if you only want the short version: **the frame budget** is
the part that matters and **what the numbers say** is the argument. The six
defects the suites turned up on the way — two of them process-killing — are in
`TODO.md` under **Performance**, along with the targets these numbers are
measured against.
## Reproducing it
```sh
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build --parallel
ctest --test-dir build -L perf --output-on-failure
```
Both suites print a table and exit non-zero if any measurement blew its budget.
They are ordinary CTest tests, so `ctest --test-dir build` runs them along with
everything else; `ctest --test-dir build -LE perf` leaves them out when you only
want the unit suites. `AKGL_BENCH_SCALE` scales every iteration count —
`AKGL_BENCH_SCALE=0.1` for a quick look, `10` for a long one. Below 1.0 the
budgets are reported but not enforced, because a short run is a noisy one.
The whole thing takes about 30 seconds: 4 s for `perf`, 25 s for `perf_render`.
## The machine
| | |
|---|---|
| CPU | AMD Ryzen 5 7535HS, 6 cores / 12 threads, 4.6 GHz max |
| Memory | 62 GiB |
| OS | Linux 6.8.0-136-generic |
| Compiler | gcc 13.3.0, `-DCMAKE_BUILD_TYPE=RelWithDebInfo` (`-O2 -g`) |
| SDL | vendored SDL 3.4.8, `dummy` video driver, `software` renderer |
| Target | 640x480, offscreen |
One machine, one build type, one afternoon. Treat the absolute numbers as this
laptop's and the *ratios* as the library's.
## How it is measured
- **Best of five.** Each benchmark runs five times and the harness keeps the
fastest. The mean is the wrong statistic on a shared machine: every source of
noise makes a run slower and none makes it faster.
- **No error checking inside the clock.** `PASS` and `CATCH` call
`akerr_valid_error_address`, which walks `AKERR_ARRAY_ERROR` — more work than
several of the calls being measured. The timed loop stashes the context and
checks it after the clock stops.
- **The renderer is flushed inside the measurement.** SDL batches: a `draw_*`
call queues a command and returns. The first version of this suite measured
*queueing* and reported a tilemap frame at 65 µs; the deferred work then took
114 seconds to come out at teardown, inside `SDL_DestroyTexture`. Every
drawing benchmark now flushes before it stops the clock, and the numbers below
are 250x larger and true.
- **SDL's log output goes to a sink that discards it.** The library logs on
paths this suite calls hundreds of thousands of times; timing a write to a
terminal measures the terminal. What the *formatting* costs is measured
deliberately, by the pair of actor-spawn benchmarks.
- **Budgets are set at roughly 10x the measured baseline.** Loose enough that a
busy machine does not turn CI red, tight enough that a linear scan becoming
quadratic cannot hide. They are enforced only in an optimized build at full
scale — a coverage build measures gcov, not libakgl.
**The renderer caveat, stated once and loudly.** These draw benchmarks run
against SDL's *software* renderer. No shipped game does that. What a software
renderer buys is that all the work stays in this process where it can be timed,
and that the per-blit cost is at least honest about how much pixel traffic was
asked for. Read every drawing number as *a count of work libakgl asked for*, not
as a frame rate anyone would ship. That is exactly why the raw-SDL control rows
exist: they do the same pixel work with none of the library in the path, so the
difference between the two is libakgl's share, and that part *does* carry over
to a GPU backend.
## Static footprint
libakgl does not call `malloc`. Every one of these arrays exists from process
start whether the game uses one slot or all of them.
| Pool | Slots | Bytes each | Total |
|---|---:|---:|---:|
| `akgl_heap_actors` | 64 | 400 | 25,600 |
| `HEAP_SPRITE` | 1024 | 176 | 180,224 |
| `HEAP_SPRITESHEET` | 1024 | 536 | 548,864 |
| `HEAP_CHARACTER` | 256 | 184 | 47,104 |
| `akgl_heap_strings` | 256 | 4,100 | 1,049,600 |
| **pools, total** | | | **1,851,392** |
| `akgl_Tilemap` (one, as `_akgl_gamemap`) | 1 | 26,388,008 | 26,388,008 |
| — of which layers | 16 | 1,120,296 | 17,924,736 |
| — of which tilesets | 16 | 528,944 | 8,463,104 |
**28 MB of BSS before `main` runs, and 94% of it is one tilemap.** A layer is
512x512 `int` cells whether the map is 512x512 or 2x2, and a tileset carries a
65,536-entry offset table whether the image holds 65,536 tiles or 1,728. This is
not a hypothetical cost: zeroing that struct is 1.37 ms, and `akgl_tilemap_load`
pays it before it has read a byte of the map file.
## Results: everything that does not draw
`tests/perf.c`, full scale. `ns/op` is the best of five runs; `ops/sec` is its
reciprocal.
| Benchmark | Unit | ns/op | ops/sec |
|---|---|---:|---:|
| `heap_next_actor`, empty pool | call | 4.0 | 248,392,158 |
| `heap_next_actor`, one slot left | call | 36.9 | 27,077,696 |
| heap string claim + release cycle | cycle | 49.8 | 20,084,967 |
| `heap_next_string`, empty pool | call | 3.9 | 255,059,748 |
| `heap_next_string`, one slot left | call | 250.9 | 3,986,007 |
| `heap_release_string`, 4 KiB wipe | call | 47.2 | 21,191,918 |
| `heap_init`, all five pools | call | 45,141 | 22,153 |
| actor spawn + release, library logging on | actor | 202.3 | 4,942,862 |
| actor spawn + release, logging suppressed | actor | 162.5 | 6,153,324 |
| `akgl_actor_set_character`, registry lookup | call | 39.3 | 25,438,092 |
| `akgl_set_property` | call | 96.9 | 10,316,516 |
| `akgl_get_property`, 4 KiB copy | call | 85.3 | 11,722,134 |
| `akgl_character_sprite_get`, state to sprite | call | 37.4 | 26,734,605 |
| `akgl_actor_update`, animation advancing | actor | 68.4 | 14,609,892 |
| `akgl_actor_update`, no sprite for state | actor | 616.5 | 1,621,937 |
| `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_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 |
| `akgl_get_json_string_value` | call | 40.7 | 24,552,958 |
| `akgl_get_json_integer_value` | call | 13.8 | 72,704,562 |
| `akgl_path_relative`, `realpath` on an existing file | call | 3,481.5 | 287,232 |
## Results: everything that draws
`tests/perf_render.c`, full scale, 640x480 software renderer. The two **control**
rows are raw `SDL_RenderTexture` loops with no libakgl in the path.
| Benchmark | Unit | ns/op | ops/sec |
|---|---|---:|---:|
| `frame_start` + `frame_end` (clear + present) | frame | 23,161.7 | 43,175 |
| `akgl_draw_point` | call | 132.5 | 7,547,141 |
| `akgl_draw_line`, screen diagonal | call | 605.9 | 1,650,375 |
| `akgl_draw_rect`, 200x150 outline | call | 520.8 | 1,920,055 |
| `akgl_draw_filled_rect`, 200x150 | call | 43,695.1 | 22,886 |
| `akgl_draw_circle`, radius 64 | call | 2,516.2 | 397,423 |
| `akgl_draw_copy_region`, 64x64 readback | call | 954.7 | 1,047,471 |
| `akgl_draw_paste_region`, 64x64 upload | call | 5,352.1 | 186,841 |
| `akgl_draw_flood_fill`, full 640x480 target | call | 1,967,295.6 | 508 |
| `akgl_text_measure`, 15 characters | call | 37.3 | 26,804,657 |
| `akgl_text_rendertextat`, 15 characters | call | 12,601.7 | 79,354 |
| `akgl_sprite_load_json`, sheet already loaded | load | 17,011.3 | 58,784 |
| `akgl_character_load_json`, two mappings | load | 14,506.8 | 68,933 |
| `akgl_tilemap_load` + release, fixture map | load | 11,881,190.8 | 84 |
| zeroing one `akgl_Tilemap` | call | 1,372,396.7 | 729 |
| `akgl_tilemap_compute_tileset_offsets`, 1728 tiles | call | 2,240.8 | 446,264 |
| `akgl_tilemap_draw`, 40x30 tiles, 1 tileset | frame | 16,260,566.6 | 61 |
| `akgl_tilemap_draw`, 40x30 tiles, 8 tilesets | frame | 16,395,182.4 | 61 |
| **control**: raw SDL blits, one source tile | frame | 471,676.6 | 2,120 |
| **control**: raw SDL blits, map order | frame | 16,229,501.0 | 62 |
| `akgl_actor_render`, on camera | actor | 2,992.0 | 334,220 |
| `draw_world`, 1200 tiles + 64 actors | frame | 16,484,493.6 | 61 |
| `akgl_game_update`, full frame | frame | 16,576,902.2 | 60 |
The `akgl_game_update` row is from before the sixteen-times-per-actor defect was
fixed in 0.5.0; see below. It sat about 92 µs above `draw_world` in the same
run, and now sits level with it. The absolute figures in this table have not
been re-taken -- a later run on this machine read every row about 15% high,
including rows nothing has touched, so the table is a record of one machine
state and the honest measurement of that fix is the *gap* between two rows of
the same run, not a new absolute.
## The frame budget
At 60 fps a frame is 16.67 ms. Here is where it goes for a 640x480 game with a
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% |
| 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% |
**Everything libakgl decides is free. The pixels are the whole frame.** Every
piece of bookkeeping this library does — pool scans, registry lookups, state-to-
sprite mapping, physics, the error-context machinery — adds up to well under 1%
of a frame that is 97% software rasterization. On a GPU backend those blits get
cheap and libakgl's own share rises, which is exactly why the per-operation
numbers above matter more than the frame totals.
## What the numbers say
### The tilemap draw is SDL, not libakgl — and I can prove it
`akgl_tilemap_draw` takes 16.26 ms for a 1200-tile screen. A raw
`SDL_RenderTexture` loop issuing the *same 1200 blits from the same scattered
source tiles* takes 16.23 ms. The library's own per-tile work — the bounds
arithmetic, the tileset scan, the offset-table lookup, the backend indirection,
the error macros — is **0.03 ms per frame, under 0.2%**.
That took three attempts to measure honestly. A control that walked the sheet
sequentially said libakgl cost 67%; a control that blitted one source tile over
and over said it cost 3400%. Both were wrong, and both were wrong the same way:
they changed the *memory access pattern of the source texture* rather than
isolating the library. A 16x16 tile read from a random place in a 768x576 sheet
costs about 13 µs on this software rasterizer; the same tile read from cache
costs 0.4 µs. That factor of thirty is the whole story, and none of it is
libakgl's.
Related: the `FIXME` in `src/tilemap.c` worrying that the per-tile tileset scan
"is probably not very efficient" is, at eight tilesets, worth **0.8% of the
frame** (16.40 ms vs 16.26 ms). It is a real O(tiles x tilesets) loop and it
should still be fixed, but it is not where the time is, and nobody should
reorganise the loader for it.
### The pools are linear scans, and only the string pool cares
Claiming an actor from an empty pool is 4.0 ns; claiming the last free slot is
36.9 ns — nine times the cost, and still nothing.
The string pool is the exception, and it is instructive. Claiming from an empty
string pool is 3.9 ns; claiming the *last free slot* is 250.9 ns, **64 times**
slower. Same algorithm, same 256-ish entries. The difference is that each
`akgl_String` is `PATH_MAX` + 4 bytes, so the scan touches one reference count
every 4 KiB and takes a cache miss on every candidate. A pool of 256 strings is
a megabyte, and walking it is walking a megabyte.
`akgl_heap_release_string` costs 47.2 ns because it `memset`s all 4,100 bytes
whether the string held a path or one character. Same for `akgl_string_copy`
(32.2 ns) and `akgl_get_property` (85.3 ns), which move `AKGL_MAX_STRING_LENGTH`
bytes unconditionally. None of these is expensive in isolation; all of them are
the same avoidable habit of paying for `PATH_MAX` when you used eleven bytes.
### Errors cost about ten times what success costs
`akgl_actor_update` on an actor whose character has a sprite for its state:
68.4 ns. The same call on an actor whose character does *not*: **616.5 ns**.
That path is not an error in any meaningful sense — the library handles
`AKERR_KEY` and carries on, and `akgl_actor_render` logs it and draws nothing.
It is a normal condition on a partly authored character. But raising it means
claiming a context out of `AKERR_ARRAY_ERROR`, formatting a message with
`vsnprintf`, appending a stack-trace frame, walking the `PROCESS` switch, and
releasing it again. Nine times the cost of the update it replaced.
The design conclusion is not "make errors cheaper". It is that a *routine*
condition should not be reported as an error. A character that has no sprite for
a state should answer that question with a boolean.
### `akgl_game_update` updated every actor sixteen times — fixed in 0.5.0
`akgl_game_update` looped over `AKGL_TILEMAP_MAX_LAYERS` with the actor sweep
nested inside it, and never compared an actor's `layer` to the layer it was on.
Every live actor's `updatefunc` ran **16 times per frame**: at 68.4 ns per
update and 64 actors, 70 µs of work to do 4.4 µs of it — and, more to the
point, every piece of a game's own per-frame actor logic running sixteen times.
The sweep is hoisted out of the layer loop. Updating an actor is not a per-layer
operation; drawing is, and `akgl_render_2d_draw_world` walks the layers itself.
`AKGL_ITERATOR_OP_LAYERMASK` is honoured now rather than ignored, so a caller
that genuinely wants one layer can still ask for it and gets each of those
actors once.
**How it was measured.** `tests/game.c` counts calls into a stub `updatefunc`
and asserts exactly one per live actor per `akgl_game_update`; against the old
code it reports 16. The timing side is the gap between the `akgl_game_update`
and `draw_world` rows of the *same* benchmark run: 92 µs before, and across
three runs afterwards -414 µs, -376 µs and +19 µs — noise in both directions.
It was invisible in the frame total because the tilemap blits are three orders
of magnitude larger, which is exactly why it needed a counting test rather than
a stopwatch. On a GPU backend, where a frame might be 2 ms, it was 3.5% of the
frame doing nothing.
### Text has no cache at all
`akgl_text_rendertextat` is 12.6 µs for fifteen characters: it rasterizes the
string, uploads it as a texture, blits it, and destroys the texture — every
call, every frame, for a score that changes once a second. Measuring the string
first with `akgl_text_measure` is 37.3 ns, i.e. free, which tells you the whole
cost is the rasterize-and-upload.
Six HUD readouts is 76 µs a frame. That is fine at 60 fps on this machine and it
is 4% of a 2 ms GPU frame. A one-line cache keyed on (font, string, colour)
would take it to nothing, and it is the single clearest optimisation in the
library.
### Loading is dominated by things that are not the file
A 2x2 fixture map with one tileset takes **11.9 ms** to load and release. Of
that, 1.37 ms — 11.5% — is `memset`ing the 26 MB `akgl_Tilemap` before anything
is read. Most of the rest is decoding the tileset PNG. The JSON is noise:
parsing a small document is 11.3 µs, and the accessors are 14-41 ns each.
`akgl_path_relative` is 3.5 µs, because it is a `realpath(3)` syscall. A map
naming twenty assets pays 70 µs. Also noise, but worth knowing it is a syscall
and not a string operation.
### Collision, measured
Added in 0.8.0. Every row below is from one run, on the machine described above.
| 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
An actor spawn — pool claim, `memset`, registry insert, release — is 202.3 ns
with the library's logging on and 162.5 ns with SDL's log priority raised so the
message is never formatted. **20% of a spawn is formatting a log line nobody
reads**, and that is with output going to a sink that throws it away; write it to
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 recorded where defects live: `TODO.md`, under **Performance ->
Defects the perf suites found**, items 28-33, each with its file, line,
functional consequence, and what fixing it would touch. They are not repeated
here.
Worth saying in this document, because it is the argument for having written the
suites at all: stress testing breaks things unit tests do not reach. Two of the
six are process-killing — an error-context leak that aborted on the 129th path
resolution (fixed, with a regression test), and a pooled-string leak that turns
into a segfault rather than an `AKGL_ERR_HEAP` around the 52nd map load. Neither
is reachable by a suite that calls each function a handful of times, and neither
had anything to do with speed. They came out of loops that ran the same call ten
thousand times in one process, which is what a running game does and what
nothing else in this tree did.
## What this report does not cover
- **One machine.** No ARM, no Raspberry Pi, no Windows, no macOS. The ratios
should travel; the absolute numbers will not.
- **No GPU backend.** Everything drawing-related is a software rasterizer under
the dummy video driver. The share of a frame that belongs to libakgl is a
floor, not an estimate.
- **No audio.** `src/audio.c` is not benchmarked at all; the mixer under the
dummy driver does not do the work a real device would.
- **No controller input.** The event path is driven by SDL and has no gamepad to
drive it under the dummy driver.
- **No heap profiling beyond the static footprint.** Nothing here measures peak
or steady-state heap usage. Correctness of the heap *is* covered now, by
`cmake --build build --target memcheck`, which runs these same binaries under
valgrind — `tests/benchutil.h` cuts the iteration counts by three orders of
magnitude when it finds itself there, so a benchmark becomes a path-coverage
program. That run found six more defects, including a leak of the parsed JSON
document in every asset loader; they are in `TODO.md` under **Memory
checking**. What is still missing is a profile: how much a running game
actually holds at once, and how that grows over an hour.
- **Single-threaded throughout.** The library is not thread-safe by design
(`akgl_game_state_lock` guards one field), and nothing here tests contention.

219
README.md Normal file
View File

@@ -0,0 +1,219 @@
# libakgl
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.
## 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
```
While editing one chapter, run it directly rather than the whole suite:
```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
The repository ships a `pre-commit` hook that keeps committed C sources in the
project's canonical format (Emacs `cc-mode` "stroustrup"; see `AGENTS.md` for the
full style guide). The hook lives in `scripts/hooks/` and is version controlled,
but **Git configuration is not cloned**, so every clone has to be pointed at it
once:
```sh
git config core.hooksPath scripts/hooks
```
Confirm it took effect:
```sh
git rev-parse --git-path hooks # should print: scripts/hooks
```
That is the whole installation. The hook is already committed with its
executable bit set, so nothing needs `chmod`.
### What the hook does
On each commit it looks at the **staged** content of any added, copied, modified,
or renamed `.c`/`.h` file under `src/`, `include/`, `tests/`, or `util/`, and
reindents it if it does not already match the canonical style. Checking the
staged content rather than the working tree means what lands in the commit is
what was actually verified.
When a file needs reindenting, the hook fixes it in the working tree and
re-stages it — but only when the index and working tree agree for that file. If
they differ, you have staged part of a file with `git add -p`, and re-staging
would sweep your unstaged work into the commit. Rather than do that silently the
hook stops and prints the commands to run yourself:
```sh
scripts/reindent.sh path/to/file.c
git add path/to/file.c
```
`include/akgl/SDL_GameControllerDB.h` is generated and always skipped.
### Requirements
The hook drives Emacs in batch mode, because `cc-mode`'s indentation engine is
the definition of the style and `clang-format` cannot reproduce it exactly. If
`emacs` is not on `PATH` the hook prints a warning and allows the commit — a
hook that refuses to run without an optional tool only teaches people to reach
for `--no-verify`. Install Emacs to get the check; without it, formatting is on
you.
### Bypassing and uninstalling
Skip the hook for a single commit:
```sh
git commit --no-verify
```
Remove it entirely:
```sh
git config --unset core.hooksPath
```
### If you already have local hooks
`core.hooksPath` **replaces** the hooks directory outright — once it is set, Git
stops reading `.git/hooks/` altogether, so any hooks you keep there will silently
stop firing. If that matters, leave `core.hooksPath` unset and symlink just this
one hook instead:
```sh
ln -s ../../scripts/hooks/pre-commit .git/hooks/pre-commit
```
### Formatting without the hook
The hook is a convenience, not the source of truth. The same check is available
directly, and is what you would run in CI:
```sh
scripts/reindent.sh --check # list non-conforming files; exit 1 if any
scripts/reindent.sh # reindent every tracked C source in place
scripts/reindent.sh src/game.c # reindent specific files
```
`--check` exits `0` when everything conforms, `1` when a file needs reindenting,
and `2` if Emacs is missing or the script cannot run — so a CI job that treats
any non-zero status as failure will not mistake a broken toolchain for a clean
tree.
## Mutation testing
The mutation harness makes one deliberate source-code change at a time in a scratch copy, then rebuilds and runs the passing CTest suite to measure whether tests detect the change. The known-failing `character` test is excluded by default.
```sh
cmake --build build --target mutation
scripts/mutation_test.py --target src/tilemap.c --list
scripts/mutation_test.py --target src/tilemap.c --max-mutants 10
scripts/mutation_test.py --threshold 40 --junit mutation-junit.xml
```
The default run covers all libakgl-owned files under `src/`. Use repeated `--target` options to narrow the scope. A surviving mutant identifies behavior that the current tests do not verify; the script prints its file, line, operator, and exact edit. The real working tree is never mutated.
## Performance testing
`tests/perf.c` and `tests/perf_render.c` are benchmarks rather than unit tests: they drive the hot paths — pool acquire and release, the registry, the physics sweep, the per-actor update and render, the tilemap draw, text, and asset loading — and print what each one costs per operation.
```sh
ctest --test-dir build -L perf --output-on-failure # benchmarks only, about 30 seconds
ctest --test-dir build -LE perf # everything except the benchmarks
AKGL_BENCH_SCALE=0.1 ctest --test-dir build -L perf # a tenth of the iterations
```
Each measurement is the best of five runs and is held to a budget set at roughly ten times the recorded baseline, so a suite that turns red means an algorithmic regression rather than a busy machine. Budgets are enforced only in an optimized build at full scale; below `AKGL_BENCH_SCALE=1.0`, and in a coverage build, they are reported without failing.
`PERFORMANCE.md` carries the recorded baseline, the frame budget it adds up to, and what the numbers say — including the raw-SDL control rows that separate what libakgl costs from what the rasterizer costs. The six defects the stress tests turned up, and the targets the numbers are measured against, are in `TODO.md` under **Performance**; the plan for the missed targets is issue #60.
## Memory checking
`memcheck` runs the suites that already exist under valgrind rather than adding suites of its own. The perf binaries carry most of it: they are the only programs in the tree that load assets, draw a scene, and run a frame loop in one process, and `tests/benchutil.h` cuts their iteration counts by three orders of magnitude when it detects valgrind — a benchmark walks one path a hundred thousand times, a leak check wants every path walked once.
```sh
cmake --build build --target memcheck # everything, about 30 seconds
scripts/memcheck.sh -R tilemap # one suite; ctest selection flags pass through
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`, "Why deps/tg is still in the
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.

933
TODO.md Normal file
View File

@@ -0,0 +1,933 @@
# Record
**Outstanding work is in the issue tracker, not in this file:**
<https://source.starfort.tech/andrew/libakgl/issues>
This file used to carry both halves of the record -- what had been done and what
was left -- and the second half is what a tracker is for. Every open item moved
there. What stays here is the part a tracker has no place for: **why a decision
went the way it did, what the measurement was, and which arguments turned out to
be wrong.**
The rule that produced this split is the one this file kept breaking: an entry
describing work still to do goes stale the moment somebody does it, and stale
entries are where the next defect hides. `AGENTS.md` records the last round of
that -- eleven entries here described code that had already changed -- and this
file admitted to three more when it was migrated. **A record of finished work
cannot go stale in that way.**
Issues are labelled by kind (`defect`, `performance`, `test-coverage`, `api-gap`,
`packaging`, `docs`, `design-decision`, `abi-break`, `hygiene`) and by blast
radius, and milestoned by what they can land in: `0.9.x` for anything that breaks
no ABI, `0.10.0` for new or changed public symbols, `1.0.0` for the design work,
`Unscheduled` for constraints waiting on a trigger.
Four issues are epics with the rest hanging off them: the performance plan
(#60), coverage (#61), actor rotation (#62), and the false header comments (#63).
---
## Test suites that could not fail
Found while renaming the exported globals. Both are fixed, and this is the entry
that argues hardest for not trusting a green suite.
**Every suite reported success on any status whose low byte is zero.**
libakerror's default unhandled-error handler ends in `exit(errctx->status)`.
`exit` keeps only the low byte of what it is given, and libakgl's status band
starts at `AKERR_FIRST_CONSUMER_STATUS`, which is 256. `AKGL_ERR_SDL` is
therefore exactly 256, `exit(256)` is a wait status of 0, and CTest recorded a
pass. **The most common failure status in a library built on SDL was the one
status that could not fail a test.**
**`tests/character.c` was green while running one of its four tests.** It aborted
in `test_character_sprite_mgmt` with
`Failed loading asset .../spritesheet.png : Parameter 'renderer' is invalid` --
the symbol collision described under "Public naming" below -- and exited 0
because that status was `AKGL_ERR_SDL`. Two independent defects had to line up,
and they did, for long enough that this file recorded the suite as passing and
wondered which change had fixed it. Nothing had; it had stopped running.
**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.
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.
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.
## Internal consistency
A sweep of `src/` and `include/` found 41 consistency and convention problems.
All are resolved except the two now filed as #4 and #5. What follows is the part
worth keeping: what changed, and which of them were not cosmetic after all.
### Public naming, resolved in 0.5.0
An ABI break, carrying the soname to `libakgl.so.0.5`. What a consumer had to
rename:
| Was | Is |
|---|---|
| `_ASSETS_H_`, `_CONTROLLER_H_`, `_DRAW_H_`, `_ERROR_H_`, `_JSON_HELPERS_H_`, `_PHYSICS_H_`, `_REGISTRY_H_`, `_RENDERER_H_`, `_TEXT_H_`, `_TILEMAP_H_`, `_UTIL_H_`, `_STRING_H_` | `_AKGL_<FILE>_H_` |
| `akgl_Actor_cmhf_*` (8 functions) | `akgl_actor_cmhf_*` |
| `akgl_game_updateFPS` | `akgl_game_update_fps` |
| `akgl_render_init2d`, `akgl_render_bind2d` | `akgl_render_2d_init`, `akgl_render_2d_bind` |
| `akgl_sprite_sheet_coords_for_frame` | `akgl_spritesheet_coords_for_frame` |
| `point`, `RectanglePoints` | `akgl_Point`, `akgl_RectanglePoints` |
| `window`, `bgm`, `game`, `gamemap`, `renderer`, `physics`, `camera` | the same, `akgl_`-prefixed |
| `_akgl_renderer`, `_akgl_physics`, `_akgl_camera`, `_akgl_gamemap` | `akgl_default_renderer`, `akgl_default_physics`, `akgl_default_camera`, `akgl_default_gamemap` |
| `HEAP_ACTOR`, `HEAP_SPRITE`, `HEAP_SPRITESHEET`, `HEAP_CHARACTER`, `HEAP_STRING` | `akgl_heap_actors`, `akgl_heap_sprites`, `akgl_heap_spritesheets`, `akgl_heap_characters`, `akgl_heap_strings` |
| `GAME_ControlMaps` | `akgl_controlmaps` |
| `AKGL_SPRITE_MAX_CHARACTER_NAME_LENGTH` | `AKGL_CHARACTER_MAX_NAME_LENGTH` |
| `AKGL_TIME_ONESEC_MS` | `AKGL_TIME_ONEMS_NS` |
`include/akgl/staticstring.h` also stopped guarding with `_STRING_H_` -- a name
several libc implementations use for their own `<string.h>` -- and its
`#include "string.h"` is now `#include <string.h>`.
**The renames were done by renaming each declaration and letting the compiler find
every use, rather than by pattern substitution.** That distinction matters for
`renderer`, `physics` and `camera`, which are also parameter and struct-member
names: a `sed` would have rewritten `map->physics` and every
`akgl_RenderBackend *renderer` parameter, and nothing would have complained.
**One rename had a live bug behind it.** `akgl_game_state_lock` counted its retry
loop against a constant named "one second in milliseconds" that held `1000000`, so
it retried 10,000 times at 100 ms and blocked for roughly sixteen minutes before
reporting failure. The budget is now `AKGL_GAME_STATE_LOCK_BUDGET_MS` (1000), with
the cadence named separately as `AKGL_GAME_STATE_LOCK_RETRY_MS`. `tests/game.c`
carries `test_game_state_lock_budget`, which holds the mutex from a second thread
and asserts the call gives up between half a second and five. The old code fails it
by timing the suite out; a build that gave up without waiting at all fails the
lower bound. **Nothing exercised the contended path before** -- the existing lock
test only ever took an uncontended mutex, which never reaches the retry loop.
**Renaming the exported globals was not cosmetic, and the proof was sitting in the
test suite.** `renderer` was exported from the shared library, and
`tests/character.c` defined an `SDL_Renderer *renderer` of its own. Both had
external linkage and the same spelling, so the executable's definition preempted
the library's: `akgl_sprite_load_json` read an `SDL_Renderer *` through an
`akgl_RenderBackend *`, every texture load in that suite failed with
`Parameter 'renderer' is invalid`, and the suite still reported success. **A
consuming game with a variable called `renderer` would have hit the same thing
with no test to notice.**
### Header/implementation surface drift, resolved in 0.5.0
**Nineteen non-static functions were defined in `src/` but declared in no header.**
Each is now declared or `static`. The controller handlers were the interesting
case: they were defined as `gamepad_handle_*` while `controller.h` declared the
`akgl_controller_handle_*` names it never defined -- four symbols *mentioned* in
prose, which is not the same as being declared.
**This is enforced now.** `scripts/check_api_surface.sh` reads the built library's
dynamic symbol table, strips comments out of every public header, and fails when an
exported `akgl_*` symbol is declared nowhere. It runs as the `api_surface` CTest
test. **Stripping comments is the point**, and is exactly how those four went
unnoticed.
**Headers relied on their includers for types.** `iterator.h` used `uint32_t`
without `<stdint.h>`; `json_helpers.h` used `json_t` without `<jansson.h>`;
`util.h` used `SDL_FRect` and `bool` without any SDL include. Each compiled only
because of `.c`-file include ordering.
Resolved, and enforced rather than merely fixed: `AKGL_PUBLIC_HEADERS` in
`CMakeLists.txt` is the single list behind both `install()` and a generated
translation unit per header -- each including exactly that header and nothing
before it -- linked into the `headers` suite. **A header that ships is a header
that is checked.** Writing that check found a case the hand-written list had
missed: `registry.h` uses `SDL_PropertiesID` in eight declarations and included no
SDL header at all. That is the argument for generating the check off the install
list rather than hand-listing the headers somebody thought were at risk.
**Object-pool size macros were defined twice and the override hook was dead.**
They live once, in `heap.h`, inside the `#ifndef` guards that were always supposed
to make them overridable. `tests/header_pool_override.c` is the regression test: it
defines its own ceilings, includes `heap.h`, and `#error`s if the guard did not
fire. **The assertion is the compile.**
**Empty parameter lists.** `akgl_game_init`, `akgl_game_update_fps`,
`akgl_heap_init`, `akgl_heap_init_actor` and the eight `akgl_registry_init*`
functions declare and define `(void)`. Before C23 `()` means "unspecified
arguments" and suppresses argument checking, **so these were the entry points a
caller could pass anything to.**
Also resolved: `akgl_game_init_screen` declared and never defined; static helpers
using three naming styles; six parameter-name disagreements between declaration and
definition (`akgl_get_json_with_default` was the interesting one -- it took the
incoming context as `err` and named its *own* context `e`, and renaming it was what
surfaced that the two had been swapped rather than merely misspelled); and
`AKERR_NOIGNORE` applied inconsistently at definition sites.
### Error-handling pattern, resolved in 0.5.0
**`*_RETURN` macros were used inside `ATTEMPT` blocks, which skips `CLEANUP`.** Ten
sites, found by scanning rather than from the list -- which named six and missed the
four in `akgl_collide_rectangles`.
The one that mattered was the success path of `akgl_get_json_tilemap_property`,
which leaked two of the string pool's 256 entries on every lookup that *found* what
it was asked for. A map load does that several times per layer.
**Two needed more than swapping the macro.** In `akgl_get_json_tilemap_property` a
plain `break` would have fallen through to the "property not found" `FAIL_RETURN`
after `FINISH`, reporting a miss for something found, so the success path sets a
flag. In `akgl_collide_rectangles` the eight early exits were followed by
`*collide = false;`, which would have overwritten the hit that broke out of the
block; each corner test writes the flag itself, so that line is gone rather than
moved.
**`scripts/check_error_protocol.py` keeps this closed**, as the `error_protocol`
test. It also enforces the other rule with a silent failure mode -- no `return` out
of a `HANDLE` block.
Also resolved: NULL-check discipline varying by function (the eight typed JSON
accessors that validated their container and then wrote through `dest`
unconditionally; the null physics backend; `akgl_render_2d_frame_start`, `_frame_end`
and `_shutdown`, which `tests/renderer.c` now calls with `NULL` and which segfaulted
before), and the `errctx`/`e` split -- all 45 remaining sites are `errctx`, in its
own commit because it is a rename and nothing else, with `e` keeping its meaning as
an incoming context being inspected.
### Types and macros, resolved in 0.5.0
**`AKGL_COLLIDE_RECTANGLES` had unbalanced parentheses.** Three opens against two
closes meant any expansion was a syntax error. It had no callers and duplicated
`akgl_collide_rectangles`, so it was deleted.
**Bitmask macros were unparenthesized.** All five are fully parenthesized, and
`AKGL_BITMASK_CLEAR` no longer carries a semicolon inside its body.
`tests/bitmasks.c` covers the composition cases, and writing them turned up
something worth recording: **the obvious test does not catch this.** For a bit that
is *set*, `!AKGL_BITMASK_HAS(mask, bit)` misparses to `!(mask & bit) == bit`, which
is `0 == bit` -- false, the same answer the correct parse gives. It only diverges
for a bit that is *not* set and whose value is not 1: `!(0)` is 1, and `1 == 64` is
false where the answer should be true. The suite uses that shape, and fails against
the old macros.
Also resolved: `float`/`double` spelled raw where `types.h` defines `float32_t` and
`float64_t`; the state and iterator bit macros mixing forms; and `akgl_Frame`,
defined and never used, deleted.
The bit-pattern comments in `actor.h` were not wrong so much as unlabelled: each
shows the pattern *within its own 16-bit half*, which is why bit 16 looks like it
restarts at bit 0. The section headings say so now. Rendering the full 32-bit value
instead would push those lines past the 100-column fill.
### `AKGL_ACTOR_STATE_STRING_NAMES`, resolved in 0.5.0
**The array bound differed between declaration and definition.** The header
declared `[AKGL_ACTOR_MAX_STATES+1]` (33) and the definition was a literal `[32]`,
so a consumer trusting the declared bound read past the object.
**Two entries named the wrong bit.** Indices 11 and 12 said
`AKGL_ACTOR_STATE_UNDEFINED_11` and `_12` where `actor.h` has `MOVING_IN` and
`MOVING_OUT`, so **no character JSON could ever bind a sprite to either state** --
the name it would have to write was not in the registry.
`tests/registry.c` now walks the whole table: every entry non-`NULL`, every entry
resolving to its own bit, no two entries sharing a name (a duplicate silently
overwrites and makes one bit unreachable), and `MOVING_IN`/`MOVING_OUT` named
explicitly so a regression reads as what it is.
The generation comment was stale -- there is no generator, no Makefile and no
`lib_src/`. The file's own header says it is maintained by hand and states the two
invariants that keep breaking.
### Formatting and hygiene, resolved in 0.5.0
**Leftover debug code.** Four `SDL_Log` lines in `src/controller.c` guarded by
`event->type == 768 && event->key.which == 11 && event->key.key == 13` -- decimal
literals for one keyboard on one developer's machine, inside the per-event inner
loop.
**`akgl_game_update`'s default flags OR-ed the same bit twice.** It reads
`AKGL_ITERATOR_OP_UPDATE | AKGL_ITERATOR_OP_LAYERMASK` now. This was a statement of
intent rather than a behaviour change, and the reason is worth knowing: **nothing in
that loop read either bit**, which was the per-layer update sweep defect fixed
later.
**`akgl_draw_background` was the only public function outside the error protocol.**
It takes an `akgl_RenderBackend *` like every other entry point in `draw.h`, returns
an error context, checks the backend and its `sdl_renderer`, and restores the draw
colour it found. It was recorded as needing the offscreen renderer harness. **It did
not, and never did** -- what it needed was to stop reading the global.
**`akgl_registry_init_actor` was the only initializer that destroyed the registry it
was replacing.** All eight go through one `registry_create()` helper now, so the
other seven stop leaking an `SDL_PropertiesID` per call after the first -- which a
game that resets between levels makes on every level. Fixed alongside it, because it
is the same function: `akgl_registry_init()` never called
`akgl_registry_init_properties()`, so `AKGL_REGISTRY_PROPERTIES` stayed 0 for any
caller that did not also go through `akgl_game_init`, making `akgl_set_property` a
silent no-op and `akgl_get_property` always return the caller's default -- so
`akgl_physics_init_arcade` and `akgl_render_2d_init` quietly ignored their
configuration.
Also resolved: five abandoned `SDL_GetBasePath()` blocks; a dozen unused locals;
`struct`-qualified parameters in two physics definitions; `text.c` validating the
wrong argument; and `dst` vs `dest` for output parameters.
## Coverage
**Line coverage 83.9%, function coverage 91.2%** (2433/2901 lines, 176/193
functions), up from 79.6% / 87.2% before the 0.5.0 defect work and from a
39.6% / 44.3% baseline. Snapshot at 0.9.0; the gaps behind it are #61.
Generated with:
```sh
cmake -S . -B build-coverage -DAKGL_COVERAGE=ON -DCMAKE_BUILD_TYPE=Debug
cmake --build build-coverage --parallel
ctest --test-dir build-coverage --output-on-failure
```
Reports land in `build-coverage/coverage/` (`index.html`, `coverage.xml`). A
coverage tree rebuilt after a source edit fails `coverage_reset` with
`GCOV returncode was 5`; `find build-coverage -name '*.gcda' -delete` clears it.
| File | Lines | Functions |
|---|---|---|
| `src/actor.c` | 205/258 (80%) | 16/18 |
| `src/assets.c` | 0/21 (0%) | 0/1 |
| `src/audio.c` | 229/248 (92%) | 22/23 |
| `src/character.c` | 122/126 (97%) | 7/7 |
| `src/controller.c` | 314/349 (90%) | 17/17 |
| `src/draw.c` | 281/286 (98%) | 12/12 |
| `src/error.c` | 9/9 (100%) | 1/1 |
| `src/game.c` | 142/243 (58%) | 13/18 |
| `src/heap.c` | 116/116 (100%) | 12/12 |
| `src/json_helpers.c` | 123/123 (100%) | 11/11 |
| `src/physics.c` | 144/144 (100%) | 10/10 |
| `src/registry.c` | 83/111 (75%) | 12/13 |
| `src/renderer.c` | 61/83 (74%) | 8/8 |
| `src/sprite.c` | 102/110 (93%) | 5/5 |
| `src/staticstring.c` | 17/18 (94%) | 2/2 |
| `src/text.c` | 79/80 (99%) | 7/7 |
| `src/tilemap.c` | 276/444 (62%) | 13/20 |
| `src/util.c` | 128/130 (98%) | 7/7 |
| `src/version.c` | 2/2 (100%) | 1/1 |
`src/tilemap.c` moved the most, 47% to 62%, because the bounds and leak work needed
the loaders driven rather than merely called.
**Branch coverage reads 24.7% and should not be used as a target.** The akerror
control-flow macros expand into large branch trees per call site, most of them
unreachable in normal operation -- `src/game.c` reports over 1700 branches across
230 lines. Track line and function coverage; treat branch coverage as a relative
signal within a file.
### Suites
Every suite is registered through the `AKGL_TEST_SUITES` list in `CMakeLists.txt`,
which drives target creation, CTest registration, the `WORKING_DIRECTORY`/`TIMEOUT`
properties, the link line, and the `FIXTURES_REQUIRED akgl_coverage` list together.
Adding `tests/<name>.c` and the name to that list is all a new suite needs; **it can
no longer be accidentally left out of the coverage fixture.**
Shared assertion helpers are in `tests/testutil.h`: `TEST_ASSERT`,
`TEST_ASSERT_FEQ`, `TEST_EXPECT_STATUS`, `TEST_EXPECT_OK`, `TEST_EXPECT_ANY_ERROR`
and `TEST_ASSERT_FLAG`. All except the last expand to a `break` on failure, so they
belong directly inside an `ATTEMPT` block, not inside a loop nested in one.
### Mutation testing
**Macros expand at call sites, so mutation testing is the only thing that checks
them.** `scripts/mutation_test.py`, run when touching control flow, refcounting,
stack traces or handlers.
Sampled runs (`--max-mutants 8` to 10 each, so these are samples rather than
exhaustive scores):
| File | Score | Surviving mutants |
|---|---|---|
| `src/draw.c` | 90% | Deleting the `FAIL_ZERO_BREAK` on `SDL_CreateTextureFromSurface` in `akgl_draw_flood_fill` |
| `src/audio.c` | 75% | Deleting `SUCCEED_RETURN` from the static `check_voice`; deleting `spec.freq` before opening a device |
| `src/text.c` | 50% | Three in `akgl_text_rendertextat`, which had no test yet, plus one `SUCCEED_RETURN` deletion |
| `src/controller.c` | 40% | Three `SDL_Log` deletions; `keybuffer_head`'s initialiser; `count > 0` in `keybuffer_attach_text`; the `mod` a text-only entry is given |
**What the runs found, and what they did not.** `src/audio.c` scored 50% first and
named two real gaps, both now tested: nothing asserted that an *unconfigured* voice
is audible (the whole reason the table defaults to a square wave at full level
rather than a zeroed struct), and no envelope test used a non-zero attack *and*
decay together, so the decay measuring from the wrong origin survived. `src/draw.c`
scored 70% first and named one: the circle was only checked at its four axis points,
which a mis-signed octant reflection survives, so it now checks that every plotted
pixel has a mirror in the other three quadrants. In `src/controller.c`, one real gap
-- nothing checked that a text-only ring entry reports no modifiers.
The rest are **equivalent mutants rather than misses**, and knowing which is which
is the point: a ring buffer whose head starts at 1 behaves identically, `count > 0`
cannot be false where it is checked, deleting the `break` on a `switch`'s last case
changes nothing, and the mixer's `v <=` bound reads one voice past a zeroed table,
which is undefined rather than observable. `SDL_Log` deletions are unobservable by
construction. Deleting a `SUCCEED_RETURN` leaves a non-void function falling off its
end, which is undefined rather than observably wrong.
The three `src/text.c` survivors are gone: it is tested against a software renderer,
and deleting any of its three backend checks fails the suite. **That was not free**
-- the first draft of the test used a made-up `SDL_Renderer` pointer, which SDL
refuses on its own, so the deleted-check mutant survived it. A live renderer was what
made the check observable.
## Defects
### Fixed while building the suites
Each was found by a test written to assert correct behaviour.
1. **`akgl_physics_simulate` dereferenced `self` before its NULL check.**
`src/physics.c:132` read `self->gravity_time` at declaration time, three lines
above the `FAIL_ZERO_RETURN`. A NULL backend segfaulted.
2. **`akgl_game_save` never flushed or closed its stream.** `CLEANUP` and `PROCESS`
were transposed, which put the `fclose` inside the `PROCESS` switch, where it
only ran if an error context existed and reported success. **An ordinary save
produced an empty file.**
3. **`akgl_game_save_actors` wrote name-table terminators from a single char.**
`aksl_fwrite((void *)&nullval, 1, AKGL_ACTOR_MAX_NAME_LENGTH, fp)` emitted 127
bytes of adjacent stack memory into the save file and produced a sentinel the
loader could not recognize.
4. **`akgl_game_load_objectnamemap` swallowed read failures.** `CATCH` used directly
inside `while (1)` breaks the loop, not the function, so a truncated or corrupt
name table loaded as a successful game.
5. **`akgl_Actor_cmhf_up_on` and `_down_on` dereferenced `basechar` unguarded**,
unlike their left and right counterparts.
6. **`akgl_actor_logic_movement` checked `actor` twice** instead of checking
`actor->basechar` before dereferencing it.
7. **The gamepad handlers checked `appstate` three times each**, so a NULL event or
a missing player actor was never caught.
### Fixed in 0.5.0 and later
- **`akgl_render_and_compare` compared a texture against itself.** Both passes drew
`t1`, so it always reported a match and **every image assertion built on it
asserted nothing.**
- **`akgl_tilemap_release` double-freed tileset textures.** The layers loop tested
`layers[i].texture` and destroyed `tilesets[i].texture`, so every tileset texture
was freed twice on a single release and no image layer's texture was freed at all.
Each pointer is cleared as it goes now, which also makes a second release safe
rather than a use-after-free.
- **`akgl_path_relative_from` was a stub that leaked.** It claimed a heap string,
never wrote its output, and never released it, so 256 calls exhausted the string
pool. Declared in no header, called from nowhere, duplicating what
`akgl_path_relative` already does. **Fixing an unfinished function nobody can
reach is worse than deleting it.**
- **`akgl_compare_sdl_surfaces` memcmp'd without checking geometry.** `tests/util.c`
compares a 32x32 surface against an 8x8 one in both directions, which used to read
4 KiB past the end of the smaller.
- **`akgl_string_initialize` overflowed by four bytes when `init` is NULL.** The four
bytes it ran past the end of the object landed on the *next* pool slot's
`refcount`, which is the field the allocator reads to decide whether a slot is free
-- **so the overrun could hand a live string out twice.** Fixed alongside, same
class: `akgl_string_copy` accepted a `count` above `AKGL_MAX_STRING_LENGTH`, which
read past the end of one pool slot and wrote past the end of another. The header
documented that as behaviour.
- **Savegame name lengths disagreed between writer and reader.** The reader used
`AKGL_ACTOR_MAX_NAME_LENGTH` for all four tables; only the spritesheet table was
wrong, and that was enough. **The failure was worse than "cannot be read back",
which is what made the test interesting**: the tables carry no length prefix and
end at a zeroed sentinel, so a reader stepping the wrong width does not run off
anything -- it finds a run of zeros somewhere inside an entry, stops early, and
reports success with silently wrong maps. A test that only asserted the load
succeeded passed against the broken reader. So `akgl_game_load` checks the stream
is at EOF once the four tables are read, which is what turns a width disagreement
into `AKERR_IO` instead of a corruption. **That EOF check has to move when the
objects themselves start being written; there is a comment at the site saying so.**
A first pass introduced four `AKGL_GAME_SAVE_*_NAME_WIDTH` aliases, on the
reasoning that the on-disk format's widths are a separate concern from the object
model's. **They were removed**: each expanded to exactly one existing constant and
had exactly one use per side, so they were indirection with no second consumer --
and the divergence they anticipated cannot happen quietly anyway, since raising one
of those lengths is an ABI change and `akgl_game_load` refuses a save whose
`libversion` does not match before it reads a single table.
- **`akgl_controller_pushmap` and `akgl_controller_default` accepted negative map
ids.** Both check the lower bound now.
- **A failed controller-DB fetch silently destroyed the tracked fallback.**
`mkcontrollermappings.sh` runs under `set -euo pipefail`, fetches into a temporary
directory, and moves the result into place only after checking curl's exit status
(with `--fail`, so an HTTP error is a status rather than an error page in the
body), a plausible minimum mapping count, and that no mapping carries a quote or
backslash that would break the C string literal it becomes.
**Verified against all five failure modes**: an unresolvable host, a 404, a
truncated response, an empty-but-successful response -- the original failure
exactly -- and a response containing a quote. Each refuses, and the header's
checksum is unchanged after every one.
The build no longer runs it. The `add_custom_command` declared
`OUTPUT include/akgl/SDL_GameControllerDB.h` as a *relative* path, which CMake
resolves against the binary directory while the script writes to the source
directory, so the declared output never appeared, the command was permanently out
of date, and **every build re-ran it** -- needing network access and leaving the
tree dirty.
Regenerating against the real upstream produced byte-identical mappings -- 2255
entries, no content change -- so the rewrite is faithful.
- **A stale build tree in the source directory broke the coverage run.** gcovr
searches for `.gcda`/`.gcno` under its search paths, and with none given it
searches `--root` -- the source directory, which is where developers keep their
build trees. `--object-directory` does not narrow that; per gcovr's own help it
only identifies "the path between gcda files and the directory where the compiler
was originally run". **Verified by reproducing it**: two instrumented trees built
inside the source directory with a source edit between them fail the old
invocation with `Got function write_exact on multiple lines: 46, 48` and exit 64;
the new one exits 0.
- **22 public symbols shipped without a version or soname bump**, contradicting this
repository's own rule that for 0.x libraries the soname carries `MAJOR.MINOR`
deliberately. Two consequences, and the first is the one that bites: a binary
compiled against the new headers links happily against a `libakgl.so.0.1` built
from the old tree, because the soname says they are the same ABI, and fails at
symbol resolution rather than at configure time. And
`AKGL_VERSION_AT_LEAST(0, 1, 0)` was true for both trees, **so a consumer could
not feature-test for the new API at all** -- `akbasic` pinned the requirement by
submodule commit instead, and said so in its README, which is not a thing a
released library should make anybody do. **Worth remembering the next time a
handful of symbols looks too small to bump for.**
### Found while rewriting the Doxygen comments
Each came out of reading an implementation against the contract its header claimed.
They are recorded inline as `@note` or `@warning` on the function concerned.
- **`akgl_sprite_load_json` did not bound the `frames` array.** The count is bounded
against `AKGL_SPRITE_MAX_FRAMES` before anything is written, and each element is
read into an `int` and narrowed deliberately rather than written through a
`uint32_t *` cast of a `uint8_t *`. A frame number that does not fit a `uint8_t`
is refused rather than truncated into an index that names a different tile.
`tests/sprite.c` covers exactly the maximum, one past it, and the wide frame
number; **against the old code the middle case loads happily.**
- **Two more unbounded array loads in the tilemap loader**, both fixed with the
bound at the top of the loop body. The object one is the reachable half -- 128
objects is not a large object layer and `akgl_TilemapObject` is not small.
- **`akgl_get_json_with_default` defaulted on a status the array accessors never
raise.** Worth knowing for the next test written against this function: **it
returns the context it was given** when it does not handle the status, so
`TEST_EXPECT_OK` -- which releases whatever the statement returns -- will
double-release it against a `CLEANUP` block that also releases it, and a
double-released context corrupts the failure instead of reporting it. The first
draft of that test did exactly that and passed against the unfixed library.
- **`akgl_character_sprite_add` leaked a sprite reference when a state is remapped.**
The new reference is taken *before* the write and given back if the write fails, so
there is no window where a sprite is bound with nothing behind it.
- **`akgl_heap_release_character` abandoned the whole state-to-sprite map.**
`src/heap.c:150` zeroed the character without walking `state_sprites`, so every
sprite reference the character took was lost and the `SDL_PropertiesID` holding the
map was never destroyed. **The test was already written**: `tests/character.c` had
asserted this contract all along -- "character did not reduce reference count of
its child sprites when released" -- and had never once run, for the two reasons
under "Test suites that could not fail".
- **`akgl_actor_render` computed a sprite's drawn height from its width.** Every
actor was drawn square, so a non-square sprite was stretched or squashed --
invisible in the fixtures because they are all square. `tests/actor.c` renders a
48x24 sprite through a backend whose `draw_texture` records the rectangle it is
handed rather than drawing it. **That recording backend is also the first coverage
`akgl_actor_render` has had at all.**
- **`akgl_text_rendertextat` refused the empty string while `akgl_text_measure`
accepted it.** The check sits after the font, text and backend guards rather than
before them, so drawing nothing still refuses the things drawing something refuses
-- a caller does not get a different contract for an empty string.
- **`character_load_json_state_int_from_strings` checked the same argument twice.**
**Not asserted, deliberately.** The function is `static` and has one call site,
which passes `&stateval`, so a `NULL` `dest` is unreachable from the public API and
the guard cannot fire. Testing it would mean giving the function external linkage
purely to reach a defensive check.
## Performance
The first measured baseline is in `PERFORMANCE.md`, produced by `tests/perf.c` and
`tests/perf_render.c` (`ctest --test-dir build -L perf`). Both suites hold every
measurement to a budget set at roughly ten times the recorded baseline, so an
algorithmic regression fails the suite rather than being discovered by a player.
**Read `PERFORMANCE.md` before arguing with anything below** -- every claim has a
number behind it, and several of the things expected to be slow are not.
The plan for the missed targets is #60.
### Defects the perf suites found
- **`akgl_path_relative` leaked an error context on every root-fallback
resolution.** It took its `ENOENT` branch by `return`ing from inside the `HANDLE`
block, which skips the `RELEASE_ERROR` that `FINISH` ends with. One entry of
`AKERR_ARRAY_ERROR` was lost per call, and the 129th call hit "Unable to pull an
error context from the array!" and **exited the process**. Every tilemap load
resolves several paths this way, so a game that loaded fifty levels died in the
loader. `tests/util.c` resolves `AKERR_MAX_ARRAY_ERROR * 2` paths through that
branch; **against the old code that test does not fail, it terminates the suite.**
This is the *only* `return` from inside a `HANDLE` block in `src/` -- the other
candidates use `SUCCEED_RETURN`, which releases correctly. Worth a grep before
anyone writes a new one, and it is now in `AGENTS.md`'s error-handling protocol,
which warned about `*_RETURN` inside `ATTEMPT` but not about returning out of
`HANDLE`.
- **`akgl_tilemap_load` leaked five pooled strings per load**, in two parts. Three
were `akgl_get_json_tilemap_property` leaking two scratch strings on every
*successful* property lookup. The remaining two were each a claim with no matching
release. **Finding the last two meant dumping the contents of every still-claimed
slot after a cycle rather than reading the code again**; `'tilelayer'` and an
assets directory named themselves immediately. Fixed alongside, same class:
`akgl_tilemap_load_layer_objects` released its scratch string after reading each
object's name and then kept using the slot -- and `akgl_get_json_string_value`
reuses a non-`NULL` destination *without* taking another reference, so the slot was
free while still live.
- **Two JSON accessors turned string-pool exhaustion into a segfault.**
`tests/json_helpers.c` claims every slot in the pool and asserts `AKGL_ERR_HEAP`
comes back out of both accessors. **Against the old code that test segfaults rather
than failing** -- and so did the new tilemap property-lookup test, which is how this
was confirmed rather than merely believed.
- **`akgl_game_update` segfaulted if `akgl_game_init` did not run.** `game.fps` is 0
for the first second of the process, which is under the threshold, so the unguarded
`akgl_game_lowfps` call fired on frame one. Only `akgl_game_init` installed the
default -- and `include/akgl/renderer.h` documents the other path on purpose: a
host that owns its own window calls `akgl_render_2d_bind` instead of
`akgl_render_2d_init`. **An embedder following that documentation crashed on its
first frame, and `akbasic` is exactly that embedder.**
- **`akgl_game_update` ran the actor update sweep once per tilemap layer.** The sweep
is hoisted out of the layer loop -- updating an actor is not a per-layer operation.
`tests/game.c` counts calls into a stub `updatefunc`; against the old code it
reports 16. **Counting is the assertion on purpose**: the defect was invisible in
the frame total because the tilemap blits are three orders of magnitude larger, so
a timing test would have measured the rasterizer.
### Targets
What a library like this *should* hit. The frame budget throughout is 16.67 ms
(60 fps); where a target is stated per-operation it is because that is the number
that survives a change of renderer.
The one that governs the rest: **libakgl's own bookkeeping should never be the
reason a frame is late.** Everything the library decides -- pool scans, registry
lookups, state-to-sprite mapping, physics, visibility, error contexts -- should fit
in 5% of a frame, leaving 95% for the pixels and the game's own logic. At 64 actors
and a screenful of tiles that is a ceiling of about 800 us.
| # | Target | Today | Verdict |
|---|---|---|---|
| 1 | Library bookkeeping under 5% of a 60 fps frame at 64 actors + 1200 tiles | ~0.3% (excluding pixel work) | **met** |
| 2 | One actor's logic update under 200 ns | 68.4 ns | **met** |
| 3 | One actor's render bookkeeping, excluding the blit, under 500 ns | ~250 ns, by subtraction | **met, weakly measured** |
| 4 | Physics sweep under 25 ns per *live* actor, proportional to live actors rather than pool size | 19 ns per live actor, but 63.9 ns for an *empty* pool | **partly** |
| 5 | Tilemap draw bookkeeping under 100 ns per tile, excluding the blit | ~25 ns | **met** |
| 6 | Pool acquire under 100 ns regardless of how full the pool is | 3.9 ns empty, 250.9 ns on the last free string slot | **missed** (#21) |
| 7 | Pool release proportional to the bytes actually used | 47.2 ns, a fixed 4 KiB wipe | **missed** (#21) |
| 8 | Re-drawing an unchanged line of text under 1 us | 12.6 us, every frame, no cache | **missed** (#22) |
| 9 | Zero texture creation or destruction per frame in steady state | one create + one destroy per line of text per frame | **missed** (#22) |
| 10 | A handled, routine condition costs no more than twice the path that succeeds | 616.5 ns vs 68.4 ns -- 9x | **missed** (#23) |
| 11 | 256 actors simulated, updated and made ready to draw in under 1 ms | ~22 us at 64 actors; linear, so ~90 us extrapolated | **met, untested at that size** |
| 12 | Collision for 256 actors under 2 ms without the caller writing a broad phase | 54.1 us 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** (#27) |
| 14 | Fixed per-load overhead under 1% of a level load | 11.5% -- zeroing 26 MB of `akgl_Tilemap` | **missed** (#28) |
| 15 | Static footprint under 4 MB in the default configuration | 28 MB, 94% of it one tilemap | **missed** (#28) |
| 16 | No pool leaks across a load/release cycle of any asset type | tilemap is clean, asserted over 64 cycles; sprite, spritesheet and character cycles are not asserted | **met where tested** (#13) |
| 17 | Pool exhaustion reports `AKGL_ERR_HEAP` and never crashes | all five pools report it | **met** |
| 18 | Every benchmark held to 10x its recorded baseline, enforced in CI | done, `ctest -L perf` | **met** |
Notes on the ones worth arguing about:
- **6 and 7 are the same fix**, and **8 and 9 are one cache**, and **14 and 15 are
the same refactor.** Each pair is one issue for that reason.
- **10 is a design target, not a speed target.** The answer is not a faster error
context; it is that "this character has no sprite for this state" is a question
with a boolean answer.
- **12 is a scope decision, not a defect, and it is done.** 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`. 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.
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. Over a 4x range in actor count the step grew 3.5x and the
all-pairs control grew 15.6x, **which is the n2 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.
## Memory checking
`cmake --build build --target memcheck` runs **the suites that already exist** under
valgrind -- `ctest -T memcheck` with the headless drivers forced, wrapped by
`scripts/memcheck.sh` so that a finding is an exit status rather than a line in a log
nobody reads. **There are no memory-check test programs, and there should never be
any**: `tests/benchutil.h` notices that it is running under valgrind and divides every
benchmark's iteration count by two thousand, which turns the perf suites into the
broadest path coverage in the tree at a cost valgrind can survive. The whole run is
about thirty seconds.
**The two halves fit together on purpose.** A benchmark is a program that walks one
path a hundred thousand times; a leak check wants every path walked once. Same
binaries, same registration, one flag apart.
Third-party findings are suppressed in `scripts/valgrind.supp`, and the run forces
`SDL_VIDEO_DRIVER=dummy` / `SDL_RENDER_DRIVER=software` / `SDL_AUDIO_DRIVER=dummy` so
the vendor GPU stack is never loaded -- that removes thousands of unfixable findings
inside `amdgpu_dri.so` **without suppressing anything at all.** Only *definite* losses
and invalid accesses are counted.
### Defects the memory checker found
**All six are fixed**, and the CI job that runs memcheck gates: the next one of these
fails the build on the push that introduces it. Every size below is measured.
1. **Every JSON loader leaked its parsed document.** Four `json_load_file` calls in
`src/` and not one `json_decref` anywhere in the library:
| Loader | Site | Leaked per call |
|---|---|---:|
| `akgl_sprite_load_json` | `src/sprite.c:140` | ~1,500 bytes |
| `akgl_character_load_json` | `src/character.c:232` | ~2,150 bytes |
| `akgl_tilemap_load` | `src/tilemap.c:693` | ~9,000 bytes (2x2 fixture map) |
| `akgl_registry_load_properties` | `src/registry.c:134` | not exercised by any test |
Blast radius: every asset load, forever. A 128x128 map leaks on the order of a
megabyte per load, and **this is the one item in this list that grew without
bound.** `akgl_registry_load_properties` needed its loop moved inside the `ATTEMPT`
block first: `props` is a borrowed reference into the document and was read after
the block ended. **The test is the memcheck run** -- nothing in the public API can
observe a jansson refcount, and inventing a hook to prove it would be testing the
test.
2. **`akgl_get_property` read up to 4 KiB past the end of the property value.** It
copied a fixed `AKGL_MAX_STRING_LENGTH` bytes out of whatever
`SDL_GetStringProperty` returns, and what it returns is an `SDL_strdup` of the
value -- four bytes for `"0.0"`. Valgrind reported an invalid read on **every
call**. This had been in `registry.h` as a `@note` about the copy being "a fixed
#AKGL_MAX_STRING_LENGTH bytes rather than the length" -- **filed as waste. It was
not waste, it was an out-of-bounds read**, and on a value that lands at the end of
a page it is a segfault in a getter.
3. **The savegame name tables read past the end of every registry key, and wrote what
they found into the file.** Two consequences, and the second is the interesting
one: the read can fault if the key sits at the end of a page, **and whatever it
reads goes into the save file, so a savegame contained up to 500 bytes of this
process's heap per registered object.** That is a file a player might send someone.
All four iterators write through `write_name_field` now, and a negative-array-size
typedef fails the build if any of the four widths ever outgrows the staging buffer.
4. **`akgl_controller_list_keyboards` leaked the array SDL gives it.** One `SDL_free`,
in a `CLEANUP` block so a failure inside the loop cannot take the array with it.
The same question was asked of every other SDL enumeration in `src/controller.c`;
`SDL_GetGamepads` and `SDL_GetGamepadMapping` are freed too.
5. **A font, once loaded, was never freed and could not be.** 10,523 bytes per font.
**It was a gap in the API rather than only a leak**: a game that switches fonts
between scenes, or a tool like `charviewer` that reloads one while the user picks a
size, had no way to give the old one back. `akgl_text_unloadfont` is a new public
symbol; `akgl_text_loadfont` calls it when it replaces a live name, **but only
after the new font has opened**, so a failed reload leaves the caller with the font
they already had. What is still missing is a teardown that calls it -- #15.
6. **Vendored `deps/semver`'s own unit test leaks 188 bytes.** Not libakgl's code and
not libakgl's to fix. **Suppressed**, which is what this list said the answer would
be if it ever became noise, and gating the job made it noise. The two entries in
`scripts/valgrind.supp` name the functions rather than the file, so a rewrite of
those cases stops being suppressed and comes back as a finding. **They are the only
entries there that hide a real leak in a program this build runs**; the alternative
was a fork of a vendored dependency.
### Not defects, and why
- **`tests/util.c` fixtures were reading uninitialised stack floats.** Three
null-pointer tests declared `SDL_FRect` and `point` fixtures without initializing
them, which is sixteen "conditional jump depends on uninitialised value" findings
for a test that is not about coordinates. The library was never at fault; **a memory
checker that reports noise gets ignored**, which is the only reason this was worth
touching.
- **"Still reachable" at exit is not counted.** SDL's global state, the hint table,
the property registry and FreeType's library instance are one per process and are
reclaimed by `SDL_Quit` / `TTF_Quit`. Counting them would bury the six findings
above under a hundred that mean nothing.
- **The GPU driver's findings are avoided rather than suppressed.** Running the suite
against the real driver produces thousands of findings inside `amdgpu_dri.so`;
running it headless produces none, and headless is what the suites are written for.
### Found while embedding libakgl in a consumer
**Shadowing `add_test()` unconditionally broke every embedding consumer.** Fixed in
the same release it was introduced in, and kept because the CMake behaviour behind it
is not obvious and will catch the next person.
**CMake chains command overrides exactly one level deep.** Overriding `add_test`
makes the builtin available as `_add_test`; overriding it a *second* time rebinds
`_add_test` to the *first* override and the builtin becomes unreachable to everybody,
permanently -- there is no `__add_test`. Verified directly rather than inferred from
the documentation:
```cmake
function(add_test) _add_test(${ARGV}) endfunction() # _add_test -> builtin
function(add_test) _add_test(${ARGV}) endfunction() # _add_test -> the first override
if(COMMAND __add_test) ... endif() # absent
```
So two projects in one tree cannot both shadow it. `akbasic` shadows it first -- it
has to, for `libakerror` and `libakstdlib`, which register their tests
unconditionally -- and its own registrations then recursed until CMake stopped at
"Maximum recursion depth of 1000 exceeded". **A consumer had no way to work around
it**: once the builtin is gone it is gone for that project too.
The override is guarded on being top-level again. An embedded `libakgl` leaves
`add_test` alone and lets its consumer suppress what it does not want -- machinery
the consumer has to have anyway.
## Build notes
The vendored SDL satellite libraries are built into per-project subdirectories that
are not on the loader's default search path, and `LD_LIBRARY_PATH` is searched ahead
of RPATH -- so a developer who had previously run `rebuild.sh` had their installed
`libakgl.so` shadow the one under test, and a developer who had not saw every test
abort before `main()` with "cannot open shared object file". `CMakeLists.txt` sets
`BUILD_RPATH` on the library, the utility and every test target, and prepends the
build tree to `LD_LIBRARY_PATH` for the CTest run.
## API gaps that blocked akbasic
`akbasic` (the C port of the BASIC interpreter,
`source.starfort.tech/andrew/akbasic`) links `libakgl` as a scripting engine for game
authors. **All ten items are resolved**, and its `libakgl`-backed text sink, graphics
backend, sound backend and input backend are written and tested.
Decisions worth keeping, because each was a fork in the road rather than an
implementation detail:
- **Text measurement.** `akgl_text_measure` and `_measure_wrapped` sit over
`TTF_GetStringSize`/`TTF_GetStringSizeWrapped`. A negative `wraplength` is refused
with `AKERR_OUTOFBOUNDS` rather than passed through, **because SDL_ttf reads it as a
very large unsigned width and silently stops wrapping.** The test font is
monospaced on purpose: it lets the suite assert `width("AAAA") == 4 * width("A")`
instead of hardcoding glyph metrics that FreeType is free to round differently.
- **Drawing: colour is an argument, not state.** There is no current-colour global to
get out of step with the caller's own. Each call saves and restores the renderer's
draw colour, so drawing a line does not change what the host's next
`SDL_RenderClear` paints. The flood fill reads the target back, fills on the CPU,
and blits only the bounding box of what changed, keeping a fixed
`AKGL_DRAW_MAX_FLOOD_SPANS` (4096) stack of horizontal runs at file scope rather
than recursing per pixel. **It is therefore not reentrant -- neither is anything
else that draws to a single `SDL_Renderer`.**
- **Audio: the voice table works with no device open.** That is not only for
embedding -- **it is what makes the suite deterministic.** A device pulls samples on
SDL's audio thread whenever it likes, so a test that opened one and then asserted on
voice state would be racing the callback. Phase is derived from the frame counter
rather than accumulated, because a float increment of `hz / 44100` is not exact and
adding it 44100 times a second walks a held note off pitch. **A voice that was never
configured is audible**: a zeroed voice has a sustain of 0.0, which is silence with
no error to explain it, so the table defaults to a square wave at full level. Three
voices summing past full scale are clamped, not scaled, so one voice plays at the
level it was asked for rather than a third of it.
- **The sweep steps once every 1/60 second** (`AKGL_AUDIO_SWEEP_TICK_HZ`), because
that is the rate the machine this vocabulary comes from advanced its sweep at. It
divides 44100 exactly, so a step boundary always lands on a whole frame -- which is
what lets the suite assert the exact frame the pitch moves on rather than a
tolerance. Direction comes from the two frequencies, not from the sign of the step.
**A swept voice accumulates its phase** instead of deriving it: deriving assumes a
constant frequency, and under a sweep it jumps the waveform at every step, which is
an audible click.
- **Keystrokes: the ring records every `SDL_EVENT_KEY_DOWN` *before* the control-map
scan**, so a key bound to an actor still reaches a polling caller -- the scan returns
as soon as a binding claims the event, and doing it afterwards would have lost
exactly the keys a game also acts on. A full buffer drops the newest key rather than
overwriting the oldest, matching the Commodore keyboard buffer. **The composed text
is attached to the press that is still waiting for it**, tracked by a flag rather
than by "whatever entry is newest": without the flag, a press dropped by a full
buffer hands its text to some older key sitting at the end of the ring -- wrong in
exactly the case where things are already going badly.
- **`akgl_render_2d_bind` deliberately does not touch `self->sdl_renderer`**, which is
the whole point -- a host that already owns one keeps it, and a host that does not
gets a backend whose entry points all report `AKERR_NULLPOINTER` instead of
crashing.
- **Vendored dependencies are added on both paths now**, guarded by
`if(NOT TARGET ...)`, through an `akgl_add_vendored_dependency(<target> <dir>)`
macro -- a macro rather than a function because `add_subdirectory()` inside a
function runs in that function's variable scope. Verified by configuring and
building a scratch consumer with `CMAKE_DISABLE_FIND_PACKAGE_*` set for all seven
dependencies, so any reliance on an installed copy would have failed the configure.
- **`tests/headers.c` is a whole suite for one `#include` on purpose**: a second
`#include` after the first proves nothing about the second, because by then the
first has dragged its dependencies in.
Still missing for a complete BASIC vocabulary, and filed: the oscillating `SOUND`
direction and `FILTER` (#58). `TEMPO` and the `PLAY` note-string parser belong in the
interpreter rather than here.
## `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. 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.
## Why libakstdlib's collections are not used
`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.
The wrappers that were adopted, and why each was worth it: `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. Whether to adopt the pure-arithmetic
wrappers is #19.
## Why `deps/tg` is still in the tree
`deps/tg` is pinned at v0.7.9 and nothing 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 serves no purpose but export, and no predicate reads it), and
**its predicates return `bool`** -- resolving a collision needs a minimum translation
vector, which `tg_geom_intersects` cannot supply.
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.
**That is a deadline, not a permanent exemption** -- #55, with #75 as the consumer
that would justify it.
## The ccd arena
`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`. **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.
`akgl_ccd_arena_highwater()` is public so a game can assert its own ceiling. The
threading constraint that follows from a single arena is #57.

View File

@@ -1,27 +0,0 @@
Rendering should move to the SDL GPU renderer so i can do lighting and particles etc
- Example suitable for my most primitive use case: https://github.com/TheSpydog/SDL_gpu_examples/blob/main/Examples/Blit2DArray.c
- Try vulkan and D3D tutorials to come up to speed on the moving pieces, then figure ou the details from the examples and API docs
- https://vulkan-tutorial.com/Drawing_a_triangle/Graphics_pipeline_basics/Introduction
- https://learn.microsoft.com/en-us/previous-versions/windows/desktop/bb153302(v=vs.85)
- http://www.directxtutorial.com/LessonList.aspx?listid=112
- Shaders compiled with: https://github.com/libsdl-org/SDL_shadercross. There are no docs on this tool.
- Shader language is tricky. MS is abandoning HLSL and everyone unifying on Spir-V. But Spir-V is intermediate, HLSL is more high level so easier to pick up.
- https://flathub.org/apps/org.shadered.SHADERed
- Really good HLSL tutorials: https://www.youtube.com/playlist?list=PL78XDi0TS4lEMvytsE_MoWEpzBcukXv9b
- Spir-V tutorials: https://github.com/google/spirv-tutor
Actors should collide with each other
- Box2D is going to be overkill for this for simpler platforms. We're going to benefit from a simpler approach.
- https://katyscode.wordpress.com/2013/01/18/2d-platform-games-collision-detection-for-dummies/ looks promising
World collision geometry should be loaded from object layers on the map
Actors should collide with the world
Actors should be able to follow a path defined with a polyline on a layer on the map
Actors should be able to calculate their facing state automatically depending on their movement state (for path following)
Maps should be able to have image layers
Map parallax should work

1
deps/clay vendored Submodule

Submodule deps/clay added at b25a31c1a1

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 issue in the tracker.
| 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 as issue #17, 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.

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

@@ -0,0 +1,274 @@
# 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 seven 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 */
#define AKGL_ERR_UI (AKGL_ERR_BASE + 6) /**< The UI subsystem refused, or clay reported a layout error; the message says which */
```
**`AKGL_ERR_LIMIT` is not a status code.** It is `AKGL_ERR_BASE + 7`, 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 an eighth 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_ERR_UI` | 262 | The UI subsystem refused, or clay reported a layout error | `akgl_ui_*` — lifecycle misuse (init twice, a widget outside the frame bracket), an arena or table at its ceiling, and layout errors surfacing from `akgl_ui_frame_end` | The message says which; the ceilings are in [Chapter 23](23-appendix-limits.md). See [Chapter 22](22-ui.md) |
`akgl_error_init` reserves the band all-or-nothing and registers a name for each of the
seven: `"SDL Error"`, `"Registry Error"`, `"Heap Error"`, `"Behavior Error"`,
`"Logic Interrupt"`, `"Collision Error"`, `"UI 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 23](23-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` 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 23](23-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 (issue #14; also the `@warning` on
`heap.h` itself).
| Acquire | Takes the reference? | Who takes it |
|---|---|---|
| `akgl_heap_next_string` | **Yes** — `refcount` is 1 on return | the acquire itself |
| `akgl_heap_next_actor` | No | `akgl_actor_initialize` |
| `akgl_heap_next_sprite` | No | `akgl_sprite_initialize` |
| `akgl_heap_next_spritesheet` | No | `akgl_spritesheet_initialize` |
| `akgl_heap_next_character` | No | `akgl_character_initialize` |
**Until a reference is taken, the slot is still free and the next acquire hands out the
same pointer.** Seven of the eight acquires therefore leave a window in which two callers can
be holding the same object, and the window closes only when `*_initialize` runs. In
practice you never see it, because the four are always immediately followed by their
initializer — which is exactly why the asymmetry has survived. Write them adjacent and
nothing can get in between:
```c
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/actor.h>
#include <akgl/heap.h>
/* Claim an actor slot and take the reference on it. */
akerr_ErrorContext AKERR_NOIGNORE *spawn_actor(akgl_Actor **dest, char *name)
{
akgl_Actor *obj = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
FAIL_ZERO_RETURN(errctx, name, AKERR_NULLPOINTER, "NULL name");
PASS(errctx, akgl_heap_next_actor(&obj));
/* obj->refcount is still 0. The slot is not yours yet. */
PASS(errctx, akgl_actor_initialize(obj, name));
/* obj->refcount is 1 now, and the actor is in AKGL_REGISTRY_ACTOR. */
*dest = obj;
SUCCEED_RETURN(errctx);
}
```
You do not have to take that on trust. This program is compiled, linked against the library
and run by the test suite, and the output below it is what it actually prints:
```c run=akglapp
akgl_Actor *first = NULL;
akgl_Actor *second = NULL;
akgl_String *str = NULL;
/* A string acquire takes the reference for you. */
PASS(errctx, akgl_heap_next_string(&str));
printf("string refcount after acquire: %d\n", str->refcount);
/* An actor acquire does not. */
PASS(errctx, akgl_heap_next_actor(&first));
printf("actor refcount after acquire: %d\n", first->refcount);
/* So a second acquire, before the initializer runs, finds the same free slot. */
PASS(errctx, akgl_heap_next_actor(&second));
printf("same slot handed out twice: %s\n", (first == second) ? "yes" : "no");
/* Initializing is what takes the reference and closes the window. */
PASS(errctx, akgl_actor_initialize(first, "player"));
printf("actor refcount after init: %d\n", first->refcount);
PASS(errctx, akgl_heap_next_actor(&second));
printf("now a distinct slot: %s\n", (first == second) ? "no" : "yes");
PASS(errctx, akgl_heap_release_actor(first));
PASS(errctx, akgl_heap_release_string(str));
```
```output
string refcount after acquire: 1
actor refcount after acquire: 0
same slot handed out twice: yes
actor refcount after init: 1
now a distinct slot: yes
```
Note also that a failure between the two — `akgl_actor_initialize` rejecting the name, say
— needs no cleanup, precisely because nothing was claimed. That is the one thing the
asymmetry is good for.
The string layer is the opposite and is safe on its own, which is what makes the
scratch-buffer idiom below work.
### What each release actually does
They all decrement, and they all do the teardown when the count reaches zero. What the
teardown *is* differs, and two of them reach outside the pool:
- **`akgl_heap_release_actor`** releases every non-`NULL` child recursively, clears the
actor's entry from `AKGL_REGISTRY_ACTOR`, and zeroes the slot. The recursion has **no
cycle check**: an actor reachable from its own child list recurses until the stack runs
out.
- **`akgl_heap_release_sprite`** clears the `AKGL_REGISTRY_SPRITE` entry and zeroes the
slot. **It does not release the spritesheet** — the sprite only borrowed it.
- **`akgl_heap_release_spritesheet`** clears the registry entry, **destroys the
`SDL_Texture`**, and zeroes the slot. Any sprite still pointing at the sheet is left with
a dangling pointer, since nothing takes a reference on a sheet on a sprite's behalf.
Destroying a texture is a main-thread operation in SDL, so this must be called from the
thread that owns the renderer.
- **`akgl_heap_release_character`** walks the state-to-sprite map with
`akgl_character_state_sprites_iterate` and `AKGL_ITERATOR_OP_RELEASE`, giving back every
reference `akgl_character_sprite_add` took, then destroys the `SDL_PropertiesID` holding
the map, clears the registry entry, and zeroes the slot.
- **`akgl_heap_release_string`** zeroes the character data. Strings are not registered
anywhere, so that is all there is to it.
One shared hazard: **a slot whose `refcount` is already 0 is re-torn-down rather than
rejected.** Harmless on a zeroed slot, destructive on a live one that was never registered.
`akgl_heap_release_string` is the exception to the library's usual `NULL`-is-a-no-op
convention: a `NULL` here is `AKERR_NULLPOINTER`, which is why every `CLEANUP` block in the
library wraps it in `IGNORE()`.
## `akgl_String`
A pooled string is a `refcount` and a `PATH_MAX` buffer:
```c excerpt=include/akgl/staticstring.h
/** @brief Provides a fixed-capacity, heap-managed string buffer. */
typedef struct
{
int refcount; /**< Pool bookkeeping; 0 means the slot is free. Owned by the heap layer. */
char data[AKGL_MAX_STRING_LENGTH]; /**< The characters. Not guaranteed NUL-terminated when filled to capacity. */
} akgl_String;
```
Capacity is fixed. `akgl_string_initialize` and `akgl_string_copy` **truncate rather than
grow, and the truncation is silent** — they both always NUL-terminate, but they do not tell
you that something was lost. If you need to know, measure first, or go through
`aksl_strncpy` with the full destination size and let it raise `AKERR_OUTOFBOUNDS`.
Two details that catch people:
- **`akgl_heap_next_string` does not clean the buffer.** The contents are whatever the last
holder left. Call `akgl_string_initialize(str, NULL)` if you need it clean — and note
that `akgl_get_property` only copies the value and its terminator, leaving the rest of
the buffer alone, so read the result as a C string rather than as
`AKGL_MAX_STRING_LENGTH` bytes.
- **Several functions claim one for you.** `akgl_get_property`,
`akgl_get_json_string_value` and `akgl_get_json_array_index_string` all claim a slot when
`*dest` is `NULL`, and write in place when it is not. Either way **the caller releases
it.**
The scratch-buffer idiom is claim, use, release unconditionally in `CLEANUP`:
```c
#include <akerror.h>
#include <akstdlib.h>
#include <akgl/error.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/staticstring.h>
/* The scratch-string idiom: claim, use, release unconditionally in CLEANUP. */
akerr_ErrorContext AKERR_NOIGNORE *screen_width(int *dest)
{
akgl_String *value = NULL;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL destination");
ATTEMPT {
CATCH(errctx, akgl_heap_next_string(&value));
CATCH(errctx, akgl_get_property("game.screenwidth", &value, "800"));
CATCH(errctx, aksl_atoi(value->data, dest));
} CLEANUP {
IGNORE(akgl_heap_release_string(value));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
```
The `CLEANUP` block runs on every path out of the `ATTEMPT`, which is the whole reason the
release goes there and not after the last `CATCH`. **Never use a `*_RETURN` macro inside
that `ATTEMPT`** — it returns past `CLEANUP` and leaks the slot. That mistake has shipped
in this library before.
## Reading `AKGL_ERR_HEAP`
When a pool is exhausted the message names the layer:
```text
Unable to find unused string on the heap
```
Before you raise the ceiling, look for the missing release. The realistic causes, in the
order they actually happen:
1. **A string claimed and never released.** By far the most common: the pool is 256 slots
and every `akgl_get_property` call can claim one. A per-frame read that forgets the
release exhausts it in four seconds at 60 Hz.
2. **A `*_RETURN` macro inside an `ATTEMPT`**, skipping the `CLEANUP` that held the
release.
3. **A `return` out of a `HANDLE` block.** That leaks a libakerror context slot rather than
an akgl one, and shows up as `"Unable to pull an error context from the array!"` on the
129th call — different pool, same shape of bug.
4. **Characters reloaded per level without releasing the previous ones**, which exhausts
the *sprite* pool rather than the character pool, since each character holds a reference
per bound state.
Only after all four are ruled out is the ceiling actually too small — and a game that
genuinely needs 256 actors should say so once, for the whole build, per the ABI note above.
## Initialization and reset
`akgl_heap_init` zeroes all eight pools. `akgl_game_init` calls it for you, before any
registry exists.
**Calling it again is a reset, not a refresh.** It does not release textures, clear
registries, or consult reference counts, so every live object becomes a dangling pointer
and every registry entry points at a zeroed slot.
`akgl_heap_init_actor` zeroes the actor pool only, which is what makes it usable between
levels: the sprites, sheets and characters are the expensive ones because they own
textures. Pair it with `akgl_registry_init_actor`, the one registry initializer that
destroys the old property set before creating the replacement. See
[Chapter 6](06-the-registry.md).
Both return `NULL` and have no failure path today — they are a series of `memset`s. Check
them anyway; the signature exists so a layer that needs real setup has somewhere to report
from.
## If you need a new kind of object
Add a layer here. Do not reach for the allocator: `AGENTS.md` makes that a standing rule,
and a `malloc` in the middle of a frame is the failure mode the pools exist to remove.
A layer is an array, an `AKGL_MAX_HEAP_*` ceiling, a `next`, and a `release` — five things,
all visible in `src/heap.c`.
## Where to go next
- [Chapter 4](04-errors.md) — `AKGL_ERR_HEAP` in the status tables.
- [Chapter 6](06-the-registry.md) — the registries the releases clear entries from.
- [Chapter 23](23-appendix-limits.md) — every `AKGL_MAX_*` in one place.

226
docs/06-the-registry.md Normal file
View File

@@ -0,0 +1,226 @@
# 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`,
"`AKGL_ACTOR_STATE_STRING_NAMES`"). 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`, "Formatting and hygiene"). 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 issue #54, and it is not fixed yet 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 issue 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 23](23-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`,
"Defects") — 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 23](23-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 (issue #26).** 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 as issue #54; 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. Issue #14. 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; issue #13.
- **`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 issue #54, 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 (issue #30).** 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. That is issue #38;
issue #54 covers the related truncation collision.
- **`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 —
issues #29 through #32. [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 23](23-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
its issue.
**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`,
"Defects"). 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 as issue #31.
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 four are issues #29 through #32.
| 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 issue #62.
- **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`, "Defects", records the fix.
- **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 it is
issue #22.
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` records 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. Issue #16; 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`
> recorded the same thing as open. **Both were 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` 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

396
docs/22-ui.md Normal file
View File

@@ -0,0 +1,396 @@
# 22. User interfaces
![The UI demo's play screen: a LIVES label top-left, a SCORE label top-right, and a dialog panel across the bottom, over a checkerboard standing in for a game world](images/uidemo.png)
Every game eventually wants a dialog box, a score counter, or a menu — and hand-rolling
them out of rectangles and `akgl_text_rendertextat` is tolerable exactly once.
[Chapter 21](21-tutorial-jrpg.md) does it once, on purpose, and its 125-line
`textbox.c` is still the right call for one panel with no ambitions. This chapter is for
everything past that point: HUDs, menus, options screens, and the layout arithmetic that
makes them miserable to maintain by hand.
The layout engine is [clay](https://github.com/nicbarker/clay), vendored under
`deps/clay` and compiled into `libakgl.so`. Per this manual's rule, clay's own API is
documented by clay — its README is thorough — and this chapter covers what libakgl adds
or constrains. The split:
| clay owns | libakgl owns |
|---|---|
| The layout algorithm and the `CLAY()` declaration DSL | The arena clay allocates from — static storage, no `malloc`, sized by `AKGL_UI_ARENA_BYTES` |
| Sizing, padding, floating elements, scroll containers | Text measurement, through SDL_ttf and the font registry |
| The render command list each frame produces | Drawing those commands, through the render backend |
| Hover and pointer-over queries | Feeding it the mouse, and telling *you* which events the UI consumed |
**There are two ways in, and they compose.** The widget helpers — `akgl_ui_dialog`,
`akgl_ui_label`, `akgl_ui_menu` — cover the common cases in one call each, and a simple
game never touches a `CLAY()` macro. When a screen outgrows them, you write clay's
declarative blocks yourself between the same two frame calls, with the whole DSL
available. The demo this chapter quotes, [`examples/uidemo`](../examples/uidemo), does
both: its title menu and HUD are widgets, its options screen is raw `CLAY()`.
**What this chapter assumes.** A program that already brings libakgl up — the
`akgl_game_init` startup order from [Chapter 7](07-the-game-and-the-frame.md) (or
[Chapter 3](03-getting-started.md)'s smallest window), plus one loaded font from
[Chapter 17](17-text-and-fonts.md). None of that is restated here, and the UI adds only
three calls to it, shown below. When something in *your* bring-up fights you, the demo's
`startup()` in [`examples/uidemo/uidemo.c`](../examples/uidemo/uidemo.c) is the complete
working sequence to diff against — including the `main()` shape, which the runnable
snippets in this manual deliberately hide.
Like collision, the subsystem is optional at runtime rather than at build time: it is
always compiled in, costs static storage until `akgl_ui_init` runs, and a game that
never calls that pays nothing else.
**One warning before any code.** libakgl ships clay inside `libakgl.so` and exports its
symbols, because the `CLAY()` macros in *your* translation units expand to calls into
them. Do not define `CLAY_IMPLEMENTATION` anywhere and do not link a second copy of clay
— two definitions of the same symbols, and the loader picks one silently.
## Bring it up, lay something out
`akgl_ui_init(width, height)` takes the layout size as parameters — deliberately not
read from the camera or the window, so a headless program can bring the UI up with no
renderer at all. It bounds clay to the `AKGL_UI_*` ceilings, checks the arena fits them,
and refuses **with both byte counts in the message** when it does not: a raised ceiling
without a raised arena is a loud startup failure, never a corruption at frame forty
thousand.
After that, a frame of UI is a bracket with declarations inside:
```c run=akglapp
PASS(errctx, akgl_ui_init(320, 240));
PASS(errctx, akgl_ui_frame_begin());
CLAY({
.id = CLAY_ID("panel"),
.layout = {
.sizing = {
.width = CLAY_SIZING_FIXED(120),
.height = CLAY_SIZING_FIXED(40)
}
},
.backgroundColor = { 24, 20, 37, 255 }
}) {}
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
PASS(errctx, akgl_ui_shutdown());
printf("one panel, laid out and drawn\n");
```
```output
one panel, laid out and drawn
```
`frame_begin` starts the clay layout and feeds it the pointer state the event handler
has been accumulating; `frame_end` computes the layout and draws every render command it
produces through the backend — fills and rounded fills, borders, clip rectangles, text,
and sprites. Everything lands in screen coordinates on top of whatever is already on the
target.
Text needs one more step at startup: fonts. clay names a font by a `uint16_t` fontId;
libakgl names one by a registry key ([Chapter 17](17-text-and-fonts.md)). The bridge is
`akgl_ui_font_register`, and the demo's whole font story is three lines:
```c excerpt=examples/uidemo/uidemo.c
PASS(errctx, akgl_text_loadfont(UIDEMO_FONT_NAME, UIDEMO_FONT_FILE, UIDEMO_FONT_SIZE));
PASS(errctx, akgl_ui_init(UIDEMO_WIDTH, UIDEMO_HEIGHT));
PASS(errctx, akgl_ui_font_register(UIDEMO_FONT_NAME, &fontid));
```
The first font registered gets id 0, which is what the widgets' default style uses — so
a one-font game never mentions a fontId again. Two things worth knowing before they
surprise you:
- **`Clay_TextElementConfig.fontSize` is ignored.** A libakgl font bakes its size in at
load ([Chapter 17](17-text-and-fonts.md) explains why); the size text renders at is
the size the font behind its id was loaded at. One face at two sizes is two loads, two
registrations, two ids.
- The table stores the *name* and resolves it per use, so `akgl_text_unloadfont` on a
registered font makes the next frame fail loudly with the name in the message — not
dangle.
## The frame contract
Where the bracket goes in a real frame, from the demo — compare the JRPG's `frame()`,
which calls `akgl_game_update` where this program paints a checkerboard:
```c excerpt=examples/uidemo/uidemo.c
PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
if ( state == UIDEMO_STATE_PLAY ) {
PASS(errctx, akgl_draw_background(akgl_renderer, UIDEMO_WIDTH, UIDEMO_HEIGHT));
score += 1;
}
PASS(errctx, akgl_ui_frame_begin());
switch ( state ) {
case UIDEMO_STATE_TITLE:
PASS(errctx, declare_title());
break;
case UIDEMO_STATE_OPTIONS:
PASS(errctx, declare_options());
break;
case UIDEMO_STATE_PLAY:
PASS(errctx, declare_play());
break;
default:
break;
}
PASS(errctx, akgl_ui_frame_end(akgl_renderer));
```
The UI draws after the world because it is declared after the world — the overlay slot
between `akgl_game_update` and the backend's `frame_end` is exactly where the JRPG drew
its hand-rolled text box, and nothing about that slot changed.
Events go through the UI *first*. `akgl_ui_handle_event` takes every event
unconditionally — the same pass-everything contract as `akgl_controller_handle_event` —
and reports back whether the UI claimed it, so a click on a menu never leaks through and
also fires a game control:
```c excerpt=examples/uidemo/uidemo.c
PASS(errctx, akgl_ui_handle_event((void *)&akgl_game.state, event, &consumed));
if ( consumed ) {
SUCCEED_RETURN(errctx);
}
switch ( state ) {
case UIDEMO_STATE_TITLE:
PASS(errctx, akgl_ui_menu_handle_event(&title_menu, event, &consumed));
break;
```
Three rules govern what gets consumed, and each is a decision worth stating:
- **Presses, releases and the wheel are consumed when the pointer is over any UI
element.** Motion and resizes never are — the game may care where the mouse is, and
certainly cares about its window.
- **The hit test runs against the layout the previous frame declared**, because this
frame's does not exist while events are being polled. clay retains the last tree for
exactly this purpose; one frame of staleness is the standard model's accepted cost,
and before any frame has been laid out, nothing is over anything.
- **Keyboard events are never consumed by the UI itself.** Which menu hears the arrow
keys is something the application declares — the `switch` above *is* the focus model —
not something a pointer position implies.
## A dialog in one call — and what it replaces
The play screen's declarations, whole:
```c excerpt=examples/uidemo/uidemo.c
PASS(errctx, aksl_snprintf(&count, scoretext, sizeof(scoretext), "SCORE %05d", score));
PASS(errctx, aksl_snprintf(&count, livestext, sizeof(livestext), "LIVES %d", lives));
PASS(errctx, akgl_ui_label("score", scoretext, AKGL_UI_ANCHOR_TOP_RIGHT, NULL));
PASS(errctx, akgl_ui_label("lives", livestext, AKGL_UI_ANCHOR_TOP_LEFT, NULL));
if ( dialog_open ) {
PASS(errctx, akgl_ui_dialog("dialog",
"This panel is one call. Space dismisses it; "
"compare examples/jrpg/textbox.c.",
NULL));
}
```
Note what is absent: no `visible` flag, no draw call, no geometry. The dialog is open
because this frame declares it — a declarative frame *is* the flag. The text buffers are
`static` because clay borrows the pointer until `frame_end` rather than copying; format
your score into storage that outlives the bracket.
Now the same panel the way [Chapter 21](21-tutorial-jrpg.md) builds it, which is the
comparison this chapter owes you. The hand-rolled version keeps a flag and a copy of the
string, and its draw function does the geometry itself:
```c excerpt=examples/jrpg/textbox.c
panel.x = TEXTBOX_MARGIN;
panel.w = akgl_camera->w - (2.0f * TEXTBOX_MARGIN);
panel.h = TEXTBOX_HEIGHT;
panel.y = akgl_camera->h - TEXTBOX_MARGIN - panel.h;
PASS(errctx, akgl_draw_filled_rect(akgl_renderer, &panel, TEXTBOX_FILL));
PASS(errctx, akgl_draw_rect(akgl_renderer, &panel, TEXTBOX_EDGE));
PASS(errctx,
akgl_text_rendertextat(
font,
textbox_text,
TEXTBOX_INK,
(int)(panel.w - (2.0f * TEXTBOX_PADDING)),
(int)(panel.x + TEXTBOX_PADDING),
(int)(panel.y + TEXTBOX_PADDING)
));
```
The widget's default style is **deliberately that panel's palette** — near-black fill,
parchment edge and ink, 8 pixels of padding — so the two produce the same picture, and
the trade is visible with nothing hidden in a theme:
- `textbox.c` is 125 lines you own completely. It costs no new concepts, no arena, no
frame bracket; its geometry is four assignments you can read. For one panel, that is a
perfectly good deal — which is why chapter 21 still teaches it and its game still
ships it.
- `akgl_ui_dialog` is one line that costs you the subsystem: `akgl_ui_init`, a
registered font, and the bracket in your frame. The payment buys every *next* piece of
interface — the second panel is also one line, the score label is one line, the menu
is a struct — and the layout arithmetic, wrapping, and stacking are clay's problem
from then on.
Neither is deprecated. The library's own position: build one panel by hand; build an
interface on the subsystem.
## Menus: one selection, three devices
A menu is caller-owned state — a struct you keep, the way `textbox.c` keeps its statics.
There is no heap pool behind it because it owns no texture, no font, and no registry
entry:
```c excerpt=examples/uidemo/uidemo.c
static akgl_UiMenu title_menu = {
.id = "title",
.items = { "Start", "Options", "Quit" },
.count = 3,
};
```
Declaring it each frame is `akgl_ui_menu(&title_menu)`. Input reaches it two ways, and
they meet in the same struct: `akgl_ui_menu_handle_event` moves `selected` from the
arrow keys and D-pad (wrapping at both ends) and sets `activated` on Return or gamepad
South — exactly `SDLK_UP`, `SDLK_DOWN` and `SDLK_RETURN` against `SDL_Event.key.key`,
and `SDL_GAMEPAD_BUTTON_DPAD_UP`, `_DPAD_DOWN` and `_SOUTH` against
`SDL_Event.gbutton.button`. Keycodes, not scancodes — which only matters when you
*synthesize* events, as the demo's `--demo` script does: a hand-built key event must
fill in `key.key`, or it matches nothing and the menu silently ignores it. The mouse
selects by *moving onto* a row and activates by clicking one, during the declaration
itself. A stationary pointer claims nothing — parking the mouse over the
menu must not pin the selection against the keyboard, which would otherwise fight it
sixty times a second and lose.
`activated` latches until you clear it, so the check lives wherever reading it is
convenient — the demo reads it after the frame closes, which catches both input routes:
```c excerpt=examples/uidemo/uidemo.c
if ( title_menu.activated ) {
title_menu.activated = false;
switch ( title_menu.selected ) {
case 0:
state = UIDEMO_STATE_PLAY;
score = 0;
dialog_open = false;
break;
case 1:
state = UIDEMO_STATE_OPTIONS;
break;
case 2:
default:
running = false;
break;
}
}
```
## Writing layout directly with CLAY()
The options screen uses no widgets, and exists to show that the widgets are a
convenience rather than a boundary. One row of it carries every idea — hover styling
computed *inside* the declaration, and a click paired with a press edge the application
tracks itself:
```c excerpt=examples/uidemo/uidemo.c
CLAY({
.id = CLAY_ID("options-music"),
.layout = { .padding = { 8, 8, 4, 4 } },
.backgroundColor = Clay_Hovered()
? (Clay_Color){ 64, 58, 88, 255 }
: (Clay_Color){ 0, 0, 0, 0 }
}) {
if ( Clay_Hovered() && clicked ) {
opt_music = !opt_music;
}
CLAY_TEXT(((Clay_String){ .length = (int32_t)strlen(musicrow), .chars = musicrow }),
CLAY_TEXT_CONFIG({ .textColor = { 240, 236, 214, 255 }, .fontId = 0 }));
}
```
`clicked` is one application-owned `bool`: set when a left press arrives in the event
loop — *before* `akgl_ui_handle_event` can consume it, because a click on a UI row is
always consumed — and cleared at the end of the frame. The widgets keep an equivalent
edge internally; a raw screen keeps its own. Everything else — the sizing model, scroll
containers, floating attach points, aspect ratios — is clay's DSL, and clay's README
documents it far better than a restatement here would.
## Images and styles
An image on a UI element is a sprite: set `.image.imageData` in a `CLAY()` declaration
to an `akgl_Sprite *` ([Chapter 10](10-spritesheets-and-sprites.md)) and its first frame
is drawn stretched to the element's box. Two current limits, both deliberate scope
rather than accident: clay's image tint colour and `CUSTOM` render commands are skipped
by the executor, and per-corner radii collapse to the top-left value — the widgets only
produce uniform corners. The mouse cursor stays the operating system's; a game that
wants a themed cursor draws a floating image element at the pointer, which makes a good
exercise.
A style is one struct shared by all three widgets — colours, padding, corner radius,
fontId — and `NULL` means the textbox palette described above. There is no per-field
defaulting: a transparent fill and a zero radius are things a style legitimately says,
so copy the default and change what you mean to change:
```c wrap=akglbody
akgl_UiStyle alert = {
.fill = { 120, 24, 24, 235 },
.edge = { 240, 236, 214, 255 },
.ink = { 240, 236, 214, 255 },
.padding = 8.0f,
.corner_radius = 6.0f,
.fontid = 0
};
PASS(errctx, akgl_ui_dialog("alert", "The reactor is on fire.", &alert));
```
## When things go wrong
Everything here reports through the ordinary error protocol
([Chapter 4](04-errors.md)) under one status, `AKGL_ERR_UI`, and the message says which
refusal it was. Lifecycle misuse fails at the call that misused it:
```text
/home/andrew/source/libakgl/src/ui.c:ui_widget_ready:850: 262 (UI Error) : Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end
/home/andrew/source/libakgl/src/ui.c:akgl_ui_dialog:866
/home/andrew/source/libakgl/main.c:main:13
/home/andrew/source/libakgl/main.c:main:16: Unhandled Error 262 (UI Error): Widgets are declared between akgl_ui_frame_begin and akgl_ui_frame_end
```
Errors *inside the layout* cannot fail at the call that caused them, because clay
reports through a void callback with libakgl nowhere on the stack. So they are logged as
they happen, stashed, and raised from `akgl_ui_frame_end` — the earliest point the
protocol can carry them — with the first message and a count of the rest. A frame that
fails is one bad frame: the next `akgl_ui_frame_begin` is legal, and the executor clears
any clip rectangle on its way out so a failed UI cannot leave the next frame's *world*
clipped.
The ceilings — elements, measured words, arena bytes, fonts, text-run length, menu
entries — are all in [Chapter 23](23-appendix-limits.md), every one of them overridable,
and every refusal names the number to raise.
## Build it and run it
The demo builds with the library:
```sh norun
cmake -S . -B build
cmake --build build -j$(nproc)
./build/examples/uidemo/uidemo
```
Arrow keys, Return, the D-pad and the mouse all drive the title menu; Space opens the
dialog on the play screen; Escape backs out. `ctest -R example_uidemo` runs the same
program headless through a scripted tour of every screen.
## Where to look next
- [clay's README](https://github.com/nicbarker/clay) — the layout DSL itself: sizing,
floating elements, scroll containers, and the debug tools.
- [Chapter 16](16-input.md) — the control maps that own the keyboard and gamepad once
the UI has declined an event.
- [Chapter 17](17-text-and-fonts.md) — fonts, the registry, and what text costs per
frame; a UI panel of text pays the same immediate-mode price.
- [Chapter 21](21-tutorial-jrpg.md) — the hand-rolled text box this chapter keeps
comparing against, in the game that earns it.
- [Chapter 23](23-appendix-limits.md) — every `AKGL_UI_*` limit and the `ui.h` status
cross-reference.

475
docs/23-appendix-limits.md Normal file
View File

@@ -0,0 +1,475 @@
# 23. 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` |
| `akgl_draw_filled_rounded_rect` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` |
| `akgl_draw_arc` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS`, `AKGL_ERR_SDL` |
| `akgl_draw_set_clip` | `AKERR_NULLPOINTER`, `AKGL_ERR_SDL` — a `NULL` rectangle is legal here and clears the clip |
### `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` |
### `ui.h`
| Function | Raises |
|---|---|
| `akgl_ui_init` | `AKERR_OUTOFBOUNDS`, `AKGL_ERR_UI` — already initialized, or an arena clay's structures do not fit; the message carries both byte counts |
| `akgl_ui_shutdown` | *nothing — idempotent by design* |
| `akgl_ui_resize` | `AKGL_ERR_UI`, `AKERR_OUTOFBOUNDS` |
| `akgl_ui_font_register` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI`, `AKERR_OUTOFBOUNDS` past `AKGL_UI_FONT_NAME_LENGTH` |
| `akgl_ui_handle_event` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` from a resize event |
| `akgl_ui_frame_begin` | `AKGL_ERR_UI` — uninitialized, or a frame already open |
| `akgl_ui_frame_end` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI` — no frame open, clay layout errors, an over-long text run, an unresolvable fontId — plus whatever the draw primitives raise |
| `akgl_ui_dialog`, `_label`, `_menu` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI` outside the frame bracket; `_label` and `_menu` add `AKERR_OUTOFBOUNDS` |
| `akgl_ui_menu_handle_event` | `AKERR_NULLPOINTER`, `AKERR_OUTOFBOUNDS` |
| `akgl_ui_execute_commands` | `AKERR_NULLPOINTER`, `AKGL_ERR_UI`, plus whatever the draw primitives raise |
| `akgl_ui_measure_text` | *cannot report — clay's callback signature; failures stash and surface from `akgl_ui_frame_end`* |
### `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,
`AKGL_CCD_ARENA_BYTES` and the `AKGL_UI_*` family 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`, "Found while rewriting
the Doxygen comments").
**`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`, "Found while rewriting
the Doxygen comments"), and the header comment was not updated with them -- issue #63.
- **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.
`AKGL_DRAW_ROUNDED_RECT_SEGMENTS` is 16 — triangles per corner of
`akgl_draw_filled_rounded_rect`. `AKGL_DRAW_ARC_MAX_POINTS` is 64 — the vertex ceiling one
`akgl_draw_arc` spends, two per step along the curve; the step count scales with the swept
angle under it. Both size fixed arrays, so neither is a per-call cost and neither is
overridable.
### The UI
Every `AKGL_UI_*` limit is guarded by `#ifndef`, so a consumer may override any of them the
way the pool ceilings are overridden — for the whole build, before `<akgl/ui.h>` is seen.
`akgl_ui_init` checks that the arena still fits the element and word ceilings and refuses
with both byte counts in the message when it does not, so a raised ceiling without a raised
arena is a loud startup failure rather than a corruption.
| Constant | Value | Bounds |
|---|---|---|
| `AKGL_UI_MAX_ELEMENTS` | 1024 | Elements one layout may declare |
| `AKGL_UI_MAX_MEASURE_WORDS` | 4096 | Words clay's text-measurement cache holds |
| `AKGL_UI_ARENA_BYTES` | 1048576 | Static storage clay lays its structures out in (812544 needed at the defaults) |
| `AKGL_UI_MAX_FONTS` | 8 | fontId table slots |
| `AKGL_UI_FONT_NAME_LENGTH` | 64 | Bytes per fontId slot's registry name, terminator included |
| `AKGL_UI_MAX_TEXT_BYTES` | 1024 | Bytes one text render command's line may occupy |
| `AKGL_UI_MENU_MAX_ITEMS` | 16 | Entries one `akgl_UiMenu` may carry |
| `AKGL_UI_DIALOG_HEIGHT` | 56 | Height of the `akgl_ui_dialog` panel, in pixels |
### 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` | 7 |
| `AKGL_ERR_LIMIT` | 263 — **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.

69
docs/README.md Normal file
View File

@@ -0,0 +1,69 @@
# 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. User interfaces](22-ui.md)** | Menus, HUDs and dialogs on the clay layout engine: the widgets, the frame bracket, and writing `CLAY()` yourself |
| **[23. Appendix](23-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 its issue. 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

BIN
docs/images/uidemo.png Normal file

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
]
}

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