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

@@ -174,8 +174,8 @@ jobs:
sudo apt-get install -y cmake gcc g++ pkg-config \ sudo apt-get install -y cmake gcc g++ pkg-config \
libfreetype-dev libharfbuzz-dev libfreetype-dev libharfbuzz-dev
# The akgl-backed half: the text sink and the graphics, audio and input # The akgl-backed half: the text sink and the graphics, audio and input
# backends, plus the akgl_backends suite that drives them against a real # backends, the standalone SDL frontend, and the two suites that drive
# software renderer and reads the pixels back. # them against a real software renderer and read the pixels back.
# #
# It is a separate job rather than a flag on cmake_build because # It is a separate job rather than a flag on cmake_build because
# AKBASIC_WITH_AKGL is off by default and that default is the point: the # AKBASIC_WITH_AKGL is off by default and that default is the point: the
@@ -191,19 +191,30 @@ jobs:
run: | run: |
cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
cmake --build build-akgl --parallel cmake --build build-akgl --parallel
# Dummy video and audio drivers: the suite creates a 128x128 software # Dummy video and audio drivers, set per test by CMakeLists rather than in
# renderer and reads it back with SDL_RenderReadPixels, so it needs no # this job's environment, because an AKGL build of `basic` is now an SDL
# display, no sound card and no offscreen harness. Same approach # program and the golden cases run *it*: forty-one real windows is not what
# anybody running the suite wanted. The suites create software renderers
# and read them back with SDL_RenderReadPixels, so none of this needs a
# display, a sound card or an offscreen harness -- the same approach
# deps/libakgl/tests/draw.c takes. # deps/libakgl/tests/draw.c takes.
# #
# 71 cases: the 70 the default build runs, plus akgl_backends. The other # The env block stays anyway. It is redundant with the per-test property
# 70 are run here too on purpose -- they are the ones that must keep # and costs nothing, and it is what keeps a hand-run `ctest` in this
# passing when SDL *is* present, and a linker that picked up the wrong # directory behaving the same way.
# akstdlib or akerror would show up here first. #
# 72 cases: the default build's 72, minus the three no_device ones whose
# premise is a driver with no devices attached -- which is exactly what
# this build contradicts -- plus akgl_backends and akgl_frontend. The
# golden cases run here too on purpose, and they are now doing double duty:
# they are the ones that must keep passing when SDL is present, *and* they
# are what proves the SDL frontend changes no output anywhere in the
# corpus.
- name: test with libakgl - name: test with libakgl
env: env:
SDL_VIDEODRIVER: dummy SDL_VIDEODRIVER: dummy
SDL_AUDIODRIVER: dummy SDL_AUDIODRIVER: dummy
SDL_RENDER_DRIVER: software
run: ctest --test-dir build-akgl --output-on-failure --output-junit "$(pwd)/ctest-akgl-junit.xml" run: ctest --test-dir build-akgl --output-on-failure --output-junit "$(pwd)/ctest-akgl-junit.xml"
- name: publish test results - name: publish test results
if: always() if: always()

View File

