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

191
TODO.md
View File

@@ -535,50 +535,49 @@ path is proved only through SDL's own event queue in `tests/akgl_frontend.c` —
same queue a real keyboard delivers into, but not the same as a window manager giving the
window focus. Somebody should type at it once.
### Five things that had to be worked around to get there
### The five things that had to be worked around — **all gone**
All five are `libakgl`'s and all five 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.
Every one was filed against `libakgl` and every one is resolved in its 0.3.0. What is left here
is the note that they existed, because the pattern is the point: file it upstream, comment the
workaround at its site with the words "filed upstream", delete it when it lands.
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.
| Was | Resolved by |
|---|---|
| An embedded `libakgl` required its vendored SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to be *installed*, so our `CMakeLists.txt` declared all five by hand first | It adds them whether or not it is top-level |
| `akgl/controller.h` did not compile on its own — it used `akgl_Actor *` and included nothing that declared it | It includes `<akgl/actor.h>` |
| There was no way to bind a 2D backend to a renderer you already had, so the six vtable pointers were assigned by hand in two files | `akgl_render_bind2d()` |
| `akgl_text_rendertextat()` dereferenced an unset `draw_texture`, so the first `PRINT` through an unbound backend segfaulted | It checks, and reports `AKERR_NULLPOINTER` like the draw entry points |
| `SOUND`'s frequency sweep had no equivalent and was refused | `akgl_audio_sweep()` |
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.
**0.3.0 also arrived with a regression that this repository is the one that found**, because it
is the only consumer that embeds `libakgl` alongside other projects: the commit that made the
vendored dependencies unconditional also made its `add_test()` shadow unconditional, and CMake
chains command overrides exactly one level deep. Two projects in one tree cannot both shadow
`add_test` — the second rebinds `_add_test` to the first and the builtin becomes unreachable to
everybody, so our own registrations recursed to CMake's depth limit. Fixed upstream by putting
the top-level guard back; filed as `libakgl` defect 27, with the two-line CMake program that
demonstrates the one-level limit.
### Still missing from the sink
Nothing that blocks a verb. The line editor exists (see above), so `INPUT` and the REPL work in
the window. Two limits are worth stating because a program can reach both:
Nothing that blocks a verb, and as of `libakgl` 0.3.0 nothing that blocks a *character* either.
The ring now carries the composed UTF-8 text SDL worked out from each keystroke, so the editor
takes that in preference to the keycode and shifted characters, keyboard layouts, compose keys
and dead keys all work. **A double quote can be typed, so a BASIC string literal can be typed**,
which was the sharp end of the old limitation — it used to mean a program with a string in it
had to be passed on the command line. Letters are no longer folded to upper case, because there
is no longer any reason to.
One limit remains, and it is a choice rather than a gap:
- **No shifted character can be typed.** The keystroke ring carries a keycode and no modifier
state, so `"`, `!`, `(`, `)`, `:` and `;` are unreachable and a string literal cannot be typed
at the window at all. Letters are folded to upper case, which is a C128 doing what a C128 does
rather than a workaround, but punctuation has no such excuse. Filed upstream as `libakgl`
item 10; until it lands, the way to run a program with a string literal in it is to pass the
file on the command line, which is unaffected.
- **No cursor movement within a line.** Backspace and escape only. The arrow keys stay in the
ring for a script's own `GET` loop, which is the more valuable use of them.
A non-ASCII character is accepted by the keyboard and then dropped rather than stored, which is
§1.2 rather than the editor: 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.
`WINDOW` and `KEY` in group E want a text-window rectangle and a function-key table on top of
this, which is ordinary work here rather than anything blocked.
@@ -757,13 +756,28 @@ behaviour to port. They matter to somebody typing in a listing out of a C128 man
`step()` is called. Audible, deterministic, and never a hang, which is the right way for
that to fail.
21. **`PLAY`'s `M` (measure) is a no-op and `SOUND`'s frequency sweep is refused.** `M`
synchronises the three voices on a C128; with one sequential queue there is nothing to
synchronise, and a listing full of them should still play, so it is accepted and ignored.
The sweep is the opposite call: `SOUND`'s `dir`/`min`/`step` arguments have no
`akgl_audio_*` equivalent, and the only way to fake one is to re-issue tones from
`step()` — which would tie audible pitch to how often the host happens to call us, a tune
that changes key with the frame rate. Filed upstream as `akgl_audio_sweep`; see §7.
21. **`PLAY`'s `M` (measure) is a no-op, and `SOUND`'s sweep runs once rather than
oscillating.** `M` synchronises the three voices on a C128; with one sequential queue there
is nothing to synchronise, and a listing full of them should still play, so it is accepted
and ignored.
The sweep *used* to be refused outright — there was no `akgl_audio_*` equivalent and faking
one would have tied audible pitch to how often the host calls us. `libakgl` 0.3.0 added
`akgl_audio_sweep()` and `SOUND voice, freq, dur, dir, min, step` now reaches it. What does
not survive is `dir` 3, "oscillate": `akgl_audio_sweep` runs one pass between two endpoints,
and a real oscillation needs the mixer to turn around at them. **What this changes for a
program:** a `dir` 3 siren rises or falls once instead of warbling. `dir` 1 and 2 are exact,
and so is the direction — both the SID and `akgl_audio_sweep` take it from which endpoint is
higher, so a `min` above the starting frequency rises whatever `dir` says.
`step` is converted as a *delta* rather than a position: it is a register increment, and the
register-to-hertz table maps positions, so it goes across as the distance between register 0
and register `step`. A step that converts to zero would never arrive and is raised to one
hertz, which is the smallest move that still gets there.
A backend whose record has no `sweep` — a host written against `libakgl` 0.2.0 — still
refuses a swept note with `AKBASIC_ERR_DEVICE` and plays a held one normally.
`tests/audio_verbs.c` asserts both halves.
22. **`TEMPO`'s constant is a calibration choice, not a transcription.** BASIC 7.0 documents
`TEMPO` as a relative duration from 1 to 255 defaulting to 8, and does not publish what a
@@ -1031,8 +1045,9 @@ When phase 7 or phase 8 needs something `libakgl` does not have, **stop and file
the `akgl_*` entry point should look like, and what tests would cover it. Growing `libakgl` to
serve the interpreter is a wanted outcome, not a detour.
**All four gaps filed so far are now closed upstream**, at `libakgl` 42b60f7. Nothing here is
blocked on `libakgl` any more; what remains is akbasic-side work.
**All ten gaps filed so far are closed upstream**, as of `libakgl` 0.3.0. Nothing here is
blocked on `libakgl`, and this repository carries no `libakgl` workarounds at all — the four
§3 used to list are deleted.
| Was blocking | Closed by |
|---|---|
@@ -1040,70 +1055,42 @@ blocked on `libakgl` any more; what remains is akbasic-side work.
| Immediate-mode drawing (§4 group G) | `akgl_draw_point`/`_line`/`_rect`/`_filled_rect`/`_circle`/`_flood_fill`/`_copy_region`/`_paste_region` |
| Audio (§4 group I) | `akgl_audio_init`/`_tone`/`_envelope`/`_waveform`/`_volume`/`_stop`/`_voice_active`/`_mix` |
| Console input (§4 group E) | `akgl_controller_poll_key`, `akgl_controller_flush_keys` |
| Vendored dependencies when embedded | added unconditionally, 0.3.0 |
| `controller.h` not self-contained | includes `<akgl/actor.h>`, 0.3.0 |
| Binding a 2D backend to an existing renderer | `akgl_render_bind2d()`, 0.3.0 |
| `akgl_text_rendertextat()` on an unbound backend | checked, 0.3.0 |
| `SOUND`'s frequency sweep (§5 item 21) | `akgl_audio_sweep()`, 0.3.0 |
| The line editor could not see Shift (§3) | `akgl_Keystroke` + `akgl_controller_poll_keystroke()`, 0.3.0 |
Two notes for whoever picks up §3 next. The draw calls take an `akgl_RenderBackend *` as their
first argument rather than reaching for a global renderer, which fits the rule that the
interpreter draws through whatever renderer the host already initialized. And the audio API is
a synthesised-voice one — `akgl_audio_tone(voice, hz, ms)` with a separate ADSR envelope —
which is the shape `PLAY` and `ENVELOPE` need, rather than the sample playback SDL3_mixer
would have given.
Three notes for whoever files the next one. The draw calls take an `akgl_RenderBackend *` as
their first argument rather than reaching for a global renderer, which fits the rule that the
interpreter draws through whatever renderer the host already initialized. The audio API is a
synthesised-voice one — `akgl_audio_tone(voice, hz, ms)` with a separate ADSR envelope — which
is the shape `PLAY` and `ENVELOPE` need, rather than the sample playback SDL3_mixer would have
given. And `akgl_controller_poll_key()` survives alongside the keystroke form on purpose: `GET`
and `GETKEY` still use it, because a script asking "was the up arrow pressed" wants a keycode
and would get the empty string from the other one.
**`FILTER` is the one verb still refused**, and it is not filed as a gap because upstream said
plainly what it would take: `akgl_audio_*` synthesises raw waveforms and mixes them, there is no
filter stage to configure, and SDL3 supplies no primitive to build one from. Until an
`akgl_audio_filter()` exists, `FILTER` is accepted by the parser and refused at execution with
`AKBASIC_ERR_DEVICE` rather than silently ignored — a program that asks for a low-pass and gets
an unfiltered square wave has been lied to.
**One thing went the other way**, and it is worth recording because it is the only defect this
project has ever sent upstream in a release it also consumed. 0.3.0's vendored-dependency fix
also made `libakgl`'s `add_test()` shadow unconditional. CMake chains command overrides exactly
one level deep, so two projects in one tree cannot both shadow it: the second rebinds
`_add_test` to the first and the builtin becomes unreachable to everyone, and this repository's
own test registrations recursed until CMake stopped at depth 1000. `akbasic` is the only
consumer that embeds `libakgl` next to other projects, so it is the only place this could show
up. Fixed upstream by restoring the top-level guard and filed as `libakgl` defect 27.
Building against any of it needs `-DAKBASIC_WITH_AKGL=ON`, which pulls in `libakgl`'s own
submodules (SDL and friends). Those are not initialized in a default clone; `git submodule
update --init --recursive` gets them.
**Seven items are filed upstream and outstanding**, at `deps/libakgl/TODO.md` items 5-10 of "API
gaps blocking akbasic" and item 14 of "Defects". Four are workarounds this repository carries
today, each commented at its site with the words "filed upstream" so it can be found and
deleted: the embedded-dependency CMake trick in our `CMakeLists.txt`, the `akgl/actor.h`
include in `src/input_akgl.c` and `src/sink_akgl.c`, and the hand-populated vtable and its
NULL-deref hazard in `tests/akgl_backends.c` and `src/frontend_akgl.c`. §3 describes all four.
The fifth is `SOUND`'s frequency sweep, the sixth is the missing version bump recorded in §8,
and the seventh is new:
- **The keystroke ring carries a keycode and no modifier state**, so the line editor cannot see
Shift. Every shifted character — `"`, `!`, `(`, `)`, `:`, `;` — is unreachable, which means a
string literal cannot be typed at the window at all, and lower case is unreachable too.
Letters are folded to upper case, which is what a C128 does and is therefore the right answer
for letters rather than merely the available one; punctuation has no such excuse. It is not
fixable from here: by the time a caller could ask `SDL_GetModState()` the key is long
released, and decoupling reads from events is the entire point of a ring. The wanted API is
an `akgl_controller_poll_keystroke()` returning keycode, modifiers and the composed UTF-8
text SDL already computes from `SDL_EVENT_TEXT_INPUT` — which is also the only way a keyboard
layout, a compose key or a dead key can ever work. Filed as item 10, alongside the existing
`akgl_controller_poll_key()`, which stays exactly as it is: a game asking "was the up arrow
pressed" wants precisely what it already gets.
**One further gap is outstanding, and upstream named it itself.** The commit that added the audio API
says plainly what it does not cover: *"Still missing for a complete BASIC sound vocabulary:
`FILTER` (SDL3 has no filter primitive; this would need writing), `TEMPO` and the `PLAY`
note-string parser, both of which belong in the interpreter rather than here."*
- **`TEMPO` and the `PLAY` parser are ours**, not a gap. They are group I work in this
repository and nothing upstream is waiting on.
- **`FILTER` is a real gap.** BASIC 7.0's `FILTER freq, lp, bp, hp, res` sets the SID's
filter cutoff, the three band-pass switches and the resonance. `akgl_audio_*` synthesises
raw waveforms and mixes them; there is no filter stage to configure and SDL3 supplies no
primitive to build one from. Until an `akgl_audio_filter()` exists, `FILTER` is accepted by
the parser and refused at execution with `AKBASIC_ERR_DEVICE` rather than silently ignored —
a program that asks for a low-pass and gets an unfiltered square wave has been lied to.
A second gap turned up while implementing group I, and is filed alongside it:
- **`SOUND` has no frequency sweep.** BASIC 7.0's `SOUND voice, freq, dur, dir, min, step`
ramps the pitch from `freq` toward `min` in `step` increments per tick, in the direction
`dir` selects. `akgl_audio_tone(voice, hz, ms)` holds one pitch for one duration and there
is no entry point that changes pitch over the life of a note. The wanted API is roughly
`akgl_audio_sweep(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms)`,
advanced on the mixer's own frame counter — the same place the phase is already derived
from, and the reason it belongs there rather than here. Faking it in the interpreter means
re-issuing tones from `akbasic_runtime_step()`, which ties audible pitch to how often a host
calls us: a tune that changes key with the frame rate. Refused with `AKBASIC_ERR_DEVICE`
until it lands. Tests would drive `akgl_audio_mix` by hand and assert the frame at which the
pitch has moved, exactly as `tests/audio.c` already does for the envelope.
When the next gap turns up, file it there rather than working around it here.
---
## 8. Status
@@ -1150,7 +1137,7 @@ Dependency baseline:
|---|---|---|
| `deps/libakerror` | 1.0.0 | Private ownership-enforced status registry. akbasic reserves 512767 in `akbasic_error_register()`. Does not namespace its `coverage` target when embedded — worked around in our `CMakeLists.txt`. |
| `deps/libakstdlib` | 0.2.0 | soname `libakstdlib.so.0.2`. `AKSL_VERSION_CHECK()` asserted in `tests/version_check.c`. This release fixed all six confirmed defects the port was working around — see §1.9, where the bans are now lifted. |
| `deps/libakgl` | 0.2.0 | soname `libakgl.so.0.2`. Owns status codes 256260. 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. It requires `libakstdlib` 0.2, which is why the two moved together. |
| `deps/libakgl` | 0.3.0 | soname `libakgl.so.0.3`. Owns status codes 256260. 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. **0.3.0 closed every API gap this port had filed** — see §7 — so the four workarounds §3 used to list are gone. |
**The `libakgl` requirement used to be pinned by commit, and no longer is.** 42b60f7 added 22
public symbols across four headers and left `project(akgl VERSION 0.1.0)` and the