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