Consume libakgl 0.3.0: every workaround deleted, two capabilities gained

0.3.0 closed all ten API gaps this port had filed. The four workarounds go
with them: the CMake block that declared libakgl's vendored dependencies by
hand, the akgl/actor.h include in three files, and the six vtable pointers
assigned by hand in two more, now akgl_render_bind2d().

Two gaps were capabilities rather than inconveniences, and both are now real:

The line editor takes the composed UTF-8 text the ring carries in preference
to the keycode, so shifted characters, keyboard layouts, compose keys and dead
keys all work. A double quote can be typed, which means a BASIC string literal
can be typed -- the sharp end of the old limitation. Letters are no longer
folded to upper case.

SOUND's dir/min/step reach akgl_audio_sweep instead of being refused. dir 3
sweeps once rather than oscillating and TODO.md section 5 says so. A backend
with no sweep still refuses the swept note and plays the held one.

The adaptors now carry an AKGL_VERSION_AT_LEAST(0, 3, 0) floor, verified by
temporarily demanding 0.4.0 and watching it fire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 13:25:14 -04:00
parent 583c0abbd2
commit fca9ad4a89
16 changed files with 495 additions and 265 deletions

View File

@@ -22,7 +22,6 @@
#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>
/*
@@ -358,26 +357,14 @@ int main(void)
&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 --
* Bind the 2D methods. 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.
* primitives take the SDL_Renderer directly. These six pointers were
* assigned by hand here until libakgl 0.3.0 split akgl_render_bind2d out
* of akgl_render_init2d -- API-gap item 7, filed from this file.
*/
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;
CATCH(errctx, akgl_render_bind2d(renderer));
/*
* libakgl's own monospaced fixture, borrowed rather than copied. Being

View File

@@ -28,7 +28,6 @@
#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>
@@ -82,6 +81,9 @@ static bool anything_drawn(SDL_Surface *shot, int x0, int y0, int w, int h)
* Straight into SDL's own queue rather than into libakgl's ring, so the
* frontend's event pump is the thing under test: nothing reaches the
* interpreter unless pump_events() drained this and handed it over.
*
* Key-down only, with no composed text -- which is what a cursor key, a function
* key or a bare modifier produces.
*/
static void push_key(int keycode)
{
@@ -93,13 +95,62 @@ static void push_key(int keycode)
SDL_PushEvent(&event);
}
/**
* @brief Queue a printable keystroke: the key-down *and* the text it composed to.
*
* Two events, because that is what a real keyboard produces and what libakgl
* assembles a keystroke from -- SDL_EVENT_KEY_DOWN records the press and
* SDL_EVENT_TEXT_INPUT delivers the character the layout, the shift state, a
* compose key and a dead key between them worked out. A keycode alone cannot
* express any of that, which is why the ring carries both.
*
* `text` is deliberately allowed to differ from `keycode`: that is how a shifted
* character and a non-US layout are tested at all.
*/
static void push_text_key(int keycode, const char *text)
{
SDL_Event event;
push_key(keycode);
memset(&event, 0, sizeof(event));
event.type = SDL_EVENT_TEXT_INPUT;
event.text.text = text;
SDL_PushEvent(&event);
}
/**
* @brief A one-character string with static storage, for one printable byte.
*
* SDL_PushEvent copies the SDL_Event but *not* the string an
* SDL_EVENT_TEXT_INPUT points at, so the text has to outlive the call that
* queued it and must not be reused before the queue is drained. A local buffer
* is neither: the first attempt at push_line() used one and every event in the
* line ended up pointing at the same overwritten two bytes, which showed up as
* a REPL that never received a line and a test that hung rather than failed.
*/
static const char *one_char(unsigned char c)
{
static char table[128][2];
static bool built = false;
int i = 0;
if ( !built ) {
for ( i = 0; i < 128; i++ ) {
table[i][0] = (char)i;
table[i][1] = '\0';
}
built = true;
}
return table[c & 0x7f];
}
/** @brief Queue a whole typed line, terminator included. */
static void push_line(const char *text)
{
size_t i = 0;
for ( i = 0; text[i] != '\0'; i++ ) {
push_key((int)(unsigned char)text[i]);
push_text_key((int)(unsigned char)text[i], one_char((unsigned char)text[i]));
}
push_key('\r');
}
@@ -269,9 +320,9 @@ static akerr_ErrorContext AKERR_NOIGNORE *test_window_close_stops(void)
* @brief The line editor turns keystrokes into a line, echoed as it is typed.
*
* Read through the sink directly rather than through the REPL, because what is
* being asserted here is the editing itself: the fold to upper case that the
* ring's missing modifier state forces, the backspace, and the echo landing in
* the grid where the cursor says it is.
* being asserted here is the editing itself: that the *composed text* is what
* gets typed rather than the keycode, the backspace, the escape, and the echo
* landing in the grid where the cursor says it is.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_line_editor(void)
{
@@ -282,40 +333,65 @@ static akerr_ErrorContext AKERR_NOIGNORE *test_line_editor(void)
PASS(errctx, start_frontend(NULL));
PASS(errctx, FRONTEND.input.flush_keys(&FRONTEND.input));
/* "prXnt" with the X backspaced away, typed in lower case. */
push_key('p');
push_key('r');
push_key('x');
push_key('\b');
push_key('i');
push_key('n');
push_key('t');
/* "prXnt" with the X backspaced away. Lower case survives, which it did not
* before libakgl 0.3.0 -- the editor folded everything to upper case because
* a keycode was all it had. */
push_text_key('p', "p");
push_text_key('r', "r");
push_text_key('x', "x");
push_key(SDLK_BACKSPACE);
push_text_key('i', "i");
push_text_key('n', "n");
push_text_key('t', "t");
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE(!eof, "a submitted line is not end of input");
/*
* Upper case because akgl_controller_poll_key() reports a keycode and no
* modifier state, so Shift is invisible and lower case is unreachable. A
* C128 is upper case too, which is why this is the right resolution rather
* than merely the available one. Filed upstream.
*/
TEST_REQUIRE_STR(line, "PRINT");
TEST_REQUIRE_STR(line, "print");
/* The echo is in the grid, and the backspace really removed a character. */
TEST_REQUIRE_STR(FRONTEND.akglstate.text[0], "PRINT");
TEST_REQUIRE_STR(FRONTEND.akglstate.text[0], "print");
TEST_REQUIRE_INT(FRONTEND.akglstate.cursorrow, 1);
TEST_REQUIRE_INT(FRONTEND.akglstate.cursorcol, 0);
TEST_REQUIRE(!FRONTEND.akglstate.editing, "the editor should be idle once the line is in");
/* Escape abandons a line rather than submitting it. */
push_key('z');
push_key(0x1b);
push_key('o');
push_key('k');
/*
* A shifted character, which is the whole point of the 0.3.0 keystroke API:
* the key is still SDLK_2 and the character is '"'. Without the composed
* text there is no way to tell those apart, and a BASIC string literal could
* not be typed at the window at all.
*/
push_text_key(SDLK_2, "\"");
push_text_key('h', "H");
push_text_key(SDLK_2, "\"");
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_STR(line, "OK");
TEST_REQUIRE_STR(FRONTEND.akglstate.text[1], "OK");
TEST_REQUIRE_STR(line, "\"H\"");
/*
* And a key whose composed character has nothing to do with its keycode,
* which is what a non-US layout looks like: AZERTY's SDLK_Q types "a".
*/
push_text_key(SDLK_Q, "a");
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_STR(line, "a");
/* A key that composes to nothing is not typed: an arrow is not a character. */
push_key(SDLK_UP);
push_key(SDLK_LEFT);
push_text_key('z', "z");
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_STR(line, "z");
/* Escape abandons a line rather than submitting it. */
push_text_key('z', "z");
push_key(SDLK_ESCAPE);
push_text_key('o', "o");
push_text_key('k', "k");
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_STR(line, "ok");
/* A closed window ends the wait, reported as end of input rather than raised. */
push_quit();

View File

@@ -131,20 +131,58 @@ static void test_sound(void)
harness_stop();
/*
* The frequency sweep is refused rather than faked. Faking it would mean
* re-issuing tones from step(), which would tie audible pitch to how often
* the host calls us -- a tune that changes key with the frame rate.
* The frequency sweep reaches the device, which it could not until libakgl
* 0.3.0 grew akgl_audio_sweep -- it was refused outright before, because
* faking it would have meant re-issuing tones from step() and tying audible
* pitch to how often the host calls us.
*
* Sweeping *down*: register 1000 is below register 16777, so the endpoints
* decide the direction and both the SID and akgl_audio_sweep read them that
* way.
*/
TEST_REQUIRE_OK(run_program("10 SOUND 1, 1000, 60, 1, 500, 10\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "frequency sweep") != NULL,
"a sweep should be refused, got \"%s\"", HARNESS_OUTPUT);
TEST_REQUIRE_OK(run_program("10 SOUND 1, 16777, 60, 2, 1000, 10\n"));
TEST_REQUIRE(strstr(MOCK.log, "sweep v0 ") != NULL,
"a sweep should reach the device, got \"%s\"", MOCK.log);
TEST_REQUIRE(strstr(MOCK.log, "1000ms") != NULL,
"60 jiffies should still be 1000 ms on a sweep, got \"%s\"", MOCK.log);
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "") != NULL && HARNESS_OUTPUT[0] == '\0',
"a sweep should not be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* A sweep direction of zero is "no sweep" and must still play. */
/* A direction outside 0..3 is a typo worth catching. */
TEST_REQUIRE_OK(run_program("10 SOUND 1, 1000, 60, 9, 500, 10\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "SOUND direction 9") != NULL,
"direction 9 should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* A sweep needs all three of its arguments. */
TEST_REQUIRE_OK(run_program("10 SOUND 1, 1000, 60, 1\n"));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "direction, a minimum and a step") != NULL,
"a partial sweep should be refused, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
/* A sweep direction of zero is "no sweep" and must still play a held note. */
TEST_REQUIRE_OK(run_program("10 SOUND 1, 1000, 60, 0\n"));
TEST_REQUIRE(strstr(MOCK.log, "tone v0 ") != NULL,
"direction 0 is not a sweep and should play, got \"%s\"", MOCK.log);
harness_stop();
/*
* A backend with no sweep is still a valid backend -- that is what a host
* written against libakgl 0.2.0 looks like -- and SOUND refuses the swept
* note rather than dereferencing a NULL entry point.
*/
TEST_REQUIRE_OK(harness_start(NULL));
mock_devices_init();
MOCK_AUDIO.sweep = NULL;
TEST_REQUIRE_OK(akbasic_runtime_set_devices(&HARNESS_RUNTIME, &MOCK_GRAPHICS,
&MOCK_AUDIO, &MOCK_INPUT));
TEST_REQUIRE_OK(akbasic_runtime_load(&HARNESS_RUNTIME, "10 SOUND 1, 1000, 60, 1, 500, 10\n"));
TEST_REQUIRE_OK(akbasic_runtime_start(&HARNESS_RUNTIME, AKBASIC_MODE_RUN));
TEST_REQUIRE_OK(akbasic_runtime_run(&HARNESS_RUNTIME, 0));
TEST_REQUIRE(strstr(HARNESS_OUTPUT, "audio device that can sweep") != NULL,
"a backend without sweep should refuse, got \"%s\"", HARNESS_OUTPUT);
harness_stop();
}
/** @brief ENVELOPE maps the SID rate numbers, and sustain is a level not a time. */

View File

@@ -191,6 +191,19 @@ static akerr_ErrorContext *mock_tone(akbasic_AudioBackend *self, int voice, doub
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *mock_sweep(akbasic_AudioBackend *self, int voice, double from_hz, double to_hz, double step_hz, int ms)
{
PREPARE_ERROR(errctx);
(void)self;
FAIL_ZERO_RETURN(errctx, (voice >= 0 && voice < AKBASIC_AUDIO_VOICES), AKBASIC_ERR_BOUNDS,
"mock voice %d out of range", voice);
MOCK.voice_active[voice] = true;
mock_log("sweep v%d %.1fhz->%.1fhz step %.1fhz %dms\n",
voice, from_hz, to_hz, step_hz, ms);
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *mock_audio_stop(akbasic_AudioBackend *self, int voice)
{
PREPARE_ERROR(errctx);
@@ -296,6 +309,13 @@ static void mock_devices_init(void)
MOCK_AUDIO.self = &MOCK;
MOCK_AUDIO.tone = mock_tone;
/*
* Set here, and deliberately cleared again by the one test that needs a
* backend without it: `sweep` may be NULL, which is what a host written
* against libakgl 0.2.0 looks like, and SOUND has to refuse rather than
* crash on one.
*/
MOCK_AUDIO.sweep = mock_sweep;
MOCK_AUDIO.stop = mock_audio_stop;
MOCK_AUDIO.waveform = mock_waveform;
MOCK_AUDIO.envelope = mock_envelope;