diff --git a/CMakeLists.txt b/CMakeLists.txt index 470a3a5..30c9f6b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -192,6 +192,7 @@ set(AKGL_TEST_SUITES json_helpers physics registry + renderer sprite staticstring text diff --git a/include/akgl/renderer.h b/include/akgl/renderer.h index 32858aa..ce874a5 100644 --- a/include/akgl/renderer.h +++ b/include/akgl/renderer.h @@ -7,6 +7,11 @@ * the six `akgl_render_2d_*` entry points. A different renderer is a different * initializer populating the same struct, not a branch inside these functions. * + * Those are two separable jobs, and a host that already owns an `SDL_Renderer` + * -- an embedded interpreter, which must not create the window -- wants only the + * second. akgl_render_bind2d() is that half on its own; akgl_render_init2d() + * makes the window and then calls it. + * * Callers do not normally name the `akgl_render_2d_*` functions directly; they * go through the pointers on the backend (`renderer->frame_start(renderer)`), * which is what makes the swap possible. The global `renderer` in game.h is the @@ -139,14 +144,36 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_mesh(akgl_RenderBackend * */ akerr_ErrorContext AKERR_NOIGNORE *akgl_render_2d_draw_world(akgl_RenderBackend *self, akgl_Iterator *opflags); +/** + * @brief Install the 2D backend's methods on a backend the caller owns. + * + * The vtable half of akgl_render_init2d(), with no window, no renderer, and no + * property registry involved: it points @p self's six function pointers at the + * `akgl_render_2d_*` entry points and returns. `sdl_renderer` is not touched, + * so a caller who has already put its own `SDL_Renderer` there keeps it, and a + * caller who has not gets a backend whose entry points all report + * `AKERR_NULLPOINTER` rather than crash. + * + * This is the entry point for a host that owns its own window -- it can drive + * akgl_actor_render(), akgl_tilemap_draw() and the rest through a backend + * libakgl never created. akgl_render_init2d() is the same thing with a window + * in front of it. + * + * @param self The backend to bind. Required. Only the method pointers are + * written. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER If @p self is `NULL`. + */ +akerr_ErrorContext AKERR_NOIGNORE *akgl_render_bind2d(akgl_RenderBackend *self); + /** * @brief Create the window and SDL renderer, and bind the 2D backend's methods. * * Reads `game.screenwidth` and `game.screenheight` from the property registry * (both defaulting to the string "0", which asks SDL for a zero-sized window), * creates the window and renderer with `game.uri` as the title, points `camera` - * at the full screen rectangle, and installs the six `akgl_render_2d_*` function - * pointers on @p self. + * at the full screen rectangle, and then calls akgl_render_bind2d() to install + * the six `akgl_render_2d_*` function pointers on @p self. * * Because the dimensions come from the registry, akgl_registry_init_properties * and the property writes have to happen first -- see the note on diff --git a/src/renderer.c b/src/renderer.c index f44fafd..a85a2ea 100644 --- a/src/renderer.c +++ b/src/renderer.c @@ -43,6 +43,18 @@ akerr_ErrorContext AKERR_NOIGNORE *akgl_render_init2d(akgl_RenderBackend *self) camera->w = screenwidth; camera->h = screenheight; + PASS(e, akgl_render_bind2d(self)); + SUCCEED_RETURN(e); +} + +akerr_ErrorContext AKERR_NOIGNORE *akgl_render_bind2d(akgl_RenderBackend *self) +{ + PREPARE_ERROR(e); + FAIL_ZERO_RETURN(e, self, AKERR_NULLPOINTER, "self"); + + // Deliberately does not touch self->sdl_renderer: a host that owns its own + // window has already put one there, and this is the only way it gets a + // usable backend without libakgl creating a second window. self->shutdown = &akgl_render_2d_shutdown; self->frame_start = &akgl_render_2d_frame_start; self->frame_end = &akgl_render_2d_frame_end; diff --git a/tests/renderer.c b/tests/renderer.c new file mode 100644 index 0000000..396f1c5 --- /dev/null +++ b/tests/renderer.c @@ -0,0 +1,222 @@ +/** + * @file renderer.c + * @brief Unit tests for the 2D render backend's vtable and its entry points. + * + * None of this needs a display. akgl_render_bind2d() installs the backend's + * methods and nothing else, so it can be checked against a backend that has no + * SDL_Renderer at all; the entry points that do draw are checked against a + * software renderer under the dummy video driver, the same way tests/draw.c + * does it. + * + * akgl_render_init2d() is not covered here: it creates a window from the + * property registry and writes the `camera` global, which is what the offscreen + * harness described in TODO.md exists to make testable. + */ + +#include +#include +#include + +#include +#include +#include + +#include "testutil.h" + +/** @brief Width and height of the offscreen target the drawing tests use. */ +#define TEST_TARGET_SIZE 32 + +/** @brief A backend bound to a live software renderer, built by main(). */ +static akgl_RenderBackend bound; + +akerr_ErrorContext *test_render_bind2d(void) +{ + PREPARE_ERROR(errctx); + akgl_RenderBackend backend; + + ATTEMPT { + // The state a host is in when it owns its own window: a zeroed backend + // with somebody else's SDL_Renderer in it. Binding must fill in the six + // methods and leave that renderer alone -- creating a second window is + // precisely what an embedded interpreter must not do. + memset(&backend, 0x00, sizeof(akgl_RenderBackend)); + backend.sdl_renderer = (SDL_Renderer *)&bound; + + TEST_EXPECT_OK(errctx, akgl_render_bind2d(&backend), "binding the 2D backend"); + + TEST_ASSERT(errctx, backend.sdl_renderer == (SDL_Renderer *)&bound, + "binding replaced the caller's SDL_Renderer"); + TEST_ASSERT(errctx, backend.shutdown == &akgl_render_2d_shutdown, + "binding did not install shutdown"); + TEST_ASSERT(errctx, backend.frame_start == &akgl_render_2d_frame_start, + "binding did not install frame_start"); + TEST_ASSERT(errctx, backend.frame_end == &akgl_render_2d_frame_end, + "binding did not install frame_end"); + TEST_ASSERT(errctx, backend.draw_texture == &akgl_render_2d_draw_texture, + "binding did not install draw_texture"); + TEST_ASSERT(errctx, backend.draw_mesh == &akgl_render_2d_draw_mesh, + "binding did not install draw_mesh"); + TEST_ASSERT(errctx, backend.draw_world == &akgl_render_2d_draw_world, + "binding did not install draw_world"); + + // Binding a backend that has no renderer yet is legal -- the two halves + // are separable in both directions -- and leaves it NULL rather than + // inventing one. + memset(&backend, 0x00, sizeof(akgl_RenderBackend)); + TEST_EXPECT_OK(errctx, akgl_render_bind2d(&backend), + "binding a backend with no SDL renderer"); + TEST_ASSERT(errctx, backend.sdl_renderer == NULL, + "binding invented an SDL_Renderer"); + + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_bind2d(NULL), + "binding a NULL backend"); + } CLEANUP { + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *test_render_backend_without_a_renderer(void) +{ + PREPARE_ERROR(errctx); + akgl_RenderBackend empty; + + ATTEMPT { + // Bound but never given an SDL_Renderer. Every entry point that draws + // has to report that rather than dereference it. + memset(&empty, 0x00, sizeof(akgl_RenderBackend)); + CATCH(errctx, akgl_render_bind2d(&empty)); + + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, empty.frame_start(&empty), + "starting a frame on a backend with no renderer"); + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, empty.frame_end(&empty), + "ending a frame on a backend with no renderer"); + + // Shutdown is the one that has nothing to release yet, so it succeeds. + TEST_EXPECT_OK(errctx, empty.shutdown(&empty), "shutting down an unused backend"); + } CLEANUP { + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *test_render_frames_and_textures(void) +{ + PREPARE_ERROR(errctx); + SDL_Surface *surface = NULL; + SDL_Texture *texture = NULL; + SDL_FRect dest; + SDL_FPoint center; + + ATTEMPT { + dest.x = 0.0f; + dest.y = 0.0f; + dest.w = 8.0f; + dest.h = 8.0f; + center.x = 4.0f; + center.y = 4.0f; + + surface = SDL_CreateSurface(8, 8, SDL_PIXELFORMAT_RGBA32); + FAIL_ZERO_BREAK(errctx, surface, AKGL_ERR_SDL, "%s", SDL_GetError()); + texture = SDL_CreateTextureFromSurface(bound.sdl_renderer, surface); + FAIL_ZERO_BREAK(errctx, texture, AKGL_ERR_SDL, "%s", SDL_GetError()); + + TEST_EXPECT_OK(errctx, bound.frame_start(&bound), "starting a frame"); + TEST_EXPECT_OK(errctx, + bound.draw_texture(&bound, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE), + "blitting a texture unrotated"); + // A non-zero angle takes the rotated path, which is the one that + // consults the pivot and the flip mode. + TEST_EXPECT_OK(errctx, + bound.draw_texture(&bound, texture, NULL, &dest, 90.0, ¢er, SDL_FLIP_HORIZONTAL), + "blitting a texture rotated"); + TEST_EXPECT_OK(errctx, bound.frame_end(&bound), "ending a frame"); + + // A rotation with no pivot is refused: SDL's "NULL means the centre of + // dest" convention is not offered here, so a NULL is a caller mistake + // rather than a default. + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + bound.draw_texture(&bound, texture, NULL, &dest, 90.0, NULL, SDL_FLIP_NONE), + "blitting rotated with no pivot"); + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + akgl_render_2d_draw_texture(NULL, texture, NULL, &dest, 0, NULL, SDL_FLIP_NONE), + "blitting through a NULL backend"); + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, + bound.draw_texture(&bound, NULL, NULL, &dest, 0, NULL, SDL_FLIP_NONE), + "blitting a NULL texture"); + } CLEANUP { + if ( texture != NULL ) { + SDL_DestroyTexture(texture); + } + if ( surface != NULL ) { + SDL_DestroySurface(surface); + } + } PROCESS(errctx) { + } FINISH(errctx, true); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *test_render_unimplemented_and_guards(void) +{ + PREPARE_ERROR(errctx); + + ATTEMPT { + // The 3D hook is not a stub that quietly does nothing. It refuses, so a + // caller that reaches it finds out on the first frame. + TEST_EXPECT_STATUS(errctx, AKERR_API, bound.draw_mesh(&bound), + "drawing a mesh through the 2D backend"); + + TEST_EXPECT_STATUS(errctx, AKERR_NULLPOINTER, akgl_render_2d_draw_world(NULL, NULL), + "drawing the world through a NULL backend"); + + // Shutting the 2D backend down releases nothing today, and says so. + TEST_EXPECT_OK(errctx, bound.shutdown(&bound), "shutting the backend down"); + } CLEANUP { + } PROCESS(errctx) { + } FINISH(errctx, true); + 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()); + memset(&bound, 0x00, sizeof(akgl_RenderBackend)); + + FAIL_ZERO_BREAK( + errctx, + SDL_Init(SDL_INIT_VIDEO), + AKGL_ERR_SDL, + "Couldn't initialize SDL: %s", + SDL_GetError()); + FAIL_ZERO_BREAK( + errctx, + SDL_CreateWindowAndRenderer( + "net/aklabs/libakgl/test_renderer", + TEST_TARGET_SIZE, + TEST_TARGET_SIZE, + 0, + &window, + &bound.sdl_renderer), + AKGL_ERR_SDL, + "Couldn't create window/renderer: %s", + SDL_GetError()); + // The host owns the window; libakgl only ever binds to it. This is the + // arrangement akgl_render_bind2d exists for. + CATCH(errctx, akgl_render_bind2d(&bound)); + + CATCH(errctx, test_render_bind2d()); + CATCH(errctx, test_render_backend_without_a_renderer()); + CATCH(errctx, test_render_frames_and_textures()); + CATCH(errctx, test_render_unimplemented_and_guards()); + } CLEANUP { + SDL_Quit(); + } PROCESS(errctx) { + } FINISH_NORETURN(errctx); +}