@@ -133,6 +133,7 @@ set(AKBASIC_SOURCES
src/runtime_input.c src/runtime_input.c
src/scanner.c src/scanner.c
src/sink_stdio.c src/sink_stdio.c
src/sink_tee.c
src/symtab.c src/symtab.c
src/value.c src/value.c
src/variable.c src/variable.c
@@ -170,6 +171,16 @@ if(AKBASIC_WITH_AKGL)
target_compile_options(akbasic_akgl PRIVATE -Wall -Wextra) target_compile_options(akbasic_akgl PRIVATE -Wall -Wextra)
target_link_libraries(akbasic_akgl PUBLIC akbasic akgl) target_link_libraries(akbasic_akgl PUBLIC akbasic akgl)
akbasic_instrument(akbasic_akgl) akbasic_instrument(akbasic_akgl)
# The SDL host, in its own target so the separation goal 3 rests on is visible
# in the build graph and not only in a comment: akbasic is the interpreter,
# akbasic_akgl is the adaptors that draw through somebody else's renderer, and
# this is the one thing in the repository that creates a window. A game
# embedding the interpreter links the first two and not this.
add_library(akbasic_frontend src/frontend_akgl.c)
target_compile_options(akbasic_frontend PRIVATE -Wall -Wextra)
target_link_libraries(akbasic_frontend PUBLIC akbasic_akgl)
akbasic_instrument(akbasic_frontend)
endif() endif()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -179,8 +190,14 @@ add_executable(basic src/main.c)
target_compile_options(basic PRIVATE -Wall -Wextra) target_compile_options(basic PRIVATE -Wall -Wextra)
target_link_libraries(basic PRIVATE akbasic) target_link_libraries(basic PRIVATE akbasic)
if(AKBASIC_WITH_AKGL) if(AKBASIC_WITH_AKGL)
target_link_libraries(basic PRIVATE akbasic_akgl) target_link_libraries(basic PRIVATE akbasic_frontend)
target_compile_definitions(basic PRIVATE AKBASIC_HAVE_AKGL=1) # The reference's own font, at the reference's own size. Its main.go opens
# "./fonts/C64_Pro_Mono-STYLE.ttf", which only ever resolved from its own
# source directory; compiled in, it resolves from anywhere, and AKBASIC_FONT
# overrides it at runtime for an installed copy.
target_compile_definitions(basic PRIVATE
AKBASIC_HAVE_AKGL=1
AKBASIC_FONT_PATH="${CMAKE_CURRENT_SOURCE_DIR}/deps/basicinterpret/fonts/C64_Pro_Mono-STYLE.ttf")
endif() endif()
akbasic_instrument(basic) akbasic_instrument(basic)
@@ -229,6 +246,7 @@ set(AKBASIC_TESTS
runtime_verbs runtime_verbs
scanner_tokens scanner_tokens
sink_stdio sink_stdio
sink_tee
symtab symtab
value_arithmetic value_arithmetic
value_bitwise value_bitwise
@@ -276,6 +294,24 @@ if(AKBASIC_WITH_AKGL)
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests" WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests"
TIMEOUT 60 TIMEOUT 60
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy") ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy")
# The frontend suite. Separate from the one above because it tests the *host*
# rather than the adaptors -- it is the only test that links akbasic_frontend,
# and the only one that opens a window and pumps events. It uses the
# reference's own Commodore font, because proving the drawn text is in that
# font is part of what TODO.md section 3 asks for.
add_executable(akbasic_test_akgl_frontend tests/akgl_frontend.c)
target_compile_options(akbasic_test_akgl_frontend PRIVATE -Wall -Wextra)
target_link_libraries(akbasic_test_akgl_frontend PRIVATE akbasic_frontend)
target_include_directories(akbasic_test_akgl_frontend PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/tests")
target_compile_definitions(akbasic_test_akgl_frontend PRIVATE
AKBASIC_TEST_C64_FONT="${CMAKE_CURRENT_SOURCE_DIR}/deps/basicinterpret/fonts/C64_Pro_Mono-STYLE.ttf")
akbasic_instrument(akbasic_test_akgl_frontend)
_add_test(NAME akgl_frontend COMMAND akbasic_test_akgl_frontend)
_set_tests_properties(akgl_frontend PROPERTIES
WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/tests"
TIMEOUT 60
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
endif() endif()
if(AKBASIC_TESTS OR AKBASIC_WILL_FAIL_TESTS OR AKBASIC_KNOWN_FAILING_TESTS) if(AKBASIC_TESTS OR AKBASIC_WILL_FAIL_TESTS OR AKBASIC_KNOWN_FAILING_TESTS)
@@ -323,6 +359,13 @@ if(AKBASIC_GOLDEN_CASES)
list(APPEND AKBASIC_GOLDEN_NAMES golden_${_name}) list(APPEND AKBASIC_GOLDEN_NAMES golden_${_name})
endforeach() endforeach()
_set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES TIMEOUT 30) _set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES TIMEOUT 30)
# An AKGL build of `basic` opens a window, and forty-one of them is not what
# anybody running the suite wanted. The dummy driver produces the same stdout,
# which is the only thing a golden case compares.
if(AKBASIC_WITH_AKGL)
_set_tests_properties(${AKBASIC_GOLDEN_NAMES} PROPERTIES
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
endif()
endif() endif()
# The local golden corpus, for verbs the reference never implemented. # The local golden corpus, for verbs the reference never implemented.
@@ -341,8 +384,21 @@ file(GLOB_RECURSE AKBASIC_LOCAL_CASES
"${CMAKE_CURRENT_SOURCE_DIR}/tests/language/*.bas" "${CMAKE_CURRENT_SOURCE_DIR}/tests/language/*.bas"
) )
#
# One family of local cases is driver-specific and has to be. A case named
# no_device.bas asserts that a verb needing a device refuses politely when it was
# given none, which is true of the stdio driver and deliberately false of the
# AKGL one -- attaching the three device backends is the whole point of
# AKBASIC_WITH_AKGL, and TODO.md section 8 item 1 asks for exactly that change in
# observable behaviour. Skipping them there is not a coverage hole: the refusal
# path is asserted directly against the backend records in tests/devices.c, which
# both configurations build.
#
set(AKBASIC_LOCAL_NAMES) set(AKBASIC_LOCAL_NAMES)
foreach(_case IN LISTS AKBASIC_LOCAL_CASES) foreach(_case IN LISTS AKBASIC_LOCAL_CASES)
if(AKBASIC_WITH_AKGL AND _case MATCHES "no_device\\.bas$")
continue()
endif()
string(REGEX REPLACE "^language/" "" _name "${_case}") string(REGEX REPLACE "^language/" "" _name "${_case}")
string(REGEX REPLACE "\\.bas$" "" _name "${_name}") string(REGEX REPLACE "\\.bas$" "" _name "${_name}")
string(REPLACE "/" "_" _name "${_name}") string(REPLACE "/" "_" _name "${_name}")
@@ -358,6 +414,10 @@ endforeach()
if(AKBASIC_LOCAL_NAMES) if(AKBASIC_LOCAL_NAMES)
_set_tests_properties(${AKBASIC_LOCAL_NAMES} PROPERTIES TIMEOUT 30) _set_tests_properties(${AKBASIC_LOCAL_NAMES} PROPERTIES TIMEOUT 30)
if(AKBASIC_WITH_AKGL)
_set_tests_properties(${AKBASIC_LOCAL_NAMES} PROPERTIES
ENVIRONMENT "SDL_VIDEODRIVER=dummy;SDL_AUDIODRIVER=dummy;SDL_RENDER_DRIVER=software")
endif()
endif() endif()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -17,11 +17,15 @@ ctest --test-dir build --output-on-failure
# API documentation, into build/docs/html # API documentation, into build/docs/html
doxygen Doxyfile doxygen Doxyfile
# With the libakgl-backed sink and devices. Off by default, because this pulls in # With the libakgl-backed sink, devices and SDL frontend. Off by default, because
# SDL3 and the whole interpreter builds and tests without it. # this pulls in SDL3 and the whole interpreter builds and tests without it.
cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON cmake -S . -B build-akgl -DAKBASIC_WITH_AKGL=ON
cmake --build build-akgl --parallel 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, 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_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 ## 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. 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`. `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. 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.

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` `-DAKBASIC_WITH_AKGL=ON` builds and its suite passes, and the build option now changes what
target, which is the only thing here that links SDL, are complete and tested: 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 | | 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 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. 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 *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` 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` established, which needs no display and no offscreen harness. Registered as the `akgl_backends`
CTest case, and only when `AKBASIC_WITH_AKGL` is on. 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. 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 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 ### Still missing from the sink
`readline` reports end of input rather than reading anything: a drawn text layer is not a Nothing that blocks a verb. The line editor exists (see above), so `INPUT` and the REPL work in
source of lines, and `INPUT` through a graphics sink wants a line editor built on the keystroke the window. Two limits are worth stating because a program can reach both:
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.
### 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 `WINDOW` and `KEY` in group E want a text-window rectangle and a function-key table on top of
from the four adaptors above: they deliberately accept resources owned by a host, and this, which is ordinary work here rather than anything blocked.
`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.
--- ---
@@ -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 matching what a C128 reports. A float variable is refused: it is neither a character nor a
code. 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 ## 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 submodules (SDL and friends). Those are not initialized in a default clone; `git submodule
update --init --recursive` gets them. 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 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 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` 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 include in `src/input_akgl.c` and `src/sink_akgl.c`, and the hand-populated vtable and its
`tests/akgl_backends.c`. §3 describes all four. The fifth is `SOUND`'s frequency sweep and the NULL-deref hazard in `tests/akgl_backends.c` and `src/frontend_akgl.c`. §3 describes all four.
sixth is the missing version bump recorded in §8. 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 **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: 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 | | Gate | Result |
|---|---| |---|---|
| `ctest` | 72/72 — 41 upstream golden cases, 5 local ones, 21 unit tests, 2 embedding examples, 1 known-failing | | `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` | 73/73 — the above plus `akgl_backends`; the `akgl_build` CI job | | `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 | | 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 | | ASan + UBSan | 72/72 |
| Line coverage | 93.6% (3618/3867) — above the 90% gate | | Line coverage | 93.6% (3618/3867) — above the 90% gate |
| Function coverage | 97.8% (267/273) | | Function coverage | 97.8% (267/273) |
@@ -922,13 +1023,14 @@ keeping:
What remains, in priority order: What remains, in priority order:
1. **The standalone AKGL frontend in §3.** An AKGL-enabled `basic` currently remains the 1. ~~**The standalone AKGL frontend in §3.**~~ **Done**`src/frontend_akgl.c` in its own
stdio driver with unused SDL adaptors linked into it. Port the window, font, composite `akbasic_frontend` target, `src/sink_tee.c` for the stdout mirror, and
stdout+SDL sink, device attachment, bounded frame loop, event pump and renderer presentation `tests/akgl_frontend.c`. The build option now changes what the executable does. The one
from the Go frontend. The build option must change the executable's observable behaviour. thing not machine-verified is typing at a real focused window; §3 records why and what was
2. **A line editor for the akgl sink.** `readline` reports EOF, so the SDL frontend cannot verified instead.
provide an interactive REPL or `INPUT` after it exists. It wants the keystroke ring plus a 2. ~~**A line editor for the akgl sink.**~~ **Done**`readline` in `src/sink_akgl.c`, over
cursor, and it is also what `WINDOW` and `KEY` in group E need. See §3. 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` 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: 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 the `COLON` token exists and nothing consumes it, and `DO`/`LOOP` in group A reads badly

2
deps/libakgl vendored

View File

@@ -39,6 +39,25 @@
/** @brief How many saved SSHAPE regions the graphics backend will hold at once. */ /** @brief How many saved SSHAPE regions the graphics backend will hold at once. */
#define AKBASIC_AKGL_MAX_SHAPES 16 #define AKBASIC_AKGL_MAX_SHAPES 16
/**
* @brief One frame of the host's own loop, borrowed by the sink's line editor.
*
* The sink has to be able to wait for a typed line without owning an event loop,
* and those two requirements only meet in one place: the host hands over a
* callback that does exactly one frame -- pump events, redraw, present -- and
* the editor calls it between keystrokes. The loop is still the host's; the
* editor just borrows it a frame at a time.
*
* Setting @p running false is how the host says the window closed. The editor
* reports end of input rather than raising, because that is what running out of
* input means everywhere else in sink.h.
*
* @param self Whatever the host passed to akbasic_sink_akgl_set_pump().
* @param running Set false to stop waiting; the editor then reports EOF.
* @return `NULL` on success, otherwise an error context owned by the caller.
*/
typedef akerr_ErrorContext AKERR_NOIGNORE *(*akbasic_AkglPump)(void *self, bool *running);
/** /**
* @brief State for the libakgl-backed text sink. * @brief State for the libakgl-backed text sink.
* *
@@ -69,6 +88,21 @@ typedef struct
* list of lines: the interpreter allocates nothing, and neither does this. * list of lines: the interpreter allocates nothing, and neither does this.
*/ */
char text[64][256]; char text[64][256];
/*
* The line editor. Nothing here is live except while readline is running,
* and `editing` is what says so -- render() draws a cursor only then, and
* the scroll adjusts `editrow` only then.
*/
akbasic_AkglPump pump;
void *pumpself;
bool editing;
int editrow; /* where the line being typed starts */
int editcol;
int editlen; /* characters accepted so far */
int echolen; /* characters currently drawn, so a backspace */
/* knows how much to erase */
char editline[256];
} akbasic_AkglSink; } akbasic_AkglSink;
/** /**
@@ -121,6 +155,34 @@ akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_init_akgl(akbasic_TextSink *obj,
*/ */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_akgl_render(akbasic_TextSink *obj); akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_akgl_render(akbasic_TextSink *obj);
/**
* @brief Give the sink a line editor by lending it one frame of the host's loop.
*
* Without this, readline reports end of input immediately: a drawn text layer is
* not a source of lines, and a sink that blocked on the keyboard with no way to
* pump events would deadlock the process on its first INPUT. With it, readline
* collects keystrokes from libakgl's ring -- the one the host's own event pump
* fills -- echoes them into the grid, and returns the line on Return.
*
* **What the editor cannot do, and why.** akgl_controller_poll_key() reports a
* keycode and nothing else: no modifier state and no composed text. So the
* editor cannot see Shift, which puts every shifted character out of reach and
* makes lowercase unreachable. Letters are folded to upper case, which is what a
* C128 does anyway and what the C64 font is drawn for. Filed upstream against
* libakgl; when the ring carries modifiers this becomes a real keyboard.
*
* Editing is Backspace to erase one character and Escape to abandon the line.
* There is no cursor movement within the line, for the same reason: the ring
* reports the arrow keys, but a script's own GET loop wants them.
*
* @param obj The sink to give an editor to.
* @param pump One frame of the host's loop, or NULL to take the editor away.
* @param self Passed back to @p pump untouched.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj` is NULL or carries no sink state.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_akgl_set_pump(akbasic_TextSink *obj, akbasic_AkglPump pump, void *self);
/** /**
* @brief Point a graphics backend at a renderer the host already has. * @brief Point a graphics backend at a renderer the host already has.
* @param obj Object to initialize, inspect, or modify. * @param obj Object to initialize, inspect, or modify.

177
include/akbasic/frontend.h Normal file
View File

@@ -0,0 +1,177 @@
/**
* @file frontend.h
* @brief The standalone SDL frontend: a host, not part of the interpreter.
*
* Everything else under include/akbasic deliberately owns no window, no renderer
* and no event loop -- goal 3 forbids it, because a game embedding this
* interpreter already has all three. This file is the other side of that line.
* It is the *host* the standalone `basic` executable needs, ported from the Go
* reference's main.go and basicruntime_graphics.go, and it is built into its own
* target so the separation is visible in the build graph rather than only in a
* comment: `akbasic` is the interpreter, `akbasic_akgl` is the adaptors, and
* `akbasic_frontend` is the one thing here that creates a window.
*
* A game does not link this. It creates its own window and calls the four
* initializers in akbasic/akgl.h against it.
*/
#ifndef _AKBASIC_FRONTEND_H_
#define _AKBASIC_FRONTEND_H_
#include <stdio.h>
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
#include <akerror.h>
#include <akgl/renderer.h>
#include <akbasic/akgl.h>
#include <akbasic/runtime.h>
#include <akbasic/sink.h>
/**
* @brief Interpreter steps run between two frames.
*
* The reason there is a number here at all is TODO.md section 1.6: the library
* steps, it does not run, precisely so a host can bound it. An unbounded run
* would starve the event pump, and a window that does not answer its close
* button is a window the desktop kills.
*
* 256 rather than 1 because a frame costs far more than a step and a tight
* program should not be paced at 60 lines per second. It is a pacing knob and
* nothing else -- no golden output depends on it, since a bounded run reproduces
* an unbounded one exactly.
*/
#define AKBASIC_FRONTEND_STEPS_PER_FRAME 256
/** @brief Window size the reference opens, from deps/basicinterpret/main.go. */
#define AKBASIC_FRONTEND_WIDTH 800
/** @brief Window height the reference opens. */
#define AKBASIC_FRONTEND_HEIGHT 600
/** @brief Font size the reference opens its font at. */
#define AKBASIC_FRONTEND_FONT_SIZE 16
/**
* @brief Everything the standalone program owns.
*
* All of it by value: the frontend allocates nothing, and a driver puts one of
* these in static storage next to its runtime for the same reason the runtime is
* static -- neither belongs on a default stack.
*/
typedef struct akbasic_AkglFrontend
{
SDL_Window *window;
akgl_RenderBackend *renderer;
TTF_Font *font;
int width;
int height;
/*
* Three sinks, because output goes two places. The tee is what the runtime
* is given; the other two are its halves. TODO.md section 3 item 5: the
* second write belongs out here, never inside the interpreter.
*/
akbasic_AkglSink akglstate;
akbasic_TextSink akglsink;
akbasic_StdioSink stdiostate;
akbasic_TextSink stdiosink;
akbasic_TeeSink teestate;
akbasic_TextSink sink;
akbasic_GraphicsBackend graphics;
akbasic_AkglGraphics graphicsstate;
akbasic_AudioBackend audio;
akbasic_InputBackend input;
/** False once the window has been closed; the drive loop stops on it. */
bool running;
/** True when this frontend called SDL_Init and owes it an SDL_Quit. */
bool ownssdl;
/** True when an audio device opened. A machine with none still runs. */
bool audioready;
} akbasic_AkglFrontend;
/**
* @brief Bring up SDL, a window, a renderer, a font and all four adaptors.
*
* Reproduces the reference's main.go: an 800x600 window titled "BASIC" and the
* Commodore font at 16 points. Audio is best-effort -- a machine with no sound
* device still runs BASIC, it just refuses SOUND and PLAY with
* AKBASIC_ERR_DEVICE, which is the same answer a stdio build gives.
*
* @param obj Object to initialize, inspect, or modify.
* @param title Window title.
* @param w Window width in pixels.
* @param h Window height in pixels.
* @param fontpath TTF to open for the text layer.
* @param fontsize Point size to open it at.
* @param mirror Where output is mirrored in addition to the window; NULL selects stdout.
* @param in Where the runtime reads its program, or NULL to read typed lines from the window.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj`, `title` or `fontpath` is NULL.
* @throws AKGL_ERR_SDL When SDL, the window, the renderer or the font refuses to open.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_frontend_akgl_init(akbasic_AkglFrontend *obj, const char *title, int w, int h, const char *fontpath, int fontsize, FILE *mirror, FILE *in);
/**
* @brief Give a runtime this frontend's sink and its three devices.
*
* Initializes @p rt as well, because a runtime has to be told its sink at
* initialization and there is nothing useful a caller could do between the two
* calls.
*
* @param obj Object to initialize, inspect, or modify.
* @param rt The runtime to wire up.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When either argument is NULL.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_frontend_akgl_attach(akbasic_AkglFrontend *obj, akbasic_Runtime *rt);
/**
* @brief One frame: pump events, clear, draw the text layer, present.
*
* Deliberately shaped as an akbasic_AkglPump so the sink's line editor can be
* handed this exact function -- the editor waits for a typed line by borrowing
* frames from here, which is how INPUT works without anybody blocking.
*
* The graphics verbs draw straight to the renderer and are *not* redrawn from a
* display list, so this does not clear the target: a DRAW from three frames ago
* has to still be on screen. The text layer is drawn over whatever is there,
* which is also what a C128 does with its text and bitmap planes.
*
* @param self The frontend, as a void * so this matches akbasic_AkglPump.
* @param running Set false when the window has been closed.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When either argument is NULL.
* @throws AKGL_ERR_SDL When the renderer refuses to present.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_frontend_akgl_pump(void *self, bool *running);
/**
* @brief Run the interpreter to completion, a bounded number of steps per frame.
*
* This is the standalone program's whole loop. It ends when the script sets
* AKBASIC_MODE_QUIT or the window is closed, and either is a clean exit -- a
* closed window is a user quitting, not a failure.
*
* @param obj Object to initialize, inspect, or modify.
* @param rt The runtime to drive.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When either argument is NULL.
* @throws AKERR_* Whatever the script raised and nothing handled.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_frontend_akgl_drive(akbasic_AkglFrontend *obj, akbasic_Runtime *rt);
/**
* @brief Close the font, the renderer, the window and SDL, in that order.
*
* Returns nothing and refuses nothing: this runs on the way out, including on
* the way out of a failure, and there is no caller left to tell.
*
* @param obj Object to initialize, inspect, or modify.
*/
void akbasic_frontend_akgl_shutdown(akbasic_AkglFrontend *obj);
#endif // _AKBASIC_FRONTEND_H_

