Files
akbasic/TODO.md
Andrew Kesterson 0f3d8a0ac6 Close the value.c mutation survivors, and correct one that cannot be
Two new tests in value_arithmetic.c, each verified by hand-applying the mutant
rather than by assuming. The obvious maximum-length-string test does not work:
math_plus clones self into the scratch first, so joining a full string to an
empty one leaves the byte a short copy missed already correct. The operands
have to sum to the limit without either being the answer.

One of the five listed survivors is equivalent and cannot be killed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:38:45 -04:00

72 KiB
Raw Blame History

TODO

Implementation plan for the Go → C port of deps/basicinterpret.

This file is written to be executed by AI agents, not read by humans for inspiration. Every item names the files it touches, the exact deliverable, and the command that proves it done. Work the phases in order. Inside a phase, items with no Depends: line may be done in parallel.


0. Agent protocol

Read these before touching anything, in this order:

  1. CLAUDE.md in this repository — project goals, the libakerror convention, the error-code range map, and the dependency versions.
  2. deps/libakerror/AGENTS.md — the ATTEMPT/CLEANUP/PROCESS/HANDLE/FINISH protocol.
  3. deps/libakerror/UPGRADING.md — 1.0.0's status registry. Required before writing an error code; the mechanism it replaced is gone.
  4. deps/libakstdlib/TODO.md §2.1 — six confirmed defects 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.
  5. deps/libakgl/AGENTS.md — the no-malloc rule and the commit co-author requirement.
  6. deps/basicinterpret/README.md — the language reference and the unimplemented list.

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 this file in the same commit as the work: strike the item, or replace it with the defect it uncovered. This file holds outstanding items only.
  • Do add the agent program name, model name and version as a commit co-author. That rule comes from libakgl and applies here.
  • Never hand-edit generated output. build/ trees and the generated akerror.h are off-limits; change the generator.
  • Never reformat a file you are not otherwise changing.
  • When a step is blocked because libakgl cannot supply a capability, do not work around it here. File it in deps/libakgl/TODO.md in the numbered prose style of that file's "Carried over" section 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, from deps/libakstdlib/TODO.md §1.6/§2.2.6: 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; CLAUDE.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_VALUE no longer exists. The name registry is sparse and takes any int. There is nothing for a consumer to size, and no compile definition to set. Anywhere you find one, delete it.
  • __AKERR_ERROR_NAMES no 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 live libakgl collision documented in CLAUDE.md.

akbasic owns 512767 per the range map in CLAUDE.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 512767 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 part of the acceptance 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. Reproduce the message strings and the newline behaviour byte for byte or the golden suite fails for reasons that have nothing to do with the interpreter. Where a Go message reads awkwardly, keep it awkward and note it here.

Numeric formatting must match too: integers via %" PRId64 ", floats via %f (Go's %f and C's %f both give six decimals — tests/language/arithmetic/float.txt confirms).

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, not aksl_atoll, wherever a base is involved. The ato* forms are base 10; src/grammar.c picks base 8 or 16 from the lexeme's prefix. A NULL endptr 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_VALUE to AKERR_VALUE, and the message text with it — VAL("garbage") now says no 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_garbage and octal_literal, so the next change to that text has to move a golden file.
  • tests/convert.c became tests/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 make VAL("garbage") return 0 silently. Same reasoning libakstdlib's own tests/test_status_registry.c gives 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, reproduces the reference byte for byte, and passes under ASan and UBSan.

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 reference's own corpus, driven in place. All 41 .bas files under deps/basicinterpret/tests/ are registered as individual CTest cases and byte-compared against their .txt — including the trailing double newline on an error line (§1.8). Nothing is copied, so the corpus cannot drift from its upstream.

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.

  1. The stdout mirror is a sink, not a second write. §1.5 made the sink boundary precisely so the interpreter would never carry the reference's hardcoded pair of calls, and §3 item 5 said it again. akbasic_sink_init_tee() composes two sinks into one and takes readline from whichever of the two was named as the reader — which is the whole difference between the two modes: a program read from a file comes in through the stdio half, and typed lines come in through the akgl half's line editor. It lives in the core library and is tested there (tests/sink_tee.c), because composing two function-pointer records needs no SDL.

  2. The line editor borrows the host's loop a frame at a time. readline has to wait for a typed line, and §1.6 forbids the library blocking — and a sink that sat on the keyboard with no way to pump events would deadlock the process on its first INPUT. So the host installs an akbasic_AkglPump with akbasic_sink_akgl_set_pump(), and the editor calls it between keystrokes. The loop is still the host's. akbasic_frontend_akgl_pump() has that exact signature and is installed as-is. Without a pump the sink reports EOF, which is what it did before and what a host that wants no editor still gets.

  3. The frame loop is bounded, and that is what makes the close button work. 256 steps per frame (AKBASIC_FRONTEND_STEPS_PER_FRAME), then pump and present. 10 GOTO 10 under an unbounded run() would own the process forever; tests/akgl_frontend.c runs exactly that and asserts the window close ends it.

  4. The frame is not cleared. The graphics verbs draw straight to the renderer rather than into a display list, so clearing would wipe every DRAW at the end of the frame it was issued in. The text layer is drawn over whatever is there, which is also how a C128 stacks its text plane on its bitmap plane.

