/**
 * @file akgl_backends.c
 * @brief Tests the libakgl-backed sink and device backends against real pixels.
 *
 * Everything here draws into a small software renderer under the dummy video
 * driver and reads the target back, which is the pattern
 * deps/libakgl/tests/draw.c established -- it needs no display, no offscreen
 * harness and no audio hardware.
 *
 * These are the only tests in this repository that link SDL, and they only build
 * under -DAKBASIC_WITH_AKGL=ON. Everything they exercise below the adaptor is
 * libakgl's own and is tested over there; what is asserted here is the seam:
 * that a BASIC verb reaches the right akgl call with the right geometry and the
 * right colour, and that the text sink puts characters where its own cursor says
 * they are.
 */

#include <string.h>

#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>

#include <akerror.h>

#include <akgl/controller.h>
#include <akgl/error.h>
/*
 * game.h purely for the `akgl_renderer` global and its `akgl_default_renderer` storage. The
 * interpreter never calls akgl_game_init() -- it drives subsystems directly, and
 * owning the game loop is exactly what goal 3 forbids -- but the draw calls read
 * that global, so a test standing in for a host has to populate it the same way
 * deps/libakgl/tests/draw.c does.
 */
#include <akgl/game.h>
#include <akgl/heap.h>
#include <akgl/registry.h>
#include <akgl/renderer.h>
#include <akgl/text.h>

#include <akbasic/akgl.h>
#include <akbasic/error.h>
#include <akbasic/sprite.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
#include <akbasic/ui.h>

#include "testutil.h"

/** @brief Width and height of the offscreen target. Small enough to read back whole. */
#define TARGET_SIZE	128

/* The interpreter carries every pool it owns, so it does not fit on a stack. */
static akbasic_Runtime RUNTIME;
static akbasic_TextSink SINK;
static akbasic_StdioSink SINKSTATE;
static akbasic_AkglSink AKGLSINKSTATE;
static akbasic_TextSink AKGLSINK;
static akbasic_GraphicsBackend GRAPHICS;
static akbasic_AkglGraphics GRAPHICSSTATE;
static akbasic_InputBackend INPUT;
static akbasic_SpriteBackend SPRITES;
static akbasic_UiBackend UI;
static akbasic_AkglUi UISTATE;
static akbasic_AkglSprites SPRITESSTATE;
static TTF_Font *font = NULL;
static char OUTPUT[8192];
static FILE *OUT = NULL;

/** @brief Report whether one pixel of @p shot carries @p color. Alpha is not compared. */
static bool pixel_is(SDL_Surface *shot, int x, int y, uint8_t r, uint8_t g, uint8_t b)
{
    uint8_t gotr = 0;
    uint8_t gotg = 0;
    uint8_t gotb = 0;
    uint8_t gota = 0;

    if ( shot == NULL ) {
	return false;
    }
    if ( !SDL_ReadSurfacePixel(shot, x, y, &gotr, &gotg, &gotb, &gota) ) {
	return false;
    }
    return ((gotr == r) && (gotg == g) && (gotb == b));
}

/** @brief Clear the target to opaque black so a drawn pixel is unambiguous. */
static akerr_ErrorContext AKERR_NOIGNORE *clear_target(void)
{
    PREPARE_ERROR(errctx);

    FAIL_ZERO_RETURN(errctx, SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0, 0, 0, 0xff),
		     AKGL_ERR_SDL, "%s", SDL_GetError());
    FAIL_ZERO_RETURN(errctx, SDL_RenderClear(akgl_renderer->sdl_renderer),
		     AKGL_ERR_SDL, "%s", SDL_GetError());
    SUCCEED_RETURN(errctx);
}

/** @brief Bring up a runtime with the akgl graphics backend attached. */
static akerr_ErrorContext AKERR_NOIGNORE *start_runtime(const char *source)
{
    PREPARE_ERROR(errctx);

    memset(OUTPUT, 0, sizeof(OUTPUT));
    OUT = fmemopen(OUTPUT, sizeof(OUTPUT), "w");
    FAIL_ZERO_RETURN(errctx, (OUT != NULL), AKERR_IO, "could not open the output buffer");
    setvbuf(OUT, NULL, _IONBF, 0);

    PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, OUT, NULL));
    PASS(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
    PASS(errctx, akbasic_graphics_init_akgl(&GRAPHICS, &GRAPHICSSTATE, akgl_renderer));
    PASS(errctx, akbasic_sprite_init_akgl(&SPRITES, &SPRITESSTATE, akgl_renderer, &GRAPHICSSTATE));
    PASS(errctx, akbasic_runtime_set_devices(&RUNTIME, &GRAPHICS, NULL, NULL, &SPRITES));
    PASS(errctx, akbasic_runtime_load(&RUNTIME, source));
    PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUN));
    PASS(errctx, akbasic_runtime_run(&RUNTIME, 0));
    SUCCEED_RETURN(errctx);
}

static void stop_runtime(void)
{
    /*
     * The sprite backend has to be torn down, not just dropped.
     *
     * This used to close the output file and nothing else, and got away with it
     * because akbasic_sprite_init_akgl() claimed no pooled objects -- the actors
     * and textures a program had created leaked, but at two or three per case
     * and 64 actor slots nothing ever ran out. Claiming eight collision proxies
     * per backend changed the arithmetic: twenty cases at eight apiece is a
     * hundred and sixty against a pool of a hundred and twenty-eight, and the
     * suite started failing in whichever case happened to be twelfth.
     *
     * A host releases what it took. So does this.
     */
    akbasic_sprite_akgl_shutdown(&SPRITES);
    /*
     * And the drawing layer, for the same reason: akbasic_graphics_init_akgl()
     * zeroes the state it is given, so a second start_runtime() would drop the
     * pointer to a live SDL texture on the floor.
     */
    akbasic_graphics_akgl_shutdown(&GRAPHICS);
    if ( OUT != NULL ) {
	fclose(OUT);
	OUT = NULL;
    }
}

/**
 * @brief A DRAW reaches real pixels, in the colour COLOR bound to the source.
 *
 * This is the assertion the whole adaptor exists for: a BASIC line in, a lit
 * pixel out, with nothing mocked in between.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_draw_reaches_pixels(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;

    PASS(errctx, clear_target());
    PASS(errctx, start_runtime("10 COLOR 1, 3\n20 DRAW 1, 10, 20\n"));
    TEST_REQUIRE_STR(OUTPUT, "");

    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    /* Palette index 3 is red: 0x88, 0x39, 0x32 in src/graphics_tables.c. */
    TEST_REQUIRE(pixel_is(shot, 10, 20, 0x88, 0x39, 0x32),
		 "DRAW 1,10,20 after COLOR 1,3 should have lit (10,20) red");
    TEST_REQUIRE(!pixel_is(shot, 11, 20, 0x88, 0x39, 0x32),
		 "DRAW should have lit one pixel, not two");
    SDL_DestroySurface(shot);
    stop_runtime();
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The renderer's own size is the space BASIC draws into.
 *
 * The one test that puts a real SDL renderer behind item 9. The target here is
 * 128x128 -- deliberately *smaller* than the 320x200 fallback -- so a `SCALE`
 * that still divided by the old constants would put its far corner at 2.5 times
 * the target's width and light nothing at all.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_size_is_the_renderer(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;

    PASS(errctx, clear_target());
    PASS(errctx, start_runtime("10 PRINT RGR(1)\n20 PRINT RGR(2)\n"));
    TEST_REQUIRE_STR(OUTPUT, "128\n128\n");
    stop_runtime();

    PASS(errctx, clear_target());
    PASS(errctx, start_runtime("10 SCALE 1, 1000, 1000\n"
			       "20 DRAW 1, 1000, 1000\n"));
    TEST_REQUIRE_STR(OUTPUT, "");

    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    /*
     * The far corner of the user space lands on the far corner of the target.
     * The old code divided by the 320x200 constants, which would have put this
     * at (400, 400) -- off a 128x128 surface entirely, lighting nothing.
     */
    TEST_REQUIRE(pixel_is(shot, TARGET_SIZE - 1, TARGET_SIZE - 1, 0xff, 0xff, 0xff),
		 "SCALE 1,1000,1000 then DRAW 1,1000,1000 should reach the bottom-right pixel");
    SDL_DestroySurface(shot);
    stop_runtime();
    SUCCEED_RETURN(errctx);
}