View File

@@ -51,4 +51,43 @@ typedef struct
*/ */
akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_init_stdio(akbasic_TextSink *obj, akbasic_StdioSink *state, FILE *out, FILE *in); akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_init_stdio(akbasic_TextSink *obj, akbasic_StdioSink *state, FILE *out, FILE *in);
/**
* @brief State for a sink that writes to two others.
*
* The reference mirrors every line to stdout *and* to its SDL surface, and that
* mirror is what makes the golden corpus runnable. Reproducing it as a second
* hardcoded write inside the interpreter is exactly what the sink boundary
* exists to prevent, so the composition lives out here instead: two sinks in,
* one sink out, and the interpreter still only knows about one.
*/
typedef struct
{
akbasic_TextSink *primary;
akbasic_TextSink *mirror;
akbasic_TextSink *reader;
} akbasic_TeeSink;
/**
* @brief Compose two sinks into one that writes to both.
*
* Writes go to @p primary first and then to @p mirror; an error from either one
* stops the pair, so a failed write is never half-reported.
*
* @p reader says which of the two answers readline, because only one of them can
* and the answer is not derivable. A file-mode driver reads its program from the
* stdio half while drawing through the akgl half; an interactive one reads from
* the akgl line editor and mirrors to stdout. Passing NULL makes readline report
* end of input, which is the honest answer for a pair that has no input.
*
* @param obj Object to initialize, inspect, or modify.
* @param state Storage for the three pointers; must outlive the sink.
* @param primary The sink written first; must not be NULL.
* @param mirror The sink written second; must not be NULL.
* @param reader Whichever of the two supplies readline, or NULL for none.
* @return `NULL` on success, otherwise an error context owned by the caller.
* @throws AKERR_NULLPOINTER When `obj`, `state`, `primary` or `mirror` is NULL.
* @throws AKBASIC_ERR_VALUE When `reader` is neither `primary` nor `mirror`.
*/
akerr_ErrorContext AKERR_NOIGNORE *akbasic_sink_init_tee(akbasic_TextSink *obj, akbasic_TeeSink *state, akbasic_TextSink *primary, akbasic_TextSink *mirror, akbasic_TextSink *reader);
#endif // _AKBASIC_SINK_H_ #endif // _AKBASIC_SINK_H_

294
src/frontend_akgl.c Normal file
View File