Acceptance: two things, and the second one is the stronger.

  • tests/akgl_frontend.c, registered as the akgl_frontend CTest case: a program run through the frontend under the dummy driver, asserting the mirrored stdout byte for byte and reading the renderer back to prove the same characters were drawn in the Commodore font; synthetic key events pushed into SDL's own queue and read back through the frontend's input backend, so every link the host owns is in the path; the line editor's fold to upper case, its backspace and its escape; a whole REPL session typed at the window and ended with a typed QUIT; and the window-close path.
  • The entire golden corpus is driven through the AKGL binary. A -DAKBASIC_WITH_AKGL=ON build registers the same 41 upstream cases against the same executable, which now opens a window to run them, and all 41 still match byte for byte. That is a much broader statement than any hand-written frontend test: the SDL path changes no observable output anywhere in the corpus.

Three no_device.bas local cases are deliberately not registered in an AKGL build. Their premise is a driver that was given no devices, which is true of the stdio driver and deliberately false of this one. The refusal path they cover is asserted directly against the backend records in tests/devices.c, which both configurations build.

Manual check against a real display, 2026-07-31. The dummy driver cannot prove presentation, so build-akgl/basic was run against X display :0 on a program that prints a line, draws a BOX and then loops. xwininfo found a real 800x600 window titled BASIC; the window was captured with import and measured: the first text row carries glyph pixels (mean 65/255), the box's left edge at x=100 is a solid white line (mean 255), the box interior is black — it outlines rather than fills — and an empty corner is black. stdout carried HELLO WORLD at the same time. QUIT exits cleanly with status 0.

What that check does not cover, and could not here: typing at a real focused window. No key synthesis tool (xdotool, xte, python-xlib) is installed on this machine, so the keyboard path is proved only through SDL's own event queue in tests/akgl_frontend.c — which is the same queue a real keyboard delivers into, but not the same as a window manager giving the window focus. Somebody should type at it once.

Five things that had to be worked around to get there

All five are libakgl's and all five are filed in deps/libakgl/TODO.md. Each workaround is commented at its site with the words "filed upstream" so it can be found and deleted later.

  1. An embedded libakgl demands its dependencies be installed. It builds its vendored SDL, SDL_image, SDL_mixer, SDL_ttf and jansson only when it is top-level; embedded, it goes down a find_package path and fails on a machine that has none of them — while the submodules sit right there in deps/libakgl/deps. Every lookup is guarded with if(NOT TARGET ...), so our CMakeLists.txt adds those five subdirectories before add_subdirectory(deps/libakgl) and the vendored copies get used after all. Same trick and same ordering requirement akerror::akerror and akstdlib::akstdlib already need.

  2. akgl/controller.h does not compile on its own. It declares two handler function pointers taking an akgl_Actor * and includes nothing that declares the type. src/input_akgl.c includes akgl/actor.h ahead of it.

  3. There is no way to attach a 2D backend to a renderer you already have. akgl_render_init2d() installs the six vtable pointers, but it also creates its own window from the game properties and writes to the camera global, so it belongs to the akgl_game_init() path — which is exactly the path an embedding host is not on. tests/akgl_backends.c assigns the six pointers by hand. What is wanted upstream is the vtable half of init2d on its own.

  4. akgl_text_rendertextat() segfaults on a backend whose vtable is empty. It reaches through renderer->draw_texture without checking it, so the first PRINT through the sink dereferences NULL. That is the same class of defect 42b60f7's own commit added a draw test for — "a backend that exists but was never given an SDL_Renderer" — and the text path still has it.

Still missing from the sink

Nothing that blocks a verb. The line editor exists (see above), so INPUT and the REPL work in the window. Two limits are worth stating because a program can reach both:

  • 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.

WINDOW and KEY in group E want a text-window rectangle and a function-key table on top of this, which is ordinary work here rather than anything blocked.


4. Remaining work: language completion (goal 2)

The work queue is the "What Isn't Implemented" list in deps/basicinterpret/README.md. Each verb is: table row (§1.1) → parse handler if it needs one → exec handler → unit test → a new .bas/.txt pair. Both kinds of test; they answer different questions.

Order by what unblocks the most, and by what does not need libakgl to grow first:

Group Verbs Blocked on
A. Structure DO, LOOP, WHILE, UNTIL, ON, BEGIN, BEND, END nothing — reuses waitingForCommand
B. Housekeeping NEW, CLR, CONT, RESTORE, RENUMBER, SWAP, TRON, TROFF, HELP nothing
C. Errors TRAP, RESUME, ER, ERR needs a BASIC-visible error object
D. Strings/format USING, PUDEF, WIDTH, CHAR nothing
E. Console WINDOW, KEY, SLEEP, WAIT, TI the sink, or the clock akbasic_runtime_settime() now carries
F. Disk DOPEN, DCLOSE, APPEND, RECORD, HEADER, COLLECT, BACKUP, COPY, CONCAT, RENAME, SCRATCH, DIRECTORY, CATALOG, DCLEAR, DVERIFY, SAVE, LOAD, VERIFY, BLOAD, BSAVE, BOOT aksl_f* only; no new akgl
G. Graphics GRAPHIC, DRAW, BOX, CIRCLE, PAINT, COLOR, SCALE, SSHAPE, GSHAPE, LOCATE donesrc/runtime_graphics.c, tests/graphics_verbs.c
H. Sprites SPRITE, MOVSPR, SPRCOLOR, SPRDEF, SPRSAV, COLLISION libakgl sprite/actor API — check before filing
I. Audio PLAY, SOUND, ENVELOPE, VOL, TEMPO donesrc/runtime_audio.c, src/play.c, tests/audio_verbs.c
I. Audio, still gapped FILTER, SOUND's sweep arguments libakgl has neither; both refused with AKBASIC_ERR_DEVICE, both filed in §7
E. Console input GET, GETKEY, SCNCLR donesrc/runtime_input.c, tests/input_verbs.c
J. Machine SYS, FETCH, STASH, POKE/PEEK variants nothing