/** @brief A BOX outlines: its corners are lit and its middle is not. */
static akerr_ErrorContext AKERR_NOIGNORE *test_box_outlines(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;

    PASS(errctx, clear_target());
    PASS(errctx, start_runtime("10 BOX 1, 10, 10, 40, 40\n"));
    TEST_REQUIRE_STR(OUTPUT, "");

    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    TEST_REQUIRE(pixel_is(shot, 10, 10, 0xff, 0xff, 0xff), "the box's corner should be lit");
    TEST_REQUIRE(pixel_is(shot, 25, 10, 0xff, 0xff, 0xff), "the box's top edge should be lit");
    TEST_REQUIRE(!pixel_is(shot, 25, 25, 0xff, 0xff, 0xff),
		 "an unrotated BOX outlines rather than fills");
    SDL_DestroySurface(shot);
    stop_runtime();
    SUCCEED_RETURN(errctx);
}

/**
 * @brief PAINT fills the region a BOX encloses and stops at its edge.
 *
 * Also the one place the akgl flood fill is exercised through a BASIC verb,
 * which is where its partial-fill error would surface if it ever fired.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_paint_fills_region(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;

    PASS(errctx, clear_target());
    PASS(errctx, start_runtime("10 BOX 1, 10, 10, 40, 40\n"
			       "20 COLOR 2, 6\n"
			       "30 PAINT 2, 25, 25\n"));
    TEST_REQUIRE_STR(OUTPUT, "");

    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    /* Palette index 6 is green: 0x55, 0xa0, 0x49. */
    TEST_REQUIRE(pixel_is(shot, 25, 25, 0x55, 0xa0, 0x49),
		 "the inside of the box should have been painted green");
    TEST_REQUIRE(!pixel_is(shot, 50, 50, 0x55, 0xa0, 0x49),
		 "the paint should have stopped at the box's edge");
    SDL_DestroySurface(shot);
    stop_runtime();
    SUCCEED_RETURN(errctx);
}

/** @brief SSHAPE and GSHAPE round-trip a region through the handle in a string. */
static akerr_ErrorContext AKERR_NOIGNORE *test_shape_roundtrip(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;

    PASS(errctx, clear_target());
    PASS(errctx, start_runtime("10 BOX 1, 4, 4, 12, 12\n"
			       "20 SSHAPE A$, 0, 0, 16, 16\n"
			       "30 GSHAPE A$, 64, 64\n"));
    TEST_REQUIRE_STR(OUTPUT, "");

    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    /* The saved corner was at (4,4); pasted at (64,64) it lands at (68,68). */
    TEST_REQUIRE(pixel_is(shot, 68, 68, 0xff, 0xff, 0xff),
		 "the pasted shape should carry the box's corner");
    SDL_DestroySurface(shot);
    stop_runtime();
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The sink measures a character grid and puts text where its cursor says.
 *
 * akgl_text_measure() is the call this whole file was blocked on until libakgl
 * 42b60f7, so the grid it produces is worth asserting directly rather than only
 * through what gets drawn.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_sink_grid_and_wrap(void)
{
    PREPARE_ERROR(errctx);
    int cellw = 0;
    int cellh = 0;

    PASS(errctx, akgl_text_measure(font, "A", &cellw, &cellh));
    TEST_REQUIRE(cellw > 0 && cellh > 0, "the fixture font should measure a real cell");

    PASS(errctx, akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, akgl_renderer, font,
					TARGET_SIZE, TARGET_SIZE));
    TEST_REQUIRE_INT(AKGLSINKSTATE.cellw, cellw);
    TEST_REQUIRE_INT(AKGLSINKSTATE.columns, TARGET_SIZE / cellw);
    TEST_REQUIRE_INT(AKGLSINKSTATE.rows, TARGET_SIZE / cellh);

    PASS(errctx, AKGLSINK.writeln(&AKGLSINK, "HELLO"));
    PASS(errctx, AKGLSINK.write(&AKGLSINK, "WORLD"));
    TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "HELLO");
    TEST_REQUIRE_STR(AKGLSINKSTATE.text[1], "WORLD");
    TEST_REQUIRE_INT(AKGLSINKSTATE.cursorrow, 1);
    TEST_REQUIRE_INT(AKGLSINKSTATE.cursorcol, 5);

    /* A clear empties the grid and takes the cursor home. */
    PASS(errctx, AKGLSINK.clear(&AKGLSINK));
    TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "");
    TEST_REQUIRE_INT(AKGLSINKSTATE.cursorrow, 0);
    TEST_REQUIRE_INT(AKGLSINKSTATE.cursorcol, 0);

    /*
     * A text area too small for one character is refused rather than silently
     * producing a zero-column grid, which would divide by zero on the first
     * wrap.
     */
    TEST_REQUIRE_STATUS(akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, akgl_renderer, font, 1, 1),
			AKBASIC_ERR_BOUNDS);
    TEST_REQUIRE_STATUS(akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, NULL, font,
					       TARGET_SIZE, TARGET_SIZE),
			AKERR_NULLPOINTER);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief A character placed past a short row's end pads rather than vanishing.
 *
 * A row is a NUL-terminated string, so `CHAR 1, 6, 0, "#"` on an otherwise empty
 * row used to store the `#` at column 6 with `text[0][0]` still `'\0'` -- and
 * the render loop, which stops at the terminator, drew nothing at all. The write
 * succeeded, the cursor moved, the stdout mirror showed the character, and the
 * window stayed blank. That silent nothing is the trap; the documented
 * truncation on the way back is fine and is asserted here too.
 *
 * The padding has to be spaces rather than whatever is in the buffer, because
 * the buffer is not cleared between rows: the gap holds the tail of some longer
 * row that used to be here, which is the second case below.
 *
 * TODO.md section 6 item 32.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_sink_writes_past_a_short_row(void)
{
    PREPARE_ERROR(errctx);

    PASS(errctx, akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, akgl_renderer, font,
					TARGET_SIZE, TARGET_SIZE));
    PASS(errctx, AKGLSINK.moveto(&AKGLSINK, 6, 0));
    PASS(errctx, AKGLSINK.write(&AKGLSINK, "#"));
    TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "      #");

    /*
     * Writing back over a long row still truncates it -- that half is the
     * documented behaviour and the reason a program builds a row whole.
     */
    PASS(errctx, AKGLSINK.clear(&AKGLSINK));
    PASS(errctx, AKGLSINK.write(&AKGLSINK, "ABCDEFGHIJ"));
    PASS(errctx, AKGLSINK.moveto(&AKGLSINK, 3, 0));
    PASS(errctx, AKGLSINK.write(&AKGLSINK, "X"));
    TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "ABCX");

    /*
     * And a write past *that* terminator pads with spaces rather than exposing
     * the "EFGHIJ" still sitting in the buffer behind it. A pad that only
     * replaced the terminator itself would leave those visible.
     */
    PASS(errctx, AKGLSINK.moveto(&AKGLSINK, 7, 0));
    PASS(errctx, AKGLSINK.write(&AKGLSINK, "Z"));
    TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "ABCX   Z");
    SUCCEED_RETURN(errctx);
}