@@ -0,0 +1,294 @@
/**
* @file frontend_akgl.c
* @brief The standalone program's SDL host, ported from the Go frontend.
*
* The reference's main.go opens an 800x600 window and a Commodore font and hands
* both to the runtime, which then owns the process until MODE_QUIT
* (basicruntime.go:682). Goal 3 forbids the second half of that outright, so
* what is ported here is the first half plus the loop the reference never had to
* write: pump events, run a bounded number of interpreter steps, draw, present,
* repeat.
*
* **This file is a host.** It is the only thing in the repository that creates a
* window, and it is in its own target so that fact is visible in the build graph
* rather than only in a comment. A game embedding the interpreter does not link
* it -- it already has a window, and it calls the four initializers in
* akbasic/akgl.h against its own.
*
* Everything the four adaptors need is created here and nowhere else:
*
* window + renderer ---> the akgl sink and the graphics backend
* SDL event pump ---> akgl_controller_handle_event, the input backend
* the frame ---> akbasic_sink_akgl_render, and the line editor
*/
#include <stddef.h>
#include <string.h>
#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>
/*
* game.h purely for the `renderer` global and its `_akgl_renderer` storage.
* akgl_text_rendertextat() takes no renderer argument and reads that global, so
* a host that never calls akgl_game_init() -- which is every host embedding this
* interpreter, since owning the game loop is exactly what goal 3 forbids -- has
* to populate it itself. deps/libakgl/tests/draw.c does the same.
*/
#include <akgl/game.h>
#include <akgl/renderer.h>
#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);
akbasic_TextSink *reader = NULL;
FAIL_ZERO_RETURN(errctx, (obj != NULL && title != NULL && fontpath != NULL),
AKERR_NULLPOINTER, "NULL argument in frontend_akgl_init");
FAIL_ZERO_RETURN(errctx, (w > 0 && h > 0 && fontsize > 0), AKBASIC_ERR_VALUE,
"A %dx%d window at %d points is not a window", w, h, fontsize);
memset(obj, 0, sizeof(*obj));
obj->width = w;
obj->height = h;
/* Before anything else in libakgl, so every AKGL_ERR_* has a name to print. */
PASS(errctx, akgl_error_init());
/*
* INIT_VIDEO only. The reference asks for INIT_EVERYTHING, which on a
* headless machine fails on a subsystem BASIC never touches; audio is opened
* separately below and is allowed to fail.
*/
FAIL_ZERO_RETURN(errctx, SDL_Init(SDL_INIT_VIDEO), AKGL_ERR_SDL,
"Couldn't initialize SDL: %s", SDL_GetError());
obj->ownssdl = true;
FAIL_ZERO_RETURN(errctx, TTF_Init(), AKGL_ERR_SDL,
"Couldn't initialize SDL_ttf: %s", SDL_GetError());
obj->renderer = &_akgl_renderer;
renderer = obj->renderer;
FAIL_ZERO_RETURN(errctx,
SDL_CreateWindowAndRenderer(title, w, h, 0,
&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);
obj->font = TTF_OpenFont(fontpath, (float)fontsize);
FAIL_ZERO_RETURN(errctx, (obj->font != NULL), AKGL_ERR_SDL,
"Couldn't open the font %s: %s", fontpath, SDL_GetError());
/*
* The two halves of the output, and then the tee that joins them. Which one
* answers readline 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.
*/
PASS(errctx, akbasic_sink_init_akgl(&obj->akglsink, &obj->akglstate,
obj->renderer, obj->font, w, h));
PASS(errctx, akbasic_sink_init_stdio(&obj->stdiosink, &obj->stdiostate,
(mirror != NULL ? mirror : stdout), in));
reader = (in != NULL ? &obj->stdiosink : &obj->akglsink);
PASS(errctx, akbasic_sink_init_tee(&obj->sink, &obj->teestate,
&obj->akglsink, &obj->stdiosink, reader));
/*
* The editor waits for a typed line by borrowing frames from this very
* frontend -- which is why pump() has the akbasic_AkglPump signature and can
* be installed as-is.
*/
PASS(errctx, akbasic_sink_akgl_set_pump(&obj->akglsink,
akbasic_frontend_akgl_pump, obj));
PASS(errctx, akbasic_graphics_init_akgl(&obj->graphics, &obj->graphicsstate,
obj->renderer));
PASS(errctx, akbasic_input_init_akgl(&obj->input));
/*
* Audio last, in its own subsystem, and allowed to fail. The reference asks
* for INIT_EVERYTHING up front, which makes a machine with no sound card
* refuse to run BASIC at all; here the failure costs only SOUND and PLAY,
* which are then refused with AKBASIC_ERR_DEVICE -- exactly what a stdio
* build does. The subsystem is tried first because akgl_audio_init() cannot
* open a device without it, and reporting "no audio device" for what is
* really "audio was never initialized" would send somebody looking at their
* hardware.
*/
if ( !SDL_InitSubSystem(SDL_INIT_AUDIO) ) {
SDL_Log("No audio subsystem (%s); SOUND and PLAY will be refused", SDL_GetError());
obj->audioready = false;
} else {
ATTEMPT {
CATCH(errctx, akbasic_audio_init_akgl(&obj->audio));
obj->audioready = true;
} CLEANUP {
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "No audio device; SOUND and PLAY will be refused");
obj->audioready = false;
} FINISH(errctx, false);
}
obj->running = true;
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_frontend_akgl_attach(akbasic_AkglFrontend *obj, akbasic_Runtime *rt)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL && rt != NULL), AKERR_NULLPOINTER,
"NULL argument in frontend_akgl_attach");
PASS(errctx, akbasic_runtime_init(rt, &obj->sink));
PASS(errctx, akbasic_runtime_set_devices(rt, &obj->graphics,
(obj->audioready ? &obj->audio : NULL),
&obj->input));
SUCCEED_RETURN(errctx);
}
/**
* @brief Drain the SDL event queue into libakgl's keystroke ring.
*
* Its own function because it is a loop, and CATCH inside one escapes only the
* loop -- PASS is the only thing that may appear in here.
*/
static akerr_ErrorContext AKERR_NOIGNORE *pump_events(akbasic_AkglFrontend *obj)
{
PREPARE_ERROR(errctx);
SDL_Event event;
while ( SDL_PollEvent(&event) ) {
if ( event.type == SDL_EVENT_QUIT ||
event.type == SDL_EVENT_WINDOW_CLOSE_REQUESTED ) {
obj->running = false;
continue;
}
/*
* Everything else goes to libakgl, which pushes key-downs into the ring
* the input backend reads before it consults its own control maps. The
* appstate is the frontend, which nothing downstream reads -- but a NULL
* one is refused outright.
*/
PASS(errctx, akgl_controller_handle_event(obj, &event));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_frontend_akgl_pump(void *self, bool *running)
{
PREPARE_ERROR(errctx);
akbasic_AkglFrontend *obj = (akbasic_AkglFrontend *)self;
FAIL_ZERO_RETURN(errctx, (obj != NULL && running != NULL), AKERR_NULLPOINTER,
"NULL argument in frontend_akgl_pump");
PASS(errctx, pump_events(obj));
*running = obj->running;
if ( !obj->running ) {
SUCCEED_RETURN(errctx);
}
/*
* No clear. The graphics verbs draw straight to the renderer rather than
* into a display list, so clearing here would wipe every DRAW the moment the
* frame it was issued in ended. The text layer is drawn over whatever is
* already there, which is also how a C128 stacks its text plane on its
* bitmap plane.
*/
PASS(errctx, akbasic_sink_akgl_render(&obj->akglsink));
FAIL_ZERO_RETURN(errctx, SDL_RenderPresent(obj->renderer->sdl_renderer),
AKGL_ERR_SDL, "Couldn't present the frame: %s", SDL_GetError());
SUCCEED_RETURN(errctx);
}
/**
* @brief The frame loop, in its own function for the usual reason: it is a loop.
*/
static akerr_ErrorContext AKERR_NOIGNORE *drive_loop(akbasic_AkglFrontend *obj, akbasic_Runtime *rt)
{
PREPARE_ERROR(errctx);
bool running = true;
while ( running && rt->mode != AKBASIC_MODE_QUIT ) {
/*
* The host owns the clock -- the library reads none, because it owns no
* loop and must not block. SDL_GetTicks() is monotonic milliseconds,
* which is exactly what settime wants and saves this file a
* clock_gettime and a _POSIX_C_SOURCE.
*/
PASS(errctx, akbasic_runtime_settime(rt, (int64_t)SDL_GetTicks()));
PASS(errctx, akbasic_runtime_run(rt, AKBASIC_FRONTEND_STEPS_PER_FRAME));
PASS(errctx, akbasic_frontend_akgl_pump(obj, &running));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_frontend_akgl_drive(akbasic_AkglFrontend *obj, akbasic_Runtime *rt)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL && rt != NULL), AKERR_NULLPOINTER,
"NULL argument in frontend_akgl_drive");
PASS(errctx, drive_loop(obj, rt));
SUCCEED_RETURN(errctx);
}
void akbasic_frontend_akgl_shutdown(akbasic_AkglFrontend *obj)
{
if ( obj == NULL ) {
return;
}
if ( obj->font != NULL ) {
TTF_CloseFont(obj->font);
obj->font = NULL;
}
if ( obj->renderer != NULL && obj->renderer->sdl_renderer != NULL ) {
SDL_DestroyRenderer(obj->renderer->sdl_renderer);
obj->renderer->sdl_renderer = NULL;
}
if ( obj->window != NULL ) {
SDL_DestroyWindow(obj->window);
obj->window = NULL;
window = NULL;
}
TTF_Quit();
if ( obj->ownssdl ) {
SDL_Quit();
obj->ownssdl = false;
}
obj->running = false;
}

View File

@@ -3,21 +3,28 @@
* @brief The standalone driver. * @brief The standalone driver.
* *
* Everything that belongs to a program rather than to a library lives here: argv * Everything that belongs to a program rather than to a library lives here: argv
* handling, sink selection, the unbounded run loop, and a FINISH_NORETURN -- * handling, which frontend to bring up, and a FINISH_NORETURN -- which belongs
* which belongs only in a main(). The interpreter library itself never * only in a main(). The interpreter library itself never terminates the process.
* terminates the process. *
* There are two drivers in here and the build option picks between them. Without
* AKBASIC_HAVE_AKGL this is a terminal program: stdio in, stdout out, no window.
* With it, the program is an SDL host -- window, font, event pump, frame loop --
* and its output goes to the window *and* to stdout, so a piped program produces
* the same bytes either way. The whole of that second driver is
* akbasic/frontend.h; what is left here is argv and the choice.
* *
* The runtime is static rather than automatic because it carries every pool the * The runtime is static rather than automatic because it carries every pool the
* interpreter owns -- several megabytes -- and that will not fit on a default * interpreter owns -- several megabytes -- and that will not fit on a default
* stack. An embedding game would place it in its own state for the same reason. * stack. An embedding game would place it in its own state for the same reason.
*/ */
/* clock_gettime and CLOCK_MONOTONIC. C99 alone does not declare either. */ /* clock_gettime, CLOCK_MONOTONIC and isatty. C99 alone declares none of them. */
#define _POSIX_C_SOURCE 199309L #define _POSIX_C_SOURCE 199309L
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <time.h> #include <time.h>
#include <unistd.h>
#include <akerror.h> #include <akerror.h>
#include <akstdlib.h> #include <akstdlib.h>
@@ -27,6 +34,13 @@
#include <akbasic/sink.h> #include <akbasic/sink.h>
static akbasic_Runtime RUNTIME; static akbasic_Runtime RUNTIME;
#ifdef AKBASIC_HAVE_AKGL
#include <akbasic/frontend.h>
static akbasic_AkglFrontend FRONTEND;
#else
static akbasic_TextSink SINK; static akbasic_TextSink SINK;
static akbasic_StdioSink SINKSTATE; static akbasic_StdioSink SINKSTATE;
@@ -82,6 +96,85 @@ static akerr_ErrorContext AKERR_NOIGNORE *drive(akbasic_Runtime *obj)
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
/**
* @brief The terminal driver: no window, no SDL, output on stdout.
*
* @param program The already-opened program file, or NULL for an interactive REPL.
*/
static akerr_ErrorContext AKERR_NOIGNORE *run_stdio(FILE *program)
{
PREPARE_ERROR(errctx);
if ( program != NULL ) {
/*
* A file argument: read the program from it in RUNSTREAM mode, which
* files each line under its line number and then switches to RUN.
*/
PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, program));
PASS(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUNSTREAM));
} else {
PASS(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, stdin));
PASS(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_REPL));
}
PASS(errctx, drive(&RUNTIME));
SUCCEED_RETURN(errctx);
}
#endif /* !AKBASIC_HAVE_AKGL */
#ifdef AKBASIC_HAVE_AKGL
/**
* @brief Where to find the Commodore font.
*
* Compiled in by CMake, because the reference's relative "./fonts/..." only ever
* worked from its own source directory. AKBASIC_FONT overrides it at runtime,
* which is what an installed copy or a different font needs.
*/
#ifndef AKBASIC_FONT_PATH
#define AKBASIC_FONT_PATH "fonts/C64_Pro_Mono-STYLE.ttf"
#endif
/**
* @brief The SDL driver: an 800x600 window, and stdout still gets everything.
*
* @param program The already-opened program file, or NULL to type at the window.
*/
static akerr_ErrorContext AKERR_NOIGNORE *run_akgl(FILE *program)
{
PREPARE_ERROR(errctx);
const char *fontpath = getenv("AKBASIC_FONT");
FILE *input = program;
int mode = AKBASIC_MODE_RUNSTREAM;
if ( fontpath == NULL ) {
fontpath = AKBASIC_FONT_PATH;
}
if ( program == NULL ) {
/*
* With a terminal on the other end of stdin the window is the console
* and typed lines come from its line editor. Piped or redirected, they
* come from the pipe instead -- so `basic < program.bas` still behaves
* in an AKGL build, and the golden corpus can be driven through this
* binary as well as through the stdio one.
*/
input = (isatty(fileno(stdin)) ? NULL : stdin);
mode = AKBASIC_MODE_REPL;
}
PASS(errctx, akbasic_frontend_akgl_init(&FRONTEND, "BASIC",
AKBASIC_FRONTEND_WIDTH,
AKBASIC_FRONTEND_HEIGHT,
fontpath, AKBASIC_FRONTEND_FONT_SIZE,
stdout, input));
PASS(errctx, akbasic_frontend_akgl_attach(&FRONTEND, &RUNTIME));
PASS(errctx, akbasic_runtime_start(&RUNTIME, mode));
PASS(errctx, akbasic_frontend_akgl_drive(&FRONTEND, &RUNTIME));
SUCCEED_RETURN(errctx);
}
#endif
int main(int argc, char **argv) int main(int argc, char **argv)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
@@ -90,24 +183,20 @@ int main(int argc, char **argv)
ATTEMPT { ATTEMPT {
if ( argc > 1 ) { if ( argc > 1 ) {
/*
* A file argument: read the program from it in RUNSTREAM mode, which
* files each line under its line number and then switches to RUN.
*/
CATCH(errctx, aksl_fopen(argv[1], "r", &program)); CATCH(errctx, aksl_fopen(argv[1], "r", &program));
CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, program));
CATCH(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
CATCH(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUNSTREAM));
} else {
CATCH(errctx, akbasic_sink_init_stdio(&SINK, &SINKSTATE, stdout, stdin));
CATCH(errctx, akbasic_runtime_init(&RUNTIME, &SINK));
CATCH(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_REPL));
} }
CATCH(errctx, drive(&RUNTIME)); #ifdef AKBASIC_HAVE_AKGL
CATCH(errctx, run_akgl(program));
#else
CATCH(errctx, run_stdio(program));
#endif
} CLEANUP { } CLEANUP {
if ( program != NULL ) { if ( program != NULL ) {
IGNORE(aksl_fclose(program)); IGNORE(aksl_fclose(program));
} }
#ifdef AKBASIC_HAVE_AKGL
akbasic_frontend_akgl_shutdown(&FRONTEND);
#endif
} PROCESS(errctx) { } PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) { } HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "akbasic terminated on an unhandled error"); LOG_ERROR_WITH_MESSAGE(errctx, "akbasic terminated on an unhandled error");

