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

View File

@@ -17,11 +17,15 @@ ctest --test-dir build --output-on-failure
# API documentation, into build/docs/html
doxygen Doxyfile
# With the libakgl-backed sink and devices. Off by default, because this pulls in
# SDL3 and the whole interpreter builds and tests without it.
# With the libakgl-backed sink, devices and SDL frontend. Off by default, because
# this pulls in SDL3 and the whole interpreter builds and tests without it.
cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
cmake --build build-akgl --parallel
SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy ctest --test-dir build-akgl --output-on-failure
ctest --test-dir build-akgl --output-on-failure
# That build of `basic` is a different program: it opens an 800x600 window, draws
# BASIC output into it in the Commodore font, and still puts every byte on stdout.
./build-akgl/basic deps/basicinterpret/tests/language/functions.bas
```
There are two workflows. **`.gitea/workflows/ci.yaml`** runs on every push: the suite,
@@ -344,6 +348,8 @@ typedef struct akbasic_TextSink
`akbasic_sink_init_stdio()` ships with the library and is what the driver and the golden-file suite use. A game supplies its own and draws into a text layer. The interpreter never owns a window, a renderer or an event loop, and `readline` is expected to set `*eof` rather than block — that is how `INPUT` behaves sanely inside a frame.
`akbasic_sink_init_tee()` also ships with the library and composes two sinks into one: writes go to both, and `readline` comes from whichever of the two you name as the reader. That is how the SDL build puts `PRINT` in a window *and* on stdout without the interpreter carrying a second write. It needs no SDL, so a game can use it to log a script's output to a file while it draws.
## Error codes
The library reserves status values **512767** with `libakerror`'s registry, under the owner string `"akbasic"`. `akbasic_runtime_init()` claims the range for you and is idempotent, so calling it twice is harmless and calling it after your own initialization is fine. If something else in the process already owns part of that band, `init` fails loudly rather than silently aliasing your error codes onto somebody else's.
@@ -359,9 +365,17 @@ 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 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.
`libakgl` itself is **not** a dependency of the core library. `-DAKBASIC_WITH_AKGL=ON` builds two additional targets, and the split between them is the one that matters if you are embedding this:
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.
| Target | What it is | Link it? |
|---|---|---|
| `akbasic` | The interpreter. No SDL, no window, nothing that terminates the process. | Always. |
| `akbasic_akgl` | The akgl-backed text sink and the graphics, sound and input backends. Every one takes a renderer, a font or a device **you** already created. | If you want the interpreter drawing through your `libakgl` renderer. |
| `akbasic_frontend` | The standalone program's host: it creates the window, opens the font, pumps SDL events and owns the frame loop. | **No.** You are the host. This exists so `basic` can be one. |
Both are 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 a default build of 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. An SDL build attaches all three. 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.