/**
 * @file perf_render.c
 * @brief Stress and timing benchmarks for the paths that need a renderer.
 *
 * The companion to `tests/perf.c`, which covers everything that does not draw.
 * Everything here runs against a software renderer under the dummy video driver
 * at #BENCH_SCREEN_W x #BENCH_SCREEN_H -- no display, no window shown, no GPU.
 *
 * A software renderer is the right instrument for this even though no shipped
 * game uses one. What these benchmarks are measuring is the work libakgl does
 * *per drawn thing* -- how many blits a screenful of tiles turns into, how many
 * lookups an actor costs before its texture moves, what a line of HUD text
 * allocates and throws away every frame. A software renderer keeps that work in
 * the same process where it can be timed, and its per-blit cost is at least
 * honest about how much pixel traffic was asked for. Read these numbers as
 * *counts of work*, not as frame rates a real game would see.
 *
 * The tilemap benchmarks build their map by hand rather than loading the 2x2
 * fixture. A 2x2 map measures nothing: the point of the exercise is a full
 * screen of tiles -- #BENCH_MAP_W x #BENCH_MAP_H cells at #BENCH_TILE_SIZE
 * pixels, of which the camera covers 40 x 30 -- and the second variant repeats
 * it with eight tilesets, because akgl_tilemap_draw scans every tileset for
 * every tile and never stops at the one that matched.
 */

#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <string.h>
#include <akerror.h>

#include <akgl/error.h>
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/actor.h>
#include <akgl/character.h>
#include <akgl/sprite.h>
#include <akgl/physics.h>
#include <akgl/renderer.h>
#include <akgl/draw.h>
#include <akgl/text.h>
#include <akgl/tilemap.h>
#include <akgl/staticstring.h>

#include "benchutil.h"
#include "testutil.h"

/** @brief Width of the offscreen render target every benchmark draws into. */
#define BENCH_SCREEN_W          640
/** @brief Height of that target. */
#define BENCH_SCREEN_H          480
/** @brief Edge of one cell in the synthetic map, in pixels. */
#define BENCH_TILE_SIZE         16
/** @brief Width of the synthetic map, in tiles. Bigger than the camera on purpose. */
#define BENCH_MAP_W             128
/** @brief Height of the synthetic map, in tiles. */
#define BENCH_MAP_H             128
/** @brief Tilesets in the many-tileset variant of the draw benchmark. */
#define BENCH_MAP_TILESETS      8
/** @brief Actors the scene-wide benchmarks put on the heap. */
#define BENCH_ACTOR_COUNT       AKGL_MAX_HEAP_ACTOR
/** @brief The tileset image the synthetic map cuts its tiles from: 768x576, 48x36 tiles. */
#define BENCH_TILESET_IMAGE     "assets/World_A1.png"
/** @brief The font the text benchmarks rasterize with. */
#define BENCH_FONT_PATH         "assets/akgl_test_mono.ttf"
/** @brief Point size for that font. */
#define BENCH_FONT_SIZE         16
/** @brief A representative line of HUD text: a score readout. */
#define BENCH_TEXT              "SCORE 000123456"

/** @brief The font opened once in main and used by the text benchmarks. */
static TTF_Font *benchfont = NULL;
/** @brief The tileset texture the synthetic map borrows. Destroyed in main. */
static SDL_Texture *benchtiles = NULL;

/**
 * @brief Stop the clock, but only after the renderer has done what it was asked.
 *
 * SDL batches. A `draw_*` call queues a command and returns, and the queue is
 * not executed until something forces it: a present, a readback, or the
 * destruction of a texture it refers to. Without a flush inside the timed
 * region, every drawing benchmark here measures the cost of *queueing* work and
 * none of the cost of doing it -- and the bill arrives at teardown, where
 * SDL_DestroyTexture on the tileset spent nearly two minutes executing blits
 * that six earlier benchmarks had queued and been given credit for not doing.
 *
 * So the flush goes inside the measurement: what these benchmarks report is N
 * calls plus the drawing those N calls asked for, which is the number a caller
 * actually pays over a frame.
 */
#define BENCH_FLUSH_STOP(e, ops)                                        \
    {                                                                   \
	bool __bench_flushed = SDL_FlushRenderer(akgl_renderer->sdl_renderer); \
	bench_stop(ops);                                                \
	FAIL_ZERO_RETURN(e, __bench_flushed, AKGL_ERR_SDL, "SDL_FlushRenderer: %s", SDL_GetError()); \
    }