View File

@@ -13,11 +13,20 @@
* equivalent until then. * equivalent until then.
*/ */
#include <ctype.h>
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <akerror.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/error.h>
#include <akgl/text.h> #include <akgl/text.h>
@@ -41,6 +50,18 @@ static void scroll(akbasic_AkglSink *state)
if ( state->cursorrow > 0 ) { if ( state->cursorrow > 0 ) {
state->cursorrow -= 1; state->cursorrow -= 1;
} }
/*
* A line being typed is anchored to a row, and that row just moved. Follow
* it, or a backspace erases somebody else's text. Clamped at zero: a line
* long enough to scroll its own start off the top redraws from the top row,
* which is cosmetically wrong and is the only place this can be seen.
*/
if ( state->editing ) {
state->editrow -= 1;
if ( state->editrow < 0 ) {
state->editrow = 0;
}
}
} }
/** @brief Move to the start of the next row, scrolling if that runs off the end. */ /** @brief Move to the start of the next row, scrolling if that runs off the end. */
@@ -104,29 +125,164 @@ static akerr_ErrorContext *sink_writeln(akbasic_TextSink *self, const char *text
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
/**
* @brief Redraw the line being typed, erasing whatever a longer one left behind.
*
* Two passes over the grid rather than one clever one. The first blanks what is
* already drawn and the second draws the current text, which leaves the cursor
* exactly where the text ends without anybody having to reproduce putchar_at's
* wrapping arithmetic a second time. Nothing is rendered here -- the grid is
* memory, and the host draws it when it draws its frame -- so the cost is a
* couple of memcpy-sized loops per keystroke.
*/
static void echo_line(akbasic_AkglSink *state)
{
int i = 0;
state->cursorrow = state->editrow;
state->cursorcol = state->editcol;
for ( i = 0; i < state->echolen; i++ ) {
putchar_at(state, ' ');
}
state->cursorrow = state->editrow;
state->cursorcol = state->editcol;
for ( i = 0; i < state->editlen; i++ ) {
putchar_at(state, state->editline[i]);
}
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)
{
if ( keycode == '\r' || keycode == '\n' ) {
*submitted = true;
return;
}
if ( keycode == '\b' || keycode == 0x7f ) {
if ( state->editlen > 0 ) {
state->editlen -= 1;
state->editline[state->editlen] = '\0';
echo_line(state);
}
return;
}
if ( keycode == 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. */
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);
}
/**
* @brief Collect keystrokes until a line is submitted or the host stops.
*
* Its own function because it is a loop: CATCH and the _BREAK macros expand to a
* C break, which inside a loop would escape only the loop and leave the rest of
* an ATTEMPT running with an error pending. PASS only in here, and the caller
* wraps this one call in the ATTEMPT that owns the cleanup.
*/
static akerr_ErrorContext AKERR_NOIGNORE *edit_loop(akbasic_AkglSink *state, bool *eof)
{
PREPARE_ERROR(errctx);
bool submitted = false;
bool available = false;
bool running = true;
int keycode = 0;
while ( !submitted ) {
PASS(errctx, akgl_controller_poll_key(&keycode, &available));
if ( available ) {
edit_key(state, keycode, &submitted);
continue;
}
/*
* Nothing waiting: hand the frame back to the host, which pumps the
* events that fill the ring this loop is reading. Skipping the pump
* while keys are available is what keeps a paste or a fast typist from
* costing one frame per character.
*/
PASS(errctx, state->pump(state->pumpself, &running));
if ( !running ) {
*eof = true;
SUCCEED_RETURN(errctx);
}
}
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *sink_readline(akbasic_TextSink *self, char *dest, size_t len, bool *eof) static akerr_ErrorContext *sink_readline(akbasic_TextSink *self, char *dest, size_t len, bool *eof)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
akbasic_AkglSink *state = NULL;
FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL && eof != NULL), AKERR_NULLPOINTER, FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL && eof != NULL), AKERR_NULLPOINTER,
"NULL argument in akgl sink readline"); "NULL argument in akgl sink readline");
/* FAIL_ZERO_RETURN(errctx, (len > 1), AKBASIC_ERR_BOUNDS,
* A drawn text layer is not a source of lines. INPUT through a graphics sink "Read buffer of %zu bytes is too small", len);
* wants a line editor built on the keystroke ring, which is its own piece of state = (akbasic_AkglSink *)self->self;
* work and is not done: see TODO.md section 3. Until it exists this reports FAIL_ZERO_RETURN(errctx, (state != NULL), AKERR_NULLPOINTER,
* end of input rather than pretending to have read something. "akgl sink has no state");
*
* EOF rather than an error, because that is the contract sink.h states: *eof = false;
* running off the end of input is how RUNSTREAM mode finishes normally, and
* INPUT already handles it.
*/
if ( len > 0 ) {
dest[0] = '\0'; dest[0] = '\0';
}
/*
* No pump means no host loop to borrow, and a sink that sat on the keyboard
* without one would deadlock the process on its first INPUT. Report end of
* input instead -- the contract sink.h states, and what INPUT already
* handles.
*/
if ( state->pump == NULL ) {
*eof = true; *eof = true;
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }
state->editing = true;
state->editrow = state->cursorrow;
state->editcol = state->cursorcol;
state->editlen = 0;
state->echolen = 0;
state->editline[0] = '\0';
ATTEMPT {
CATCH(errctx, edit_loop(state, eof));
} CLEANUP {
/* Whatever happened, stop drawing a cursor over a line nobody is typing. */
state->editing = false;
} PROCESS(errctx) {
} FINISH(errctx, true);
if ( *eof ) {
SUCCEED_RETURN(errctx);
}
strncpy(dest, state->editline, len - 1);
dest[len - 1] = '\0';
newline(state);
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *sink_clear(akbasic_TextSink *self) static akerr_ErrorContext *sink_clear(akbasic_TextSink *self)
{ {
PREPARE_ERROR(errctx); PREPARE_ERROR(errctx);
@@ -236,5 +392,35 @@ akerr_ErrorContext *akbasic_sink_akgl_render(akbasic_TextSink *obj)
state->x, state->x,
state->y + (row * state->cellh))); state->y + (row * state->cellh)));
} }
/*
* The cursor, drawn only while a line is being typed. The reference draws it
* the same way, as a literal underscore glyph (drawCursor,
* basicruntime_graphics.go:33), rather than as a filled rectangle -- which
* means it needs no draw primitive and cannot be a different shape from the
* text it sits in.
*/
if ( state->editing ) {
PASS(errctx, akgl_text_rendertextat(state->font, "_",
state->color, 0,
state->x + (state->cursorcol * state->cellw),
state->y + (state->cursorrow * state->cellh)));
}
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_sink_akgl_set_pump(akbasic_TextSink *obj, akbasic_AkglPump pump, void *self)
{
PREPARE_ERROR(errctx);
akbasic_AkglSink *state = NULL;
FAIL_ZERO_RETURN(errctx, (obj != NULL), AKERR_NULLPOINTER,
"NULL sink in sink_akgl_set_pump");
state = (akbasic_AkglSink *)obj->self;
FAIL_ZERO_RETURN(errctx, (state != NULL), AKERR_NULLPOINTER,
"akgl sink has no state");
state->pump = pump;
state->pumpself = self;
SUCCEED_RETURN(errctx); SUCCEED_RETURN(errctx);
} }

