`spr_collisions()` had no test. `tests/sprite_verbs.c` drives the collision path end to end but through a mock backend, so the real overlap arithmetic in `src/sprite_akgl.c` could have changed what `BUMP(1)` reports for every program in existence and the suite would still have printed 110/110. That gap is closed here, before anything touches the arithmetic, so that there is a *before* to compare an *after* against. Six cases against the real akgl backend: nothing defined, two sprites overlapping, edge-to-edge, a hidden sprite, and the x-expand bit doubling the box that collides rather than only the one that draws. Edge-to-edge earns its place -- the test is a strict `<`, a tile-aligned program puts sprites there constantly, and a replacement answering "touching" instead of "overlapping" would change every one of them silently. **The seventh is the cross-shaped overlap**, and it is the one to watch. A tall thin sprite crossing a short wide one overlaps without either rectangle holding a corner of the other; `akgl_collide_rectangles()` is documented as answering "no" there, which is why `src/sprite_akgl.c` does the four comparisons itself rather than calling it. Two further assertions stop that test passing by accident: each sprite is moved clear along the axis it is supposed to be short on, so a sprite that came out the wrong size fails rather than quietly reporting an ordinary overlap. `tests/collision_perf.c` answers the question nobody had measured. The service runs at the top of every interpreter *step* and the frontend takes 256 steps per rendered frame, so a busy program scans up to 256 times a frame over sprites that have not moved. At RelWithDebInfo, scale 10, best of 5: the scan is 96.3 ns at eight overlapping sprites and 19.4 ns at none, against a rendered frame of 1.17 ms. **256 scans is 24.7 us, or 2.1% of a frame, in the pathological case, and 0.42% for a program with no sprites.** So the per-step cadence stays. It is what makes a collision report describe where the sprites have just been moved to rather than where they were, and 2% of a frame in a case no real program reaches is not worth changing when a handler fires for every program that already works. The numbers and that conclusion are in `MAINTENANCE.md` so it does not get re-argued. The benchmark borrows libakgl's `benchutil.h` by include path rather than copying it, the way the fixture font is already borrowed, and is labelled `perf` so `ctest -LE perf` can leave it out. It runs at scale 1 in the ordinary suite -- 1.2 seconds -- because a benchmark nothing ever builds is a benchmark that rots. Both suites green: 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
837 lines
31 KiB
C
837 lines
31 KiB
C
/**
|
|
* @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 "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_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)
|
|
{
|
|
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 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);
|
|
}
|
|
|
|
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_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());
|
|
} 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;
|
|
}
|