Files
libakgl/tests/game.c
Andrew Kesterson 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

681 lines
23 KiB
C

/**
* @file game.c
* @brief Unit tests for savegame serialization, version gating, and frame accounting.
*
* akgl_game_init() and akgl_game_update() need a window and a live frame loop,
* so they are out of scope here. Everything else in the game module is either
* pure logic or file IO and is covered below.
*/
#include <SDL3/SDL.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <akerror.h>
#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/sprite.h>
#include <akgl/staticstring.h>
#include <akgl/renderer.h>
#include <akgl/physics.h>
#include <akgl/iterator.h>
#include "testutil.h"
/** @brief Scratch savegame path, created and removed by the tests that use it. */
static char savepath[] = "akgl_test_savegame.bin";
/** @brief Scratch path for deliberately malformed savegames. */
static char truncatedpath[] = "akgl_test_truncated.bin";
/** @brief Populate the process-wide game record with a valid identity. */
static void set_game_identity(void)
{
memset(&akgl_game, 0x00, sizeof(akgl_Game));
strncpy((char *)&akgl_game.libversion, AKGL_VERSION, 31);
strncpy((char *)&akgl_game.version, "1.2.3", 31);
strncpy((char *)&akgl_game.name, "libakgl test game", 255);
strncpy((char *)&akgl_game.uri, "https://example.invalid/akgl-test", 255);
}
akerr_ErrorContext *test_game_load_versioncmp_matching(void)
{
PREPARE_ERROR(e);
ATTEMPT {
TEST_EXPECT_OK(e, akgl_game_load_versioncmp("library", "1.2.3", "1.2.3"),
"identical versions must be compatible");
TEST_EXPECT_OK(e, akgl_game_load_versioncmp("library", "0.1.0", "0.1.0"),
"identical zero-major versions must be compatible");
TEST_EXPECT_OK(e, akgl_game_load_versioncmp("game", "10.20.30", "10.20.30"),
"identical multi-digit versions must be compatible");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_game_load_versioncmp_mismatched(void)
{
PREPARE_ERROR(e);
ATTEMPT {
// A savegame from a different build is refused on any component.
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "2.2.3", "1.2.3"),
"a differing major version must be refused");
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "1.3.3", "1.2.3"),
"a differing minor version must be refused");
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load_versioncmp("library", "1.2.4", "1.2.3"),
"a differing patch version must be refused");
// Unparseable versions are a value error, distinct from a mismatch.
TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_game_load_versioncmp("library", "1.2.3", "not-a-version"),
"an unparseable current version must be refused");
TEST_EXPECT_STATUS(e, AKERR_VALUE, akgl_game_load_versioncmp("library", "not-a-version", "1.2.3"),
"an unparseable savegame version must be refused");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp(NULL, "1.2.3", "1.2.3"),
"versioncmp with a NULL version type");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp("library", NULL, "1.2.3"),
"versioncmp with a NULL new version");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load_versioncmp("library", "1.2.3", NULL),
"versioncmp with a NULL current version");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_game_load_versioncmp_releases_semver(void)
{
PREPARE_ERROR(e);
int i = 0;
bool leaked = false;
ATTEMPT {
// semver_parse allocates; the comparison must free both sides on the
// success and the failure path or a long session will drift.
for ( i = 0; i < 2000; i++ ) {
akerr_ErrorContext *result = akgl_game_load_versioncmp("library", "1.2.3", "1.2.3");
if ( result != NULL ) {
result->handled = true;
result = akerr_release_error(result);
leaked = true;
}
result = akgl_game_load_versioncmp("library", "9.9.9", "1.2.3");
if ( result != NULL ) {
result->handled = true;
result = akerr_release_error(result);
}
}
TEST_ASSERT(e, leaked == false,
"a matching version comparison started failing partway through a long run");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_game_save_roundtrip(void)
{
PREPARE_ERROR(e);
akgl_Game expected;
ATTEMPT {
CATCH(e, akgl_registry_init());
CATCH(e, akgl_heap_init());
set_game_identity();
akgl_game.fps = 60;
akgl_game.framesSinceUpdate = 7;
memcpy(&expected, &akgl_game, sizeof(akgl_Game));
TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath), "saving a game");
// Scribble over the live state so a successful load has to restore it.
akgl_game.fps = 0;
akgl_game.framesSinceUpdate = 0;
TEST_EXPECT_OK(e, akgl_game_load((char *)&savepath), "loading the game back");
TEST_ASSERT(e, akgl_game.fps == 60, "fps restored as %d, expected 60", akgl_game.fps);
TEST_ASSERT(e, akgl_game.framesSinceUpdate == 7,
"framesSinceUpdate restored as %d, expected 7", akgl_game.framesSinceUpdate);
TEST_ASSERT(e, strncmp((char *)&akgl_game.name, (char *)&expected.name, 256) == 0,
"the game name was not preserved across a save and load");
TEST_ASSERT(e, strncmp((char *)&akgl_game.version, (char *)&expected.version, 32) == 0,
"the game version was not preserved across a save and load");
} CLEANUP {
unlink((char *)&savepath);
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_game_load_rejects_foreign_saves(void)
{
PREPARE_ERROR(e);
ATTEMPT {
CATCH(e, akgl_registry_init());
CATCH(e, akgl_heap_init());
// A save written by a different game must not load into this one.
set_game_identity();
CATCH(e, akgl_game_save((char *)&savepath));
strncpy((char *)&akgl_game.name, "a completely different game", 255);
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath),
"a savegame with a foreign game name must be refused");
unlink((char *)&savepath);
// Same for a differing URI.
set_game_identity();
CATCH(e, akgl_game_save((char *)&savepath));
strncpy((char *)&akgl_game.uri, "https://example.invalid/other", 255);
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath),
"a savegame with a foreign URI must be refused");
unlink((char *)&savepath);
// A save written against a different library version must be refused.
set_game_identity();
strncpy((char *)&akgl_game.libversion, "99.98.97", 31);
CATCH(e, akgl_game_save((char *)&savepath));
set_game_identity();
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath),
"a savegame from a different library version must be refused");
unlink((char *)&savepath);
// And one written against a different game version.
set_game_identity();
strncpy((char *)&akgl_game.version, "4.5.6", 31);
CATCH(e, akgl_game_save((char *)&savepath));
set_game_identity();
TEST_EXPECT_STATUS(e, AKERR_API, akgl_game_load((char *)&savepath),
"a savegame from a different game version must be refused");
} CLEANUP {
unlink((char *)&savepath);
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_game_save_load_nullpointers(void)
{
PREPARE_ERROR(e);
ATTEMPT {
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_save(NULL),
"akgl_game_save(NULL)");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_load(NULL),
"akgl_game_load(NULL)");
TEST_EXPECT_STATUS(e, AKERR_NULLPOINTER, akgl_game_save_actors(NULL),
"akgl_game_save_actors(NULL)");
// A path under a directory that does not exist cannot be opened.
TEST_EXPECT_ANY_ERROR(e, akgl_game_save("no_such_directory/save.bin"),
"saving into a nonexistent directory");
TEST_EXPECT_ANY_ERROR(e, akgl_game_load("no_such_file_anywhere.bin"),
"loading a nonexistent savegame");
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_game_load_truncated_table(void)
{
PREPARE_ERROR(e);
FILE *fp = NULL;
char partial[64];
ATTEMPT {
CATCH(e, akgl_registry_init());
CATCH(e, akgl_heap_init());
set_game_identity();
// A valid header followed by a table that ends before its sentinel. The
// name-map reader loops until it sees the sentinel, so it has to notice
// EOF instead of spinning.
memset(&partial, 0x00, sizeof(partial));
fp = fopen((char *)&truncatedpath, "wb");
FAIL_ZERO_BREAK(e, fp, AKERR_IO, "unable to create the truncated savegame fixture");
FAIL_ZERO_BREAK(e, fwrite(&akgl_game, 1, sizeof(akgl_Game), fp), AKERR_IO,
"unable to write the truncated savegame header");
FAIL_ZERO_BREAK(e, fwrite(&partial, 1, sizeof(partial), fp), AKERR_IO,
"unable to write the truncated savegame body");
fclose(fp);
fp = NULL;
TEST_EXPECT_ANY_ERROR(e, akgl_game_load((char *)&truncatedpath),
"loading a savegame whose name table is truncated");
} CLEANUP {
if ( fp != NULL ) {
fclose(fp);
}
unlink((char *)&truncatedpath);
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_game_save_writes_name_tables(void)
{
PREPARE_ERROR(e);
akgl_Actor *actor = NULL;
FILE *fp = NULL;
long filesize = 0;
long minimum = 0;
ATTEMPT {
CATCH(e, akgl_registry_init());
CATCH(e, akgl_heap_init());
set_game_identity();
// One registered actor, so the actor table has a real entry ahead of its
// terminating sentinel.
CATCH(e, akgl_heap_next_actor(&actor));
CATCH(e, akgl_actor_initialize(actor, "saved_actor"));
TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath), "saving a game with one actor");
fp = fopen((char *)&savepath, "rb");
FAIL_ZERO_BREAK(e, fp, AKERR_IO, "unable to reopen the savegame");
fseek(fp, 0, SEEK_END);
filesize = ftell(fp);
// The header, then four name tables each ending in a name-sized and a
// pointer-sized sentinel, plus the one real actor entry.
minimum = (long)sizeof(akgl_Game)
+ (long)(AKGL_ACTOR_MAX_NAME_LENGTH + sizeof(akgl_Actor *)) * 2
+ (long)(AKGL_SPRITE_MAX_NAME_LENGTH + sizeof(akgl_Sprite *))
+ (long)(AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH + sizeof(akgl_SpriteSheet *))
+ (long)(AKGL_CHARACTER_MAX_NAME_LENGTH + sizeof(akgl_Character *));
TEST_ASSERT(e, filesize >= minimum,
"the savegame is %ld bytes, expected at least %ld for the header and four name tables",
filesize, minimum);
} CLEANUP {
if ( fp != NULL ) {
fclose(fp);
}
unlink((char *)&savepath);
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
akerr_ErrorContext *test_game_state_lock(void)
{
PREPARE_ERROR(e);
ATTEMPT {
set_game_identity();
akgl_game.statelock = SDL_CreateMutex();
FAIL_ZERO_BREAK(e, akgl_game.statelock, AKGL_ERR_SDL, "unable to create the state mutex");
TEST_EXPECT_OK(e, akgl_game_state_lock(), "taking the state lock");
TEST_EXPECT_OK(e, akgl_game_state_unlock(), "releasing the state lock");
// The lock is reusable after a matched unlock.
TEST_EXPECT_OK(e, akgl_game_state_lock(), "retaking the state lock");
TEST_EXPECT_OK(e, akgl_game_state_unlock(), "releasing the state lock again");
} CLEANUP {
if ( akgl_game.statelock != NULL ) {
SDL_DestroyMutex(akgl_game.statelock);
akgl_game.statelock = NULL;
}
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/**
* @brief akgl_game_update_fps must not call a lowfpsfunc nobody installed.
*
* `game.fps` is 0 for the first second of the process, which is under the
* threshold, so this fires on frame one. Only akgl_game_init installs the
* default -- and renderer.h documents the other path deliberately: a host that
* owns its own window calls akgl_render_2d_bind instead. Such an embedder
* crashed here on its first frame, through a NULL function pointer.
*/
akerr_ErrorContext *test_game_updateFPS_without_a_lowfps_handler(void)
{
PREPARE_ERROR(e);
ATTEMPT {
set_game_identity();
// Exactly the state a host that never called akgl_game_init is in.
akgl_game.lowfpsfunc = NULL;
akgl_game.fps = 0;
akgl_game.framesSinceUpdate = 0;
akgl_game.lastFPSTime = SDL_GetTicksNS();
akgl_game_update_fps();
TEST_ASSERT(e, akgl_game.lowfpsfunc != NULL,
"akgl_game_update_fps left lowfpsfunc NULL");
TEST_ASSERT(e, akgl_game.lowfpsfunc == &akgl_game_lowfps,
"akgl_game_update_fps installed something other than the default");
// And it keeps working on the frames after.
akgl_game_update_fps();
TEST_ASSERT(e, akgl_game.framesSinceUpdate == 2,
"frames counted as %d over two updates, expected 2",
akgl_game.framesSinceUpdate);
} CLEANUP {
akgl_game.lowfpsfunc = &akgl_game_lowfps;
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/** @brief Cleared while the helper thread should keep holding the state mutex. */
static SDL_AtomicInt lockholder_release;
/** @brief Takes the state mutex and sits on it until lockholder_release is set. */
static int SDLCALL lockholder_thread(void *userdata)
{
SDL_Mutex *statelock = (SDL_Mutex *)userdata;
SDL_LockMutex(statelock);
while ( SDL_GetAtomicInt(&lockholder_release) == 0 ) {
SDL_Delay(10);
}
SDL_UnlockMutex(statelock);
return 0;
}
/**
* @brief akgl_game_state_lock must give up on a contended mutex in about a second.
*
* The uncontended path above never reaches the retry loop, which is where the
* budget lives and where the defect was: the loop counted 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 upper bound below is the assertion that matters. The lower bound
* is there so a build that gave up immediately -- reporting failure without
* waiting at all -- cannot pass either.
*/
akerr_ErrorContext *test_game_state_lock_budget(void)
{
PREPARE_ERROR(e);
SDL_Thread *holder = NULL;
Uint64 started = 0;
Uint64 elapsed = 0;
ATTEMPT {
set_game_identity();
akgl_game.statelock = SDL_CreateMutex();
FAIL_ZERO_BREAK(e, akgl_game.statelock, AKGL_ERR_SDL, "unable to create the state mutex");
SDL_SetAtomicInt(&lockholder_release, 0);
holder = SDL_CreateThread(lockholder_thread, "akgl_test_lockholder", (void *)akgl_game.statelock);
FAIL_ZERO_BREAK(e, holder, AKGL_ERR_SDL, "unable to start the lock-holding thread");
// Wait until the helper actually owns the mutex. Without this the
// measurement races the thread start and the lock is taken on the first
// try, which measures nothing.
while ( SDL_TryLockMutex(akgl_game.statelock) == true ) {
SDL_UnlockMutex(akgl_game.statelock);
SDL_Delay(1);
}
started = SDL_GetTicksNS();
TEST_EXPECT_STATUS(
e,
AKGL_ERR_SDL,
akgl_game_state_lock(),
"taking a state lock another thread is holding");
elapsed = SDL_GetTicksNS() - started;
TEST_ASSERT(
e,
elapsed >= (AKGL_TIME_ONESEC_NS / 2),
"state lock gave up after %" SDL_PRIu64 " ns without waiting out its budget",
elapsed);
TEST_ASSERT(
e,
elapsed < (5 * (Uint64)AKGL_TIME_ONESEC_NS),
"state lock waited %" SDL_PRIu64 " ns on a %d ms budget",
elapsed,
AKGL_GAME_STATE_LOCK_BUDGET_MS);
} CLEANUP {
SDL_SetAtomicInt(&lockholder_release, 1);
if ( holder != NULL ) {
SDL_WaitThread(holder, NULL);
}
if ( akgl_game.statelock != NULL ) {
SDL_DestroyMutex(akgl_game.statelock);
akgl_game.statelock = NULL;
}
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/** @brief Counts calls made to the low-FPS callback. */
static int lowfps_calls = 0;
/** @brief Low-FPS callback stub that only records that it fired. */
static void stub_lowfps(void)
{
lowfps_calls += 1;
}
akerr_ErrorContext *test_game_updateFPS(void)
{
PREPARE_ERROR(e);
int16_t framesbefore = 0;
ATTEMPT {
set_game_identity();
akgl_game.lowfpsfunc = &stub_lowfps;
// Below the 30 FPS floor, every update notifies the callback.
akgl_game.fps = 10;
akgl_game.lastFPSTime = SDL_GetTicksNS();
lowfps_calls = 0;
framesbefore = akgl_game.framesSinceUpdate;
akgl_game_update_fps();
TEST_ASSERT(e, lowfps_calls == 1,
"a sub-30 FPS update fired the low-FPS callback %d times, expected 1", lowfps_calls);
TEST_ASSERT(e, akgl_game.framesSinceUpdate == (framesbefore + 1),
"updateFPS did not count the frame (%d, expected %d)",
akgl_game.framesSinceUpdate, framesbefore + 1);
TEST_ASSERT(e, akgl_game.lastIterTime != 0, "updateFPS did not stamp lastIterTime");
// At or above the floor, the callback stays quiet.
akgl_game.fps = 60;
akgl_game.lastFPSTime = SDL_GetTicksNS();
lowfps_calls = 0;
akgl_game_update_fps();
TEST_ASSERT(e, lowfps_calls == 0,
"a 60 FPS update fired the low-FPS callback %d times, expected 0", lowfps_calls);
// Once a full second has elapsed, the frame counter rolls into fps.
akgl_game.fps = 60;
akgl_game.framesSinceUpdate = 45;
akgl_game.lastFPSTime = SDL_GetTicksNS() - (2 * (SDL_Time)AKGL_TIME_ONESEC_NS);
akgl_game_update_fps();
TEST_ASSERT(e, akgl_game.fps == 45,
"after a second elapsed, fps rolled over as %d, expected 45", akgl_game.fps);
TEST_ASSERT(e, akgl_game.framesSinceUpdate == 1,
"the frame counter restarted at %d, expected 1", akgl_game.framesSinceUpdate);
// The shipped default callback only logs, so it just has to not crash.
akgl_game.fps = 1;
akgl_game_lowfps();
} CLEANUP {
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
/** @brief Counts akgl_game_update calls into each actor's updatefunc, by actor index. */
static int updatecounts[AKGL_MAX_HEAP_ACTOR];
/** @brief updatefunc stub: record that this actor was updated. */
static akerr_ErrorContext *counting_updatefunc(akgl_Actor *obj)
{
PREPARE_ERROR(e);
int i = 0;
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
if ( &akgl_heap_actors[i] == obj ) {
updatecounts[i] += 1;
break;
}
}
SUCCEED_RETURN(e);
}
/** @brief Physics backend stub: akgl_game_update calls simulate, and it must not matter here. */
static akerr_ErrorContext *stub_simulate(akgl_PhysicsBackend *self, akgl_Iterator *opflags)
{
PREPARE_ERROR(e);
SUCCEED_RETURN(e);
}
/** @brief Render backend stub: akgl_game_update calls draw_world; drawing is not under test. */
static akerr_ErrorContext *stub_draw_world(akgl_RenderBackend *self, akgl_Iterator *opflags)
{
PREPARE_ERROR(e);
SUCCEED_RETURN(e);
}
/**
* @brief akgl_game_update must call each live actor's updatefunc exactly once.
*
* The sweep used to sit inside a walk over AKGL_TILEMAP_MAX_LAYERS and never
* compared actor->layer to the layer it was on, so every live actor updated
* sixteen times a frame -- 70 microseconds of work to do 4.4 of it, and every
* bit of per-frame actor logic running sixteen times over. It hid behind a
* software rasterizer and would not have hidden behind a GPU backend.
*
* Counting is the assertion. A timing test would measure the machine.
*/
akerr_ErrorContext *test_game_update_visits_each_actor_once(void)
{
PREPARE_ERROR(e);
akgl_PhysicsBackend stubphysics;
akgl_RenderBackend stubrenderer;
akgl_Iterator opflags;
akgl_Actor *actors[3] = { NULL, NULL, NULL };
int layers[3] = { 0, 1, 1 };
int i = 0;
int live = 0;
ATTEMPT {
set_game_identity();
akgl_game.statelock = SDL_CreateMutex();
FAIL_ZERO_BREAK(e, akgl_game.statelock, AKGL_ERR_SDL, "unable to create the state mutex");
akgl_game.lowfpsfunc = &akgl_game_lowfps;
CATCH(e, akgl_heap_init());
CATCH(e, akgl_registry_init_actor());
memset(&stubphysics, 0x00, sizeof(akgl_PhysicsBackend));
memset(&stubrenderer, 0x00, sizeof(akgl_RenderBackend));
stubphysics.simulate = &stub_simulate;
stubrenderer.draw_world = &stub_draw_world;
akgl_physics = &stubphysics;
akgl_renderer = &stubrenderer;
akgl_gamemap = &akgl_default_gamemap;
for ( i = 0; i < 3; i++ ) {
char name[32];
snprintf((char *)&name, sizeof(name), "sweepactor%d", i);
CATCH(e, akgl_heap_next_actor(&actors[i]));
CATCH(e, akgl_actor_initialize(actors[i], (char *)&name));
actors[i]->updatefunc = &counting_updatefunc;
actors[i]->layer = layers[i];
}
// The default sweep: every live actor, once.
memset(&updatecounts, 0x00, sizeof(updatecounts));
TEST_EXPECT_OK(e, akgl_game_update(NULL), "one default game update");
live = 0;
for ( i = 0; i < AKGL_MAX_HEAP_ACTOR; i++ ) {
if ( akgl_heap_actors[i].refcount == 0 ) {
TEST_ASSERT(e, updatecounts[i] == 0,
"a free actor slot %d was updated %d times", i, updatecounts[i]);
continue;
}
live += 1;
TEST_ASSERT(e, updatecounts[i] == 1,
"actor %d updated %d times in one frame, expected 1",
i, updatecounts[i]);
}
TEST_ASSERT(e, live == 3, "%d live actors, expected 3", live);
// Two frames means two updates, not thirty-two.
memset(&updatecounts, 0x00, sizeof(updatecounts));
TEST_EXPECT_OK(e, akgl_game_update(NULL), "the second game update");
TEST_EXPECT_OK(e, akgl_game_update(NULL), "the third game update");
for ( i = 0; i < 3; i++ ) {
TEST_ASSERT(e, updatecounts[i] == 2,
"actor %d updated %d times over two frames, expected 2",
i, updatecounts[i]);
}
// AKGL_ITERATOR_OP_LAYERMASK now means what it says: only layer 1.
memset(&updatecounts, 0x00, sizeof(updatecounts));
AKGL_BITMASK_CLEAR(opflags.flags);
AKGL_BITMASK_ADD(opflags.flags, AKGL_ITERATOR_OP_UPDATE);
AKGL_BITMASK_ADD(opflags.flags, AKGL_ITERATOR_OP_LAYERMASK);
opflags.layerid = 1;
TEST_EXPECT_OK(e, akgl_game_update(&opflags), "a layer-masked game update");
TEST_ASSERT(e, updatecounts[0] == 0,
"the layer 0 actor updated %d times under a layer 1 mask", updatecounts[0]);
TEST_ASSERT(e, updatecounts[1] == 1,
"the first layer 1 actor updated %d times, expected 1", updatecounts[1]);
TEST_ASSERT(e, updatecounts[2] == 1,
"the second layer 1 actor updated %d times, expected 1", updatecounts[2]);
} CLEANUP {
for ( i = 0; i < 3; i++ ) {
if ( actors[i] != NULL ) {
IGNORE(akgl_heap_release_actor(actors[i]));
}
}
if ( akgl_game.statelock != NULL ) {
SDL_DestroyMutex(akgl_game.statelock);
akgl_game.statelock = NULL;
}
} PROCESS(e) {
} FINISH(e, true);
SUCCEED_RETURN(e);
}
int main(void)
{
PREPARE_ERROR(errctx);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
ATTEMPT {
CATCH(errctx, akgl_error_init());
TEST_TRAP_UNHANDLED_ERRORS();
CATCH(errctx, akgl_heap_init());
CATCH(errctx, akgl_registry_init());
CATCH(errctx, test_game_load_versioncmp_matching());
CATCH(errctx, test_game_load_versioncmp_mismatched());
CATCH(errctx, test_game_load_versioncmp_releases_semver());
CATCH(errctx, test_game_save_roundtrip());
CATCH(errctx, test_game_load_rejects_foreign_saves());
CATCH(errctx, test_game_save_load_nullpointers());
CATCH(errctx, test_game_load_truncated_table());
CATCH(errctx, test_game_save_writes_name_tables());
CATCH(errctx, test_game_state_lock());
CATCH(errctx, test_game_state_lock_budget());
CATCH(errctx, test_game_updateFPS());
CATCH(errctx, test_game_updateFPS_without_a_lowfps_handler());
CATCH(errctx, test_game_update_visits_each_actor_once());
} CLEANUP {
} PROCESS(errctx) {
} FINISH_NORETURN(errctx);
}