120
src/sink_tee.c Normal file
View File

@@ -0,0 +1,120 @@
/**
* @file sink_tee.c
* @brief A text sink that writes to two others.
*
* The reference's Write() and Println() (basicruntime_graphics.go:140,148) put
* every line on stdout *and* on an SDL surface. TODO.md section 1.5 turned that
* pair of hardcoded calls into a vtable specifically so the interpreter would
* never carry the second one, and section 3 item 5 says it again for the
* standalone AKGL driver: do not put a hardcoded second write in the
* interpreter. This is where the second write lives instead.
*
* No SDL and no libakgl. Composing two function-pointer records needs neither,
* so this stays in the core library where the stdio-only suite can test it.
*/
#include <stddef.h>
#include <akerror.h>
#include <akbasic/error.h>
#include <akbasic/sink.h>
/** @brief Fetch and validate the state behind a tee sink. */
static akerr_ErrorContext AKERR_NOIGNORE *tee_state(akbasic_TextSink *self, akbasic_TeeSink **dest)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (self != NULL && dest != NULL), AKERR_NULLPOINTER,
"NULL argument in tee sink");
*dest = (akbasic_TeeSink *)self->self;
FAIL_ZERO_RETURN(errctx, (*dest != NULL), AKERR_NULLPOINTER,
"tee sink has no state");
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *tee_write(akbasic_TextSink *self, const char *text)
{
PREPARE_ERROR(errctx);
akbasic_TeeSink *state = NULL;
PASS(errctx, tee_state(self, &state));
PASS(errctx, state->primary->write(state->primary, text));
PASS(errctx, state->mirror->write(state->mirror, text));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *tee_writeln(akbasic_TextSink *self, const char *text)
{
PREPARE_ERROR(errctx);
akbasic_TeeSink *state = NULL;
PASS(errctx, tee_state(self, &state));
PASS(errctx, state->primary->writeln(state->primary, text));
PASS(errctx, state->mirror->writeln(state->mirror, text));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *tee_readline(akbasic_TextSink *self, char *dest, size_t len, bool *eof)
{
PREPARE_ERROR(errctx);
akbasic_TeeSink *state = NULL;
PASS(errctx, tee_state(self, &state));
FAIL_ZERO_RETURN(errctx, (dest != NULL && eof != NULL), AKERR_NULLPOINTER,
"NULL argument in tee sink readline");
if ( state->reader == NULL ) {
/*
* A pair with no reader reports end of input rather than raising, which
* is the contract sink.h states: running off the end is how RUNSTREAM
* mode finishes normally and INPUT already handles it.
*/
if ( len > 0 ) {
dest[0] = '\0';
}
*eof = true;
SUCCEED_RETURN(errctx);
}
PASS(errctx, state->reader->readline(state->reader, dest, len, eof));
SUCCEED_RETURN(errctx);
}
static akerr_ErrorContext *tee_clear(akbasic_TextSink *self)
{
PREPARE_ERROR(errctx);
akbasic_TeeSink *state = NULL;
PASS(errctx, tee_state(self, &state));
PASS(errctx, state->primary->clear(state->primary));
PASS(errctx, state->mirror->clear(state->mirror));
SUCCEED_RETURN(errctx);
}
akerr_ErrorContext *akbasic_sink_init_tee(akbasic_TextSink *obj, akbasic_TeeSink *state, akbasic_TextSink *primary, akbasic_TextSink *mirror, akbasic_TextSink *reader)
{
PREPARE_ERROR(errctx);
FAIL_ZERO_RETURN(errctx, (obj != NULL && state != NULL), AKERR_NULLPOINTER,
"NULL argument in sink_init_tee");
FAIL_ZERO_RETURN(errctx, (primary != NULL && mirror != NULL), AKERR_NULLPOINTER,
"A tee sink needs two sinks to write to");
/*
* Refused rather than quietly accepted. A third sink here would read from
* somewhere nothing writes to, which is the sort of wiring mistake that
* shows up much later as a program that never sees its own input.
*/
FAIL_ZERO_RETURN(errctx, (reader == NULL || reader == primary || reader == mirror),
AKBASIC_ERR_VALUE,
"A tee sink's reader must be one of the two sinks it writes to");
state->primary = primary;
state->mirror = mirror;
state->reader = reader;
obj->self = state;
obj->write = tee_write;
obj->writeln = tee_writeln;
obj->readline = tee_readline;
obj->clear = tee_clear;
SUCCEED_RETURN(errctx);
}

422
tests/akgl_frontend.c Normal file
View File

@@ -0,0 +1,422 @@
/**
* @file akgl_frontend.c
* @brief Tests the standalone SDL frontend: the host, not the adaptors.
*
* tests/akgl_backends.c stands in for a host and asserts that each adaptor
* reaches the right libakgl call. This file asserts the thing that was missing
* until the frontend existed: that there *is* a host, that it composes the two
* output paths so a program's bytes reach the window and stdout alike, that its
* event pump feeds the keystroke ring a script reads, that its line editor turns
* keystrokes into typed lines, and that closing the window ends a program that
* would otherwise never stop.
*
* Everything runs under the dummy video and audio drivers with a software
* renderer, following deps/libakgl/tests/draw.c, so it needs no display and no
* sound card. The font is the reference's own Commodore one -- the acceptance
* criterion in TODO.md section 3 asks for the drawn text to be in that font, and
* the frontend is the only place that opens it.
*
* Only one frontend may exist at a time: libakgl's renderer and window are
* process globals, and a host that never calls akgl_game_init() populates them
* itself. Each test therefore brings one up and takes it down again.
*/
#include <stdio.h>
#include <string.h>
#include <SDL3/SDL.h>
#include <akerror.h>
#include <akgl/actor.h>
#include <akgl/controller.h>
#include <akgl/error.h>
#include <akbasic/error.h>
#include <akbasic/frontend.h>
#include <akbasic/runtime.h>
#include "testutil.h"
/** @brief Window size. Small enough to read the whole target back cheaply. */
#define WINDOW_W 320
/** @brief Window height. */
#define WINDOW_H 200
/* Both of these carry pools far too large for a stack. */
static akbasic_AkglFrontend FRONTEND;
static akbasic_Runtime RUNTIME;
static char OUTPUT[8192];
static FILE *MIRROR = NULL;
static FILE *PROGRAM = NULL;
/** @brief Report whether any pixel in a rectangle is not the background. */
static bool anything_drawn(SDL_Surface *shot, int x0, int y0, int w, int h)
{
uint8_t r = 0;
uint8_t g = 0;
uint8_t b = 0;
uint8_t a = 0;
int x = 0;
int y = 0;
if ( shot == NULL ) {
return false;
}
for ( y = y0; y < y0 + h; y++ ) {
for ( x = x0; x < x0 + w; x++ ) {
if ( !SDL_ReadSurfacePixel(shot, x, y, &r, &g, &b, &a) ) {
continue;
}
if ( r != 0 || g != 0 || b != 0 ) {
return true;
}
}
}
return false;
}
/**
* @brief Queue one key-down, exactly as a keyboard would.
*
* Straight into SDL's own queue rather than into libakgl's ring, so the
* frontend's event pump is the thing under test: nothing reaches the
* interpreter unless pump_events() drained this and handed it over.
*/
static void push_key(int keycode)
{
SDL_Event event;
memset(&event, 0, sizeof(event));
event.type = SDL_EVENT_KEY_DOWN;
event.key.key = keycode;
SDL_PushEvent(&event);
}
/** @brief Queue a whole typed line, terminator included. */
static void push_line(const char *text)
{
size_t i = 0;
for ( i = 0; text[i] != '\0'; i++ ) {
push_key((int)(unsigned char)text[i]);
}
push_key('\r');
}
/** @brief Queue the event a window manager sends when the close button is hit. */
static void push_quit(void)
{
SDL_Event event;
memset(&event, 0, sizeof(event));
event.type = SDL_EVENT_QUIT;
SDL_PushEvent(&event);
}
/**
* @brief Bring up a frontend whose mirror is a buffer this file can read.
*
* @param source A program to feed through the stdio half, or NULL to type it.
*/
static akerr_ErrorContext AKERR_NOIGNORE *start_frontend(const char *source)
{
PREPARE_ERROR(errctx);
memset(OUTPUT, 0, sizeof(OUTPUT));
MIRROR = fmemopen(OUTPUT, sizeof(OUTPUT), "w");
FAIL_ZERO_RETURN(errctx, (MIRROR != NULL), AKERR_IO, "could not open the mirror buffer");
setvbuf(MIRROR, NULL, _IONBF, 0);
PROGRAM = NULL;
if ( source != NULL ) {
PROGRAM = fmemopen((void *)(uintptr_t)source, strlen(source), "r");
FAIL_ZERO_RETURN(errctx, (PROGRAM != NULL), AKERR_IO,
"could not open the program buffer");
}
PASS(errctx, akbasic_frontend_akgl_init(&FRONTEND, "akbasic frontend test",
WINDOW_W, WINDOW_H,
AKBASIC_TEST_C64_FONT,
AKBASIC_FRONTEND_FONT_SIZE,
MIRROR, PROGRAM));
PASS(errctx, akbasic_frontend_akgl_attach(&FRONTEND, &RUNTIME));
SUCCEED_RETURN(errctx);
}
static void stop_frontend(void)
{
akbasic_frontend_akgl_shutdown(&FRONTEND);
if ( MIRROR != NULL ) {
fclose(MIRROR);
MIRROR = NULL;
}
if ( PROGRAM != NULL ) {
fclose(PROGRAM);
PROGRAM = NULL;
}
}
/**
* @brief A program run through the frontend reaches stdout *and* the window.
*
* This is the assertion the whole frontend exists for, and it is two assertions
* on purpose: the mirrored bytes are what the golden corpus compares, and the
* lit pixels are what a user actually sees. Before the frontend existed an AKGL
* build produced the first and not the second.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_program_reaches_both(void)
{
PREPARE_ERROR(errctx);
SDL_Surface *shot = NULL;
PASS(errctx, start_frontend("10 PRINT \"HELLO\"\n20 QUIT\n"));
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUNSTREAM));
PASS(errctx, akbasic_frontend_akgl_drive(&FRONTEND, &RUNTIME));
/* Byte for byte, exactly what the stdio-only driver would have written. */
TEST_REQUIRE_STR(OUTPUT, "HELLO\n");
/* And the same characters are in the grid the window draws from. */
TEST_REQUIRE_STR(FRONTEND.akglstate.text[0], "HELLO");
/*
* Asserted as "the first five cells have something in them and the sixth
* does not", rather than against particular pixels: which ones a glyph
* lights is FreeType's business. What is being proved is that five
* characters of Commodore font reached the renderer where the sink's cursor
* said they would.
*/
shot = SDL_RenderReadPixels(FRONTEND.renderer->sdl_renderer, NULL);
TEST_REQUIRE(shot != NULL, "could not read the render target back");
TEST_REQUIRE(anything_drawn(shot, 0, 0,
5 * FRONTEND.akglstate.cellw,
FRONTEND.akglstate.cellh),
"HELLO should have been drawn into the first five character cells");
TEST_REQUIRE(!anything_drawn(shot, 0,
3 * FRONTEND.akglstate.cellh,
WINDOW_W, FRONTEND.akglstate.cellh),
"nothing was printed on the fourth row, so nothing should be drawn there");
SDL_DestroySurface(shot);
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief The event pump is what carries a keystroke to the interpreter.
*
* Pushed into SDL's queue and read back through the frontend's own input
* backend, so every link in the chain the host owns is in the path: SDL queue,
* pump_events, akgl_controller_handle_event, the ring, the backend.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_pump_feeds_input(void)
{
PREPARE_ERROR(errctx);
bool running = false;
bool available = false;
int keycode = 0;
PASS(errctx, start_frontend("10 QUIT\n"));
PASS(errctx, FRONTEND.input.flush_keys(&FRONTEND.input));
/* Nothing pumped yet: the ring is empty and that is success, not an error. */
PASS(errctx, FRONTEND.input.poll_key(&FRONTEND.input, &keycode, &available));
TEST_REQUIRE(!available, "an unpumped frontend should have no keys waiting");
push_key(SDLK_A);
PASS(errctx, akbasic_frontend_akgl_pump(&FRONTEND, &running));
TEST_REQUIRE(running, "a key event should not stop the frontend");
PASS(errctx, FRONTEND.input.poll_key(&FRONTEND.input, &keycode, &available));
TEST_REQUIRE(available, "the pump should have handed the key to the interpreter");
TEST_REQUIRE_INT(keycode, SDLK_A);
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief Closing the window stops a program that would otherwise never stop.
*
* `10 GOTO 10` is the case that matters: an unbounded run() would own the
* process forever and the close button would do nothing. The bounded frame loop
* is what makes this test finish at all.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_window_close_stops(void)
{
PREPARE_ERROR(errctx);
bool running = true;
PASS(errctx, start_frontend("10 GOTO 10\n"));
PASS(errctx, akbasic_runtime_load(&RUNTIME, "10 GOTO 10\n"));
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_RUN));
push_quit();
PASS(errctx, akbasic_frontend_akgl_drive(&FRONTEND, &RUNTIME));
TEST_REQUIRE(!FRONTEND.running, "the close event should have stopped the frontend");
TEST_REQUIRE(RUNTIME.mode != AKBASIC_MODE_QUIT,
"closing the window stops the host, it does not QUIT the script");
/* And a pump after the close keeps reporting the same thing. */
PASS(errctx, akbasic_frontend_akgl_pump(&FRONTEND, &running));
TEST_REQUIRE(!running, "a closed frontend stays closed");
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief The line editor turns keystrokes into a line, echoed as it is typed.
*
* Read through the sink directly rather than through the REPL, because what is
* being asserted here is the editing itself: the fold to upper case that the
* ring's missing modifier state forces, the backspace, and the echo landing in
* the grid where the cursor says it is.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_line_editor(void)
{
PREPARE_ERROR(errctx);
char line[64];
bool eof = true;
PASS(errctx, start_frontend(NULL));
PASS(errctx, FRONTEND.input.flush_keys(&FRONTEND.input));
/* "prXnt" with the X backspaced away, typed in lower case. */
push_key('p');
push_key('r');
push_key('x');
push_key('\b');
push_key('i');
push_key('n');
push_key('t');
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE(!eof, "a submitted line is not end of input");
/*
* Upper case because akgl_controller_poll_key() reports a keycode and no
* modifier state, so Shift is invisible and lower case is unreachable. A
* C128 is upper case too, which is why this is the right resolution rather
* than merely the available one. Filed upstream.
*/
TEST_REQUIRE_STR(line, "PRINT");
/* The echo is in the grid, and the backspace really removed a character. */
TEST_REQUIRE_STR(FRONTEND.akglstate.text[0], "PRINT");
TEST_REQUIRE_INT(FRONTEND.akglstate.cursorrow, 1);
TEST_REQUIRE_INT(FRONTEND.akglstate.cursorcol, 0);
TEST_REQUIRE(!FRONTEND.akglstate.editing, "the editor should be idle once the line is in");
/* Escape abandons a line rather than submitting it. */
push_key('z');
push_key(0x1b);
push_key('o');
push_key('k');
push_key('\r');
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE_STR(line, "OK");
TEST_REQUIRE_STR(FRONTEND.akglstate.text[1], "OK");
/* A closed window ends the wait, reported as end of input rather than raised. */
push_quit();
PASS(errctx, FRONTEND.akglsink.readline(&FRONTEND.akglsink, line, sizeof(line), &eof));
TEST_REQUIRE(eof, "closing the window during an INPUT is end of input");
stop_frontend();
SUCCEED_RETURN(errctx);
}
/**
* @brief A whole REPL session typed at the window, ending with QUIT.
*
* The end-to-end case: keystrokes into SDL's queue, out through the editor, into
* the interpreter, and the result on both output paths. Nothing here is mocked
* except the keyboard and the mirror.
*/
static akerr_ErrorContext AKERR_NOIGNORE *test_repl_session(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, start_frontend(NULL));
PASS(errctx, FRONTEND.input.flush_keys(&FRONTEND.input));
/*
* Every keystroke queued up front, and the session ended with a typed QUIT
* rather than a close event: the pump drains the whole SDL queue in one
* call, so a close event queued here would be seen before the first
* keystroke was ever read.
*/
push_line("10 print 1");
push_line("run");
push_line("quit");
PASS(errctx, akbasic_runtime_start(&RUNTIME, AKBASIC_MODE_REPL));
PASS(errctx, akbasic_frontend_akgl_drive(&FRONTEND, &RUNTIME));
TEST_REQUIRE_INT(RUNTIME.mode, AKBASIC_MODE_QUIT);
/* The reference prints READY at the prompt and after a RUN. */
TEST_REQUIRE_STR(OUTPUT, "READY\n1\nREADY\n");
stop_frontend();
SUCCEED_RETURN(errctx);
}
/** @brief Every entry point validates its pointers before touching SDL. */
static akerr_ErrorContext AKERR_NOIGNORE *test_arguments_refused(void)
{
PREPARE_ERROR(errctx);
bool running = false;
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_init(NULL, "t", WINDOW_W, WINDOW_H,
AKBASIC_TEST_C64_FONT, 16, NULL, NULL),
AKERR_NULLPOINTER);
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_init(&FRONTEND, "t", WINDOW_W, WINDOW_H,
NULL, 16, NULL, NULL),
AKERR_NULLPOINTER);
/* A zero-point font would divide by zero measuring the character grid. */
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_init(&FRONTEND, "t", WINDOW_W, WINDOW_H,
AKBASIC_TEST_C64_FONT, 0, NULL, NULL),
AKBASIC_ERR_VALUE);
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_attach(NULL, &RUNTIME), AKERR_NULLPOINTER);
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_pump(NULL, &running), AKERR_NULLPOINTER);
TEST_REQUIRE_STATUS(akbasic_frontend_akgl_drive(&FRONTEND, NULL), AKERR_NULLPOINTER);
/* Shutting down something that was never brought up is not an error. */
akbasic_frontend_akgl_shutdown(NULL);
SUCCEED_RETURN(errctx);
}
int main(void)
{
PREPARE_ERROR(errctx);
SDL_SetHint(SDL_HINT_VIDEO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_AUDIO_DRIVER, "dummy");
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "software");
ATTEMPT {
CATCH(errctx, akgl_error_init());
CATCH(errctx, akbasic_error_register());
CATCH(errctx, test_arguments_refused());
CATCH(errctx, test_program_reaches_both());
CATCH(errctx, test_pump_feeds_input());
CATCH(errctx, test_window_close_stops());
CATCH(errctx, test_line_editor());
CATCH(errctx, test_repl_session());
} CLEANUP {
stop_frontend();
} PROCESS(errctx) {
} HANDLE_DEFAULT(errctx) {
LOG_ERROR_WITH_MESSAGE(errctx, "akgl frontend test failed");
akbasic_test_failures += 1;
/*
* FINISH_NORETURN rather than FINISH, matching tests/akgl_backends.c:
* FINISH expands a `return __err_context` that this int-returning
* function cannot compile even where the branch is unreachable.
* HANDLE_DEFAULT has already marked the context handled, so nothing
* aborts.
*/
} FINISH_NORETURN(errctx);
return akbasic_test_failures;
}

