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