/**
 * @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);
}