/** @brief The sink's text actually reaches the target when the host renders. */
static akerr_ErrorContext AKERR_NOIGNORE *test_sink_renders(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;
    int x = 0;
    int y = 0;
    bool lit = false;

    PASS(errctx, clear_target());
    PASS(errctx, akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, akgl_renderer, font,
					TARGET_SIZE, TARGET_SIZE));
    PASS(errctx, AKGLSINK.write(&AKGLSINK, "X"));
    PASS(errctx, akbasic_sink_akgl_render(&AKGLSINK));

    /*
     * Asserted as "something was drawn in the first cell" rather than against
     * particular pixels: which ones a glyph lights is FreeType's business and it
     * is free to hint them differently. The seam being tested is that the text
     * reached the renderer at the cursor's cell at all.
     */
    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    for ( y = 0; y < AKGLSINKSTATE.cellh && !lit; y++ ) {
	for ( x = 0; x < AKGLSINKSTATE.cellw && !lit; x++ ) {
	    lit = !pixel_is(shot, x, y, 0, 0, 0);
	}
    }
    TEST_REQUIRE(lit, "the sink drew nothing into the first character cell");
    SDL_DestroySurface(shot);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The input adaptor drains the ring the host's event pump fills.
 *
 * Synthetic key events through akgl_controller_handle_event(), which is exactly
 * how a host feeds it, and then read back through the akbasic backend.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_input_backend(void)
{
    PREPARE_ERROR(errctx);
    SDL_Event event;
    int keycode = 0;
    bool available = false;
    /*
     * handle_event refuses a NULL appstate outright, so it needs something to
     * point at even though nothing here reads it. deps/libakgl/tests/controller.c
     * uses a placeholder for the same reason.
     */
    int appstate_placeholder = 0;

    PASS(errctx, akbasic_input_init_akgl(&INPUT));
    PASS(errctx, INPUT.flush_keys(&INPUT));

    /* An empty ring is success with nothing available, never an error. */
    PASS(errctx, INPUT.poll_key(&INPUT, &keycode, &available));
    TEST_REQUIRE(!available, "an empty ring should report nothing available");

    memset(&event, 0, sizeof(event));
    event.type = SDL_EVENT_KEY_DOWN;
    event.key.key = SDLK_A;
    PASS(errctx, akgl_controller_handle_event(&appstate_placeholder, &event));

    PASS(errctx, INPUT.poll_key(&INPUT, &keycode, &available));
    TEST_REQUIRE(available, "a key the host pumped should reach the interpreter");
    TEST_REQUIRE_INT(keycode, SDLK_A);

    PASS(errctx, INPUT.poll_key(&INPUT, &keycode, &available));
    TEST_REQUIRE(!available, "the ring should drain to empty");
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The UI adaptor: retained widgets, a rendered frame, and a real choice.
 *
 * The seam this asserts is the one the whole file is about, and here it is more
 * than a coordinate conversion. libakgl's UI is immediate mode and this
 * interpreter is not, so what has to work is that a MENU declared once survives
 * a frame bracket it never saw, and that a keystroke libakgl consumed comes back
 * out through the record as an activation a BASIC program can read.
 *
 * The keystrokes are **keycodes, not scancodes** -- akgl_ui_menu_handle_event
 * matches on `event.key.key`, and a scancode-filled event is silently ignored.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_ui_backend(void)
{
    PREPARE_ERROR(errctx);
    const char *items[3] = { "START", "OPTIONS", "QUIT" };
    akbasic_Color fill = { 0x00, 0x00, 0x00, 0xff };
    akbasic_Color edge = { 0xff, 0xff, 0xff, 0xff };
    akbasic_Color ink = { 0xff, 0xff, 0xff, 0xff };
    SDL_Event event;
    int selected = 99;
    bool activated = true;
    bool consumed = false;

    PASS(errctx, akbasic_ui_init_akgl(&UI, &UISTATE, akgl_renderer,
				      AKBASIC_TEST_FONT, 12, TARGET_SIZE, TARGET_SIZE));

    /* Nothing declared yet: a frame with no widgets is still a legal frame. */
    PASS(errctx, akbasic_ui_akgl_render(&UI));

    PASS(errctx, UI.style(&UI, &fill, &edge, &ink, 4.0, 0.0));
    PASS(errctx, UI.label(&UI, 0, AKBASIC_UI_ANCHOR_TOP_LEFT, "LIVES 3"));
    PASS(errctx, UI.dialog(&UI, "A PANEL"));
    PASS(errctx, UI.menu(&UI, 0, items, 3));

    /* Freshly defined: first entry highlighted, nothing chosen. */
    PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false));
    TEST_REQUIRE_INT(selected, 0);
    TEST_REQUIRE(!activated, "a freshly defined menu should not be activated");

    /*
     * A whole frame, from what the verbs left behind. This is the assertion that
     * the retained set is re-declarable: clay borrows the text until frame_end,
     * and every pointer it is handed here belongs to UISTATE rather than to a
     * BASIC value that stopped existing several statements ago.
     */
    PASS(errctx, akbasic_ui_akgl_render(&UI));

    /* Down, then Return -- the menu owns both while it has entries. */
    memset(&event, 0, sizeof(event));
    event.type = SDL_EVENT_KEY_DOWN;
    event.key.key = SDLK_DOWN;
    PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
    TEST_REQUIRE(consumed, "a menu that is up should consume Down");

    memset(&event, 0, sizeof(event));
    event.type = SDL_EVENT_KEY_DOWN;
    event.key.key = SDLK_RETURN;
    PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
    TEST_REQUIRE(consumed, "a menu that is up should consume Return");

    PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, true));
    TEST_REQUIRE_INT(selected, 1);
    TEST_REQUIRE(activated, "Return should have activated the second entry");

    /* Reading with clear consumed the latch; the highlight is untouched. */
    PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false));
    TEST_REQUIRE_INT(selected, 1);
    TEST_REQUIRE(!activated, "reading the latch should have cleared it");

    /*
     * Retiring the menu hands the cursor keys back. Without this a program that
     * put a menu up could never take a typed line again, which is what makes the
     * REPL usable at all.
     */
    PASS(errctx, UI.menu(&UI, 0, NULL, 0));
    memset(&event, 0, sizeof(event));
    event.type = SDL_EVENT_KEY_DOWN;
    event.key.key = SDLK_DOWN;
    PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
    TEST_REQUIRE(!consumed, "a retired menu must not still be eating Down");

    /* Escape is nobody's, even with a menu up. */
    PASS(errctx, UI.menu(&UI, 0, items, 3));
    memset(&event, 0, sizeof(event));
    event.type = SDL_EVENT_KEY_DOWN;
    event.key.key = SDLK_ESCAPE;
    PASS(errctx, akbasic_ui_akgl_handle_event(&UI, &event, &consumed));
    TEST_REQUIRE(!consumed, "Escape should reach the program, not the menu");

    PASS(errctx, UI.clear(&UI));
    PASS(errctx, UI.menu_state(&UI, 0, &selected, &activated, false));
    TEST_REQUIRE_INT(selected, 0);
    PASS(errctx, akbasic_ui_akgl_render(&UI));

    /* Shutdown is idempotent, because teardown paths are already unwinding. */
    akbasic_ui_akgl_shutdown(&UI);
    akbasic_ui_akgl_shutdown(&UI);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief A sprite loaded from an image file lands on the target where MOVSPR put it.
 *
 * The one assertion in this repository that exercises the whole file path:
 * a BASIC string that is not an SSHAPE handle, resolved by akgl_path_relative()
 * against the working directory, decoded by SDL_image inside
 * akgl_spritesheet_initialize(), and blitted through the actor's renderfunc.
 *
 * The fixture is 8x8 rather than 24x21 on purpose -- a sprite from a file takes
 * the image's own size, and an assertion at (43, 33) rather than (40, 30) is
 * what says the size came from the image rather than from a constant.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_sprite_from_file(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;

    PASS(errctx, clear_target());
    PASS(errctx, start_runtime("10 SPRSAV \"assets/sprite8x8.png\", 1\n"
			       "20 SPRITE 1, 1\n"
			       "30 MOVSPR 1, 40, 30\n"));
    TEST_REQUIRE_STR(OUTPUT, "");
    PASS(errctx, akbasic_sprite_akgl_render(&SPRITES));

    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    /*
     * Magenta, not white: SPRITE's colour argument modulates the texture and
     * this program never gave one, so the image's own colour comes through.
     */
    TEST_REQUIRE(pixel_is(shot, 43, 33, 0xff, 0x00, 0xff),
		 "the loaded sprite should be drawn at MOVSPR's coordinate");
    TEST_REQUIRE(pixel_is(shot, 47, 37, 0xff, 0x00, 0xff),
		 "the whole 8x8 image should have been drawn, not one pixel of it");
    TEST_REQUIRE(!pixel_is(shot, 49, 39, 0xff, 0x00, 0xff),
		 "an 8x8 sprite should stop after eight pixels");
    SDL_DestroySurface(shot);

    /* A path that names nothing is the program's mistake, and is reported. */
    stop_runtime();
    PASS(errctx, start_runtime("10 SPRSAV \"assets/nosuchfile.png\", 1\n"));
    TEST_REQUIRE(strstr(OUTPUT, "No such sprite image") != NULL,
		 "a missing image should be reported by name, got \"%s\"", OUTPUT);
    stop_runtime();
    SUCCEED_RETURN(errctx);
}

