A drawing lasted exactly one frame. The verbs are immediate, they went to the back buffer, SDL double-buffers and the frontend never clears -- so the only way to keep a picture was to capture it with `SSHAPE` and install it as a sprite, which is what `examples/breakout/sprites/breakout.bas` spends two of its eight sprites doing. That was TODO.md section 9 item 9. The drawing verbs now render into a layer texture the frame composites under the text and the sprites. Draw once; it is there on every frame after. **Bracketed around the step phase, not around each verb.** One pair of `SDL_SetRenderTarget` calls a frame instead of one per `DRAW`, and it is also what makes `SSHAPE` read back what the program has just drawn rather than whatever the last frame left. **The layer is transparent where nothing was drawn.** It covers the whole window and composites underneath, so an opaque one would black out the frame the moment a program issued a single `DRAW`. And a fresh SDL target texture's contents are undefined, so it is cleared on creation -- skipping that puts uninitialised memory under the first frame's text and looks like a driver bug rather than a missing memset. **The line editor forced a wrinkle worth naming.** `akbasic_frontend_akgl_pump()` is called from two places with different answers to "is a render target current": the frame loop calls it between steps, and the sink's editor calls it from *inside* a step, borrowing a frame while it waits for a typed line. SDL refuses to present while a target is current, so the pump ends the layer, presents, and puts it back only if it was the one that ended it. `akgl_frontend` caught this -- it drives a REPL session, and it failed with "You can't present on a render target" the first time the brackets went in. This does not make a drawing *visible* on its own. The text layer still repaints every row it owns, opaque, every frame, and by default it owns the whole window; `WINDOW` shrinks it and that half was already fixed. The two together are what a picture needed, and the tests assert both -- a pixel still there a frame later with nothing redrawn, and a pixel below a shrunk text area surviving the text repaint. The second assertion wipes to a non-black colour first, because against black it could not tell a transparent layer from an opaque one. The tests found two of their own bugs on the way: `stop_runtime()` was not tearing the graphics backend down, so re-initialising it dropped a live texture on the floor; and a first draft called `begin()` before `start_runtime()`, which re-inits the backend, so the assertion read back off an orphaned render target and passed while proving nothing. Chapters 6 and 13 stop saying a drawing has to be redrawn every frame, because it does not. The batch-boundary tear stays documented -- it bites an `SSHAPE` capture, which matters much less now that capturing is not the only way to keep a picture. Both games still run clean. 111 with akgl, 110 without. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwxGB6TdoVvZ11KQQME9cL
1163 lines
44 KiB
C
1163 lines
44 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)
|
|
{
|
|
/*
|
|
* 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 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);
|
|
}
|
|
|
|
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());
|
|
CATCH(errctx, test_sprite_shapes());
|
|
CATCH(errctx, test_static_geometry());
|
|
CATCH(errctx, test_drawing_layer_persists());
|
|
} 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;
|
|
}
|