/**
 * @brief Swallow SDL's log output. See the same function in tests/perf.c.
 */
static void bench_discard_log(void *userdata, int category, SDL_LogPriority priority, const char *message)
{
    return;
}

/**
 * @brief Build a map by hand: one tile layer, @p tilesets tilesets, every cell filled.
 *
 * The cells all draw from the *last* tileset, which is the worst case for
 * akgl_tilemap_draw's inner scan and the reason the many-tileset variant exists.
 * Global ids are laid out so that tileset k owns [1 + k*tilecount, ...], which
 * keeps each cell matching exactly one tileset -- the draw loop has no `break`,
 * so overlapping ranges would blit the same cell more than once and measure
 * something that cannot happen with a map Tiled produced.
 *
 * @param map      The tilemap to fill in. Required. Zeroed first.
 * @param tilesets How many tilesets to declare, all sharing one texture.
 */
static akerr_ErrorContext *bench_build_map(akgl_Tilemap *map, int tilesets)
{
    PREPARE_ERROR(errctx);
    int columns = 0;
    int rows = 0;
    int tilecount = 0;
    int lastgid = 0;
    int i = 0;
    int cell = 0;

    FAIL_ZERO_RETURN(errctx, map, AKERR_NULLPOINTER, "map");
    FAIL_ZERO_RETURN(errctx, benchtiles, AKERR_NULLPOINTER, "benchtiles");
    FAIL_NONZERO_RETURN(
	errctx,
	((tilesets < 1) || (tilesets > AKGL_TILEMAP_MAX_TILESETS)),
	AKERR_OUTOFBOUNDS,
	"%d tilesets is outside 1..%d",
	tilesets,
	AKGL_TILEMAP_MAX_TILESETS);

    memset(map, 0x00, sizeof(akgl_Tilemap));
    columns = benchtiles->w / BENCH_TILE_SIZE;
    rows = benchtiles->h / BENCH_TILE_SIZE;
    tilecount = columns * rows;

    map->tilewidth = BENCH_TILE_SIZE;
    map->tileheight = BENCH_TILE_SIZE;
    map->width = BENCH_MAP_W;
    map->height = BENCH_MAP_H;
    map->numlayers = 1;
    map->numtilesets = tilesets;

    for ( i = 0; i < tilesets; i++ ) {
	map->tilesets[i].firstgid = 1 + (i * tilecount);
	map->tilesets[i].tilecount = tilecount;
	map->tilesets[i].columns = columns;
	map->tilesets[i].tilewidth = BENCH_TILE_SIZE;
	map->tilesets[i].tileheight = BENCH_TILE_SIZE;
	map->tilesets[i].imagewidth = benchtiles->w;
	map->tilesets[i].imageheight = benchtiles->h;
	map->tilesets[i].texture = benchtiles;
	snprintf((char *)&map->tilesets[i].name, AKGL_TILEMAP_MAX_TILESET_NAME_SIZE, "benchtileset%d", i);
	PASS(errctx, akgl_tilemap_compute_tileset_offsets(map, i));
    }

    lastgid = map->tilesets[tilesets - 1].firstgid;
    map->layers[0].type = AKGL_TILEMAP_LAYER_TYPE_TILES;
    map->layers[0].width = BENCH_MAP_W;
    map->layers[0].height = BENCH_MAP_H;
    map->layers[0].visible = true;
    map->layers[0].opacity = 1.0;
    for ( i = 0; i < (BENCH_MAP_W * BENCH_MAP_H); i++ ) {
	cell = i % tilecount;
	map->layers[0].data[i] = lastgid + cell;
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Load the test sprite and character, then fill the actor pool with actors wearing it.
 *
 * These are the real assets, loaded through the real loaders, so the actors have
 * a texture-backed sprite and akgl_actor_render takes its full path rather than
 * bailing out at the missing-sprite branch. Facing is pinned by turning
 * `movement_controls_face` off: the automatic face logic clears every facing bit
 * for an actor that is not moving, and the fixture character maps its sprite to
 * `ALIVE | FACE_LEFT`.
 */
static akerr_ErrorContext *bench_populate_scene(void)
{
    PREPARE_ERROR(errctx);
    akgl_Actor *actor = NULL;
    char name[AKGL_ACTOR_MAX_NAME_LENGTH];
    int i = 0;

    PASS(errctx, akgl_heap_init());
    PASS(errctx, akgl_registry_init());
    PASS(errctx, akgl_sprite_load_json("assets/testsprite.json"));
    PASS(errctx, akgl_sprite_load_json("assets/testsprite2.json"));
    PASS(errctx, akgl_character_load_json("assets/testcharacter.json"));

    for ( i = 0; i < BENCH_ACTOR_COUNT; i++ ) {
	memset(&name, 0x00, sizeof(name));
	snprintf((char *)&name, AKGL_ACTOR_MAX_NAME_LENGTH, "benchactor%d", i);
	PASS(errctx, akgl_heap_next_actor(&actor));
	PASS(errctx, akgl_actor_initialize(actor, (char *)&name));
	PASS(errctx, akgl_actor_set_character(actor, "testcharacter"));
	actor->visible = true;
	actor->layer = 0;
	actor->movement_controls_face = false;
	actor->x = (float32_t)((i * 37) % BENCH_SCREEN_W);
	actor->y = (float32_t)((i * 23) % BENCH_SCREEN_H);
	actor->curSpriteFrameId = 0;
	AKGL_BITMASK_ADD(actor->state, (AKGL_ACTOR_STATE_ALIVE | AKGL_ACTOR_STATE_FACE_LEFT));
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Time the empty frame: clear the target and present it.
 *
 * Everything else a frame does is on top of this. It is also the only benchmark
 * here whose cost is entirely SDL's rather than libakgl's, which makes it the
 * baseline the rest are worth reading against.
 */
static akerr_ErrorContext *bench_frame(void)
{
    PREPARE_ERROR(errctx);
    akerr_ErrorContext *inner = NULL;
    int count = bench_iterations(5000);
    int i = 0;
    int rep = 0;

    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	bench_start("frame_start + frame_end, 640x480", "frame", 230000.0);
	for ( i = 0; i < count; i++ ) {
	    inner = akgl_renderer->frame_start(akgl_renderer);
	    if ( inner != NULL ) {
		break;
	    }
	    inner = akgl_renderer->frame_end(akgl_renderer);
	    if ( inner != NULL ) {
		break;
	    }
	}
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Time the immediate-mode primitives.
 *
 * Each of these saves the renderer's draw colour, sets its own, draws, and puts
 * the colour back, so the fixed cost per call is three SDL round trips whatever
 * the shape. The shapes are sized like something a game would actually draw: a
 * full-diagonal line, a dialogue-box-sized rectangle, a 64 pixel radius circle.
 */
static akerr_ErrorContext *bench_primitives(void)
{
    PREPARE_ERROR(errctx);
    akerr_ErrorContext *inner = NULL;
    SDL_Color red = { 0xff, 0x00, 0x00, 0xff };
    SDL_FRect rect = { .x = 32.0, .y = 32.0, .w = 200.0, .h = 150.0 };
    int count = bench_iterations(100000);
    int filled = bench_iterations(2000);
    int circles = bench_iterations(20000);
    int i = 0;
    int rep = 0;

    PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	bench_start("draw_point", "call", 1400.0);
	BENCH_LOOP(inner, i, count, akgl_draw_point(akgl_renderer, 320.0, 240.0, red));
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);

	bench_start("draw_line, screen diagonal", "call", 6200.0);
	BENCH_LOOP(inner, i, count, akgl_draw_line(akgl_renderer, 0.0, 0.0, 639.0, 479.0, red));
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);

	bench_start("draw_rect, 200x150 outline", "call", 5000.0);
	BENCH_LOOP(inner, i, count, akgl_draw_rect(akgl_renderer, &rect, red));
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);

	bench_start("draw_filled_rect, 200x150", "call", 440000.0);
	BENCH_LOOP(inner, i, filled, akgl_draw_filled_rect(akgl_renderer, &rect, red));
	BENCH_FLUSH_STOP(errctx, filled);
	PASS(errctx, inner);

	bench_start("draw_circle, radius 64", "call", 25000.0);
	BENCH_LOOP(inner, i, circles, akgl_draw_circle(akgl_renderer, 320.0, 240.0, 64.0, red));
	BENCH_FLUSH_STOP(errctx, circles);
	PASS(errctx, inner);
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Time the framebuffer round trips: save a region, put it back, flood fill.
 *
 * These are the expensive ones by construction. A copy reads pixels back off the
 * render target; a paste uploads a texture, draws it, and destroys it again; and
 * a flood fill reads back the *entire* target, converts the whole surface to
 * RGBA32, walks it, uploads a patch, and destroys everything. That is a
 * megabyte and a half of traffic for one fill at 640x480, whatever the size of
 * the region that actually changed.
 *
 * The fill alternates colours because a fill whose seed pixel already carries
 * the requested colour returns immediately, and would otherwise measure the
 * early exit rather than the fill.
 */
static akerr_ErrorContext *bench_framebuffer(void)
{
    PREPARE_ERROR(errctx);
    akerr_ErrorContext *inner = NULL;
    SDL_Surface *saved = NULL;
    SDL_Rect region = { .x = 64, .y = 64, .w = 64, .h = 64 };
    SDL_Color red = { 0xff, 0x00, 0x00, 0xff };
    SDL_Color blue = { 0x00, 0x00, 0xff, 0xff };
    int count = bench_iterations(5000);
    int fills = bench_iterations(200);
    int i = 0;
    int rep = 0;

    PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
    PASS(errctx, akgl_draw_copy_region(akgl_renderer, &region, &saved));

    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	bench_start("draw_copy_region, 64x64 readback", "call", 10000.0);
	for ( i = 0; i < count; i++ ) {
	    SDL_Surface *scratch = NULL;
	    inner = akgl_draw_copy_region(akgl_renderer, &region, &scratch);
	    if ( inner != NULL ) {
		break;
	    }
	    SDL_DestroySurface(scratch);
	}
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);

	bench_start("draw_paste_region, 64x64 upload", "call", 55000.0);
	BENCH_LOOP(inner, i, count, akgl_draw_paste_region(akgl_renderer, saved, 64.0, 64.0));
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);

	bench_start("draw_flood_fill, full 640x480 target", "call", 20000000.0);
	for ( i = 0; i < fills; i++ ) {
	    inner = akgl_draw_flood_fill(akgl_renderer, 320, 240, ( (i % 2) == 0 ) ? red : blue);
	    if ( inner != NULL ) {
		break;
	    }
	}
	BENCH_FLUSH_STOP(errctx, fills);
	PASS(errctx, inner);
	PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
    }
    SDL_DestroySurface(saved);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Time measuring and drawing one line of text.
 *
 * akgl_text_rendertextat rasterizes the string, uploads it as a texture, blits
 * it, and destroys the texture again -- every call, with no cache anywhere. A
 * HUD with six readouts pays this six times a frame for text that changes once a
 * second.
 */
static akerr_ErrorContext *bench_text(void)
{
    PREPARE_ERROR(errctx);
    akerr_ErrorContext *inner = NULL;
    SDL_Color white = { 0xff, 0xff, 0xff, 0xff };
    int w = 0;
    int h = 0;
    int count = bench_iterations(20000);
    int lines = bench_iterations(5000);
    int i = 0;
    int rep = 0;

    PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	bench_start("text_measure, 15 characters", "call", 400.0);
	BENCH_LOOP(inner, i, count, akgl_text_measure(benchfont, BENCH_TEXT, &w, &h));
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);

	bench_start("text_rendertextat, 15 characters", "call", 130000.0);
	BENCH_LOOP(inner, i, lines, akgl_text_rendertextat(benchfont, BENCH_TEXT, white, 0, 8, 8));
	BENCH_FLUSH_STOP(errctx, lines);
	PASS(errctx, inner);
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Time loading assets off disk: a sprite, a character, and a whole map.
 *
 * This is startup and level-transition cost, not frame cost, and it is measured
 * because it is the part of a game the player waits through. The sprite load
 * after the first reuses the already registered spritesheet, so what is measured
 * is the JSON path rather than the image decode.
 *
 * @note Each character load leaks the SDL property set holding its
 *       state-to-sprite map -- akgl_heap_release_character does not walk it.
 *       That is a known defect rather than something this benchmark introduces;
 *       it is repeated here often enough to be worth saying out loud.
 */
static akerr_ErrorContext *bench_asset_load(void)
{
    PREPARE_ERROR(errctx);
    akerr_ErrorContext *inner = NULL;
    akgl_Sprite *sprite = NULL;
    akgl_Character *basechar = NULL;
    int count = bench_iterations(2000);
    int maps = bench_iterations(50);
    int i = 0;
    int j = 0;
    int rep = 0;

    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	PASS(errctx, bench_populate_scene());

	bench_start("sprite_load_json, sheet already loaded", "load", 180000.0);
	for ( i = 0; i < count; i++ ) {
	    inner = akgl_sprite_load_json("assets/testsprite.json");
	    if ( inner != NULL ) {
		break;
	    }
	    sprite = SDL_GetPointerProperty(AKGL_REGISTRY_SPRITE, "testsprite", NULL);
	    if ( sprite == NULL ) {
		break;
	    }
	    inner = akgl_heap_release_sprite(sprite);
	    if ( inner != NULL ) {
		break;
	    }
	}
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);

	PASS(errctx, bench_populate_scene());
	bench_start("character_load_json, two sprite mappings", "load", 150000.0);
	for ( i = 0; i < count; i++ ) {
	    inner = akgl_character_load_json("assets/testcharacter.json");
	    if ( inner != NULL ) {
		break;
	    }
	    basechar = SDL_GetPointerProperty(AKGL_REGISTRY_CHARACTER, "testcharacter", NULL);
	    if ( basechar == NULL ) {
		break;
	    }
	    inner = akgl_heap_release_character(basechar);
	    if ( inner != NULL ) {
		break;
	    }
	}
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);

	PASS(errctx, bench_populate_scene());
	bench_start("tilemap_load + release, fixture map", "load", 120000000.0);
	for ( i = 0; i < maps; i++ ) {
	    // The map's object layer spawns actors into the pool, and neither
	    // akgl_tilemap_release nor anything else gives them back, so a loop
	    // that only loaded and released would exhaust the actor pool on its
	    // 32nd iteration. Clearing the pool is part of what a host does
	    // between levels anyway, and it is a pair of memsets against a map
	    // load that decodes a 768x576 PNG.
	    inner = akgl_heap_init_actor();
	    if ( inner != NULL ) {
		break;
	    }
	    inner = akgl_registry_init_actor();
	    if ( inner != NULL ) {
		break;
	    }
	    // And the string pool has to be reclaimed by hand, because a map
	    // load leaks five pooled strings that release does not give back.
	    // The 52nd load would find the pool empty, and what happens then is
	    // not an AKGL_ERR_HEAP: akgl_get_json_string_value finishes with
	    // pass-up false, swallows the failed claim, and dereferences the
	    // pointer it never set. Both defects are recorded in TODO.md; this loop
	    // works around the first so it never reaches the second.
	    for ( j = 0; j < AKGL_MAX_HEAP_STRING; j++ ) {
		akgl_heap_strings[j].refcount = 0;
	    }
	    inner = akgl_tilemap_load("assets/testmap.tmj", akgl_gamemap);
	    if ( inner != NULL ) {
		break;
	    }
	    inner = akgl_tilemap_release(akgl_gamemap);
	    if ( inner != NULL ) {
		break;
	    }
	}
	BENCH_FLUSH_STOP(errctx, maps);
	PASS(errctx, inner);
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Time zeroing an akgl_Tilemap, which is the first thing a map load does.
 *
 * akgl_tilemap_load begins with `memset(dest, 0, sizeof(akgl_Tilemap))`, and an
 * akgl_Tilemap is 16 layers of 512x512 cells plus 16 tilesets of 65536 offset
 * pairs. The struct is that size whether the map is 2x2 or 512x512, so this is a
 * fixed toll on every level transition, paid before a single byte of the map
 * file has been read. Measuring it separately is the only way to tell how much
 * of the load benchmark above is the map and how much is the container.
 */