/**
 * @brief A sprite defined from 63 pattern bytes reaches real pixels, in its colour.
 *
 * The DATA-driven path a type-in listing uses, end to end: an integer array, the
 * bit unpacking, a surface built a pixel at a time, and a texture. The pattern
 * is one set bit in the top-left corner, so what is asserted is both that the
 * pixel is lit *and* that its neighbour is not -- which is what catches a bit
 * order that came through reversed.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_sprite_from_pattern(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;

    PASS(errctx, clear_target());
    PASS(errctx, start_runtime("10 DIM P#(63)\n"
			       "20 P#(0) = 128\n"
			       "30 SPRSAV P#, 1\n"
			       "40 SPRITE 1, 1, 6\n"
			       "50 MOVSPR 1, 20, 20\n"));
    TEST_REQUIRE_STR(OUTPUT, "");
    PASS(errctx, akbasic_sprite_akgl_render(&SPRITES));

    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    /* Palette index 6 is green: 0x55, 0xa0, 0x49. */
    TEST_REQUIRE(pixel_is(shot, 20, 20, 0x55, 0xa0, 0x49),
		 "the pattern's one set bit should be the sprite's top-left pixel");
    TEST_REQUIRE(!pixel_is(shot, 21, 20, 0x55, 0xa0, 0x49),
		 "only one bit was set, so only one pixel should be lit");
    TEST_REQUIRE(!pixel_is(shot, 43, 20, 0x55, 0xa0, 0x49),
		 "a reversed bit order would have lit the far end of the row");
    SDL_DestroySurface(shot);
    stop_runtime();
    SUCCEED_RETURN(errctx);
}

/**
 * @brief A region SSHAPE saved becomes a sprite, which is the shared-format claim.
 *
 * The C128 documents SPRSAV's string as the SSHAPE data format at a fixed 24x21,
 * so reusing the shape pool here is faithful rather than a shortcut -- and this
 * is what says the handle actually survives the trip through a BASIC string and
 * into the sprite device.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_sprite_from_shape(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;

    PASS(errctx, clear_target());
    PASS(errctx, start_runtime("10 COLOR 1, 3\n"
			       "20 BOX 1, 0, 0, 10, 10\n"
			       "30 SSHAPE A$, 0, 0, 10, 10\n"
			       "40 SPRSAV A$, 1\n"
			       "50 SPRITE 1, 1\n"
			       "60 MOVSPR 1, 60, 60\n"));
    TEST_REQUIRE_STR(OUTPUT, "");
    PASS(errctx, akbasic_sprite_akgl_render(&SPRITES));

    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    /*
     * Palette index 3 is red, which is what the BOX was outlined in. The corner
     * of the box is the (0,0) pixel of the saved region, so it lands exactly
     * where MOVSPR put the sprite -- and its interior does not, which is what
     * says a region was copied rather than a rectangle drawn.
     */
    TEST_REQUIRE(pixel_is(shot, 60, 60, 0x88, 0x39, 0x32),
		 "the saved region's corner should have been drawn as a sprite");
    TEST_REQUIRE(!pixel_is(shot, 65, 65, 0x88, 0x39, 0x32),
		 "the region was an outline, so its middle should not be red");
    SDL_DestroySurface(shot);
    stop_runtime();
    SUCCEED_RETURN(errctx);
}

/**
 * @brief Run one program and report the collision mask the device computes.
 *
 * Straight at the backend rather than through `BUMP(1)`, because what is under
 * test is the overlap arithmetic and not the accumulate-and-clear the runtime
 * wraps it in -- that half is already covered by tests/sprite_verbs.c against a
 * mock. Going through BUMP here would assert both at once and blame the wrong
 * one when either broke.
 */
static akerr_ErrorContext AKERR_NOIGNORE *mask_for(const char *source, uint16_t *dest)
{
    PREPARE_ERROR(errctx);

    PASS(errctx, start_runtime(source));
    TEST_REQUIRE_STR(OUTPUT, "");
    PASS(errctx, SPRITES.collisions(&SPRITES, dest));
    SUCCEED_RETURN(errctx);
}

/**
 * @brief What spr_collisions() answers, pinned before anything replaces it.
 *
 * **This code had no test at all.** tests/sprite_verbs.c drives the collision
 * path end to end but through a mock backend, so the real overlap arithmetic
 * could have changed what BUMP(1) reports for every program in existence and the
 * suite would still have passed. That is the gap this closes, and it is closed
 * *before* the arithmetic is touched so there is a before to compare an after
 * against.
 *
 * Two of the cases below are the ones a replacement is most likely to get wrong,
 * and each says so where it stands.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_collision_pairs(void)
{
    PREPARE_ERROR(errctx);
    uint16_t mask = 0;

    /* Nothing defined: no actor, so nothing to compare and nothing set. */
    PASS(errctx, mask_for("10 X# = 0\n", &mask));
    TEST_REQUIRE_INT(mask, 0);
    stop_runtime();

    /*
     * Two 24x21 patterns overlapping. Bit n-1 is sprite n, so sprites 1 and 2
     * are 0x03.
     */
    PASS(errctx, mask_for("10 DIM P#(63)\n"
			  "20 FOR I# = 0 TO 62\n"
			  "30   P#(I#) = 255\n"
			  "40 NEXT I#\n"
			  "50 SPRSAV P#, 1\n"
			  "60 SPRSAV P#, 2\n"
			  "70 SPRITE 1, 1\n"
			  "80 SPRITE 2, 1\n"
			  "90 MOVSPR 1, 10, 10\n"
			  "100 MOVSPR 2, 20, 20\n", &mask));
    TEST_REQUIRE_INT(mask, 0x03);
    stop_runtime();

    /*
     * **Edge to edge is not a collision.** Sprite 1 spans x 10..33 inclusive and
     * sprite 2 starts at 34, so the test is `ax + aw < bx + 1` -- the strict `<`
     * in spr_collisions(). A tile-aligned program puts sprites here constantly,
     * and a replacement that answers "touching" instead of "overlapping" changes
     * every one of them.
     */
    PASS(errctx, mask_for("10 DIM P#(63)\n"
			  "20 FOR I# = 0 TO 62\n"
			  "30   P#(I#) = 255\n"
			  "40 NEXT I#\n"
			  "50 SPRSAV P#, 1\n"
			  "60 SPRSAV P#, 2\n"
			  "70 SPRITE 1, 1\n"
			  "80 SPRITE 2, 1\n"
			  "90 MOVSPR 1, 10, 10\n"
			  "100 MOVSPR 2, 34, 10\n", &mask));
    TEST_REQUIRE_INT(mask, 0);
    stop_runtime();

    /* A hidden sprite is not in the world, whatever its coordinates say. */
    PASS(errctx, mask_for("10 DIM P#(63)\n"
			  "20 FOR I# = 0 TO 62\n"
			  "30   P#(I#) = 255\n"
			  "40 NEXT I#\n"
			  "50 SPRSAV P#, 1\n"
			  "60 SPRSAV P#, 2\n"
			  "70 SPRITE 1, 1\n"
			  "80 SPRITE 2, 0\n"
			  "90 MOVSPR 1, 10, 10\n"
			  "100 MOVSPR 2, 10, 10\n", &mask));
    TEST_REQUIRE_INT(mask, 0);
    stop_runtime();

    /*
     * The x-expand bit doubles the box that collides, not just the one that
     * draws. Sprite 1 unexpanded ends at x 33 and does not reach sprite 2 at 40;
     * expanded it spans 48 and does.
     */
    PASS(errctx, mask_for("10 DIM P#(63)\n"
			  "20 FOR I# = 0 TO 62\n"
			  "30   P#(I#) = 255\n"
			  "40 NEXT I#\n"
			  "50 SPRSAV P#, 1\n"
			  "60 SPRSAV P#, 2\n"
			  "70 SPRITE 1, 1, 1, 0, 1, 0\n"
			  "80 SPRITE 2, 1\n"
			  "90 MOVSPR 1, 10, 10\n"
			  "100 MOVSPR 2, 40, 10\n", &mask));
    TEST_REQUIRE_INT(mask, 0x03);
    stop_runtime();

    SUCCEED_RETURN(errctx);
}

