diff --git a/CMakeLists.txt b/CMakeLists.txt index b06184a..9c848a9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -72,6 +72,26 @@ endfunction() add_subdirectory(deps/libakerror EXCLUDE_FROM_ALL) add_subdirectory(deps/libakstdlib EXCLUDE_FROM_ALL) if(AKBASIC_WITH_AKGL) + # libakgl builds its own vendored SDL, SDL_image, SDL_mixer, SDL_ttf and + # jansson only when it is *top-level*. Embedded, it goes down a find_package + # path instead and requires all five installed on the system -- which is a + # surprise, because they are sitting right there in deps/libakgl/deps as + # submodules. Every one of those lookups is guarded with + # if(NOT TARGET ...), so declaring the targets here first satisfies them + # and the vendored copies get used after all. Same trick, and the same + # ordering requirement, that akerror::akerror and akstdlib::akstdlib already + # need above. + # + # Filed upstream: an embedded libakgl should add its own vendored + # dependencies rather than requiring them installed. + set(JANSSON_WITHOUT_TESTS ON CACHE BOOL "Do not build vendored Jansson tests" FORCE) + set(JANSSON_EXAMPLES OFF CACHE BOOL "Do not build vendored Jansson examples" FORCE) + set(JANSSON_BUILD_DOCS OFF CACHE BOOL "Do not build vendored Jansson docs" FORCE) + add_subdirectory(deps/libakgl/deps/jansson EXCLUDE_FROM_ALL) + add_subdirectory(deps/libakgl/deps/SDL EXCLUDE_FROM_ALL) + add_subdirectory(deps/libakgl/deps/SDL_image EXCLUDE_FROM_ALL) + add_subdirectory(deps/libakgl/deps/SDL_mixer EXCLUDE_FROM_ALL) + add_subdirectory(deps/libakgl/deps/SDL_ttf EXCLUDE_FROM_ALL) add_subdirectory(deps/libakgl EXCLUDE_FROM_ALL) endif() @@ -142,7 +162,12 @@ akbasic_instrument(akbasic) # The libakgl-backed text sink, kept in its own target so the core library and # the whole golden suite build with no SDL present. if(AKBASIC_WITH_AKGL) - add_library(akbasic_akgl src/sink_akgl.c) + add_library(akbasic_akgl + src/audio_akgl.c + src/graphics_akgl.c + src/input_akgl.c + src/sink_akgl.c + ) target_compile_options(akbasic_akgl PRIVATE -Wall -Wextra) target_link_libraries(akbasic_akgl PUBLIC akbasic akgl) akbasic_instrument(akbasic_akgl) @@ -230,6 +255,30 @@ foreach(_test IN LISTS AKBASIC_TESTS AKBASIC_WILL_FAIL_TESTS AKBASIC_KNOWN_FAILI _add_test(NAME ${_test} COMMAND akbasic_test_${_test}) endforeach() +# The akgl-backed suite. Its own list because it is the only test here that links +# SDL, and because it can only exist when AKBASIC_WITH_AKGL is on -- the whole +# point of the backend records is that everything else builds without it. +# +# It runs under the dummy video and audio drivers with a software renderer, +# following deps/libakgl/tests/draw.c: a 128x128 target read back with +# SDL_RenderReadPixels, so it needs no display and no offscreen harness. +if(AKBASIC_WITH_AKGL) + add_executable(akbasic_test_akgl_backends tests/akgl_backends.c) + target_compile_options(akbasic_test_akgl_backends PRIVATE -Wall -Wextra) + target_link_libraries(akbasic_test_akgl_backends PRIVATE akbasic_akgl) + target_include_directories(akbasic_test_akgl_backends PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests") + # libakgl's monospaced fixture font, borrowed rather than copied: a second copy + # of a 10 KB binary is a second thing to keep in step for no benefit. + target_compile_definitions(akbasic_test_akgl_backends PRIVATE + AKBASIC_TEST_FONT="${CMAKE_CURRENT_SOURCE_DIR}/deps/libakgl/tests/assets/akgl_test_mono.ttf") + akbasic_instrument(akbasic_test_akgl_backends) + _add_test(NAME akgl_backends COMMAND akbasic_test_akgl_backends) + _set_tests_properties(akgl_backends PROPERTIES + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" + TIMEOUT 60 + ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy") +endif() + if(AKBASIC_TESTS OR AKBASIC_WILL_FAIL_TESTS OR AKBASIC_KNOWN_FAILING_TESTS) _set_tests_properties( ${AKBASIC_TESTS} ${AKBASIC_WILL_FAIL_TESTS} ${AKBASIC_KNOWN_FAILING_TESTS} diff --git a/README.md b/README.md index 2b16ff8..8586741 100644 --- a/README.md +++ b/README.md @@ -353,7 +353,11 @@ target_link_libraries(YOUR_GAME PRIVATE akbasic::akbasic) `akbasic` links `akerror::akerror` and `akstdlib::akstdlib` publicly, so you inherit both. If your project already declares those targets, declare them *before* adding this one — the same rule that applies to `libakgl`. -`libakgl` itself is **not** a dependency of the core library. `-DAKBASIC_WITH_AKGL=ON` builds an additional `akbasic_akgl` target carrying the akgl-backed text sink; it is off by default, which is why the interpreter and its whole test suite build on a machine with no SDL. +`libakgl` itself is **not** a dependency of the core library. `-DAKBASIC_WITH_AKGL=ON` builds an additional `akbasic_akgl` target carrying the akgl-backed text sink and the graphics, sound and input backends; it is off by default, which is why the interpreter and its whole test suite build on a machine with no SDL. + +The graphics, sound and console verbs reach hardware through records of function pointers on the runtime — `akbasic_GraphicsBackend`, `akbasic_AudioBackend` and `akbasic_InputBackend`, attached with `akbasic_runtime_set_devices()`. All three may be `NULL`, which is what the standalone driver gives them: a verb that needs a device it was not given raises rather than crashing, and a `PRINT`-only program never notices. A host that renders some other way supplies its own records and never links `libakgl` at all. + +Two things about those verbs that differ from a real C128, because both would otherwise surprise you. `PLAY` does not block — it queues its notes and `akbasic_runtime_step()` releases them against whatever time you last passed to `akbasic_runtime_settime()`, so the statement after a `PLAY` runs immediately. And `GETKEY` holds the program without blocking you: the step still returns, it simply does not advance until a key arrives. ## Limits diff --git a/TODO.md b/TODO.md index e26d109..21cf611 100644 --- a/TODO.md +++ b/TODO.md @@ -369,44 +369,72 @@ such ceiling because it called `make()`. --- -## 3. Remaining work: the akgl text sink +## 3. The akgl-backed sink and devices — **done** -**Unblocked as of `libakgl` 42b60f7** — the text-measurement call this waited on now exists. +`-DAKBASIC_WITH_AKGL=ON` builds and its suite passes. Four adaptors in the `akbasic_akgl` +target, which is the only thing here that links SDL: -**`src/sink_akgl.c`.** Implement the §1.5 vtable against `akgl_text_loadfont`, -`akgl_text_rendertextat` and `akgl_text_measure`. Owns the cursor, the wrap, and the scroll — -everything in `basicruntime_graphics.go` except `Write`/`Println`, which are now the sink -interface itself. +| File | Backs | +|---|---| +| `src/sink_akgl.c` | the §1.5 text sink, over `akgl_text_measure` and `akgl_text_rendertextat` | +| `src/graphics_akgl.c` | `akbasic_GraphicsBackend`, over `akgl_draw_*`, and the SSHAPE surface pool | +| `src/audio_akgl.c` | `akbasic_AudioBackend`, over `akgl_audio_*` | +| `src/input_akgl.c` | `akbasic_InputBackend`, over `akgl_controller_poll_key` | -The character grid comes from `akgl_text_measure(font, "A", &w, &h)`, which is the direct -equivalent of the `font.SizeUTF8("A")` at `basicruntime.go:96` that the reference derives -`maxCharsW`/`maxCharsH` from. For wrapping, `akgl_text_measure_wrapped` takes the same -`wraplength` argument `akgl_text_rendertextat` already does, so the measurement and the draw -cannot disagree about where a line breaks. +The character grid comes from `akgl_text_measure(font, "A", &w, &h)` — the direct equivalent of +the `font.SizeUTF8("A")` at `basicruntime.go:96`, and the call this was blocked on until +42b60f7. Wrapping is done on the character grid rather than by handing SDL_ttf a `wraplength`, +because the cursor has to land somewhere definite: a program that `PRINT`s a long string and +then `PRINT`s again expects the second to start on the row after the first ended, and only the +code that placed the characters knows which row that is. -**The interpreter does not own the window, the renderer, or the game loop.** The sink draws -through whatever renderer the host already initialized. `akbasic_sink_init_akgl()` takes the -renderer; it does not create one. +**The interpreter owns no window, renderer or event loop.** Every initializer takes something +the host already made. Each calls `akgl_error_init()` first: `akgl_game_init()` would have, +but we drive subsystems directly and never call it, and a code raised before that registration +carries no name into its stack trace. It is idempotent. -**Call `akgl_error_init()` before anything else in `libakgl`.** New in `libakgl` 0.1.0 -(`deps/libakgl/src/error.c`): it reserves the 256–260 status band and registers a name for -each `AKGL_ERR_*` code. `akgl_game_init()` calls it first, but we drive subsystems directly and -never call `akgl_game_init()`, so it is ours to call. Skip it and every `AKGL_ERR_*` that -reaches a stack trace prints "Unknown Error" — which is exactly the defect the upstream commit -found in `game.c`, where an SDL failure five lines ahead of the old registration site was -guaranteed to be unnamed. It is idempotent, so calling it from both `akbasic_sink_init_akgl()` -and a host that already did is harmless. -`PASS` its result; a range collision is an initialization failure, not a warning. +*Acceptance:* `tests/akgl_backends.c`, a 128x128 software renderer under the dummy video +driver read back with `SDL_RenderReadPixels` — the pattern `deps/libakgl/tests/draw.c` +established, which needs no display and no offscreen harness. Registered as the `akgl_backends` +CTest case, and only when `AKBASIC_WITH_AKGL` is on. -**Still to check before starting:** `-DAKBASIC_WITH_AKGL=ON` has never been configured in this -repository, because until now there was nothing to build against. It needs `libakgl`'s own -submodules present (`git submodule update --init --recursive` pulls SDL and friends), and the -`akbasic_akgl` target in `CMakeLists.txt` has therefore never been compiled. Expect to fix -something there on the first attempt. +### Four things that had to be worked around to get there -*Acceptance:* `tests/sink_akgl.c` against the offscreen renderer harness described in -`deps/libakgl/TODO.md` under "Remaining work". If that harness still does not exist, a -known-failing test plus a `libakgl` TODO entry saying so. +All four are `libakgl`'s and all four are filed in `deps/libakgl/TODO.md`. Each workaround is +commented at its site with the words "filed upstream" so it can be found and deleted later. + +1. **An embedded `libakgl` demands its dependencies be *installed*.** It builds its vendored + SDL, SDL_image, SDL_mixer, SDL_ttf and jansson only when it is top-level; embedded, it goes + down a `find_package` path and fails on a machine that has none of them — while the + submodules sit right there in `deps/libakgl/deps`. Every lookup is guarded with + `if(NOT TARGET ...)`, so our `CMakeLists.txt` adds those five subdirectories *before* + `add_subdirectory(deps/libakgl)` and the vendored copies get used after all. Same trick and + same ordering requirement `akerror::akerror` and `akstdlib::akstdlib` already need. + +2. **`akgl/controller.h` does not compile on its own.** It declares two handler function + pointers taking an `akgl_Actor *` and includes nothing that declares the type. + `src/input_akgl.c` includes `akgl/actor.h` ahead of it. + +3. **There is no way to attach a 2D backend to a renderer you already have.** + `akgl_render_init2d()` installs the six vtable pointers, but it also creates its own window + from the game properties and writes to the `camera` global, so it belongs to the + `akgl_game_init()` path — which is exactly the path an embedding host is not on. + `tests/akgl_backends.c` assigns the six pointers by hand. What is wanted upstream is the + vtable half of `init2d` on its own. + +4. **`akgl_text_rendertextat()` segfaults on a backend whose vtable is empty.** It reaches + through `renderer->draw_texture` without checking it, so the first `PRINT` through the sink + dereferences NULL. That is the same class of defect 42b60f7's own commit added a draw test + for — "a backend that exists but was never given an `SDL_Renderer`" — and the text path + still has it. + +### Still missing from the sink + +`readline` reports end of input rather than reading anything: a drawn text layer is not a +source of lines, and `INPUT` through a graphics sink wants a line editor built on the keystroke +ring. EOF rather than an error is the contract `sink.h` states, so `INPUT` handles it already. +That editor is the next piece of work here, and it is what `WINDOW` and `KEY` in group E want +as well. --- @@ -781,7 +809,7 @@ Dependency baseline: |---|---|---| | `deps/libakerror` | 1.0.0 | Private ownership-enforced status registry. akbasic reserves 512–767 in `akbasic_error_register()`. Does not namespace its `coverage` target when embedded — worked around in our `CMakeLists.txt`. | | `deps/libakstdlib` | 0.1.0 | soname `libakstdlib.so.0.1`. `AKSL_VERSION_CHECK()` asserted in `tests/version_check.c`. Its `aksl_ato*` family is banned here — see §1.9. | -| `deps/libakgl` | 0.1.0, **pinned at commit 42b60f7** | Migrated to the 1.0.0 registry and owns 256–260. Not yet linked: `AKBASIC_WITH_AKGL` defaults OFF and `src/sink_akgl.c` does not exist. The version number cannot carry this requirement — see below. | +| `deps/libakgl` | 0.1.0, **pinned at commit 42b60f7** | Migrated to the 1.0.0 registry and owns 256–260. Linked and tested under `-DAKBASIC_WITH_AKGL=ON`, which still defaults OFF so the core library and its whole suite build on a machine with no SDL. The version number cannot carry this requirement — see below. | **The `libakgl` requirement is pinned by commit, not by version, and that is a defect upstream.** 42b60f7 added 22 public symbols across four headers — `akgl_draw_*` (8), @@ -823,16 +851,21 @@ keeping: What remains, in priority order: -1. **§3 — the akgl text sink.** No longer blocked: `libakgl` 42b60f7 landed - `akgl_text_measure`, so this is now ordinary work. Note that `-DAKBASIC_WITH_AKGL=ON` has - never been configured here, so the `akbasic_akgl` target is unproven. -2. **§4 — the language completion work queue.** Nothing in it is blocked on `libakgl` any more: - groups G, I and part of E were, and `libakgl` 42b60f7 closed all three. Multiple statements - per line (the `COLON` token exists and nothing consumes it) should still come first, because - `DO`/`LOOP` reads badly without it. -3. **§6 items 12–17** — the defects the port uncovered. Item 12 is the one that produces a +1. **§4 — the language completion work queue.** Groups G and I and the `GET`/`GETKEY`/`SCNCLR` + part of E are done. Of what is left, **multiple statements per line** should come first: + the `COLON` token exists and nothing consumes it, and `DO`/`LOOP` in group A reads badly + without it. Then groups A, B, D, F and J, none of which need anything from `libakgl`. +2. **A line editor for the akgl sink.** `readline` reports EOF, so `INPUT` does not work + through a drawn text layer. It wants the keystroke ring plus a cursor, and it is also what + `WINDOW` and `KEY` in group E need. See §3. +3. **CI does not cover `-DAKBASIC_WITH_AKGL=ON`.** Doing so needs `submodules: recursive`, + which pulls SDL, SDL_image, SDL_mixer, SDL_ttf and jansson — a long build on a job the other + four do not need. Deliberately deferred, not forgotten: the akgl target is built and tested + locally with the two commands in §3, and it will rot silently until somebody decides that + cost is worth paying. +4. **§6 items 12–17** — the defects the port uncovered. Item 12 is the one that produces a *wrong answer* rather than a refused one, and should be fixed first. -4. **Mutation survivors in `src/value.c`.** The harness is in place (`scripts/mutation_test.py`, +5. **Mutation survivors in `src/value.c`.** The harness is in place (`scripts/mutation_test.py`, the `mutation` CMake target, and a CI job on `src/convert.c`), and a partial run over `src/value.c` turned up test gaps worth closing. These are *not* equivalent mutants; each is a real bug of that shape the suite would not notice: diff --git a/include/akbasic/akgl.h b/include/akbasic/akgl.h new file mode 100644 index 0000000..01f029b --- /dev/null +++ b/include/akbasic/akgl.h @@ -0,0 +1,167 @@ +/** + * @file akgl.h + * @brief The libakgl-backed implementations of the sink and the three devices. + * + * Everything declared here lives in the separate `akbasic_akgl` target, which is + * the only part of this project that links SDL. The core library builds and its + * whole test suite runs on a machine with no SDL on it; that is why the records + * these initializers populate are plain function-pointer structs and why this + * header is the only one that includes a libakgl header. + * + * **The interpreter owns no window, no renderer and no event loop.** Every one + * of these takes something the host already created and draws or plays through + * it. None of them creates a device, and none of them pumps events. + * + * Each initializer calls akgl_error_init() first. It reserves libakgl's 256-260 + * status band and names every AKGL_ERR_* code; akgl_game_init() calls it as its + * first statement, but a program driving subsystems directly -- which is exactly + * what an embedded interpreter does -- never goes through akgl_game_init() and + * has to call it itself. Skip it and every AKGL_ERR_* that reaches a stack trace + * prints "Unknown Error". It is idempotent, so a host that already called it + * loses nothing. + */ + +#ifndef _AKBASIC_AKGL_H_ +#define _AKBASIC_AKGL_H_ + +#include +#include + +#include + +#include + +#include +#include +#include +#include + +/** @brief How many saved SSHAPE regions the graphics backend will hold at once. */ +#define AKBASIC_AKGL_MAX_SHAPES 16 + +/** + * @brief State for the libakgl-backed text sink. + * + * The cursor, the wrap and the scroll live here rather than in the interpreter: + * everything in the reference's basicruntime_graphics.go except Write and + * Println, which are the sink interface itself. + */ +typedef struct +{ + akgl_RenderBackend *renderer; + TTF_Font *font; + SDL_Color color; + + int x; /* pixel origin of the text area */ + int y; + int width; /* pixel size of the text area */ + int height; + int cellw; /* one character cell, measured from the font */ + int cellh; + int columns; /* the character grid the cell size works out to */ + int rows; + + int cursorcol; + int cursorrow; + + /* + * The scrollback the sink redraws every frame. A fixed grid rather than a + * list of lines: the interpreter allocates nothing, and neither does this. + */ + char text[64][256]; +} akbasic_AkglSink; + +/** + * @brief State for the libakgl-backed graphics backend. + * + * The shape pool is why this exists at all. SSHAPE hands the BASIC program a + * handle rather than the pixels -- see TODO.md section 5 -- and these are the + * surfaces those handles refer to. + */ +typedef struct +{ + akgl_RenderBackend *renderer; + SDL_Surface *shapes[AKBASIC_AKGL_MAX_SHAPES]; + int shapecount; +} akbasic_AkglGraphics; + +/** + * @brief Point a text sink at a renderer and a font the host already has. + * + * The character grid is derived by measuring one glyph with akgl_text_measure(), + * which is the direct equivalent of the reference's font.SizeUTF8("A") -- and + * which did not exist in libakgl until 42b60f7. A font that is not monospaced + * still works; the grid is then sized by whatever "A" happens to measure, and + * proportional glyphs simply do not line up in columns. + * + * @param obj Object to initialize, inspect, or modify. + * @param state Storage for the sink's own state; must outlive the sink. + * @param renderer The renderer the host already initialized; not created here. + * @param font An open font; not opened or closed here. + * @param w Width in pixels of the text area. + * @param h Height in pixels of the text area. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When any pointer argument is NULL. + * @throws AKBASIC_ERR_VALUE When the font measures a zero-width cell, which would divide by zero. + * @throws AKBASIC_ERR_BOUNDS When the area is too small for even one character. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_init_akgl(akbasic_TextSink *obj, akbasic_AkglSink *state, akgl_RenderBackend *renderer, TTF_Font *font, int w, int h); + +/** + * @brief Draw whatever the sink currently holds. + * + * Separate from the sink's write path because the interpreter does not own the + * frame: a host calls this when it is drawing, not when the script happens to + * PRINT. A driver that only wants stdout never calls it at all. + * + * @param obj The sink to draw. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL. + * @throws AKGL_ERR_SDL When the renderer refuses the text. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_akgl_render(akbasic_TextSink *obj); + +/** + * @brief Point a graphics backend at a renderer the host already has. + * @param obj Object to initialize, inspect, or modify. + * @param state Storage for the shape pool; must outlive the backend. + * @param renderer The renderer the host already initialized; not created here. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When any argument is NULL. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_graphics_init_akgl(akbasic_GraphicsBackend *obj, akbasic_AkglGraphics *state, akgl_RenderBackend *renderer); + +/** + * @brief Wire an audio backend to libakgl's tone generator. + * + * Calls akgl_audio_init(), which opens an SDL audio device. A host that owns its + * own audio pipeline can call akgl_audio_init() itself beforehand -- it is + * idempotent in the sense that mattered upstream: the voice table works whether + * or not a device is open. + * + * @param obj Object to initialize, inspect, or modify. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL. + * @throws AKGL_ERR_SDL When no audio device can be opened. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_audio_init_akgl(akbasic_AudioBackend *obj); + +/** + * @brief Wire an input backend to libakgl's keystroke ring. + * + * Reads only. The ring is filled by akgl_controller_handle_event(), which the + * *host* calls as it pumps SDL events -- so a script gets keystrokes without the + * interpreter owning the event loop. + * + * Worth knowing before lending this to a script: that ring is process-global and + * the host's own control maps read the same events. A script sitting in a GET + * loop drains keystrokes the game will then never see. A host that cares either + * withholds the input backend or supplies a filtered one of its own. + * + * @param obj Object to initialize, inspect, or modify. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKERR_NULLPOINTER When `obj` is NULL. + */ +akerr_ErrorContext AKERR_NOIGNORE *akbasic_input_init_akgl(akbasic_InputBackend *obj); + +#endif // _AKBASIC_AKGL_H_ diff --git a/src/audio_akgl.c b/src/audio_akgl.c new file mode 100644 index 0000000..2e7eb9d --- /dev/null +++ b/src/audio_akgl.c @@ -0,0 +1,137 @@ +/** + * @file audio_akgl.c + * @brief Wires the audio backend record to libakgl's tone generator. + * + * Another thin adaptor. The one thing in here worth reading is the waveform + * mapping: akbasic_Waveform and akgl_AudioWaveform currently agree on every + * value, and the mapping is still written out rather than cast across, because + * two enumerations agreeing by accident is not a contract and a future SOUND + * waveform number would break the coincidence silently. + */ + +#include + +#include + +#include +#include + +#include +#include + +/** + * @brief Map a BASIC waveform onto libakgl's. + * + * @param waveform The BASIC-side selection. + * @param dest Output destination populated by the function. + * @return `NULL` on success, otherwise an error context owned by the caller. + * @throws AKBASIC_ERR_BOUNDS When the waveform is not one of the four SOUND defines. + */ +static akerr_ErrorContext *to_akgl_waveform(akbasic_Waveform waveform, akgl_AudioWaveform *dest) +{ + PREPARE_ERROR(errctx); + + switch ( waveform ) { + case AKBASIC_WAVE_TRIANGLE: + *dest = AKGL_AUDIO_WAVE_TRIANGLE; + break; + case AKBASIC_WAVE_SAWTOOTH: + *dest = AKGL_AUDIO_WAVE_SAWTOOTH; + break; + case AKBASIC_WAVE_SQUARE: + *dest = AKGL_AUDIO_WAVE_SQUARE; + break; + case AKBASIC_WAVE_NOISE: + *dest = AKGL_AUDIO_WAVE_NOISE; + break; + default: + FAIL_RETURN(errctx, AKBASIC_ERR_BOUNDS, "No such waveform %d", (int)waveform); + } + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *snd_tone(akbasic_AudioBackend *self, int voice, double hz, int ms) +{ + PREPARE_ERROR(errctx); + + (void)self; + FAIL_ZERO_RETURN(errctx, (ms >= 0), AKERR_VALUE, "Negative note duration %d", ms); + PASS(errctx, akgl_audio_tone(voice, (float32_t)hz, (uint32_t)ms)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *snd_stop(akbasic_AudioBackend *self, int voice) +{ + PREPARE_ERROR(errctx); + + (void)self; + PASS(errctx, akgl_audio_stop(voice)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *snd_waveform(akbasic_AudioBackend *self, int voice, akbasic_Waveform waveform) +{ + PREPARE_ERROR(errctx); + akgl_AudioWaveform mapped = AKGL_AUDIO_WAVE_SQUARE; + + (void)self; + PASS(errctx, to_akgl_waveform(waveform, &mapped)); + PASS(errctx, akgl_audio_waveform(voice, mapped)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *snd_envelope(akbasic_AudioBackend *self, int voice, int attack, int decay, double sustain, int release) +{ + PREPARE_ERROR(errctx); + + (void)self; + FAIL_ZERO_RETURN(errctx, (attack >= 0 && decay >= 0 && release >= 0), AKERR_VALUE, + "Negative envelope stage: %d/%d/%d", attack, decay, release); + PASS(errctx, akgl_audio_envelope(voice, (uint32_t)attack, (uint32_t)decay, + (float32_t)sustain, (uint32_t)release)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *snd_volume(akbasic_AudioBackend *self, double level) +{ + PREPARE_ERROR(errctx); + + (void)self; + PASS(errctx, akgl_audio_volume((float32_t)level)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *snd_voice_active(akbasic_AudioBackend *self, int voice, bool *active) +{ + PREPARE_ERROR(errctx); + + (void)self; + PASS(errctx, akgl_audio_voice_active(voice, active)); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_audio_init_akgl(akbasic_AudioBackend *obj) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, + "NULL backend in audio_init_akgl"); + PASS(errctx, akgl_error_init()); + + /* + * Opens an SDL audio device. A host that owns its own audio pipeline can + * skip this entirely and pull samples with akgl_audio_mix() instead -- the + * voice table works either way, which is the design upstream chose and the + * reason its own suite can be deterministic. + */ + PASS(errctx, akgl_audio_init()); + + obj->self = NULL; /* the voice table is libakgl's, not ours */ + obj->tone = snd_tone; + obj->stop = snd_stop; + obj->waveform = snd_waveform; + obj->envelope = snd_envelope; + obj->volume = snd_volume; + obj->voice_active = snd_voice_active; + SUCCEED_RETURN(errctx); +} diff --git a/src/graphics_akgl.c b/src/graphics_akgl.c new file mode 100644 index 0000000..6e40235 --- /dev/null +++ b/src/graphics_akgl.c @@ -0,0 +1,244 @@ +/** + * @file graphics_akgl.c + * @brief Wires the graphics backend record to libakgl's immediate-mode drawing. + * + * A thin adaptor and deliberately so: the interesting decisions about what a + * BASIC graphics verb means live in src/runtime_graphics.c, and everything here + * is coordinate and colour conversion plus the shape pool that SSHAPE hands out + * handles into. + * + * The akgl_draw_* family is new in libakgl 42b60f7. Every one of its entry + * points takes the akgl_RenderBackend the host already initialized rather than + * reaching for a global, which is exactly what goal 3 needs. + */ + +#include + +#include + +#include +#include + +#include +#include + +/** @brief The colour conversion, which is the whole impedance mismatch. */ +static SDL_Color to_sdl(akbasic_Color color) +{ + SDL_Color out; + + out.r = color.r; + out.g = color.g; + out.b = color.b; + out.a = color.a; + return out; +} + +/** @brief Recover the backend's own state, or say that it has none. */ +static akerr_ErrorContext *state_of(akbasic_GraphicsBackend *self, akbasic_AkglGraphics **dest) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL), AKERR_NULLPOINTER, + "NULL argument in akgl graphics backend"); + *dest = (akbasic_AkglGraphics *)self->self; + FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKERR_NULLPOINTER, + "akgl graphics backend has no state"); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *gfx_point(akbasic_GraphicsBackend *self, double x, double y, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + akbasic_AkglGraphics *state = NULL; + + PASS(errctx, state_of(self, &state)); + PASS(errctx, akgl_draw_point(state->renderer, (float32_t)x, (float32_t)y, to_sdl(color))); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *gfx_line(akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + akbasic_AkglGraphics *state = NULL; + + PASS(errctx, state_of(self, &state)); + PASS(errctx, akgl_draw_line(state->renderer, (float32_t)x1, (float32_t)y1, + (float32_t)x2, (float32_t)y2, to_sdl(color))); + SUCCEED_RETURN(errctx); +} + +/** + * @brief Turn two opposite corners into the rect SDL wants. + * + * BASIC gives two corners in whatever order the program felt like; SDL_FRect is + * an origin and a size and does nothing useful with a negative one. + */ +static SDL_FRect corners_to_rect(double x1, double y1, double x2, double y2) +{ + SDL_FRect rect; + + rect.x = (float32_t)((x1 < x2) ? x1 : x2); + rect.y = (float32_t)((y1 < y2) ? y1 : y2); + rect.w = (float32_t)((x1 < x2) ? (x2 - x1) : (x1 - x2)); + rect.h = (float32_t)((y1 < y2) ? (y2 - y1) : (y1 - y2)); + return rect; +} + +static akerr_ErrorContext *gfx_rect(akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + akbasic_AkglGraphics *state = NULL; + SDL_FRect rect; + + PASS(errctx, state_of(self, &state)); + rect = corners_to_rect(x1, y1, x2, y2); + PASS(errctx, akgl_draw_rect(state->renderer, &rect, to_sdl(color))); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *gfx_filled_rect(akbasic_GraphicsBackend *self, double x1, double y1, double x2, double y2, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + akbasic_AkglGraphics *state = NULL; + SDL_FRect rect; + + PASS(errctx, state_of(self, &state)); + rect = corners_to_rect(x1, y1, x2, y2); + PASS(errctx, akgl_draw_filled_rect(state->renderer, &rect, to_sdl(color))); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *gfx_paint(akbasic_GraphicsBackend *self, int x, int y, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + akbasic_AkglGraphics *state = NULL; + + PASS(errctx, state_of(self, &state)); + /* + * AKERR_OUTOFBOUNDS out of here means the fill exhausted its span stack and + * left the region partly filled. It is passed straight through: PAINT is + * where that is turned into something a BASIC program can see, because PAINT + * is the only thing that knows a program is watching. + */ + PASS(errctx, akgl_draw_flood_fill(state->renderer, x, y, to_sdl(color))); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *gfx_clear(akbasic_GraphicsBackend *self, akbasic_Color color) +{ + PREPARE_ERROR(errctx); + akbasic_AkglGraphics *state = NULL; + SDL_FRect rect; + int w = 0; + int h = 0; + + PASS(errctx, state_of(self, &state)); + FAIL_ZERO_RETURN(errctx, + SDL_GetCurrentRenderOutputSize(state->renderer->sdl_renderer, &w, &h), + AKGL_ERR_SDL, "%s", SDL_GetError()); + + /* + * A filled rectangle over the output rather than SDL_RenderClear, because + * clearing is the host's prerogative: a game that draws a background and + * then hands the renderer to a script does not expect GRAPHIC to wipe it to + * a colour of the script's choosing outside the area the script owns. This + * covers exactly the render output and nothing beyond it. + */ + rect.x = 0.0f; + rect.y = 0.0f; + rect.w = (float32_t)w; + rect.h = (float32_t)h; + PASS(errctx, akgl_draw_filled_rect(state->renderer, &rect, to_sdl(color))); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *gfx_save_shape(akbasic_GraphicsBackend *self, int x1, int y1, int x2, int y2, int *handle) +{ + PREPARE_ERROR(errctx); + akbasic_AkglGraphics *state = NULL; + SDL_Rect region; + + PASS(errctx, state_of(self, &state)); + FAIL_ZERO_RETURN(errctx, (handle != NULL), AKERR_NULLPOINTER, + "NULL handle in save_shape"); + FAIL_ZERO_RETURN(errctx, (state->shapecount < AKBASIC_AKGL_MAX_SHAPES), + AKBASIC_ERR_DEVICE, + "SSHAPE pool is full at %d saved regions; GRAPHIC CLR releases them", + AKBASIC_AKGL_MAX_SHAPES); + + region.x = (x1 < x2) ? x1 : x2; + region.y = (y1 < y2) ? y1 : y2; + region.w = (x1 < x2) ? (x2 - x1) : (x1 - x2); + region.h = (y1 < y2) ? (y2 - y1) : (y1 - y2); + + /* + * NULL through dest asks akgl_draw_copy_region to allocate the surface. That + * is the one allocation in this file and it belongs to the pool below, which + * is bounded -- so it is a fixed ceiling on memory, not an open one. + */ + state->shapes[state->shapecount] = NULL; + PASS(errctx, akgl_draw_copy_region(state->renderer, ®ion, + &state->shapes[state->shapecount])); + *handle = state->shapecount; + state->shapecount += 1; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *gfx_paste_shape(akbasic_GraphicsBackend *self, int handle, double x, double y) +{ + PREPARE_ERROR(errctx); + akbasic_AkglGraphics *state = NULL; + + PASS(errctx, state_of(self, &state)); + FAIL_ZERO_RETURN(errctx, (handle >= 0 && handle < state->shapecount), + AKBASIC_ERR_BOUNDS, "No saved shape %d", handle); + FAIL_ZERO_RETURN(errctx, (state->shapes[handle] != NULL), AKBASIC_ERR_STATE, + "Saved shape %d has been released", handle); + PASS(errctx, akgl_draw_paste_region(state->renderer, state->shapes[handle], + (float32_t)x, (float32_t)y)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *gfx_free_shapes(akbasic_GraphicsBackend *self) +{ + PREPARE_ERROR(errctx); + akbasic_AkglGraphics *state = NULL; + int i = 0; + + PASS(errctx, state_of(self, &state)); + for ( i = 0; i < state->shapecount; i++ ) { + if ( state->shapes[i] != NULL ) { + SDL_DestroySurface(state->shapes[i]); + state->shapes[i] = NULL; + } + } + state->shapecount = 0; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_graphics_init_akgl(akbasic_GraphicsBackend *obj, akbasic_AkglGraphics *state, akgl_RenderBackend *renderer) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL && state != NULL), AKERR_NULLPOINTER, + "NULL argument in graphics_init_akgl"); + FAIL_ZERO_RETURN(errctx, (renderer != NULL), AKERR_NULLPOINTER, + "NULL renderer in graphics_init_akgl: the host creates it, not this"); + PASS(errctx, akgl_error_init()); + + memset(state, 0, sizeof(*state)); + state->renderer = renderer; + + obj->self = state; + obj->point = gfx_point; + obj->line = gfx_line; + obj->rect = gfx_rect; + obj->filled_rect = gfx_filled_rect; + obj->paint = gfx_paint; + obj->clear = gfx_clear; + obj->save_shape = gfx_save_shape; + obj->paste_shape = gfx_paste_shape; + obj->free_shapes = gfx_free_shapes; + SUCCEED_RETURN(errctx); +} diff --git a/src/input_akgl.c b/src/input_akgl.c new file mode 100644 index 0000000..207cc4e --- /dev/null +++ b/src/input_akgl.c @@ -0,0 +1,69 @@ +/** + * @file input_akgl.c + * @brief Wires the input backend record to libakgl's keystroke ring. + * + * The thinnest of the four adaptors, and the one whose *shape* carries the + * design: akgl_controller_poll_key() reads a ring that + * akgl_controller_handle_event() fills as the **host** pumps SDL events. The + * interpreter therefore answers "is there a key waiting" without owning an event + * loop, which is the whole of goal 3 for input. + * + * The caveat a host needs, repeated here because this is the file that causes + * it: that ring is process-global and the host's own control maps read the same + * events. A script sitting in a GET loop drains keystrokes the game will then + * never see. Withhold the backend, or supply a filtered one. + */ + +#include + +/* + * akgl/actor.h before akgl/controller.h, and it is not optional: controller.h + * declares two handler function pointers taking an akgl_Actor * and includes + * nothing that declares the type, so on its own it does not compile. Not a + * workaround anybody should copy -- filed upstream against libakgl, whose own + * style rules call for self-contained headers. Delete this note and the include + * when controller.h includes what it uses. + */ +#include +#include +#include + +#include +#include + +static akerr_ErrorContext *in_poll_key(akbasic_InputBackend *self, int *keycode, bool *available) +{ + PREPARE_ERROR(errctx); + + (void)self; + /* + * An empty ring comes back as success with available false, both here and + * upstream. Passed through unchanged: GET reports it as the empty string, + * which is ordinary BASIC rather than a failure. + */ + PASS(errctx, akgl_controller_poll_key(keycode, available)); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *in_flush_keys(akbasic_InputBackend *self) +{ + PREPARE_ERROR(errctx); + + (void)self; + PASS(errctx, akgl_controller_flush_keys()); + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_input_init_akgl(akbasic_InputBackend *obj) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, + "NULL backend in input_init_akgl"); + PASS(errctx, akgl_error_init()); + + obj->self = NULL; /* the ring is libakgl's, not ours */ + obj->poll_key = in_poll_key; + obj->flush_keys = in_flush_keys; + SUCCEED_RETURN(errctx); +} diff --git a/src/sink_akgl.c b/src/sink_akgl.c new file mode 100644 index 0000000..a0763d3 --- /dev/null +++ b/src/sink_akgl.c @@ -0,0 +1,240 @@ +/** + * @file sink_akgl.c + * @brief The libakgl-backed text sink: where PRINT goes when a host is drawing. + * + * Implements the section 1.5 vtable over akgl_text_measure() and + * akgl_text_rendertextat(). Owns the cursor, the wrap and the scroll -- + * everything in the reference's basicruntime_graphics.go except Write and + * Println, which are the sink interface itself. + * + * The measurement call is new in libakgl 42b60f7 and is the reason this file can + * exist at all: the reference derives its character grid from + * font.SizeUTF8("A") (basicruntime.go:96) and there was no akgl_text_* + * equivalent until then. + */ + +#include +#include + +#include + +#include +#include + +#include +#include + +/** @brief Rows of scrollback the sink keeps. Matches the `text` array in akgl.h. */ +#define SINK_MAX_ROWS 64 +/** @brief Bytes per row, including the terminator. */ +#define SINK_MAX_COLUMNS 256 + +/** @brief Scroll the grid up by one row, dropping the top one. */ +static void scroll(akbasic_AkglSink *state) +{ + int row = 0; + + for ( row = 0; row < SINK_MAX_ROWS - 1; row++ ) { + memcpy(state->text[row], state->text[row + 1], SINK_MAX_COLUMNS); + } + memset(state->text[SINK_MAX_ROWS - 1], 0, SINK_MAX_COLUMNS); + if ( state->cursorrow > 0 ) { + state->cursorrow -= 1; + } +} + +/** @brief Move to the start of the next row, scrolling if that runs off the end. */ +static void newline(akbasic_AkglSink *state) +{ + state->cursorcol = 0; + state->cursorrow += 1; + if ( state->cursorrow >= state->rows || state->cursorrow >= SINK_MAX_ROWS ) { + scroll(state); + } +} + +/** + * @brief Put one character at the cursor, wrapping and scrolling as needed. + * + * Wrapping happens here, on the character grid, rather than being left to + * SDL_ttf's own wraplength. The cursor has to end up somewhere definite: a + * program that PRINTs a long string and then PRINTs again expects the second one + * to start on the row after the first one ended, and only the code that placed + * the characters knows which row that is. + */ +static void putchar_at(akbasic_AkglSink *state, char c) +{ + if ( c == '\n' ) { + newline(state); + return; + } + if ( state->cursorcol >= state->columns || state->cursorcol >= SINK_MAX_COLUMNS - 1 ) { + newline(state); + } + state->text[state->cursorrow][state->cursorcol] = c; + state->cursorcol += 1; + state->text[state->cursorrow][state->cursorcol] = '\0'; +} + +static akerr_ErrorContext *sink_write(akbasic_TextSink *self, const char *text) +{ + PREPARE_ERROR(errctx); + akbasic_AkglSink *state = NULL; + size_t i = 0; + + FAIL_ZERO_RETURN(errctx, (self != NULL && text != NULL), AKERR_NULLPOINTER, + "NULL argument in akgl sink write"); + state = (akbasic_AkglSink *)self->self; + FAIL_ZERO_RETURN(errctx, (state != NULL), AKERR_NULLPOINTER, + "akgl sink has no state"); + for ( i = 0; text[i] != '\0'; i++ ) { + putchar_at(state, text[i]); + } + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *sink_writeln(akbasic_TextSink *self, const char *text) +{ + PREPARE_ERROR(errctx); + akbasic_AkglSink *state = NULL; + + PASS(errctx, sink_write(self, text)); + state = (akbasic_AkglSink *)self->self; + newline(state); + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *sink_readline(akbasic_TextSink *self, char *dest, size_t len, bool *eof) +{ + PREPARE_ERROR(errctx); + + FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL && eof != NULL), AKERR_NULLPOINTER, + "NULL argument in akgl sink readline"); + /* + * A drawn text layer is not a source of lines. INPUT through a graphics sink + * wants a line editor built on the keystroke ring, which is its own piece of + * work and is not done: see TODO.md section 3. Until it exists this reports + * end of input rather than pretending to have read something. + * + * EOF rather than an error, because that is the contract sink.h states: + * running off the end of input is how RUNSTREAM mode finishes normally, and + * INPUT already handles it. + */ + if ( len > 0 ) { + dest[0] = '\0'; + } + *eof = true; + SUCCEED_RETURN(errctx); +} + +static akerr_ErrorContext *sink_clear(akbasic_TextSink *self) +{ + PREPARE_ERROR(errctx); + akbasic_AkglSink *state = NULL; + + FAIL_ZERO_RETURN(errctx, (self != NULL), AKERR_NULLPOINTER, + "NULL sink in akgl sink clear"); + state = (akbasic_AkglSink *)self->self; + FAIL_ZERO_RETURN(errctx, (state != NULL), AKERR_NULLPOINTER, + "akgl sink has no state"); + memset(state->text, 0, sizeof(state->text)); + state->cursorcol = 0; + state->cursorrow = 0; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_sink_init_akgl(akbasic_TextSink *obj, akbasic_AkglSink *state, akgl_RenderBackend *renderer, TTF_Font *font, int w, int h) +{ + PREPARE_ERROR(errctx); + int cellw = 0; + int cellh = 0; + + FAIL_ZERO_RETURN(errctx, (obj != NULL && state != NULL), AKERR_NULLPOINTER, + "NULL argument in sink_init_akgl"); + FAIL_ZERO_RETURN(errctx, (renderer != NULL), AKERR_NULLPOINTER, + "NULL renderer in sink_init_akgl: the host creates it, not the sink"); + FAIL_ZERO_RETURN(errctx, (font != NULL), AKERR_NULLPOINTER, + "NULL font in sink_init_akgl"); + + /* + * Before anything else in libakgl. akgl_game_init() would have done it, but + * an embedded interpreter drives subsystems directly and never calls that -- + * and a code raised before this runs carries no name into its stack trace. + * Idempotent, so a host that already called it loses nothing. + */ + PASS(errctx, akgl_error_init()); + + /* + * The character grid, measured rather than assumed. Direct equivalent of the + * reference's font.SizeUTF8("A"), and it needs no renderer -- which is why + * the sink can size itself before a frame has ever been drawn. + */ + PASS(errctx, akgl_text_measure(font, "A", &cellw, &cellh)); + FAIL_ZERO_RETURN(errctx, (cellw > 0 && cellh > 0), AKBASIC_ERR_VALUE, + "Font measures a %dx%d character cell, which cannot be a grid", + cellw, cellh); + FAIL_ZERO_RETURN(errctx, (w >= cellw && h >= cellh), AKBASIC_ERR_BOUNDS, + "A %dx%d text area has no room for a %dx%d character", + w, h, cellw, cellh); + + memset(state, 0, sizeof(*state)); + state->renderer = renderer; + state->font = font; + state->color.r = 0xff; + state->color.g = 0xff; + state->color.b = 0xff; + state->color.a = 0xff; + state->x = 0; + state->y = 0; + state->width = w; + state->height = h; + state->cellw = cellw; + state->cellh = cellh; + state->columns = w / cellw; + state->rows = h / cellh; + if ( state->columns > SINK_MAX_COLUMNS - 1 ) { + state->columns = SINK_MAX_COLUMNS - 1; + } + if ( state->rows > SINK_MAX_ROWS ) { + state->rows = SINK_MAX_ROWS; + } + + obj->self = state; + obj->write = sink_write; + obj->writeln = sink_writeln; + obj->readline = sink_readline; + obj->clear = sink_clear; + SUCCEED_RETURN(errctx); +} + +akerr_ErrorContext *akbasic_sink_akgl_render(akbasic_TextSink *obj) +{ + PREPARE_ERROR(errctx); + akbasic_AkglSink *state = NULL; + int row = 0; + + FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER, + "NULL sink in sink_akgl_render"); + state = (akbasic_AkglSink *)obj->self; + FAIL_ZERO_RETURN(errctx, (state != NULL), AKERR_NULLPOINTER, + "akgl sink has no state"); + + /* + * A loop, so no CATCH and no _BREAK macros: they expand to a C break, which + * would escape the loop with an error still pending. PASS only. + * + * Wrapping is off at the draw -- a zero wraplength -- because the grid above + * already decided where every line ends. Letting SDL_ttf wrap again would + * put characters somewhere the cursor does not think they are. + */ + for ( row = 0; row < state->rows; row++ ) { + if ( state->text[row][0] == '\0' ) { + continue; + } + PASS(errctx, akgl_text_rendertextat(state->font, state->text[row], + state->color, 0, + state->x, + state->y + (row * state->cellh))); + } + SUCCEED_RETURN(errctx); +} diff --git a/tests/akgl_backends.c b/tests/akgl_backends.c new file mode 100644 index 0000000..1888af0 --- /dev/null +++ b/tests/akgl_backends.c @@ -0,0 +1,416 @@ +/** + * @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 +#include +/* + * game.h purely for the `renderer` global and its `_akgl_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 "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 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(renderer->sdl_renderer, 0, 0, 0, 0xff), + AKGL_ERR_SDL, "%s", SDL_GetError()); + FAIL_ZERO_RETURN(errctx, SDL_RenderClear(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, renderer)); + PASS(errctx, akbasic_runtime_set_devices(&RUNTIME, &GRAPHICS, NULL, NULL)); + PASS(errctx, akbasic_runtime_load(&RUNTIME, source)); + PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUN)); + PASS(errctx, akbasic_runtime_run(&RUNTIME, 0)); + SUCCEED_RETURN(errctx); +} + +static void stop_runtime(void) +{ + if ( OUT != NULL ) { + fclose(OUT); + OUT = NULL; + } +} + +/** + * @brief A DRAW reaches real pixels, in the colour COLOR bound to the source. + * + * This is the assertion the whole adaptor exists for: a BASIC line in, a lit + * pixel out, with nothing mocked in between. + */ +static akerr_ErrorContext AKERR_NOIGNORE *test_draw_reaches_pixels(void) +{ + PREPARE_ERROR(errctx); + SDL_Surface *shot = NULL; + + PASS(errctx, clear_target()); + PASS(errctx, start_runtime("10 COLOR 1, 3\n20 DRAW 1, 10, 20\n")); + TEST_REQUIRE_STR(OUTPUT, ""); + + shot = SDL_RenderReadPixels(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 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(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(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(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, 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, 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 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, 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(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); +} + +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()); + renderer = &_akgl_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, + &window, &renderer->sdl_renderer), + AKGL_ERR_SDL, "Couldn't create window/renderer: %s", SDL_GetError()); + /* + * Populate the backend's vtable by hand, which is not where anybody would + * expect to have to do it. + * + * akgl_text_rendertextat() reaches through 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. The obvious call, + * akgl_render_init2d(), does install these six pointers, but it also + * creates its own window from the game properties and writes to the + * `camera` global, so it is part of the akgl_game_init() path rather than + * something a caller who already has a renderer can use. A host embedding + * this interpreter is in exactly that position. Filed upstream: what is + * wanted is the vtable half of init2d on its own. + */ + renderer->shutdown = &akgl_render_2d_shutdown; + renderer->frame_start = &akgl_render_2d_frame_start; + renderer->frame_end = &akgl_render_2d_frame_end; + renderer->draw_texture = &akgl_render_2d_draw_texture; + renderer->draw_mesh = &akgl_render_2d_draw_mesh; + renderer->draw_world = &akgl_render_2d_draw_world; + + /* + * 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_box_outlines()); + CATCH(errctx, test_paint_fills_region()); + CATCH(errctx, test_shape_roundtrip()); + CATCH(errctx, test_sink_grid_and_wrap()); + CATCH(errctx, test_sink_renders()); + CATCH(errctx, test_input_backend()); + } 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; +}