static akerr_ErrorContext *bench_map_zero(void)
{
    PREPARE_ERROR(errctx);
    int count = bench_iterations(200);
    int i = 0;
    int rep = 0;

    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	bench_start("zeroing one akgl_Tilemap", "call", 16000000.0);
	for ( i = 0; i < count; i++ ) {
	    memset(akgl_gamemap, 0x00, sizeof(akgl_Tilemap));
	}
	BENCH_FLUSH_STOP(errctx, count);
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Time building a tileset's offset table.
 *
 * akgl_tilemap_compute_tileset_offsets fills one entry per tile in the image --
 * 1728 of them for the fixture tileset -- and a map pays it once per tileset at
 * load time. It is also the function whose table costs 512 KB per tileset,
 * which is the reason an akgl_Tilemap is as large as it is.
 */
static akerr_ErrorContext *bench_tileset_offsets(void)
{
    PREPARE_ERROR(errctx);
    akerr_ErrorContext *inner = NULL;
    int count = bench_iterations(20000);
    int i = 0;
    int rep = 0;

    PASS(errctx, bench_build_map(akgl_gamemap, 1));
    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	bench_start("tilemap_compute_tileset_offsets, 1728 tiles", "call", 23000.0);
	BENCH_LOOP(inner, i, count, akgl_tilemap_compute_tileset_offsets(akgl_gamemap, 0));
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Time drawing a screenful of tiles, with one tileset and with eight.
 *
 * The camera covers 40 x 30 cells, so each of these is 1200 blits plus whatever
 * the tileset scan costs on top. The gap between the two numbers is that scan:
 * akgl_tilemap_draw walks every tileset for every tile and does not stop at the
 * one that matched, so the cost is linear in tileset count even though at most
 * one can ever answer.
 */
static akerr_ErrorContext *bench_tilemap_draw(void)
{
    PREPARE_ERROR(errctx);
    akerr_ErrorContext *inner = NULL;
    int count = bench_iterations(30);
    int i = 0;
    int rep = 0;

    PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
    PASS(errctx, bench_build_map(akgl_gamemap, 1));
    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	bench_start("tilemap_draw, 40x30 tiles, 1 tileset", "frame", 170000000.0);
	BENCH_LOOP(inner, i, count, akgl_tilemap_draw(akgl_gamemap, akgl_camera, 0));
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);
    }

    PASS(errctx, bench_build_map(akgl_gamemap, BENCH_MAP_TILESETS));
    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	bench_start("tilemap_draw, 40x30 tiles, 8 tilesets", "frame", 170000000.0);
	BENCH_LOOP(inner, i, count, akgl_tilemap_draw(akgl_gamemap, akgl_camera, 0));
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);
    }
    printf("tilemap_draw covers %d x %d = %d tiles per frame\n",
	   (BENCH_SCREEN_W / BENCH_TILE_SIZE),
	   (BENCH_SCREEN_H / BENCH_TILE_SIZE),
	   ((BENCH_SCREEN_W / BENCH_TILE_SIZE) * (BENCH_SCREEN_H / BENCH_TILE_SIZE)));
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The control: the same 1200 blits, issued straight to SDL.
 *
 * Without this, every drawing number in this file is unreadable -- there is no
 * way to tell how much of a 16 ms tilemap frame is libakgl deciding what to draw
 * and how much is the software rasterizer moving pixels. This does the same
 * amount of pixel work with none of the library in the way: same texture, same
 * source rectangles, same destinations, no bounds logic, no tileset scan, no
 * error contexts. Subtract it from the tilemap number and what is left is what
 * libakgl costs.
 */
