Move every program and expectation out of tests/reference and register the unified tests/language corpus as local cases. Remove the old immutable-corpus protections from build, maintenance, and documentation paths. Co-authored-by: andrew <andrew@aklabs.net>
188 KiB
Record
Outstanding work is in the issue tracker, not in this file: https://source.starfort.tech/andrew/akbasic/issues
This file was the implementation plan for the Go → C port of deps/basicinterpret, written to be
executed by AI agents rather than read for inspiration. The port is done, so what it is now is
the record: the design decisions that are settled, the deviations from the reference and why each
was taken, the defects that were found and fixed, and the reasoning behind the measurements.
Each open item that moved leaves a line saying what it was and which issue carries it, because an entry explaining why a defect matters is worth keeping beside the work it constrains — but the tracking happens there, not here.
Issues are labelled by kind and blast radius, and milestoned by what they can land in: 0.1.x for
anything that changes no public contract, 0.2.0 for new verbs and observable behaviour changes,
1.0.0 for the design decisions. Everything filed carries status::grooming.
Two gaps this file recorded and deliberately did not file — the missing HUD anchors and a
dismissable dialog — are now libakgl #79 and #80. §7's rule is right that changing a dependency
is that repository's decision; it does not follow that reporting the gap is.
0. Agent protocol
0.1 The Go reference is deprecated. Stop matching it.
deps/basicinterpret is a dead project. It will not be updated, and this interpreter is no
longer required to reproduce its behaviour. Recorded here first because it silently reverses
the premise several sections of this file were written on, and because an agent that reads them
without this will park work that is no longer blocked.
What it changes:
- §6 is now an ordinary defect list. "Port the behaviour first so the port is provably faithful" is retired. Fix them because they are wrong, not when fidelity permits.
- §1.8's message-text contract is now a convention. Improving a message is allowed; it costs a golden file, which is a cost rather than a veto.
- §5's bar drops from "defensible against the golden suite" to defensible on its own merits.
tests/language/is the editable language corpus rather than a protected specification. Diverging from it is allowed and must be deliberate and recorded — see its README.
What it does not change:
- The corpus stays and stays green. Forty-one real BASIC programs with known-good output are worth having whatever their provenance, and an unexplained change there is still a red flag.
- The Go source stays readable as documentation. It remains the best answer to "what did the original actually do here", which is a question worth being able to answer even once the answer stops being binding.
- Nothing about the
ak*house rules, which never came from the reference.
Read these before touching anything, in this order:
MAINTENANCE.mdin this repository — project goals, thelibakerrorconvention, the error-code range map, and the dependency versions.deps/libakerror/AGENTS.md— theATTEMPT/CLEANUP/PROCESS/HANDLE/FINISHprotocol.deps/libakerror/UPGRADING.md— 1.0.0's status registry. Required before writing an error code; the mechanism it replaced is gone.deps/libakstdlib's issue tracker — the defects and gaps in the library this port calls into. §1.9 below says which calls are cleared for use; that section is not optional reading, it bans a family of functions the port would otherwise reach for by reflex.deps/libakgl/AGENTS.md— the no-mallocrule and the commit co-author requirement.deps/basicinterpret/README.md— the language reference and the unimplemented list. Still worth reading for the verb set and the semantics; no longer binding, per §0.1.
Rules for working this file:
- Do not mark an item done until its acceptance command passes on a clean out-of-tree build. "It compiles" is not acceptance.
- Do update the tracker in the same commit as the work: close the issue, or replace it with the defect it uncovered. Outstanding items live there; this file holds the record.
- Do add the agent program name, model name and version as a commit co-author. That rule
comes from
libakgland applies here. - Never hand-edit generated output.
build/trees and the generatedakerror.hare off-limits; change the generator. - Never reformat a file you are not otherwise changing.
- When a step is blocked because
libakglcannot supply a capability, do not work around it here. File it againstlibakglin its issue tracker -- what the BASIC verb requires, what theakgl_*entry point should look like, and what tests would cover it -- and note the block in §7 below.
Style, restated so nobody has to go look: C99, 4-space indent, tabs at width 8
(stroustrup), function-body braces in column 0 on their own line, control braces on the same
line, always brace, spaces inside control-flow parens — if ( x == y ) {. Pointer star
binds to the identifier: char *name. Prefix is akbasic_ for functions,
akbasic_TypeName for types, AKBASIC_UPPER_SNAKE for macros. static helpers drop the
prefix. Parameter names must match between header and source.
1. Design decisions already made
These are settled. Do not relitigate them mid-port; if evidence says one is wrong, say so in a commit that changes it deliberately, and update this section.
1.1 Reflection becomes one aligned dispatch table
The Go runtime resolves verbs with reflect.MethodByName("Command" + NAME)
(basicruntime.go:401), functions with "Function" + NAME, and special parse paths with
"ParseCommand" + NAME (basicparser.go:103). C has no reflection and we are not adding
any. Replace all three with one static table in src/verbs.c, sorted by name, searched
with bsearch(3):
/* name token type parse handler exec handler */
{ "AUTO", AKBASIC_TOK_CMDIMM, NULL, cmd_auto },
{ "DATA", AKBASIC_TOK_COMMAND, parse_data, cmd_data },
{ "DEF", AKBASIC_TOK_COMMAND, parse_def, cmd_def },
A NULL parse handler means "parse the rval as a plain expression", which is exactly what
commandByReflection returning (nil, nil) means today. Adding a verb is adding one row plus
two functions. Keep the table column-aligned and one row per verb — it is a table, so it
gets laid out as one.
This also kills the Go scanner's three separate maps (reservedwords, commands,
functions, basicscanner.go:64-67): the token type lives in the same row.
1.2 Strings are fixed-size and live inline
libakstdlib has no string type. libakgl has akgl_String but it is PATH_MAX bytes,
refcounted, and pool-allocated — wrong shape for a value that gets copied on every
assignment, and it would drag a libakgl dependency into the core interpreter.
Define in include/akbasic/types.h:
#define AKBASIC_MAX_STRING_LENGTH 256 /* matches AKBASIC_MAX_LINE_LENGTH */
and give akbasic_Value a char stringval[AKBASIC_MAX_STRING_LENGTH] inline. clone()
becomes a struct assignment. No allocator, no refcount, no lifetime question.
Tradeoff, stated: every akbasic_Value is ~300 bytes, so one environment's
values[AKBASIC_MAX_VALUES] pool is ~19KB, and 32 environments is ~610KB of BSS. That is
fine on a PC and is the price of never calling malloc. If it ever isn't fine, the knob is
AKBASIC_MAX_STRING_LENGTH, not the allocator.
Truncation is an error, not a silent clamp: FAIL_RETURN(e, AKBASIC_ERR_VALUE, ...).
1.3 Maps become fixed-capacity open-addressed tables
Five Go maps need replacing:
| Go site | Purpose | C replacement |
|---|---|---|
BasicScanner.reservedwords/commands/functions |
keyword → token type | the §1.1 static table + bsearch |
BasicEnvironment.variables |
name → *BasicVariable |
akbasic_SymbolTable, capacity AKBASIC_MAX_VARIABLES |
BasicEnvironment.functions |
name → *BasicFunctionDef |
akbasic_SymbolTable, capacity AKBASIC_MAX_FUNCTIONS |
BasicEnvironment.labels |
name → line number | akbasic_SymbolTable, capacity AKBASIC_MAX_LABELS |
One implementation, src/symtab.c, keyed by aksl_strhash_djb2() (already in
libakstdlib) with linear probing and a fixed slot array. Use the existing hash; do not
write another one. Table full is an error, not a resize.
Caveat, recorded upstream: the wrapper sign-extends char, so a
high-bit byte hashes wrong — "\xff\xfe" returns 5859874 where the unsigned char answer is
5868578. BASIC identifiers are 7-bit ASCII (the scanner only accepts IsLetter/IsDigit plus
a type suffix), so this cannot bite the symbol tables. It would bite if anyone later keys
a table on a string literal or a filename. Do not work around it here; it is already filed
upstream.
1.4 Environments come from a pool and are released
Go calls new(BasicEnvironment) at basicruntime.go:121 and basicparser_commands.go:124
and never frees one. A long-running GOSUB or FOR in Go leaks; the GC eventually catches
some of it, and nothing in the tests notices.
C gets HEAP_ENVIRONMENT[AKBASIC_MAX_ENVIRONMENTS] with
akbasic_env_acquire() / akbasic_env_release(), in the shape of akgl_heap_next_*.
akbasic_runtime_prev_environment() must release the environment it pops. Pool
exhaustion is AKBASIC_ERR_ENVIRONMENT, reported with the current line number.
Watch the one place this is not a clean stack: userFunction (basicruntime.go:348) stores
a BasicEnvironment by value inside BasicFunctionDef and re-init()s it on every call.
In C the funcdef holds an akbasic_Environment * acquired at DEF time and reset per call —
it is owned by the funcdef, not the pool's free list, until the funcdef dies.
1.5 Output goes through a text sink backend
Write() and Println() (basicruntime_graphics.go:140,148) mirror every line to stdout
and to an SDL surface. That mirror is the only reason the golden-file suite works. Do not
reproduce it as a hardcoded pair of calls.
Define a record of function pointers, populated by an initializer — the house pattern:
typedef struct akbasic_TextSink
{
void *self;
akerr_ErrorContext AKERR_NOIGNORE *(*write)(struct akbasic_TextSink *self, char *text);
akerr_ErrorContext AKERR_NOIGNORE *(*writeln)(struct akbasic_TextSink *self, char *text);
akerr_ErrorContext AKERR_NOIGNORE *(*readline)(struct akbasic_TextSink *self, char *dest, size_t len);
akerr_ErrorContext AKERR_NOIGNORE *(*clear)(struct akbasic_TextSink *self);
} akbasic_TextSink;
akbasic_sink_init_stdio() is in the library and akbasic_sink_init_akgl() is in the
akgl-backed module. The driver is supposed to pick: a default build selects stdio, while an
AKBASIC_WITH_AKGL build selects the SDL text path and mirrors its output to stdout. It does
not do that yet; §3 records the missing standalone frontend. Cursor arithmetic, wrapping and
scrolling belong to the akgl sink, not to the interpreter.
1.6 The interpreter steps; it does not run
Go's run() (basicruntime.go:682) is a for {} that owns the process until MODE_QUIT.
Goal 3 forbids that: a host game must be able to bound execution.
The library exposes:
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_step(akbasic_Runtime *obj);
akerr_ErrorContext AKERR_NOIGNORE *akbasic_runtime_run(akbasic_Runtime *obj, int maxsteps);
_step() does exactly what one iteration of Go's for {} body does and returns.
_run(obj, maxsteps) loops _step() until the mode is AKBASIC_MODE_QUIT or maxsteps
steps have elapsed; maxsteps <= 0 means unbounded, which is what the standalone driver
passes. This is a deliberate restructure, and it must not change a single byte of golden
output.
QUIT sets AKBASIC_MODE_QUIT and returns. Nothing in the library calls exit(),
abort(), or FINISH_NORETURN. FINISH_NORETURN appears exactly once in the tree, in
src/main.c.
1.7 Error codes are absolute, reserved from the 1.0.0 registry
deps/libakerror is at 1.0.0. Read deps/libakerror/UPGRADING.md before writing an error
code; MAINTENANCE.md's error-code section is the condensed version. Three things this changes
from what you may have seen in an older draft or in libakgl:
AKERR_MAX_ERR_VALUEno longer exists. The name registry is sparse and takes anyint. There is nothing for a consumer to size, and no compile definition to set. Anywhere you find one, delete it.__AKERR_ERROR_NAMESno longer exists. The table is private.- Codes are absolute integer constants at 256 or above, never
AKERR_LAST_ERRNO_VALUE + N. The offset scheme is what produced the livelibakglcollision documented inMAINTENANCE.md.
akbasic owns 512–767 per the range map in MAINTENANCE.md. Declare it as an enum, so the
values stay compile-time integer constants (HANDLE expands to case labels, which require
that) and adding a code does not mean renumbering an offset:
#define AKBASIC_OWNER "akbasic"
enum {
AKBASIC_ERR_BASE = 512, /** Start of akbasic's reserved status range */
AKBASIC_ERR_SYNTAX = AKBASIC_ERR_BASE,
/** Parse-time grammar violation */
AKBASIC_ERR_TYPE, /** Incompatible types in an operation */
AKBASIC_ERR_UNDEFINED, /** Reference to an undefined verb, function or label */
AKBASIC_ERR_BOUNDS, /** Array subscript or pool index out of range */
AKBASIC_ERR_ENVIRONMENT, /** Environment pool exhausted or orphaned environment */
AKBASIC_ERR_VALUE, /** A value was malformed, truncated or unconvertible */
AKBASIC_ERR_STATE, /** A verb ran outside the block structure it requires */
AKBASIC_ERR_LIMIT = AKBASIC_ERR_BASE + 256
};
akbasic_init() reserves the whole 256 in one call and then names each code. Both
registry calls return akerr_ErrorContext * and are AKERR_NOIGNORE, so a collision is an
ordinary error — PASS it and let it propagate out of init:
akerr_ErrorContext AKERR_NOIGNORE *akbasic_init(void)
{
PREPARE_ERROR(errctx);
PASS(errctx, akerr_reserve_status_range(AKBASIC_ERR_BASE,
AKBASIC_ERR_LIMIT - AKBASIC_ERR_BASE,
AKBASIC_OWNER));
PASS(errctx, akerr_register_status_name(AKBASIC_OWNER, AKBASIC_ERR_SYNTAX,
"Syntax Error"));
/* ... one per code ... */
SUCCEED_RETURN(errctx);
}
Reserve the whole range in one call — a subset or superset of your own range raises
AKERR_STATUS_RANGE_OVERLAP, not a no-op. Use akerr_register_status_name(), never the
two-argument akerr_name_for_status(status, name) set path: the owned form is the one that
catches a component writing into a range that is not its own, and the two-argument form is
what let libakgl silently clobber libakerror's names.
There is no startup ceiling to assert any more — the BSS-overflow hazard the old draft
defended against was deleted along with AKERR_MAX_ERR_VALUE. What replaces it is the
reservation itself: if akbasic_init() returns an error, something else owns part of 512–767
and the process must not continue as though it does not.
Do not call akerr_init() first. Every registry entry point calls it, and since 1.0.0 it no
longer clears reservations made before it ran.
1.8 Error message text is a convention, no longer a contract
tests/language/array_outofbounds.txt is, verbatim:
? 20 : RUNTIME ERROR Variable index access out of bounds at dimension 0: 4 (max 2)\n\n
The trailing double newline is real: basicError builds a string ending in \n and hands it
to Println, which adds another.
This used to be a hard contract and is now a default. The Go implementation is deprecated
and will not be updated, so the two projects are no longer required to match — see §0.1. What
survives is the practical half: these strings and this newline behaviour are what every
expectation in tests/language/ was written against, so changing one means changing the paired
files, and that is worth doing on purpose rather than by accident. A message that reads
awkwardly may now be improved; do it deliberately, move the expectations in the same commit,
and add a line to §5.
Numeric formatting still matches the reference: integers via %" PRId64 ", floats via %f
(Go's %f and C's %f both give six decimals — tests/language/arithmetic/float.txt
confirms). No reason to change it, which is different from not being allowed to.
1.9 Which libakstdlib calls are cleared for use — the bans are lifted
deps/libakstdlib is at 0.2.0, and that release fixed all six of the confirmed defects
this section was built around, plus seventeen contract gaps. The table of bans that used to
live here is gone because it is no longer true, and leaving it would send an agent around a
workaround for a function that now works.
What changed, in the four that mattered to this port:
| Was banned | Now |
|---|---|
aksl_atoi / _atol / _atoll / _atof |
Every one takes a dest pointer and raises AKERR_VALUE on no digits or trailing junk, ERANGE on overflow — exactly the contract this section demanded |
aksl_list_append / _iterate |
Append no longer truncates to two nodes; iterate no longer skips the first half |
aksl_tree_iterate |
AKERR_ITERATOR_BREAK stops the traversal |
aksl_realpath |
Rewritten; no longer reads uninitialised memory on its error path |
Two signature changes reach this repository. aksl_fread and aksl_fwrite now take a
required size_t *nmemb_out and report a short transfer as AKERR_IO instead of a silent
success — so a DSAVE onto a full disk is now an error a program sees, where before it
reported nothing. src/runtime_commands.c passes the count and discards it, which is correct:
the library does the noticing now.
src/convert.c is gone. It existed only because aksl_ato* could not report a conversion
failure, and its own note said what to do when that changed: "When it grows it, delete
src/convert.c and switch the call sites over." That is done. Six call sites now go straight
to the library — newLiteralInt and newLiteralFloat in src/grammar.c, the line number in
src/scanner.c, FunctionVAL in src/runtime_functions.c, INPUT in
src/runtime_commands.c, and GSHAPE's handle in src/runtime_graphics.c.
Three things that fell out of it, recorded because each was checked rather than assumed:
aksl_strtoll(str, NULL, base, &dest)is the exact replacement, notaksl_atoll, wherever a base is involved. Theato*forms are base 10;src/grammar.cpicks base 8 or 16 from the lexeme's prefix. ANULLendptr is what makes trailing junk an error rather than a stopping point, and that is the whole contract this port needs.- The raised status changed from
AKBASIC_ERR_VALUEtoAKERR_VALUE, and the message text with it —VAL("garbage")now saysno digits in "garbage". §1.8 makes message text part of the acceptance contract, so this was checked against the corpus first: no golden file contained a conversion message, which is also why §1.9 used to ask for one. Two now exist,tests/language/numeric/val_refuses_garbageandoctal_literal, so the next change to that text has to move a golden file. tests/convert.cbecametests/numeric_contract.c. The assertions did not become worthless when the wrapper went away — they became assertions about a contract this port depends on and no longer owns, which is worth pinning exactly because a regression in it would makeVAL("garbage")return 0 silently. Same reasoninglibakstdlib's owntests/test_status_registry.cgives for pinning that it reserves no status range.
One caveat survives the upgrade unchanged: aksl_strhash_djb2 still sign-extends char, so a
high-bit byte hashes differently from the unsigned char answer. BASIC identifiers are 7-bit
ASCII so the symbol tables cannot reach it — see §1.3, which is still accurate.
2. What exists — the core port is complete and green
Phases 0 through 6 of the original plan are done. The interpreter builds clean under
-Wall -Wextra, passes the reference's entire corpus, and passes under ASan and UBSan.
It did reproduce the reference byte for byte, and that claim is retired rather than broken:
§0.1 released it, and one case has since diverged deliberately (§6 item 16, listed in
tests/language/README.md). Everything else still matches, which is worth knowing but is no
longer a gate.
cmake -S . -B build && cmake --build build --parallel
ctest --test-dir build --output-on-failure # 59/59
cmake -S . -B build-asan -DAKBASIC_SANITIZE=ON # 59/59
cmake -S . -B build-cov -DAKBASIC_COVERAGE=ON # 92.3% line, 96.9% function
| Module | Source | Reference |
|---|---|---|
| Symbol table | src/symtab.c |
the five Go maps |
| Value | src/value.c |
basicvalue.go |
| Variable | src/variable.c |
basicvariable.go |
| Tokens and AST leaves | src/grammar.c |
basicgrammar.go + basicparser.go |
| Dispatch table | src/verbs.c |
the three reflection lookups |
| Scanner | src/scanner.c |
basicscanner.go |
| Parser | src/parser.c, src/parser_commands.c |
basicparser*.go |
| Environment | src/environment.c |
basicenvironment.go |
| Runtime | src/runtime.c |
basicruntime.go minus SDL |
| Verbs and functions | src/runtime_commands.c, src/runtime_functions.c |
basicruntime_{commands,functions}.go |
| Text sink | src/sink_stdio.c |
basicruntime_graphics.go's stdout mirror |
| Stdio driver | src/main.c |
main.go minus its SDL frontend |
| Embedding examples | examples/embed.c, examples/hostvars.c |
(new) |
akbasic_runtime_load(rt, source) was added while writing the README's embedding
section: a host usually holds its script as a string and wants the sink reserved for output,
and the only path that existed — AKBASIC_MODE_RUNSTREAM reading through the sink's
readline — forces a game to point its output device at its source text. examples/embed.c
is the code the README quotes, built by every build and registered as a CTest case so a
signature change breaks the build rather than rotting the document.
The acceptance suite is the editable language corpus, checked in at tests/language/. All
65 .bas files are registered as individual CTest cases and compared against their .txt
— including the trailing double newline on an error line (§1.8).
It was driven in place out of deps/basicinterpret until 2026-07-31, on the reasoning that
copying a submodule's corpus guarantees drift. That reasoning was sound and was overruled
deliberately: the Go dependency is being deprecated, and a build that cannot run its own
acceptance suite without cloning the implementation it replaced is not finished. The copy is
originally byte-identical to basicinterpreter@d76162c, and tests/language/README.md records the
provenance and the rule that programs and expectations are edited together deliberately.
Nothing in the build or the suite needs deps/basicinterpret any more, and that is checked
rather than assumed — both configurations were configured, built and run from scratch with the
submodule moved out of the tree. The Commodore font moved too, to assets/fonts/, which was the
other thing tying the build to it; assets/fonts/PROVENANCE.md carries the licence question that
came with it.
Eighteen unit tests cover the modules the corpus cannot reach on its own.
tests/known_reference_defects.c is registered in AKBASIC_KNOWN_FAILING_TESTS and asserts
the correct contract for six of the defects in §6; when one is fixed CTest reports
"unexpectedly passed", which is the cue to split that assertion out and strike the item.
Two budget numbers moved during implementation and are worth knowing:
AKBASIC_MAX_ARRAY_ELEMENTS (1024) caps one array and AKBASIC_MAX_ARRAY_VALUES (4096) caps
all of them together, drawn from an akbasic_ValuePool the runtime owns. The reference had no
such ceiling because it called make().
3. The akgl-backed sink, devices and standalone frontend — done
-DAKBASIC_WITH_AKGL=ON builds and its suite passes, and the build option now changes what
the executable does: an AKGL build of basic opens a window, draws BASIC output into it,
pumps SDL events, lets you type at it, and still puts every byte on stdout. Four adaptors in
the akbasic_akgl target, which is the only thing here that links SDL, are complete and
tested:
| File | Backs |
|---|---|
src/sink_akgl.c |
the §1.5 text sink, over akgl_text_measure and akgl_text_rendertextat |
src/graphics_akgl.c |
akbasic_GraphicsBackend, over akgl_draw_*, and the SSHAPE surface pool |
src/audio_akgl.c |
akbasic_AudioBackend, over akgl_audio_* |
src/input_akgl.c |
akbasic_InputBackend, over akgl_controller_poll_key |
The character grid comes from akgl_text_measure(font, "A", &w, &h) — the direct equivalent of
the font.SizeUTF8("A") at basicruntime.go:96, and the call this was blocked on until
42b60f7. Wrapping is done on the character grid rather than by handing SDL_ttf a wraplength,
because the cursor has to land somewhere definite: a program that PRINTs a long string and
then PRINTs again expects the second to start on the row after the first ended, and only the
code that placed the characters knows which row that is.
The interpreter owns no window, renderer or event loop. Every initializer takes something
the host already made. Each calls akgl_error_init() first: akgl_game_init() would have,
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.
Acceptance: tests/akgl_backends.c, a 128x128 software renderer under the dummy video
driver read back with SDL_RenderReadPixels — the pattern deps/libakgl/tests/draw.c
established, which needs no display and no offscreen harness. Registered as the akgl_backends
CTest case, and only when AKBASIC_WITH_AKGL is on.
The standalone frontend
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.
-
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 takesreadlinefrom 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. -
The line editor borrows the host's loop a frame at a time.
readlinehas 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 firstINPUT. So the host installs anakbasic_AkglPumpwithakbasic_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. -
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 10under an unboundedrun()would own the process forever;tests/akgl_frontend.cruns exactly that and asserts the window close ends it. -
The frame is not cleared. The graphics verbs draw straight to the renderer rather than into a display list, so clearing would wipe every
DRAWat 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 theakgl_frontendCTest 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 typedQUIT; and the window-close path.- The entire golden corpus is driven through the AKGL binary. A
-DAKBASIC_WITH_AKGL=ONbuild 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.
The typing half is no longer manual, and it is no longer a gap. It used to read "somebody
should type at it once", because no key-synthesis tool was installed. xdotool now is, and
tests/akgl_typing.sh is registered as the akgl_typing CTest case: it starts the driver under
a pty, waits for the window with xdotool search --sync, gives it keyboard focus, types a
program containing a string literal and a lower-case one, and polls the mirrored stdout
for what they print.
That case earns its keep twice over. It is the only test that covers the path upstream of SDL
— X11 → SDL composition → libakgl's ring → the editor — and everything else synthesises SDL
events, which is precisely how the missing SDL_StartTextInput() shipped with a green suite.
Confirmed by reverting both halves of that fix and watching it fail with the same symptom that
was reported: READY, and nothing after it.
It needs a real X server, a window manager and xdotool, and it steals keyboard focus for about
fifteen seconds. Absent any of those it reports CTest Skipped rather than failing, which is
what it does in CI. AKBASIC_SKIP_INTERACTIVE=1 skips it deliberately.
The five things that had to be worked around — all gone
Every one was filed against libakgl and every one is resolved in its 0.3.0. What is left here
is the note that they existed, because the pattern is the point: file it upstream, comment the
workaround at its site with the words "filed upstream", delete it when it lands.
| Was | Resolved by |
|---|---|
An embedded libakgl required its vendored SDL, SDL_image, SDL_mixer, SDL_ttf and jansson to be installed, so our CMakeLists.txt declared all five by hand first |
It adds them whether or not it is top-level |
akgl/controller.h did not compile on its own — it used akgl_Actor * and included nothing that declared it |
It includes <akgl/actor.h> |
| There was no way to bind a 2D backend to a renderer you already had, so the six vtable pointers were assigned by hand in two files | akgl_render_bind2d() |
akgl_text_rendertextat() dereferenced an unset draw_texture, so the first PRINT through an unbound backend segfaulted |
It checks, and reports AKERR_NULLPOINTER like the draw entry points |
SOUND's frequency sweep had no equivalent and was refused |
akgl_audio_sweep() |
0.3.0 also arrived with a regression that this repository is the one that found, because it
is the only consumer that embeds libakgl alongside other projects: the commit that made the
vendored dependencies unconditional also made its add_test() shadow unconditional, and CMake
chains command overrides exactly one level deep. Two projects in one tree cannot both shadow
add_test — the second rebinds _add_test to the first and the builtin becomes unreachable to
everybody, so our own registrations recursed to CMake's depth limit. Fixed upstream by putting
the top-level guard back; filed as libakgl defect 27, with the two-line CMake program that
demonstrates the one-level limit.
Still missing from the sink
Nothing that blocks a verb, and as of libakgl 0.3.0 nothing that blocks a character either.
The ring now carries the composed UTF-8 text SDL worked out from each keystroke, so the editor
takes that in preference to the keycode and shifted characters, keyboard layouts, compose keys
and dead keys all work. A double quote can be typed, so a BASIC string literal can be typed,
which was the sharp end of the old limitation — it used to mean a program with a string in it
had to be passed on the command line. Letters are no longer folded to upper case, because there
is no longer any reason to.
The host has to turn text input on, and forgetting to is how this broke once. SDL3 has it
off by default and per-window, so it is akbasic_frontend_akgl_init() that calls
SDL_StartTextInput() — akgl/controller.h says as much. Without it SDL emits no
SDL_EVENT_TEXT_INPUT at all, every keystroke reaches the ring with an empty text, and an
editor that reads that as "not a character" is silently dead: nothing echoes in the window, and
nothing reaches stdout either, because RUN can never be typed.
The editor therefore