134
tests/sink_tee.c Normal file
View File

@@ -0,0 +1,134 @@
/**
* @file sink_tee.c
* @brief Tests the composing sink that mirrors output to a second sink.
*
* This is the piece that lets an AKGL build draw BASIC output in a window and
* still put it on stdout, without the interpreter carrying a second write. Two
* stdio sinks over fmemopen buffers stand in for the two halves here -- the
* composition is what is being tested, not what either half does with the bytes,
* and using two of the same kind keeps this file free of SDL.
*/
#include <stdio.h>
#include <string.h>
#include <akbasic/error.h>
#include <akbasic/sink.h>
#include "testutil.h"
static char PRIMARY[1024];
static char MIRROR[1024];
/** @brief A sink whose every entry point fails, for asserting the pair stops. */
static akerr_ErrorContext AKERR_NOIGNORE *broken_write(akbasic_TextSink *self, const char *text)
{
PREPARE_ERROR(errctx);
(void)self;
(void)text;
FAIL_RETURN(errctx, AKERR_IO, "this sink always fails");
}
static akerr_ErrorContext AKERR_NOIGNORE *broken_clear(akbasic_TextSink *self)
{
PREPARE_ERROR(errctx);
(void)self;
FAIL_RETURN(errctx, AKERR_IO, "this sink always fails");
}
int main(void)
{
akbasic_TextSink primary;
akbasic_StdioSink primarystate;
akbasic_TextSink mirror;
akbasic_StdioSink mirrorstate;
akbasic_TextSink broken;
akbasic_TextSink tee;
akbasic_TeeSink teestate;
FILE *primaryout = NULL;
FILE *mirrorout = NULL;
FILE *in = NULL;
char line[64];
bool eof = false;
TEST_REQUIRE_OK(akbasic_error_register());
memset(PRIMARY, 0, sizeof(PRIMARY));
memset(MIRROR, 0, sizeof(MIRROR));
primaryout = fmemopen(PRIMARY, sizeof(PRIMARY), "w");
mirrorout = fmemopen(MIRROR, sizeof(MIRROR), "w");
in = fmemopen((void *)(uintptr_t)"typed\n", 6, "r");
TEST_REQUIRE(primaryout != NULL && mirrorout != NULL && in != NULL,
"could not open the test buffers");
setvbuf(primaryout, NULL, _IONBF, 0);
setvbuf(mirrorout, NULL, _IONBF, 0);
TEST_REQUIRE_OK(akbasic_sink_init_stdio(&primary, &primarystate, primaryout, in));
TEST_REQUIRE_OK(akbasic_sink_init_stdio(&mirror, &mirrorstate, mirrorout, NULL));
/* A reader that is neither half is a wiring mistake, and is refused. */
TEST_REQUIRE_STATUS(akbasic_sink_init_tee(&tee, &teestate, &primary, &mirror, &broken),
AKBASIC_ERR_VALUE);
TEST_REQUIRE_STATUS(akbasic_sink_init_tee(&tee, &teestate, &primary, NULL, NULL),
AKERR_NULLPOINTER);
TEST_REQUIRE_STATUS(akbasic_sink_init_tee(NULL, &teestate, &primary, &mirror, NULL),
AKERR_NULLPOINTER);
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &primary, &mirror, &primary));
/* Every write reaches both halves, byte for byte, including the newline. */
TEST_REQUIRE_OK(tee.write(&tee, "AB"));
TEST_REQUIRE_OK(tee.writeln(&tee, "CD"));
TEST_REQUIRE_STR(PRIMARY, "ABCD\n");
TEST_REQUIRE_STR(MIRROR, "ABCD\n");
/* The error-line double newline survives the composition unchanged. */
TEST_REQUIRE_OK(tee.writeln(&tee, "? 20 : RUNTIME ERROR something\n"));
TEST_REQUIRE_STR(MIRROR, "ABCD\n? 20 : RUNTIME ERROR something\n\n");
/* clear reaches both; the stdio half has no screen, so it just succeeds. */
TEST_REQUIRE_OK(tee.clear(&tee));
/* readline comes from the designated reader only. */
TEST_REQUIRE_OK(tee.readline(&tee, line, sizeof(line), &eof));
TEST_REQUIRE(!eof, "the reader had a line to give");
TEST_REQUIRE_STR(line, "typed");
TEST_REQUIRE_OK(tee.readline(&tee, line, sizeof(line), &eof));
TEST_REQUIRE(eof, "running off the reader's end must set eof, not raise");
/* With no reader at all, readline reports end of input rather than raising. */
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &primary, &mirror, NULL));
eof = false;
TEST_REQUIRE_OK(tee.readline(&tee, line, sizeof(line), &eof));
TEST_REQUIRE(eof, "a tee with no reader reports EOF");
TEST_REQUIRE_STR(line, "");
TEST_REQUIRE_STATUS(tee.readline(&tee, NULL, sizeof(line), &eof), AKERR_NULLPOINTER);
/*
* A failing half stops the pair rather than being swallowed. A half-written
* line is worse than a refused one: the golden corpus compares whole files.
*/
broken.self = &broken;
broken.write = broken_write;
broken.writeln = broken_write;
broken.readline = NULL;
broken.clear = broken_clear;
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &primary, &broken, NULL));
TEST_REQUIRE_STATUS(tee.write(&tee, "X"), AKERR_IO);
TEST_REQUIRE_STATUS(tee.writeln(&tee, "X"), AKERR_IO);
TEST_REQUIRE_STATUS(tee.clear(&tee), AKERR_IO);
TEST_REQUIRE_OK(akbasic_sink_init_tee(&tee, &teestate, &broken, &primary, NULL));
TEST_REQUIRE_STATUS(tee.write(&tee, "X"), AKERR_IO);
/* A sink whose state pointer was never set is caught rather than dereferenced. */
tee.self = NULL;
TEST_REQUIRE_STATUS(tee.write(&tee, "X"), AKERR_NULLPOINTER);
fclose(primaryout);
fclose(mirrorout);
fclose(in);
return akbasic_test_failures;
}