static akerr_ErrorContext *bench_raw_blit_control(void)
{
    PREPARE_ERROR(errctx);
    SDL_FRect src = { .x = 0.0, .y = 0.0, .w = BENCH_TILE_SIZE, .h = BENCH_TILE_SIZE };
    SDL_FRect dest = { .x = 0.0, .y = 0.0, .w = BENCH_TILE_SIZE, .h = BENCH_TILE_SIZE };
    int across = (BENCH_SCREEN_W / BENCH_TILE_SIZE);
    int down = (BENCH_SCREEN_H / BENCH_TILE_SIZE);
    int columns = 0;
    int tilecount = 0;
    int tile = 0;
    int count = bench_iterations(30);
    int i = 0;
    int x = 0;
    int y = 0;
    int rep = 0;

    FAIL_ZERO_RETURN(errctx, benchtiles, AKERR_NULLPOINTER, "benchtiles");
    columns = benchtiles->w / BENCH_TILE_SIZE;
    tilecount = columns * (benchtiles->h / BENCH_TILE_SIZE);

    PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	// One source tile, blitted 1200 times. The floor: 1200 SDL calls and
	// 1200 x 256 pixels of copying, with the source rectangle staying in
	// cache the whole way.
	bench_start("raw SDL blits, one source tile (control)", "frame", 5000000.0);
	for ( i = 0; i < count; i++ ) {
	    src.x = 0.0;
	    src.y = 0.0;
	    for ( y = 0; y < down; y++ ) {
		for ( x = 0; x < across; x++ ) {
		    dest.x = (float32_t)(x * BENCH_TILE_SIZE);
		    dest.y = (float32_t)(y * BENCH_TILE_SIZE);
		    SDL_RenderTexture(akgl_renderer->sdl_renderer, benchtiles, &src, &dest);
		}
	    }
	}
	BENCH_FLUSH_STOP(errctx, count);

	// The same 1200 blits reading the same scattered source tiles the map
	// asks for: cell (x, y) of a BENCH_MAP_W-wide map, so consecutive screen
	// rows read parts of the sheet BENCH_MAP_W tiles apart rather than
	// walking it in order. This is the honest control for
	// akgl_tilemap_draw -- same call count, same pixels, same access
	// pattern, none of libakgl in the path -- and the gap between the two is
	// what the library's own per-tile work costs.
	bench_start("raw SDL blits, map order (control)", "frame", 170000000.0);
	for ( i = 0; i < count; i++ ) {
	    for ( y = 0; y < down; y++ ) {
		for ( x = 0; x < across; x++ ) {
		    tile = (x + (y * BENCH_MAP_W)) % tilecount;
		    src.x = (float32_t)((tile % columns) * BENCH_TILE_SIZE);
		    src.y = (float32_t)((tile / columns) * BENCH_TILE_SIZE);
		    dest.x = (float32_t)(x * BENCH_TILE_SIZE);
		    dest.y = (float32_t)(y * BENCH_TILE_SIZE);
		    SDL_RenderTexture(akgl_renderer->sdl_renderer, benchtiles, &src, &dest);
		}
	    }
	}
	BENCH_FLUSH_STOP(errctx, count);
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Time one actor's render, and then the whole scene through the backend.
 *
 * A render is two state-to-sprite lookups -- one for the sprite, one inside the
 * visibility test -- some frame arithmetic, and one blit. The scene benchmark
 * puts that together with the tilemap: one layer of tiles plus 64 actors, which
 * is what akgl_render_2d_draw_world does for a modest 2D game.
 */
static akerr_ErrorContext *bench_scene(void)
{
    PREPARE_ERROR(errctx);
    akerr_ErrorContext *inner = NULL;
    akgl_Actor *actor = NULL;
    int count = bench_iterations(20000);
    int frames = bench_iterations(30);
    int i = 0;
    int rep = 0;

    PASS(errctx, bench_populate_scene());
    PASS(errctx, bench_build_map(akgl_gamemap, 1));
    PASS(errctx, akgl_renderer->frame_start(akgl_renderer));
    actor = &akgl_heap_actors[0];

    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	bench_start("actor_render, on camera", "actor", 31000.0);
	BENCH_LOOP(inner, i, count, actor->renderfunc(actor));
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);

	bench_start("draw_world, 1200 tiles + 64 actors", "frame", 170000000.0);
	BENCH_LOOP(inner, i, frames, akgl_renderer->draw_world(akgl_renderer, NULL));
	BENCH_FLUSH_STOP(errctx, frames);
	PASS(errctx, inner);
    }
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Time a whole frame the way akgl_game_update runs one.
 *
 * This is the number a host actually gets, and it is not the sum of the parts.
 *
 * It used to be measurably worse than that: the update sweep was nested inside
 * a loop over #AKGL_TILEMAP_MAX_LAYERS that never filtered by layer, so every
 * live actor updated sixteen times a frame, and this benchmark sat about 92 us
 * above the scene benchmark below it. That gap *was* the multiplier. Since
 * 0.5.0 the two are equal within run-to-run noise, which is the shape of the
 * fix rather than a number worth quoting on its own.
 */
