Port the standalone SDL frontend, and give the sink a line editor

An AKGL build of `basic` was a terminal program with unused SDL linked into
it. It now opens the reference's 800x600 window, draws BASIC output in the
Commodore font, pumps events, lets you type at it, and still mirrors every
byte to stdout.

The stdout mirror is a composing sink rather than a second write inside the
interpreter, and lives in the core library where it needs no SDL. The line
editor waits for a typed line by borrowing one frame at a time from the host,
so nothing blocks and nothing owns an event loop it should not.

The whole golden corpus now runs through the SDL binary as well as the stdio
one, byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 11:21:39 -04:00
parent 7897a49fb5
commit f24201fdef
14 changed files with 1816 additions and 106 deletions

226
TODO.md
View File

@@ -372,10 +372,13 @@ such ceiling because it called `make()`.
---
## 3. The akgl-backed sink and devices — **adaptors done; standalone frontend missing**
## 3. The akgl-backed sink, devices and standalone frontend — **done**
`-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, are complete and tested:
`-DAKBASIC_WITH_AKGL=ON` builds and its suite passes, and the build option now changes what
the executable *does*: an AKGL build of `basic` opens a window, draws BASIC output into it,
pumps SDL events, lets you type at it, and still puts every byte on stdout. Four adaptors in
the `akbasic_akgl` target, which is the only thing here that links SDL, are complete and
tested:
| File | Backs |
|---|---|
@@ -396,20 +399,93 @@ the host already made. Each calls `akgl_error_init()` first: `akgl_game_init()`
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.
The standalone executable is itself a host, and that part of the Go frontend has not been
ported. `CMakeLists.txt` links `akbasic_akgl` into `basic` and defines
`AKBASIC_HAVE_AKGL`, but `src/main.c` never tests that definition. It always initializes the
stdio sink, attaches no device backends, and runs without creating a window or pumping an SDL
event. The result is a terminal program with unused SDL code linked into it.
*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.
### Four things that had to be worked around to get there
### The standalone frontend
All four are `libakgl`'s and all four are filed in `deps/libakgl/TODO.md`. Each workaround is
The standalone executable is itself a host, and that half of the Go frontend is now ported —
`src/frontend_akgl.c`, in its own `akbasic_frontend` target. **The separation is in the build
graph and not only in a comment**: `akbasic` is the interpreter, `akbasic_akgl` is the four
adaptors that draw through somebody else's renderer, and `akbasic_frontend` is the one thing in
the repository that creates a window. A game embedding the interpreter links the first two and
not the third.
| Piece | Where |
|---|---|
| Window, renderer, font, the four adaptors | `akbasic_frontend_akgl_init()` |
| Sink and devices onto a runtime | `akbasic_frontend_akgl_attach()` |
| One frame: pump events, draw the text layer, present | `akbasic_frontend_akgl_pump()` |
| The bounded frame loop | `akbasic_frontend_akgl_drive()` |
| stdout mirror | `src/sink_tee.c`, in the *core* library |
Four things are worth knowing about how it came out.
1. **The stdout mirror is a sink, not a second write.** §1.5 made the sink boundary precisely
so the interpreter would never carry the reference's hardcoded pair of calls, and §3 item 5
said it again. `akbasic_sink_init_tee()` composes two sinks into one and takes `readline`
from whichever of the two was named as the reader — which is the whole difference between
the two modes: a program read from a file comes in through the stdio half, and typed lines
come in through the akgl half's line editor. It lives in the core library and is tested
there (`tests/sink_tee.c`), because composing two function-pointer records needs no SDL.
2. **The line editor borrows the host's loop a frame at a time.** `readline` has to wait for a
typed line, and §1.6 forbids the library blocking — and a sink that sat on the keyboard with
no way to pump events would deadlock the process on its first `INPUT`. So the host installs
an `akbasic_AkglPump` with `akbasic_sink_akgl_set_pump()`, and the editor calls it between
keystrokes. The loop is still the host's. `akbasic_frontend_akgl_pump()` has that exact
signature and is installed as-is. Without a pump the sink reports EOF, which is what it did
before and what a host that wants no editor still gets.
3. **The frame loop is bounded, and that is what makes the close button work.** 256 steps per
frame (`AKBASIC_FRONTEND_STEPS_PER_FRAME`), then pump and present. `10 GOTO 10` under an
unbounded `run()` would own the process forever; `tests/akgl_frontend.c` runs exactly that
and asserts the window close ends it.
4. **The frame is not cleared.** The graphics verbs draw straight to the renderer rather than
into a display list, so clearing would wipe every `DRAW` at the end of the frame it was
issued in. The text layer is drawn over whatever is there, which is also how a C128 stacks
its text plane on its bitmap plane.
*Acceptance:* two things, and the second one is the stronger.
- `tests/akgl_frontend.c`, registered as the `akgl_frontend` CTest case: a program run through
the frontend under the dummy driver, asserting the mirrored stdout **byte for byte** and
reading the renderer back to prove the same characters were drawn in the Commodore font;
synthetic key events pushed into SDL's own queue and read back through the frontend's input
backend, so every link the host owns is in the path; the line editor's fold to upper case,
its backspace and its escape; a whole REPL session typed at the window and ended with a typed
`QUIT`; and the window-close path.
- **The entire golden corpus is driven through the AKGL binary.** A `-DAKBASIC_WITH_AKGL=ON`
build registers the same 41 upstream cases against the same executable, which now opens a
window to run them, and all 41 still match byte for byte. That is a much broader statement
than any hand-written frontend test: the SDL path changes no observable output anywhere in
the corpus.
Three `no_device.bas` local cases are **deliberately not registered** in an AKGL build. Their
premise is a driver that was given no devices, which is true of the stdio driver and
deliberately false of this one. The refusal path they cover is asserted directly against the
backend records in `tests/devices.c`, which both configurations build.
**Manual check against a real display, 2026-07-31.** The dummy driver cannot prove presentation,
so `build-akgl/basic` was run against X display `:0` on a program that prints a line, draws a
`BOX` and then loops. `xwininfo` found a real 800x600 window titled `BASIC`; the window was
captured with `import` and measured: the first text row carries glyph pixels (mean 65/255), the
box's left edge at x=100 is a solid white line (mean 255), the box interior is black — it
outlines rather than fills — and an empty corner is black. stdout carried `HELLO WORLD` at the
same time. `QUIT` exits cleanly with status 0.
**What that check does not cover, and could not here:** typing at a real focused window. No key
synthesis tool (`xdotool`, `xte`, python-xlib) is installed on this machine, so the keyboard
path is proved only through SDL's own event queue in `tests/akgl_frontend.c` — which is the
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
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.
1. **An embedded `libakgl` demands its dependencies be *installed*.** It builds its vendored
@@ -439,45 +515,20 @@ commented at its site with the words "filed upstream" so it can be found and del
### 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.
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:
### Still missing from the standalone driver
- **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.
**Port the Go program's SDL frontend when `AKBASIC_HAVE_AKGL` is defined.** This is separate
from the four adaptors above: they deliberately accept resources owned by a host, and
`src/main.c` is the host for the standalone executable.
The work touches `src/main.c`, `CMakeLists.txt`, the text-sink interface or a new composite
sink module, and the driver tests. It must:
1. Initialize SDL and SDL_ttf, create the 800x600 `BASIC` window and renderer, and open
`deps/basicinterpret/fonts/C64_Pro_Mono-STYLE.ttf` at 16 points, matching
`deps/basicinterpret/main.go`.
2. Initialize the akgl text, graphics, audio and input adaptors and attach the three device
records with `akbasic_runtime_set_devices()`.
3. Pump SDL events every frame, pass keyboard events to `akgl_controller_handle_event()`, and
make window close terminate the runtime cleanly.
4. Clear, draw the BASIC text with `akbasic_sink_akgl_render()`, and present the renderer every
frame. Keep interpreter execution bounded so events and repainting cannot starve.
5. Mirror `write`, `writeln` and `clear` to both the akgl sink and stdout through a composed
text-sink backend. **Do not** put a hardcoded second write in the interpreter; §1.5 made the
sink boundary specifically to avoid that coupling.
6. Preserve the current stdio-only driver when `AKBASIC_HAVE_AKGL` is absent. File input in an
AKGL build must still appear in the window and on stdout. Interactive REPL and `INPUT`
additionally depend on the line editor above; do not claim them complete until keyboard
editing, submission and echo all work in the SDL window.
**Acceptance:** add an SDL dummy-driver CTest that launches `basic` on a short `.bas` file,
asserts its stdout byte-for-byte, and reads back the renderer to prove the same text was drawn
with the Commodore font. Add a synthetic keyboard-event test that proves the standalone event
pump feeds the input backend. Finally, run `build-akgl/basic` under a real video driver and
verify the visible window, keyboard focus, stdout mirror and clean window-close path; record
that manual check here because the dummy driver cannot prove focus or presentation to a real
display.
`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.
---
@@ -640,6 +691,42 @@ behaviour to port. They matter to somebody typing in a listing out of a C128 man
matching what a C128 reports. A float variable is refused: it is neither a character nor a
code.
### Deviations in the standalone frontend
Items 112 are deviations from ported interpreter code and 1324 from BASIC 7.0. These four are
deviations from the reference's *program*: `main.go` and the SDL half of
`basicruntime_graphics.go`.
25. **The frame loop is bounded and the reference's is not.** Go's `run()` is a `for {}` that
owns the process until `MODE_QUIT` (`basicruntime.go:682`), which is why the reference's
window never answers its close button. Here the driver runs
`AKBASIC_FRONTEND_STEPS_PER_FRAME` steps, pumps, presents, and goes round again. **What
this changes for a program:** nothing observable — a bounded run reproduces an unbounded one
exactly, and the whole golden corpus is driven through this loop to prove it. What it
changes for a *user* is that the window closes when asked.
26. **The frame is not cleared between frames.** The reference blits a text surface onto the
window surface and calls `UpdateSurface`. Here the graphics verbs draw straight to the
renderer rather than into a display list, so a `SDL_RenderClear` at the top of each frame
would erase every `DRAW` at the end of the frame it was issued in. The text layer is drawn
over whatever is already there. **What this costs:** text that has scrolled away leaves its
pixels behind wherever a graphics verb has drawn, because nothing repaints that region. A
`SCNCLR` does not repaint it either — it clears the text grid, not the bitmap. `GRAPHIC 0`
is the verb that wipes the drawing surface.
27. **The window is closed by the host, and that is not `QUIT`.** Closing the window stops the
frontend and leaves `obj->mode` alone; it does not set `AKBASIC_MODE_QUIT`. The distinction
is invisible to the standalone program, which exits either way, and load-bearing for an
embedding host: a game that closes its own window has not decided that the script is
finished, and a script that ran `QUIT` has. `tests/akgl_frontend.c` asserts both halves.
28. **Piped input still works in an AKGL build.** With a terminal on stdin the window is the
console and lines come from its editor; piped or redirected, they come from the pipe. This
is not in the reference, which always reads `os.Stdin` in REPL mode. It exists so
`basic < program.bas` behaves the same in both builds — and so the golden corpus can be
driven through the SDL binary, which is where most of the frontend's real coverage comes
from.
---
## 6. Reference defects — ported faithfully, fix them deliberately
@@ -794,13 +881,27 @@ Building against any of it needs `-DAKBASIC_WITH_AKGL=ON`, which pulls in `libak
submodules (SDL and friends). Those are not initialized in a default clone; `git submodule
update --init --recursive` gets them.
**Six items are filed upstream and outstanding**, at `deps/libakgl/TODO.md` items 5-9 of "API
**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 the hand-populated vtable and its NULL-deref hazard in
`tests/akgl_backends.c`. §3 describes all four. The fifth is `SOUND`'s frequency sweep and the
sixth is the missing version bump recorded in §8.
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:
@@ -841,9 +942,9 @@ entire test corpus and passes clean under ASan and UBSan.
| Gate | Result |
|---|---|
| `ctest` | 72/72 — 41 upstream golden cases, 5 local ones, 21 unit tests, 2 embedding examples, 1 known-failing |
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 73/73 — the above plus `akgl_backends`; the `akgl_build` CI job |
| Golden corpus | 41/41 byte-exact, driven in place from the submodule |
| `ctest` | 72/72 — 41 upstream golden cases, 5 local ones, 22 unit tests, 2 embedding examples, 1 known-failing, 1 version check |
| `ctest` with `-DAKBASIC_WITH_AKGL=ON` | 72/72 — the same, minus the three `no_device` cases the SDL driver contradicts, plus `akgl_backends` and `akgl_frontend`; the `akgl_build` CI job |
| Golden corpus | 41/41 byte-exact, driven in place from the submodule **and 41/41 again through the SDL binary**, which is most of what proves the frontend changes no output |
| ASan + UBSan | 72/72 |
| Line coverage | 93.6% (3618/3867) — above the 90% gate |
| Function coverage | 97.8% (267/273) |
@@ -922,13 +1023,14 @@ keeping:
What remains, in priority order:
1. **The standalone AKGL frontend in §3.** An AKGL-enabled `basic` currently remains the
stdio driver with unused SDL adaptors linked into it. Port the window, font, composite
stdout+SDL sink, device attachment, bounded frame loop, event pump and renderer presentation
from the Go frontend. The build option must change the executable's observable behaviour.
2. **A line editor for the akgl sink.** `readline` reports EOF, so the SDL frontend cannot
provide an interactive REPL or `INPUT` after it exists. It wants the keystroke ring plus a
cursor, and it is also what `WINDOW` and `KEY` in group E need. See §3.
1. ~~**The standalone AKGL frontend in §3.**~~ **Done**`src/frontend_akgl.c` in its own
`akbasic_frontend` target, `src/sink_tee.c` for the stdout mirror, and
`tests/akgl_frontend.c`. The build option now changes what the executable does. The one
thing not machine-verified is typing at a real focused window; §3 records why and what was
verified instead.
2. ~~**A line editor for the akgl sink.**~~ **Done**`readline` in `src/sink_akgl.c`, over
the keystroke ring, borrowing frames from the host through an `akbasic_AkglPump`. It cannot
type a shifted character, which is `libakgl` item 10 rather than work outstanding here.
3. **§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