Files
libakgl/src/util.c
Tachikoma bb278cefe8 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>
Co-Authored-By: Andrew Kesterson <andrew@aklabs.net>
2026-08-01 07:33:37 -04:00

303 lines
12 KiB
C

/**
* @file util.c
* @brief Implements the util subsystem.
*/
#include <limits.h>
#include <stdlib.h>
#include <errno.h>
#include <libgen.h>
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <akerror.h>
#include <akgl/util.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/game.h>
#include <akgl/staticstring.h>
#include <akstdlib.h>
/**
* @brief Resolve @p path against @p root and write the absolute result to @p dest.
*
* The second half of akgl_path_relative: joins the two with a `/` and resolves
* the join, so symlinks and `..` are folded out and the result is absolute. It
* is the fallback, reached only once resolving @p path on its own has failed.
*
* `static`: it is reachable only through akgl_path_relative, which is the only
* caller and the only sensible one.
*
* @param root Directory to resolve against, normally `dirname` of the file that
* named @p path. Required. A trailing `/` is harmless; the join adds
* one unconditionally and `realpath` collapses the double.
* @param path Relative path to resolve. Required. An absolute @p path still gets
* @p root pasted in front of it, which will not exist -- so callers
* must not send absolute paths down this branch.
* @param dest Receives the resolved absolute path. Required, and must already be
* a claimed pool string.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER If @p root, @p path, or @p dest is `NULL`.
* @throws AKERR_OUTOFBOUNDS If `root + path` is at least #AKGL_MAX_STRING_LENGTH
* bytes. The message reports both the combined length and the limit.
* @throws ENOENT If the joined path does not exist. Any other `errno`
* `realpath(3)` raises -- EACCES, ELOOP, ENOTDIR -- propagates likewise.
* @throws AKGL_ERR_HEAP If the string pool cannot supply the two scratch buffers.
*
* @note The AKERR_OUTOFBOUNDS check uses `FAIL_RETURN` from inside the `ATTEMPT`
* block, which returns past `CLEANUP` -- so on that one path the two
* scratch strings are never released. This is exactly the hazard AGENTS.md
* warns about; it wants a `FAIL_BREAK`.
*/
static akerr_ErrorContext *path_relative_root(char *root, char *path, akgl_String *dest)
{
PREPARE_ERROR(errctx);
akgl_String *pathbuf;
akgl_String *strbuf;
int rootlen;
int pathlen;
int count;
FAIL_ZERO_RETURN(errctx, root, AKERR_NULLPOINTER, "NULL argument");
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "NULL argument");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL argument");
PASS(errctx, akgl_heap_next_string(&strbuf));
PASS(errctx, akgl_heap_next_string(&pathbuf));
ATTEMPT {
// Is it relative to the root?
rootlen = strlen(root);
pathlen = strlen(path);
if ( (rootlen + pathlen) >= AKGL_MAX_STRING_LENGTH ) {
FAIL_BREAK(errctx, AKERR_OUTOFBOUNDS, "Total path length (%d) is greater than maximum akgl_String length (%d)", (rootlen + pathlen), AKGL_MAX_STRING_LENGTH);
}
DISABLE_GCC_WARNING_FORMAT_TRUNCATION
CATCH(errctx, aksl_snprintf(
&count,
(char *)&pathbuf->data,
sizeof(pathbuf->data),
"%s/%s",
root,
path
));
RESTORE_GCC_WARNINGS
CATCH(errctx, aksl_realpath((char *)&pathbuf->data, (char *)&strbuf->data, sizeof(strbuf->data)));
CATCH(errctx, akgl_string_copy(strbuf, dest, 0));
} CLEANUP {
IGNORE(akgl_heap_release_string(strbuf));
IGNORE(akgl_heap_release_string(pathbuf));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_path_relative(char *root, char *path, akgl_String *dest)
{
PREPARE_ERROR(errctx);
akgl_String *strbuf;
bool relative_to_root = false;
FAIL_ZERO_RETURN(errctx, root, AKERR_NULLPOINTER, "NULL argument");
FAIL_ZERO_RETURN(errctx, path, AKERR_NULLPOINTER, "NULL argument");
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL argument");
PASS(errctx, akgl_heap_next_string(&strbuf));
ATTEMPT {
// Is path relative to our current working directory?
CATCH(errctx, aksl_realpath(path, (char *)&strbuf->data, sizeof(strbuf->data)));
// Yes it is. strbuf->data contains the absolute path.
CATCH(errctx, akgl_string_copy(strbuf, dest, 0));
} CLEANUP {
IGNORE(akgl_heap_release_string(strbuf));
} PROCESS(errctx) {
} HANDLE(errctx, ENOENT) {
// Path is not relative to our current working directory. Resolve it
// against root instead -- but after FINISH, not from in here. Returning
// from inside a HANDLE block skips the RELEASE_ERROR that FINISH ends
// with, so the handled context is never given back to
// AKERR_ARRAY_ERROR. That leaks one context per call, and the 129th
// call takes the whole process down with "Unable to pull an error
// context from the array!". Every map load resolves several paths this
// way.
relative_to_root = true;
} FINISH(errctx, true);
if ( relative_to_root == true ) {
PASS(errctx, path_relative_root(root, path, dest));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_rectangle_points(akgl_RectanglePoints *dest, SDL_FRect *rect)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, dest, AKERR_NULLPOINTER, "NULL akgl_RectanglePoints reference");
FAIL_ZERO_RETURN(errctx, rect, AKERR_NULLPOINTER, "NULL Rectangle reference");
dest->topleft.x = rect->x;
dest->topleft.y = rect->y;
dest->bottomleft.x = rect->x;
dest->bottomleft.y = rect->y + rect->h;
dest->topright.x = rect->x + rect->w;
dest->topright.y = rect->y;
dest->bottomright.x = rect->x + rect->w;
dest->bottomright.y = rect->y + rect->h;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collide_point_rectangle(akgl_Point *p, akgl_RectanglePoints *rp, bool *collide)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, p, AKERR_NULLPOINTER, "NULL Point reference");
FAIL_ZERO_RETURN(errctx, rp, AKERR_NULLPOINTER, "NULL akgl_RectanglePoints reference");
FAIL_ZERO_RETURN(errctx, collide, AKERR_NULLPOINTER, "NULL boolean reference");
if ( (p->x >= rp->topleft.x) && (p->y >= rp->topleft.y) &&
(p->x <= rp->bottomright.x) && (p->y <= rp->bottomright.y) ) {
*collide = true;
} else {
*collide = false;
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_collide_rectangles(SDL_FRect *r1, SDL_FRect *r2, bool *collide)
{
akgl_RectanglePoints r1p;
akgl_RectanglePoints r2p;
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, r1, AKERR_NULLPOINTER, "NULL rectangle reference");
FAIL_ZERO_RETURN(errctx, r2, AKERR_NULLPOINTER, "NULL rectangle reference");
FAIL_ZERO_RETURN(errctx, collide, AKERR_NULLPOINTER, "NULL collision flag reference");
ATTEMPT {
CATCH(errctx, akgl_rectangle_points(&r1p, r1));
CATCH(errctx, akgl_rectangle_points(&r2p, r2));
// is the upper left corner of r1 contacting r2?
CATCH(errctx, akgl_collide_point_rectangle(&r1p.topleft, &r2p, collide));
if ( *collide == true ) { break; }
// is the upper left corner of r2 contacting r1?
CATCH(errctx, akgl_collide_point_rectangle(&r2p.topleft, &r1p, collide));
if ( *collide == true ) { break; }
// is the top right corner of r1 contacting r2?
CATCH(errctx, akgl_collide_point_rectangle(&r1p.topright, &r2p, collide));
if ( *collide == true ) { break; }
// is the top right corner of r2 contacting r1?
CATCH(errctx, akgl_collide_point_rectangle(&r2p.topright, &r1p, collide));
if ( *collide == true ) { break; }
// is the bottom left corner of r1 contacting r2?
CATCH(errctx, akgl_collide_point_rectangle(&r1p.bottomleft, &r2p, collide));
if ( *collide == true ) { break; }
// is the bottom left corner of r2 contacting r1?
CATCH(errctx, akgl_collide_point_rectangle(&r2p.bottomleft, &r1p, collide));
if ( *collide == true ) { break; }
// is the bottom right corner of r1 contacting r2?
CATCH(errctx, akgl_collide_point_rectangle(&r1p.bottomright, &r2p, collide));
if ( *collide == true ) { break; }
// is the bottom right corner of r2 contacting r1?
CATCH(errctx, akgl_collide_point_rectangle(&r2p.bottomright, &r1p, collide));
if ( *collide == true ) { break; }
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
// No trailing `*collide = false;` here. Each corner test writes the flag
// itself, so the eighth one leaves it false when nothing hit; assigning
// after FINISH would overwrite the hit that broke out of the block early.
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_compare_sdl_surfaces(SDL_Surface *s1, SDL_Surface *s2)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, s1, AKERR_NULLPOINTER, "NULL Surface pointer");
FAIL_ZERO_RETURN(errctx, s2, AKERR_NULLPOINTER, "NULL Surface pointer");
// Geometry first. The memcmp reads s1->pitch * s1->h bytes out of *both*,
// so a smaller s2 was read past its end rather than reported as a
// mismatch -- and a differing pitch or format made the comparison
// meaningless even when it did not run off.
FAIL_NONZERO_RETURN(
errctx,
((s1->w != s2->w) || (s1->h != s2->h)),
AKERR_VALUE,
"Comparison surfaces differ in size: %dx%d against %dx%d",
s1->w, s1->h, s2->w, s2->h);
FAIL_NONZERO_RETURN(
errctx,
(s1->pitch != s2->pitch),
AKERR_VALUE,
"Comparison surfaces differ in pitch: %d against %d",
s1->pitch, s2->pitch);
FAIL_NONZERO_RETURN(
errctx,
(s1->format != s2->format),
AKERR_VALUE,
"Comparison surfaces differ in pixel format: %s against %s",
SDL_GetPixelFormatName(s1->format), SDL_GetPixelFormatName(s2->format));
FAIL_ZERO_RETURN(errctx, s1->pixels, AKERR_NULLPOINTER, "First surface has no pixels");
FAIL_ZERO_RETURN(errctx, s2->pixels, AKERR_NULLPOINTER, "Second surface has no pixels");
FAIL_NONZERO_RETURN(errctx, memcmp(s1->pixels, s2->pixels, (s1->pitch * s1->h)), AKERR_VALUE, "Comparison surfaces are not equal");
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akgl_render_and_compare(SDL_Texture *t1, SDL_Texture *t2, int x, int y, int w, int h, char *writeout)
{
SDL_Surface *s1 = NULL;
SDL_Surface *s2 = NULL;
SDL_FRect src = {.x = x, .y = y, .w = w, .h = h};
SDL_FRect dest = {.x = x, .y = y, .w = w, .h = h};
SDL_Rect read = {.x = x, .y = y, .w = w, .h = h};
akgl_String *tmpstring = NULL;
PREPARE_ERROR(errctx);
ATTEMPT {
FAIL_ZERO_BREAK(errctx, t1, AKERR_NULLPOINTER, "NULL texture");
FAIL_ZERO_BREAK(errctx, t2, AKERR_NULLPOINTER, "NULL texture");
CATCH(errctx, akgl_heap_next_string(&tmpstring));
SDL_RenderClear(akgl_renderer->sdl_renderer);
CATCH(errctx, akgl_renderer->draw_texture(akgl_renderer, t1, &src, &dest, 0, NULL, SDL_FLIP_NONE));
s1 = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, &read);
FAIL_ZERO_BREAK(errctx, s1, AKGL_ERR_SDL, "Failed to read pixels from renderer");
if ( writeout != NULL ) {
snprintf((char *)&tmpstring->data, AKGL_MAX_STRING_LENGTH, "%s%s", SDL_GetBasePath(), writeout);
FAIL_ZERO_BREAK(
errctx,
IMG_SavePNG(s1, (char *)&tmpstring->data),
AKERR_IO,
"Unable to save %s: %s",
(char *)&tmpstring->data,
SDL_GetError());
}
SDL_RenderClear(akgl_renderer->sdl_renderer);
CATCH(errctx, akgl_renderer->draw_texture(akgl_renderer, t2, &src, &dest, 0, NULL, SDL_FLIP_NONE));
s2 = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, &read);
FAIL_ZERO_BREAK(errctx, s2, AKGL_ERR_SDL, "Failed to read pixels from renderer");
CATCH(errctx, akgl_compare_sdl_surfaces(s1, s2));
} CLEANUP {
if ( s1 != NULL )
SDL_DestroySurface(s1);
if ( s2 != NULL )
SDL_DestroySurface(s2);
IGNORE(akgl_heap_release_string(tmpstring));
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}