/**
 * @brief A cross-shaped overlap is a collision, and it is the case to watch.
 *
 * A tall thin sprite crossing a short wide one overlaps without either
 * rectangle containing a corner of the other. libakgl's akgl_collide_rectangles()
 * is documented as answering "no" here -- a corner-containment test cannot see
 * it -- which is why src/sprite_akgl.c does the four comparisons itself rather
 * than calling it.
 *
 * So this assertion is the one that fails if collision is ever moved onto a
 * corner-containment implementation, and it is worth having before rather than
 * after such a move.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_collision_cross_shape(void)
{
    PREPARE_ERROR(errctx);
    uint16_t mask = 0;

    /*
     * SSHAPE captures a region at whatever size it is asked for, and a sprite
     * made from one keeps that size -- which is the only way to get two sprites
     * of different aspect, since a DATA pattern is always 24 by 21.
     *
     * Sprite 1 is about 6 wide and 40 tall at (30, 10), so it spans roughly
     * x 30..36, y 10..50. Sprite 2 is about 40 wide and 6 tall at (10, 30), so
     * roughly x 10..50, y 30..36. They cross in the middle, and **neither holds
     * a corner of the other** -- which is the whole point.
     *
     * Nothing is drawn into either region first. Collision is by bounding box,
     * so what a sprite's pixels contain does not enter into it; the only thing
     * being borrowed from SSHAPE here is the size. That also keeps the margins
     * wide enough that SSHAPE's inclusive-bound question cannot decide the
     * answer either way.
     */
    PASS(errctx, mask_for("10 GRAPHIC 1, 1\n"
			  "20 SSHAPE V$, 0, 0, 6, 40\n"
			  "30 SSHAPE H$, 0, 50, 40, 56\n"
			  "40 SPRSAV V$, 1\n"
			  "50 SPRSAV H$, 2\n"
			  "60 SPRITE 1, 1\n"
			  "70 SPRITE 2, 1\n"
			  "80 MOVSPR 1, 30, 10\n"
			  "90 MOVSPR 2, 10, 30\n", &mask));
    TEST_REQUIRE_INT(mask, 0x03);
    stop_runtime();

    /*
     * The two assertions that stop the one above from passing by accident.
     *
     * A cross is only a cross if the two sprites really are the long thin shapes
     * this test believes they are, and nothing above proves that -- two 40x40
     * blobs at the same coordinates would also report 0x03, for the ordinary
     * reason, and the test would look like it was doing its job. So move each
     * one clear along the axis it is *supposed* to be short on. A sprite that
     * came out the wrong size stays overlapping and fails here.
     */
    PASS(errctx, mask_for("10 GRAPHIC 1, 1\n"
			  "20 SSHAPE V$, 0, 0, 6, 40\n"
			  "30 SSHAPE H$, 0, 50, 40, 56\n"
			  "40 SPRSAV V$, 1\n"
			  "50 SPRSAV H$, 2\n"
			  "60 SPRITE 1, 1\n"
			  "70 SPRITE 2, 1\n"
			  "80 MOVSPR 1, 30, 10\n"
			  "90 MOVSPR 2, 10, 60\n", &mask));
    TEST_REQUIRE_INT(mask, 0);
    stop_runtime();

    PASS(errctx, mask_for("10 GRAPHIC 1, 1\n"
			  "20 SSHAPE V$, 0, 0, 6, 40\n"
			  "30 SSHAPE H$, 0, 50, 40, 56\n"
			  "40 SPRSAV V$, 1\n"
			  "50 SPRSAV H$, 2\n"
			  "60 SPRITE 1, 1\n"
			  "70 SPRITE 2, 1\n"
			  "80 MOVSPR 1, 60, 10\n"
			  "90 MOVSPR 2, 10, 30\n", &mask));
    TEST_REQUIRE_INT(mask, 0);
    stop_runtime();

    SUCCEED_RETURN(errctx);
}

/** @brief The 24x21 pattern program the shape cases share, with @p tail appended. */
static akerr_ErrorContext AKERR_NOIGNORE *two_sprites(const char *tail, uint16_t *dest)
{
    PREPARE_ERROR(errctx);
    char source[2048];

    snprintf(source, sizeof(source),
	     "10 DIM P#(63)\n"
	     "20 FOR I# = 0 TO 62\n"
	     "30   P#(I#) = 255\n"
	     "40 NEXT I#\n"
	     "50 SPRSAV P#, 1\n"
	     "60 SPRSAV P#, 2\n"
	     "70 SPRITE 1, 1\n"
	     "80 SPRITE 2, 1\n"
	     "%s", tail);
    PASS(errctx, mask_for(source, dest));
    SUCCEED_RETURN(errctx);
}

/**
 * @brief SPRHIT narrows what a sprite collides with, and the default is the frame.
 *
 * The first case is the one that matters most and the reason this whole change
 * could be made at all: **a program that never says SPRHIT must behave exactly as
 * it did.** Everything else here is new surface; that one is a promise.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_sprite_shapes(void)
{
    PREPARE_ERROR(errctx);
    uint16_t mask = 0;

    /* Two 24x21 frames, ten pixels apart: overlapping, as they always were. */
    PASS(errctx, two_sprites("90 MOVSPR 1, 10, 10\n"
			     "100 MOVSPR 2, 20, 20\n", &mask));
    TEST_REQUIRE_INT(mask, 0x03);
    stop_runtime();

    /*
     * A box in the top-left corner of sprite 1 only, four pixels square. Sprite
     * 2's frame starts at 20 and the shape ends at 14, so they no longer meet --
     * with both sprites in exactly the places the case above had them.
     */
    PASS(errctx, two_sprites("85 SPRHIT 1, 1, 0, 0, 4, 4\n"
			     "90 MOVSPR 1, 10, 10\n"
			     "100 MOVSPR 2, 20, 20\n", &mask));
    TEST_REQUIRE_INT(mask, 0);
    stop_runtime();

    /* And the shape is offset from the sprite, so moving the sprite moves it. */
    PASS(errctx, two_sprites("85 SPRHIT 1, 1, 0, 0, 4, 4\n"
			     "90 MOVSPR 1, 18, 18\n"
			     "100 MOVSPR 2, 20, 20\n", &mask));
    TEST_REQUIRE_INT(mask, 0x03);
    stop_runtime();

    /* Kind 0 takes a sprite out of collision while leaving it on the screen. */
    PASS(errctx, two_sprites("85 SPRHIT 1, 0\n"
			     "90 MOVSPR 1, 10, 10\n"
			     "100 MOVSPR 2, 10, 10\n", &mask));
    TEST_REQUIRE_INT(mask, 0);
    stop_runtime();

    /*
     * A circle is inscribed in the frame, so two frames touching at their
     * corners -- which is the false positive docs/08-sprites.md ships a figure
     * of -- stop colliding when both become circles. This is the case that
     * figure exists to complain about.
     */
    PASS(errctx, two_sprites("90 MOVSPR 1, 0, 0\n"
			     "100 MOVSPR 2, 23, 20\n", &mask));
    TEST_REQUIRE_INT(mask, 0x03);
    stop_runtime();

    PASS(errctx, two_sprites("85 SPRHIT 1, 2\n"
			     "86 SPRHIT 2, 2\n"
			     "90 MOVSPR 1, 0, 0\n"
			     "100 MOVSPR 2, 23, 20\n", &mask));
    TEST_REQUIRE_INT(mask, 0);
    stop_runtime();

    SUCCEED_RETURN(errctx);
}

