Files
libakgl/tests/game.c
Andrew Kesterson e4aa6a5084
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
Take libakerror 2.0.1 and drop the workaround it makes obsolete
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

800 lines
28 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);
}
/**
* @brief A save whose close fails must not report success.
*
* akgl_game_save checks every write, and every one of those writes lands in
* stdio's buffer -- for a record this size, nothing reaches the device until
* the close flushes it. So a full disk, an exceeded quota or a server going
* away is reported *only* by the close, and while that close was unchecked the
* function returned success over a savegame that was never written.
*
* `/dev/full` reproduces that exactly: writes to it are accepted, and the
* ENOSPC surfaces at the flush. Verified against the unfixed code, which
* reported a clean save.
*
* Linux-specific, so it skips rather than fails where the device is not there.
* That is the right trade for a test whose alternative is filling a real
* filesystem.
*/
akerr_ErrorContext *test_game_save_reports_a_failed_close(void)
{
PREPARE_ERROR(e);
char fullpath[] = "/dev/full";
FILE *probe = NULL;
ATTEMPT {
probe = fopen(fullpath, "wb");
if ( probe == NULL ) {
printf(" skipping the failed-close case: %s is not writable here\n", fullpath);
SUCCEED_BREAK(e);
}
fclose(probe);
probe = NULL;
CATCH(e, akgl_registry_init());
CATCH(e, akgl_heap_init());
set_game_identity();
TEST_EXPECT_ANY_ERROR(e, akgl_game_save((char *)&fullpath),
"saving to a device with no room");
} CLEANUP {
if ( probe != NULL ) {
fclose(probe);
}
} 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 A save with a registered spritesheet must read back.
*
* The four name tables carry no length prefix, so the reader finds each entry
* by stepping a fixed width. The writer used each object's own maximum name
* length -- 512 for a spritesheet, which is a filename -- and the reader used
* AKGL_ACTOR_MAX_NAME_LENGTH for all four. The other three are 128 as well, so
* only the spritesheet table was wrong, and that was enough: every entry after
* it was read out of the middle of its neighbour.
*
* The existing roundtrip test passed because empty registries write nothing but
* the zeroed sentinel. This one puts a name in each of the four registries, and
* a long one in the spritesheet registry, so the widths actually have to agree.
*
* The registry values are placeholder pointers rather than real objects: the
* save tables record name-to-address pairs and the loader looks each name up in
* the live registry, so what the pointers point at never matters here.
*/
akerr_ErrorContext *test_game_save_roundtrip_with_a_spritesheet(void)
{
PREPARE_ERROR(e);
char longsheetname[AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH];
akgl_Actor placeholder_actor;
akgl_Sprite placeholder_sprite;
akgl_SpriteSheet placeholder_sheet;
akgl_Character placeholder_character;
int i = 0;
ATTEMPT {
CATCH(e, akgl_registry_init());
CATCH(e, akgl_heap_init());
set_game_identity();
// A spritesheet name that does not fit the width the reader used to
// assume. Filled to just under the field so the terminator still fits.
memset(&longsheetname, 0x00, sizeof(longsheetname));
for ( i = 0; i < (AKGL_SPRITE_SHEET_MAX_FILENAME_LENGTH - 1); i++ ) {
longsheetname[i] = 'a' + (i % 26);
}
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_ACTOR, "roundtrip_actor",
(void *)&placeholder_actor),
AKERR_KEY, "could not register the actor");
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_SPRITE, "roundtrip_sprite",
(void *)&placeholder_sprite),
AKERR_KEY, "could not register the sprite");
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_SPRITESHEET, (char *)&longsheetname,
(void *)&placeholder_sheet),
AKERR_KEY, "could not register the spritesheet");
FAIL_ZERO_BREAK(e, SDL_SetPointerProperty(AKGL_REGISTRY_CHARACTER, "roundtrip_character",
(void *)&placeholder_character),
AKERR_KEY, "could not register the character");
TEST_EXPECT_OK(e, akgl_game_save((char *)&savepath),
"saving a game with all four registries populated");
// The load is what walks the four tables in order. If any width
// disagrees with the writer's, the table after it starts mid-entry and
// the read runs off the end of the file.
TEST_EXPECT_OK(e, akgl_game_load((char *)&savepath),
"loading back a save with a registered spritesheet");
TEST_ASSERT(e, strncmp((char *)&akgl_game.name, "libakgl test game", 256) == 0,
"the game identity did not survive the roundtrip");
} CLEANUP {
unlink((char *)&savepath);
} 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());
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_save_reports_a_failed_close());
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_save_roundtrip_with_a_spritesheet());
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);
}