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>
This commit is contained in:
2026-08-01 00:15:36 -04:00
parent a60220d39b
commit 19530f6a97
28 changed files with 735 additions and 398 deletions

View File

@@ -1,9 +1,21 @@
/**
* @file bitmasks.c
* @brief The AKGL_BITMASK_* macros, including the ways an unparenthesized one breaks.
*
* These are macros, so the only thing that checks them is a call site. Most of
* this file is the ordinary arithmetic; the composition cases at the bottom are
* the ones that matter, because until 0.5.0 the macros expanded to bare
* expressions and every one of them was a silent misparse waiting for a caller.
*/
#include <akgl/game.h>
#include <akgl/actor.h>
int main(void)
{
int mask = 0;
int counter = 0;
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_ALIVE);
if ( mask != AKGL_ACTOR_STATE_ALIVE )
return 1;
@@ -27,5 +39,70 @@ int main(void)
AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_FACE_DOWN);
if ( mask != (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_MOVING_DOWN | AKGL_ACTOR_STATE_FACE_DOWN) )
return 1;
// ---- composition, which is what the parentheses are for ----
// Negation. Unparenthesized, AKGL_BITMASK_HAS expanded to a bare
// `(x & y) == y`, so `!` bound to the `&` and the whole thing read as
// `!(mask & bit) == bit`.
//
// Note which case catches that and which does not. For a bit that *is*
// set, `!(nonzero)` is 0 and `0 == bit` is false -- the same answer the
// correct parse gives, so a test written that way passes either way. 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.
// That is the shape below, and it is why this was worth a real case rather
// than an obvious one.
mask = AKGL_ACTOR_STATE_ALIVE;
if ( !AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_ALIVE) )
return 1;
if ( AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_DEAD) )
return 1;
if ( !AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_DEAD) ) {
counter = 1;
} else {
return 1;
}
if ( counter != 1 )
return 1;
// The same divergence through AKGL_BITMASK_HASNOT, which was written the
// same way: `!(0) != 64` is true where the answer should be false.
if ( !AKGL_BITMASK_HASNOT(mask, AKGL_ACTOR_STATE_DEAD) )
return 1;
if ( AKGL_BITMASK_HASNOT(mask, AKGL_ACTOR_STATE_ALIVE) )
return 1;
// As an operand of a lower-precedence operator.
if ( AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_ALIVE) == false )
return 1;
if ( (AKGL_BITMASK_HAS(mask, AKGL_ACTOR_STATE_ALIVE) & 1) != 1 )
return 1;
// The mutating macros as expressions rather than statements, and with an
// argument that is itself an expression: `x |= y` unparenthesized cannot be
// used in either position.
mask = 0;
counter = (AKGL_BITMASK_ADD(mask, AKGL_ACTOR_STATE_DYING));
if ( counter != AKGL_ACTOR_STATE_DYING )
return 1;
if ( (AKGL_BITMASK_CLEAR(mask)) != 0 )
return 1;
if ( mask != 0 )
return 1;
// AKGL_BITMASK_CLEAR carries no trailing semicolon, so it is a statement
// like any other and does not swallow the one after it when it is the whole
// body of an unbraced `if`.
mask = AKGL_ACTOR_STATE_ALIVE;
counter = 0;
if ( mask != 0 )
AKGL_BITMASK_CLEAR(mask);
counter = 1;
if ( mask != 0 )
return 1;
if ( counter != 1 )
return 1;
return 0;
}

View File