/** @brief Run a program and report the static-geometry mask. */
static akerr_ErrorContext AKERR_NOIGNORE *solidmask_for(const char *source, uint16_t *dest)
{
    PREPARE_ERROR(errctx);

    PASS(errctx, start_runtime(source));
    TEST_REQUIRE_STR(OUTPUT, "");
    PASS(errctx, SPRITES.solids(&SPRITES, dest));
    SUCCEED_RETURN(errctx);
}

/**
 * @brief SOLID puts a collidable rectangle on the field without spending a sprite.
 *
 * This is the point of the whole exercise. A brick is not a sprite -- there are
 * eight and a wall wants sixty -- so until now a program could only collide with
 * one by doing the arithmetic itself against its own array. Now it registers a
 * rectangle and the interpreter answers.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_static_geometry(void)
{
    PREPARE_ERROR(errctx);
    uint16_t mask = 0;

    /* A 24x21 sprite at (100, 100) squarely inside a rectangle. */
    PASS(errctx, solidmask_for("10 DIM P#(63)\n"
			       "20 FOR I# = 0 TO 62\n"
			       "30   P#(I#) = 255\n"
			       "40 NEXT I#\n"
			       "50 SPRSAV P#, 1\n"
			       "60 SPRITE 1, 1\n"
			       "70 SOLID 7, 90, 90, 140, 140\n"
			       "80 MOVSPR 1, 100, 100\n", &mask));
    TEST_REQUIRE_INT(mask, 0x01);
    stop_runtime();

    /* Moved clear of it, nothing. */
    PASS(errctx, solidmask_for("10 DIM P#(63)\n"
			       "20 FOR I# = 0 TO 62\n"
			       "30   P#(I#) = 255\n"
			       "40 NEXT I#\n"
			       "50 SPRSAV P#, 1\n"
			       "60 SPRITE 1, 1\n"
			       "70 SOLID 7, 90, 90, 140, 140\n"
			       "80 MOVSPR 1, 200, 200\n", &mask));
    TEST_REQUIRE_INT(mask, 0);
    stop_runtime();

    /*
     * Retired, and the sprite has not moved. This is the assertion that catches
     * a proxy released without its registration going with it -- the shape would
     * still be found and the mask would still come back set.
     */
    PASS(errctx, solidmask_for("10 DIM P#(63)\n"
			       "20 FOR I# = 0 TO 62\n"
			       "30   P#(I#) = 255\n"
			       "40 NEXT I#\n"
			       "50 SPRSAV P#, 1\n"
			       "60 SPRITE 1, 1\n"
			       "70 SOLID 7, 90, 90, 140, 140\n"
			       "80 MOVSPR 1, 100, 100\n"
			       "90 SOLID 7\n", &mask));
    TEST_REQUIRE_INT(mask, 0);
    stop_runtime();

    /*
     * A full wall registered, walked into, and cleared -- sixty-four of them, so
     * the proxy budget is exercised at its ceiling and the pool has to come back
     * intact afterwards or the next case fails.
     */
    PASS(errctx, solidmask_for("10 DIM P#(63)\n"
			       "20 FOR I# = 0 TO 62\n"
			       "30   P#(I#) = 255\n"
			       "40 NEXT I#\n"
			       "50 SPRSAV P#, 1\n"
			       "60 SPRITE 1, 1\n"
			       "70 FOR I# = 1 TO 64\n"
			       "80   X# = I# * 30\n"
			       "90   SOLID I#, X#, 0, X# + 20, 20\n"
			       "100 NEXT I#\n"
			       "110 MOVSPR 1, 300, 5\n", &mask));
    TEST_REQUIRE_INT(mask, 0x01);
    stop_runtime();

    /* And the sixty-fifth is refused by name rather than exhausting the pool. */
    PASS(errctx, start_runtime("10 SOLID 65, 0, 0, 10, 10\n"));
    TEST_REQUIRE(strstr(OUTPUT, "outside 1..64") != NULL,
		 "SOLID 65 should be refused, got \"%s\"", OUTPUT);
    stop_runtime();

    SUCCEED_RETURN(errctx);
}

/**
 * @brief A drawing survives the frame it was made in, and the next one.
 *
 * The defect this closes, quoted from TODO.md section 9 item 3: "in the
 * standalone SDL build, nothing the graphics verbs draw can be seen unless the
 * program shrinks the text area first." The verbs are immediate and go to the
 * back buffer, SDL double-buffers, and the frontend never clears -- so a picture
 * lasted exactly one frame and the only way to keep one was to capture it with
 * SSHAPE and install it as a sprite.
 *
 * **The second half is the assertion that matters.** Anything can put a pixel on
 * the target once. Compositing the layer a second time, with the program having
 * drawn nothing in between, is what says the picture is *kept* rather than
 * merely issued.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_drawing_layer_persists(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;

    PASS(errctx, clear_target());

    /*
     * The frontend's order exactly, and it has to be: devices up once, then
     * frames. start_runtime() cannot be used here because it re-initializes the
     * graphics backend, which zeroes the layer pointer -- and a test that called
     * begin() first would then read its assertion back off a render target that
     * happened to still be the orphaned layer, and pass while proving nothing.
     */
    memset(OUTPUT, 0, sizeof(OUTPUT));
    OUT = fmemopen(OUTPUT, sizeof(OUTPUT), "w");
    FAIL_ZERO_RETURN(errctx, (OUT != NULL), AKERR_IO, "could not open the output buffer");
    setvbuf(OUT, NULL, _IONBF, 0);
    PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, OUT, NULL));
    PASS(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
    PASS(errctx, akbasic_graphics_init_akgl(&GRAPHICS, &GRAPHICSSTATE, akgl_renderer));
    PASS(errctx, akbasic_runtime_set_devices(&RUNTIME, &GRAPHICS, NULL, NULL, NULL));
    PASS(errctx, akbasic_runtime_load(&RUNTIME, "10 COLOR 1, 3\n20 DRAW 1, 40, 40\n"));
    PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUN));

    /* Frame one: steps run with the layer current, then it is composited. */
    PASS(errctx, akbasic_graphics_akgl_begin(&GRAPHICS));
    PASS(errctx, akbasic_runtime_run(&RUNTIME, 0));
    PASS(errctx, akbasic_graphics_akgl_end(&GRAPHICS));
    TEST_REQUIRE_STR(OUTPUT, "");
    PASS(errctx, akbasic_graphics_akgl_render(&GRAPHICS));

    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    TEST_REQUIRE(pixel_is(shot, 40, 40, 0x88, 0x39, 0x32),
		 "the drawing should have reached the frame through the layer");
    SDL_DestroySurface(shot);

    /*
     * **Frame two is the assertion that matters.** Anything can put a pixel on
     * the target once. Wiping the frame the way a present does, running no
     * program at all, and compositing again is what says the picture is *kept*
     * rather than merely issued -- which is the whole of TODO.md section 9
     * item 3.
     */
    PASS(errctx, clear_target());
    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    TEST_REQUIRE(!pixel_is(shot, 40, 40, 0x88, 0x39, 0x32),
		 "the clear should have wiped the frame, or the next assertion proves nothing");
    SDL_DestroySurface(shot);

    PASS(errctx, akbasic_graphics_akgl_render(&GRAPHICS));
    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    TEST_REQUIRE(pixel_is(shot, 40, 40, 0x88, 0x39, 0x32),
		 "the drawing should still be there a frame later without being redrawn");
    SDL_DestroySurface(shot);

    /*
     * **And transparent where nothing was drawn.** The layer covers the whole
     * window and composites *under* the text and the sprites, so an opaque one
     * would black out the frame the moment a program issued a single DRAW. Wipe
     * to a colour that is not black, composite, and the untouched pixel has to
     * still be that colour -- against black this assertion could not tell an
     * opaque layer from a transparent one.
     */
    FAIL_ZERO_RETURN(errctx,
		     SDL_SetRenderDrawColor(akgl_renderer->sdl_renderer, 0x11, 0x22, 0x33, 0xff),
		     AKGL_ERR_SDL, "%s", SDL_GetError());
    FAIL_ZERO_RETURN(errctx, SDL_RenderClear(akgl_renderer->sdl_renderer),
		     AKGL_ERR_SDL, "%s", SDL_GetError());
    PASS(errctx, akbasic_graphics_akgl_render(&GRAPHICS));
    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    TEST_REQUIRE(pixel_is(shot, 100, 100, 0x11, 0x22, 0x33),
		 "the layer should be transparent where nothing was drawn");
    TEST_REQUIRE(pixel_is(shot, 40, 40, 0x88, 0x39, 0x32),
		 "and still opaque where something was");
    SDL_DestroySurface(shot);

    /*
     * **And the two halves together are what makes a drawing usable.**
     *
     * The text layer repaints every row it owns, opaque, every frame -- for the
     * reason akbasic_sink_akgl_render() gives at length -- and by default it
     * owns the whole window, so a drawing composited underneath is covered.
     * `WINDOW` shrinks it, and that half was already fixed. What was missing was
     * persistence: a program could uncover the drawing and then had nothing to
     * put there, because the picture was gone by the next frame.
     *
     * So: shrink the text area to two rows, render the text layer over the
     * composited drawing, and check that a pixel below the text area survived.
     */
    PASS(errctx, akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, akgl_renderer, font,
					TARGET_SIZE, TARGET_SIZE));
    TEST_REQUIRE_OK(AKGLSINK.window(&AKGLSINK, 0, 0, 15, 1));
    PASS(errctx, clear_target());
    PASS(errctx, akbasic_graphics_akgl_render(&GRAPHICS));
    PASS(errctx, akbasic_sink_akgl_render(&AKGLSINK));

    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the render target back");
    TEST_REQUIRE(pixel_is(shot, 40, 40, 0x88, 0x39, 0x32),
		 "with the text area shrunk, the drawing below it should survive the frame");
    SDL_DestroySurface(shot);

    stop_runtime();
    SUCCEED_RETURN(errctx);
}

