Files
libakgl/docs/18-utilities.md
Andrew Kesterson 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

21 KiB

18. Utilities

util.h, json_helpers.h and staticstring.h hold the pieces that belong to no subsystem: rectangle overlap, asset path resolution, typed JSON accessors, and the pooled string type everything else passes around. The JSON accessors are the valuable part of this chapter — their status semantics are what every asset loader in the library is written against, and they are documented nowhere else.

Collision helpers

Three functions, all axis-aligned, all in util.h:

Function Question it answers
akgl_rectangle_points(dest, rect) What are the four corners of this SDL_FRect?
akgl_collide_point_rectangle(p, r, collide) Is this point inside those corners?
akgl_collide_rectangles(r1, r2, collide) Do these two rectangles overlap?
#include <akerror.h>
#include <SDL3/SDL.h>
#include <akgl/util.h>

/* Do two actor boxes touch? Edges count as touching. */
akerr_ErrorContext *boxes_touch(SDL_FRect *a, SDL_FRect *b, bool *hit)
{
    PREPARE_ERROR(errctx);
    PASS(errctx, akgl_collide_rectangles(a, b, hit));
    SUCCEED_RETURN(errctx);
}

/* Is the mouse over a button? Corners first, then the point test. */
akerr_ErrorContext *point_in_box(int x, int y, SDL_FRect *box, bool *hit)
{
    akgl_RectanglePoints corners;
    akgl_Point p;

    PREPARE_ERROR(errctx);
    p.x = x;
    p.y = y;
    p.z = 0;
    PASS(errctx, akgl_rectangle_points(&corners, box));
    PASS(errctx, akgl_collide_point_rectangle(&p, &corners, hit));
    SUCCEED_RETURN(errctx);
}

Four properties, all deliberate:

Axis-aligned only. There is no rotation support and no separating-axis test. An actor drawn with a non-zero angle still collides as its unrotated box.

Edges count as touching. A point exactly on a boundary is inside; two rectangles sharing an edge and no area overlap. Both comparisons are >= and <=.

akgl_rectangle_points truncates from float to int. A rectangle at x = 10.9 has its corners at 10. That is right for tile-grid work and wrong for sub-pixel work; if you need the latter, do not round-trip through it. akgl_collide_rectangles no longer does — it compares the spans in float directly, so a sub-pixel overlap registers.

z is carried and never used. akgl_Point has one, akgl_rectangle_points never writes it, and neither collision test reads it. These are 2D tests.

The arrangement it 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:

          +---+                        +---+
          |   |                        |   |
      +---|---|---+                +---+---+---+     <- 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. <= rather than < because akgl_collide_point_rectangle is inclusive on all four edges and these two have always agreed: touching counts.

If you are upgrading from 0.7.x, two answers change. The cross now reports true, which is the point. And because the comparison no longer round-trips through akgl_Point's int members, overlaps and gaps smaller than a pixel are now seen rather than truncated away — so a pickup test that was accidentally forgiving by up to a pixel is no longer forgiving.

This is separate from the fact that nothing in the library calls these during a frame. akgl_physics_simulate never calls collide, and akgl_physics_arcade_collide raises AKERR_API — see Chapter 14. Collision detection is yours to run, and these are the helpers for running it.

Path resolution

Asset files name their neighbours relatively: a sprite definition names its spritesheet, a tilemap names its tilesets. "Relative" has to mean relative to the file doing the naming, not to wherever the game was launched from — and akgl_path_relative is what makes that true.

#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:

   akgl_path_relative(root, path, dest)
        |
        1. realpath(path)                  <- relative to the process CWD
        |     |
        |     +-- succeeds -> absolute result into dest, done
        |     |
        |     +-- ENOENT ---+
        |                   |
        2.                  +-> realpath(root + "/" + path)
                                  |
                                  +-- succeeds -> absolute result, done
                                  |
                                  +-- fails -> the errno propagates

Either way the result is absolute, with symlinks and .. folded out, because both branches end in realpath(3).

Three things to know:

  • dest must already be a claimed pool string. This writes into it; it does not claim one for you. root is required even on the path where it goes unused.
  • The fallback pastes root in front of path unconditionally, so an absolute path that does not exist gets root prepended and fails on a nonsense spelling. Do not send absolute paths you expect to fall through.
  • AKERR_OUTOFBOUNDS if root + "/" + path would not fit AKGL_MAX_STRING_LENGTH (PATH_MAX); ENOENT, or whatever else realpath(3) reports — EACCES, ELOOP, ENOTDIR — if neither spelling names an existing file; AKGL_ERR_HEAP if the string pool cannot supply the scratch buffers.

Stale defect note. util.h:124-128 warns that the fallback path "returns straight out of the ENOENT handler and so never releases the error context it was handling", costing one of libakerror's 128 context slots per call for the life of the process, and TODO.md item 15 records the same thing as open. Both are out of date. src/util.c:115-129 sets a flag in the HANDLE(errctx, ENOENT) block and calls path_relative_root after FINISH, which is exactly the fix that entry proposes, and the code carries a comment explaining why. The leak is gone; the two notes describing it are not.

The JSON accessors

Every asset format in this library is JSON, parsed by jansson, 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 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

#include <akerror.h>
#include <jansson.h>
#include <akgl/json_helpers.h>
#include <akgl/heap.h>
#include <akgl/staticstring.h>