LOCATE used to appear in both E and G. It belongs to G alone: in BASIC 7.0 LOCATE moves the graphics pixel cursor that DRAW starts from, and the text cursor verb is CHAR, which is already in group D.

Out of scope, and staying that way — the reference marks these as incompatible with a modern PC and that reasoning stands: BANK (no bank switching), FAST (irrelevant CPU speed control), MONITOR (no machine-language monitor).

Also on the queue, from the reference's own defect list:

  • Multiple statements per line (10 PRINT A$ : REM ...). The COLON token exists (basicscanner.go:40) and nothing consumes it. This changes the parser's statement loop and should be done before group A, because DO/LOOP bodies read badly without it.
  • Array references in parameter lists (READ A$(0), B#) currently fail to parse. Fix in argumentList.

5. Deliberate deviations from the reference

Keep this list current. Each of these changes structure without changing observable output, and each one has to be defensible against the golden suite.

  1. Reflection → static dispatch table (§1.1).
  2. Go maps → fixed open-addressed tables (§1.3).
  3. Unbounded new(BasicEnvironment) → pool with release (§1.4).
  4. Hardcoded stdout+SDL mirror → text sink backend (§1.5).
  5. run() owns the process → step() / bounded run() (§1.6).
  6. panic()FAIL_RETURN; no library call terminates the process (§1.6).
  7. debug.PrintStack() on parse error → the akerr stack trace only (§3.1 of the original plan).
  8. BasicEnvironment.eval_clone_identifiers deleted as dead state (§4.2 of the original plan).
  9. BasicValue.name and BasicEnvironment.update() deleted as dead: nothing ever writes the field a non-empty string, and update() — its only reader — has no callers.
  10. The DEF-statement bootstrap is gone. The reference declares every builtin by running a BASIC program of DEF lines through the interpreter at startup and then nulling out the expressions (basicruntime_functions.go:14); here a builtin's name, arity and handler are a row in the dispatch table, and MOD, SPC and STR are ordinary native handlers. This removes the need to run the interpreter before the interpreter is ready.
  11. Undefined behaviour the reference reaches by a defined route is refused rather than inherited: a shift count outside 0..63, a negative string multiplier, and integer division by zero all raise. Go defines all three (or panics); C does not. No golden case exercises any of them, so observable behaviour is unchanged.
  12. CommandEXIT clears the pending NEXT wait before popping. The reference does not, which leaves the parent waiting for a NEXT that never arrives (§6 item 8) — in C that is a hang rather than a misbehaviour, and no golden case depends on it.

Deviations in the verbs the reference never implemented

Items 112 above are deviations from ported code. These are deviations from Commodore BASIC 7.0, in verbs the reference lists as unimplemented and which therefore had no Go behaviour to port. They matter to somebody typing in a listing out of a C128 manual.

  1. Hardware verbs reach a device through a backend record, and refuse when there is none. akbasic_GraphicsBackend, _AudioBackend and _InputBackend are records of function pointers on the runtime; all three may be NULL, which is what the current stdio-only standalone driver gives them. That is correct for a default build and unfinished for an AKBASIC_WITH_AKGL build; see §3. A verb that needs a device it was not given raises AKBASIC_ERR_DEVICE naming itself. COLOR, LOCATE, SCALE, ENVELOPE, TEMPO and VOL deliberately do not require one — they change interpreter state, so a program can configure itself before the host lends it a renderer.

  2. CIRCLE is a polygon, always. BASIC 7.0's CIRCLE takes two radii, a start and end angle, a rotation and a degree increment, which makes it an inc-degree polygon by definition. akgl_draw_circle exists and is deliberately not used: it draws one radius, full sweep, unrotated, so it could serve only the case where every optional argument is defaulted — and a shape that changed character depending on whether the two radii happened to be equal is worse than one that is uniformly a polygon.

  3. SSHAPE stores a handle in the string, not the pixels. A real C128 packs the region's bitmap into the string variable. Here a value's string is a fixed 256 bytes (§1.2) and a saved region is a device surface, so the variable gets SHAPE:<n> and the surface stays in a fixed pool on the backend. GSHAPE accepts only a string carrying that prefix, so a hand-written one is refused rather than pasting an unrelated slot. What this costs: a shape cannot be written to disk, cannot be concatenated, and LEN of it is not its size. Nothing in BASIC does any of those to a shape string except a program deliberately poking at it.

  4. BOX fills on a negative angle rather than on a seventh argument. 7.0's last argument selects outline or fill and sits after the rotation, which would make a filled box a seven-argument call. That is filed rather than implemented; today a rotation of zero outlines through the renderer's rectangle and any other rotation draws four lines.

  5. GRAPHIC records its mode but honours only one consequence of it. 7.0's five modes differ in bitmap resolution and in whether the bottom of the screen stays text. Neither means anything against a host's renderer, whose size the interpreter does not know and does not own. The mode is stored, an out-of-range one is refused because that is a typo worth catching, and the one observable rule is that mode 0 is text.

  6. Coordinates reaching a backend are BASIC's 320x200 space, and scaling to the window is the host's job. The interpreter cannot ask the renderer how big it is without owning it. SCALE maps user coordinates onto that space; with SCALE off they pass through.

  7. PLAY does not block. On a C128 PLAY holds the program until the last note ends. §1.6 forbids that outright, so PLAY parses the string into a fixed queue and returns; akbasic_runtime_step() releases one note at a time against whatever time the host last passed to akbasic_runtime_settime(). What this changes for a program: the statement after a PLAY runs immediately rather than a bar later, so a listing that relied on PLAY for timing will race ahead. A program that wants to wait should test the queue rather than assume the verb waits for it.

  8. The host owns the clock, and an unset clock rushes the music. The library reads no clock — it owns no loop and must not block. A host calls akbasic_runtime_settime() once a frame; the standalone driver calls it every step off CLOCK_MONOTONIC. Left unset it reads zero forever, so every duration has already expired and the queue drains as fast as step() is called. Audible, deterministic, and never a hang, which is the right way for that to fail.

  9. PLAY's M (measure) is a no-op and SOUND's frequency sweep is refused. M synchronises the three voices on a C128; with one sequential queue there is nothing to synchronise, and a listing full of them should still play, so it is accepted and ignored. The sweep is the opposite call: SOUND's dir/min/step arguments have no akgl_audio_* equivalent, and the only way to fake one is to re-issue tones from step() — which would tie audible pitch to how often the host happens to call us, a tune that changes key with the frame rate. Filed upstream as akgl_audio_sweep; see §7.

  10. TEMPO's constant is a calibration choice, not a transcription. BASIC 7.0 documents TEMPO as a relative duration from 1 to 255 defaulting to 8, and does not publish what a whole note actually lasts. src/audio_tables.c uses 16000 ms at TEMPO 1, which puts a default-tempo quarter note at 500 ms — 120 beats per minute. If the real constant turns up, that is the one line to change.

  11. GETKEY holds the step loop rather than blocking. A C128 sits on the keyboard inside GETKEY until somebody types. §1.6 forbids the library blocking, so the verb sets a flag and akbasic_runtime_step() declines to advance the program until a key arrives. Every step still returns and a bounded akbasic_runtime_run() still comes back, so a host keeps its frame rate; the program simply does not move past the GETKEY, which is the part a program author cares about. The PLAY queue is serviced before this check on purpose — music should keep playing while a program waits for a keypress. Withdrawing the input device while a GETKEY is holding releases it rather than wedging the script forever.

  12. GET and GETKEY into a numeric variable yield the key code. BASIC 7.0's GET takes a string variable. Accepting an integer one as well, and giving it the raw code, is what a program testing for cursor or function keys needs and costs nothing. No key is code zero, matching what a C128 reports. A float variable is refused: it is neither a character nor a code.

Deviations in the standalone frontend

Items 112 are deviations from ported interpreter code and 1324 from BASIC 7.0. These four are deviations from the reference's program: main.go and the SDL half of basicruntime_graphics.go.

  1. 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.

  2. 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.

  3. 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.

  4. 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

These are real bugs in deps/basicinterpret. Port the behaviour first so the golden suite passes and the port is provably faithful, then fix each one in its own commit with a test that asserts the correct contract. Until fixed, the correct-contract test lives in AKBASIC_KNOWN_FAILING_TESTS.

  1. basicvariable.go:176toString() tests len(self.values) == 0 and then indexes self.values[0]. Inverted condition; on an empty variable it panics, and on a non-empty one it returns the "not implemented for arrays" string. Consequence: PRINT of a scalar through this path is wrong. Fix: if ( count == 1 ).
  2. basicvariable.go:108setBoolean builds a value with valuetype: TYPE_STRING. Consequence: a boolean stored in a variable reads back as a string with an empty stringval. Fix: TYPE_BOOLEAN.
  3. basicenvironment.go:157stopWaiting(command) ignores command and clears the wait unconditionally. Consequence: an inner block can clear an outer block's wait. Fix: compare before clearing, and error on a mismatch.
  4. basicvalue.go:181mathPlus mutates self in place when self.mutable is true, while every other operator always clones. Consequence: A# + 1 can modify A# depending on where the value came from. This one is high blast radius; it interacts with eval_clone_identifiers and with CommandNEXT's increment (basicruntime_commands.go:663), which relies on the mutation. Do not fix it before FOR/NEXT has full test coverage.
  5. basicvalue.go:191 and siblings — every binary operator adds both of the right-hand operand's numeric fields: rval.intval + int64(rval.floatval). Works only because the unused field is always zero. Consequence: none today; it is a landmine for any future value that carries both.
  6. basicvalue.go, all comparisonsdest is cloned from self, so the resulting boolean inherits self.name. Consequence: a comparison result can be written back to the wrong variable through environment.update().
  7. basicruntime_commands.go:484CommandIF walks expr.right to the end of the chain looking for a THEN command, then dereferences expr.right after the loop guaranteed it is nil. Consequence: the "Malformed IF statement" check is unreachable and nested IF ... THEN ... ELSE is unverified.
  8. basicruntime_commands.go:669CommandEXIT pops the environment without stopWaiting, leaving the parent waiting on a NEXT that will never come.
  9. basicruntime.go:149newVariable() and BasicRuntime.variables[MAX_VARIABLES] are dead; variables live in the environment's map. Do not port them.
  10. basicgrammar.go:224newLiteralInt selects base 8 whenever the lexeme starts with 0. Fixed. Base 10 unless prefixed 0x; a leading zero in a listing is padding, not a radix. PRINT 010 prints 10 and PRINT 08 parses. Nothing in the upstream corpus had a leading-zero literal, which is why nothing there noticed. Regression tests in tests/grammar_leaves.c and tests/language/numeric/octal_literal.bas, which was rewritten from pinning the defect to pinning the fix.
  11. basicruntime.go:121 / basicparser_commands.go:124 — environments are never freed. Addressed structurally by §1.4; no separate fix needed, but the C pool will exhaust where Go merely leaked, so tests/environment_scope.c's exhaustion assertion matters.

Found during the port

Five more, none of which the reference's own corpus catches. Four are now fixed, each with a regression test in the suite that pinned it; the fifth is described below and is not a coding question.

  1. basicparser.go:329subtraction() returns after one operator where addition() loops. Fixed. 1 - 2 - 3 computed 1 - 2 and abandoned the rest of the line; the remaining - 3 was left in the token stream, picked up by the statement loop as a second statement, evaluated and discarded. A wrong answer, silently, which made this the highest-impact item on the list. exponent() has the identical shape — a for loop with a return inside it, in both the reference and the port — so 2 ^ 3 ^ 2 had it too. Both now fold left, which is what a C128 does with a run of same-precedence operators. Regression tests in tests/parser_expressions.c.

    Worth recording for whoever reads the reference next: exponent() there is for self.match(CARAT) { ... return expr, nil } (basicparser.go:519-539), so the loop is a loop in shape only. subtraction() is the same at :357-377. Neither is a transcription slip in the port.

  2. basicparser.go:565 — a unary-minus argument inflates a function's arity count. Fixed, and the fix is structural rather than a smarter counter. The counter walked .right, which is where a unary leaf keeps its operand and where an argument list chains its arguments; the two meanings were indistinguishable, so ABS(-9) counted as two arguments and was refused with "function ABS takes 1 arguments, received 2". No builtin could be called with a negative literal at all. The corpus hid it — sgn.bas assigns -1 to a variable first.

    A unary leaf's operand now hangs off .left. A unary leaf had no other use for that field, so the two meanings separate for the cost of one nobody was using — and counting then needs no special case at all. That also fixes the half of the defect the original entry did not mention: chaining the next argument through .right overwrote the operand, so MOD(-7, 3) destroyed its own first argument. Three readers moved (src/runtime.c, src/grammar.c's renderer, src/runtime_commands.c's LIST range parser) and that is the whole change. Regression tests in tests/runtime_evaluate.c, both halves.

    The same collision remains for array subscripts, which is why §4 still lists "array references in parameter lists" as outstanding: an identifier keeps its subscript list on .right too. The same move — onto .left — is very likely the fix, and it is not made here because it wants its own commit and its own tests.

  3. basicscanner.go:272matchNextChar returns without setting a token type when it cannot peek past the end of the line. Fixed. A comparison operator in the final column was silently dropped: A# = produced one token, not two, and the parse error that followed pointed somewhere else entirely. falsetype is now set before returning on the peek failure. Regression tests in tests/scanner_tokens.c, for =, < and >.

  4. basicscanner.go:318 — hex literals do not survive the scanner. Fixed. matchNumber let x through but not the hex digits after it, so 0xff lexed as 0x followed by an identifier ff, the base-16 branch in newLiteralInt was unreachable, and the README's hex support did not exist. Once the x is seen the run continues over hex digits. Accepted anywhere in a number run rather than only after a leading 0, which is the reference's rule and is worth keeping: it is what makes 1x2 one malformed token that the line-number conversion diagnoses by name. Regression tests in tests/scanner_tokens.c and tests/language/numeric/octal_literal.bas.

  5. basicscanner.go:349 — the "Reserved word in variable name" check never fires. The lexeme still carries its type suffix when the keyword tables are searched, so PRINT$ misses every table and becomes an ordinary string variable. Consequence: the diagnostic is dead code and PRINT$ = 1 is accepted.

    Deliberately not fixed, and this is the interesting one. The fix is four lines — strip the suffix before the lookup — and it works. It also breaks a golden case: the reference's own deps/basicinterpret/tests/examples/strreverse.bas names a variable INPUT$, and with the check working that line is refused.

    A real C128 refuses it too, so the reference program is the wrong one here, not the diagnostic. But the corpus is the acceptance contract (§1.8), it lives in a submodule this repository may not edit, and reproducing it byte for byte is goal 1's headline claim. The two cannot both hold. Fixing this means deciding that the diagnostic is worth one permanently-excluded upstream case, which is a call for the author rather than for whoever is next through this file. Until then it stays pinned in tests/known_reference_defects.c, which is now the only thing left in there.

Ours, not the reference's

  1. A host cannot reliably create a script variable while a script is suspended. This one is not inherited — it falls out of §1.4's environment pool meeting §1.6's bounded run(), a combination the reference never had because its run() never returned.

    Exchanging variables with an embedding host works and is documented in README.md: the host calls akbasic_environment_get() to find or create a variable and akbasic_variable_set_* / _get_subscript to move values across. Seeding before akbasic_runtime_start() and reading after the script stops is safe, and so is updating an existing global at any time, because the parent chain is searched. Creating one mid-run is not, in two ways, and both are silent:

    • A script suspended part-way through a bounded akbasic_runtime_run() is usually inside a FOR or GOSUB scope, so obj->environment is that scope. A variable created through it lands in the loop's scope and dies when the environment pops — the script reads the value correctly inside the loop and gets 0 immediately after it.
    • Reaching for the root explicitly does not help. akbasic_environment_get only auto-creates when obj->runtime->environment == obj (src/environment.c:242), so with a child active it returns NULL through dest without raising, and an unchecked host dereferences it.

    examples/hostvars.c demonstrates both rather than describing them, and prints what it observes, so the day this is fixed that output changes and the example needs revisiting.

    Fix: add akbasic_runtime_global(akbasic_Runtime *obj, const char *name, akbasic_Variable **dest) that walks obj->environment to the root and creates there unconditionally, and point the README at it instead of at akbasic_environment_get. Roughly fifteen lines. The one design question worth settling first is what it should do when the script is suspended inside a user function's scope — that environment is owned by the funcdef rather than the pool (§1.4), and "root" is still the right answer there, but it is worth being deliberate about it rather than discovering it. Tests: create a global while suspended in a FOR body and assert the script still sees it after NEXT; the same from inside a GOSUB; and the NULL-without-error path, which should become an impossible state.


7. Filing gaps against libakgl

When phase 7 or phase 8 needs something libakgl does not have, stop and file it in deps/libakgl/TODO.md in that file's numbered prose style: what the BASIC verb requires, what the akgl_* entry point should look like, and what tests would cover it. Growing libakgl to serve the interpreter is a wanted outcome, not a detour.

All four gaps filed so far are now closed upstream, at libakgl 42b60f7. Nothing here is blocked on libakgl any more; what remains is akbasic-side work.

Was blocking Closed by
Text measurement (§3, the akgl sink) akgl_text_measure, akgl_text_measure_wrapped
Immediate-mode drawing (§4 group G) akgl_draw_point/_line/_rect/_filled_rect/_circle/_flood_fill/_copy_region/_paste_region
Audio (§4 group I) akgl_audio_init/_tone/_envelope/_waveform/_volume/_stop/_voice_active/_mix
Console input (§4 group E) akgl_controller_poll_key, akgl_controller_flush_keys

Two notes for whoever picks up §3 next. The draw calls take an akgl_RenderBackend * as their first argument rather than reaching for a global renderer, which fits the rule that the interpreter draws through whatever renderer the host already initialized. And the audio API is a synthesised-voice one — akgl_audio_tone(voice, hz, ms) with a separate ADSR envelope — which is the shape PLAY and ENVELOPE need, rather than the sample playback SDL3_mixer would have given.

Building against any of it needs -DAKBASIC_WITH_AKGL=ON, which pulls in libakgl's own submodules (SDL and friends). Those are not initialized in a default clone; git submodule update --init --recursive gets them.

Seven items are filed upstream and outstanding, at deps/libakgl/TODO.md items 5-10 of "API gaps blocking akbasic" and item 14 of "Defects". Four are workarounds this repository carries today, each commented at its site with the words "filed upstream" so it can be found and deleted: the embedded-dependency CMake trick in our CMakeLists.txt, the akgl/actor.h include in src/input_akgl.c and src/sink_akgl.c, and the hand-populated vtable and its NULL-deref hazard in tests/akgl_backends.c and src/frontend_akgl.c. §3 describes all four. The fifth is SOUND's frequency sweep, the sixth is the missing version bump recorded in §8, and the seventh is new:

  • The keystroke ring carries a keycode and no modifier state, so the line editor cannot see Shift. Every shifted character — ", !, (, ), :, ; — is unreachable, which means a string literal cannot be typed at the window at all, and lower case is unreachable too. Letters are folded to upper case, which is what a C128 does and is therefore the right answer for letters rather than merely the available one; punctuation has no such excuse. It is not fixable from here: by the time a caller could ask SDL_GetModState() the key is long released, and decoupling reads from events is the entire point of a ring. The wanted API is an akgl_controller_poll_keystroke() returning keycode, modifiers and the composed UTF-8 text SDL already computes from SDL_EVENT_TEXT_INPUT — which is also the only way a keyboard layout, a compose key or a dead key can ever work. Filed as item 10, alongside the existing akgl_controller_poll_key(), which stays exactly as it is: a game asking "was the up arrow pressed" wants precisely what it already gets.

One further gap is outstanding, and upstream named it itself. The commit that added the audio API says plainly what it does not cover: "Still missing for a complete BASIC sound vocabulary: FILTER (SDL3 has no filter primitive; this would need writing), TEMPO and the PLAY note-string parser, both of which belong in the interpreter rather than here."

  • TEMPO and the PLAY parser are ours, not a gap. They are group I work in this repository and nothing upstream is waiting on.
  • FILTER is a real gap. BASIC 7.0's FILTER freq, lp, bp, hp, res sets the SID's filter cutoff, the three band-pass switches and the resonance. akgl_audio_* synthesises raw waveforms and mixes them; there is no filter stage to configure and SDL3 supplies no primitive to build one from. Until an akgl_audio_filter() exists, FILTER is accepted by the parser and refused at execution with AKBASIC_ERR_DEVICE rather than silently ignored — a program that asks for a low-pass and gets an unfiltered square wave has been lied to.

A second gap turned up while implementing group I, and is filed alongside it:

  • SOUND has no frequency sweep. BASIC 7.0's SOUND voice, freq, dur, dir, min, step ramps the pitch from freq toward min in step increments per tick, in the direction dir selects. akgl_audio_tone(voice, hz, ms) holds one pitch for one duration and there is no entry point that changes pitch over the life of a note. The wanted API is roughly akgl_audio_sweep(int voice, float32_t from_hz, float32_t to_hz, float32_t step_hz, uint32_t ms), advanced on the mixer's own frame counter — the same place the phase is already derived from, and the reason it belongs there rather than here. Faking it in the interpreter means re-issuing tones from akbasic_runtime_step(), which ties audible pitch to how often a host calls us: a tune that changes key with the frame rate. Refused with AKBASIC_ERR_DEVICE until it lands. Tests would drive akgl_audio_mix by hand and assert the frame at which the pitch has moved, exactly as tests/audio.c already does for the envelope.

When the next gap turns up, file it there rather than working around it here.


8. Status

The port is done. The C interpreter reproduces the Go reference byte for byte across its entire test corpus and passes clean under ASan and UBSan.

Gate Result
ctest 72/72 — 41 upstream golden cases, 5 local ones, 22 unit tests, 2 embedding examples, 1 known-failing, 1 version check
ctest with -DAKBASIC_WITH_AKGL=ON 72/72 — the same, minus the three no_device cases the SDL driver contradicts, plus akgl_backends and akgl_frontend; the akgl_build CI job
Golden corpus 41/41 byte-exact, driven in place from the submodule — and 41/41 again through the SDL binary, which is most of what proves the frontend changes no output
ASan + UBSan 72/72
Line coverage 93.6% (3618/3867) — above the 90% gate
Function coverage 97.8% (267/273)
Warnings none under -Wall -Wextra
doxygen Doxyfile clean
Mutation (src/symtab.c) 74.1%, against a gate of 65 — see .gitea/workflows/ci.yaml

Coverage of the verb groups added for goal 2, since they are the new surface: src/audio_tables.c and src/graphics_tables.c 100%, src/runtime_input.c 100%, src/runtime_graphics.c and src/runtime_audio.c 98%, src/play.c 87%. The akbasic_akgl target is not in the coverage figure — it is not built in a default configuration.

A note on reading that number, because it was misread once while producing it. A stale build-cov/ left in the source directory from an earlier session was silently folded into the report by gcovr --root ., which produced the previous run's 92.3% for a tree that had grown by 800 lines. That is libakgl defect #13 happening here rather than there, and build*/ being gitignored is exactly what makes it invisible. Keep build trees out of the source directory, and if a coverage number looks suspiciously unchanged, find . -name '*.gcda' before believing it.

Branch coverage reads 18.0% and is not a target, for the reason libakgl/TODO.md and libakstdlib/TODO.md both give: the akerror control-flow macros expand into large branch trees at every call site, most of them unreachable in normal operation. Track line and function coverage.

Dependency baseline:

Submodule Version Notes
deps/libakerror 1.0.0 Private ownership-enforced status registry. akbasic reserves 512767 in akbasic_error_register(). Does not namespace its coverage target when embedded — worked around in our CMakeLists.txt.
deps/libakstdlib 0.2.0 soname libakstdlib.so.0.2. AKSL_VERSION_CHECK() asserted in tests/version_check.c. This release fixed all six confirmed defects the port was working around — see §1.9, where the bans are now lifted.
deps/libakgl 0.2.0 soname libakgl.so.0.2. Owns status codes 256260. Linked and tested under -DAKBASIC_WITH_AKGL=ON, which still defaults OFF so the core library and its whole suite build on a machine with no SDL. It requires libakstdlib 0.2, which is why the two moved together.

The libakgl requirement used to be pinned by commit, and no longer is. 42b60f7 added 22 public symbols across four headers and left project(akgl VERSION 0.1.0) and the libakgl.so.0.1 soname unchanged, so nothing here could feature-test for the new API and the submodule pointer was the only thing expressing the requirement. That was filed upstream as libakgl defect #14 and fixed by its commit 1066ac7 — the two crossed rather than one following the other. libakgl is 0.2.0 with an libakgl.so.0.2 soname, so AKGL_VERSION_AT_LEAST(0, 2, 0) now means something and an ABI mismatch is refused at load time. Kept here because the reason is the reusable part: an additive release under an unchanged soname costs its consumers the ability to detect it at all.

CI is split in two. .gitea/workflows/ci.yaml runs on push -- the suite, ASan+UBSan, coverage and a two-file mutation run -- and .gitea/workflows/release.yaml is manual (workflow_dispatch), carrying the doxygen gate and the whole-tree mutation run. The split is about cost: the full mutation set is 3675 mutants and hours of runner time, which is a release gate rather than a per-commit one, and the docs are wanted at release time. Three notes worth keeping:

  • The checkout is submodules: true, not recursive. The build needs libakerror, libakstdlib and basicinterpret; it does not need libakgl, which is guarded behind AKBASIC_WITH_AKGL and defaults OFF — and recursing into it would clone SDL, SDL_image, SDL_mixer, SDL_ttf and jansson for a target that is never configured. The docs job needs no submodules at all.

  • The push-path mutation_test job is bounded to src/symtab.c, scoring 74.1% against a gate of 65. It was two files until src/convert.c was deleted. src/audio_tables.c was measured as a replacement and scored 64.7%, below the gate — a real gap rather than a reason to skip the file: almost every survivor is in akbasic_audio_state_init, where nothing asserts that a freshly initialised audio state is actually zeroed and defaulted. That is the same gap this job's history records closing for src/symtab.c, and closing it is what earns the file its place back in CI.

    Worth knowing before reading a score: the harness's ICR operator only rewrites the constants 0 and 1, so a lookup table of other values produces no mutants at all and a high score over one says nothing about whether its entries are right. tests/audio_verbs.c asserts all 32 ADSR entries anyway — that catches a hand-edit typo, which is the real failure mode, but it did not and could not move the mutation number. src/value.c is the file most worth mutating and is deliberately not on that path: 368 mutants at roughly 11 seconds each is about 70 minutes, because almost everything links against it. It is covered by release.yaml, or locally with cmake --build build --target mutation.

  • release.yaml's threshold defaults to 65, the same as the push path, rather than something stricter. Only two files have ever been measured; a tighter whole-tree number would be a guess until a full run establishes a baseline. Raise it through the workflow input once one has.

What remains, in priority order:

  1. The standalone AKGL frontend in §3. Donesrc/frontend_akgl.c in its own akbasic_frontend target, src/sink_tee.c for the stdout mirror, and tests/akgl_frontend.c. The build option now changes what the executable does. The one thing not machine-verified is typing at a real focused window; §3 records why and what was verified instead.

  2. A line editor for the akgl sink. Donereadline in src/sink_akgl.c, over the keystroke ring, borrowing frames from the host through an akbasic_AkglPump. It cannot type a shifted character, which is libakgl item 10 rather than work outstanding here.

  3. §4 — the language completion work queue. Groups G and I and the GET/GETKEY/SCNCLR part of E are done. Of what is left, multiple statements per line should come first: the COLON token exists and nothing consumes it, and DO/LOOP in group A reads badly without it. Then groups A, B, D, F and J, none of which need anything from libakgl.

  4. CI does not cover -DAKBASIC_WITH_AKGL=ON. Done — the akgl_build job in .gitea/workflows/ci.yaml. It turned out much cheaper than the deferral assumed, and the two reasons are worth keeping because both were guesses that measurement corrected:

    • submodules: recursive is not needed and costs 461 MB. It descends into SDL_image/external, SDL_mixer/external and SDL_ttf/external — aom, dav1d, libjxl, libtiff, mpg123, opus, flac and a dozen more — and not one of them is used. Every one configures as "Could NOT find". The job initialises exactly six submodules non-recursively instead, which takes about 25 seconds.
    • The build is about a minute of CPU, not "a long build". 330 object files, because SDL3 compiles out almost every backend that is not wanted here.

    The one real system dependency is libfreetype-dev and libharfbuzz-dev: SDL_ttf prefers the system copies and would otherwise reach for external/freetype, which the checkout deliberately does not clone. Two apt packages against two more submodules is an easy trade.

    Every step was verified from a clean clone before being written down, not inferred.

  5. §6 items 16 and 17. Items 10 and 1215 are fixed, each with a regression test in the suite that pinned it. What is left is not more of the same:

    • Item 16 is a decision, not a fix. Making the "Reserved word in variable name" check fire costs an upstream golden case, because the reference's own strreverse.bas names a variable INPUT$. §6 states the trade; somebody has to make it.
    • Item 17 is ours: a host cannot reliably create a script variable while a script is suspended. The fix is akbasic_runtime_global() and roughly fifteen lines. §6 describes it and names the one design question to settle first.
  6. Mutation survivors in src/value.cclosed, with one correction to what was written here before. tests/value_arithmetic.c grew two functions, test_maximum_length_string() and test_pool_starts_empty(), and each was verified by hand-applying the mutant and watching the test fail rather than by assuming it would:

    Mutant Result
    strncpy(dest->stringval, src, AKBASIC_MAX_STRING_LENGTH - 1)- 2 killed
    dest->stringval[AKBASIC_MAX_STRING_LENGTH - 1] = '\0'- 2 killed
    strncpy(..., AKBASIC_MAX_STRING_LENGTH - 1)- 0 equivalent — cannot be killed
    memset(obj, 0, sizeof(*obj)) deleted from akbasic_valuepool_init killed
    obj->next = 0= 1 killed

    The correction. This entry used to say all five were real bugs and that "one test that round-trips a 255-character string kills all five". Two things about that were wrong.

    The - 0 mutant is equivalent. strncpy(dest, src, 256) into a 256-byte buffer writes at most 256 bytes, and set_string() has already refused anything longer than 255 two lines above, so the extra byte is always the terminator strncpy would have written anyway. It cannot be killed and should not be counted against the score.

    And the obvious test does not kill the truncating mutant. Joining a full-length string to an empty one passes with the mutant in place, because akbasic_value_math_plus clones self into the scratch before writing and the byte a short copy fails to write is already the right one. The operands have to sum to the limit without either of them being the answer — the test uses 200 X plus 55 Z, and says so at the site, because this is exactly the sort of thing that gets "simplified" back into a passing no-op.

    Two survivors there are genuinely equivalent and cannot be killed: rval_as_int and rval_as_float (src/value.c:30,35) tolerate +- because the reference's habit of adding both of the right operand's numeric fields only works when the unused one is zero — §6 item 5. Fixing that defect would make these two mutants killable, which is a small argument for doing it.

    src/value.c takes about 70 minutes to mutate in full (368 mutants, ~11s each, because almost everything links against it), which is why CI runs src/symtab.c instead and the whole-tree run is a local cmake --build build --target mutation.