/**
 * @brief GRAPHIC selects whether the AKGL text plane covers the drawing layer.
 *
 * `PRINT` always writes into the retained grid. In a full bitmap mode the grid
 * is simply not composited; returning to mode zero reveals it. Split mode moves
 * the text region below the requested row, leaving the drawing visible above.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_graphic_text_composition(void)
{
    PREPARE_ERROR(errctx);
    SDL_Surface *shot = NULL;
    int fullrows = 0;

    PASS(errctx, akbasic_sink_init_akgl(&AKGLSINK, &AKGLSINKSTATE, akgl_renderer, font,
					TARGET_SIZE, TARGET_SIZE));
    PASS(errctx, akbasic_runtime_init(&RUNTIME, &AKGLSINK));
    PASS(errctx, akbasic_graphics_init_akgl(&GRAPHICS, &GRAPHICSSTATE, akgl_renderer));
    PASS(errctx, akbasic_runtime_set_devices(&RUNTIME, &GRAPHICS, NULL, NULL, NULL));
    PASS(errctx, akbasic_runtime_load(&RUNTIME,
			       "10 GRAPHIC 1, 1\n"
			       "20 COLOR 1, 3\n"
			       "30 DRAW 1, 40, 40\n"
			       "40 PRINT \"HIDDEN\"\n"));
    PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUN));
    PASS(errctx, akbasic_graphics_akgl_begin(&GRAPHICS));
    PASS(errctx, akbasic_runtime_run(&RUNTIME, 0));
    PASS(errctx, akbasic_graphics_akgl_end(&GRAPHICS));
    TEST_REQUIRE_INT(AKGLSINKSTATE.graphicmode, 1);
    TEST_REQUIRE_STR(AKGLSINKSTATE.text[0], "HIDDEN");

    PASS(errctx, clear_target());
    PASS(errctx, akbasic_graphics_akgl_render(&GRAPHICS));
    PASS(errctx, akbasic_sink_akgl_render(&AKGLSINK));
    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the full-bitmap frame back");
    TEST_REQUIRE(pixel_is(shot, 40, 40, 0x88, 0x39, 0x32),
		 "GRAPHIC 1 must show DRAW even after PRINT wrote the hidden text grid");
    SDL_DestroySurface(shot);

    PASS(errctx, AKGLSINK.graphic(&AKGLSINK, 0, -1));
    PASS(errctx, clear_target());
    PASS(errctx, akbasic_graphics_akgl_render(&GRAPHICS));
    PASS(errctx, akbasic_sink_akgl_render(&AKGLSINK));
    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the text frame back");
    TEST_REQUIRE(!pixel_is(shot, 40, 40, 0x88, 0x39, 0x32),
		 "GRAPHIC 0 must put the retained text plane back over the drawing");
    SDL_DestroySurface(shot);

    PASS(errctx, AKGLSINK.clear(&AKGLSINK));
    fullrows = TARGET_SIZE / AKGLSINKSTATE.cellh;
    PASS(errctx, AKGLSINK.graphic(&AKGLSINK, 2, 3));
    TEST_REQUIRE_INT(AKGLSINKSTATE.y, 3 * AKGLSINKSTATE.cellh);
    TEST_REQUIRE_INT(AKGLSINKSTATE.rows, fullrows - 3);
    PASS(errctx, AKGLSINK.writeln(&AKGLSINK, "BOTTOM"));
    PASS(errctx, clear_target());
    PASS(errctx, akbasic_graphics_akgl_render(&GRAPHICS));
    PASS(errctx, akbasic_sink_akgl_render(&AKGLSINK));
    shot = SDL_RenderReadPixels(akgl_renderer->sdl_renderer, NULL);
    TEST_REQUIRE(shot != NULL, "could not read the split-screen frame back");
    TEST_REQUIRE(pixel_is(shot, 40, 40, 0x88, 0x39, 0x32),
		 "GRAPHIC 2 must leave the graphics region above its text rows visible");
    SDL_DestroySurface(shot);

    PASS(errctx, AKGLSINK.graphic(&AKGLSINK, 0, -1));
    TEST_REQUIRE_INT(AKGLSINKSTATE.texttop, 0);
    TEST_REQUIRE_INT(AKGLSINKSTATE.rows, fullrows);

    akbasic_graphics_akgl_shutdown(&GRAPHICS);
    SUCCEED_RETURN(errctx);
}

/**
 * @brief The contact says which way to push out and how far.
 *
 * The reason for going through libakgl's narrowphase at all. Four comparisons
 * can say *that* two things overlap; only a narrowphase can say by how much and
 * in which direction, and that is what a game needs to reverse a ball or stop a
 * player at a wall.
 *
 * **The normal's sign is the assertion worth having.** It points out of the other
 * shape and toward this one, so a program moves along it by the depth and is
 * clear. Getting it backwards is the classic mistake and nothing else here would
 * notice.
 */