/* A required key, an optional one with a default, and a borrowed sub-object. */
akerr_ErrorContext *read_sprite_header(json_t *doc)
{
    json_t *frames = NULL;          /* BORROWED. Never json_decref this. */
    akgl_String *name = NULL;       /* Copied, and pooled. Release it. */
    int width = 0;
    int width_default = 32;
    float32_t scale = 0.0f;

    PREPARE_ERROR(errctx);

    ATTEMPT {
	/* Required: an absent "name" is AKERR_KEY and propagates. */
	CATCH(errctx, akgl_get_json_string_value(doc, "name", &name));

	/* Optional: AKERR_KEY becomes width_default and a NULL return. */
	CATCH(errctx, akgl_get_json_with_default(
		  akgl_get_json_integer_value(doc, "width", &width),
		  &width_default,
		  &width,
		  sizeof(int)));

	/* Strict: "scale": 2 and "scale": 2.0 are both accepted here.
	   akgl_get_json_integer_value would refuse the second. */
	CATCH(errctx, akgl_get_json_number_value(doc, "scale", &scale));

	/* Borrowed: valid until the document is freed. */
	CATCH(errctx, akgl_get_json_array_value(doc, "frames", &frames));
	SDL_Log("%s: %d wide, %.1f scale, %d frames",
		name->data, width, scale, (int)json_array_size(frames));
    } CLEANUP {
	IGNORE(akgl_heap_release_string(name));
    } PROCESS(errctx) {
    } FINISH(errctx, true);

    SUCCEED_RETURN(errctx);
}

akgl_get_json_with_default — how optional is spelled

Run the accessor, hand its error context here along with the fallback. A status it handles becomes dest holding the default and a NULL return; anything else propagates untouched. When the error is consumed it is also released, so you must not release it again. A NULL e — the accessor succeeded — returns at once without touching dest.

It defaults on three statuses, and this is the current behaviour, checked against src/json_helpers.c:207-211:

Status Defaulted? Why
AKERR_KEY Yes The key is absent. That is what "optional" means for an object member
AKERR_OUTOFBOUNDS Yes The index is past the end. That is what "optional" means for an array element
AKERR_INDEX Yes Defensive only — nothing in libakgl raises it
AKERR_TYPE No Propagates, and that is the correct line to draw

The AKERR_TYPE exclusion is the interesting one. "The key is missing" and "the key is present but holds a string where a number belongs" are not the same situation. The first is an omitted setting and a default is exactly right for it. The second is a malformed document, and quietly substituting a default there would hide the defect and hand the game a value the author never wrote. So it propagates.

AKERR_OUTOFBOUNDS is the one that changed, because it is what the akgl_get_json_array_index_* family actually raises for a short array. An array element does get a default now, and "this element is optional" works for an array index as well as for an object member.

Stale header prose. include/akgl/json_helpers.h:230-234 still says the opposite — that it defaults on AKERR_KEY and AKERR_INDEX "but not on AKERR_OUTOFBOUNDS … so this pairs with the object accessors and does not currently give an array index a default". That was true before 0.5.0. src/json_helpers.c now carries a third HANDLE_GROUP(e, AKERR_OUTOFBOUNDS) arm, placed above the arm holding the memcpy because HANDLE_GROUP emits no break and every arm falls into that body, and tests/json_helpers.c covers it through both the integer and object index accessors. TODO.md item 18 records it as fixed. The header comment wants correcting in its own commit.

defsize is trusted, not derived: it is a memcpy through void * with no type information, and it must match the type both sides actually are.

defsize is not the trap, though. Ownership is.

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

akgl_String

The library allocates nothing at runtime, so a string here is a fixed-capacity buffer claimed from the pool:

#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 — and akgl_heap_next_string is the one acquire function that takes the reference for you, unlike the other four pools.

Two functions operate on the contents:

Function What it does
akgl_string_initialize(obj, init) Copies init in, or zero-fills when init is NULL, and sets refcount to 1. Always NUL-terminates; truncates silently
akgl_string_copy(src, dest, count) Bounded copy between two already-claimed slots. count of 0 selects the whole buffer. Always NUL-terminates. Leaves refcount alone

Capacity is PATH_MAX, which is what makes the string pool by far the largest of the five — 256 slots is about a megabyte — and why claiming the last free slot from it costs 250 ns against 4 ns from the actor pool. Same algorithm, different cache behaviour; the scan touches one reference count every 4 KiB. PERFORMANCE.md has the measurement.

akgl_string_copy refuses a negative count or one above AKGL_MAX_STRING_LENGTH with AKERR_OUTOFBOUNDS, because both buffers are exactly that long and a larger count walked off the end of two pool slots at once.

The two test-only helpers

akgl_compare_sdl_surfaces and akgl_render_and_compare are in util.h and are labelled in the header as "REALLY slow routines that are only useful in testing harnesses". They are not frame-path functions and this manual does not otherwise cover them:

  • akgl_compare_sdl_surfaces(s1, s2) — dimensions, pitch and format first, then a memcmp over the raw pixels. A mismatch is reported as AKERR_VALUE, an error rather than an out-parameter.
  • akgl_render_and_compare(t1, t2, x, y, w, h, writeout) — draws each texture into the same rectangle, reads the framebuffer back after each, and compares. Answers "do these render the same", which is not the same question as "are these identical", because the renderer's scaling and blending sit in between. writeout names a PNG of the first render, under SDL_GetBasePath(), which is how you see what was actually drawn when an assertion fails.

Both have a scar worth repeating. Until 0.5.0 akgl_render_and_compare drew t1 on both passes, so it always reported a match and every image assertion built on it asserted nothing.