static akerr_ErrorContext *bench_game_update(void)
{
    PREPARE_ERROR(errctx);
    akerr_ErrorContext *inner = NULL;
    int count = bench_iterations(30);
    int i = 0;
    int rep = 0;

    PASS(errctx, bench_populate_scene());
    PASS(errctx, bench_build_map(akgl_gamemap, 1));
    PASS(errctx, akgl_physics_init_null(akgl_physics));

    for ( rep = 0; rep < AKGL_BENCH_REPETITIONS; rep++ ) {
	bench_start("game_update, full frame", "frame", 170000000.0);
	BENCH_LOOP(inner, i, count, akgl_game_update(NULL));
	BENCH_FLUSH_STOP(errctx, count);
	PASS(errctx, inner);
    }
    SUCCEED_RETURN(errctx);
}

int main(void)
{
    PREPARE_ERROR(errctx);

    SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
    SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
    SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
    SDL_SetLogOutputFunction(bench_discard_log, NULL);

    ATTEMPT {
	CATCH(errctx, akgl_error_init());
	akgl_renderer = &akgl_default_renderer;
	akgl_physics = &akgl_default_physics;
	akgl_camera = &akgl_default_camera;
	akgl_gamemap = &akgl_default_gamemap;

	FAIL_ZERO_BREAK(
	    errctx,
	    SDL_Init(SDL_INIT_VIDEO),
	    AKGL_ERR_SDL,
	    "Couldn't initialize SDL: %s",
	    SDL_GetError());
	FAIL_ZERO_BREAK(
	    errctx,
	    TTF_Init(),
	    AKGL_ERR_SDL,
	    "Couldn't initialize the font engine: %s",
	    SDL_GetError());
	FAIL_ZERO_BREAK(
	    errctx,
	    SDL_CreateWindowAndRenderer(
		"net/aklabs/libakgl/test_perf_render",
		BENCH_SCREEN_W,
		BENCH_SCREEN_H,
		0,
		&akgl_window,
		&akgl_renderer->sdl_renderer),
	    AKGL_ERR_SDL,
	    "Couldn't create window/renderer: %s",
	    SDL_GetError());
	CATCH(errctx, akgl_render_2d_bind(akgl_renderer));

	akgl_camera->x = 0.0;
	akgl_camera->y = 0.0;
	akgl_camera->w = BENCH_SCREEN_W;
	akgl_camera->h = BENCH_SCREEN_H;

	// akgl_game_update takes this before it does anything else. Normally
	// akgl_game_init creates it; this suite brings the library up piece by
	// piece so it can stay headless, so it creates the mutex itself.
	akgl_game.statelock = SDL_CreateMutex();
	FAIL_ZERO_BREAK(errctx, akgl_game.statelock, AKGL_ERR_SDL, "%s", SDL_GetError());

	// akgl_game.lowfpsfunc is deliberately left NULL. It used to have to be
	// installed by hand here, because akgl_game_update_fps called it
	// unguarded on every frame under 30 fps -- which includes the first,
	// before there is a frame rate to compare against -- and a host that
	// binds its own renderer the way renderer.h documents never went through
	// akgl_game_init to get one. It installs the default itself now, and
	// leaving it NULL here is what keeps that true.

	benchfont = TTF_OpenFont(BENCH_FONT_PATH, BENCH_FONT_SIZE);
	FAIL_ZERO_BREAK(errctx, benchfont, AKGL_ERR_SDL, "Couldn't open %s: %s", BENCH_FONT_PATH, SDL_GetError());
	benchtiles = IMG_LoadTexture(akgl_renderer->sdl_renderer, BENCH_TILESET_IMAGE);
	FAIL_ZERO_BREAK(errctx, benchtiles, AKGL_ERR_SDL, "Couldn't load %s: %s", BENCH_TILESET_IMAGE, SDL_GetError());

	CATCH(errctx, akgl_heap_init());
	CATCH(errctx, akgl_registry_init());
	CATCH(errctx, akgl_registry_init_properties());

	CATCH(errctx, bench_frame());
	CATCH(errctx, bench_primitives());
	CATCH(errctx, bench_framebuffer());
	CATCH(errctx, bench_text());
	CATCH(errctx, bench_asset_load());
	CATCH(errctx, bench_map_zero());
	CATCH(errctx, bench_tileset_offsets());
	CATCH(errctx, bench_tilemap_draw());
	CATCH(errctx, bench_raw_blit_control());
	CATCH(errctx, bench_scene());
	CATCH(errctx, bench_game_update());

	BENCH_REPORT_BREAK(errctx);
    } CLEANUP {
	if ( benchtiles != NULL ) {
	    SDL_DestroyTexture(benchtiles);
	}
	if ( benchfont != NULL ) {
	    TTF_CloseFont(benchfont);
	}
	TTF_Quit();
	SDL_Quit();
    } PROCESS(errctx) {
    } FINISH_NORETURN(errctx);
}