static akerr_ErrorContext AKERR_NOIGNORE *test_contact_geometry(void)
{
    PREPARE_ERROR(errctx);
    akbasic_Contact contact;
    uint16_t mask = 0;
    bool found = false;

    /*
     * A 24x21 sprite at (100, 100), and a wall whose left edge is at 118. They
     * overlap by six pixels along x and by far more along y, so the minimum
     * translation axis is x and the sprite has to move left to clear it.
     */
    PASS(errctx, solidmask_for("10 DIM P#(63)\n"
			       "20 FOR I# = 0 TO 62\n"
			       "30   P#(I#) = 255\n"
			       "40 NEXT I#\n"
			       "50 SPRSAV P#, 1\n"
			       "60 SPRITE 1, 1\n"
			       "70 SOLID 3, 118, 0, 300, 300\n"
			       "80 MOVSPR 1, 100, 100\n", &mask));
    TEST_REQUIRE_INT(mask, 0x01);

    memset(&contact, 0, sizeof(contact));
    PASS(errctx, SPRITES.contact(&SPRITES, 1, &contact, &found));
    TEST_REQUIRE(found, "sprite 1 overlapped a wall, so it should have a contact");
    TEST_REQUIRE_INT(contact.what, 2);
    TEST_REQUIRE_INT(contact.other, 3);
    TEST_REQUIRE_INT(contact.axis, 1);
    /* Six pixels of overlap: 100 + 24 = 124, less the wall's 118. */
    TEST_REQUIRE(contact.depth > 5.5 && contact.depth < 6.5,
		 "the overlap is six pixels, got %f", contact.depth);
    /* Out of the wall and toward the sprite is leftward, so negative x. */
    TEST_REQUIRE(contact.nx < -0.5,
		 "the normal should point away from the wall, got (%f, %f)",
		 contact.nx, contact.ny);
    stop_runtime();

    /* The same overlap from above: the axis becomes y and the normal flips. */
    PASS(errctx, solidmask_for("10 DIM P#(63)\n"
			       "20 FOR I# = 0 TO 62\n"
			       "30   P#(I#) = 255\n"
			       "40 NEXT I#\n"
			       "50 SPRSAV P#, 1\n"
			       "60 SPRITE 1, 1\n"
			       "70 SOLID 3, 0, 118, 300, 300\n"
			       "80 MOVSPR 1, 100, 100\n", &mask));
    TEST_REQUIRE_INT(mask, 0x01);
    memset(&contact, 0, sizeof(contact));
    PASS(errctx, SPRITES.contact(&SPRITES, 1, &contact, &found));
    TEST_REQUIRE(found, "sprite 1 should have a contact");
    TEST_REQUIRE_INT(contact.axis, 2);
    TEST_REQUIRE(contact.ny < -0.5,
		 "the normal should point up out of the wall, got (%f, %f)",
		 contact.nx, contact.ny);
    stop_runtime();

    /*
     * Sprite against sprite, and **both** get a contact pointing the way *they*
     * have to move. Sharing one normal would tell both to go the same direction,
     * which is how two things end up stuck inside each other.
     */
    PASS(errctx, two_sprites("90 MOVSPR 1, 100, 100\n"
			     "100 MOVSPR 2, 118, 100\n", &mask));
    TEST_REQUIRE_INT(mask, 0x03);
    memset(&contact, 0, sizeof(contact));
    PASS(errctx, SPRITES.contact(&SPRITES, 1, &contact, &found));
    TEST_REQUIRE(found, "sprite 1 should have a contact");
    TEST_REQUIRE_INT(contact.what, 1);
    TEST_REQUIRE_INT(contact.other, 2);
    TEST_REQUIRE(contact.nx < -0.5, "sprite 1 is on the left, so it moves left");
    memset(&contact, 0, sizeof(contact));
    PASS(errctx, SPRITES.contact(&SPRITES, 2, &contact, &found));
    TEST_REQUIRE(found, "sprite 2 should have a contact too");
    TEST_REQUIRE_INT(contact.other, 1);
    TEST_REQUIRE(contact.nx > 0.5, "sprite 2 is on the right, so it moves right");
    stop_runtime();

    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");

    ATTEMPT {
	CATCH(errctx, akgl_error_init());
	akgl_renderer = &akgl_default_renderer;

	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 SDL_ttf: %s", SDL_GetError());
	FAIL_ZERO_BREAK(errctx,
			SDL_CreateWindowAndRenderer("net/aklabs/akbasic/test_akgl_backends",
						    TARGET_SIZE, TARGET_SIZE, 0,
						    &akgl_window, &akgl_renderer->sdl_renderer),
			AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError());
	/*
	 * Bind the 2D methods. akgl_text_rendertextat() reaches through
	 * akgl_renderer->draw_texture and an SDL_Renderer alone does not fill that in;
	 * deps/libakgl/tests/draw.c gets away without it because the draw
	 * primitives take the SDL_Renderer directly. These six pointers were
	 * assigned by hand here until libakgl 0.3.0 split the bind half out of the
	 * init half -- API-gap item 7, filed from this file. Both were renamed in
	 * 0.5.0, from akgl_render_bind2d and akgl_render_init2d.
	 */
	CATCH(errctx, akgl_render_2d_bind(akgl_renderer));

	/*
	 * The three things akgl_game_init() would have done and this file, being
	 * a host that deliberately never calls it, has to do itself: the object
	 * pools, the name registries, and a camera for akgl_actor_render() to
	 * dereference. src/frontend_akgl.c does the same, and says why at length.
	 */
	CATCH(errctx, akgl_heap_init());
	CATCH(errctx, akgl_registry_init());
	akgl_camera = &akgl_default_camera;
	akgl_camera->x = 0.0f;
	akgl_camera->y = 0.0f;
	akgl_camera->w = (float)TARGET_SIZE;
	akgl_camera->h = (float)TARGET_SIZE;

	/*
	 * libakgl's own monospaced fixture, borrowed rather than copied. Being
	 * monospaced is what makes a character grid meaningful to assert on.
	 */
	font = TTF_OpenFont(AKBASIC_TEST_FONT, 12);
	FAIL_ZERO_BREAK(errctx, font, AKGL_ERR_SDL,
			"Couldn't open %s: %s", AKBASIC_TEST_FONT, SDL_GetError());

	CATCH(errctx, test_draw_reaches_pixels());
	CATCH(errctx, test_size_is_the_renderer());
	CATCH(errctx, test_box_outlines());
	CATCH(errctx, test_paint_fills_region());
	CATCH(errctx, test_shape_roundtrip());
	CATCH(errctx, test_sink_grid_and_wrap());
	CATCH(errctx, test_sink_writes_past_a_short_row());
	CATCH(errctx, test_sink_renders());
	CATCH(errctx, test_input_backend());
	CATCH(errctx, test_ui_backend());
	CATCH(errctx, test_sprite_from_file());
	CATCH(errctx, test_sprite_from_pattern());
	CATCH(errctx, test_sprite_from_shape());
	CATCH(errctx, test_collision_pairs());
	CATCH(errctx, test_collision_cross_shape());
	CATCH(errctx, test_sprite_shapes());
	CATCH(errctx, test_static_geometry());
	CATCH(errctx, test_contact_geometry());
	CATCH(errctx, test_drawing_layer_persists());
	CATCH(errctx, test_graphic_text_composition());
    } CLEANUP {
	if ( font != NULL ) {
	    TTF_CloseFont(font);
	}
	TTF_Quit();
	SDL_Quit();
    } PROCESS(errctx) {
    } HANDLE_DEFAULT(errctx) {
	LOG_ERROR_WITH_MESSAGE(errctx, "akgl backend test failed");
	akbasic_test_failures += 1;
	/*
	 * FINISH_NORETURN rather than FINISH, matching src/main.c: FINISH
	 * expands a `return __err_context` that this int-returning function
	 * cannot compile even when the branch is unreachable. HANDLE_DEFAULT
	 * above has already marked the context handled, so nothing aborts.
	 */
    } FINISH_NORETURN(errctx);

    return akbasic_test_failures;
}
