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

@@ -60,6 +60,26 @@ static akerr_ErrorContext *snd_tone(akbasic_AudioBackend *self, int voice, doubl
SUCCEED_RETURN(errctx);
}
/**
* @brief A note whose pitch moves, for SOUND's dir/min/step arguments.
*
* New in libakgl 0.3.0, which is why the record's entry may be NULL and why
* SOUND checks before using it. The sweep is advanced on the mixer's own frame
* counter -- doing it from akbasic_runtime_step() instead would tie audible
* pitch to how often a host happens to call us, which is the reason this was
* filed upstream rather than faked here.
*/
static akerr_ErrorContext *snd_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, (ms >= 0), AKERR_VALUE, "Negative note duration %d", ms);
PASS(errctx, akgl_audio_sweep(voice, (float32_t)from_hz, (float32_t)to_hz,
(float32_t)step_hz, (uint32_t)ms));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *snd_stop(akbasic_AudioBackend *self, int voice)
{
PREPARE_ERROR(errctx);
@@ -128,6 +148,7 @@ akerr_ErrorContext *akbasic_audio_init_akgl(akbasic_AudioBackend *obj)
obj->self = NULL; /* the voice table is libakgl's, not ours */
obj->tone = snd_tone;
obj->sweep = snd_sweep;
obj->stop = snd_stop;
obj->waveform = snd_waveform;
obj->envelope = snd_envelope;

View File

@@ -27,13 +27,6 @@
#include <akerror.h>
/*
* akgl/actor.h before akgl/controller.h: 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. Filed upstream against libakgl;
* see the same note in src/input_akgl.c, which is where it turned up first.
*/
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>
/*
@@ -49,28 +42,6 @@
#include <akbasic/error.h>
#include <akbasic/frontend.h>
/** @brief Fill in the 2D backend's six vtable entries against an existing renderer.
*
* akgl_render_init2d() installs exactly these, but it also creates its own
* window from the game properties and writes to the `camera` global, which makes
* it part of the akgl_game_init() path -- and a host that already has a renderer
* is not on that path. Without them akgl_text_rendertextat() dereferences a NULL
* draw_texture and the first PRINT segfaults.
*
* Filed upstream: what is wanted is the vtable half of init2d on its own.
* tests/akgl_backends.c carries the identical six lines for the identical
* reason; delete both when it lands.
*/
static void install_2d_vtable(akgl_RenderBackend *backend)
{
backend->shutdown = &akgl_render_2d_shutdown;
backend->frame_start = &akgl_render_2d_frame_start;
backend->frame_end = &akgl_render_2d_frame_end;
backend->draw_texture = &akgl_render_2d_draw_texture;
backend->draw_mesh = &akgl_render_2d_draw_mesh;
backend->draw_world = &akgl_render_2d_draw_world;
}
akerr_ErrorContext *akbasic_frontend_akgl_init(akbasic_AkglFrontend *obj, const char *title, int w, int h, const char *fontpath, int fontsize, FILE *mirror, FILE *in)
{
PREPARE_ERROR(errctx);
@@ -106,7 +77,15 @@ akerr_ErrorContext *akbasic_frontend_akgl_init(akbasic_AkglFrontend *obj, const
&obj->window, &obj->renderer->sdl_renderer),
AKGL_ERR_SDL, "Couldn't create the window: %s", SDL_GetError());
window = obj->window;
install_2d_vtable(obj->renderer);
/*
* Bind the 2D methods onto the renderer we just made. akgl_render_init2d()
* would do this too, but it creates its own window from the game properties
* first, which is the akgl_game_init() path a host with its own window is
* not on. libakgl 0.3.0 split the vtable half out for exactly this caller --
* it was API-gap item 7, and until it landed these six pointers were
* assigned by hand here and in tests/akgl_backends.c.
*/
PASS(errctx, akgl_render_bind2d(obj->renderer));
obj->font = TTF_OpenFont(fontpath, (float)fontsize);
FAIL_ZERO_RETURN(errctx, (obj->font != NULL), AKGL_ERR_SDL,

View File

@@ -16,15 +16,6 @@
#include <akerror.h>
/*
* 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 <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>

View File

@@ -73,6 +73,61 @@ static akerr_ErrorContext *collect_numbers(akbasic_Runtime *obj, akbasic_ASTLeaf
SUCCEED_RETURN(errctx);
}
/**
* @brief Issue SOUND's swept note, translating its arguments out of SID space.
*
* BASIC 7.0 spells the sweep as `dir`, `min` and `step`, all in the SID's
* 16-bit register units rather than in hertz, and `dir` picks the shape:
*
* 0 no sweep -- handled by the caller, which issues a held note instead
* 1 up, from the SOUND frequency toward min
* 2 down, from the SOUND frequency toward min
* 3 oscillate between the two
*
* **Direction 3 sweeps once rather than oscillating**, and that is a deviation
* worth knowing about: akgl_audio_sweep runs one pass from a start to an end,
* and a genuine oscillation needs the mixer to turn around at the endpoints.
* Sweeping once in the direction the endpoints imply is the closest honest
* approximation; TODO.md section 5 records it.
*
* Directions 1 and 2 both name `min` as the far end, so the *value* of min is
* what decides which way the pitch actually travels -- a `min` above the
* starting frequency rises whatever `dir` says. That is the SID's behaviour and
* akgl_audio_sweep works the same way, comparing its two endpoints, so the two
* agree without this having to second-guess either.
*/
static akerr_ErrorContext AKERR_NOIGNORE *sweep_note(akbasic_Runtime *obj, int voice, double hz, double *args, int ms)
{
PREPARE_ERROR(errctx);
double tohz = 0.0;
double stephz = 0.0;
double stepbase = 0.0;
PASS(errctx, akbasic_audio_register_to_hz((int)args[4], &tohz));
/*
* `step` is a register *delta*, not a register value, so it cannot go
* through register_to_hz on its own -- that maps a position, and a delta has
* no position. Convert it as the distance between register 0 and register
* `step`, which is the same linear scale the table applies to everything
* else.
*/
PASS(errctx, akbasic_audio_register_to_hz(0, &stepbase));
PASS(errctx, akbasic_audio_register_to_hz((int)args[5], &stephz));
stephz -= stepbase;
/*
* A zero step would never arrive and libakgl refuses it outright. One hertz
* is the smallest move that still gets there, and a program that asked for
* no movement asked for a held note -- which is what it gets.
*/
if ( stephz <= 0.0 ) {
stephz = 1.0;
}
PASS(errctx, obj->audio->sweep(obj->audio, voice, hz, tohz, stephz, ms));
SUCCEED_RETURN(errctx);
}
/* --------------------------------------------------------------- SOUND --- */
akerr_ErrorContext *akbasic_cmd_sound(akbasic_Runtime *obj, akbasic_ASTLeaf *expr, akbasic_Value *lval, akbasic_Value *rval, akbasic_Value **dest)
@@ -110,13 +165,27 @@ akerr_ErrorContext *akbasic_cmd_sound(akbasic_Runtime *obj, akbasic_ASTLeaf *exp
/*
* Arguments 4 through 6 are a frequency sweep: a direction, a floor and a
* step. There is no akgl_audio_* equivalent, and faking one by re-issuing
* tones from akbasic_runtime_step() would tie audible pitch to how often the
* host happens to call us -- a tune that changes key with the frame rate.
* Refused, and filed upstream as akgl_audio_sweep. TODO.md section 7.
* step. `dir` is 0 to hold, 1 to sweep up, 2 to sweep down and 3 to
* oscillate; `min` is the far end of the sweep as a register value, and
* `step` is how far the pitch moves per tick.
*
* This was refused outright until libakgl 0.3.0 grew akgl_audio_sweep --
* faking it by re-issuing tones from akbasic_runtime_step() would have tied
* audible pitch to how often the host happens to call us, a tune that
* changes key with the frame rate. It is still refused when the host's
* backend has no sweep, which is what an older one looks like.
*/
FAIL_NONZERO_RETURN(errctx, (count >= 4 && args[3] != 0.0), AKBASIC_ERR_DEVICE,
"SOUND's frequency sweep needs a device capability that does not exist yet");
if ( count >= 4 && args[3] != 0.0 ) {
FAIL_ZERO_RETURN(errctx, (args[3] >= 0.0 && args[3] <= 3.0), AKBASIC_ERR_BOUNDS,
"SOUND direction %d out of range (0 to 3)", (int)args[3]);
FAIL_ZERO_RETURN(errctx, (obj->audio->sweep != NULL), AKBASIC_ERR_DEVICE,
"SOUND's frequency sweep needs an audio device that can sweep");
FAIL_ZERO_RETURN(errctx, (count >= 6), AKBASIC_ERR_SYNTAX,
"SOUND's frequency sweep expected a direction, a minimum and a step");
PASS(errctx, sweep_note(obj, voice, hz, args, ms));
SUCCEED_TRUE(obj, dest);
SUCCEED_RETURN(errctx);
}
if ( count >= 7 ) {
/* The seventh argument selects a waveform, 0 through 3. */

View File

@@ -13,19 +13,11 @@
* equivalent until then.
*/
#include <ctype.h>
#include <stdio.h>
#include <string.h>
#include <akerror.h>
/*
* 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. Filed upstream; see the same note in
* src/input_akgl.c, which is where it was found first.
*/
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>
#include <akgl/text.h>
@@ -152,47 +144,83 @@ static void echo_line(akbasic_AkglSink *state)
state->echolen = state->editlen;
}
/**
* @brief Fold one keycode into the line being typed.
*
* The keycodes are SDL's, and an unshifted printable key carries its own ASCII
* value, which is why the range test below is all the translation there is.
* Shifted characters are unreachable: the ring carries no modifier state. See
* akbasic_sink_akgl_set_pump() in akgl.h.
*/
static void edit_key(akbasic_AkglSink *state, int keycode, bool *submitted)
/** @brief Append one byte to the line being typed, if there is room for it. */
static void edit_append(akbasic_AkglSink *state, char c)
{
if ( keycode == '\r' || keycode == '\n' ) {
if ( state->editlen >= (int)sizeof(state->editline) - 1 ) {
/* Full. Dropped silently, exactly as a C128's 80-character limit does. */
return;
}
state->editline[state->editlen] = c;
state->editlen += 1;
state->editline[state->editlen] = '\0';
}
/**
* @brief Fold one keystroke into the line being typed.
*
* **The composed text is preferred over the keycode wherever there is any**, and
* that is the whole reason libakgl 0.3.0's akgl_Keystroke exists. A keycode
* cannot express a shifted character, a keyboard layout, a compose key or a dead
* key; SDL has already worked all of that out by the time the ring sees it, and
* `text` is the answer. Before 0.3.0 this took a bare keycode, folded letters to
* upper case, and could not type a double quote -- which meant a BASIC string
* literal could not be typed at the window at all.
*
* The keycode is still what identifies the editing keys, because Return,
* Backspace and Escape are keys rather than characters and several of them
* compose to text SDL would otherwise hand straight through.
*/
static void edit_key(akbasic_AkglSink *state, const akgl_Keystroke *key, bool *submitted)
{
size_t i = 0;
if ( key->key == SDLK_RETURN || key->key == SDLK_KP_ENTER ||
key->key == '\r' || key->key == '\n' ) {
*submitted = true;
return;
}
if ( keycode == '\b' || keycode == 0x7f ) {
if ( key->key == SDLK_BACKSPACE || key->key == '\b' || key->key == 0x7f ) {
if ( state->editlen > 0 ) {
/*
* One *byte* at a time, which is wrong for a multi-byte character and
* is left that way deliberately: every character a BASIC program can
* hold is one byte (section 1.2's inline string), so a multi-byte
* character cannot survive being submitted anyway. Erasing what was
* accepted is consistent with that.
*/
state->editlen -= 1;
state->editline[state->editlen] = '\0';
echo_line(state);
}
return;
}
if ( keycode == 0x1b ) {
if ( key->key == SDLK_ESCAPE || key->key == 0x1b ) {
state->editlen = 0;
state->editline[0] = '\0';
echo_line(state);
return;
}
if ( keycode < 0x20 || keycode > 0x7e ) {
/* A cursor or function key. Not an editing command here; a script's own
* GET loop is what wants those. */
if ( key->text[0] != '\0' ) {
for ( i = 0; key->text[i] != '\0' && i < sizeof(key->text); i++ ) {
/*
* Printable ASCII only. The grid is a byte per cell and a value's
* string is a fixed 256 bytes, so a multi-byte character has nowhere
* to go -- dropping it is honest where storing half of it is not.
*/
if ( (unsigned char)key->text[i] >= 0x20 && (unsigned char)key->text[i] < 0x7f ) {
edit_append(state, key->text[i]);
}
}
echo_line(state);
return;
}
if ( state->editlen >= (int)sizeof(state->editline) - 1 ) {
/* Full. Dropped silently, exactly as a C128's 80-character limit does. */
return;
}
state->editline[state->editlen] = (char)toupper(keycode);
state->editlen += 1;
state->editline[state->editlen] = '\0';
echo_line(state);
/*
* No composed text: a cursor key, a function key or a bare modifier. Not an
* editing command here -- a script's own GET loop is what wants those.
*/
}
/**
@@ -206,15 +234,21 @@ static void edit_key(akbasic_AkglSink *state, int keycode, bool *submitted)
static akerr_ErrorContext AKERR_NOIGNORE *edit_loop(akbasic_AkglSink *state, bool *eof)
{
PREPARE_ERROR(errctx);
akgl_Keystroke key;
bool submitted = false;
bool available = false;
bool running = true;
int keycode = 0;
while ( !submitted ) {
PASS(errctx, akgl_controller_poll_key(&keycode, &available));
/*
* poll_keystroke rather than poll_key: the same ring, with the modifier
* state and the composed text still attached. poll_key is the reduced
* view, and it is what GET and GETKEY still use -- a script asking "was
* the up arrow pressed" wants a keycode, not the empty string.
*/
PASS(errctx, akgl_controller_poll_keystroke(&key, &available));
if ( available ) {
edit_key(state, keycode, &submitted);
edit_key(state, &key, &submitted);
continue;
}
/*