@@ -554,6 +554,80 @@ akerr_ErrorContext *test_draw_backend_without_a_renderer(void)
SUCCEED_RETURN(errctx);
}
/**
* @brief akgl_draw_background paints its checkerboard and restores the draw colour.
*
* Until 0.5.0 this function returned `void`, drew through the global renderer
* with no check on it, and left the draw colour changed. TODO.md listed it
* under "needs the offscreen renderer harness" purely because of that global;
* taking a backend is what makes it testable here alongside everything else in
* this file.
*/
akerr_ErrorContext *test_draw_background(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
SDL_Color after;
SDL_Color light = { 0x99, 0x99, 0x99, 0xff };
SDL_Color dark = { 0x66, 0x66, 0x66, 0xff };
ATTEMPT {
CATCH(errctx, clear_target());
// A deliberately odd colour, so "restored" cannot be confused with
// "happened to already be black".
FAIL_ZERO_BREAK(
errctx,
SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0x12, 0x34, 0x56, 0x78),
AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_EXPECT_OK(errctx,
akgl_draw_background(akgl_renderer, TEST_TARGET_SIZE, TEST_TARGET_SIZE),
"painting the transparency checkerboard");
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
// The pattern is 8x8 cells alternating on ((x ^ y) >> 3) & 1, so (0,0)
// and (8,8) share a colour and (8,0) is the other one.
TEST_ASSERT(errctx, pixel_is(shot, 0, 0, dark), "cell (0,0) is not the first checker colour");
TEST_ASSERT(errctx, pixel_is(shot, 7, 7, dark), "cell (0,0) is not filled to its edge");
TEST_ASSERT(errctx, pixel_is(shot, 8, 0, light), "cell (1,0) is not the second checker colour");
TEST_ASSERT(errctx, pixel_is(shot, 0, 8, light), "cell (0,1) is not the second checker colour");
TEST_ASSERT(errctx, pixel_is(shot, 8, 8, dark), "cell (1,1) is not the first checker colour");
FAIL_ZERO_BREAK(
errctx,
SDL_GetRenderDrawColor(akgl_renderer->sdl_renderer, &after.r, &after.g, &after.b, &after.a),
AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(errctx,
(after.r == 0x12) && (after.g == 0x34) && (after.b == 0x56) && (after.a == 0x78),
"the draw colour was left at %02x%02x%02x%02x rather than restored",
after.r, after.g, after.b, after.a);
// Degenerate sizes paint nothing and are not an error.
CATCH(errctx, clear_target());
TEST_EXPECT_OK(errctx, akgl_draw_background(akgl_renderer, 0, 0),
"painting a zero-sized background");
TEST_EXPECT_OK(errctx, akgl_draw_background(akgl_renderer, -8, -8),
"painting a negative-sized background");
SDL_DestroySurface(shot);
shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
FAIL_ZERO_BREAK(errctx, shot, AKGL_ERR_SDL, "%s", SDL_GetError());
TEST_ASSERT(errctx, pixel_is(shot, 0, 0, testblack),
"a zero or negative sized background painted something");
TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER,
akgl_draw_background(NULL, 8, 8),
"painting a background through a NULL backend");
} CLEANUP {
if ( shot != NULL ) {
SDL_DestroySurface(shot);
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
@@ -594,6 +668,7 @@ int main(void)
CATCH(errctx, test_draw_copy_and_paste_region());
CATCH(errctx, test_draw_preserves_render_draw_color());
CATCH(errctx, test_draw_backend_without_a_renderer());
CATCH(errctx, test_draw_background());
} CLEANUP {
SDL_Quit();
} PROCESS(errctx) {

View File

@@ -5,6 +5,7 @@
#include <akgl/error.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/actor.h>
#include <akgl/staticstring.h>
#include "testutil.h"
@@ -158,6 +159,132 @@ akerr_ErrorContext *test_akgl_get_property_copies_only_the_value(void)
SUCCEED_RETURN(errctx);
}
/**
* @brief Every actor-state bit must be nameable, and every name must map to its own bit.
*
* akgl_registry_init_actor_state_strings walks AKGL_ACTOR_STATE_STRING_NAMES
* and registers entry `i` under the value `1 << i`. Character JSON resolves
* state names through that registry, so an entry that disagrees with the
* `#define` in actor.h is a state no character can ever bind a sprite to. Bits
* 11 and 12 were `UNDEFINED_11` and `UNDEFINED_12` here while actor.h called
* them MOVING_IN and MOVING_OUT, so both were unreachable by name.
*
* The loop also pins the array's length: it reads exactly
* AKGL_ACTOR_MAX_STATES entries, which is what the header declares and what
* the definition is now sized by. It was declared one longer than defined.
*/
akerr_ErrorContext *test_actor_state_string_names(void)
{
PREPARE_ERROR(errctx);
int i = 0;
int j = 0;
Sint64 registered = 0;
ATTEMPT {
CATCH(errctx, akgl_registry_init_actor_state_strings());
for ( i = 0; i < AKGL_ACTOR_MAX_STATES; i++ ) {
TEST_ASSERT(errctx, AKGL_ACTOR_STATE_STRING_NAMES[i] != NULL,
"actor state bit %d has no name", i);
registered = SDL_GetNumberProperty(
AKGL_REGISTRY_ACTOR_STATE_STRINGS,
AKGL_ACTOR_STATE_STRING_NAMES[i],
0);
TEST_ASSERT(errctx, registered == (Sint64)(1 << i),
"state name %s resolves to %lld, expected %d",
AKGL_ACTOR_STATE_STRING_NAMES[i],
(long long)registered,
(1 << i));
// No two entries may share a name, or the later one silently
// overwrites the earlier in the registry and one bit becomes
// unreachable without anything looking wrong.
for ( j = 0; j < i; j++ ) {
TEST_ASSERT(errctx,
strcmp(AKGL_ACTOR_STATE_STRING_NAMES[i],
AKGL_ACTOR_STATE_STRING_NAMES[j]) != 0,
"actor state bits %d and %d share the name %s",
j, i, AKGL_ACTOR_STATE_STRING_NAMES[i]);
}
}
// The two that were unreachable, named explicitly so a regression reads
// as what it is rather than as an index.
TEST_ASSERT(errctx,
SDL_GetNumberProperty(AKGL_REGISTRY_ACTOR_STATE_STRINGS,
"AKGL_ACTOR_STATE_MOVING_IN", 0)
== (Sint64)AKGL_ACTOR_STATE_MOVING_IN,
"AKGL_ACTOR_STATE_MOVING_IN is not resolvable by name");
TEST_ASSERT(errctx,
SDL_GetNumberProperty(AKGL_REGISTRY_ACTOR_STATE_STRINGS,
"AKGL_ACTOR_STATE_MOVING_OUT", 0)
== (Sint64)AKGL_ACTOR_STATE_MOVING_OUT,
"AKGL_ACTOR_STATE_MOVING_OUT is not resolvable by name");
} CLEANUP {
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
/**
* @brief Re-initializing a registry must replace the old set, not abandon it.
*
* Only akgl_registry_init_actor destroyed the set it was replacing; the other
* seven leaked an SDL_PropertiesID on every call after the first. A game that
* resets between levels calls these repeatedly.
*
* Also checks that akgl_registry_init() brings up the properties registry.
* It did not until 0.5.0, so akgl_set_property was a silent no-op for any
* caller that had not also gone through akgl_game_init.
*/
akerr_ErrorContext *test_akgl_registry_init_is_repeatable(void)
{
PREPARE_ERROR(errctx);
SDL_PropertiesID first = 0;
akgl_String *readback = NULL;
ATTEMPT {
CATCH(errctx, akgl_registry_init());
TEST_ASSERT(errctx, AKGL_REGISTRY_PROPERTIES != 0,
"akgl_registry_init did not create the properties registry");
// Whether the old set was *destroyed* is not observable from here --
// SDL hands out no liveness query, and it reuses ids -- so that half is
// the memcheck run's job, and `cmake --build build --target memcheck`
// gates on it. What is observable is that re-initializing really does
// replace: a key written before the call must be gone after it.
first = AKGL_REGISTRY_SPRITE;
SDL_SetNumberProperty(AKGL_REGISTRY_SPRITE, "akgl_test_sentinel", 1);
TEST_ASSERT(errctx,
SDL_GetNumberProperty(AKGL_REGISTRY_SPRITE, "akgl_test_sentinel", 0) == 1,
"could not write a sentinel into the sprite registry");
CATCH(errctx, akgl_registry_init_sprite());
TEST_ASSERT(errctx, AKGL_REGISTRY_SPRITE != 0,
"re-initializing the sprite registry left it unset");
TEST_ASSERT(errctx,
SDL_GetNumberProperty(AKGL_REGISTRY_SPRITE, "akgl_test_sentinel", 0) == 0,
"re-initializing the sprite registry kept the old contents");
// Configuration set through the registry has to survive and read back,
// which is the behaviour the missing initializer was breaking.
CATCH(errctx, akgl_registry_init());
TEST_EXPECT_OK(errctx, akgl_set_property("test.property", "1234"),
"setting a property after akgl_registry_init");
TEST_EXPECT_OK(errctx, akgl_get_property("test.property", &readback, NULL),
"reading a property back after akgl_registry_init");
TEST_ASSERT(errctx, strcmp((char *)&readback->data, "1234") == 0,
"property read back as '%s', expected '1234'",
(char *)&readback->data);
} CLEANUP {
if ( readback != NULL ) {
IGNORE(akgl_heap_release_string(readback));
}
} PROCESS(errctx) {
} FINISH(errctx, true);
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
@@ -166,6 +293,8 @@ int main(void)
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, test_akgl_registry_init_creation_failures());
CATCH(errctx, test_akgl_get_property_copies_only_the_value());
CATCH(errctx, test_actor_state_string_names());
CATCH(errctx, test_akgl_registry_init_is_repeatable());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);