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:
226
TODO.md
226
TODO.md
@@ -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 1–12 are deviations from ported interpreter code and 13–24 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
|
||||
|
||||
Reference in New Issue
Block a user