Updates deps/libakstdlib to 669b2b3. That commit is a TODO entry rather than new code, so the interesting part is what libakgl was not yet calling: it links libakstdlib and uses ten of its wrappers while calling the raw libc function at a good many more sites. Four of those were carrying a real defect. akgl_game_save checked every write and threw away the close. Every write goes through aksl_fwrite, and for a record this size all of them land in stdio's buffer -- nothing reaches the device until the close flushes it, so a full disk, an exceeded quota or an NFS server going away is reported only there. The function returned success over a savegame that was never written. aksl_fclose surfaces it. tests/game.c reaches the path through /dev/full, which accepts writes and fails at the flush; against the unfixed code the test reports "expected a failure, got success" with ENOSPC. The close has to drop the FILE * before the status is examined, because fclose(3) disassociates the stream either way and CLEANUP would close it again. Written the other way round it aborted in glibc with "double free detected in tcache" the first time a close actually failed, which is how that ordering was found. akgl_game_load's end-of-file check used fgetc(3), which spells "end of file" and "the read failed" with the same EOF -- a disk error there read as a clean end and passed the check it was meant to fail. aksl_fgetc tells them apart. Extracted to require_at_eof, because inverting the sense of a call inline needs a nested ATTEMPT whose break binds to the wrong switch. Two snprintf sites were truncating under a silenced -Wformat-truncation. The compiler was right about both. The tileset one handed the shortened path to IMG_LoadTexture, so a length error was reported as a missing file, with SDL naming a path the caller never wrote. Both use aksl_snprintf now and the suppression is gone; nothing in the tree uses those macros any more. tests/tilemap.c covers the join, and against the unfixed code it gets AKGL_ERR_SDL where it wants AKERR_OUTOFBOUNDS. The two src/game.c strncpy sites the fixed-width copy sweep missed -- the savegame name field and the libversion stamp -- use aksl_strncpy and sizeof rather than a repeated 32. Also makes tests/physics_sim.c deterministic. It placed gravity_time at "now - dt" and let the real clock supply the step, so a machine busy enough to deschedule the process between that store and the SDL_GetTicksNS() inside simulate got a longer step. It went red once, under a parallel ctest. It now sets max_timestep to the step it wants and gravity_time to zero, so the bound supplies the step and the scheduler cannot reach it. TODO.md records what is deliberately not adopted: the pure-arithmetic wrappers, which can only fail on NULL and which the tree already spells two ways, and the collections, which the registries do not want because SDL owns the property-set lifetime. AGENTS.md has the rule and the fclose ordering trap. 26/26 ctest, memcheck clean, warning-clean at -Wall -Werror. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8T5FAYXE8HEJqFLCYwNNc
801 lines
28 KiB
C
801 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());
|
|
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_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);
|
|
}
|