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
800 lines
28 KiB
C
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);
|
|
}
|